mega-plant/components/InvoiceUploader.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

158 lines
No EOL
5.6 KiB
TypeScript

"use client";
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { UploadCloud, FileText, Loader2, CheckCircle2, AlertCircle } from "lucide-react";
const MAX_BYTES = 20 * 1024 * 1024;
// Drag-and-drop / tap-to-browse PDF upload against an invoice's `source_pdf` field.
// Accepts PDF only, matching the server route — the client check is here to fail fast,
// the server check is the one that actually guards the data.
export function InvoiceUploader({ invoiceNo }: { invoiceNo: string }) {
const router = useRouter();
const inputRef = useRef<HTMLInputElement>(null);
const [dragging, setDragging] = useState(false);
const [state, setState] = useState<{ status: "idle" | "uploading" | "done" | "error"; message?: string }>({
status: "idle",
});
async function send(file: File) {
if (file.type !== "application/pdf" && !file.name.toLowerCase().endsWith(".pdf")) {
setState({ status: "error", message: "Only PDF invoices are accepted" });
return;
}
if (file.size > MAX_BYTES) {
setState({ status: "error", message: `${(file.size / 1048576).toFixed(1)} MB — limit is 20 MB` });
return;
}
setState({ status: "uploading" });
const body = new FormData();
body.append("file", file);
body.append("invoiceNo", invoiceNo);
try {
const res = await fetch("/api/invoices/upload", { method: "POST", body });
const data = await res.json().catch(() => ({ ok: false, error: "Unexpected response" }));
if (!data.ok) setState({ status: "error", message: data.error || "Upload failed" });
else {
setState({ status: "done", message: `${data.file} attached` });
router.refresh();
}
} catch {
setState({ status: "error", message: "Could not reach the server" });
}
}
return (
<div>
{/* Whole zone is the tap target — button semantics on a div, so it stays one
44px-tall control instead of a small inner button. */}
<div
role="button"
tabIndex={0}
aria-label={`Upload the invoice PDF for ${invoiceNo}`}
onClick={() => inputRef.current?.click()}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
inputRef.current?.click();
}
}}
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault();
setDragging(false);
const file = e.dataTransfer.files?.[0];
if (file) send(file);
}}
className={`flex items-center gap-3 min-h-[44px] px-4 py-3 rounded-xl border-2 border-dashed cursor-pointer transition ${
dragging
? "border-[#00798C] bg-[#00798C]/10"
: "border-gray-200 dark:border-white/10 hover:border-[#00798C]/50 hover:bg-gray-50 dark:hover:bg-white/[0.02]"
}`}
>
{state.status === "uploading" ? (
<Loader2 className="w-5 h-5 animate-spin text-[#00798C] shrink-0" />
) : state.status === "done" ? (
<CheckCircle2 className="w-5 h-5 text-[#00798C] shrink-0" />
) : (
<UploadCloud className="w-5 h-5 text-[#00798C] shrink-0" />
)}
<div className="min-w-0 text-left">
<div className="text-sm font-medium text-[#003D5B] dark:text-white">
{state.status === "uploading"
? "Uploading…"
: state.status === "done"
? state.message
: "Drop the invoice PDF here, or tap to browse"}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
PDF only · up to 20 MB · attaches to {invoiceNo}
</div>
</div>
</div>
<input
ref={inputRef}
type="file"
accept="application/pdf,.pdf"
className="sr-only"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) send(file);
e.target.value = ""; // allow re-picking the same file
}}
/>
{state.status === "error" && (
<p className="flex items-start gap-1.5 text-sm text-[#D1495B] mt-2">
<AlertCircle className="w-4 h-4 mt-0.5 shrink-0" />
{state.message}
</p>
)}
</div>
);
}
// Shown once a document is attached, with a link that opens it. The link points at our
// own authenticated proxy, not at PocketBase's /api/files — the invoices collection is
// rule-locked, so a direct PB link 404s in the browser.
export function AttachedDocument({
invoiceNo,
invoiceId,
}: {
invoiceNo: string;
invoiceId: string;
}) {
const [showUploader, setShowUploader] = useState(false);
return (
<div className="flex items-center gap-3 flex-wrap">
<a
href={`/api/invoices/${invoiceId}/document`}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-2 min-h-[44px] px-3 rounded-lg text-sm font-medium text-[#00798C] hover:bg-[#00798C]/10 transition"
>
<FileText className="w-4 h-4 shrink-0" />
Open invoice PDF
</a>
<button
type="button"
onClick={() => setShowUploader((s) => !s)}
className="inline-flex items-center gap-2 min-h-[44px] px-3 rounded-lg text-sm font-medium text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-white/5 transition"
>
{showUploader ? "Cancel" : "Replace"}
</button>
{showUploader && (
<div className="w-full">
<InvoiceUploader invoiceNo={invoiceNo} />
</div>
)}
</div>
);
}