Updated styling and fixed add row functionality

This commit is contained in:
Spencer Twaddle
2026-05-03 06:15:29 -05:00
parent bc9f55ef91
commit 665062f0b5
21 changed files with 1220 additions and 546 deletions
@@ -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)
+4 -1
View File
@@ -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
View File
@@ -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);
}
}
+13 -10
View File
@@ -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,15 +31,17 @@ function App() {
<AuthProvider {...authConfig} onSigninCallback={onSigninCallback}> <AuthProvider {...authConfig} onSigninCallback={onSigninCallback}>
<TokenSync /> <TokenSync />
<BrowserRouter> <BrowserRouter>
<Routes> <Layout>
<Route path="/" element={<Navigate to="/budgets" replace />} /> <Routes>
<Route path="/callback" element={<CallbackPage />} /> <Route path="/" element={<Navigate to="/budgets" replace />} />
<Route path="/budgets" element={<AuthGuard><BudgetsPage /></AuthGuard>} /> <Route path="/callback" element={<CallbackPage />} />
<Route path="/budgets/:id/income" element={<AuthGuard><IncomePage /></AuthGuard>} /> <Route path="/budgets" element={<AuthGuard><BudgetsPage /></AuthGuard>} />
<Route path="/budgets/:id/outgo" element={<AuthGuard><OutgoPage /></AuthGuard>} /> <Route path="/budgets/:id/income" element={<AuthGuard><IncomePage /></AuthGuard>} />
<Route path="/budgets/:id/summary" element={<AuthGuard><SummaryPage /></AuthGuard>} /> <Route path="/budgets/:id/outgo" element={<AuthGuard><OutgoPage /></AuthGuard>} />
<Route path="/budgets/:id/settings" element={<AuthGuard><SettingsPage /></AuthGuard>} /> <Route path="/budgets/:id/summary" element={<AuthGuard><SummaryPage /></AuthGuard>} />
</Routes> <Route path="/budgets/:id/settings" element={<AuthGuard><SettingsPage /></AuthGuard>} />
</Routes>
</Layout>
</BrowserRouter> </BrowserRouter>
</AuthProvider> </AuthProvider>
</ToastProvider> </ToastProvider>
+13 -3
View File
@@ -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}</>;
+38 -7
View File
@@ -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>
</nav> {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>
</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 (
<table> <div className="table-wrapper">
<tbody> <table>
{Array.from({ length: rows }).map((_, r) => ( <tbody>
<tr key={r}> {Array.from({ length: rows }).map((_, r) => (
{Array.from({ length: cols }).map((_, c) => ( <tr key={r}>
<td key={c}> {Array.from({ length: cols }).map((_, c) => (
<div style={{ <td key={c}>
height: '1em', <div
background: 'linear-gradient(90deg, #e5e7eb 25%, #f3f4f6 50%, #e5e7eb 75%)', className="skeleton"
backgroundSize: '200% 100%', style={{
animation: 'shimmer 1.5s infinite', height: '1em',
borderRadius: '4px', width: `${60 + (r * 7 + c * 13) % 30}%`,
width: `${60 + Math.random() * 30}%`, }}
}} /> />
</td> </td>
))} ))}
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div>
); );
} }
+519 -90
View File
@@ -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; }
} }
+33 -18
View File
@@ -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 ? (
{budgets.map(b => ( <div className="empty-state" style={{ padding: '2rem 0' }}>
<li key={b.id}> <p style={{ color: 'var(--color-text-muted)' }}>No budgets yet. Create one below to get started.</p>
<button onClick={() => navigate(`/budgets/${b.id}/income`)}>
{b.name}
</button>
</li>
))}
</ul>
<form onSubmit={handleSubmit(onSubmit)} style={{ marginTop: '1rem' }}>
<div style={{ display: 'flex', gap: '8px', alignItems: 'flex-start' }}>
<div>
<input placeholder="Budget name" {...register('name')} />
{errors.name && <span style={{ color: 'red', display: 'block', fontSize: '0.85rem' }}>{errors.name.message}</span>}
</div>
<button type="submit" disabled={isSubmitting || createBudget.isPending}>Create</button>
</div> </div>
</form> ) : (
<ul className="budget-list">
{budgets.map(b => (
<li key={b.id}>
<button
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>
</li>
))}
</ul>
)}
<div className="card" style={{ maxWidth: '400px' }}>
<div className="section-title">New Budget</div>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="form-field" style={{ marginBottom: '0.75rem' }}>
<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>
<button type="submit" className="btn btn-primary" disabled={isSubmitting || createBudget.isPending}>
Create Budget
</button>
</form>
</div>
</div> </div>
); );
} }
+3 -3
View File
@@ -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>;
} }
+147 -45
View File
@@ -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,34 +230,47 @@ export function IncomePage() {
<div> <div>
<BudgetNav /> <BudgetNav />
<h1>Income</h1> <h1>Income</h1>
<table> <div className="table-wrapper">
<thead> <table>
<tr> <thead>
<th></th> <tr>
<th>Name</th> <th style={{ width: 32 }}></th>
<th>Frequency</th> <th>Name</th>
<th>Amount</th> <th>Frequency</th>
<th>Monthly</th> <th className="col-money">Amount</th>
<th>Annually</th> <th className="col-money">Monthly</th>
<th></th> <th className="col-money">Annually</th>
</tr> <th></th>
</thead> </tr>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}> </thead>
<SortableContext items={displayItems.map(i => i.id)} strategy={verticalListSortingStrategy}> <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<tbody> <SortableContext items={displayItems.map(i => i.id)} strategy={verticalListSortingStrategy}>
{displayItems.map(income => ( <tbody>
<SortableRow {displayItems.map(income => (
key={income.id} <SortableRow
income={income} key={income.id}
onSave={handleSave} income={income}
onDelete={handleDelete} onSave={handleSave}
/> onDelete={handleDelete}
))} />
</tbody> ))}
</SortableContext> {addingRow && (
</DndContext> <NewIncomeRow
</table> onAdd={handleAdd}
<button onClick={handleAdd} disabled={createIncome.isPending}>+ Add Row</button> onCancel={() => setAddingRow(false)}
isPending={createIncome.isPending}
/>
)}
</tbody>
</SortableContext>
</DndContext>
</table>
</div>
{!addingRow && (
<button className="btn btn-secondary" onClick={() => setAddingRow(true)}>
+ Add Row
</button>
)}
</div> </div>
); );
} }
+195 -37
View File
@@ -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>
); );
} }
+93 -69
View File
@@ -62,106 +62,130 @@ 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> <button
type="submit"
className="btn btn-primary btn-sm"
disabled={renameForm.formState.isSubmitting || updateBudget.isPending}
>
Save
</button>
</form> </form>
</section> </div>
<section style={{ marginBottom: '2rem' }}> <div className="card" style={{ maxWidth: '480px' }}>
<h2>Effective Tax Rate</h2> <div className="section-title">Effective Tax Rate</div>
<form onSubmit={taxForm.handleSubmit(onSaveTaxRate)}> <form onSubmit={taxForm.handleSubmit(onSaveTaxRate)}>
<label> <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.5rem' }}>
Rate (%){' '} <label htmlFor="tax-rate-settings">Rate (%)</label>
<input <input
id="tax-rate-settings"
type="number" type="number"
min="0" min="0"
max="99" max="99"
style={{ width: '72px' }}
{...taxForm.register('effectiveTaxRate', { valueAsNumber: true })} {...taxForm.register('effectiveTaxRate', { valueAsNumber: true })}
style={{ width: '60px' }}
/> />
<button <button
type="submit" type="submit"
className="btn btn-primary btn-sm"
disabled={taxForm.formState.isSubmitting || updateTaxRate.isPending} disabled={taxForm.formState.isSubmitting || updateTaxRate.isPending}
style={{ marginLeft: '8px' }}
> >
Save Save
</button> </button>
</label> </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>
<table>
<thead>
<tr>
<th>Email</th>
<th>Permission</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{shares.map(s => (
<tr key={s.id}>
<td>{s.sharedWithEmail}</td>
<td>
<select
value={s.permission}
onChange={e => updateShare.mutate({ id: s.id, permission: e.target.value as SharePermission })}
>
<option value="View">View</option>
<option value="Edit">Edit</option>
</select>
</td>
<td>{s.isPending ? <em>Pending</em> : 'Active'}</td>
<td><button onClick={() => revokeShare.mutate(s.id)}>Revoke</button></td>
</tr>
))}
</tbody>
</table>
<form {shares.length > 0 && (
onSubmit={shareForm.handleSubmit(onAddShare)} <div className="table-wrapper" style={{ marginBottom: '1rem' }}>
style={{ marginTop: '1rem', display: 'flex', gap: '8px', alignItems: 'flex-start' }} <table>
> <thead>
<div> <tr>
<input placeholder="Email address" {...shareForm.register('email')} /> <th>Email</th>
{shareForm.formState.errors.email && ( <th>Permission</th>
<span style={{ color: 'red', display: 'block', fontSize: '0.85rem' }}> <th>Status</th>
{shareForm.formState.errors.email.message} <th></th>
</span> </tr>
)} </thead>
<tbody>
{shares.map(s => (
<tr key={s.id}>
<td>{s.sharedWithEmail}</td>
<td>
<select
value={s.permission}
onChange={e => updateShare.mutate({ id: s.id, permission: e.target.value as SharePermission })}
>
<option value="View">View</option>
<option value="Edit">Edit</option>
</select>
</td>
<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>
))}
</tbody>
</table>
</div>
)}
<form onSubmit={shareForm.handleSubmit(onAddShare)}>
<div className="form-row" style={{ flexWrap: 'wrap' }}>
<div className="form-field" style={{ flex: '1 1 200px' }}>
<label htmlFor="share-email">Email address</label>
<input id="share-email" type="email" placeholder="user@example.com" {...shareForm.register('email')} />
{shareForm.formState.errors.email && (
<span className="field-error">{shareForm.formState.errors.email.message}</span>
)}
</div>
<div className="form-field">
<label htmlFor="share-permission">Permission</label>
<select id="share-permission" {...shareForm.register('permission')}>
<option value="View">View</option>
<option value="Edit">Edit</option>
</select>
</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> </div>
<select {...shareForm.register('permission')}>
<option value="View">View</option>
<option value="Edit">Edit</option>
</select>
<button type="submit" disabled={shareForm.formState.isSubmitting || addShare.isPending}>Add Share</button>
</form> </form>
</section> </div>
</div> </div>
); );
} }
+65 -54
View File
@@ -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> <div className="summary-stat">
<div style={{ fontSize: '0.85rem', color: '#666' }}>Annual Income</div> <div className="summary-stat-label">Annual Income</div>
<div style={{ fontSize: '1.5rem', fontWeight: 'bold' }}> <div className="summary-stat-value"><MoneyDisplay value={summary.annualIncome} /></div>
<MoneyDisplay value={summary.annualIncome} />
</div>
</div> </div>
</div> </div>
<table> <div className="table-wrapper">
<thead> <table>
<tr> <thead>
<th>Type</th> <tr>
<th>Monthly Total</th> <th>Type</th>
<th>Annual Total</th> <th className="col-money">Monthly Total</th>
<th>% of Income</th> <th className="col-money">Annual Total</th>
<th>Max Amount (target%)</th> <th className="col-pct">% of Income</th>
<th>Remaining</th> <th className="col-money">Max (target%)</th>
</tr> <th className="col-money">Remaining</th>
</thead>
<tbody>
{summary.breakdown.map(row => (
<tr key={row.type}>
<td>
{row.type}
{row.targetPercent != null && (
<span title={`Target: ${row.targetPercent}%`} style={{ marginLeft: '4px', cursor: 'help', color: '#888' }}></span>
)}
</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>
))} </thead>
</tbody> <tbody>
</table> {summary.breakdown.map(row => (
<tr key={row.type}>
<td>
<span className={TYPE_BADGE[row.type] ?? 'badge'}>
{row.type}
{row.targetPercent != null && ` (${row.targetPercent}%)`}
</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>
</tr>
))}
</tbody>
</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>
+21
View File
@@ -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;
}