// flows.jsx — Claims (+ log-a-claim flow) and Invoices vault const { useState: uS, useEffect: uE } = React; const catName = (k) => (Store.doc.budget.find((b) => b.key === k) || {}).name || k; const catColor = (k) => (Store.doc.budget.find((b) => b.key === k) || {}).color || 'var(--ink-2)'; const catSoft = (k) => (Store.doc.budget.find((b) => b.key === k) || {}).soft || 'var(--surface-2)'; // delete with a built-in second click to confirm (disarms after 3s) function DangerBtn({ onConfirm, label = 'Delete', title }) { const [armed, setArmed] = uS(false); uE(() => { if (armed) { const t = setTimeout(() => setArmed(false), 3000); return () => clearTimeout(t); } }, [armed]); return ( ); } // shared edit-form styles const editInput = { width: '100%', padding: '11px 13px', fontSize: 14.5, fontFamily: 'var(--sans)', border: '1px solid var(--line-2)', borderRadius: 11, background: 'var(--surface)', color: 'var(--ink)', outline: 'none', boxSizing: 'border-box' }; const editLabel = { fontSize: 13, fontWeight: 600, color: 'var(--ink-2)', marginBottom: 6, display: 'block' }; function EditClaim({ claim, onClose }) { const [f, setF] = uS(null); uE(() => { if (claim) setF({ provider: claim.provider, cat: claim.cat, sub: claim.sub || '', date: claim.date, amount: String(claim.amount), invoice: claim.invoice || '' }); }, [claim]); if (!claim || !f) return null; const set = (k, v) => setF((x) => ({ ...x, [k]: v })); const canSave = f.provider && f.cat && f.amount; return (
set('provider', e.target.value)} /> {Store.doc.providers.map((p) =>
set('date', e.target.value)} />
$ set('amount', e.target.value)} />
set('invoice', e.target.value)} />
); } function EditInvoice({ inv, onClose }) { const [f, setF] = uS(null); uE(() => { if (inv) setF({ provider: inv.provider, cat: inv.cat, date: inv.date, amount: String(inv.amount) }); }, [inv]); if (!inv || !f) return null; const set = (k, v) => setF((x) => ({ ...x, [k]: v })); const canSave = f.provider && f.amount; return (
set('provider', e.target.value)} /> {Store.doc.providers.map((p) =>
set('date', e.target.value)} />
$ set('amount', e.target.value)} />
); } // ══════════════════════════════════════════════════════════════════ // CLAIMS // ══════════════════════════════════════════════════════════════════ function Claims({ arg, go, clearArg }) { const S = useHaven(); const claims = S.doc.claims; const [filter, setFilter] = uS('all'); const [logging, setLogging] = uS(false); const [expanded, setExpanded] = uS(null); const [prefill, setPrefill] = uS(null); const [editing, setEditing] = uS(null); uE(() => { if (arg === 'new') { setLogging(true); clearArg && clearArg(); } else if (arg && arg.id) { setPrefill(arg); setLogging(true); clearArg && clearArg(); } }, [arg]); const counts = { all: claims.length, todo: claims.filter((c) => c.status === 'draft' || c.status === 'rejected').length, submitted: claims.filter((c) => c.status === 'submitted').length, paid: claims.filter((c) => c.status === 'paid').length, }; const tabs = [ { key: 'all', label: 'All claims' }, { key: 'todo', label: 'Needs you', n: counts.todo }, { key: 'submitted', label: 'Submitted', n: counts.submitted }, { key: 'paid', label: 'Paid', n: counts.paid }, ]; const shown = claims.filter((c) => filter === 'all' ? true : filter === 'todo' ? (c.status === 'draft' || c.status === 'rejected') : c.status === filter); const totalSubmitted = claims.filter((c) => c.status === 'submitted').reduce((s, c) => s + c.amount, 0); return (
A asks the NDIS to pay you back for a support you've already bought. Log it, attach the invoice, send.} right={} /> {totalSubmitted > 0 && (
{fmtMoney(totalSubmitted, 2)} is on its way back to you — submitted claims usually land in your bank account within 2–3 business days.
)} {/* tabs */}
{tabs.map((t) => ( ))}
{shown.length === 0 &&
Nothing here — all caught up.
} {shown.map((c, i) => { const isOpen = expanded === c.id; return (
setExpanded(isOpen ? null : c.id)} style={{ display: 'flex', alignItems: 'center', gap: 16, padding: '16px 22px', cursor: 'pointer' }}>
{c.provider}
{c.sub} · {fmtDateShort(c.date)}
{fmtMoney(c.amount, 2)}
{isOpen && (
{c.status === 'rejected' && ( )}
Invoice {c.invoice} Support date {fmtDate(c.date)}
{c.status === 'submitted' && } {c.status === 'submitted' && } S.deleteClaim(c.id)} />
)}
); })}
{ setLogging(false); setPrefill(null); }} onDone={(c) => { S.addClaim(c); setFilter('submitted'); }} /> setEditing(null)} />
); } function ClaimTimeline({ c }) { const steps = [ { label: 'Logged', date: c.date, done: true }, { label: 'Submitted to NDIS', date: c.submitted, done: !!c.submitted && c.status !== 'draft' }, c.status === 'rejected' ? { label: 'Bounced back', date: c.submitted, done: true, bad: true } : { label: 'Paid to you', date: c.paid, done: c.status === 'paid' }, ]; return (
{steps.map((s, i) => (
{s.bad ? : s.done ? : } {i < steps.length - 1 && }
{s.label}
{s.date &&
{fmtDateShort(s.date)}
}
))}
); } // ── Log-a-claim multi-step flow ───────────────────────────────────── function LogClaim({ open, onClose, onDone, prefill }) { const [step, setStep] = uS(0); const blank = { provider: '', cat: '', sub: '', date: TODAY.toISOString().slice(0, 10), amount: '', invoice: '' }; const [form, setForm] = uS(blank); const [file, setFile] = uS(null); const [uping, setUping] = uS(false); uE(() => { if (open) { setStep(0); setFile(null); setUping(false); if (prefill && prefill.provider) { setForm({ provider: prefill.provider, cat: prefill.cat || '', sub: prefill.sub || '', date: TODAY.toISOString().slice(0, 10), amount: prefill.amount || '', invoice: '' }); } else if (prefill && prefill.id) { // an invoice object setForm({ provider: prefill.provider, cat: prefill.cat, sub: '', date: prefill.date, amount: prefill.amount, invoice: prefill.id }); } else setForm(blank); } }, [open]); const set = (k, v) => setForm((f) => ({ ...f, [k]: v })); const provObj = Store.doc.providers.find((p) => p.name === form.provider || p.short === form.provider); const canNext = form.provider && form.cat && form.amount; const inputStyle = { width: '100%', padding: '11px 13px', fontSize: 14.5, fontFamily: 'var(--sans)', border: '1px solid var(--line-2)', borderRadius: 11, background: 'var(--surface)', color: 'var(--ink)', outline: 'none' }; const labelStyle = { fontSize: 13, fontWeight: 600, color: 'var(--ink-2)', marginBottom: 6, display: 'block' }; const submit = () => { onDone({ provider: provObj ? provObj.short : form.provider, cat: form.cat, sub: form.sub || (provObj && provObj.sub) || catName(form.cat), date: form.date, amount: parseFloat(form.amount), invoice: form.invoice || 'INV-NEW', invoiceId: (prefill && prefill.kind && prefill.id) || null, file: (prefill && prefill.kind) ? null : file }); setStep(2); }; const pickFile = async (e) => { const f = e.target.files && e.target.files[0]; if (!f) return; setUping(true); try { const r = await Store.uploadFile(f); setFile({ name: r.name, url: r.url }); } catch (err) { setFile({ name: f.name, url: null }); } setUping(false); }; return ( {step < 2 && (
{[0, 1].map((s) =>
)}
)} {step === 0 && (
set('date', e.target.value)} />
$ set('amount', e.target.value)} />
set('invoice', e.target.value)} />
)} {step === 1 && (
{[ ['Provider', form.provider], ['Category', catName(form.cat)], ['Support date', fmtDate(form.date)], ['Invoice', form.invoice || '—'], ].map(([k, v]) => (
{k}{v}
))}
Claim amount{fmtMoney(parseFloat(form.amount || 0), 2)}
This will come out of your {catName(form.cat)} budget, which has {fmtMoney((Store.doc.budget.find((b) => b.key === form.cat) || {}).remaining || 0)} left. Once sent, the NDIS usually pays you within 2–3 days.
)} {step === 2 && (

Claim sent 🎉

{fmtMoney(parseFloat(form.amount || 0), 2)} to {form.provider} has been recorded. {file && file.url ? 'The invoice file is stored in your vault for the 5 years the NDIS asks for.' : 'Now submit it in the NDIS myplace portal if you haven\u2019t already \u2014 and mark it paid here when the money lands.'}

)} ); } // ══════════════════════════════════════════════════════════════════ // INVOICES VAULT // ══════════════════════════════════════════════════════════════════ function Invoices({ arg, go, clearArg }) { const S = useHaven(); const invs = S.doc.invoices; const [filter, setFilter] = uS('all'); const [q, setQ] = uS(''); const [adding, setAdding] = uS(false); const [editingInv, setEditingInv] = uS(null); uE(() => { if (arg === 'add') { setAdding(true); clearArg && clearArg(); } }, [arg]); const waiting = invs.filter((i) => !i.claimed); const waitingTotal = waiting.reduce((s, i) => s + i.amount, 0); const filtered = invs.filter((i) => (filter === 'all' ? true : filter === 'waiting' ? !i.claimed : !!i.claimed) && (q === '' || (i.provider + ' ' + i.id).toLowerCase().includes(q.toLowerCase()))); return (
Every invoice and receipt, kept safe and searchable. The NDIS asks you to hold these for 5 years — Haven does that automatically.} right={} /> {waiting.length > 0 && (
{waiting.length} invoices ({fmtMoney(waitingTotal, 2)}) are waiting to be claimed.{' '} setFilter('waiting')}>Show them →
)}
setQ(e.target.value)} style={{ width: '100%', padding: '9px 13px 9px 38px', fontSize: 14, fontFamily: 'var(--sans)', border: '1px solid var(--line-2)', borderRadius: 11, outline: 'none', background: 'var(--surface)', color: 'var(--ink)' }} />
{[['all', 'All'], ['waiting', 'To claim'], ['claimed', 'Claimed']].map(([k, l]) => ( ))}
{filtered.length === 0 &&
No invoices here yet — they'll appear as you add them.
} {filtered.map((inv) => ( {/* file preview */}
{inv.file && inv.file.url ? ( {inv.file.name} ) : ( )} {inv.claimed ? Claimed : To claim}
{inv.provider} {fmtMoney(inv.amount, 2)}
{fmtDateShort(inv.date)} · {inv.id}
{!inv.claimed ? : }
S.deleteInvoice(inv.id)} />
))}
setAdding(false)} onDone={(inv) => S.addInvoice(inv)} /> setEditingInv(null)} />
); } // ── Add an invoice: real file upload + your details ───────────────── function AddInvoice({ open, onClose, onDone }) { const [phase, setPhase] = uS('drop'); // drop | up | read | form const [file, setFile] = uS(null); const skipRef = React.useRef(false); const blankInv = { id: '', provider: '', cat: 'core', date: '', amount: '' }; const [f, setF] = uS(blankInv); uE(() => { if (open) { setPhase('drop'); setFile(null); setF({ ...blankInv, date: TODAY.toISOString().slice(0, 10) }); } }, [open]); const inputStyle = { width: '100%', padding: '11px 13px', fontSize: 14.5, fontFamily: 'var(--sans)', border: '1px solid var(--line-2)', borderRadius: 11, background: 'var(--surface)', color: 'var(--ink)', outline: 'none', boxSizing: 'border-box' }; const labelStyle = { fontSize: 13, fontWeight: 600, color: 'var(--ink-2)', marginBottom: 6, display: 'block' }; const set = (k, v) => setF((x) => ({ ...x, [k]: v })); const handleFile = async (fl) => { if (!fl) return; setPhase('up'); let stored; try { const r = await Store.uploadFile(fl); stored = { name: r.name, url: r.url }; } catch (e) { stored = { name: fl.name, url: null }; } setFile(stored); // read the file on this computer and take a best guess at the fields skipRef.current = false; setPhase('read'); let guess = null; try { guess = await extractInvoiceFields(fl); } catch (e) {} if (skipRef.current) return; // user chose to type instead if (guess) setF((x) => ({ ...x, provider: guess.provider || x.provider, cat: guess.cat || x.cat, amount: guess.amount != null ? String(guess.amount) : x.amount, id: guess.id || x.id, date: guess.date || x.date, _guessed: true })); setPhase('form'); }; const canSave = f.provider && f.amount; const save = () => { onDone({ id: f.id || ('R-' + Date.now().toString(36).toUpperCase()), provider: f.provider, cat: f.cat, date: f.date, amount: parseFloat(f.amount) || 0, kind: 'invoice', claimed: null, file }); onClose(); }; return ( {phase === 'drop' && (
setPhase('form')}>No file? Enter the details only →
)} {phase === 'up' && (
Storing on your NAS…
)} {phase === 'read' && (
Reading the invoice…
This happens on your computer — the file isn't sent anywhere. The first time takes a minute while the reader downloads.
)} {phase === 'form' && (
{file && !file.url && {file.name} couldn't be stored (NAS not reachable right now) — the details below will still be saved.} {f._guessed ? Haven read the file and took its best guess — check every field before saving. : (file && file.url && {file.name} is stored safely on the NAS. Now the details:)}
{ set('provider', e.target.value); const p = Store.doc.providers.find((x) => x.name === e.target.value); if (p) set('cat', p.cat); }} /> {Store.doc.providers.map((p) =>
set('date', e.target.value)} />
$ set('amount', e.target.value)} />
set('id', e.target.value)} />
)} ); } Object.assign(window, { Claims, Invoices, catName, catColor, catSoft });