// 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 (
{ e.stopPropagation(); if (armed) { onConfirm(); setArmed(false); } else setArmed(true); }} style={{
display: 'inline-flex', alignItems: 'center', gap: 6, padding: '7px 11px', fontSize: 13, fontWeight: 600,
borderRadius: 10, border: '1px solid ' + (armed ? 'var(--alert)' : 'var(--line-2)'), whiteSpace: 'nowrap',
background: armed ? 'var(--alert)' : 'transparent', color: armed ? '#fff' : 'var(--ink-3)', transition: 'all .15s', flexShrink: 0,
}}> {armed ? 'Sure? Click again' : label}
);
}
// 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 (
);
}
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 (
);
}
// ══════════════════════════════════════════════════════════════════
// 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={ setLogging(true)}>Log a claim }
/>
{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) => (
setFilter(t.key)} style={{
background: 'none', border: 'none', padding: '10px 4px', marginRight: 14, fontSize: 14.5, fontWeight: 600,
color: filter === t.key ? 'var(--primary-ink)' : 'var(--ink-2)', borderBottom: filter === t.key ? '2.5px solid var(--primary)' : '2.5px solid transparent',
marginBottom: -1, display: 'flex', alignItems: 'center', gap: 7,
}}>
{t.label}
{t.n > 0 && {t.n} }
))}
{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' && S.setClaimStatus(c.id, 'paid')}>NDIS paid this }
{c.status === 'submitted' && S.setClaimStatus(c.id, 'rejected')}>It bounced }
setEditing(c)}>Edit
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 && (
)}
{step === 0 && (
)}
{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.
setStep(0)}>Back
Submit claim
)}
{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.'}
Done
{ setStep(0); setForm({ ...blank }); }} icon={Icons.plus}>Log another
)}
);
}
// ══════════════════════════════════════════════════════════════════
// 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={ setAdding(true)}>Add invoice }
/>
{waiting.length > 0 && (
)}
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]) => (
setFilter(k)} style={{ border: 'none', background: filter === k ? 'var(--surface)' : 'transparent', boxShadow: filter === k ? 'var(--shadow-sm)' : 'none', color: filter === k ? 'var(--ink)' : 'var(--ink-2)', fontWeight: 600, fontSize: 13.5, padding: '7px 14px', borderRadius: 8 }}>{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}
) : (
{ const fl = e.target.files && e.target.files[0]; e.target.value = ''; if (!fl) return; try { const r = await Store.uploadFile(fl); S.listPatch('invoices', inv.id, { file: { name: r.name, url: r.url } }); } catch (err) { alert("Couldn't store the file — is the NAS reachable?"); } }} />
attach the file…
)}
{inv.claimed
? Claimed
: To claim }
{inv.provider}
{fmtMoney(inv.amount, 2)}
{fmtDateShort(inv.date)} · {inv.id}
{!inv.claimed
? go('claims', inv)}>Claim this
: go('claims')}>View claim }
setEditingInv(inv)} style={{ width: 34, borderRadius: 10, border: '1px solid var(--line-2)', background: 'transparent', color: 'var(--ink-3)', display: 'grid', placeItems: 'center', flexShrink: 0 }}>
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' && (
e.preventDefault()}
onDrop={(e) => { e.preventDefault(); handleFile(e.dataTransfer.files && e.dataTransfer.files[0]); }}
style={{ display: 'block', border: '2px dashed var(--line-2)', borderRadius: 16, padding: '46px 24px', textAlign: 'center', cursor: 'pointer', background: 'var(--surface-2)' }}>
handleFile(e.target.files && e.target.files[0])} />
Drop a PDF or photo here
or click to browse
)}
{phase === 'up' && (
)}
{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:)}
Provider
{ 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) => )}
Cancel
Save to vault
)}
);
}
Object.assign(window, { Claims, Invoices, catName, catColor, catSoft });