From 665062f0b5683b57ef2a082cf318555bcf70225d Mon Sep 17 00:00:00 2001 From: Spencer Twaddle <7374698+stwaddle@users.noreply.github.com> Date: Sun, 3 May 2026 06:15:29 -0500 Subject: [PATCH] Updated styling and fixed add row functionality --- .../Controllers/BudgetsController.cs | 2 +- .../Controllers/IncomesController.cs | 2 +- .../Controllers/OutgosController.cs | 2 +- .../Controllers/SharesController.cs | 2 +- .../Controllers/SummaryController.cs | 2 +- src/Budget.Api/Program.cs | 5 +- src/Budget.Client/src/App.css | 185 +----- src/Budget.Client/src/App.tsx | 23 +- src/Budget.Client/src/auth/AuthGuard.tsx | 16 +- .../src/components/BudgetNav.tsx | 45 +- src/Budget.Client/src/components/Header.tsx | 38 ++ src/Budget.Client/src/components/Layout.tsx | 11 + .../src/components/LoadingSkeleton.tsx | 41 +- src/Budget.Client/src/index.css | 609 +++++++++++++++--- src/Budget.Client/src/pages/BudgetsPage.tsx | 51 +- src/Budget.Client/src/pages/CallbackPage.tsx | 6 +- src/Budget.Client/src/pages/IncomePage.tsx | 192 ++++-- src/Budget.Client/src/pages/OutgoPage.tsx | 232 +++++-- src/Budget.Client/src/pages/SettingsPage.tsx | 162 +++-- src/Budget.Client/src/pages/SummaryPage.tsx | 119 ++-- src/Budget.Client/src/utils/frequency.ts | 21 + 21 files changed, 1220 insertions(+), 546 deletions(-) create mode 100644 src/Budget.Client/src/components/Header.tsx create mode 100644 src/Budget.Client/src/components/Layout.tsx create mode 100644 src/Budget.Client/src/utils/frequency.ts diff --git a/src/Budget.Api/Controllers/BudgetsController.cs b/src/Budget.Api/Controllers/BudgetsController.cs index d71210d..89d6340 100644 --- a/src/Budget.Api/Controllers/BudgetsController.cs +++ b/src/Budget.Api/Controllers/BudgetsController.cs @@ -13,7 +13,7 @@ namespace Budget.Api.Controllers; [ApiController] [Route("api/[controller]")] -[Authorize] +[Authorize(Roles = "admin,user")] public class BudgetsController(AppDbContext db, BudgetAuthorizationService authz) : ControllerBase { private IActionResult? TryGetUserId(out string userId) diff --git a/src/Budget.Api/Controllers/IncomesController.cs b/src/Budget.Api/Controllers/IncomesController.cs index 62f18fd..698aa17 100644 --- a/src/Budget.Api/Controllers/IncomesController.cs +++ b/src/Budget.Api/Controllers/IncomesController.cs @@ -13,7 +13,7 @@ namespace Budget.Api.Controllers; [ApiController] [Route("api/budgets/{budgetId:guid}/incomes")] -[Authorize] +[Authorize(Roles = "admin,user")] public class IncomesController(AppDbContext db, BudgetAuthorizationService authz) : ControllerBase { private IActionResult? TryGetUserId(out string userId) diff --git a/src/Budget.Api/Controllers/OutgosController.cs b/src/Budget.Api/Controllers/OutgosController.cs index 8481096..2813af4 100644 --- a/src/Budget.Api/Controllers/OutgosController.cs +++ b/src/Budget.Api/Controllers/OutgosController.cs @@ -13,7 +13,7 @@ namespace Budget.Api.Controllers; [ApiController] [Route("api/budgets/{budgetId:guid}/outgos")] -[Authorize] +[Authorize(Roles = "admin,user")] public class OutgosController(AppDbContext db, BudgetAuthorizationService authz) : ControllerBase { private IActionResult? TryGetUserId(out string userId) diff --git a/src/Budget.Api/Controllers/SharesController.cs b/src/Budget.Api/Controllers/SharesController.cs index d5bc7ff..0486263 100644 --- a/src/Budget.Api/Controllers/SharesController.cs +++ b/src/Budget.Api/Controllers/SharesController.cs @@ -12,7 +12,7 @@ namespace Budget.Api.Controllers; [ApiController] [Route("api/budgets/{budgetId:guid}/shares")] -[Authorize] +[Authorize(Roles = "admin,user")] public class SharesController(AppDbContext db, BudgetAuthorizationService authz) : ControllerBase { private IActionResult? TryGetUserId(out string userId) diff --git a/src/Budget.Api/Controllers/SummaryController.cs b/src/Budget.Api/Controllers/SummaryController.cs index 26c2c88..95f2f67 100644 --- a/src/Budget.Api/Controllers/SummaryController.cs +++ b/src/Budget.Api/Controllers/SummaryController.cs @@ -13,7 +13,7 @@ namespace Budget.Api.Controllers; [ApiController] [Route("api/budgets/{budgetId:guid}/summary")] -[Authorize] +[Authorize(Roles = "admin,user")] public class SummaryController(AppDbContext db, BudgetAuthorizationService authz) : ControllerBase { private IActionResult? TryGetUserId(out string userId) diff --git a/src/Budget.Api/Program.cs b/src/Budget.Api/Program.cs index 168867c..299c82e 100644 --- a/src/Budget.Api/Program.cs +++ b/src/Budget.Api/Program.cs @@ -87,7 +87,10 @@ builder.Services.AddRateLimiter(options => builder.Services.AddAuthorization(); builder.Services.AddScoped(); -builder.Services.AddControllers(); +builder.Services.AddControllers() + .AddJsonOptions(opts => + opts.JsonSerializerOptions.Converters.Add( + new System.Text.Json.Serialization.JsonStringEnumConverter())); builder.Services.AddHealthChecks() .AddDbContextCheck(); diff --git a/src/Budget.Client/src/App.css b/src/Budget.Client/src/App.css index f90339d..05a8d7d 100644 --- a/src/Budget.Client/src/App.css +++ b/src/Budget.Client/src/App.css @@ -1,184 +1 @@ -.counter { - 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); - } -} +/* App-level overrides (intentionally minimal — see index.css for the design system) */ diff --git a/src/Budget.Client/src/App.tsx b/src/Budget.Client/src/App.tsx index 0d33899..5386e04 100644 --- a/src/Budget.Client/src/App.tsx +++ b/src/Budget.Client/src/App.tsx @@ -8,6 +8,7 @@ import { AuthGuard } from './auth/AuthGuard'; import { ErrorBoundary } from './components/ErrorBoundary'; import { ToastProvider } from './components/Toast'; import { QueryToastBridge } from './components/QueryToastBridge'; +import { Layout } from './components/Layout'; import { CallbackPage } from './pages/CallbackPage'; import { BudgetsPage } from './pages/BudgetsPage'; import { IncomePage } from './pages/IncomePage'; @@ -17,7 +18,7 @@ import { SettingsPage } from './pages/SettingsPage'; const onSigninCallback = () => { if (!window.location.search.includes('error')) { - window.history.replaceState({}, '', '/'); + window.location.replace('/'); } }; @@ -30,15 +31,17 @@ function App() { - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + diff --git a/src/Budget.Client/src/auth/AuthGuard.tsx b/src/Budget.Client/src/auth/AuthGuard.tsx index 255b8e1..e3916f6 100644 --- a/src/Budget.Client/src/auth/AuthGuard.tsx +++ b/src/Budget.Client/src/auth/AuthGuard.tsx @@ -4,11 +4,21 @@ import { useAuth } from 'react-oidc-context'; export function AuthGuard({ children }: { children: ReactNode }) { const auth = useAuth(); - if (auth.isLoading) return
Loading...
; + if (auth.isLoading) { + return
Loading…
; + } if (!auth.isAuthenticated) { - auth.signinRedirect(); - return null; + return ( +
+ +

Budget

+

Sign in to view and manage your personal budgets.

+ +
+ ); } return <>{children}; diff --git a/src/Budget.Client/src/components/BudgetNav.tsx b/src/Budget.Client/src/components/BudgetNav.tsx index e1b32af..404c9f0 100644 --- a/src/Budget.Client/src/components/BudgetNav.tsx +++ b/src/Budget.Client/src/components/BudgetNav.tsx @@ -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() { const { id } = useParams<{ id: string }>(); const base = `/budgets/${id}`; + const { data: budget } = useBudget(id!); + return ( - +
+
+ + ← My Budgets + + {budget && {budget.name}} +
+ +
); } diff --git a/src/Budget.Client/src/components/Header.tsx b/src/Budget.Client/src/components/Header.tsx new file mode 100644 index 0000000..9cc6020 --- /dev/null +++ b/src/Budget.Client/src/components/Header.tsx @@ -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 ( +
+
+ + + Budget + +
+ {auth.isAuthenticated ? ( + <> + {name && {name}} + + + ) : ( + + )} +
+
+
+ ); +} diff --git a/src/Budget.Client/src/components/Layout.tsx b/src/Budget.Client/src/components/Layout.tsx new file mode 100644 index 0000000..2b6e0c1 --- /dev/null +++ b/src/Budget.Client/src/components/Layout.tsx @@ -0,0 +1,11 @@ +import type { ReactNode } from 'react'; +import { Header } from './Header'; + +export function Layout({ children }: { children: ReactNode }) { + return ( + <> +
+
{children}
+ + ); +} diff --git a/src/Budget.Client/src/components/LoadingSkeleton.tsx b/src/Budget.Client/src/components/LoadingSkeleton.tsx index ba4c359..aaffb4f 100644 --- a/src/Budget.Client/src/components/LoadingSkeleton.tsx +++ b/src/Budget.Client/src/components/LoadingSkeleton.tsx @@ -2,25 +2,26 @@ interface Props { rows?: number; cols?: number; } export function LoadingSkeleton({ rows = 5, cols = 4 }: Props) { return ( - - - {Array.from({ length: rows }).map((_, r) => ( - - {Array.from({ length: cols }).map((_, c) => ( - - ))} - - ))} - -
-
-
+
+ + + {Array.from({ length: rows }).map((_, r) => ( + + {Array.from({ length: cols }).map((_, c) => ( + + ))} + + ))} + +
+
+
+
); } diff --git a/src/Budget.Client/src/index.css b/src/Budget.Client/src/index.css index 5fb3313..74e1ae5 100644 --- a/src/Budget.Client/src/index.css +++ b/src/Budget.Client/src/index.css @@ -1,111 +1,540 @@ :root { - --text: #6b6375; - --text-h: #08060d; - --bg: #fff; - --border: #e5e4e7; - --code-bg: #f4f3ec; - --accent: #aa3bff; - --accent-bg: rgba(170, 59, 255, 0.1); - --accent-border: rgba(170, 59, 255, 0.5); - --social-bg: rgba(244, 243, 236, 0.5); - --shadow: - rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; + /* Green palette */ + --green-950: #052e16; + --green-900: #14532d; + --green-800: #166534; + --green-700: #15803d; + --green-600: #16a34a; + --green-500: #22c55e; + --green-300: #86efac; + --green-200: #bbf7d0; + --green-100: #dcfce7; + --green-50: #f0fdf4; - --sans: system-ui, 'Segoe UI', Roboto, sans-serif; - --heading: system-ui, 'Segoe UI', Roboto, sans-serif; - --mono: ui-monospace, Consolas, monospace; + --red-600: #dc2626; + --red-100: #fee2e2; + --red-50: #fef2f2; - font: 18px/145% var(--sans); - letter-spacing: 0.18px; - color-scheme: light dark; - color: var(--text); - background: var(--bg); - font-synthesis: none; - text-rendering: optimizeLegibility; + --amber-100: #fef3c7; + + /* Semantic tokens */ + --color-primary: var(--green-700); + --color-primary-hover: var(--green-800); + --color-primary-light: var(--green-50); + --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; -moz-osx-font-smoothing: grayscale; - - @media (max-width: 1024px) { - font-size: 16px; - } } -@media (prefers-color-scheme: dark) { - :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; -} +*, *::before, *::after { box-sizing: border-box; } body { margin: 0; + background: var(--color-bg-subtle); } -h1, -h2 { - font-family: var(--heading); - font-weight: 500; - color: var(--text-h); +#root { + min-height: 100svh; + display: flex; + flex-direction: column; } -h1 { - font-size: 56px; - 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 { +/* ---- Typography ---- */ +h1, h2, h3, h4 { 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, -.counter { - font-family: var(--mono); +/* ---- Header ---- */ +.app-header { + 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; - border-radius: 4px; - color: var(--text-h); + align-items: center; + 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 { - font-size: 15px; - line-height: 135%; - padding: 4px 8px; - background: var(--code-bg); +.btn-secondary { + background: #ffffff; + color: var(--color-primary); + border-color: var(--color-border-dark); +} +.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; } } diff --git a/src/Budget.Client/src/pages/BudgetsPage.tsx b/src/Budget.Client/src/pages/BudgetsPage.tsx index aab0134..734b387 100644 --- a/src/Budget.Client/src/pages/BudgetsPage.tsx +++ b/src/Budget.Client/src/pages/BudgetsPage.tsx @@ -22,25 +22,40 @@ export function BudgetsPage() { return (

My Budgets

- {budgets.length === 0 &&

No budgets yet. Create one below.

} -
    - {budgets.map(b => ( -
  • - -
  • - ))} -
-
-
-
- - {errors.name && {errors.name.message}} -
- + + {budgets.length === 0 ? ( +
+

No budgets yet. Create one below to get started.

- + ) : ( +
    + {budgets.map(b => ( +
  • + +
  • + ))} +
+ )} + +
+
New Budget
+
+
+ + + {errors.name && {errors.name.message}} +
+ +
+
); } diff --git a/src/Budget.Client/src/pages/CallbackPage.tsx b/src/Budget.Client/src/pages/CallbackPage.tsx index 3b8f733..91dd7c6 100644 --- a/src/Budget.Client/src/pages/CallbackPage.tsx +++ b/src/Budget.Client/src/pages/CallbackPage.tsx @@ -7,12 +7,12 @@ export function CallbackPage() { if (error) { return ( -
+

Sign-in failed

-

{errorDescription ?? error}

+

{errorDescription ?? error}

); } - return
Signing in...
; + return
Signing in…
; } diff --git a/src/Budget.Client/src/pages/IncomePage.tsx b/src/Budget.Client/src/pages/IncomePage.tsx index d05a6fa..5178574 100644 --- a/src/Budget.Client/src/pages/IncomePage.tsx +++ b/src/Budget.Client/src/pages/IncomePage.tsx @@ -24,7 +24,89 @@ import { MoneyDisplay } from '../components/MoneyDisplay'; import { BudgetNav } from '../components/BudgetNav'; import { useIncomes, useCreateIncome, useUpdateIncome, useDeleteIncome, useReorderIncomes } from '../api/incomes'; 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; + onCancel: () => void; + isPending: boolean; +}) { + const { register, handleSubmit, control, watch, setFocus, formState: { errors, isSubmitting } } = useForm({ + 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 ( + + + + + {errors.name && {errors.name.message}} + + + ( + + )} + /> + + + + {errors.amount && {errors.amount.message}} + + + {preview ? fmt.format(preview.monthly) : '—'} + + + {preview ? fmt.format(preview.annually) : '—'} + + +
+ + +
+ + + ); +} + +// ---- Existing row (edit in place) ---- function SortableRow({ income, onSave, @@ -58,10 +140,10 @@ function SortableRow({ if (editing) { return ( - ⠿ + ⠿ - {errors.name && {errors.name.message}} + {errors.name && {errors.name.message}} - {errors.amount && {errors.amount.message}} + {errors.amount && {errors.amount.message}} - - - - - + — + — + +
+ + +
); @@ -88,20 +172,24 @@ function SortableRow({ return ( - ⠿ - {income.name} - {income.frequency} - - - - + ⠿ + {income.name} + {income.frequency} + + + + + + ); } +// ---- Page ---- export function IncomePage() { const { id: budgetId } = useParams<{ id: string }>(); const sensors = useSensors(useSensor(PointerSensor)); + const [addingRow, setAddingRow] = useState(false); const { data: incomes = [], isLoading } = useIncomes(budgetId!); const [displayItems, setDisplayItems] = useState([]); @@ -121,8 +209,9 @@ export function IncomePage() { deleteIncome.mutate(id); }; - const handleAdd = () => { - createIncome.mutate({ name: 'New Income', frequency: 'Monthly', amount: 0 }); + const handleAdd = async (data: CreateIncomeInput) => { + await createIncome.mutateAsync(data); + setAddingRow(false); }; const handleDragEnd = (event: DragEndEvent) => { @@ -141,34 +230,47 @@ export function IncomePage() {

Income

- - - - - - - - - - - - - - i.id)} strategy={verticalListSortingStrategy}> - - {displayItems.map(income => ( - - ))} - - - -
NameFrequencyAmountMonthlyAnnually
- +
+ + + + + + + + + + + + + + i.id)} strategy={verticalListSortingStrategy}> + + {displayItems.map(income => ( + + ))} + {addingRow && ( + setAddingRow(false)} + isPending={createIncome.isPending} + /> + )} + + + +
NameFrequencyAmountMonthlyAnnually
+
+ {!addingRow && ( + + )}
); } diff --git a/src/Budget.Client/src/pages/OutgoPage.tsx b/src/Budget.Client/src/pages/OutgoPage.tsx index 0bc7e0e..b4fe748 100644 --- a/src/Budget.Client/src/pages/OutgoPage.tsx +++ b/src/Budget.Client/src/pages/OutgoPage.tsx @@ -28,9 +28,145 @@ import { useCreateOutgo, useUpdateOutgo, useDeleteOutgo, useReorderOutgos, } from '../api/outgos'; 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 TYPE_BADGE: Record = { + 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; + onCancel: () => void; + isPending: boolean; +}) { + const { register, handleSubmit, control, watch, setFocus, formState: { errors, isSubmitting } } = useForm({ + 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 ( + + + + + {errors.name && {errors.name.message}} + + + ( + + )} + /> + + + + + + ( + + )} + /> + + + + {errors.amount && {errors.amount.message}} + + + {preview ? fmt.format(preview.monthly) : '—'} + + + {preview ? fmt.format(preview.annually) : '—'} + + — + + ( + + )} + /> + + + + + +
+ + +
+ + + ); +} + +// ---- Existing row (edit in place) ---- function SortableRow({ outgo, categories, @@ -84,10 +220,10 @@ function SortableRow({ if (editing) { return ( - ⠿ + ⠿ - {errors.name && {errors.name.message}} + {errors.name && {errors.name.message}} - {errors.amount && {errors.amount.message}} + {errors.amount && {errors.amount.message}} - - - + — + — + — - - - + +
+ + +
); @@ -149,25 +287,31 @@ function SortableRow({ return ( - ⠿ - {outgo.name} - {outgo.category} - {outgo.type} - {outgo.frequency} - - - - {outgo.monthlyPercent.toFixed(1)}% - {outgo.paymentSource} - {outgo.notes} - + ⠿ + {outgo.name} + {outgo.category} + + {outgo.type} + + {outgo.frequency} + + + + {outgo.monthlyPercent.toFixed(1)}% + {outgo.paymentSource} + {outgo.notes} + + + ); } +// ---- Page ---- export function OutgoPage() { const { id: budgetId } = useParams<{ id: string }>(); const sensors = useSensors(useSensor(PointerSensor)); + const [addingRow, setAddingRow] = useState(false); const { data: outgos = [], isLoading } = useOutgos(budgetId!); const { data: categories = [] } = useCategories(budgetId!); @@ -198,16 +342,17 @@ export function OutgoPage() { deleteOutgo.mutate(id); }; - const handleAdd = () => { - createOutgo.mutate({ - name: 'New Outgo', - category: null, - type: 'Need', - frequency: 'Monthly', - amount: 0, - paymentSource: null, - notes: null, + const handleAdd = async (data: CreateOutgoInput) => { + await createOutgo.mutateAsync({ + name: data.name, + category: data.category || null, + type: data.type, + frequency: data.frequency, + amount: data.amount, + paymentSource: data.paymentSource || null, + notes: data.notes || null, }); + setAddingRow(false); }; const handleDragEnd = (event: DragEndEvent) => { @@ -226,19 +371,19 @@ export function OutgoPage() {

Outgo

-
+
- + - - - - + + + + @@ -257,12 +402,25 @@ export function OutgoPage() { onDelete={handleDelete} /> ))} + {addingRow && ( + setAddingRow(false)} + isPending={createOutgo.isPending} + /> + )}
Name Category Type FrequencyAmountMonthlyAnnuallyMonthly%AmountMonthlyAnnuallyMonthly% Payment Source Notes
- + {!addingRow && ( + + )}
); } diff --git a/src/Budget.Client/src/pages/SettingsPage.tsx b/src/Budget.Client/src/pages/SettingsPage.tsx index c5cf326..c480b70 100644 --- a/src/Budget.Client/src/pages/SettingsPage.tsx +++ b/src/Budget.Client/src/pages/SettingsPage.tsx @@ -62,106 +62,130 @@ export function SettingsPage() { shareForm.reset({ email: '', permission: 'View' }); }; - if (!budget) return
Loading...
; + if (!budget) return <>
Loading…
; return (

Settings

-
-

Rename Budget

-
-
- +
+
Rename Budget
+ +
+ + {renameForm.formState.errors.name && ( - - {renameForm.formState.errors.name.message} - + {renameForm.formState.errors.name.message} )}
- + -
+
-
-

Effective Tax Rate

+
+
Effective Tax Rate
-
+
-
-

Sharing

- - - - - - - - - - - {shares.map(s => ( - - - - - - - ))} - -
EmailPermissionStatus
{s.sharedWithEmail} - - {s.isPending ? Pending : 'Active'}
+
+
Sharing
-
-
- - {shareForm.formState.errors.email && ( - - {shareForm.formState.errors.email.message} - - )} + {shares.length > 0 && ( +
+ + + + + + + + + + + {shares.map(s => ( + + + + + + + ))} + +
EmailPermissionStatus
{s.sharedWithEmail} + + + {s.isPending + ? Pending + : Active} + + +
+
+ )} + + +
+
+ + + {shareForm.formState.errors.email && ( + {shareForm.formState.errors.email.message} + )} +
+
+ + +
+
+ +
- - -
+
); } diff --git a/src/Budget.Client/src/pages/SummaryPage.tsx b/src/Budget.Client/src/pages/SummaryPage.tsx index 4c29746..7c8a6d2 100644 --- a/src/Budget.Client/src/pages/SummaryPage.tsx +++ b/src/Budget.Client/src/pages/SummaryPage.tsx @@ -9,6 +9,12 @@ import { updateTaxRateSchema, type UpdateTaxRateInput } from '../schemas/index'; const fmt = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }); +const TYPE_BADGE: Record = { + Need: 'badge badge-need', + Want: 'badge badge-want', + Save: 'badge badge-save', +}; + export function SummaryPage() { const { id: budgetId } = useParams<{ id: string }>(); const { data: summary } = useSummary(budgetId!); @@ -29,87 +35,92 @@ export function SummaryPage() { await updateTaxRate.mutateAsync(data.effectiveTaxRate / 100); }; - if (!summary) return
Loading...
; + if (!summary) return <>
Loading…
; return (

Summary

-
-
-
Monthly Income
-
- -
+
+
+
Monthly Income
+
-
-
Annual Income
-
- -
+
+
Annual Income
+
- - - - - - - - - - - - - {summary.breakdown.map(row => ( - - - - - - - +
+
TypeMonthly TotalAnnual Total% of IncomeMax Amount (target%)Remaining
- {row.type} - {row.targetPercent != null && ( - - )} - {row.percent.toFixed(1)}%{row.maxAmount != null ? fmt.format(row.maxAmount) : '—'}{row.remaining != null ? fmt.format(row.remaining) : '—'}
+ + + + + + + + - ))} - -
TypeMonthly TotalAnnual Total% of IncomeMax (target%)Remaining
+ + + {summary.breakdown.map(row => ( + + + + {row.type} + {row.targetPercent != null && ` (${row.targetPercent}%)`} + + + + + {row.percent.toFixed(1)}% + {row.maxAmount != null ? fmt.format(row.maxAmount) : '—'} + + {row.remaining != null ? fmt.format(row.remaining) : '—'} + + + ))} + + +
-
-

Pre-Tax Income

-
-