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>
158 lines
No EOL
5.6 KiB
TypeScript
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>
|
|
);
|
|
} |