Updated styling and fixed add row functionality
This commit is contained in:
@@ -13,7 +13,7 @@ namespace Budget.Api.Controllers;
|
|||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/[controller]")]
|
[Route("api/[controller]")]
|
||||||
[Authorize]
|
[Authorize(Roles = "admin,user")]
|
||||||
public class BudgetsController(AppDbContext db, BudgetAuthorizationService authz) : ControllerBase
|
public class BudgetsController(AppDbContext db, BudgetAuthorizationService authz) : ControllerBase
|
||||||
{
|
{
|
||||||
private IActionResult? TryGetUserId(out string userId)
|
private IActionResult? TryGetUserId(out string userId)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ namespace Budget.Api.Controllers;
|
|||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/budgets/{budgetId:guid}/incomes")]
|
[Route("api/budgets/{budgetId:guid}/incomes")]
|
||||||
[Authorize]
|
[Authorize(Roles = "admin,user")]
|
||||||
public class IncomesController(AppDbContext db, BudgetAuthorizationService authz) : ControllerBase
|
public class IncomesController(AppDbContext db, BudgetAuthorizationService authz) : ControllerBase
|
||||||
{
|
{
|
||||||
private IActionResult? TryGetUserId(out string userId)
|
private IActionResult? TryGetUserId(out string userId)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ namespace Budget.Api.Controllers;
|
|||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/budgets/{budgetId:guid}/outgos")]
|
[Route("api/budgets/{budgetId:guid}/outgos")]
|
||||||
[Authorize]
|
[Authorize(Roles = "admin,user")]
|
||||||
public class OutgosController(AppDbContext db, BudgetAuthorizationService authz) : ControllerBase
|
public class OutgosController(AppDbContext db, BudgetAuthorizationService authz) : ControllerBase
|
||||||
{
|
{
|
||||||
private IActionResult? TryGetUserId(out string userId)
|
private IActionResult? TryGetUserId(out string userId)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ namespace Budget.Api.Controllers;
|
|||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/budgets/{budgetId:guid}/shares")]
|
[Route("api/budgets/{budgetId:guid}/shares")]
|
||||||
[Authorize]
|
[Authorize(Roles = "admin,user")]
|
||||||
public class SharesController(AppDbContext db, BudgetAuthorizationService authz) : ControllerBase
|
public class SharesController(AppDbContext db, BudgetAuthorizationService authz) : ControllerBase
|
||||||
{
|
{
|
||||||
private IActionResult? TryGetUserId(out string userId)
|
private IActionResult? TryGetUserId(out string userId)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ namespace Budget.Api.Controllers;
|
|||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/budgets/{budgetId:guid}/summary")]
|
[Route("api/budgets/{budgetId:guid}/summary")]
|
||||||
[Authorize]
|
[Authorize(Roles = "admin,user")]
|
||||||
public class SummaryController(AppDbContext db, BudgetAuthorizationService authz) : ControllerBase
|
public class SummaryController(AppDbContext db, BudgetAuthorizationService authz) : ControllerBase
|
||||||
{
|
{
|
||||||
private IActionResult? TryGetUserId(out string userId)
|
private IActionResult? TryGetUserId(out string userId)
|
||||||
|
|||||||
@@ -87,7 +87,10 @@ builder.Services.AddRateLimiter(options =>
|
|||||||
|
|
||||||
builder.Services.AddAuthorization();
|
builder.Services.AddAuthorization();
|
||||||
builder.Services.AddScoped<BudgetAuthorizationService>();
|
builder.Services.AddScoped<BudgetAuthorizationService>();
|
||||||
builder.Services.AddControllers();
|
builder.Services.AddControllers()
|
||||||
|
.AddJsonOptions(opts =>
|
||||||
|
opts.JsonSerializerOptions.Converters.Add(
|
||||||
|
new System.Text.Json.Serialization.JsonStringEnumConverter()));
|
||||||
builder.Services.AddHealthChecks()
|
builder.Services.AddHealthChecks()
|
||||||
.AddDbContextCheck<AppDbContext>();
|
.AddDbContextCheck<AppDbContext>();
|
||||||
|
|
||||||
|
|||||||
@@ -1,184 +1 @@
|
|||||||
.counter {
|
/* App-level overrides (intentionally minimal — see index.css for the design system) */
|
||||||
font-size: 16px;
|
|
||||||
padding: 5px 10px;
|
|
||||||
border-radius: 5px;
|
|
||||||
color: var(--accent);
|
|
||||||
background: var(--accent-bg);
|
|
||||||
border: 2px solid transparent;
|
|
||||||
transition: border-color 0.3s;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: var(--accent-border);
|
|
||||||
}
|
|
||||||
&:focus-visible {
|
|
||||||
outline: 2px solid var(--accent);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero {
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
.base,
|
|
||||||
.framework,
|
|
||||||
.vite {
|
|
||||||
inset-inline: 0;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.base {
|
|
||||||
width: 170px;
|
|
||||||
position: relative;
|
|
||||||
z-index: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.framework,
|
|
||||||
.vite {
|
|
||||||
position: absolute;
|
|
||||||
}
|
|
||||||
|
|
||||||
.framework {
|
|
||||||
z-index: 1;
|
|
||||||
top: 34px;
|
|
||||||
height: 28px;
|
|
||||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
|
||||||
scale(1.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.vite {
|
|
||||||
z-index: 0;
|
|
||||||
top: 107px;
|
|
||||||
height: 26px;
|
|
||||||
width: auto;
|
|
||||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
|
||||||
scale(0.8);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#center {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 25px;
|
|
||||||
place-content: center;
|
|
||||||
place-items: center;
|
|
||||||
flex-grow: 1;
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
padding: 32px 20px 24px;
|
|
||||||
gap: 18px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#next-steps {
|
|
||||||
display: flex;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
text-align: left;
|
|
||||||
|
|
||||||
& > div {
|
|
||||||
flex: 1 1 0;
|
|
||||||
padding: 32px;
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
padding: 24px 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
width: 22px;
|
|
||||||
height: 22px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
flex-direction: column;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#docs {
|
|
||||||
border-right: 1px solid var(--border);
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
border-right: none;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#next-steps ul {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
margin: 32px 0 0;
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
height: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: var(--text-h);
|
|
||||||
font-size: 16px;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: var(--social-bg);
|
|
||||||
display: flex;
|
|
||||||
padding: 6px 12px;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: box-shadow 0.3s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
}
|
|
||||||
.button-icon {
|
|
||||||
height: 18px;
|
|
||||||
width: 18px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
margin-top: 20px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
li {
|
|
||||||
flex: 1 1 calc(50% - 8px);
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
width: 100%;
|
|
||||||
justify-content: center;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#spacer {
|
|
||||||
height: 88px;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
height: 48px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.ticks {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
&::before,
|
|
||||||
&::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: -4.5px;
|
|
||||||
border: 5px solid transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
left: 0;
|
|
||||||
border-left-color: var(--border);
|
|
||||||
}
|
|
||||||
&::after {
|
|
||||||
right: 0;
|
|
||||||
border-right-color: var(--border);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { AuthGuard } from './auth/AuthGuard';
|
|||||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||||
import { ToastProvider } from './components/Toast';
|
import { ToastProvider } from './components/Toast';
|
||||||
import { QueryToastBridge } from './components/QueryToastBridge';
|
import { QueryToastBridge } from './components/QueryToastBridge';
|
||||||
|
import { Layout } from './components/Layout';
|
||||||
import { CallbackPage } from './pages/CallbackPage';
|
import { CallbackPage } from './pages/CallbackPage';
|
||||||
import { BudgetsPage } from './pages/BudgetsPage';
|
import { BudgetsPage } from './pages/BudgetsPage';
|
||||||
import { IncomePage } from './pages/IncomePage';
|
import { IncomePage } from './pages/IncomePage';
|
||||||
@@ -17,7 +18,7 @@ import { SettingsPage } from './pages/SettingsPage';
|
|||||||
|
|
||||||
const onSigninCallback = () => {
|
const onSigninCallback = () => {
|
||||||
if (!window.location.search.includes('error')) {
|
if (!window.location.search.includes('error')) {
|
||||||
window.history.replaceState({}, '', '/');
|
window.location.replace('/');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ function App() {
|
|||||||
<AuthProvider {...authConfig} onSigninCallback={onSigninCallback}>
|
<AuthProvider {...authConfig} onSigninCallback={onSigninCallback}>
|
||||||
<TokenSync />
|
<TokenSync />
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
<Layout>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Navigate to="/budgets" replace />} />
|
<Route path="/" element={<Navigate to="/budgets" replace />} />
|
||||||
<Route path="/callback" element={<CallbackPage />} />
|
<Route path="/callback" element={<CallbackPage />} />
|
||||||
@@ -39,6 +41,7 @@ function App() {
|
|||||||
<Route path="/budgets/:id/summary" element={<AuthGuard><SummaryPage /></AuthGuard>} />
|
<Route path="/budgets/:id/summary" element={<AuthGuard><SummaryPage /></AuthGuard>} />
|
||||||
<Route path="/budgets/:id/settings" element={<AuthGuard><SettingsPage /></AuthGuard>} />
|
<Route path="/budgets/:id/settings" element={<AuthGuard><SettingsPage /></AuthGuard>} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</Layout>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
|
|||||||
@@ -4,11 +4,21 @@ import { useAuth } from 'react-oidc-context';
|
|||||||
export function AuthGuard({ children }: { children: ReactNode }) {
|
export function AuthGuard({ children }: { children: ReactNode }) {
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
|
|
||||||
if (auth.isLoading) return <div>Loading...</div>;
|
if (auth.isLoading) {
|
||||||
|
return <div className="loading-text">Loading…</div>;
|
||||||
|
}
|
||||||
|
|
||||||
if (!auth.isAuthenticated) {
|
if (!auth.isAuthenticated) {
|
||||||
auth.signinRedirect();
|
return (
|
||||||
return null;
|
<div className="auth-landing">
|
||||||
|
<div className="auth-landing-icon" aria-hidden="true">💰</div>
|
||||||
|
<h1>Budget</h1>
|
||||||
|
<p>Sign in to view and manage your personal budgets.</p>
|
||||||
|
<button className="btn btn-primary" onClick={() => auth.signinRedirect()}>
|
||||||
|
Sign In
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
|
|||||||
@@ -1,14 +1,45 @@
|
|||||||
import { NavLink, useParams } from 'react-router-dom';
|
import { NavLink, Link, useParams } from 'react-router-dom';
|
||||||
|
import { useBudget } from '../api/budgets';
|
||||||
|
|
||||||
export function BudgetNav() {
|
export function BudgetNav() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const base = `/budgets/${id}`;
|
const base = `/budgets/${id}`;
|
||||||
|
const { data: budget } = useBudget(id!);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav style={{ display: 'flex', gap: '1rem', marginBottom: '1rem' }}>
|
<div>
|
||||||
<NavLink to={`${base}/income`}>Income</NavLink>
|
<div className="budget-nav-header">
|
||||||
<NavLink to={`${base}/outgo`}>Outgo</NavLink>
|
<Link to="/budgets" className="back-link">
|
||||||
<NavLink to={`${base}/summary`}>Summary</NavLink>
|
← My Budgets
|
||||||
<NavLink to={`${base}/settings`}>Settings</NavLink>
|
</Link>
|
||||||
|
{budget && <span className="budget-nav-title">{budget.name}</span>}
|
||||||
|
</div>
|
||||||
|
<nav className="nav-tabs">
|
||||||
|
<NavLink
|
||||||
|
to={`${base}/income`}
|
||||||
|
className={({ isActive }) => `nav-tab${isActive ? ' active' : ''}`}
|
||||||
|
>
|
||||||
|
Income
|
||||||
|
</NavLink>
|
||||||
|
<NavLink
|
||||||
|
to={`${base}/outgo`}
|
||||||
|
className={({ isActive }) => `nav-tab${isActive ? ' active' : ''}`}
|
||||||
|
>
|
||||||
|
Outgo
|
||||||
|
</NavLink>
|
||||||
|
<NavLink
|
||||||
|
to={`${base}/summary`}
|
||||||
|
className={({ isActive }) => `nav-tab${isActive ? ' active' : ''}`}
|
||||||
|
>
|
||||||
|
Summary
|
||||||
|
</NavLink>
|
||||||
|
<NavLink
|
||||||
|
to={`${base}/settings`}
|
||||||
|
className={({ isActive }) => `nav-tab${isActive ? ' active' : ''}`}
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
</NavLink>
|
||||||
</nav>
|
</nav>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useAuth } from 'react-oidc-context';
|
||||||
|
|
||||||
|
export function Header() {
|
||||||
|
const auth = useAuth();
|
||||||
|
const name = auth.user?.profile?.name ?? auth.user?.profile?.email;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="app-header">
|
||||||
|
<div className="header-inner">
|
||||||
|
<Link to="/budgets" className="header-brand">
|
||||||
|
<span aria-hidden="true">💰</span>
|
||||||
|
Budget
|
||||||
|
</Link>
|
||||||
|
<div className="header-actions">
|
||||||
|
{auth.isAuthenticated ? (
|
||||||
|
<>
|
||||||
|
{name && <span className="header-username">{name}</span>}
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
onClick={() => auth.signoutRedirect()}
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
onClick={() => auth.signinRedirect()}
|
||||||
|
>
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { Header } from './Header';
|
||||||
|
|
||||||
|
export function Layout({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header />
|
||||||
|
<div className="page-wrapper">{children}</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,25 +2,26 @@ interface Props { rows?: number; cols?: number; }
|
|||||||
|
|
||||||
export function LoadingSkeleton({ rows = 5, cols = 4 }: Props) {
|
export function LoadingSkeleton({ rows = 5, cols = 4 }: Props) {
|
||||||
return (
|
return (
|
||||||
|
<div className="table-wrapper">
|
||||||
<table>
|
<table>
|
||||||
<tbody>
|
<tbody>
|
||||||
{Array.from({ length: rows }).map((_, r) => (
|
{Array.from({ length: rows }).map((_, r) => (
|
||||||
<tr key={r}>
|
<tr key={r}>
|
||||||
{Array.from({ length: cols }).map((_, c) => (
|
{Array.from({ length: cols }).map((_, c) => (
|
||||||
<td key={c}>
|
<td key={c}>
|
||||||
<div style={{
|
<div
|
||||||
|
className="skeleton"
|
||||||
|
style={{
|
||||||
height: '1em',
|
height: '1em',
|
||||||
background: 'linear-gradient(90deg, #e5e7eb 25%, #f3f4f6 50%, #e5e7eb 75%)',
|
width: `${60 + (r * 7 + c * 13) % 30}%`,
|
||||||
backgroundSize: '200% 100%',
|
}}
|
||||||
animation: 'shimmer 1.5s infinite',
|
/>
|
||||||
borderRadius: '4px',
|
|
||||||
width: `${60 + Math.random() * 30}%`,
|
|
||||||
}} />
|
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+519
-90
@@ -1,111 +1,540 @@
|
|||||||
:root {
|
:root {
|
||||||
--text: #6b6375;
|
/* Green palette */
|
||||||
--text-h: #08060d;
|
--green-950: #052e16;
|
||||||
--bg: #fff;
|
--green-900: #14532d;
|
||||||
--border: #e5e4e7;
|
--green-800: #166534;
|
||||||
--code-bg: #f4f3ec;
|
--green-700: #15803d;
|
||||||
--accent: #aa3bff;
|
--green-600: #16a34a;
|
||||||
--accent-bg: rgba(170, 59, 255, 0.1);
|
--green-500: #22c55e;
|
||||||
--accent-border: rgba(170, 59, 255, 0.5);
|
--green-300: #86efac;
|
||||||
--social-bg: rgba(244, 243, 236, 0.5);
|
--green-200: #bbf7d0;
|
||||||
--shadow:
|
--green-100: #dcfce7;
|
||||||
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
--green-50: #f0fdf4;
|
||||||
|
|
||||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
--red-600: #dc2626;
|
||||||
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
--red-100: #fee2e2;
|
||||||
--mono: ui-monospace, Consolas, monospace;
|
--red-50: #fef2f2;
|
||||||
|
|
||||||
font: 18px/145% var(--sans);
|
--amber-100: #fef3c7;
|
||||||
letter-spacing: 0.18px;
|
|
||||||
color-scheme: light dark;
|
/* Semantic tokens */
|
||||||
color: var(--text);
|
--color-primary: var(--green-700);
|
||||||
background: var(--bg);
|
--color-primary-hover: var(--green-800);
|
||||||
font-synthesis: none;
|
--color-primary-light: var(--green-50);
|
||||||
text-rendering: optimizeLegibility;
|
--color-accent: var(--green-600);
|
||||||
|
|
||||||
|
--color-text: #1a1a1a;
|
||||||
|
--color-text-muted: #6b7280;
|
||||||
|
--color-text-faint: #9ca3af;
|
||||||
|
--color-heading: #111827;
|
||||||
|
|
||||||
|
--color-bg: #ffffff;
|
||||||
|
--color-bg-subtle: #f9fafb;
|
||||||
|
--color-bg-muted: #f3f4f6;
|
||||||
|
|
||||||
|
--color-border: #e5e7eb;
|
||||||
|
--color-border-dark: #d1d5db;
|
||||||
|
|
||||||
|
--color-header-bg: var(--green-900);
|
||||||
|
--color-header-text: #ffffff;
|
||||||
|
|
||||||
|
--color-positive: var(--green-700);
|
||||||
|
--color-negative: var(--red-600);
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
--font-sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
--font-mono: ui-monospace, 'Cascadia Code', Consolas, monospace;
|
||||||
|
|
||||||
|
/* Shape */
|
||||||
|
--radius-sm: 4px;
|
||||||
|
--radius: 6px;
|
||||||
|
--radius-lg: 10px;
|
||||||
|
|
||||||
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
|
--shadow: 0 1px 3px rgba(0, 0, 0, 0.10), 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||||
|
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.07), 0 2px 4px rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--color-text);
|
||||||
|
background: var(--color-bg);
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
:root {
|
|
||||||
--text: #9ca3af;
|
|
||||||
--text-h: #f3f4f6;
|
|
||||||
--bg: #16171d;
|
|
||||||
--border: #2e303a;
|
|
||||||
--code-bg: #1f2028;
|
|
||||||
--accent: #c084fc;
|
|
||||||
--accent-bg: rgba(192, 132, 252, 0.15);
|
|
||||||
--accent-border: rgba(192, 132, 252, 0.5);
|
|
||||||
--social-bg: rgba(47, 48, 58, 0.5);
|
|
||||||
--shadow:
|
|
||||||
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#social .button-icon {
|
|
||||||
filter: invert(1) brightness(2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#root {
|
|
||||||
width: 1126px;
|
|
||||||
max-width: 100%;
|
|
||||||
margin: 0 auto;
|
|
||||||
text-align: center;
|
|
||||||
border-inline: 1px solid var(--border);
|
|
||||||
min-height: 100svh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
background: var(--color-bg-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
h1,
|
#root {
|
||||||
h2 {
|
min-height: 100svh;
|
||||||
font-family: var(--heading);
|
display: flex;
|
||||||
font-weight: 500;
|
flex-direction: column;
|
||||||
color: var(--text-h);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
/* ---- Typography ---- */
|
||||||
font-size: 56px;
|
h1, h2, h3, h4 {
|
||||||
letter-spacing: -1.68px;
|
|
||||||
margin: 32px 0;
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
font-size: 36px;
|
|
||||||
margin: 20px 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
h2 {
|
|
||||||
font-size: 24px;
|
|
||||||
line-height: 118%;
|
|
||||||
letter-spacing: -0.24px;
|
|
||||||
margin: 0 0 8px;
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
p {
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-heading);
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
h1 { font-size: 1.75rem; margin-bottom: 1.25rem; }
|
||||||
|
h2 { font-size: 1.2rem; margin-bottom: 0.75rem; }
|
||||||
|
h3 { font-size: 1rem; }
|
||||||
|
p { margin: 0; }
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
color: var(--color-primary-hover);
|
||||||
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
code,
|
/* ---- Header ---- */
|
||||||
.counter {
|
.app-header {
|
||||||
font-family: var(--mono);
|
background: var(--color-header-bg);
|
||||||
|
color: var(--color-header-text);
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.25);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-inner {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 1.5rem;
|
||||||
|
height: 56px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: #ffffff;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
.header-brand:hover { color: var(--green-200); text-decoration: none; }
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-username {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--green-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Page layout ---- */
|
||||||
|
.page-wrapper {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.5rem;
|
||||||
|
width: 100%;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Buttons ---- */
|
||||||
|
.btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
border-radius: 4px;
|
align-items: center;
|
||||||
color: var(--text-h);
|
gap: 0.375rem;
|
||||||
|
padding: 0.4rem 0.875rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
.btn:focus-visible {
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: #ffffff;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: var(--color-primary-hover);
|
||||||
|
border-color: var(--color-primary-hover);
|
||||||
|
color: #ffffff;
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
code {
|
.btn-secondary {
|
||||||
font-size: 15px;
|
background: #ffffff;
|
||||||
line-height: 135%;
|
color: var(--color-primary);
|
||||||
padding: 4px 8px;
|
border-color: var(--color-border-dark);
|
||||||
background: var(--code-bg);
|
}
|
||||||
|
.btn-secondary:hover:not(:disabled) {
|
||||||
|
background: var(--color-primary-light);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: #ffffff;
|
||||||
|
color: var(--color-negative);
|
||||||
|
border-color: #fca5a5;
|
||||||
|
}
|
||||||
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
background: var(--red-50);
|
||||||
|
border-color: var(--color-negative);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-header-text);
|
||||||
|
border-color: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
.btn-ghost:hover:not(:disabled) {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
text-decoration: none;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
padding: 0.25rem 0.625rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
padding: 0.25rem 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Forms ---- */
|
||||||
|
input[type="text"],
|
||||||
|
input[type="number"],
|
||||||
|
input[type="email"],
|
||||||
|
input[type="password"],
|
||||||
|
input:not([type]),
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding: 0.375rem 0.625rem;
|
||||||
|
border: 1px solid var(--color-border-dark);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: #ffffff;
|
||||||
|
color: var(--color-text);
|
||||||
|
line-height: 1.5;
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
input[type="number"] { width: auto; }
|
||||||
|
input:focus, select:focus, textarea:focus {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
box-shadow: 0 0 0 3px rgba(22, 163, 74, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-error {
|
||||||
|
color: var(--color-negative);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Tables ---- */
|
||||||
|
.table-wrapper {
|
||||||
|
overflow-x: auto;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
background: #ffffff;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead {
|
||||||
|
background: var(--green-50);
|
||||||
|
border-bottom: 2px solid var(--green-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
thead th {
|
||||||
|
padding: 0.625rem 0.875rem;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--green-800);
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr {
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
tbody tr:last-child { border-bottom: none; }
|
||||||
|
tbody tr:hover { background: var(--green-50); }
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 0.5rem 0.875rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-money { text-align: right; font-family: var(--font-mono); font-size: 0.8125rem; }
|
||||||
|
.col-pct { text-align: right; font-size: 0.8125rem; }
|
||||||
|
.col-drag { width: 32px; color: var(--color-text-faint); cursor: grab; user-select: none; }
|
||||||
|
.col-drag:active { cursor: grabbing; }
|
||||||
|
.col-actions { width: 1%; white-space: nowrap; }
|
||||||
|
|
||||||
|
/* Inline edit inputs inside tables */
|
||||||
|
td input[type="text"], td input:not([type]) {
|
||||||
|
min-width: 100px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
td input[type="number"] { min-width: 80px; }
|
||||||
|
td select { min-width: 80px; }
|
||||||
|
|
||||||
|
/* ---- Budget nav tabs ---- */
|
||||||
|
.budget-nav-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-nav-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
.back-link:hover { color: var(--color-primary); text-decoration: none; }
|
||||||
|
|
||||||
|
.nav-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
border-bottom: 2px solid var(--color-border);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tab {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
margin-bottom: -2px;
|
||||||
|
transition: color 0.15s, border-color 0.15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.nav-tab:hover { color: var(--color-primary); text-decoration: none; }
|
||||||
|
.nav-tab.active {
|
||||||
|
color: var(--color-primary);
|
||||||
|
border-bottom-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Cards / Sections ---- */
|
||||||
|
.card {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 1.25rem;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-heading);
|
||||||
|
margin-bottom: 0.875rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Budget list ---- */
|
||||||
|
.budget-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0 0 1.5rem;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-list-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.875rem 1.125rem;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
width: 100%;
|
||||||
|
font-family: inherit;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.budget-list-item:hover {
|
||||||
|
border-color: var(--green-300);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-list-item-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-heading);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-list-arrow {
|
||||||
|
color: var(--color-text-faint);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Type badges ---- */
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.1rem 0.5rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.badge-need { background: var(--amber-100); color: #92400e; }
|
||||||
|
.badge-want { background: #ede9fe; color: #5b21b6; }
|
||||||
|
.badge-save { background: var(--green-100); color: var(--green-800); }
|
||||||
|
|
||||||
|
/* ---- Auth landing ---- */
|
||||||
|
.auth-landing {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
padding: 4rem 1.5rem;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
.auth-landing-icon { font-size: 3.5rem; }
|
||||||
|
.auth-landing h1 {
|
||||||
|
font-size: 2.25rem;
|
||||||
|
color: var(--green-900);
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.auth-landing p {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 1rem;
|
||||||
|
max-width: 380px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Loading ---- */
|
||||||
|
.loading-text {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Skeleton animation ---- */
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { background-position: 200% 0; }
|
||||||
|
100% { background-position: -200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Summary table ---- */
|
||||||
|
.summary-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-stat {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
min-width: 160px;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-stat-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-stat-value {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-positive);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Responsive ---- */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.page-wrapper { padding: 1rem; }
|
||||||
|
.header-inner { padding: 0 1rem; }
|
||||||
|
h1 { font-size: 1.4rem; margin-bottom: 1rem; }
|
||||||
|
.nav-tab { padding: 0.5rem 0.625rem; font-size: 0.8125rem; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,25 +22,40 @@ export function BudgetsPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1>My Budgets</h1>
|
<h1>My Budgets</h1>
|
||||||
{budgets.length === 0 && <p>No budgets yet. Create one below.</p>}
|
|
||||||
<ul>
|
{budgets.length === 0 ? (
|
||||||
|
<div className="empty-state" style={{ padding: '2rem 0' }}>
|
||||||
|
<p style={{ color: 'var(--color-text-muted)' }}>No budgets yet. Create one below to get started.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="budget-list">
|
||||||
{budgets.map(b => (
|
{budgets.map(b => (
|
||||||
<li key={b.id}>
|
<li key={b.id}>
|
||||||
<button onClick={() => navigate(`/budgets/${b.id}/income`)}>
|
<button
|
||||||
{b.name}
|
className="budget-list-item"
|
||||||
|
onClick={() => navigate(`/budgets/${b.id}/income`)}
|
||||||
|
>
|
||||||
|
<span className="budget-list-item-name">{b.name}</span>
|
||||||
|
<span className="budget-list-arrow">→</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<form onSubmit={handleSubmit(onSubmit)} style={{ marginTop: '1rem' }}>
|
)}
|
||||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'flex-start' }}>
|
|
||||||
<div>
|
<div className="card" style={{ maxWidth: '400px' }}>
|
||||||
<input placeholder="Budget name" {...register('name')} />
|
<div className="section-title">New Budget</div>
|
||||||
{errors.name && <span style={{ color: 'red', display: 'block', fontSize: '0.85rem' }}>{errors.name.message}</span>}
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
</div>
|
<div className="form-field" style={{ marginBottom: '0.75rem' }}>
|
||||||
<button type="submit" disabled={isSubmitting || createBudget.isPending}>Create</button>
|
<label htmlFor="budget-name">Budget name</label>
|
||||||
|
<input id="budget-name" placeholder="e.g. 2025 Annual Budget" {...register('name')} />
|
||||||
|
{errors.name && <span className="field-error">{errors.name.message}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={isSubmitting || createBudget.isPending}>
|
||||||
|
Create Budget
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,12 @@ export function CallbackPage() {
|
|||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="auth-landing">
|
||||||
<h2>Sign-in failed</h2>
|
<h2>Sign-in failed</h2>
|
||||||
<p>{errorDescription ?? error}</p>
|
<p style={{ color: 'var(--color-negative)' }}>{errorDescription ?? error}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <div>Signing in...</div>;
|
return <div className="loading-text">Signing in…</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,89 @@ import { MoneyDisplay } from '../components/MoneyDisplay';
|
|||||||
import { BudgetNav } from '../components/BudgetNav';
|
import { BudgetNav } from '../components/BudgetNav';
|
||||||
import { useIncomes, useCreateIncome, useUpdateIncome, useDeleteIncome, useReorderIncomes } from '../api/incomes';
|
import { useIncomes, useCreateIncome, useUpdateIncome, useDeleteIncome, useReorderIncomes } from '../api/incomes';
|
||||||
import { createIncomeSchema, type CreateIncomeInput } from '../schemas/index';
|
import { createIncomeSchema, type CreateIncomeInput } from '../schemas/index';
|
||||||
|
import { toMonthly, toAnnually } from '../utils/frequency';
|
||||||
|
|
||||||
|
const fmt = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
|
||||||
|
|
||||||
|
// ---- New row form (inline at bottom of table) ----
|
||||||
|
function NewIncomeRow({
|
||||||
|
onAdd,
|
||||||
|
onCancel,
|
||||||
|
isPending,
|
||||||
|
}: {
|
||||||
|
onAdd: (data: CreateIncomeInput) => Promise<void>;
|
||||||
|
onCancel: () => void;
|
||||||
|
isPending: boolean;
|
||||||
|
}) {
|
||||||
|
const { register, handleSubmit, control, watch, setFocus, formState: { errors, isSubmitting } } = useForm<CreateIncomeInput>({
|
||||||
|
resolver: zodResolver(createIncomeSchema),
|
||||||
|
defaultValues: { name: '', frequency: 'Monthly', amount: '' as unknown as number },
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => { setFocus('name'); }, [setFocus]);
|
||||||
|
|
||||||
|
const watchedAmount = watch('amount');
|
||||||
|
const watchedFrequency = watch('frequency');
|
||||||
|
const preview = Number.isFinite(watchedAmount) && watchedAmount > 0
|
||||||
|
? { monthly: toMonthly(watchedAmount, watchedFrequency), annually: toAnnually(watchedAmount, watchedFrequency) }
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onCancel();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr onKeyDown={onKeyDown} style={{ background: 'var(--green-50)', outline: '2px solid var(--green-300)', outlineOffset: '-1px' }}>
|
||||||
|
<td className="col-drag" />
|
||||||
|
<td>
|
||||||
|
<input placeholder="Name" {...register('name')} />
|
||||||
|
{errors.name && <span className="field-error">{errors.name.message}</span>}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="frequency"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FrequencySelect value={field.value} onChange={field.onChange} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0.01"
|
||||||
|
placeholder="0.00"
|
||||||
|
style={{ width: '100px' }}
|
||||||
|
{...register('amount', { valueAsNumber: true })}
|
||||||
|
/>
|
||||||
|
{errors.amount && <span className="field-error">{errors.amount.message}</span>}
|
||||||
|
</td>
|
||||||
|
<td className="col-money" style={{ color: preview ? 'var(--color-positive)' : 'var(--color-text-faint)' }}>
|
||||||
|
{preview ? fmt.format(preview.monthly) : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="col-money" style={{ color: preview ? 'var(--color-positive)' : 'var(--color-text-faint)' }}>
|
||||||
|
{preview ? fmt.format(preview.annually) : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="col-actions">
|
||||||
|
<div style={{ display: 'flex', gap: '4px' }}>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
onClick={handleSubmit(onAdd)}
|
||||||
|
disabled={isSubmitting || isPending}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Existing row (edit in place) ----
|
||||||
function SortableRow({
|
function SortableRow({
|
||||||
income,
|
income,
|
||||||
onSave,
|
onSave,
|
||||||
@@ -58,10 +140,10 @@ function SortableRow({
|
|||||||
if (editing) {
|
if (editing) {
|
||||||
return (
|
return (
|
||||||
<tr ref={setNodeRef} style={style}>
|
<tr ref={setNodeRef} style={style}>
|
||||||
<td>⠿</td>
|
<td className="col-drag">⠿</td>
|
||||||
<td>
|
<td>
|
||||||
<input {...register('name')} />
|
<input {...register('name')} />
|
||||||
{errors.name && <span style={{ color: 'red', fontSize: '0.8rem' }}>{errors.name.message}</span>}
|
{errors.name && <span className="field-error">{errors.name.message}</span>}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<Controller
|
<Controller
|
||||||
@@ -74,13 +156,15 @@ function SortableRow({
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<input type="number" step="0.01" {...register('amount', { valueAsNumber: true })} />
|
<input type="number" step="0.01" {...register('amount', { valueAsNumber: true })} />
|
||||||
{errors.amount && <span style={{ color: 'red', fontSize: '0.8rem' }}>{errors.amount.message}</span>}
|
{errors.amount && <span className="field-error">{errors.amount.message}</span>}
|
||||||
</td>
|
</td>
|
||||||
<td></td>
|
<td className="col-money">—</td>
|
||||||
<td></td>
|
<td className="col-money">—</td>
|
||||||
<td>
|
<td className="col-actions">
|
||||||
<button onClick={handleSubmit(onSubmit)} disabled={isSubmitting}>Save</button>
|
<div style={{ display: 'flex', gap: '4px' }}>
|
||||||
<button onClick={cancel}>Cancel</button>
|
<button className="btn btn-primary btn-sm" onClick={handleSubmit(onSubmit)} disabled={isSubmitting}>Save</button>
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={cancel}>Cancel</button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
@@ -88,20 +172,24 @@ function SortableRow({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<tr ref={setNodeRef} style={style}>
|
<tr ref={setNodeRef} style={style}>
|
||||||
<td {...attributes} {...listeners} style={{ cursor: 'grab' }}>⠿</td>
|
<td className="col-drag" {...attributes} {...listeners}>⠿</td>
|
||||||
<td onClick={startEdit}>{income.name}</td>
|
<td onClick={startEdit} style={{ cursor: 'pointer' }}>{income.name}</td>
|
||||||
<td onClick={startEdit}>{income.frequency}</td>
|
<td onClick={startEdit} style={{ cursor: 'pointer' }}>{income.frequency}</td>
|
||||||
<td onClick={startEdit}><MoneyDisplay value={income.amount} /></td>
|
<td className="col-money" onClick={startEdit} style={{ cursor: 'pointer' }}><MoneyDisplay value={income.amount} /></td>
|
||||||
<td><MoneyDisplay value={income.monthly} /></td>
|
<td className="col-money"><MoneyDisplay value={income.monthly} /></td>
|
||||||
<td><MoneyDisplay value={income.annually} /></td>
|
<td className="col-money"><MoneyDisplay value={income.annually} /></td>
|
||||||
<td><button onClick={() => onDelete(income.id)}>Delete</button></td>
|
<td className="col-actions">
|
||||||
|
<button className="btn btn-danger btn-sm" onClick={() => onDelete(income.id)}>Delete</button>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Page ----
|
||||||
export function IncomePage() {
|
export function IncomePage() {
|
||||||
const { id: budgetId } = useParams<{ id: string }>();
|
const { id: budgetId } = useParams<{ id: string }>();
|
||||||
const sensors = useSensors(useSensor(PointerSensor));
|
const sensors = useSensors(useSensor(PointerSensor));
|
||||||
|
const [addingRow, setAddingRow] = useState(false);
|
||||||
|
|
||||||
const { data: incomes = [], isLoading } = useIncomes(budgetId!);
|
const { data: incomes = [], isLoading } = useIncomes(budgetId!);
|
||||||
const [displayItems, setDisplayItems] = useState<IncomeDto[]>([]);
|
const [displayItems, setDisplayItems] = useState<IncomeDto[]>([]);
|
||||||
@@ -121,8 +209,9 @@ export function IncomePage() {
|
|||||||
deleteIncome.mutate(id);
|
deleteIncome.mutate(id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAdd = () => {
|
const handleAdd = async (data: CreateIncomeInput) => {
|
||||||
createIncome.mutate({ name: 'New Income', frequency: 'Monthly', amount: 0 });
|
await createIncome.mutateAsync(data);
|
||||||
|
setAddingRow(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDragEnd = (event: DragEndEvent) => {
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
@@ -141,15 +230,16 @@ export function IncomePage() {
|
|||||||
<div>
|
<div>
|
||||||
<BudgetNav />
|
<BudgetNav />
|
||||||
<h1>Income</h1>
|
<h1>Income</h1>
|
||||||
|
<div className="table-wrapper">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th></th>
|
<th style={{ width: 32 }}></th>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Frequency</th>
|
<th>Frequency</th>
|
||||||
<th>Amount</th>
|
<th className="col-money">Amount</th>
|
||||||
<th>Monthly</th>
|
<th className="col-money">Monthly</th>
|
||||||
<th>Annually</th>
|
<th className="col-money">Annually</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -164,11 +254,23 @@ export function IncomePage() {
|
|||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{addingRow && (
|
||||||
|
<NewIncomeRow
|
||||||
|
onAdd={handleAdd}
|
||||||
|
onCancel={() => setAddingRow(false)}
|
||||||
|
isPending={createIncome.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
</table>
|
</table>
|
||||||
<button onClick={handleAdd} disabled={createIncome.isPending}>+ Add Row</button>
|
</div>
|
||||||
|
{!addingRow && (
|
||||||
|
<button className="btn btn-secondary" onClick={() => setAddingRow(true)}>
|
||||||
|
+ Add Row
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,9 +28,145 @@ import {
|
|||||||
useCreateOutgo, useUpdateOutgo, useDeleteOutgo, useReorderOutgos,
|
useCreateOutgo, useUpdateOutgo, useDeleteOutgo, useReorderOutgos,
|
||||||
} from '../api/outgos';
|
} from '../api/outgos';
|
||||||
import { createOutgoSchema, type CreateOutgoInput } from '../schemas/index';
|
import { createOutgoSchema, type CreateOutgoInput } from '../schemas/index';
|
||||||
|
import { toMonthly, toAnnually } from '../utils/frequency';
|
||||||
|
|
||||||
|
const fmt = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
|
||||||
const OUTGO_TYPES = ['Need', 'Want', 'Save'] as const;
|
const OUTGO_TYPES = ['Need', 'Want', 'Save'] as const;
|
||||||
|
|
||||||
|
const TYPE_BADGE: Record<string, string> = {
|
||||||
|
Need: 'badge badge-need',
|
||||||
|
Want: 'badge badge-want',
|
||||||
|
Save: 'badge badge-save',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- New row form (inline at bottom of table) ----
|
||||||
|
function NewOutgoRow({
|
||||||
|
categories,
|
||||||
|
paymentSources,
|
||||||
|
onAdd,
|
||||||
|
onCancel,
|
||||||
|
isPending,
|
||||||
|
}: {
|
||||||
|
categories: string[];
|
||||||
|
paymentSources: string[];
|
||||||
|
onAdd: (data: CreateOutgoInput) => Promise<void>;
|
||||||
|
onCancel: () => void;
|
||||||
|
isPending: boolean;
|
||||||
|
}) {
|
||||||
|
const { register, handleSubmit, control, watch, setFocus, formState: { errors, isSubmitting } } = useForm<CreateOutgoInput>({
|
||||||
|
resolver: zodResolver(createOutgoSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: '',
|
||||||
|
category: '',
|
||||||
|
type: 'Need',
|
||||||
|
frequency: 'Monthly',
|
||||||
|
amount: '' as unknown as number,
|
||||||
|
paymentSource: '',
|
||||||
|
notes: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => { setFocus('name'); }, [setFocus]);
|
||||||
|
|
||||||
|
const watchedAmount = watch('amount');
|
||||||
|
const watchedFrequency = watch('frequency');
|
||||||
|
const preview = Number.isFinite(watchedAmount) && watchedAmount > 0
|
||||||
|
? { monthly: toMonthly(watchedAmount, watchedFrequency), annually: toAnnually(watchedAmount, watchedFrequency) }
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onCancel();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr onKeyDown={onKeyDown} style={{ background: 'var(--green-50)', outline: '2px solid var(--green-300)', outlineOffset: '-1px' }}>
|
||||||
|
<td className="col-drag" />
|
||||||
|
<td>
|
||||||
|
<input placeholder="Name" {...register('name')} />
|
||||||
|
{errors.name && <span className="field-error">{errors.name.message}</span>}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="category"
|
||||||
|
render={({ field }) => (
|
||||||
|
<AutocompleteInput
|
||||||
|
value={field.value ?? ''}
|
||||||
|
onChange={field.onChange}
|
||||||
|
suggestions={categories}
|
||||||
|
placeholder="Category"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<select {...register('type')}>
|
||||||
|
{OUTGO_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="frequency"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FrequencySelect value={field.value} onChange={field.onChange} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0.01"
|
||||||
|
placeholder="0.00"
|
||||||
|
style={{ width: '100px' }}
|
||||||
|
{...register('amount', { valueAsNumber: true })}
|
||||||
|
/>
|
||||||
|
{errors.amount && <span className="field-error">{errors.amount.message}</span>}
|
||||||
|
</td>
|
||||||
|
<td className="col-money" style={{ color: preview ? 'var(--color-positive)' : 'var(--color-text-faint)' }}>
|
||||||
|
{preview ? fmt.format(preview.monthly) : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="col-money" style={{ color: preview ? 'var(--color-positive)' : 'var(--color-text-faint)' }}>
|
||||||
|
{preview ? fmt.format(preview.annually) : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="col-pct" style={{ color: 'var(--color-text-faint)' }}>—</td>
|
||||||
|
<td>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="paymentSource"
|
||||||
|
render={({ field }) => (
|
||||||
|
<AutocompleteInput
|
||||||
|
value={field.value ?? ''}
|
||||||
|
onChange={field.onChange}
|
||||||
|
suggestions={paymentSources}
|
||||||
|
placeholder="Payment source"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input placeholder="Notes" {...register('notes')} />
|
||||||
|
</td>
|
||||||
|
<td className="col-actions">
|
||||||
|
<div style={{ display: 'flex', gap: '4px' }}>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
onClick={handleSubmit(onAdd)}
|
||||||
|
disabled={isSubmitting || isPending}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Existing row (edit in place) ----
|
||||||
function SortableRow({
|
function SortableRow({
|
||||||
outgo,
|
outgo,
|
||||||
categories,
|
categories,
|
||||||
@@ -84,10 +220,10 @@ function SortableRow({
|
|||||||
if (editing) {
|
if (editing) {
|
||||||
return (
|
return (
|
||||||
<tr ref={setNodeRef} style={style}>
|
<tr ref={setNodeRef} style={style}>
|
||||||
<td>⠿</td>
|
<td className="col-drag">⠿</td>
|
||||||
<td>
|
<td>
|
||||||
<input {...register('name')} />
|
<input {...register('name')} />
|
||||||
{errors.name && <span style={{ color: 'red', fontSize: '0.8rem' }}>{errors.name.message}</span>}
|
{errors.name && <span className="field-error">{errors.name.message}</span>}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<Controller
|
<Controller
|
||||||
@@ -119,11 +255,11 @@ function SortableRow({
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<input type="number" step="0.01" {...register('amount', { valueAsNumber: true })} />
|
<input type="number" step="0.01" {...register('amount', { valueAsNumber: true })} />
|
||||||
{errors.amount && <span style={{ color: 'red', fontSize: '0.8rem' }}>{errors.amount.message}</span>}
|
{errors.amount && <span className="field-error">{errors.amount.message}</span>}
|
||||||
</td>
|
</td>
|
||||||
<td></td>
|
<td className="col-money">—</td>
|
||||||
<td></td>
|
<td className="col-money">—</td>
|
||||||
<td></td>
|
<td className="col-pct">—</td>
|
||||||
<td>
|
<td>
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
@@ -139,9 +275,11 @@ function SortableRow({
|
|||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td><input {...register('notes')} /></td>
|
<td><input {...register('notes')} /></td>
|
||||||
<td>
|
<td className="col-actions">
|
||||||
<button onClick={handleSubmit(onSubmit)} disabled={isSubmitting}>Save</button>
|
<div style={{ display: 'flex', gap: '4px' }}>
|
||||||
<button onClick={cancel}>Cancel</button>
|
<button className="btn btn-primary btn-sm" onClick={handleSubmit(onSubmit)} disabled={isSubmitting}>Save</button>
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={cancel}>Cancel</button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
@@ -149,25 +287,31 @@ function SortableRow({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<tr ref={setNodeRef} style={style}>
|
<tr ref={setNodeRef} style={style}>
|
||||||
<td {...attributes} {...listeners} style={{ cursor: 'grab' }}>⠿</td>
|
<td className="col-drag" {...attributes} {...listeners}>⠿</td>
|
||||||
<td onClick={startEdit}>{outgo.name}</td>
|
<td onClick={startEdit} style={{ cursor: 'pointer' }}>{outgo.name}</td>
|
||||||
<td onClick={startEdit}>{outgo.category}</td>
|
<td onClick={startEdit} style={{ cursor: 'pointer' }}>{outgo.category}</td>
|
||||||
<td onClick={startEdit}>{outgo.type}</td>
|
<td onClick={startEdit} style={{ cursor: 'pointer' }}>
|
||||||
<td onClick={startEdit}>{outgo.frequency}</td>
|
<span className={TYPE_BADGE[outgo.type] ?? 'badge'}>{outgo.type}</span>
|
||||||
<td onClick={startEdit}><MoneyDisplay value={outgo.amount} /></td>
|
</td>
|
||||||
<td><MoneyDisplay value={outgo.monthly} /></td>
|
<td onClick={startEdit} style={{ cursor: 'pointer' }}>{outgo.frequency}</td>
|
||||||
<td><MoneyDisplay value={outgo.annually} /></td>
|
<td className="col-money" onClick={startEdit} style={{ cursor: 'pointer' }}><MoneyDisplay value={outgo.amount} /></td>
|
||||||
<td>{outgo.monthlyPercent.toFixed(1)}%</td>
|
<td className="col-money"><MoneyDisplay value={outgo.monthly} /></td>
|
||||||
<td onClick={startEdit}>{outgo.paymentSource}</td>
|
<td className="col-money"><MoneyDisplay value={outgo.annually} /></td>
|
||||||
<td onClick={startEdit}>{outgo.notes}</td>
|
<td className="col-pct">{outgo.monthlyPercent.toFixed(1)}%</td>
|
||||||
<td><button onClick={() => onDelete(outgo.id)}>Delete</button></td>
|
<td onClick={startEdit} style={{ cursor: 'pointer' }}>{outgo.paymentSource}</td>
|
||||||
|
<td onClick={startEdit} style={{ cursor: 'pointer' }}>{outgo.notes}</td>
|
||||||
|
<td className="col-actions">
|
||||||
|
<button className="btn btn-danger btn-sm" onClick={() => onDelete(outgo.id)}>Delete</button>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Page ----
|
||||||
export function OutgoPage() {
|
export function OutgoPage() {
|
||||||
const { id: budgetId } = useParams<{ id: string }>();
|
const { id: budgetId } = useParams<{ id: string }>();
|
||||||
const sensors = useSensors(useSensor(PointerSensor));
|
const sensors = useSensors(useSensor(PointerSensor));
|
||||||
|
const [addingRow, setAddingRow] = useState(false);
|
||||||
|
|
||||||
const { data: outgos = [], isLoading } = useOutgos(budgetId!);
|
const { data: outgos = [], isLoading } = useOutgos(budgetId!);
|
||||||
const { data: categories = [] } = useCategories(budgetId!);
|
const { data: categories = [] } = useCategories(budgetId!);
|
||||||
@@ -198,16 +342,17 @@ export function OutgoPage() {
|
|||||||
deleteOutgo.mutate(id);
|
deleteOutgo.mutate(id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAdd = () => {
|
const handleAdd = async (data: CreateOutgoInput) => {
|
||||||
createOutgo.mutate({
|
await createOutgo.mutateAsync({
|
||||||
name: 'New Outgo',
|
name: data.name,
|
||||||
category: null,
|
category: data.category || null,
|
||||||
type: 'Need',
|
type: data.type,
|
||||||
frequency: 'Monthly',
|
frequency: data.frequency,
|
||||||
amount: 0,
|
amount: data.amount,
|
||||||
paymentSource: null,
|
paymentSource: data.paymentSource || null,
|
||||||
notes: null,
|
notes: data.notes || null,
|
||||||
});
|
});
|
||||||
|
setAddingRow(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDragEnd = (event: DragEndEvent) => {
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
@@ -226,19 +371,19 @@ export function OutgoPage() {
|
|||||||
<div>
|
<div>
|
||||||
<BudgetNav />
|
<BudgetNav />
|
||||||
<h1>Outgo</h1>
|
<h1>Outgo</h1>
|
||||||
<div style={{ overflowX: 'auto' }}>
|
<div className="table-wrapper">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th></th>
|
<th style={{ width: 32 }}></th>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Category</th>
|
<th>Category</th>
|
||||||
<th>Type</th>
|
<th>Type</th>
|
||||||
<th>Frequency</th>
|
<th>Frequency</th>
|
||||||
<th>Amount</th>
|
<th className="col-money">Amount</th>
|
||||||
<th>Monthly</th>
|
<th className="col-money">Monthly</th>
|
||||||
<th>Annually</th>
|
<th className="col-money">Annually</th>
|
||||||
<th>Monthly%</th>
|
<th className="col-pct">Monthly%</th>
|
||||||
<th>Payment Source</th>
|
<th>Payment Source</th>
|
||||||
<th>Notes</th>
|
<th>Notes</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
@@ -257,12 +402,25 @@ export function OutgoPage() {
|
|||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{addingRow && (
|
||||||
|
<NewOutgoRow
|
||||||
|
categories={categories}
|
||||||
|
paymentSources={paymentSources}
|
||||||
|
onAdd={handleAdd}
|
||||||
|
onCancel={() => setAddingRow(false)}
|
||||||
|
isPending={createOutgo.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={handleAdd} disabled={createOutgo.isPending}>+ Add Row</button>
|
{!addingRow && (
|
||||||
|
<button className="btn btn-secondary" onClick={() => setAddingRow(true)}>
|
||||||
|
+ Add Row
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,58 +62,65 @@ export function SettingsPage() {
|
|||||||
shareForm.reset({ email: '', permission: 'View' });
|
shareForm.reset({ email: '', permission: 'View' });
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!budget) return <div>Loading...</div>;
|
if (!budget) return <><BudgetNav /><div className="loading-text">Loading…</div></>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<BudgetNav />
|
<BudgetNav />
|
||||||
<h1>Settings</h1>
|
<h1>Settings</h1>
|
||||||
|
|
||||||
<section style={{ marginBottom: '2rem' }}>
|
<div className="card" style={{ maxWidth: '480px' }}>
|
||||||
<h2>Rename Budget</h2>
|
<div className="section-title">Rename Budget</div>
|
||||||
<form onSubmit={renameForm.handleSubmit(onRename)} style={{ display: 'flex', gap: '8px', alignItems: 'flex-start' }}>
|
<form onSubmit={renameForm.handleSubmit(onRename)}>
|
||||||
<div>
|
<div className="form-field" style={{ marginBottom: '0.75rem' }}>
|
||||||
<input {...renameForm.register('name')} />
|
<label htmlFor="budget-rename">Budget name</label>
|
||||||
|
<input id="budget-rename" {...renameForm.register('name')} />
|
||||||
{renameForm.formState.errors.name && (
|
{renameForm.formState.errors.name && (
|
||||||
<span style={{ color: 'red', display: 'block', fontSize: '0.85rem' }}>
|
<span className="field-error">{renameForm.formState.errors.name.message}</span>
|
||||||
{renameForm.formState.errors.name.message}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" disabled={renameForm.formState.isSubmitting || updateBudget.isPending}>Save</button>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section style={{ marginBottom: '2rem' }}>
|
|
||||||
<h2>Effective Tax Rate</h2>
|
|
||||||
<form onSubmit={taxForm.handleSubmit(onSaveTaxRate)}>
|
|
||||||
<label>
|
|
||||||
Rate (%){' '}
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
max="99"
|
|
||||||
{...taxForm.register('effectiveTaxRate', { valueAsNumber: true })}
|
|
||||||
style={{ width: '60px' }}
|
|
||||||
/>
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={taxForm.formState.isSubmitting || updateTaxRate.isPending}
|
className="btn btn-primary btn-sm"
|
||||||
style={{ marginLeft: '8px' }}
|
disabled={renameForm.formState.isSubmitting || updateBudget.isPending}
|
||||||
>
|
>
|
||||||
Save
|
Save
|
||||||
</button>
|
</button>
|
||||||
</label>
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card" style={{ maxWidth: '480px' }}>
|
||||||
|
<div className="section-title">Effective Tax Rate</div>
|
||||||
|
<form onSubmit={taxForm.handleSubmit(onSaveTaxRate)}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.5rem' }}>
|
||||||
|
<label htmlFor="tax-rate-settings">Rate (%)</label>
|
||||||
|
<input
|
||||||
|
id="tax-rate-settings"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="99"
|
||||||
|
style={{ width: '72px' }}
|
||||||
|
{...taxForm.register('effectiveTaxRate', { valueAsNumber: true })}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
disabled={taxForm.formState.isSubmitting || updateTaxRate.isPending}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{taxForm.formState.errors.effectiveTaxRate && (
|
{taxForm.formState.errors.effectiveTaxRate && (
|
||||||
<span style={{ color: 'red', display: 'block', fontSize: '0.85rem' }}>
|
<span className="field-error">{taxForm.formState.errors.effectiveTaxRate.message}</span>
|
||||||
{taxForm.formState.errors.effectiveTaxRate.message}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</div>
|
||||||
|
|
||||||
<section>
|
<div className="card">
|
||||||
<h2>Sharing</h2>
|
<div className="section-title">Sharing</div>
|
||||||
|
|
||||||
|
{shares.length > 0 && (
|
||||||
|
<div className="table-wrapper" style={{ marginBottom: '1rem' }}>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -136,32 +143,49 @@ export function SettingsPage() {
|
|||||||
<option value="Edit">Edit</option>
|
<option value="Edit">Edit</option>
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
<td>{s.isPending ? <em>Pending</em> : 'Active'}</td>
|
<td>
|
||||||
<td><button onClick={() => revokeShare.mutate(s.id)}>Revoke</button></td>
|
{s.isPending
|
||||||
|
? <span className="badge" style={{ background: 'var(--amber-100)', color: '#92400e' }}>Pending</span>
|
||||||
|
: <span className="badge badge-save">Active</span>}
|
||||||
|
</td>
|
||||||
|
<td className="col-actions">
|
||||||
|
<button className="btn btn-danger btn-sm" onClick={() => revokeShare.mutate(s.id)}>Revoke</button>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<form
|
<form onSubmit={shareForm.handleSubmit(onAddShare)}>
|
||||||
onSubmit={shareForm.handleSubmit(onAddShare)}
|
<div className="form-row" style={{ flexWrap: 'wrap' }}>
|
||||||
style={{ marginTop: '1rem', display: 'flex', gap: '8px', alignItems: 'flex-start' }}
|
<div className="form-field" style={{ flex: '1 1 200px' }}>
|
||||||
>
|
<label htmlFor="share-email">Email address</label>
|
||||||
<div>
|
<input id="share-email" type="email" placeholder="user@example.com" {...shareForm.register('email')} />
|
||||||
<input placeholder="Email address" {...shareForm.register('email')} />
|
|
||||||
{shareForm.formState.errors.email && (
|
{shareForm.formState.errors.email && (
|
||||||
<span style={{ color: 'red', display: 'block', fontSize: '0.85rem' }}>
|
<span className="field-error">{shareForm.formState.errors.email.message}</span>
|
||||||
{shareForm.formState.errors.email.message}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<select {...shareForm.register('permission')}>
|
<div className="form-field">
|
||||||
|
<label htmlFor="share-permission">Permission</label>
|
||||||
|
<select id="share-permission" {...shareForm.register('permission')}>
|
||||||
<option value="View">View</option>
|
<option value="View">View</option>
|
||||||
<option value="Edit">Edit</option>
|
<option value="Edit">Edit</option>
|
||||||
</select>
|
</select>
|
||||||
<button type="submit" disabled={shareForm.formState.isSubmitting || addShare.isPending}>Add Share</button>
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-end' }}>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={shareForm.formState.isSubmitting || addShare.isPending}
|
||||||
|
>
|
||||||
|
Add Share
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ import { updateTaxRateSchema, type UpdateTaxRateInput } from '../schemas/index';
|
|||||||
|
|
||||||
const fmt = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
|
const fmt = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
|
||||||
|
|
||||||
|
const TYPE_BADGE: Record<string, string> = {
|
||||||
|
Need: 'badge badge-need',
|
||||||
|
Want: 'badge badge-want',
|
||||||
|
Save: 'badge badge-save',
|
||||||
|
};
|
||||||
|
|
||||||
export function SummaryPage() {
|
export function SummaryPage() {
|
||||||
const { id: budgetId } = useParams<{ id: string }>();
|
const { id: budgetId } = useParams<{ id: string }>();
|
||||||
const { data: summary } = useSummary(budgetId!);
|
const { data: summary } = useSummary(budgetId!);
|
||||||
@@ -29,87 +35,92 @@ export function SummaryPage() {
|
|||||||
await updateTaxRate.mutateAsync(data.effectiveTaxRate / 100);
|
await updateTaxRate.mutateAsync(data.effectiveTaxRate / 100);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!summary) return <div>Loading...</div>;
|
if (!summary) return <><BudgetNav /><div className="loading-text">Loading…</div></>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<BudgetNav />
|
<BudgetNav />
|
||||||
<h1>Summary</h1>
|
<h1>Summary</h1>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '2rem', marginBottom: '1.5rem' }}>
|
<div className="summary-stats">
|
||||||
<div>
|
<div className="summary-stat">
|
||||||
<div style={{ fontSize: '0.85rem', color: '#666' }}>Monthly Income</div>
|
<div className="summary-stat-label">Monthly Income</div>
|
||||||
<div style={{ fontSize: '1.5rem', fontWeight: 'bold' }}>
|
<div className="summary-stat-value"><MoneyDisplay value={summary.monthlyIncome} /></div>
|
||||||
<MoneyDisplay value={summary.monthlyIncome} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: '0.85rem', color: '#666' }}>Annual Income</div>
|
|
||||||
<div style={{ fontSize: '1.5rem', fontWeight: 'bold' }}>
|
|
||||||
<MoneyDisplay value={summary.annualIncome} />
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="summary-stat">
|
||||||
|
<div className="summary-stat-label">Annual Income</div>
|
||||||
|
<div className="summary-stat-value"><MoneyDisplay value={summary.annualIncome} /></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="table-wrapper">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Type</th>
|
<th>Type</th>
|
||||||
<th>Monthly Total</th>
|
<th className="col-money">Monthly Total</th>
|
||||||
<th>Annual Total</th>
|
<th className="col-money">Annual Total</th>
|
||||||
<th>% of Income</th>
|
<th className="col-pct">% of Income</th>
|
||||||
<th>Max Amount (target%)</th>
|
<th className="col-money">Max (target%)</th>
|
||||||
<th>Remaining</th>
|
<th className="col-money">Remaining</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{summary.breakdown.map(row => (
|
{summary.breakdown.map(row => (
|
||||||
<tr key={row.type}>
|
<tr key={row.type}>
|
||||||
<td>
|
<td>
|
||||||
|
<span className={TYPE_BADGE[row.type] ?? 'badge'}>
|
||||||
{row.type}
|
{row.type}
|
||||||
{row.targetPercent != null && (
|
{row.targetPercent != null && ` (${row.targetPercent}%)`}
|
||||||
<span title={`Target: ${row.targetPercent}%`} style={{ marginLeft: '4px', cursor: 'help', color: '#888' }}>ⓘ</span>
|
</span>
|
||||||
)}
|
</td>
|
||||||
|
<td className="col-money"><MoneyDisplay value={row.total} /></td>
|
||||||
|
<td className="col-money"><MoneyDisplay value={row.annually} /></td>
|
||||||
|
<td className="col-pct">{row.percent.toFixed(1)}%</td>
|
||||||
|
<td className="col-money">{row.maxAmount != null ? fmt.format(row.maxAmount) : '—'}</td>
|
||||||
|
<td className="col-money" style={{ color: row.remaining != null && row.remaining < 0 ? 'var(--color-negative)' : undefined }}>
|
||||||
|
{row.remaining != null ? fmt.format(row.remaining) : '—'}
|
||||||
</td>
|
</td>
|
||||||
<td><MoneyDisplay value={row.total} /></td>
|
|
||||||
<td><MoneyDisplay value={row.annually} /></td>
|
|
||||||
<td>{row.percent.toFixed(1)}%</td>
|
|
||||||
<td>{row.maxAmount != null ? fmt.format(row.maxAmount) : '—'}</td>
|
|
||||||
<td>{row.remaining != null ? fmt.format(row.remaining) : '—'}</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ marginTop: '2rem' }}>
|
<div className="card" style={{ maxWidth: '480px' }}>
|
||||||
<h2>Pre-Tax Income</h2>
|
<div className="section-title">Pre-Tax Income</div>
|
||||||
<form onSubmit={handleSubmit(onSaveTaxRate)}>
|
<form onSubmit={handleSubmit(onSaveTaxRate)} style={{ marginBottom: '0.875rem' }}>
|
||||||
<label>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||||
Effective Tax Rate (%){' '}
|
<label htmlFor="tax-rate">Effective Tax Rate (%)</label>
|
||||||
<input
|
<input
|
||||||
|
id="tax-rate"
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
max="99"
|
max="99"
|
||||||
|
style={{ width: '72px' }}
|
||||||
{...register('effectiveTaxRate', { valueAsNumber: true })}
|
{...register('effectiveTaxRate', { valueAsNumber: true })}
|
||||||
style={{ width: '60px' }}
|
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
disabled={isSubmitting || updateTaxRate.isPending}
|
disabled={isSubmitting || updateTaxRate.isPending}
|
||||||
style={{ marginLeft: '8px' }}
|
|
||||||
>
|
>
|
||||||
{updateTaxRate.isPending ? 'Saving…' : 'Save'}
|
{updateTaxRate.isPending ? 'Saving…' : 'Save'}
|
||||||
</button>
|
</button>
|
||||||
</label>
|
</div>
|
||||||
{errors.effectiveTaxRate && (
|
{errors.effectiveTaxRate && (
|
||||||
<span style={{ color: 'red', display: 'block', fontSize: '0.85rem' }}>
|
<span className="field-error">{errors.effectiveTaxRate.message}</span>
|
||||||
{errors.effectiveTaxRate.message}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
<div style={{ marginTop: '0.75rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem', fontSize: '0.9rem' }}>
|
||||||
<div>Minimum Annual Gross: <strong><MoneyDisplay value={summary.preTaxIncome.minimumAnnualGross} /></strong></div>
|
<div>
|
||||||
<div>Minimum Monthly Gross: <strong><MoneyDisplay value={summary.preTaxIncome.minimumMonthlyGross} /></strong></div>
|
<span style={{ color: 'var(--color-text-muted)' }}>Minimum Annual Gross: </span>
|
||||||
|
<strong className="font-mono"><MoneyDisplay value={summary.preTaxIncome.minimumAnnualGross} /></strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span style={{ color: 'var(--color-text-muted)' }}>Minimum Monthly Gross: </span>
|
||||||
|
<strong className="font-mono"><MoneyDisplay value={summary.preTaxIncome.minimumMonthlyGross} /></strong>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Frequency } from '../types';
|
||||||
|
|
||||||
|
const MONTHLY_FACTOR: Record<Frequency, number> = {
|
||||||
|
Biennial: 1 / 24,
|
||||||
|
Annually: 1 / 12,
|
||||||
|
Biannually: 2 / 12,
|
||||||
|
Quarterly: 4 / 12,
|
||||||
|
EveryTwoMonths: 6 / 12,
|
||||||
|
Monthly: 1,
|
||||||
|
SemiMonthly: 2,
|
||||||
|
Biweekly: 26 / 12,
|
||||||
|
Weekly: 52 / 12,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function toMonthly(amount: number, frequency: Frequency): number {
|
||||||
|
return amount * (MONTHLY_FACTOR[frequency] ?? 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toAnnually(amount: number, frequency: Frequency): number {
|
||||||
|
return toMonthly(amount, frequency) * 12;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user