Add variable amount bills
- Migration 002: variable_amount boolean column on bills (default false) - Bills form: 'Variable amount' checkbox; amount field becomes optional 'Typical amount' when checked; table shows 'varies (~$X)' and a 〜 badge - Paycheck view: variable bills show a pencil edit button to enter the month's actual amount, stored as amount_override on paycheck_bills - New PATCH /api/paycheck-bills/:id/amount endpoint - Lazy generation still works: setting an amount on a virtual paycheck generates it first then saves the override Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -27,7 +27,7 @@ function todayISO() {
|
||||
|
||||
// ─── PaycheckColumn ───────────────────────────────────────────────────────────
|
||||
|
||||
function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggle, onOteDelete, onOteAdd, onGenerate, onAmountSave }) {
|
||||
function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggle, onOteDelete, onOteAdd, onGenerate, onAmountSave, onBillAmountSave }) {
|
||||
const [newOteName, setNewOteName] = useState('');
|
||||
const [newOteAmount, setNewOteAmount] = useState('');
|
||||
const [actuals, setActuals] = useState([]);
|
||||
@@ -41,6 +41,23 @@ function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggl
|
||||
const [formSubmitting, setFormSubmitting] = useState(false);
|
||||
const [formError, setFormError] = useState(null);
|
||||
|
||||
// Inline bill amount editing (for variable bills)
|
||||
const [editingBillAmount, setEditingBillAmount] = useState(null); // bill_id
|
||||
const [billAmountDraft, setBillAmountDraft] = useState('');
|
||||
const [billAmountSaving, setBillAmountSaving] = useState(false);
|
||||
|
||||
async function saveBillAmount(bill) {
|
||||
setBillAmountSaving(true);
|
||||
try {
|
||||
await onBillAmountSave(bill.paycheck_bill_id, bill.bill_id, paycheck.paycheck_number, parseFloat(billAmountDraft) || 0);
|
||||
setEditingBillAmount(null);
|
||||
} catch (err) {
|
||||
alert(`Failed to save amount: ${err.message}`);
|
||||
} finally {
|
||||
setBillAmountSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Inline gross/net editing
|
||||
const [editingAmounts, setEditingAmounts] = useState(false);
|
||||
const [editGross, setEditGross] = useState('');
|
||||
@@ -242,10 +259,47 @@ function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggl
|
||||
<div className="bill-row__info">
|
||||
<div className={`bill-row__name${bill.paid ? ' paid' : ''}`}>
|
||||
<span>{bill.name}</span>
|
||||
<span className="bill-row__amount">{formatCurrency(bill.effective_amount)}</span>
|
||||
{bill.variable_amount && editingBillAmount === bill.bill_id ? (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={billAmountDraft}
|
||||
onChange={e => setBillAmountDraft(e.target.value)}
|
||||
className="form-input"
|
||||
style={{ width: '90px', padding: '0.2rem 0.35rem', fontSize: '0.8rem' }}
|
||||
autoFocus
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') saveBillAmount(bill);
|
||||
if (e.key === 'Escape') setEditingBillAmount(null);
|
||||
}}
|
||||
/>
|
||||
<button className="btn btn-sm btn-primary" disabled={billAmountSaving}
|
||||
onClick={() => saveBillAmount(bill)} style={{ padding: '0.2rem 0.4rem', fontSize: '0.75rem' }}>
|
||||
✓
|
||||
</button>
|
||||
<button className="btn btn-sm" onClick={() => setEditingBillAmount(null)}
|
||||
style={{ padding: '0.2rem 0.4rem', fontSize: '0.75rem' }}>✕</button>
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
|
||||
<span className="bill-row__amount">
|
||||
{bill.variable_amount && !bill.amount_override
|
||||
? <span className="text-faint" style={{ fontSize: '0.8rem' }}>enter amount</span>
|
||||
: formatCurrency(bill.effective_amount)}
|
||||
</span>
|
||||
{bill.variable_amount && !bill.paid && (
|
||||
<button className="btn-icon" style={{ fontSize: '0.8rem', color: 'var(--text-faint)' }}
|
||||
onClick={() => { setBillAmountDraft(bill.amount_override ?? bill.amount ?? ''); setEditingBillAmount(bill.bill_id); }}
|
||||
title="Set this month's amount">✎</button>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="bill-row__meta">
|
||||
<span>due {ordinal(bill.due_day)}</span>
|
||||
{bill.variable_amount && <span className="badge badge-category">variable</span>}
|
||||
{bill.category && <span className="badge badge-category">{bill.category}</span>}
|
||||
</div>
|
||||
</div>
|
||||
@@ -445,6 +499,35 @@ function PaycheckView() {
|
||||
));
|
||||
}
|
||||
|
||||
async function handleBillAmountSave(paycheckBillId, billId, paycheckNumber, amount) {
|
||||
let realPaycheckBillId = paycheckBillId;
|
||||
|
||||
if (!realPaycheckBillId) {
|
||||
const generated = await generateMonth();
|
||||
const pc = generated.find(p => p.paycheck_number === paycheckNumber);
|
||||
realPaycheckBillId = pc.bills.find(b => b.bill_id === billId).paycheck_bill_id;
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/paycheck-bills/${realPaycheckBillId}/amount`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ amount }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json();
|
||||
throw new Error(body.error || `Server error: ${res.status}`);
|
||||
}
|
||||
// Reflect the new override in local state
|
||||
setPaychecks(prev => prev.map(pc => ({
|
||||
...pc,
|
||||
bills: pc.bills.map(b =>
|
||||
b.paycheck_bill_id === realPaycheckBillId
|
||||
? { ...b, amount_override: amount, effective_amount: amount }
|
||||
: b
|
||||
),
|
||||
})));
|
||||
}
|
||||
|
||||
async function handleBillPaidToggle(paycheckBillId, paid, billId, paycheckNumber) {
|
||||
let realPaycheckBillId = paycheckBillId;
|
||||
|
||||
@@ -589,6 +672,7 @@ function PaycheckView() {
|
||||
categories={categories}
|
||||
onGenerate={generateMonth}
|
||||
onAmountSave={handleAmountSave}
|
||||
onBillAmountSave={handleBillAmountSave}
|
||||
/>
|
||||
<PaycheckColumn
|
||||
paycheck={pc2}
|
||||
@@ -599,6 +683,7 @@ function PaycheckView() {
|
||||
categories={categories}
|
||||
onGenerate={generateMonth}
|
||||
onAmountSave={handleAmountSave}
|
||||
onBillAmountSave={handleBillAmountSave}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user