Adds an Invoices sub-menu under /income with a summary table (one row per invoice, click to expand the full detail) and the data model behind it. Data: - invoices + invoice_lines PocketBase collections, created idempotently by scripts/setup-invoice-collections.mjs - line items transcribed from PHI 166/167/171 with per-invoice arithmetic validation in the seed; a bad invoice is refused without blocking the rest - invoice-*.json in data/source are auto-imported, so adding an invoice is one file Booking: - manual book-in per line (Booked to), grouped by category - POST /api/invoice-lines/:id/book moves the income row rather than adding a second one, so a booking can be changed freely - scripts/book-invoice.mjs is idempotent: rows are stamped with monthly_income.invoice_line and replaced on re-run - booked amount follows lib/invoice-booking-policy.json (bookInclusiveVat), read by both the app and the CLI so they cannot disagree Money: - the R24,205.13 column on these invoices is PLUS VAT, not escalation. The seed now rejects VAT that disagrees with the stated rate. - booked amounts are VAT-inclusive per the client ruling of 2026-10-01; the VAT stays visible rather than hidden. Uploads: - drag-and-drop PDF attached to invoices.source_pdf - GET /api/invoices/:id/document proxies it — the collection is rule-locked, so a direct PocketBase /api/files link 404s in a browser Fixes a month-aggregation bug this introduced: buildIncomeRows assigned into months[i], so a machine with two income rows in one month (one per invoice, which is normal multi-site use) showed only the last. SS01 September read R20,700 instead of R35,305 while its own YTD included both. Months now accumulate, per-month profit is computed once after accumulation, and lib/rows.test.ts pins it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
105 lines
No EOL
3.6 KiB
TypeScript
105 lines
No EOL
3.6 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useMemo } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { Loader2, AlertCircle } from "lucide-react";
|
|
|
|
type AssetOption = { id: string; code: string; name: string; category: string };
|
|
|
|
// Books one invoice line to a plant item from the register.
|
|
//
|
|
// Saves immediately on change — there is no separate "save" button to miss on a phone.
|
|
// On success it refreshes the router so the server-rendered header, banner and totals
|
|
// re-read from PocketBase rather than showing stale booking state.
|
|
export function BookingSelect({
|
|
lineId,
|
|
value,
|
|
assets,
|
|
}: {
|
|
lineId: string;
|
|
value: string;
|
|
assets: AssetOption[];
|
|
}) {
|
|
const router = useRouter();
|
|
const [current, setCurrent] = useState(value);
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// 44 plant items flat in one dropdown is unusable — group them by category.
|
|
const groups = useMemo(() => {
|
|
const byCategory = new Map<string, AssetOption[]>();
|
|
for (const a of assets) {
|
|
const key = a.category || "Other";
|
|
if (!byCategory.has(key)) byCategory.set(key, []);
|
|
byCategory.get(key)!.push(a);
|
|
}
|
|
return Array.from(byCategory.entries())
|
|
.map(([category, items]) => ({ category, items: items.sort((x, y) => x.code.localeCompare(y.code)) }))
|
|
.sort((a, b) => a.category.localeCompare(b.category));
|
|
}, [assets]);
|
|
|
|
async function book(assetId: string) {
|
|
const previous = current;
|
|
setSaving(true);
|
|
setError(null);
|
|
setCurrent(assetId); // optimistic
|
|
try {
|
|
const res = await fetch(`/api/invoice-lines/${lineId}/book`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ assetId }),
|
|
});
|
|
const data = await res.json().catch(() => ({ ok: false, error: "Unexpected response" }));
|
|
if (!data.ok) {
|
|
setCurrent(previous); // roll back so the UI never claims a booking that failed
|
|
setError(data.error || "Booking failed");
|
|
} else {
|
|
router.refresh(); // re-read server-rendered banner/totals
|
|
}
|
|
} catch {
|
|
setCurrent(previous);
|
|
setError("Could not reach the server");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<label className="sr-only" htmlFor={`book-${lineId}`}>
|
|
Book this line to a plant item
|
|
</label>
|
|
<select
|
|
id={`book-${lineId}`}
|
|
value={current}
|
|
disabled={saving}
|
|
onChange={(e) => book(e.target.value)}
|
|
className={`min-h-[44px] w-full rounded-lg border px-2 text-sm transition focus:outline-none focus:border-[#30638E] disabled:opacity-60 ${
|
|
current
|
|
? "border-[#00798C]/30 bg-[#00798C]/5 text-[#003D5B] dark:text-white"
|
|
: "border-[#D1495B]/40 bg-[#D1495B]/5 text-[#D1495B]"
|
|
}`}
|
|
>
|
|
<option value="">— Not booked —</option>
|
|
{groups.map((g) => (
|
|
<optgroup key={g.category} label={g.category}>
|
|
{g.items.map((a) => (
|
|
<option key={a.id} value={a.id}>
|
|
{a.code} · {a.name}
|
|
</option>
|
|
))}
|
|
</optgroup>
|
|
))}
|
|
</select>
|
|
{saving && <Loader2 className="w-4 h-4 animate-spin text-[#00798C] shrink-0" aria-label="Saving" />}
|
|
</div>
|
|
{error && (
|
|
<p className="flex items-start gap-1 text-sm text-[#D1495B] mt-1">
|
|
<AlertCircle className="w-3.5 h-3.5 mt-0.5 shrink-0" />
|
|
{error}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
} |