mega-plant/components/BookingSelect.tsx
David baf0bddde6 Invoices: line-item extraction, booking to plant items, VAT-inclusive income
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>
2026-10-01 15:59:30 +02:00

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>
);
}