Add modern UI with light/dark mode

- CSS custom properties design system with full light/dark themes
- ThemeContext with localStorage persistence and system preference detection
- Theme toggle button in nav (moon/sun icon)
- Modern Inter font, card-based layout, sticky nav
- All pages restyled with CSS classes instead of inline styles

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-19 19:47:34 -04:00
parent 16ba4166f2
commit db541b4147
9 changed files with 1293 additions and 1274 deletions

View File

@@ -1,4 +1,5 @@
import { Routes, Route, NavLink } from 'react-router-dom'; import { Routes, Route, NavLink } from 'react-router-dom';
import { useTheme } from './ThemeContext.jsx';
import PaycheckView from './pages/PaycheckView.jsx'; import PaycheckView from './pages/PaycheckView.jsx';
import Bills from './pages/Bills.jsx'; import Bills from './pages/Bills.jsx';
import Settings from './pages/Settings.jsx'; import Settings from './pages/Settings.jsx';
@@ -6,17 +7,30 @@ import MonthlySummary from './pages/MonthlySummary.jsx';
import AnnualOverview from './pages/AnnualOverview.jsx'; import AnnualOverview from './pages/AnnualOverview.jsx';
function App() { function App() {
const { theme, toggle } = useTheme();
return ( return (
<div> <div className="app-shell">
<nav style={{ display: 'flex', gap: '1rem', padding: '1rem', borderBottom: '1px solid #ccc' }}> <nav className="app-nav">
<NavLink to="/">Paycheck</NavLink> <span className="nav-brand">Budget</span>
<NavLink to="/bills">Bills</NavLink> <div className="nav-links">
<NavLink to="/settings">Settings</NavLink> <NavLink className={({ isActive }) => 'nav-link' + (isActive ? ' active' : '')} to="/" end>Paychecks</NavLink>
<NavLink to="/summary/monthly">Monthly Summary</NavLink> <NavLink className={({ isActive }) => 'nav-link' + (isActive ? ' active' : '')} to="/bills">Bills</NavLink>
<NavLink to="/summary/annual">Annual Overview</NavLink> <NavLink className={({ isActive }) => 'nav-link' + (isActive ? ' active' : '')} to="/summary/monthly">Monthly</NavLink>
<NavLink className={({ isActive }) => 'nav-link' + (isActive ? ' active' : '')} to="/summary/annual">Annual</NavLink>
<NavLink className={({ isActive }) => 'nav-link' + (isActive ? ' active' : '')} to="/settings">Settings</NavLink>
</div>
<button
className="theme-toggle"
onClick={toggle}
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
aria-label="Toggle theme"
>
{theme === 'dark' ? '☀️' : '🌙'}
</button>
</nav> </nav>
<main style={{ padding: '1rem' }}> <main className="app-main">
<Routes> <Routes>
<Route path="/" element={<PaycheckView />} /> <Route path="/" element={<PaycheckView />} />
<Route path="/bills" element={<Bills />} /> <Route path="/bills" element={<Bills />} />

View File

@@ -0,0 +1,30 @@
import { createContext, useContext, useEffect, useState } from 'react';
const ThemeContext = createContext(null);
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState(() => {
const stored = localStorage.getItem('theme');
if (stored === 'light' || stored === 'dark') return stored;
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
});
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
}, [theme]);
function toggle() {
setTheme(t => (t === 'dark' ? 'light' : 'dark'));
}
return (
<ThemeContext.Provider value={{ theme, toggle }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
return useContext(ThemeContext);
}

784
client/src/index.css Normal file
View File

@@ -0,0 +1,784 @@
/* ─── Google Fonts ─────────────────────────────────────────────────────────── */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
/* ─── Design tokens ─────────────────────────────────────────────────────────── */
:root {
/* Brand */
--accent: #2563eb;
--accent-hover: #1d4ed8;
--accent-subtle: #eff6ff;
/* Surfaces */
--bg: #f1f5f9;
--surface: #ffffff;
--surface-raised: #f8fafc;
--border: #e2e8f0;
--border-strong: #cbd5e1;
/* Text */
--text: #0f172a;
--text-muted: #64748b;
--text-faint: #94a3b8;
/* Semantic */
--success: #16a34a;
--success-bg: #f0fdf4;
--danger: #dc2626;
--danger-bg: #fef2f2;
--danger-border: #fca5a5;
--warning: #d97706;
/* Misc */
--radius-sm: 4px;
--radius: 8px;
--radius-lg: 12px;
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.10), 0 1px 2px -1px rgb(0 0 0 / 0.10);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.10), 0 2px 4px -2px rgb(0 0 0 / 0.10);
--nav-height: 56px;
--transition: 150ms ease;
}
[data-theme="dark"] {
--accent: #3b82f6;
--accent-hover: #60a5fa;
--accent-subtle: #1e3a5f;
--bg: #0f172a;
--surface: #1e293b;
--surface-raised: #273548;
--border: #334155;
--border-strong: #475569;
--text: #f1f5f9;
--text-muted: #94a3b8;
--text-faint: #64748b;
--success: #4ade80;
--success-bg: #052e16;
--danger: #f87171;
--danger-bg: #450a0a;
--danger-border: #991b1b;
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3);
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.4), 0 1px 2px -1px rgb(0 0 0 / 0.4);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4), 0 2px 4px -2px rgb(0 0 0 / 0.4);
}
/* ─── Reset & base ───────────────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; }
html {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
font-size: 16px;
-webkit-font-smoothing: antialiased;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
transition: background var(--transition), color var(--transition);
min-height: 100vh;
}
/* ─── Layout ─────────────────────────────────────────────────────────────────── */
.app-shell {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.app-nav {
position: sticky;
top: 0;
z-index: 50;
height: var(--nav-height);
background: var(--surface);
border-bottom: 1px solid var(--border);
box-shadow: var(--shadow-sm);
display: flex;
align-items: center;
padding: 0 1.5rem;
gap: 0;
}
.app-nav .nav-brand {
font-size: 1.1rem;
font-weight: 800;
color: var(--accent);
text-decoration: none;
letter-spacing: -0.02em;
margin-right: 2rem;
white-space: nowrap;
}
.app-nav .nav-links {
display: flex;
gap: 0.25rem;
flex: 1;
align-items: center;
}
.app-nav .nav-link {
padding: 0.4rem 0.75rem;
border-radius: var(--radius-sm);
font-size: 0.875rem;
font-weight: 500;
color: var(--text-muted);
text-decoration: none;
transition: color var(--transition), background var(--transition);
white-space: nowrap;
}
.app-nav .nav-link:hover {
color: var(--text);
background: var(--surface-raised);
}
.app-nav .nav-link.active {
color: var(--accent);
background: var(--accent-subtle);
font-weight: 600;
}
.theme-toggle {
margin-left: auto;
width: 36px;
height: 36px;
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--surface-raised);
color: var(--text-muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 1rem;
transition: background var(--transition), color var(--transition), border-color var(--transition);
flex-shrink: 0;
}
.theme-toggle:hover {
background: var(--border);
color: var(--text);
}
.app-main {
flex: 1;
padding: 1.75rem 1.5rem;
max-width: 1100px;
margin: 0 auto;
width: 100%;
}
/* ─── Page header ────────────────────────────────────────────────────────────── */
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.5rem;
}
.page-title {
margin: 0;
font-size: 1.5rem;
font-weight: 800;
letter-spacing: -0.02em;
}
/* ─── Cards ──────────────────────────────────────────────────────────────────── */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
}
.card-body {
padding: 1.25rem;
}
/* ─── Stat cards ─────────────────────────────────────────────────────────────── */
.stat-cards {
display: flex;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 1.25rem;
}
.stat-card {
flex: 1 1 150px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1rem 1.25rem;
box-shadow: var(--shadow-sm);
}
.stat-card__label {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
margin-bottom: 0.4rem;
}
.stat-card__value {
font-size: 1.35rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--text);
line-height: 1.2;
}
/* ─── Month / Year nav ───────────────────────────────────────────────────────── */
.period-nav {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.period-nav__label {
font-size: 1.2rem;
font-weight: 700;
min-width: 170px;
text-align: center;
letter-spacing: -0.01em;
}
/* ─── Buttons ────────────────────────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.375rem;
padding: 0.45rem 1rem;
font-size: 0.875rem;
font-weight: 500;
font-family: inherit;
border-radius: var(--radius);
border: 1px solid var(--border-strong);
background: var(--surface);
color: var(--text);
cursor: pointer;
transition: background var(--transition), border-color var(--transition), color var(--transition), box-shadow var(--transition);
white-space: nowrap;
line-height: 1;
}
.btn:hover {
background: var(--surface-raised);
border-color: var(--border-strong);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.btn-primary:hover:not(:disabled) {
background: var(--accent-hover);
border-color: var(--accent-hover);
}
.btn-danger {
background: transparent;
color: var(--danger);
border-color: var(--danger);
}
.btn-danger:hover:not(:disabled) {
background: var(--danger-bg);
}
.btn-ghost {
background: transparent;
border-color: transparent;
color: var(--text-muted);
}
.btn-ghost:hover {
background: var(--surface-raised);
color: var(--text);
border-color: var(--border);
}
.btn-icon {
width: 28px;
height: 28px;
padding: 0;
border-radius: var(--radius-sm);
background: transparent;
border: none;
color: var(--text-faint);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1rem;
transition: color var(--transition), background var(--transition);
}
.btn-icon:hover {
color: var(--danger);
background: var(--danger-bg);
}
.btn-nav {
width: 34px;
height: 34px;
padding: 0;
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--surface);
color: var(--text-muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 1rem;
transition: background var(--transition), color var(--transition);
}
.btn-nav:hover {
background: var(--surface-raised);
color: var(--text);
}
.btn-sm {
padding: 0.3rem 0.65rem;
font-size: 0.8rem;
}
/* ─── Forms ──────────────────────────────────────────────────────────────────── */
.form-group {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.form-label {
font-size: 0.8rem;
font-weight: 600;
color: var(--text-muted);
}
.form-input,
.form-select {
padding: 0.45rem 0.625rem;
font-size: 0.875rem;
font-family: inherit;
color: var(--text);
background: var(--surface);
border: 1px solid var(--border-strong);
border-radius: var(--radius);
transition: border-color var(--transition), box-shadow var(--transition);
outline: none;
width: 100%;
}
.form-input:focus,
.form-select:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgb(37 99 235 / 0.15);
}
.form-input::placeholder {
color: var(--text-faint);
}
.form-error {
font-size: 0.8rem;
color: var(--danger);
margin-top: 0.25rem;
}
/* ─── Tables ─────────────────────────────────────────────────────────────────── */
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
.data-table th {
text-align: left;
padding: 0.6rem 1rem;
background: var(--surface-raised);
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
border-bottom: 1px solid var(--border);
}
.data-table th.text-right,
.data-table td.text-right {
text-align: right;
}
.data-table td {
padding: 0.65rem 1rem;
color: var(--text);
border-bottom: 1px solid var(--border);
}
.data-table tbody tr:last-child td {
border-bottom: none;
}
.data-table tbody tr:hover td {
background: var(--surface-raised);
}
.data-table tfoot tr td {
border-top: 2px solid var(--border-strong);
border-bottom: none;
font-weight: 700;
background: var(--surface-raised);
}
.data-table .row-muted td {
color: var(--text-faint);
}
/* ─── Alerts ─────────────────────────────────────────────────────────────────── */
.alert {
padding: 0.75rem 1rem;
border-radius: var(--radius);
font-size: 0.875rem;
margin-bottom: 1rem;
}
.alert-error {
background: var(--danger-bg);
border: 1px solid var(--danger-border);
color: var(--danger);
}
.alert-success {
background: var(--success-bg);
color: var(--success);
font-weight: 600;
}
/* ─── Badges ─────────────────────────────────────────────────────────────────── */
.badge {
display: inline-block;
padding: 0.15rem 0.5rem;
border-radius: 999px;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.02em;
}
.badge-category {
background: var(--surface-raised);
color: var(--text-muted);
border: 1px solid var(--border);
}
/* ─── Divider ────────────────────────────────────────────────────────────────── */
.divider {
border: none;
border-top: 1px solid var(--border);
margin: 0.75rem 0;
}
/* ─── Empty state ────────────────────────────────────────────────────────────── */
.empty-state {
color: var(--text-faint);
font-size: 0.875rem;
font-style: italic;
padding: 0.25rem 0;
}
/* ─── Paycheck grid ──────────────────────────────────────────────────────────── */
.paycheck-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.25rem;
align-items: start;
}
@media (max-width: 680px) {
.paycheck-grid { grid-template-columns: 1fr; }
.app-nav .nav-link { font-size: 0.8rem; padding: 0.35rem 0.5rem; }
}
.paycheck-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: var(--shadow-sm);
}
.paycheck-card__header {
padding: 1rem 1.25rem 0.875rem;
border-bottom: 1px solid var(--border);
background: var(--surface-raised);
}
.paycheck-card__number {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--accent);
margin-bottom: 0.2rem;
}
.paycheck-card__date {
font-size: 1rem;
font-weight: 700;
color: var(--text);
margin-bottom: 0.5rem;
}
.paycheck-card__amounts {
display: flex;
gap: 1.25rem;
font-size: 0.8rem;
color: var(--text-muted);
}
.paycheck-card__amounts strong {
color: var(--text);
}
.paycheck-card__body {
padding: 1rem 1.25rem;
}
.section-title {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--text-muted);
margin-bottom: 0.625rem;
}
/* Bill rows */
.bill-row {
display: flex;
align-items: flex-start;
gap: 0.625rem;
padding: 0.4rem 0;
border-bottom: 1px solid var(--border);
}
.bill-row:last-child { border-bottom: none; }
.bill-row__check {
margin-top: 2px;
accent-color: var(--accent);
cursor: pointer;
flex-shrink: 0;
width: 15px;
height: 15px;
}
.bill-row__info { flex: 1; }
.bill-row__name {
display: flex;
justify-content: space-between;
align-items: baseline;
font-size: 0.875rem;
font-weight: 500;
color: var(--text);
gap: 0.5rem;
}
.bill-row__name.paid {
text-decoration: line-through;
color: var(--text-faint);
}
.bill-row__amount {
font-variant-numeric: tabular-nums;
font-size: 0.875rem;
white-space: nowrap;
}
.bill-row__meta {
font-size: 0.75rem;
color: var(--text-faint);
display: flex;
gap: 0.5rem;
margin-top: 1px;
}
/* OTE rows */
.ote-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0;
border-bottom: 1px solid var(--border);
font-size: 0.875rem;
}
.ote-row:last-child { border-bottom: none; }
.ote-row__check { accent-color: var(--accent); cursor: pointer; flex-shrink: 0; }
.ote-row__name {
flex: 1;
color: var(--text);
}
.ote-row__name.paid {
text-decoration: line-through;
color: var(--text-faint);
}
.ote-row__amount {
font-variant-numeric: tabular-nums;
color: var(--text);
white-space: nowrap;
}
/* Actual rows */
.actual-row {
padding: 0.4rem 0;
border-bottom: 1px solid var(--border);
font-size: 0.875rem;
}
.actual-row:last-child { border-bottom: none; }
.actual-row__main {
display: flex;
justify-content: space-between;
align-items: baseline;
}
.actual-row__category {
font-weight: 500;
color: var(--text);
}
.actual-row__amount {
font-variant-numeric: tabular-nums;
color: var(--text);
}
.actual-row__meta {
display: flex;
gap: 0.5rem;
align-items: center;
font-size: 0.75rem;
color: var(--text-faint);
margin-top: 1px;
}
.actual-row__note { font-style: italic; flex: 1; }
/* Inline add forms */
.inline-add-form {
display: flex;
gap: 0.4rem;
margin-top: 0.625rem;
flex-wrap: wrap;
}
.inline-add-form .form-input {
flex: 1;
min-width: 80px;
}
/* Multi-row form */
.form-rows {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin-top: 0.75rem;
}
.form-row {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}
.form-row .form-input,
.form-row .form-select {
flex: 1;
min-width: 80px;
}
/* Remaining balance */
.remaining-row {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 0.75rem;
margin-top: 0.75rem;
border-top: 2px solid var(--border-strong);
}
.remaining-row__label {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-muted);
}
.remaining-row__amount {
font-size: 1.15rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.remaining-row__amount.positive { color: var(--success); }
.remaining-row__amount.negative { color: var(--danger); }
/* ─── Settings ───────────────────────────────────────────────────────────────── */
.settings-section {
margin-bottom: 2rem;
}
.settings-section__title {
font-size: 1rem;
font-weight: 700;
color: var(--text);
margin: 0 0 1rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--border);
}
.settings-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 1rem;
}
/* ─── Utilities ──────────────────────────────────────────────────────────────── */
.text-right { text-align: right; }
.text-muted { color: var(--text-muted); }
.text-faint { color: var(--text-faint); }
.text-success { color: var(--success); }
.text-danger { color: var(--danger); }
.font-tabular { font-variant-numeric: tabular-nums; }
.font-bold { font-weight: 700; }
.mt-1 { margin-top: 0.5rem; }
.mt-2 { margin-top: 1rem; }
.mb-1 { margin-bottom: 0.5rem; }
.mb-2 { margin-bottom: 1rem; }
.gap-sm { gap: 0.5rem; }
.flex { display: flex; }
.flex-1 { flex: 1; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }

View File

@@ -2,11 +2,15 @@ import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom'; import { BrowserRouter } from 'react-router-dom';
import App from './App.jsx'; import App from './App.jsx';
import { ThemeProvider } from './ThemeContext.jsx';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')).render( ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode> <React.StrictMode>
<BrowserRouter> <BrowserRouter>
<App /> <ThemeProvider>
<App />
</ThemeProvider>
</BrowserRouter> </BrowserRouter>
</React.StrictMode> </React.StrictMode>
); );

View File

@@ -13,9 +13,9 @@ function fmt(value) {
return num < 0 ? `-$${abs}` : `$${abs}`; return num < 0 ? `-$${abs}` : `$${abs}`;
} }
function surplusStyle(value) { function surplusClass(value) {
if (value == null || isNaN(Number(value))) return {}; if (value == null || isNaN(Number(value))) return '';
return { color: Number(value) >= 0 ? '#2a9d2a' : '#cc2222', fontWeight: 500 }; return Number(value) >= 0 ? 'text-success' : 'text-danger';
} }
function sumField(rows, field) { function sumField(rows, field) {
@@ -37,7 +37,6 @@ export default function AnnualOverview() {
setLoading(true); setLoading(true);
setError(null); setError(null);
// Normalize nested API response to flat fields used by this component
function normalize(data) { function normalize(data) {
if (!data) return null; if (!data) return null;
return { return {
@@ -53,22 +52,13 @@ export default function AnnualOverview() {
Promise.all( Promise.all(
Array.from({ length: 12 }, (_, i) => Array.from({ length: 12 }, (_, i) =>
fetch(`/api/summary/monthly?year=${year}&month=${i + 1}`) fetch(`/api/summary/monthly?year=${year}&month=${i + 1}`)
.then(r => { .then(r => r.ok ? r.json().then(normalize) : null)
if (!r.ok) return null;
return r.json().then(normalize);
})
.catch(() => null) .catch(() => null)
) )
).then(results => { ).then(results => {
if (!cancelled) { if (!cancelled) { setMonthData(results); setLoading(false); }
setMonthData(results);
setLoading(false);
}
}).catch(err => { }).catch(err => {
if (!cancelled) { if (!cancelled) { setError(err.message || 'Failed to load data'); setLoading(false); }
setError(err.message || 'Failed to load data');
setLoading(false);
}
}); });
return () => { cancelled = true; }; return () => { cancelled = true; };
@@ -76,152 +66,93 @@ export default function AnnualOverview() {
const hasData = monthData.some(row => row != null); const hasData = monthData.some(row => row != null);
const annualIncome = sumField(monthData, 'total_income'); const annualIncome = sumField(monthData, 'total_income');
const annualBills = sumField(monthData, 'total_bills'); const annualBills = sumField(monthData, 'total_bills');
const annualVariable = sumField(monthData, 'total_variable'); const annualVariable = sumField(monthData, 'total_variable');
const annualOneTime = sumField(monthData, 'total_one_time'); const annualOneTime = sumField(monthData, 'total_one_time');
const annualSpending = sumField(monthData, 'total_spending'); const annualSpending = sumField(monthData, 'total_spending');
const annualSurplus = sumField(monthData, 'surplus_deficit'); const annualSurplus = sumField(monthData, 'surplus_deficit');
const cardStyle = {
background: '#f5f5f5',
border: '1px solid #ddd',
borderRadius: 6,
padding: '1rem 1.25rem',
minWidth: 160,
flex: '1 1 160px',
};
const cardLabelStyle = {
fontSize: '0.75rem',
color: '#666',
textTransform: 'uppercase',
letterSpacing: '0.05em',
marginBottom: 4,
};
const cardValueStyle = {
fontSize: '1.4rem',
fontWeight: 700,
};
return ( return (
<div style={{ maxWidth: 1000 }}> <div>
{/* Year navigation */} <div className="period-nav">
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '1.5rem' }}> <button className="btn-nav" onClick={() => setYear(y => y - 1)} aria-label="Previous year">&#8592;</button>
<button <span className="period-nav__label">{year}</span>
onClick={() => setYear(y => y - 1)} <button className="btn-nav" onClick={() => setYear(y => y + 1)} aria-label="Next year">&#8594;</button>
style={{ fontSize: '1.2rem', cursor: 'pointer', background: 'none', border: '1px solid #ccc', borderRadius: 4, padding: '0.2rem 0.6rem' }}
aria-label="Previous year"
>
</button>
<h1 style={{ margin: 0, fontSize: '1.5rem' }}>{year} Annual Overview</h1>
<button
onClick={() => setYear(y => y + 1)}
style={{ fontSize: '1.2rem', cursor: 'pointer', background: 'none', border: '1px solid #ccc', borderRadius: 4, padding: '0.2rem 0.6rem' }}
aria-label="Next year"
>
</button>
</div> </div>
{/* Summary cards */} <div className="stat-cards">
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap', marginBottom: '2rem' }}> <div className="stat-card">
<div style={cardStyle}> <div className="stat-card__label">Annual Income (net)</div>
<div style={cardLabelStyle}>Annual Income (net)</div> <div className="stat-card__value">{hasData ? fmt(annualIncome) : '—'}</div>
<div style={cardValueStyle}>{hasData ? fmt(annualIncome) : '—'}</div>
</div> </div>
<div style={cardStyle}> <div className="stat-card">
<div style={cardLabelStyle}>Annual Bills</div> <div className="stat-card__label">Annual Bills</div>
<div style={cardValueStyle}>{hasData ? fmt(annualBills) : '—'}</div> <div className="stat-card__value">{hasData ? fmt(annualBills) : '—'}</div>
</div> </div>
<div style={cardStyle}> <div className="stat-card">
<div style={cardLabelStyle}>Annual Variable Spending</div> <div className="stat-card__label">Variable Spending</div>
<div style={cardValueStyle}>{hasData ? fmt(annualVariable) : '—'}</div> <div className="stat-card__value">{hasData ? fmt(annualVariable) : '—'}</div>
</div> </div>
<div style={{ ...cardStyle }}> <div className="stat-card">
<div style={cardLabelStyle}>Annual Surplus / Deficit</div> <div className="stat-card__label">Annual Surplus / Deficit</div>
<div style={{ ...cardValueStyle, ...surplusStyle(annualSurplus) }}> <div className={`stat-card__value ${surplusClass(annualSurplus)}`}>
{hasData ? fmt(annualSurplus) : '—'} {hasData ? fmt(annualSurplus) : '—'}
</div> </div>
</div> </div>
</div> </div>
{/* Status messages */} {loading && <p className="text-muted">Loading</p>}
{loading && <p style={{ color: '#666' }}>Loading</p>} {error && <div className="alert alert-error">Error: {error}</div>}
{error && <p style={{ color: '#cc2222' }}>Error: {error}</p>}
{/* Monthly table */} <div className="card" style={{ overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.9rem' }}> <table className="data-table">
<thead> <thead>
<tr style={{ background: '#f0f0f0', textAlign: 'right' }}> <tr>
<th style={{ textAlign: 'left', padding: '0.5rem 0.75rem', borderBottom: '2px solid #ccc' }}>Month</th> <th>Month</th>
<th style={{ padding: '0.5rem 0.75rem', borderBottom: '2px solid #ccc' }}>Income (net)</th> <th className="text-right">Income (net)</th>
<th style={{ padding: '0.5rem 0.75rem', borderBottom: '2px solid #ccc' }}>Bills</th> <th className="text-right">Bills</th>
<th style={{ padding: '0.5rem 0.75rem', borderBottom: '2px solid #ccc' }}>Variable</th> <th className="text-right">Variable</th>
<th style={{ padding: '0.5rem 0.75rem', borderBottom: '2px solid #ccc' }}>One-time</th> <th className="text-right">One-time</th>
<th style={{ padding: '0.5rem 0.75rem', borderBottom: '2px solid #ccc' }}>Total Spending</th> <th className="text-right">Total Spending</th>
<th style={{ padding: '0.5rem 0.75rem', borderBottom: '2px solid #ccc' }}>Surplus / Deficit</th> <th className="text-right">Surplus / Deficit</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{MONTH_NAMES.map((name, i) => { {MONTH_NAMES.map((name, i) => {
const row = monthData[i]; const row = monthData[i];
const hasRow = row != null; const hasRow = row != null;
const surplus = hasRow ? row.surplus_deficit : null; const surplus = hasRow ? row.surplus_deficit : null;
return ( return (
<tr <tr key={name}>
key={name} <td>{name}</td>
style={{ borderBottom: '1px solid #eee' }} <td className="text-right font-tabular">{hasRow ? fmt(row.total_income) : '—'}</td>
> <td className="text-right font-tabular">{hasRow ? fmt(row.total_bills) : '—'}</td>
<td style={{ padding: '0.5rem 0.75rem' }}>{name}</td> <td className="text-right font-tabular">{hasRow ? fmt(row.total_variable) : '—'}</td>
<td style={{ textAlign: 'right', padding: '0.5rem 0.75rem' }}> <td className="text-right font-tabular">{hasRow ? fmt(row.total_one_time) : '—'}</td>
{hasRow ? fmt(row.total_income) : '—'} <td className="text-right font-tabular">{hasRow ? fmt(row.total_spending) : '—'}</td>
</td> <td className={`text-right font-tabular ${surplusClass(surplus)}`}>
<td style={{ textAlign: 'right', padding: '0.5rem 0.75rem' }}> {hasRow ? fmt(surplus) : '—'}
{hasRow ? fmt(row.total_bills) : '—'} </td>
</td> </tr>
<td style={{ textAlign: 'right', padding: '0.5rem 0.75rem' }}> );
{hasRow ? fmt(row.total_variable) : '—'} })}
</td> </tbody>
<td style={{ textAlign: 'right', padding: '0.5rem 0.75rem' }}> <tfoot>
{hasRow ? fmt(row.total_one_time) : '—'} <tr>
</td> <td className="font-bold">Total</td>
<td style={{ textAlign: 'right', padding: '0.5rem 0.75rem' }}> <td className="text-right font-tabular">{hasData ? fmt(annualIncome) : '—'}</td>
{hasRow ? fmt(row.total_spending) : '—'} <td className="text-right font-tabular">{hasData ? fmt(annualBills) : '—'}</td>
</td> <td className="text-right font-tabular">{hasData ? fmt(annualVariable) : '—'}</td>
<td style={{ textAlign: 'right', padding: '0.5rem 0.75rem', ...surplusStyle(surplus) }}> <td className="text-right font-tabular">{hasData ? fmt(annualOneTime) : '—'}</td>
{hasRow ? fmt(surplus) : '—'} <td className="text-right font-tabular">{hasData ? fmt(annualSpending) : '—'}</td>
</td> <td className={`text-right font-tabular font-bold ${surplusClass(annualSurplus)}`}>
</tr> {hasData ? fmt(annualSurplus) : '—'}
); </td>
})} </tr>
</tbody> </tfoot>
<tfoot> </table>
<tr style={{ borderTop: '2px solid #ccc', fontWeight: 700, background: '#fafafa' }}> </div>
<td style={{ padding: '0.5rem 0.75rem' }}>Total</td>
<td style={{ textAlign: 'right', padding: '0.5rem 0.75rem' }}>
{hasData ? fmt(annualIncome) : '—'}
</td>
<td style={{ textAlign: 'right', padding: '0.5rem 0.75rem' }}>
{hasData ? fmt(annualBills) : '—'}
</td>
<td style={{ textAlign: 'right', padding: '0.5rem 0.75rem' }}>
{hasData ? fmt(annualVariable) : '—'}
</td>
<td style={{ textAlign: 'right', padding: '0.5rem 0.75rem' }}>
{hasData ? fmt(annualOneTime) : '—'}
</td>
<td style={{ textAlign: 'right', padding: '0.5rem 0.75rem' }}>
{hasData ? fmt(annualSpending) : '—'}
</td>
<td style={{ textAlign: 'right', padding: '0.5rem 0.75rem', ...surplusStyle(annualSurplus) }}>
{hasData ? fmt(annualSurplus) : '—'}
</td>
</tr>
</tfoot>
</table>
</div> </div>
); );
} }

View File

@@ -1,13 +1,8 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
const CATEGORIES = [ const CATEGORIES = [
'Housing', 'Housing', 'Utilities', 'Subscriptions', 'Insurance',
'Utilities', 'Transportation', 'Debt', 'General',
'Subscriptions',
'Insurance',
'Transportation',
'Debt',
'General',
]; ];
const EMPTY_FORM = { const EMPTY_FORM = {
@@ -47,8 +42,7 @@ function Bills() {
setLoading(true); setLoading(true);
const res = await fetch('/api/bills'); const res = await fetch('/api/bills');
if (!res.ok) throw new Error('Failed to load bills'); if (!res.ok) throw new Error('Failed to load bills');
const data = await res.json(); setBills(await res.json());
setBills(data);
setError(null); setError(null);
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
@@ -57,9 +51,7 @@ function Bills() {
} }
} }
useEffect(() => { useEffect(() => { loadBills(); }, []);
loadBills();
}, []);
function openAddForm() { function openAddForm() {
setEditingId(null); setEditingId(null);
@@ -90,7 +82,7 @@ function Bills() {
function handleChange(e) { function handleChange(e) {
const { name, value } = e.target; const { name, value } = e.target;
setForm((prev) => ({ ...prev, [name]: value })); setForm(prev => ({ ...prev, [name]: value }));
} }
async function handleSave(e) { async function handleSave(e) {
@@ -105,22 +97,15 @@ function Bills() {
assigned_paycheck: form.assigned_paycheck, assigned_paycheck: form.assigned_paycheck,
category: form.category, category: form.category,
}; };
const url = editingId ? `/api/bills/${editingId}` : '/api/bills'; const url = editingId ? `/api/bills/${editingId}` : '/api/bills';
const method = editingId ? 'PUT' : 'POST'; const method = editingId ? 'PUT' : 'POST';
const res = await fetch(url, { const res = await fetch(url, {
method, method,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) { if (!res.ok) { setFormError(data.error || 'Failed to save bill'); return; }
setFormError(data.error || 'Failed to save bill');
return;
}
await loadBills(); await loadBills();
cancelForm(); cancelForm();
} catch (err) { } catch (err) {
@@ -135,9 +120,7 @@ function Bills() {
const res = await fetch(`/api/bills/${bill.id}/toggle`, { method: 'PATCH' }); const res = await fetch(`/api/bills/${bill.id}/toggle`, { method: 'PATCH' });
if (!res.ok) throw new Error('Failed to toggle bill'); if (!res.ok) throw new Error('Failed to toggle bill');
await loadBills(); await loadBills();
} catch (err) { } catch (err) { alert(err.message); }
alert(err.message);
}
} }
async function handleDelete(bill) { async function handleDelete(bill) {
@@ -146,134 +129,13 @@ function Bills() {
const res = await fetch(`/api/bills/${bill.id}`, { method: 'DELETE' }); const res = await fetch(`/api/bills/${bill.id}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Failed to delete bill'); if (!res.ok) throw new Error('Failed to delete bill');
await loadBills(); await loadBills();
} catch (err) { } catch (err) { alert(err.message); }
alert(err.message);
}
} }
return ( return (
<div> <div>
<style>{` <div className="page-header">
.bills-header { <h1 className="page-title">Bills</h1>
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.25rem;
}
.bills-header h1 {
margin: 0;
}
.btn {
padding: 0.4rem 0.9rem;
border: 1px solid #888;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
background: #fff;
}
.btn-primary {
background: #2563eb;
color: #fff;
border-color: #2563eb;
}
.btn-primary:hover { background: #1d4ed8; border-color: #1d4ed8; }
.btn-danger {
background: #fff;
color: #dc2626;
border-color: #dc2626;
}
.btn-danger:hover { background: #fef2f2; }
.btn-sm {
padding: 0.2rem 0.6rem;
font-size: 0.8rem;
}
.form-card {
border: 1px solid #d1d5db;
border-radius: 6px;
padding: 1.25rem;
margin-bottom: 1.5rem;
background: #f9fafb;
max-width: 560px;
}
.form-card h2 {
margin: 0 0 1rem 0;
font-size: 1.1rem;
}
.form-row {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
flex: 1 1 180px;
}
.form-group label {
font-size: 0.85rem;
font-weight: 600;
color: #374151;
}
.form-group input,
.form-group select {
padding: 0.35rem 0.5rem;
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: 0.9rem;
}
.form-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.75rem;
}
.form-error {
color: #dc2626;
font-size: 0.85rem;
margin-bottom: 0.5rem;
}
.bills-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.bills-table th,
.bills-table td {
text-align: left;
padding: 0.55rem 0.75rem;
border-bottom: 1px solid #e5e7eb;
}
.bills-table th {
background: #f3f4f6;
font-weight: 600;
color: #374151;
}
.bills-table tr:hover td {
background: #f9fafb;
}
.inactive-row td {
color: #9ca3af;
}
.active-toggle {
cursor: pointer;
user-select: none;
font-size: 1.1rem;
}
.actions-cell {
display: flex;
gap: 0.4rem;
white-space: nowrap;
}
.empty-state {
color: #6b7280;
padding: 2rem 0;
text-align: center;
}
`}</style>
<div className="bills-header">
<h1>Bills</h1>
{!showForm && ( {!showForm && (
<button className="btn btn-primary" onClick={openAddForm}> <button className="btn btn-primary" onClick={openAddForm}>
+ Add Bill + Add Bill
@@ -282,155 +144,113 @@ function Bills() {
</div> </div>
{showForm && ( {showForm && (
<div className="form-card"> <div className="card card-body mb-2" style={{ maxWidth: '580px' }}>
<h2>{editingId ? 'Edit Bill' : 'Add Bill'}</h2> <h2 style={{ margin: '0 0 1rem', fontSize: '1rem', fontWeight: '700' }}>
{formError && <div className="form-error">{formError}</div>} {editingId ? 'Edit Bill' : 'Add Bill'}
</h2>
{formError && <div className="alert alert-error">{formError}</div>}
<form onSubmit={handleSave} autoComplete="off"> <form onSubmit={handleSave} autoComplete="off">
<div className="form-row"> <div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: '0.75rem', marginBottom: '0.75rem' }}>
<div className="form-group" style={{ flex: '2 1 240px' }}> <div className="form-group">
<label htmlFor="name">Name</label> <label className="form-label" htmlFor="name">Name</label>
<input <input id="name" name="name" type="text" value={form.name} onChange={handleChange}
id="name" required placeholder="e.g. Rent" className="form-input" />
name="name"
type="text"
value={form.name}
onChange={handleChange}
required
placeholder="e.g. Rent"
/>
</div> </div>
<div className="form-group"> <div className="form-group">
<label htmlFor="amount">Amount ($)</label> <label className="form-label" htmlFor="amount">Amount ($)</label>
<input <input id="amount" name="amount" type="number" min="0" step="0.01"
id="amount" value={form.amount} onChange={handleChange} required placeholder="0.00" className="form-input" />
name="amount"
type="number"
min="0"
step="0.01"
value={form.amount}
onChange={handleChange}
required
placeholder="0.00"
/>
</div> </div>
</div> </div>
<div className="form-row"> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 2fr', gap: '0.75rem', marginBottom: '1rem' }}>
<div className="form-group"> <div className="form-group">
<label htmlFor="due_day">Due Day (131)</label> <label className="form-label" htmlFor="due_day">Due Day</label>
<input <input id="due_day" name="due_day" type="number" min="1" max="31"
id="due_day" value={form.due_day} onChange={handleChange} required placeholder="1" className="form-input" />
name="due_day"
type="number"
min="1"
max="31"
value={form.due_day}
onChange={handleChange}
required
placeholder="1"
/>
</div> </div>
<div className="form-group"> <div className="form-group">
<label htmlFor="assigned_paycheck">Paycheck</label> <label className="form-label" htmlFor="assigned_paycheck">Paycheck</label>
<select <select id="assigned_paycheck" name="assigned_paycheck"
id="assigned_paycheck" value={form.assigned_paycheck} onChange={handleChange} className="form-select">
name="assigned_paycheck"
value={form.assigned_paycheck}
onChange={handleChange}
>
<option value="1">Paycheck 1</option> <option value="1">Paycheck 1</option>
<option value="2">Paycheck 2</option> <option value="2">Paycheck 2</option>
</select> </select>
</div> </div>
<div className="form-group" style={{ flex: '2 1 200px' }}> <div className="form-group">
<label htmlFor="category">Category</label> <label className="form-label" htmlFor="category">Category</label>
<input <input id="category" name="category" type="text" list="category-list"
id="category" value={form.category} onChange={handleChange} placeholder="General" className="form-input" />
name="category"
type="text"
list="category-list"
value={form.category}
onChange={handleChange}
placeholder="General"
/>
<datalist id="category-list"> <datalist id="category-list">
{CATEGORIES.map((c) => ( {CATEGORIES.map(c => <option key={c} value={c} />)}
<option key={c} value={c} />
))}
</datalist> </datalist>
</div> </div>
</div> </div>
<div className="form-actions"> <div style={{ display: 'flex', gap: '0.5rem' }}>
<button className="btn btn-primary" type="submit" disabled={saving}> <button className="btn btn-primary" type="submit" disabled={saving}>
{saving ? 'Saving…' : 'Save'} {saving ? 'Saving…' : 'Save'}
</button> </button>
<button className="btn" type="button" onClick={cancelForm}> <button className="btn" type="button" onClick={cancelForm}>Cancel</button>
Cancel
</button>
</div> </div>
</form> </form>
</div> </div>
)} )}
{loading && <p>Loading bills</p>} {loading && <p className="text-muted">Loading bills</p>}
{error && <p style={{ color: '#dc2626' }}>Error: {error}</p>} {error && <div className="alert alert-error">Error: {error}</div>}
{!loading && !error && bills.length === 0 && ( {!loading && !error && bills.length === 0 && (
<p className="empty-state">No bills yet. Click "Add Bill" to get started.</p> <div className="card card-body" style={{ textAlign: 'center', color: 'var(--text-muted)' }}>
No bills yet. Click "Add Bill" to get started.
</div>
)} )}
{!loading && !error && bills.length > 0 && ( {!loading && !error && bills.length > 0 && (
<table className="bills-table"> <div className="card" style={{ overflow: 'hidden' }}>
<thead> <table className="data-table">
<tr> <thead>
<th>Name</th> <tr>
<th>Amount</th> <th>Name</th>
<th>Due Day</th> <th>Amount</th>
<th>Paycheck</th> <th>Due Day</th>
<th>Category</th> <th>Paycheck</th>
<th>Active</th> <th>Category</th>
<th>Actions</th> <th>Active</th>
</tr> <th>Actions</th>
</thead>
<tbody>
{bills.map((bill) => (
<tr key={bill.id} className={bill.active ? '' : 'inactive-row'}>
<td>{bill.name}</td>
<td>{formatCurrency(bill.amount)}</td>
<td>{ordinal(bill.due_day)}</td>
<td>Paycheck {bill.assigned_paycheck}</td>
<td>{bill.category || 'General'}</td>
<td>
<span
className="active-toggle"
title={bill.active ? 'Click to deactivate' : 'Click to activate'}
onClick={() => handleToggle(bill)}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === 'Enter' && handleToggle(bill)}
>
{bill.active ? '✅' : '⬜'}
</span>
</td>
<td>
<div className="actions-cell">
<button
className="btn btn-sm"
onClick={() => openEditForm(bill)}
>
Edit
</button>
<button
className="btn btn-sm btn-danger"
onClick={() => handleDelete(bill)}
>
Delete
</button>
</div>
</td>
</tr> </tr>
))} </thead>
</tbody> <tbody>
</table> {bills.map((bill) => (
<tr key={bill.id} className={bill.active ? '' : 'row-muted'}>
<td>{bill.name}</td>
<td className="font-tabular">{formatCurrency(bill.amount)}</td>
<td>{ordinal(bill.due_day)}</td>
<td>#{bill.assigned_paycheck}</td>
<td>
{bill.category && <span className="badge badge-category">{bill.category}</span>}
</td>
<td>
<span
style={{ cursor: 'pointer', userSelect: 'none', fontSize: '1.1rem' }}
title={bill.active ? 'Click to deactivate' : 'Click to activate'}
onClick={() => handleToggle(bill)}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === 'Enter' && handleToggle(bill)}
>
{bill.active ? '✅' : '⬜'}
</span>
</td>
<td>
<div style={{ display: 'flex', gap: '0.375rem' }}>
<button className="btn btn-sm" onClick={() => openEditForm(bill)}>Edit</button>
<button className="btn btn-sm btn-danger" onClick={() => handleDelete(bill)}>Delete</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)} )}
</div> </div>
); );

View File

@@ -10,11 +10,11 @@ function formatCurrency(value) {
return '$' + num.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); return '$' + num.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
} }
function StatCard({ label, value, valueColor }) { function StatCard({ label, value, valueClass }) {
return ( return (
<div style={styles.card}> <div className="stat-card">
<div style={styles.cardLabel}>{label}</div> <div className="stat-card__label">{label}</div>
<div style={{ ...styles.cardValue, color: valueColor || '#222' }}>{value}</div> <div className={`stat-card__value${valueClass ? ' ' + valueClass : ''}`}>{value}</div>
</div> </div>
); );
} }
@@ -27,9 +27,7 @@ function MonthlySummary() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState(null);
useEffect(() => { useEffect(() => { loadSummary(year, month); }, [year, month]);
loadSummary(year, month);
}, [year, month]);
async function loadSummary(y, m) { async function loadSummary(y, m) {
setLoading(true); setLoading(true);
@@ -37,8 +35,7 @@ function MonthlySummary() {
try { try {
const res = await fetch(`/api/summary/monthly?year=${y}&month=${m}`); const res = await fetch(`/api/summary/monthly?year=${y}&month=${m}`);
if (!res.ok) throw new Error(`Server error: ${res.status}`); if (!res.ok) throw new Error(`Server error: ${res.status}`);
const json = await res.json(); setData(await res.json());
setData(json);
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
} finally { } finally {
@@ -47,118 +44,78 @@ function MonthlySummary() {
} }
function prevMonth() { function prevMonth() {
if (month === 1) { if (month === 1) { setYear(y => y - 1); setMonth(12); } else { setMonth(m => m - 1); }
setYear(y => y - 1);
setMonth(12);
} else {
setMonth(m => m - 1);
}
} }
function nextMonth() { function nextMonth() {
if (month === 12) { if (month === 12) { setYear(y => y + 1); setMonth(1); } else { setMonth(m => m + 1); }
setYear(y => y + 1);
setMonth(1);
} else {
setMonth(m => m + 1);
}
} }
const surplusColor = data && data.summary.surplus_deficit >= 0 ? '#2a7a2a' : '#c0392b'; const surplusClass = data
? (data.summary.surplus_deficit >= 0 ? 'text-success' : 'text-danger')
: '';
return ( return (
<div style={styles.container}> <div>
<div style={styles.monthNav}> <div className="period-nav">
<button style={styles.navButton} onClick={prevMonth}>&larr;</button> <button className="btn-nav" onClick={prevMonth}>&#8592;</button>
<span style={styles.monthLabel}>{MONTH_NAMES[month - 1]} {year}</span> <span className="period-nav__label">{MONTH_NAMES[month - 1]} {year}</span>
<button style={styles.navButton} onClick={nextMonth}>&rarr;</button> <button className="btn-nav" onClick={nextMonth}>&#8594;</button>
</div> </div>
{error && ( {error && <div className="alert alert-error">Error: {error}</div>}
<div style={styles.errorBanner}>Error: {error}</div> {loading && <p className="text-muted">Loading</p>}
)}
{loading && (
<div style={styles.loadingMsg}>Loading...</div>
)}
{!loading && data && ( {!loading && data && (
<> <>
<div style={styles.cardRow}> <div className="stat-cards">
<StatCard <StatCard label="Net Income" value={formatCurrency(data.income.net)} />
label="Total Income (net)" <StatCard label="Bills Planned" value={formatCurrency(data.bills.planned)} />
value={formatCurrency(data.income.net)} <StatCard label="Variable Spending" value={formatCurrency(data.actuals.total)} />
/> <StatCard label="One-time Expenses" value={formatCurrency(data.one_time_expenses.total)} />
<StatCard <StatCard label="Total Spending" value={formatCurrency(data.summary.total_spending)} />
label="Total Bills Planned"
value={formatCurrency(data.bills.planned)}
/>
<StatCard
label="Total Variable Spending"
value={formatCurrency(data.actuals.total)}
/>
<StatCard
label="Total One-time"
value={formatCurrency(data.one_time_expenses.total)}
/>
</div>
<div style={styles.cardRow}>
<StatCard
label="Total Spending"
value={formatCurrency(data.summary.total_spending)}
/>
<StatCard <StatCard
label="Surplus / Deficit" label="Surplus / Deficit"
value={formatCurrency(data.summary.surplus_deficit)} value={formatCurrency(data.summary.surplus_deficit)}
valueColor={surplusColor} valueClass={surplusClass}
/>
<StatCard
label="Bills Paid"
value={`${data.bills.paid_count} of ${data.bills.count}`}
/> />
<StatCard label="Bills Paid" value={`${data.bills.paid_count} of ${data.bills.count}`} />
</div> </div>
<div style={styles.tableWrapper}> <div className="card" style={{ overflow: 'hidden' }}>
<table style={styles.table}> <table className="data-table">
<thead> <thead>
<tr> <tr>
<th style={styles.th}>Category</th> <th>Category</th>
<th style={{ ...styles.th, textAlign: 'right' }}>Amount</th> <th className="text-right">Amount</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr style={styles.tr}> <tr>
<td style={styles.td}>Income (net)</td> <td>Income (net)</td>
<td style={{ ...styles.td, ...styles.tdRight, color: '#2a7a2a' }}> <td className="text-right font-tabular text-success">{formatCurrency(data.income.net)}</td>
{formatCurrency(data.income.net)}
</td>
</tr> </tr>
<tr style={styles.tr}> <tr>
<td style={styles.td}>Bills (planned)</td> <td>Bills (planned)</td>
<td style={{ ...styles.td, ...styles.tdRight }}> <td className="text-right font-tabular">{formatCurrency(data.bills.planned)}</td>
-{formatCurrency(data.bills.planned)}
</td>
</tr> </tr>
<tr style={styles.tr}> <tr>
<td style={styles.td}>Variable spending</td> <td>Variable spending</td>
<td style={{ ...styles.td, ...styles.tdRight }}> <td className="text-right font-tabular">{formatCurrency(data.actuals.total)}</td>
-{formatCurrency(data.actuals.total)}
</td>
</tr> </tr>
<tr style={styles.tr}> <tr>
<td style={styles.td}>One-time expenses</td> <td>One-time expenses</td>
<td style={{ ...styles.td, ...styles.tdRight }}> <td className="text-right font-tabular">{formatCurrency(data.one_time_expenses.total)}</td>
-{formatCurrency(data.one_time_expenses.total)}
</td>
</tr> </tr>
<tr style={styles.trTotal}> </tbody>
<td style={{ ...styles.td, ...styles.tdBold }}>Surplus / Deficit</td> <tfoot>
<td style={{ ...styles.td, ...styles.tdRight, ...styles.tdBold, color: surplusColor }}> <tr>
<td className="font-bold">Surplus / Deficit</td>
<td className={`text-right font-tabular font-bold ${surplusClass}`}>
{formatCurrency(data.summary.surplus_deficit)} {formatCurrency(data.summary.surplus_deficit)}
</td> </td>
</tr> </tr>
</tbody> </tfoot>
</table> </table>
</div> </div>
</> </>
@@ -167,108 +124,4 @@ function MonthlySummary() {
); );
} }
const styles = {
container: {
maxWidth: '860px',
margin: '0 auto',
},
monthNav: {
display: 'flex',
alignItems: 'center',
gap: '1rem',
marginBottom: '1.5rem',
},
navButton: {
padding: '0.3rem 0.75rem',
fontSize: '1rem',
cursor: 'pointer',
border: '1px solid #bbb',
borderRadius: '4px',
background: '#f5f5f5',
},
monthLabel: {
fontSize: '1.25rem',
fontWeight: '600',
minWidth: '160px',
textAlign: 'center',
},
errorBanner: {
background: '#fde8e8',
border: '1px solid #f5a0a0',
borderRadius: '4px',
padding: '0.75rem 1rem',
marginBottom: '1rem',
color: '#c0392b',
},
loadingMsg: {
padding: '2rem',
color: '#888',
},
cardRow: {
display: 'flex',
gap: '1rem',
marginBottom: '1rem',
flexWrap: 'wrap',
},
card: {
flex: '1 1 160px',
border: '1px solid #ddd',
borderRadius: '6px',
padding: '1rem',
background: '#fafafa',
minWidth: '140px',
},
cardLabel: {
fontSize: '0.8rem',
color: '#666',
marginBottom: '0.4rem',
fontWeight: '500',
textTransform: 'uppercase',
letterSpacing: '0.03em',
},
cardValue: {
fontSize: '1.35rem',
fontWeight: '700',
fontVariantNumeric: 'tabular-nums',
},
tableWrapper: {
marginTop: '1.5rem',
border: '1px solid #ddd',
borderRadius: '6px',
overflow: 'hidden',
},
table: {
width: '100%',
borderCollapse: 'collapse',
fontSize: '0.95rem',
},
th: {
padding: '0.6rem 1rem',
background: '#f0f0f0',
fontWeight: '600',
color: '#444',
borderBottom: '1px solid #ddd',
textAlign: 'left',
},
tr: {
borderBottom: '1px solid #eee',
},
trTotal: {
borderTop: '2px solid #ccc',
background: '#fafafa',
},
td: {
padding: '0.6rem 1rem',
color: '#333',
},
tdRight: {
textAlign: 'right',
fontVariantNumeric: 'tabular-nums',
},
tdBold: {
fontWeight: '700',
fontSize: '1rem',
},
};
export default MonthlySummary; export default MonthlySummary;

View File

@@ -17,7 +17,6 @@ function formatCurrency(value) {
} }
function formatPayDate(dateStr) { function formatPayDate(dateStr) {
// dateStr is YYYY-MM-DD
const [year, month, day] = dateStr.split('-').map(Number); const [year, month, day] = dateStr.split('-').map(Number);
return `${MONTH_NAMES[month - 1]} ${day}, ${year}`; return `${MONTH_NAMES[month - 1]} ${day}, ${year}`;
} }
@@ -62,11 +61,7 @@ function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggl
async function handleAddActual(e) { async function handleAddActual(e) {
e.preventDefault(); e.preventDefault();
if (!formAmount) { if (!formAmount) { setFormError('Amount is required'); return; }
setFormError('Amount is required');
return;
}
setFormSubmitting(true); setFormSubmitting(true);
setFormError(null); setFormError(null);
try { try {
@@ -85,9 +80,7 @@ function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggl
const body = await res.json(); const body = await res.json();
throw new Error(body.error || `Server error: ${res.status}`); throw new Error(body.error || `Server error: ${res.status}`);
} }
// Refresh the actuals list
await loadActuals(paycheck.id); await loadActuals(paycheck.id);
// Reset form fields (keep date as today)
setFormCategoryId(''); setFormCategoryId('');
setFormAmount(''); setFormAmount('');
setFormNote(''); setFormNote('');
@@ -114,8 +107,10 @@ function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggl
if (!paycheck) { if (!paycheck) {
return ( return (
<div style={styles.column}> <div className="paycheck-card">
<p style={{ color: '#888' }}>No data</p> <div className="paycheck-card__body">
<p className="empty-state">No data</p>
</div>
</div> </div>
); );
} }
@@ -125,196 +120,180 @@ function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggl
const otesTotal = paycheck.one_time_expenses.reduce((sum, e) => sum + (parseFloat(e.amount) || 0), 0); const otesTotal = paycheck.one_time_expenses.reduce((sum, e) => sum + (parseFloat(e.amount) || 0), 0);
const actualsTotal = actuals.reduce((sum, a) => sum + (parseFloat(a.amount) || 0), 0); const actualsTotal = actuals.reduce((sum, a) => sum + (parseFloat(a.amount) || 0), 0);
const remaining = net - billsTotal - otesTotal - actualsTotal; const remaining = net - billsTotal - otesTotal - actualsTotal;
const remainingColor = remaining >= 0 ? '#2a7a2a' : '#c0392b';
return ( return (
<div style={styles.column}> <div className="paycheck-card">
<div style={styles.columnHeader}> <div className="paycheck-card__header">
<h2 style={styles.paycheckTitle}>Paycheck {paycheck.paycheck_number}</h2> <div className="paycheck-card__number">Paycheck {paycheck.paycheck_number}</div>
<div style={styles.payDate}>{formatPayDate(paycheck.pay_date)}</div> <div className="paycheck-card__date">{formatPayDate(paycheck.pay_date)}</div>
<div style={styles.payAmounts}> <div className="paycheck-card__amounts">
<span>Gross: <strong>{formatCurrency(paycheck.gross)}</strong></span> <span>Gross: <strong>{formatCurrency(paycheck.gross)}</strong></span>
<span style={{ marginLeft: '1rem' }}>Net: <strong>{formatCurrency(paycheck.net)}</strong></span> <span>Net: <strong>{formatCurrency(paycheck.net)}</strong></span>
</div> </div>
</div> </div>
<div style={styles.section}> <div className="paycheck-card__body">
<div style={styles.sectionLabel}>Bills</div> {/* Bills */}
<div style={styles.divider} /> <div className="mb-2">
{paycheck.bills.length === 0 ? ( <div className="section-title">Bills</div>
<div style={styles.emptyNote}>(none)</div> {paycheck.bills.length === 0 ? (
) : ( <p className="empty-state">(none)</p>
paycheck.bills.map((bill) => ( ) : (
<div paycheck.bills.map((bill) => (
key={bill.paycheck_bill_id} <div key={bill.paycheck_bill_id} className="bill-row" style={{ opacity: bill.paid ? 0.6 : 1 }}>
style={{ <input
...styles.billRow, type="checkbox"
opacity: bill.paid ? 0.6 : 1, checked={!!bill.paid}
}} onChange={() => onBillPaidToggle(bill.paycheck_bill_id, !bill.paid)}
> className="bill-row__check"
<input />
type="checkbox" <div className="bill-row__info">
checked={!!bill.paid} <div className={`bill-row__name${bill.paid ? ' paid' : ''}`}>
onChange={() => onBillPaidToggle(bill.paycheck_bill_id, !bill.paid)} <span>{bill.name}</span>
style={styles.checkbox} <span className="bill-row__amount">{formatCurrency(bill.effective_amount)}</span>
/> </div>
<div style={styles.billDetails}> <div className="bill-row__meta">
<div style={bill.paid ? styles.billNamePaid : styles.billName}> <span>due {ordinal(bill.due_day)}</span>
{bill.name} {bill.category && <span className="badge badge-category">{bill.category}</span>}
<span style={styles.billAmount}>{formatCurrency(bill.effective_amount)}</span> </div>
</div>
<div style={styles.billMeta}>
<span>due {ordinal(bill.due_day)}</span>
{bill.category && (
<span style={styles.category}>{bill.category}</span>
)}
</div> </div>
</div> </div>
</div> ))
)) )}
)}
</div>
<div style={styles.section}>
<div style={styles.sectionLabel}>One-time expenses</div>
<div style={styles.divider} />
{paycheck.one_time_expenses.length === 0 ? (
<div style={styles.emptyNote}>(none)</div>
) : (
paycheck.one_time_expenses.map((ote) => (
<div key={ote.id} style={{ ...styles.oteRow, opacity: ote.paid ? 0.6 : 1 }}>
<input
type="checkbox"
checked={!!ote.paid}
onChange={() => onOtePaidToggle(ote.id, !ote.paid)}
style={styles.checkbox}
/>
<span style={ote.paid ? { ...styles.oteName, textDecoration: 'line-through', color: '#999' } : styles.oteName}>
{ote.name}
</span>
<span style={styles.oteAmount}>{formatCurrency(ote.amount)}</span>
<button onClick={() => onOteDelete(ote.id)} style={styles.deleteButton} title="Remove expense">&times;</button>
</div>
))
)}
<div style={styles.oteAddForm}>
<input
type="text"
placeholder="Name"
value={newOteName}
onChange={(e) => setNewOteName(e.target.value)}
style={styles.oteAddInput}
/>
<input
type="number"
placeholder="Amount"
value={newOteAmount}
onChange={(e) => setNewOteAmount(e.target.value)}
min="0"
step="0.01"
style={{ ...styles.oteAddInput, width: '80px' }}
/>
<button
onClick={() => {
if (!newOteName.trim() || !newOteAmount) return;
onOteAdd(paycheck.id, newOteName.trim(), parseFloat(newOteAmount));
setNewOteName('');
setNewOteAmount('');
}}
style={styles.oteAddButton}
>
Add
</button>
</div> </div>
</div>
<div style={styles.section}> {/* One-time expenses */}
<div style={styles.sectionLabel}>Variable Spending</div> <div className="mb-2">
<div style={styles.divider} /> <div className="section-title">One-time Expenses</div>
{paycheck.one_time_expenses.length === 0 ? (
{actualsLoading && <div style={styles.emptyNote}>Loading</div>} <p className="empty-state">(none)</p>
{actualsError && <div style={styles.actualsError}>Error: {actualsError}</div>} ) : (
paycheck.one_time_expenses.map((ote) => (
{!actualsLoading && actuals.length === 0 && ( <div key={ote.id} className="ote-row" style={{ opacity: ote.paid ? 0.6 : 1 }}>
<div style={styles.emptyNote}>(none)</div> <input
)} type="checkbox"
checked={!!ote.paid}
{actuals.map((actual) => ( onChange={() => onOtePaidToggle(ote.id, !ote.paid)}
<div key={actual.id} style={styles.actualRow}> className="ote-row__check"
<div style={styles.actualMain}> />
<span style={styles.actualCategory}> <span className={`ote-row__name${ote.paid ? ' paid' : ''}`}>{ote.name}</span>
{actual.category_name || <em style={{ color: '#aaa' }}>Uncategorized</em>} <span className="ote-row__amount">{formatCurrency(ote.amount)}</span>
</span> <button className="btn-icon" onClick={() => onOteDelete(ote.id)} title="Remove">&times;</button>
<span style={styles.actualAmount}>{formatCurrency(actual.amount)}</span> </div>
</div> ))
<div style={styles.actualMeta}> )}
{actual.note && <span style={styles.actualNote}>{actual.note}</span>} <div className="inline-add-form">
<span style={styles.actualDate}>{actual.date}</span> <input
<button type="text"
style={styles.deleteButton} placeholder="Name"
onClick={() => handleDeleteActual(actual.id)} value={newOteName}
title="Remove" onChange={(e) => setNewOteName(e.target.value)}
> className="form-input"
&times; />
</button>
</div>
</div>
))}
<form onSubmit={handleAddActual} style={styles.actualForm}>
<div style={styles.actualFormRow}>
<select
value={formCategoryId}
onChange={e => setFormCategoryId(e.target.value)}
style={styles.formSelect}
>
<option value=""> Category </option>
{categories.map(cat => (
<option key={cat.id} value={cat.id}>{cat.name}</option>
))}
</select>
<input <input
type="number" type="number"
placeholder="Amount" placeholder="Amount"
value={formAmount} value={newOteAmount}
onChange={e => setFormAmount(e.target.value)} onChange={(e) => setNewOteAmount(e.target.value)}
step="0.01"
min="0" min="0"
style={styles.formInput} step="0.01"
required className="form-input"
/> style={{ maxWidth: '100px' }}
</div>
<div style={styles.actualFormRow}>
<input
type="text"
placeholder="Note (optional)"
value={formNote}
onChange={e => setFormNote(e.target.value)}
style={{ ...styles.formInput, flex: 2 }}
/>
<input
type="date"
value={formDate}
onChange={e => setFormDate(e.target.value)}
style={styles.formInput}
/> />
<button <button
type="submit" className="btn btn-sm btn-primary"
disabled={formSubmitting} onClick={() => {
style={styles.addButton} if (!newOteName.trim() || !newOteAmount) return;
onOteAdd(paycheck.id, newOteName.trim(), parseFloat(newOteAmount));
setNewOteName('');
setNewOteAmount('');
}}
> >
Add Add
</button> </button>
</div> </div>
{formError && <div style={styles.formError}>{formError}</div>} </div>
</form>
</div>
<div style={styles.remainingRow}> {/* Variable spending */}
<span style={styles.remainingLabel}>Remaining:</span> <div className="mb-2">
<span style={{ ...styles.remainingAmount, color: remainingColor }}> <div className="section-title">Variable Spending</div>
{formatCurrency(remaining)}
</span> {actualsLoading && <p className="empty-state">Loading</p>}
{actualsError && <div className="alert alert-error">Error: {actualsError}</div>}
{!actualsLoading && actuals.length === 0 && <p className="empty-state">(none)</p>}
{actuals.map((actual) => (
<div key={actual.id} className="actual-row">
<div className="actual-row__main">
<span className="actual-row__category">
{actual.category_name || <em className="text-faint">Uncategorized</em>}
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
<span className="actual-row__amount">{formatCurrency(actual.amount)}</span>
<button className="btn-icon" onClick={() => handleDeleteActual(actual.id)} title="Remove">&times;</button>
</div>
</div>
<div className="actual-row__meta">
{actual.note && <span className="actual-row__note">{actual.note}</span>}
<span>{actual.date}</span>
</div>
</div>
))}
<form onSubmit={handleAddActual} className="form-rows">
<div className="form-row">
<select
value={formCategoryId}
onChange={e => setFormCategoryId(e.target.value)}
className="form-select"
>
<option value=""> Category </option>
{categories.map(cat => (
<option key={cat.id} value={cat.id}>{cat.name}</option>
))}
</select>
<input
type="number"
placeholder="Amount"
value={formAmount}
onChange={e => setFormAmount(e.target.value)}
step="0.01"
min="0"
className="form-input"
style={{ maxWidth: '110px' }}
required
/>
</div>
<div className="form-row">
<input
type="text"
placeholder="Note (optional)"
value={formNote}
onChange={e => setFormNote(e.target.value)}
className="form-input"
/>
<input
type="date"
value={formDate}
onChange={e => setFormDate(e.target.value)}
className="form-input"
style={{ maxWidth: '140px' }}
/>
<button type="submit" disabled={formSubmitting} className="btn btn-sm btn-primary" style={{ flexShrink: 0 }}>
Add
</button>
</div>
{formError && <div className="form-error">{formError}</div>}
</form>
</div>
{/* Remaining */}
<div className="remaining-row">
<span className="remaining-row__label">Remaining</span>
<span className={`remaining-row__amount ${remaining >= 0 ? 'positive' : 'negative'}`}>
{formatCurrency(remaining)}
</span>
</div>
</div> </div>
</div> </div>
); );
@@ -323,19 +302,14 @@ function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggl
function PaycheckView() { function PaycheckView() {
const now = new Date(); const now = new Date();
const [year, setYear] = useState(now.getFullYear()); const [year, setYear] = useState(now.getFullYear());
const [month, setMonth] = useState(now.getMonth() + 1); // 1-based const [month, setMonth] = useState(now.getMonth() + 1);
const [paychecks, setPaychecks] = useState([]); const [paychecks, setPaychecks] = useState([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [categories, setCategories] = useState([]); const [categories, setCategories] = useState([]);
useEffect(() => { useEffect(() => { loadPaychecks(year, month); }, [year, month]);
loadPaychecks(year, month); useEffect(() => { loadCategories(); }, []);
}, [year, month]);
useEffect(() => {
loadCategories();
}, []);
async function loadPaychecks(y, m) { async function loadPaychecks(y, m) {
setLoading(true); setLoading(true);
@@ -343,8 +317,7 @@ function PaycheckView() {
try { try {
const res = await fetch(`/api/paychecks?year=${y}&month=${m}`); const res = await fetch(`/api/paychecks?year=${y}&month=${m}`);
if (!res.ok) throw new Error(`Server error: ${res.status}`); if (!res.ok) throw new Error(`Server error: ${res.status}`);
const data = await res.json(); setPaychecks(await res.json());
setPaychecks(data);
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
} finally { } finally {
@@ -355,30 +328,17 @@ function PaycheckView() {
async function loadCategories() { async function loadCategories() {
try { try {
const res = await fetch('/api/expense-categories'); const res = await fetch('/api/expense-categories');
if (!res.ok) throw new Error(`Server error: ${res.status}`); if (!res.ok) return;
const data = await res.json(); setCategories(await res.json());
setCategories(data); } catch { /* silent */ }
} catch (err) {
console.error('Failed to load expense categories:', err.message);
}
} }
function prevMonth() { function prevMonth() {
if (month === 1) { if (month === 1) { setYear(y => y - 1); setMonth(12); } else { setMonth(m => m - 1); }
setYear(y => y - 1);
setMonth(12);
} else {
setMonth(m => m - 1);
}
} }
function nextMonth() { function nextMonth() {
if (month === 12) { if (month === 12) { setYear(y => y + 1); setMonth(1); } else { setMonth(m => m + 1); }
setYear(y => y + 1);
setMonth(1);
} else {
setMonth(m => m + 1);
}
} }
async function handleOtePaidToggle(oteId, paid) { async function handleOtePaidToggle(oteId, paid) {
@@ -421,7 +381,6 @@ function PaycheckView() {
} }
async function handleBillPaidToggle(paycheckBillId, paid) { async function handleBillPaidToggle(paycheckBillId, paid) {
// Optimistic update
setPaychecks(prev => setPaychecks(prev =>
prev.map(pc => ({ prev.map(pc => ({
...pc, ...pc,
@@ -441,7 +400,6 @@ function PaycheckView() {
}); });
if (!res.ok) throw new Error(`Server error: ${res.status}`); if (!res.ok) throw new Error(`Server error: ${res.status}`);
const updated = await res.json(); const updated = await res.json();
// Sync server response
setPaychecks(prev => setPaychecks(prev =>
prev.map(pc => ({ prev.map(pc => ({
...pc, ...pc,
@@ -453,14 +411,11 @@ function PaycheckView() {
})) }))
); );
} catch (err) { } catch (err) {
// Revert optimistic update on failure
setPaychecks(prev => setPaychecks(prev =>
prev.map(pc => ({ prev.map(pc => ({
...pc, ...pc,
bills: pc.bills.map(b => bills: pc.bills.map(b =>
b.paycheck_bill_id === paycheckBillId b.paycheck_bill_id === paycheckBillId ? { ...b, paid: !paid } : b
? { ...b, paid: !paid }
: b
), ),
})) }))
); );
@@ -472,21 +427,19 @@ function PaycheckView() {
const pc2 = paychecks.find(p => p.paycheck_number === 2) || null; const pc2 = paychecks.find(p => p.paycheck_number === 2) || null;
return ( return (
<div style={styles.container}> <div>
<div style={styles.monthNav}> <div className="period-nav">
<button style={styles.navButton} onClick={prevMonth}>&larr;</button> <button className="btn-nav" onClick={prevMonth}>&#8592;</button>
<span style={styles.monthLabel}>{MONTH_NAMES[month - 1]} {year}</span> <span className="period-nav__label">{MONTH_NAMES[month - 1]} {year}</span>
<button style={styles.navButton} onClick={nextMonth}>&rarr;</button> <button className="btn-nav" onClick={nextMonth}>&#8594;</button>
</div> </div>
{error && ( {error && <div className="alert alert-error">Error: {error}</div>}
<div style={styles.errorBanner}>Error: {error}</div>
)}
{loading ? ( {loading ? (
<div style={styles.loadingMsg}>Loading...</div> <p className="text-muted">Loading</p>
) : ( ) : (
<div style={styles.grid}> <div className="paycheck-grid">
<PaycheckColumn <PaycheckColumn
paycheck={pc1} paycheck={pc1}
onBillPaidToggle={handleBillPaidToggle} onBillPaidToggle={handleBillPaidToggle}
@@ -509,282 +462,4 @@ function PaycheckView() {
); );
} }
const styles = {
container: {
maxWidth: '960px',
margin: '0 auto',
},
monthNav: {
display: 'flex',
alignItems: 'center',
gap: '1rem',
marginBottom: '1.25rem',
},
navButton: {
padding: '0.3rem 0.75rem',
fontSize: '1rem',
cursor: 'pointer',
border: '1px solid #bbb',
borderRadius: '4px',
background: '#f5f5f5',
},
monthLabel: {
fontSize: '1.25rem',
fontWeight: '600',
minWidth: '160px',
textAlign: 'center',
},
errorBanner: {
background: '#fde8e8',
border: '1px solid #f5a0a0',
borderRadius: '4px',
padding: '0.75rem 1rem',
marginBottom: '1rem',
color: '#c0392b',
},
loadingMsg: {
padding: '2rem',
color: '#888',
},
grid: {
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '1.5rem',
alignItems: 'start',
},
column: {
border: '1px solid #ddd',
borderRadius: '6px',
padding: '1rem',
background: '#fafafa',
},
columnHeader: {
marginBottom: '1rem',
paddingBottom: '0.75rem',
borderBottom: '2px solid #eee',
},
paycheckTitle: {
margin: '0 0 0.25rem 0',
fontSize: '1.1rem',
fontWeight: '700',
},
payDate: {
color: '#555',
marginBottom: '0.4rem',
fontSize: '0.95rem',
},
payAmounts: {
fontSize: '0.95rem',
color: '#333',
},
section: {
marginBottom: '1rem',
},
sectionLabel: {
fontWeight: '600',
fontSize: '0.9rem',
color: '#444',
marginBottom: '0.25rem',
},
divider: {
borderTop: '1px solid #ddd',
marginBottom: '0.5rem',
},
emptyNote: {
color: '#aaa',
fontSize: '0.875rem',
fontStyle: 'italic',
paddingLeft: '0.25rem',
},
billRow: {
display: 'flex',
alignItems: 'flex-start',
gap: '0.5rem',
marginBottom: '0.5rem',
},
checkbox: {
marginTop: '3px',
cursor: 'pointer',
flexShrink: 0,
},
billDetails: {
flex: 1,
},
billName: {
display: 'flex',
justifyContent: 'space-between',
fontWeight: '500',
fontSize: '0.95rem',
},
billNamePaid: {
display: 'flex',
justifyContent: 'space-between',
fontWeight: '500',
fontSize: '0.95rem',
textDecoration: 'line-through',
color: '#999',
},
billAmount: {
fontVariantNumeric: 'tabular-nums',
marginLeft: '0.5rem',
},
billMeta: {
fontSize: '0.8rem',
color: '#888',
display: 'flex',
gap: '0.5rem',
marginTop: '1px',
},
category: {
background: '#e8eaf0',
borderRadius: '3px',
padding: '0 4px',
fontSize: '0.75rem',
color: '#666',
},
oteRow: {
display: 'flex',
justifyContent: 'space-between',
fontSize: '0.95rem',
padding: '0.2rem 0',
},
oteName: {
color: '#333',
},
oteAmount: {
fontVariantNumeric: 'tabular-nums',
color: '#333',
},
// Actuals styles
actualsError: {
color: '#c0392b',
fontSize: '0.85rem',
marginBottom: '0.4rem',
},
actualRow: {
marginBottom: '0.5rem',
fontSize: '0.9rem',
},
actualMain: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'baseline',
},
actualCategory: {
fontWeight: '500',
color: '#333',
},
actualAmount: {
fontVariantNumeric: 'tabular-nums',
color: '#333',
marginLeft: '0.5rem',
},
actualMeta: {
display: 'flex',
gap: '0.5rem',
alignItems: 'center',
fontSize: '0.8rem',
color: '#888',
marginTop: '1px',
},
actualNote: {
fontStyle: 'italic',
flex: 1,
},
actualDate: {
whiteSpace: 'nowrap',
},
deleteButton: {
background: 'none',
border: 'none',
cursor: 'pointer',
color: '#c0392b',
fontSize: '1rem',
lineHeight: '1',
padding: '0 2px',
opacity: 0.7,
},
actualForm: {
marginTop: '0.75rem',
display: 'flex',
flexDirection: 'column',
gap: '0.4rem',
},
actualFormRow: {
display: 'flex',
gap: '0.4rem',
flexWrap: 'wrap',
},
formSelect: {
flex: 1,
minWidth: '100px',
fontSize: '0.85rem',
padding: '0.3rem 0.4rem',
border: '1px solid #ccc',
borderRadius: '4px',
},
formInput: {
flex: 1,
minWidth: '70px',
fontSize: '0.85rem',
padding: '0.3rem 0.4rem',
border: '1px solid #ccc',
borderRadius: '4px',
},
addButton: {
padding: '0.3rem 0.75rem',
fontSize: '0.85rem',
cursor: 'pointer',
border: '1px solid #bbb',
borderRadius: '4px',
background: '#e8f0e8',
color: '#2a7a2a',
fontWeight: '600',
whiteSpace: 'nowrap',
},
formError: {
color: '#c0392b',
fontSize: '0.8rem',
},
oteAddForm: {
display: 'flex',
gap: '0.4rem',
marginTop: '0.5rem',
alignItems: 'center',
},
oteAddInput: {
padding: '0.2rem 0.4rem',
fontSize: '0.875rem',
border: '1px solid #ccc',
borderRadius: '3px',
flex: 1,
},
oteAddButton: {
padding: '0.2rem 0.6rem',
fontSize: '0.875rem',
cursor: 'pointer',
border: '1px solid #bbb',
borderRadius: '3px',
background: '#f0f0f0',
flexShrink: 0,
},
remainingRow: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: '0.5rem',
paddingTop: '0.75rem',
borderTop: '2px solid #ddd',
},
remainingLabel: {
fontWeight: '600',
fontSize: '1rem',
},
remainingAmount: {
fontWeight: '700',
fontSize: '1.1rem',
fontVariantNumeric: 'tabular-nums',
},
};
export default PaycheckView; export default PaycheckView;

View File

@@ -1,54 +1,5 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
const fieldStyle = {
display: 'flex',
flexDirection: 'column',
gap: '4px',
marginBottom: '16px',
};
const labelStyle = {
fontWeight: '600',
fontSize: '14px',
};
const inputStyle = {
padding: '8px 10px',
fontSize: '14px',
border: '1px solid #ccc',
borderRadius: '4px',
width: '180px',
};
const sectionStyle = {
marginBottom: '32px',
};
const sectionTitleStyle = {
fontSize: '18px',
fontWeight: '700',
marginBottom: '16px',
borderBottom: '2px solid #e5e7eb',
paddingBottom: '8px',
};
const submitStyle = {
padding: '10px 24px',
fontSize: '15px',
fontWeight: '600',
background: '#2563eb',
color: '#fff',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
};
const successStyle = {
color: '#16a34a',
fontWeight: '600',
marginTop: '12px',
};
const DEFAULT_FORM = { const DEFAULT_FORM = {
paycheck1_day: '', paycheck1_day: '',
paycheck2_day: '', paycheck2_day: '',
@@ -65,8 +16,8 @@ function Settings() {
useEffect(() => { useEffect(() => {
fetch('/api/config') fetch('/api/config')
.then((res) => res.json()) .then(res => res.json())
.then((data) => { .then(data => {
setForm({ setForm({
paycheck1_day: data.paycheck1_day ?? '', paycheck1_day: data.paycheck1_day ?? '',
paycheck2_day: data.paycheck2_day ?? '', paycheck2_day: data.paycheck2_day ?? '',
@@ -82,7 +33,7 @@ function Settings() {
function handleChange(e) { function handleChange(e) {
const { name, value } = e.target; const { name, value } = e.target;
setSaved(false); setSaved(false);
setForm((prev) => ({ ...prev, [name]: value })); setForm(prev => ({ ...prev, [name]: value }));
} }
function handleSubmit(e) { function handleSubmit(e) {
@@ -92,9 +43,7 @@ function Settings() {
const payload = {}; const payload = {};
for (const [key, val] of Object.entries(form)) { for (const [key, val] of Object.entries(form)) {
if (val !== '' && val !== null) { if (val !== '' && val !== null) payload[key] = Number(val);
payload[key] = Number(val);
}
} }
fetch('/api/config', { fetch('/api/config', {
@@ -102,11 +51,11 @@ function Settings() {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
}) })
.then((res) => { .then(res => {
if (!res.ok) throw new Error('Save failed'); if (!res.ok) throw new Error('Save failed');
return res.json(); return res.json();
}) })
.then((data) => { .then(data => {
setForm({ setForm({
paycheck1_day: data.paycheck1_day ?? '', paycheck1_day: data.paycheck1_day ?? '',
paycheck2_day: data.paycheck2_day ?? '', paycheck2_day: data.paycheck2_day ?? '',
@@ -121,115 +70,74 @@ function Settings() {
} }
return ( return (
<div style={{ maxWidth: '480px', margin: '32px auto', padding: '0 16px' }}> <div style={{ maxWidth: '520px' }}>
<h1 style={{ fontSize: '24px', fontWeight: '800', marginBottom: '24px' }}>Settings</h1> <div className="page-header">
<h1 className="page-title">Settings</h1>
</div>
{error && <p style={{ color: '#dc2626', marginBottom: '16px' }}>{error}</p>} {error && <div className="alert alert-error">{error}</div>}
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div style={sectionStyle}> <div className="card card-body mb-2">
<div style={sectionTitleStyle}>Pay Schedule</div> <div className="settings-section">
<div className="settings-section__title">Pay Schedule</div>
<div style={fieldStyle}> <div className="settings-grid">
<label style={labelStyle} htmlFor="paycheck1_day">Paycheck 1 Day</label> <div className="form-group">
<input <label className="form-label" htmlFor="paycheck1_day">Paycheck 1 Day</label>
id="paycheck1_day" <input id="paycheck1_day" name="paycheck1_day" type="number"
name="paycheck1_day" min="1" max="28" value={form.paycheck1_day} onChange={handleChange}
type="number" className="form-input" placeholder="e.g. 1" />
min="1" </div>
max="28" <div className="form-group">
value={form.paycheck1_day} <label className="form-label" htmlFor="paycheck2_day">Paycheck 2 Day</label>
onChange={handleChange} <input id="paycheck2_day" name="paycheck2_day" type="number"
style={inputStyle} min="1" max="28" value={form.paycheck2_day} onChange={handleChange}
/> className="form-input" placeholder="e.g. 15" />
</div> </div>
<div style={fieldStyle}>
<label style={labelStyle} htmlFor="paycheck2_day">Paycheck 2 Day</label>
<input
id="paycheck2_day"
name="paycheck2_day"
type="number"
min="1"
max="28"
value={form.paycheck2_day}
onChange={handleChange}
style={inputStyle}
/>
</div>
</div>
<div style={sectionStyle}>
<div style={sectionTitleStyle}>Paycheck Amounts</div>
<div style={{ marginBottom: '20px' }}>
<div style={{ fontWeight: '700', marginBottom: '10px' }}>Paycheck 1</div>
<div style={fieldStyle}>
<label style={labelStyle} htmlFor="paycheck1_gross">Gross</label>
<input
id="paycheck1_gross"
name="paycheck1_gross"
type="number"
min="0"
step="0.01"
value={form.paycheck1_gross}
onChange={handleChange}
style={inputStyle}
placeholder="0.00"
/>
</div>
<div style={fieldStyle}>
<label style={labelStyle} htmlFor="paycheck1_net">Net</label>
<input
id="paycheck1_net"
name="paycheck1_net"
type="number"
min="0"
step="0.01"
value={form.paycheck1_net}
onChange={handleChange}
style={inputStyle}
placeholder="0.00"
/>
</div> </div>
</div> </div>
<div> <div className="settings-section">
<div style={{ fontWeight: '700', marginBottom: '10px' }}>Paycheck 2</div> <div className="settings-section__title">Paycheck 1 Amounts</div>
<div style={fieldStyle}> <div className="settings-grid">
<label style={labelStyle} htmlFor="paycheck2_gross">Gross</label> <div className="form-group">
<input <label className="form-label" htmlFor="paycheck1_gross">Gross</label>
id="paycheck2_gross" <input id="paycheck1_gross" name="paycheck1_gross" type="number"
name="paycheck2_gross" min="0" step="0.01" value={form.paycheck1_gross} onChange={handleChange}
type="number" className="form-input" placeholder="0.00" />
min="0" </div>
step="0.01" <div className="form-group">
value={form.paycheck2_gross} <label className="form-label" htmlFor="paycheck1_net">Net</label>
onChange={handleChange} <input id="paycheck1_net" name="paycheck1_net" type="number"
style={inputStyle} min="0" step="0.01" value={form.paycheck1_net} onChange={handleChange}
placeholder="0.00" className="form-input" placeholder="0.00" />
/> </div>
</div> </div>
<div style={fieldStyle}> </div>
<label style={labelStyle} htmlFor="paycheck2_net">Net</label>
<input <div className="settings-section" style={{ marginBottom: 0 }}>
id="paycheck2_net" <div className="settings-section__title">Paycheck 2 Amounts</div>
name="paycheck2_net" <div className="settings-grid">
type="number" <div className="form-group">
min="0" <label className="form-label" htmlFor="paycheck2_gross">Gross</label>
step="0.01" <input id="paycheck2_gross" name="paycheck2_gross" type="number"
value={form.paycheck2_net} min="0" step="0.01" value={form.paycheck2_gross} onChange={handleChange}
onChange={handleChange} className="form-input" placeholder="0.00" />
style={inputStyle} </div>
placeholder="0.00" <div className="form-group">
/> <label className="form-label" htmlFor="paycheck2_net">Net</label>
<input id="paycheck2_net" name="paycheck2_net" type="number"
min="0" step="0.01" value={form.paycheck2_net} onChange={handleChange}
className="form-input" placeholder="0.00" />
</div>
</div> </div>
</div> </div>
</div> </div>
<button type="submit" style={submitStyle}>Save Settings</button> <div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<button type="submit" className="btn btn-primary">Save Settings</button>
{saved && <p style={successStyle}>Settings saved</p>} {saved && <span className="alert-success" style={{ padding: '0.3rem 0' }}>Saved </span>}
</div>
</form> </form>
</div> </div>
); );