/* TMD Portal — money screens + settings. */
const uSt = React.useState;
/* ================= Expenses ================= */
function Expenses({ app }) {
const [add, setAdd] = uSt(false);
const [cat, setCat] = uSt('all');
const [cl, setCl] = uSt('all');
const [paid, setPaid] = uSt('all');
const [when, setWhen] = uSt('all');
const cats = ['Ad spend', 'Production', 'Software', 'Freelance', 'Print', 'Travel'];
const list = app.expenses.filter(e => (cat === 'all' || e.cat === cat) && (cl === 'all' || e.client === cl) && (paid === 'all' || e.paidBy === paid) &&
(when === 'all' || (when === '7' ? dayDiff(e.date) >= -7 : when === 'aug' ? e.date >= monthStart(0) : e.date >= monthStart(-1) && e.date <= monthEnd(-1))));
const total = list.filter(e => e.paidBy === 'TMD').reduce((s, e) => s + e.amount, 0);
const billable = list.filter(e => e.billable).reduce((s, e) => s + e.amount, 0);
return (
setAdd(true)}>Add expense} />
({ v: c, l: c })))} />
({ v: c.id, l: c.name })))} />
{!list.length ? setAdd(true)}>Add expense} /> : app.mobile ? (
{list.map(e => (
{fmtDate(e.date)} {money(e.amount, { bare: true })}
{e.desc}
{e.cat} {e.client ? client(e.client).name : 'Overhead'} {e.billable ? Rebill : null}
))}
) : (
Date Expense Category Client Paid by Rebill Amount
{list.map(e => (
{fmtDate(e.date)} {e.desc} {e.cat}
{e.client ? client(e.client).name : Overhead } {e.paidBy}
{e.billable ? Rebill : — } {money(e.amount, { bare: true })}
))}
)}
setAdd(false)} app={app} />
);
}
function AddExpense({ open, onClose, app }) {
const [amt, setAmt] = uSt('');
const [cat, setCat] = uSt('');
const [cl, setCl] = uSt('');
const [paidBy, setPaidBy] = uSt('TMD');
const [bill, setBill] = uSt(true);
const [date, setDate] = uSt(T.TODAY);
const cats = ['Ad spend', 'Production', 'Software', 'Freelance', 'Print', 'Travel'];
const ready = amt && cat;
return (
Cancel { app.addExpense({ amount: Number(amt), cat, client: cl || null, paidBy, billable: bill, date }); setAmt(''); setCat(''); setCl(''); setDate(T.TODAY); onClose(); }}>Log expense >}>
Amount LKR setAmt(e.target.value.replace(/[^0-9]/g, ''))} placeholder="0" />
Category {cats.map(c => setCat(c)}>{c} )}
Client {T.clients.map(c => setCl(c.id === cl ? '' : c.id)}>{c.name} )} setCl('')}>Overhead
Paid by
setBill(e.target.checked)} />Rebill to client on the next invoice
Nothing is stored here but the link.
);
}
/* ================= Invoices ================= */
const invTotal = i => i.lines.reduce((s, l) => s + l.amount, 0);
const invPaid = i => i.payments.reduce((s, p) => s + p.amount, 0);
function Invoices({ app }) {
const [batch, setBatch] = uSt(false);
const list = app.invoices;
const out = list.reduce((s, i) => s + (i.status === 'draft' ? 0 : invTotal(i) - invPaid(i)), 0);
const drafts = list.filter(i => i.status === 'draft').length;
return (
setBatch(true)}>Review {monthLabel(0)} drafts{T.clients.filter(c => c.status === 'Active').length} } />
{drafts ? {drafts} draft{drafts > 1 ? 's' : ''} from {monthLabel(-1)} still unissued. Drafts are editable. Issued invoices are not.
: null}
Number Client Period Status Due Total Outstanding
{list.map(i => (
app.go('inv:' + i.id)} tabIndex={0}>
{i.no} {client(i.client).name} {i.period}
{i.status}
{i.due ? : — }
{money(invTotal(i), { bare: true })}
{i.status === 'paid' ? — : money(invTotal(i) - invPaid(i), { bare: true })}
))}
setBatch(false)} app={app} />
);
}
function InvoiceDetail({ app, id }) {
const i = byId(app.invoices, id);
const [confirm, setConfirm] = uSt(false);
const [pay, setPay] = uSt(false);
if (!i) return ;
const draft = i.status === 'draft';
const total = invTotal(i), paid = invPaid(i);
return (
app.go('invoices')}>← Invoices
Edit lines setConfirm(true)}>Review and issue >
: <> setPay(true)}>Record payment Issue credit note Void >} />
{!draft ? Issued {fmtDate(i.issued, true)} — this invoice cannot be edited. Corrections are made with a credit note, so the client's copy and yours never disagree.
: null}
The Marketing Desk (Pvt) Ltd · Colombo 05 · Sri Lanka
{draft ? 'Draft' : i.no}
Issued {i.issued ? fmtDate(i.issued, true) : '—'}
Due {i.due ? fmtDate(i.due, true) : '—'}
Description Amount
{i.lines.map((l, n) => {l.desc} {money(l.amount, { bare: true })} )}
Total {money(total, { bare: true })}
{paid ? Paid − {money(paid, { bare: true })} : null}
{paid ? 'Balance' : 'Due'} {money(total - paid, { bare: true })}
No tax is charged. TMD is not tax-registered. Payment to Sampath Bank · 0071 2345 6789 .
Payments
{i.payments.length ? Date Reference Amount
{i.payments.map((p, n) => {fmtDate(p.date, true)} {p.ref} {money(p.amount, { bare: true })} )}
: }
{client(i.client).name} · {i.period} · {money(total)}
Once issued, this invoice is locked. No line can be changed, no amount corrected, no date moved. A mistake after this point is fixed with a credit note, and both documents stay on the record.
>}
onClose={() => setConfirm(false)} onConfirm={() => { setConfirm(false); app.issue([i.id]); }} />
setPay(false)} title="Record payment"
footer={<> setPay(false)}>Cancel { setPay(false); app.toast('Payment recorded'); }}>Record payment >}>
Amount LKR
Date received
);
}
function BatchIssue({ open, onClose, app }) {
const [step, setStep] = uSt(1);
const [sel, setSel] = uSt(T.clients.map(c => c.id));
const [confirm, setConfirm] = uSt(false);
const rows = T.clients.map(c => {
const rebill = T.expenses.filter(e => e.client === c.id && e.billable).reduce((s, e) => s + e.amount, 0);
return { c, retainer: c.retainer, rebill, total: c.retainer + rebill };
});
const chosen = rows.filter(r => sel.includes(r.c.id));
const sum = chosen.reduce((s, r) => s + r.total, 0);
const toggle = id => setSel(s => s.includes(id) ? s.filter(x => x !== id) : s.concat(id));
return (
{ setStep(1); onClose(); }} title={step === 1 ? monthLabel(0) + ' drafts — review' : 'Issue ' + chosen.length + ' invoices'}
footer={step === 1
? <>Close setStep(2)}>Continue — {chosen.length} selected >
: <> setStep(1)}>Back setConfirm(true)}>Issue {chosen.length} invoices >}>
{step === 1 ? (
<>
The desk drafted one invoice per active client for {monthLabel(0)}: the retainer in force on the last day of the month, plus every expense marked rebillable. Uncheck anything that should not go out.
{rows.map(r => (
toggle(r.c.id)} />
{r.c.name}{r.c.kind === 'internal' ? 'In-house' : 'External'}
{money(r.retainer, { bare: true })}retainer
{r.rebill ? money(r.rebill, { bare: true }) : '—'}rebilled
{money(r.total, { bare: true })}
))}
Total to issue {money(sum)}
>
) : (
<>
This is the door. Issuing sends {chosen.length} invoices worth {money(sum)} into the record. Each one is locked from that moment: no edits, no deletions, no quiet corrections. Only payments, voids and credit notes afterwards.
{chosen.map(r => {r.c.name} {money(r.total, { bare: true })} )}
Numbers are never reused.
>
)}
This cannot be undone. {money(sum)} across {chosen.length} clients becomes permanent record.}
onClose={() => setConfirm(false)} onConfirm={() => { setConfirm(false); setStep(1); onClose(); app.toast(chosen.length + ' invoices issued'); }} />
);
}
/* ================= Client P&L ================= */
function PnL({ app }) {
const rows = T.clients.map(c => {
const rev = c.retainer;
const exp = T.expenses.filter(e => e.client === c.id && e.paidBy === 'TMD').reduce((s, e) => s + e.amount, 0);
const share = app.dels.filter(d => d.client === c.id).length;
const totalWork = app.dels.length;
const peopleCost = Math.round((T.people.reduce((s, p) => s + (p.cost || 0), 0)) * (share / totalWork));
const margin = rev - exp - peopleCost;
return { c, rev, exp, peopleCost, margin, share };
});
const tot = rows.reduce((a, r) => ({ rev: a.rev + r.rev, exp: a.exp + r.exp, pc: a.pc + r.peopleCost, m: a.m + r.margin }), { rev: 0, exp: 0, pc: 0, m: 0 });
return (
Client Retainer TMD-paid expenses People cost est. Margin %
{rows.map(r => (
app.go('client:' + r.c.id)} tabIndex={0}>
{r.c.name}
{money(r.rev, { bare: true })}
{money(r.exp, { bare: true })}
{money(r.peopleCost, { bare: true })}
{money(r.margin, { bare: true })}
{Math.round(r.margin / r.rev * 100)}%
))}
All clients {money(tot.rev, { bare: true })} {money(tot.exp, { bare: true })} {money(tot.pc, { bare: true })} {money(tot.m, { bare: true })} {Math.round(tot.m / tot.rev * 100)}%
People cost is an estimate. It splits salaries by share of deliverables this month, not by hours — the desk does not track hours. Treat the ranking as real and the exact figure as approximate.
);
}
/* ================= Settings — pipeline editor ================= */
function Pipelines({ app }) {
const [tid, setTid] = uSt('reel');
const t = typeOf(tid);
const [stages, setStages] = uSt(t.stages);
React.useEffect(() => { setStages(typeOf(tid).stages); }, [tid]);
const move = (i, dir) => setStages(s => { const n = s.slice(); const j = i + dir; if (j < 0 || j >= n.length) return s; const x = n[i]; n[i] = n[j]; n[j] = x; return n; });
const setSem = (i, sem) => setStages(s => s.map((x, n) => n === i ? Object.assign({}, x, { sem }) : x));
const inUse = app.dels.filter(d => d.type === tid).length;
return (
{T.types.map(x => setTid(x.id)}>{x.name} )}
{inUse} live deliverables use this pipeline.Renaming a stage renames it on their cards. Deleting one asks where its work should go first.
Stage Maps to Approval Order
{stages.map((s, i) => (
{['todo', 'doing', 'waiting', 'done', 'blocked'].map(k => (
setSem(i, k)}>{T.SEMANTIC[k].label}
))}
{s.approval ? {s.approval === 'client' ? 'Client' : 'Internal'} : None }
move(i, -1)} aria-label="Move up">↑ move(i, 1)} aria-label="Move down">↓
))}
Add stage app.toast('Pipeline saved', true)}>Save pipeline
The five states
{['todo', 'doing', 'waiting', 'done', 'blocked'].map(k => (
{{
todo: 'Requested, queued, briefed — real work has not started.',
doing: 'Someone at the desk is working on it right now.',
waiting: 'Nothing can move until a person outside the work responds. The loudest state on purpose — this is where agency work dies.',
done: 'Published, delivered, live. It leaves the board.',
blocked: 'Stopped by something that is not a queue: no consent, no budget, no rate card.'
}[k]}
))}
);
}
Object.assign(window, { Expenses, AddExpense, Invoices, InvoiceDetail, BatchIssue, PnL, Pipelines, invTotal, invPaid });