Lazy paycheck generation, regenerate button, inline gross/net edit
- GET /api/paychecks returns virtual data (id: null) without writing to DB when no records exist for the month - First interaction (bill toggle, OTE add, actual add) lazily calls POST /api/paychecks/generate to persist the paycheck - New PATCH /api/paychecks/:id to update gross and net - Regenerate/refresh button syncs gross/net from current Settings - Inline pencil edit for gross/net on each paycheck column header - 'preview' badge and info banner shown for unsaved months Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -25,7 +25,9 @@ function todayISO() {
|
|||||||
return new Date().toISOString().slice(0, 10);
|
return new Date().toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggle, onOteDelete, onOteAdd }) {
|
// ─── PaycheckColumn ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggle, onOteDelete, onOteAdd, onGenerate, onAmountSave }) {
|
||||||
const [newOteName, setNewOteName] = useState('');
|
const [newOteName, setNewOteName] = useState('');
|
||||||
const [newOteAmount, setNewOteAmount] = useState('');
|
const [newOteAmount, setNewOteAmount] = useState('');
|
||||||
const [actuals, setActuals] = useState([]);
|
const [actuals, setActuals] = useState([]);
|
||||||
@@ -39,8 +41,15 @@ function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggl
|
|||||||
const [formSubmitting, setFormSubmitting] = useState(false);
|
const [formSubmitting, setFormSubmitting] = useState(false);
|
||||||
const [formError, setFormError] = useState(null);
|
const [formError, setFormError] = useState(null);
|
||||||
|
|
||||||
|
// Inline gross/net editing
|
||||||
|
const [editingAmounts, setEditingAmounts] = useState(false);
|
||||||
|
const [editGross, setEditGross] = useState('');
|
||||||
|
const [editNet, setEditNet] = useState('');
|
||||||
|
const [amountSaving, setAmountSaving] = useState(false);
|
||||||
|
const [amountError, setAmountError] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!paycheck) return;
|
if (!paycheck?.id) { setActuals([]); return; }
|
||||||
loadActuals(paycheck.id);
|
loadActuals(paycheck.id);
|
||||||
}, [paycheck?.id]);
|
}, [paycheck?.id]);
|
||||||
|
|
||||||
@@ -50,8 +59,7 @@ function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggl
|
|||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/actuals?paycheck_id=${paycheckId}`);
|
const res = await fetch(`/api/actuals?paycheck_id=${paycheckId}`);
|
||||||
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();
|
setActuals(await res.json());
|
||||||
setActuals(data);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setActualsError(err.message);
|
setActualsError(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -65,11 +73,18 @@ function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggl
|
|||||||
setFormSubmitting(true);
|
setFormSubmitting(true);
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
try {
|
try {
|
||||||
|
// Lazy generate if this is a virtual paycheck
|
||||||
|
let paycheckId = paycheck.id;
|
||||||
|
if (!paycheckId) {
|
||||||
|
const generated = await onGenerate();
|
||||||
|
paycheckId = generated.find(p => p.paycheck_number === paycheck.paycheck_number).id;
|
||||||
|
}
|
||||||
|
|
||||||
const res = await fetch('/api/actuals', {
|
const res = await fetch('/api/actuals', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
paycheck_id: paycheck.id,
|
paycheck_id: paycheckId,
|
||||||
category_id: formCategoryId || null,
|
category_id: formCategoryId || null,
|
||||||
amount: parseFloat(formAmount),
|
amount: parseFloat(formAmount),
|
||||||
note: formNote || null,
|
note: formNote || null,
|
||||||
@@ -80,7 +95,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}`);
|
||||||
}
|
}
|
||||||
await loadActuals(paycheck.id);
|
await loadActuals(paycheckId);
|
||||||
setFormCategoryId('');
|
setFormCategoryId('');
|
||||||
setFormAmount('');
|
setFormAmount('');
|
||||||
setFormNote('');
|
setFormNote('');
|
||||||
@@ -105,6 +120,27 @@ function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openAmountEdit() {
|
||||||
|
setEditGross(parseFloat(paycheck.gross) || '');
|
||||||
|
setEditNet(parseFloat(paycheck.net) || '');
|
||||||
|
setAmountError(null);
|
||||||
|
setEditingAmounts(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAmounts(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
setAmountSaving(true);
|
||||||
|
setAmountError(null);
|
||||||
|
try {
|
||||||
|
await onAmountSave(paycheck.paycheck_number, parseFloat(editGross) || 0, parseFloat(editNet) || 0);
|
||||||
|
setEditingAmounts(false);
|
||||||
|
} catch (err) {
|
||||||
|
setAmountError(err.message);
|
||||||
|
} finally {
|
||||||
|
setAmountSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!paycheck) {
|
if (!paycheck) {
|
||||||
return (
|
return (
|
||||||
<div className="paycheck-card">
|
<div className="paycheck-card">
|
||||||
@@ -115,6 +151,7 @@ function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggl
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isVirtual = paycheck.id === null;
|
||||||
const net = parseFloat(paycheck.net) || 0;
|
const net = parseFloat(paycheck.net) || 0;
|
||||||
const billsTotal = paycheck.bills.reduce((sum, b) => sum + (parseFloat(b.effective_amount) || 0), 0);
|
const billsTotal = paycheck.bills.reduce((sum, b) => sum + (parseFloat(b.effective_amount) || 0), 0);
|
||||||
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);
|
||||||
@@ -124,12 +161,67 @@ function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggl
|
|||||||
return (
|
return (
|
||||||
<div className="paycheck-card">
|
<div className="paycheck-card">
|
||||||
<div className="paycheck-card__header">
|
<div className="paycheck-card__header">
|
||||||
<div className="paycheck-card__number">Paycheck {paycheck.paycheck_number}</div>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||||
<div className="paycheck-card__date">{formatPayDate(paycheck.pay_date)}</div>
|
<div>
|
||||||
<div className="paycheck-card__amounts">
|
<div className="paycheck-card__number">
|
||||||
<span>Gross: <strong>{formatCurrency(paycheck.gross)}</strong></span>
|
Paycheck {paycheck.paycheck_number}
|
||||||
<span>Net: <strong>{formatCurrency(paycheck.net)}</strong></span>
|
{isVirtual && (
|
||||||
|
<span style={{
|
||||||
|
marginLeft: '0.5rem',
|
||||||
|
fontSize: '0.65rem',
|
||||||
|
background: 'var(--accent-subtle)',
|
||||||
|
color: 'var(--accent)',
|
||||||
|
padding: '0.1rem 0.4rem',
|
||||||
|
borderRadius: '999px',
|
||||||
|
border: '1px solid var(--accent)',
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
}}>preview</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="paycheck-card__date">{formatPayDate(paycheck.pay_date)}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{editingAmounts ? (
|
||||||
|
<form onSubmit={saveAmounts} style={{ marginTop: '0.5rem' }}>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
|
<div className="form-group" style={{ flex: '1 1 120px' }}>
|
||||||
|
<label className="form-label">Gross</label>
|
||||||
|
<input type="number" min="0" step="0.01" value={editGross}
|
||||||
|
onChange={e => setEditGross(e.target.value)} className="form-input" />
|
||||||
|
</div>
|
||||||
|
<div className="form-group" style={{ flex: '1 1 120px' }}>
|
||||||
|
<label className="form-label">Net</label>
|
||||||
|
<input type="number" min="0" step="0.01" value={editNet}
|
||||||
|
onChange={e => setEditNet(e.target.value)} className="form-input" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{amountError && <div className="form-error">{amountError}</div>}
|
||||||
|
<div style={{ display: 'flex', gap: '0.4rem', marginTop: '0.5rem' }}>
|
||||||
|
<button type="submit" className="btn btn-sm btn-primary" disabled={amountSaving}>
|
||||||
|
{amountSaving ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-sm" onClick={() => setEditingAmounts(false)}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: '0.35rem' }}>
|
||||||
|
<div className="paycheck-card__amounts">
|
||||||
|
<span>Gross: <strong>{formatCurrency(paycheck.gross)}</strong></span>
|
||||||
|
<span>Net: <strong>{formatCurrency(paycheck.net)}</strong></span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="btn-icon"
|
||||||
|
onClick={openAmountEdit}
|
||||||
|
title="Edit gross / net"
|
||||||
|
style={{ fontSize: '0.85rem', color: 'var(--text-faint)', flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
✎
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="paycheck-card__body">
|
<div className="paycheck-card__body">
|
||||||
@@ -140,11 +232,11 @@ function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggl
|
|||||||
<p className="empty-state">(none)</p>
|
<p className="empty-state">(none)</p>
|
||||||
) : (
|
) : (
|
||||||
paycheck.bills.map((bill) => (
|
paycheck.bills.map((bill) => (
|
||||||
<div key={bill.paycheck_bill_id} className="bill-row" style={{ opacity: bill.paid ? 0.6 : 1 }}>
|
<div key={bill.bill_id} className="bill-row" style={{ opacity: bill.paid ? 0.6 : 1 }}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={!!bill.paid}
|
checked={!!bill.paid}
|
||||||
onChange={() => onBillPaidToggle(bill.paycheck_bill_id, !bill.paid)}
|
onChange={() => onBillPaidToggle(bill.paycheck_bill_id, !bill.paid, bill.bill_id, paycheck.paycheck_number)}
|
||||||
className="bill-row__check"
|
className="bill-row__check"
|
||||||
/>
|
/>
|
||||||
<div className="bill-row__info">
|
<div className="bill-row__info">
|
||||||
@@ -183,32 +275,17 @@ function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggl
|
|||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
<div className="inline-add-form">
|
<div className="inline-add-form">
|
||||||
<input
|
<input type="text" placeholder="Name" value={newOteName}
|
||||||
type="text"
|
onChange={(e) => setNewOteName(e.target.value)} className="form-input" />
|
||||||
placeholder="Name"
|
<input type="number" placeholder="Amount" value={newOteAmount}
|
||||||
value={newOteName}
|
onChange={(e) => setNewOteAmount(e.target.value)} min="0" step="0.01"
|
||||||
onChange={(e) => setNewOteName(e.target.value)}
|
className="form-input" style={{ maxWidth: '100px' }} />
|
||||||
className="form-input"
|
<button className="btn btn-sm btn-primary" onClick={() => {
|
||||||
/>
|
if (!newOteName.trim() || !newOteAmount) return;
|
||||||
<input
|
onOteAdd(paycheck.paycheck_number, newOteName.trim(), parseFloat(newOteAmount));
|
||||||
type="number"
|
setNewOteName('');
|
||||||
placeholder="Amount"
|
setNewOteAmount('');
|
||||||
value={newOteAmount}
|
}}>
|
||||||
onChange={(e) => setNewOteAmount(e.target.value)}
|
|
||||||
min="0"
|
|
||||||
step="0.01"
|
|
||||||
className="form-input"
|
|
||||||
style={{ maxWidth: '100px' }}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
className="btn btn-sm btn-primary"
|
|
||||||
onClick={() => {
|
|
||||||
if (!newOteName.trim() || !newOteAmount) return;
|
|
||||||
onOteAdd(paycheck.id, newOteName.trim(), parseFloat(newOteAmount));
|
|
||||||
setNewOteName('');
|
|
||||||
setNewOteAmount('');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Add
|
Add
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -217,7 +294,6 @@ function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggl
|
|||||||
{/* Variable spending */}
|
{/* Variable spending */}
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
<div className="section-title">Variable Spending</div>
|
<div className="section-title">Variable Spending</div>
|
||||||
|
|
||||||
{actualsLoading && <p className="empty-state">Loading…</p>}
|
{actualsLoading && <p className="empty-state">Loading…</p>}
|
||||||
{actualsError && <div className="alert alert-error">Error: {actualsError}</div>}
|
{actualsError && <div className="alert alert-error">Error: {actualsError}</div>}
|
||||||
{!actualsLoading && actuals.length === 0 && <p className="empty-state">(none)</p>}
|
{!actualsLoading && actuals.length === 0 && <p className="empty-state">(none)</p>}
|
||||||
@@ -242,43 +318,21 @@ function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggl
|
|||||||
|
|
||||||
<form onSubmit={handleAddActual} className="form-rows">
|
<form onSubmit={handleAddActual} className="form-rows">
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<select
|
<select value={formCategoryId} onChange={e => setFormCategoryId(e.target.value)} className="form-select">
|
||||||
value={formCategoryId}
|
|
||||||
onChange={e => setFormCategoryId(e.target.value)}
|
|
||||||
className="form-select"
|
|
||||||
>
|
|
||||||
<option value="">— Category —</option>
|
<option value="">— Category —</option>
|
||||||
{categories.map(cat => (
|
{categories.map(cat => (
|
||||||
<option key={cat.id} value={cat.id}>{cat.name}</option>
|
<option key={cat.id} value={cat.id}>{cat.name}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<input
|
<input type="number" placeholder="Amount" value={formAmount}
|
||||||
type="number"
|
onChange={e => setFormAmount(e.target.value)} step="0.01" min="0"
|
||||||
placeholder="Amount"
|
className="form-input" style={{ maxWidth: '110px' }} required />
|
||||||
value={formAmount}
|
|
||||||
onChange={e => setFormAmount(e.target.value)}
|
|
||||||
step="0.01"
|
|
||||||
min="0"
|
|
||||||
className="form-input"
|
|
||||||
style={{ maxWidth: '110px' }}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<input
|
<input type="text" placeholder="Note (optional)" value={formNote}
|
||||||
type="text"
|
onChange={e => setFormNote(e.target.value)} className="form-input" />
|
||||||
placeholder="Note (optional)"
|
<input type="date" value={formDate} onChange={e => setFormDate(e.target.value)}
|
||||||
value={formNote}
|
className="form-input" style={{ maxWidth: '140px' }} />
|
||||||
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 }}>
|
<button type="submit" disabled={formSubmitting} className="btn btn-sm btn-primary" style={{ flexShrink: 0 }}>
|
||||||
Add
|
Add
|
||||||
</button>
|
</button>
|
||||||
@@ -299,6 +353,8 @@ function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggl
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── PaycheckView ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function PaycheckView() {
|
function PaycheckView() {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const [year, setYear] = useState(now.getFullYear());
|
const [year, setYear] = useState(now.getFullYear());
|
||||||
@@ -307,6 +363,7 @@ function PaycheckView() {
|
|||||||
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([]);
|
||||||
|
const [regenerating, setRegenerating] = useState(false);
|
||||||
|
|
||||||
useEffect(() => { loadPaychecks(year, month); }, [year, month]);
|
useEffect(() => { loadPaychecks(year, month); }, [year, month]);
|
||||||
useEffect(() => { loadCategories(); }, []);
|
useEffect(() => { loadCategories(); }, []);
|
||||||
@@ -333,6 +390,27 @@ function PaycheckView() {
|
|||||||
} catch { /* silent */ }
|
} catch { /* silent */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generates (or regenerates) paychecks for the current month, updates state,
|
||||||
|
// and returns the new paychecks array.
|
||||||
|
async function generateMonth() {
|
||||||
|
const res = await fetch(`/api/paychecks/generate?year=${year}&month=${month}`, { method: 'POST' });
|
||||||
|
if (!res.ok) throw new Error(`Server error: ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
setPaychecks(data);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRegenerate() {
|
||||||
|
setRegenerating(true);
|
||||||
|
try {
|
||||||
|
await generateMonth();
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Failed to regenerate: ${err.message}`);
|
||||||
|
} finally {
|
||||||
|
setRegenerating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function prevMonth() {
|
function prevMonth() {
|
||||||
if (month === 1) { setYear(y => y - 1); setMonth(12); } else { setMonth(m => m - 1); }
|
if (month === 1) { setYear(y => y - 1); setMonth(12); } else { setMonth(m => m - 1); }
|
||||||
}
|
}
|
||||||
@@ -341,6 +419,81 @@ function PaycheckView() {
|
|||||||
if (month === 12) { setYear(y => y + 1); setMonth(1); } else { setMonth(m => m + 1); }
|
if (month === 12) { setYear(y => y + 1); setMonth(1); } else { setMonth(m => m + 1); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Saves gross/net for a paycheck. If virtual, generates first then patches.
|
||||||
|
async function handleAmountSave(paycheckNumber, gross, net) {
|
||||||
|
let pc = paychecks.find(p => p.paycheck_number === paycheckNumber);
|
||||||
|
|
||||||
|
if (!pc.id) {
|
||||||
|
const generated = await generateMonth();
|
||||||
|
pc = generated.find(p => p.paycheck_number === paycheckNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`/api/paychecks/${pc.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ gross, net }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json();
|
||||||
|
throw new Error(body.error || `Server error: ${res.status}`);
|
||||||
|
}
|
||||||
|
const updated = await res.json();
|
||||||
|
setPaychecks(prev => prev.map(p =>
|
||||||
|
p.paycheck_number === paycheckNumber
|
||||||
|
? { ...p, id: updated.id, gross: updated.gross, net: updated.net }
|
||||||
|
: p
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBillPaidToggle(paycheckBillId, paid, billId, paycheckNumber) {
|
||||||
|
let realPaycheckBillId = paycheckBillId;
|
||||||
|
|
||||||
|
if (!realPaycheckBillId) {
|
||||||
|
// Virtual paycheck — generate first, then find the real paycheck_bill_id
|
||||||
|
const generated = await generateMonth();
|
||||||
|
const pc = generated.find(p => p.paycheck_number === paycheckNumber);
|
||||||
|
const bill = pc.bills.find(b => b.bill_id === billId);
|
||||||
|
realPaycheckBillId = bill.paycheck_bill_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optimistic update
|
||||||
|
setPaychecks(prev => prev.map(pc => ({
|
||||||
|
...pc,
|
||||||
|
bills: pc.bills.map(b =>
|
||||||
|
b.paycheck_bill_id === realPaycheckBillId
|
||||||
|
? { ...b, paid, paid_at: paid ? new Date().toISOString() : null }
|
||||||
|
: b
|
||||||
|
),
|
||||||
|
})));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/paycheck-bills/${realPaycheckBillId}/paid`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ paid }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Server error: ${res.status}`);
|
||||||
|
const updated = await res.json();
|
||||||
|
setPaychecks(prev => prev.map(pc => ({
|
||||||
|
...pc,
|
||||||
|
bills: pc.bills.map(b =>
|
||||||
|
b.paycheck_bill_id === realPaycheckBillId
|
||||||
|
? { ...b, paid: updated.paid, paid_at: updated.paid_at }
|
||||||
|
: b
|
||||||
|
),
|
||||||
|
})));
|
||||||
|
} catch (err) {
|
||||||
|
// Revert on failure
|
||||||
|
setPaychecks(prev => prev.map(pc => ({
|
||||||
|
...pc,
|
||||||
|
bills: pc.bills.map(b =>
|
||||||
|
b.paycheck_bill_id === realPaycheckBillId ? { ...b, paid: !paid } : b
|
||||||
|
),
|
||||||
|
})));
|
||||||
|
alert(`Failed to update bill: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleOtePaidToggle(oteId, paid) {
|
async function handleOtePaidToggle(oteId, paid) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/one-time-expenses/${oteId}/paid`, {
|
const res = await fetch(`/api/one-time-expenses/${oteId}/paid`, {
|
||||||
@@ -366,7 +519,15 @@ function PaycheckView() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleOteAdd(paycheckId, name, amount) {
|
async function handleOteAdd(paycheckNumber, name, amount) {
|
||||||
|
let pc = paychecks.find(p => p.paycheck_number === paycheckNumber);
|
||||||
|
let paycheckId = pc?.id;
|
||||||
|
|
||||||
|
if (!paycheckId) {
|
||||||
|
const generated = await generateMonth();
|
||||||
|
paycheckId = generated.find(p => p.paycheck_number === paycheckNumber).id;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/one-time-expenses', {
|
const res = await fetch('/api/one-time-expenses', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -380,51 +541,9 @@ function PaycheckView() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleBillPaidToggle(paycheckBillId, paid) {
|
|
||||||
setPaychecks(prev =>
|
|
||||||
prev.map(pc => ({
|
|
||||||
...pc,
|
|
||||||
bills: pc.bills.map(b =>
|
|
||||||
b.paycheck_bill_id === paycheckBillId
|
|
||||||
? { ...b, paid, paid_at: paid ? new Date().toISOString() : null }
|
|
||||||
: b
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/paycheck-bills/${paycheckBillId}/paid`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ paid }),
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error(`Server error: ${res.status}`);
|
|
||||||
const updated = await res.json();
|
|
||||||
setPaychecks(prev =>
|
|
||||||
prev.map(pc => ({
|
|
||||||
...pc,
|
|
||||||
bills: pc.bills.map(b =>
|
|
||||||
b.paycheck_bill_id === paycheckBillId
|
|
||||||
? { ...b, paid: updated.paid, paid_at: updated.paid_at }
|
|
||||||
: b
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
setPaychecks(prev =>
|
|
||||||
prev.map(pc => ({
|
|
||||||
...pc,
|
|
||||||
bills: pc.bills.map(b =>
|
|
||||||
b.paycheck_bill_id === paycheckBillId ? { ...b, paid: !paid } : b
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
alert(`Failed to update bill: ${err.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const pc1 = paychecks.find(p => p.paycheck_number === 1) || null;
|
const pc1 = paychecks.find(p => p.paycheck_number === 1) || null;
|
||||||
const pc2 = paychecks.find(p => p.paycheck_number === 2) || null;
|
const pc2 = paychecks.find(p => p.paycheck_number === 2) || null;
|
||||||
|
const isVirtual = paychecks.length > 0 && paychecks.every(p => p.id === null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -432,8 +551,29 @@ function PaycheckView() {
|
|||||||
<button className="btn-nav" onClick={prevMonth}>←</button>
|
<button className="btn-nav" onClick={prevMonth}>←</button>
|
||||||
<span className="period-nav__label">{MONTH_NAMES[month - 1]} {year}</span>
|
<span className="period-nav__label">{MONTH_NAMES[month - 1]} {year}</span>
|
||||||
<button className="btn-nav" onClick={nextMonth}>→</button>
|
<button className="btn-nav" onClick={nextMonth}>→</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm"
|
||||||
|
onClick={handleRegenerate}
|
||||||
|
disabled={regenerating}
|
||||||
|
title="Refresh gross/net from current Settings for this month"
|
||||||
|
style={{ marginLeft: '0.5rem' }}
|
||||||
|
>
|
||||||
|
{regenerating ? 'Refreshing…' : '↺ Refresh amounts'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isVirtual && (
|
||||||
|
<div className="alert" style={{
|
||||||
|
background: 'var(--accent-subtle)',
|
||||||
|
border: '1px solid var(--accent)',
|
||||||
|
color: 'var(--accent)',
|
||||||
|
marginBottom: '1rem',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
}}>
|
||||||
|
Previewing from current settings — no data saved yet for this month.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && <div className="alert alert-error">Error: {error}</div>}
|
{error && <div className="alert alert-error">Error: {error}</div>}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -447,6 +587,8 @@ function PaycheckView() {
|
|||||||
onOteDelete={handleOteDelete}
|
onOteDelete={handleOteDelete}
|
||||||
onOteAdd={handleOteAdd}
|
onOteAdd={handleOteAdd}
|
||||||
categories={categories}
|
categories={categories}
|
||||||
|
onGenerate={generateMonth}
|
||||||
|
onAmountSave={handleAmountSave}
|
||||||
/>
|
/>
|
||||||
<PaycheckColumn
|
<PaycheckColumn
|
||||||
paycheck={pc2}
|
paycheck={pc2}
|
||||||
@@ -455,6 +597,8 @@ function PaycheckView() {
|
|||||||
onOteDelete={handleOteDelete}
|
onOteDelete={handleOteDelete}
|
||||||
onOteAdd={handleOteAdd}
|
onOteAdd={handleOteAdd}
|
||||||
categories={categories}
|
categories={categories}
|
||||||
|
onGenerate={generateMonth}
|
||||||
|
onAmountSave={handleAmountSave}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -38,13 +38,59 @@ async function getConfig() {
|
|||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pad a number to two digits
|
|
||||||
function pad2(n) {
|
function pad2(n) {
|
||||||
return String(n).padStart(2, '0');
|
return String(n).padStart(2, '0');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build virtual (unsaved) paycheck data from config + active bills.
|
||||||
|
// Returns the same shape as fetchPaychecksForMonth but with id: null
|
||||||
|
// and paycheck_bill_id: null — nothing is written to the DB.
|
||||||
|
async function buildVirtualPaychecks(year, month) {
|
||||||
|
const config = await getConfig();
|
||||||
|
const paychecks = [];
|
||||||
|
|
||||||
|
for (const num of [1, 2]) {
|
||||||
|
const day = num === 1 ? config.paycheck1_day : config.paycheck2_day;
|
||||||
|
const gross = num === 1 ? config.paycheck1_gross : config.paycheck2_gross;
|
||||||
|
const net = num === 1 ? config.paycheck1_net : config.paycheck2_net;
|
||||||
|
const payDate = `${year}-${pad2(month)}-${pad2(day ?? 1)}`;
|
||||||
|
|
||||||
|
const billsResult = await pool.query(
|
||||||
|
`SELECT id, name, amount, due_day, category
|
||||||
|
FROM bills WHERE active = TRUE AND assigned_paycheck = $1
|
||||||
|
ORDER BY due_day, name`,
|
||||||
|
[num]
|
||||||
|
);
|
||||||
|
|
||||||
|
paychecks.push({
|
||||||
|
id: null,
|
||||||
|
period_year: year,
|
||||||
|
period_month: month,
|
||||||
|
paycheck_number: num,
|
||||||
|
pay_date: payDate,
|
||||||
|
gross: gross || 0,
|
||||||
|
net: net || 0,
|
||||||
|
bills: billsResult.rows.map(b => ({
|
||||||
|
paycheck_bill_id: null,
|
||||||
|
bill_id: b.id,
|
||||||
|
name: b.name,
|
||||||
|
amount: b.amount,
|
||||||
|
amount_override: null,
|
||||||
|
effective_amount: b.amount,
|
||||||
|
due_day: b.due_day,
|
||||||
|
category: b.category,
|
||||||
|
paid: false,
|
||||||
|
paid_at: null,
|
||||||
|
})),
|
||||||
|
one_time_expenses: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return paychecks;
|
||||||
|
}
|
||||||
|
|
||||||
// Generate (upsert) paycheck records for the given year/month.
|
// Generate (upsert) paycheck records for the given year/month.
|
||||||
// Returns the two paycheck rows with their assigned bills.
|
// Returns the two paycheck IDs.
|
||||||
async function generatePaychecks(year, month) {
|
async function generatePaychecks(year, month) {
|
||||||
const config = await getConfig();
|
const config = await getConfig();
|
||||||
|
|
||||||
@@ -55,12 +101,11 @@ async function generatePaychecks(year, month) {
|
|||||||
const paycheckIds = [];
|
const paycheckIds = [];
|
||||||
|
|
||||||
for (const num of [1, 2]) {
|
for (const num of [1, 2]) {
|
||||||
const day = num === 1 ? config.paycheck1_day : config.paycheck2_day;
|
const day = num === 1 ? config.paycheck1_day : config.paycheck2_day;
|
||||||
const gross = num === 1 ? config.paycheck1_gross : config.paycheck2_gross;
|
const gross = num === 1 ? config.paycheck1_gross : config.paycheck2_gross;
|
||||||
const net = num === 1 ? config.paycheck1_net : config.paycheck2_net;
|
const net = num === 1 ? config.paycheck1_net : config.paycheck2_net;
|
||||||
const payDate = `${year}-${pad2(month)}-${pad2(day)}`;
|
const payDate = `${year}-${pad2(month)}-${pad2(day)}`;
|
||||||
|
|
||||||
// Upsert paycheck record
|
|
||||||
const pcResult = await client.query(
|
const pcResult = await client.query(
|
||||||
`INSERT INTO paychecks (period_year, period_month, paycheck_number, pay_date, gross, net)
|
`INSERT INTO paychecks (period_year, period_month, paycheck_number, pay_date, gross, net)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
@@ -74,13 +119,11 @@ async function generatePaychecks(year, month) {
|
|||||||
const paycheckId = pcResult.rows[0].id;
|
const paycheckId = pcResult.rows[0].id;
|
||||||
paycheckIds.push(paycheckId);
|
paycheckIds.push(paycheckId);
|
||||||
|
|
||||||
// Fetch all active bills assigned to this paycheck number
|
|
||||||
const billsResult = await client.query(
|
const billsResult = await client.query(
|
||||||
'SELECT id FROM bills WHERE active = TRUE AND assigned_paycheck = $1',
|
'SELECT id FROM bills WHERE active = TRUE AND assigned_paycheck = $1',
|
||||||
[num]
|
[num]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Idempotently insert each bill into paycheck_bills
|
|
||||||
for (const bill of billsResult.rows) {
|
for (const bill of billsResult.rows) {
|
||||||
await client.query(
|
await client.query(
|
||||||
`INSERT INTO paycheck_bills (paycheck_id, bill_id)
|
`INSERT INTO paycheck_bills (paycheck_id, bill_id)
|
||||||
@@ -103,7 +146,6 @@ async function generatePaychecks(year, month) {
|
|||||||
|
|
||||||
// Fetch both paycheck records for a month with full bill and one_time_expense data.
|
// Fetch both paycheck records for a month with full bill and one_time_expense data.
|
||||||
async function fetchPaychecksForMonth(year, month) {
|
async function fetchPaychecksForMonth(year, month) {
|
||||||
// Fetch paycheck rows
|
|
||||||
const pcResult = await pool.query(
|
const pcResult = await pool.query(
|
||||||
`SELECT id, period_year, period_month, paycheck_number, pay_date, gross, net
|
`SELECT id, period_year, period_month, paycheck_number, pay_date, gross, net
|
||||||
FROM paychecks
|
FROM paychecks
|
||||||
@@ -115,7 +157,6 @@ async function fetchPaychecksForMonth(year, month) {
|
|||||||
const paychecks = [];
|
const paychecks = [];
|
||||||
|
|
||||||
for (const pc of pcResult.rows) {
|
for (const pc of pcResult.rows) {
|
||||||
// Fetch associated bills joined with bill definitions
|
|
||||||
const billsResult = await pool.query(
|
const billsResult = await pool.query(
|
||||||
`SELECT pb.id AS paycheck_bill_id,
|
`SELECT pb.id AS paycheck_bill_id,
|
||||||
pb.bill_id,
|
pb.bill_id,
|
||||||
@@ -134,7 +175,6 @@ async function fetchPaychecksForMonth(year, month) {
|
|||||||
[pc.id]
|
[pc.id]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Fetch one-time expenses
|
|
||||||
const oteResult = await pool.query(
|
const oteResult = await pool.query(
|
||||||
`SELECT id, name, amount, paid, paid_at
|
`SELECT id, name, amount, paid, paid_at
|
||||||
FROM one_time_expenses
|
FROM one_time_expenses
|
||||||
@@ -172,7 +212,7 @@ async function fetchPaychecksForMonth(year, month) {
|
|||||||
|
|
||||||
// POST /api/paychecks/generate?year=&month=
|
// POST /api/paychecks/generate?year=&month=
|
||||||
router.post('/paychecks/generate', async (req, res) => {
|
router.post('/paychecks/generate', async (req, res) => {
|
||||||
const year = parseInt(req.query.year, 10);
|
const year = parseInt(req.query.year, 10);
|
||||||
const month = parseInt(req.query.month, 10);
|
const month = parseInt(req.query.month, 10);
|
||||||
|
|
||||||
if (isNaN(year) || isNaN(month) || month < 1 || month > 12) {
|
if (isNaN(year) || isNaN(month) || month < 1 || month > 12) {
|
||||||
@@ -190,8 +230,9 @@ router.post('/paychecks/generate', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/paychecks?year=&month=
|
// GET /api/paychecks?year=&month=
|
||||||
|
// Returns virtual (unsaved) data when no DB records exist for the month.
|
||||||
router.get('/paychecks', async (req, res) => {
|
router.get('/paychecks', async (req, res) => {
|
||||||
const year = parseInt(req.query.year, 10);
|
const year = parseInt(req.query.year, 10);
|
||||||
const month = parseInt(req.query.month, 10);
|
const month = parseInt(req.query.month, 10);
|
||||||
|
|
||||||
if (isNaN(year) || isNaN(month) || month < 1 || month > 12) {
|
if (isNaN(year) || isNaN(month) || month < 1 || month > 12) {
|
||||||
@@ -199,14 +240,14 @@ router.get('/paychecks', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if paychecks exist for this month; if not, auto-generate
|
|
||||||
const existing = await pool.query(
|
const existing = await pool.query(
|
||||||
'SELECT id FROM paychecks WHERE period_year = $1 AND period_month = $2 LIMIT 1',
|
'SELECT id FROM paychecks WHERE period_year = $1 AND period_month = $2 LIMIT 1',
|
||||||
[year, month]
|
[year, month]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (existing.rows.length === 0) {
|
if (existing.rows.length === 0) {
|
||||||
await generatePaychecks(year, month);
|
const virtual = await buildVirtualPaychecks(year, month);
|
||||||
|
return res.json(virtual);
|
||||||
}
|
}
|
||||||
|
|
||||||
const paychecks = await fetchPaychecksForMonth(year, month);
|
const paychecks = await fetchPaychecksForMonth(year, month);
|
||||||
@@ -232,6 +273,36 @@ router.get('/paychecks/months', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// PATCH /api/paychecks/:id — update gross and net
|
||||||
|
router.patch('/paychecks/:id', async (req, res) => {
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
if (isNaN(id)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid id' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { gross, net } = req.body;
|
||||||
|
if (gross == null || net == null) {
|
||||||
|
return res.status(400).json({ error: 'gross and net are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE paychecks SET gross = $1, net = $2 WHERE id = $3
|
||||||
|
RETURNING id, gross, net`,
|
||||||
|
[parseFloat(gross), parseFloat(net), id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Paycheck not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(result.rows[0]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('PATCH /api/paychecks/:id error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to update paycheck' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// PATCH /api/paycheck-bills/:id/paid
|
// PATCH /api/paycheck-bills/:id/paid
|
||||||
router.patch('/paycheck-bills/:id/paid', async (req, res) => {
|
router.patch('/paycheck-bills/:id/paid', async (req, res) => {
|
||||||
const id = parseInt(req.params.id, 10);
|
const id = parseInt(req.params.id, 10);
|
||||||
|
|||||||
Reference in New Issue
Block a user