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:
@@ -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 />} />
|
||||||
|
|||||||
30
client/src/ThemeContext.jsx
Normal file
30
client/src/ThemeContext.jsx
Normal 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
784
client/src/index.css
Normal 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; }
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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">←</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">→</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (1–31)</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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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}>←</button>
|
<button className="btn-nav" onClick={prevMonth}>←</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}>→</button>
|
<button className="btn-nav" onClick={nextMonth}>→</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;
|
||||||
|
|||||||
@@ -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">×</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">×</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"
|
||||||
×
|
/>
|
||||||
</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">×</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}>←</button>
|
<button className="btn-nav" onClick={prevMonth}>←</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}>→</button>
|
<button className="btn-nav" onClick={nextMonth}>→</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;
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user