diff --git a/app/income/invoices/page.tsx b/app/income/invoices/page.tsx
index 41d96f8..79917f1 100644
--- a/app/income/invoices/page.tsx
+++ b/app/income/invoices/page.tsx
@@ -1,7 +1,4 @@
-import {
- getAssets, getInvoices, getInvoiceLines, joinBookedLines,
- findDuplicateMachineCodes,
-} from "@/lib/data";
+import { getAssets, getInvoices, getInvoiceLines, joinBookedLines } from "@/lib/data";
import { FileText } from "lucide-react";
import { InvoiceList, EmptyInvoices } from "@/components/InvoiceList";
import { AddInvoiceButton, ProblemInbox } from "@/components/InvoiceIntake";
@@ -25,7 +22,6 @@ export default async function InvoicesPage() {
}
const unbookedCount = joined.filter((l) => !l.isBooked).length;
- const multiSite = findDuplicateMachineCodes(invoices, joined);
return (
@@ -49,7 +45,7 @@ export default async function InvoicesPage() {
{invoices.length === 0 ? (
) : (
-
+
)}
{unbookedCount > 0 && (
diff --git a/components/InvoiceIntake.tsx b/components/InvoiceIntake.tsx
index bec922a..e7d629d 100644
--- a/components/InvoiceIntake.tsx
+++ b/components/InvoiceIntake.tsx
@@ -3,70 +3,110 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import {
- FileUp, Loader2, AlertTriangle, CheckCircle2, RotateCw, Inbox, Trash2, X,
+ FileUp, Loader2, AlertTriangle, CheckCircle2, RotateCw, Inbox, Trash2, X, UploadCloud, FileText,
} from "lucide-react";
import { ZAR2 } from "@/lib/utils";
-// "Add Invoice" — drop a PDF and the app parses it itself. No agent, no AI: the
-// figures are read by coordinate from a fixed report template and checked
-// arithmetically before anything is written.
+// "Add Invoice" — opens a standalone dialog. The file is chosen or dropped into the
+// modal and nothing is uploaded until OK is pressed, so a mis-drop can be cleared
+// without leaving a stray record in the inbox.
export function AddInvoiceButton() {
const [open, setOpen] = useState(false);
- const [busy, setBusy] = useState(false);
+ const [file, setFile] = useState
(null);
const [dragging, setDragging] = useState(false);
+ const [busy, setBusy] = useState(false);
const [result, setResult] = useState(null);
const inputRef = useRef(null);
const router = useRouter();
- const send = useCallback(
- async (file: File) => {
- setBusy(true);
- setResult(null);
- const fd = new FormData();
- fd.append("file", file);
- try {
- const res = await fetch("/api/invoices/intake", { method: "POST", body: fd });
- const data = await res.json().catch(() => ({ ok: false, error: "Unexpected response" }));
- if (data.ok) {
- const unassigned = (data.unassigned ?? []) as string[];
- setResult({
- ok: true,
- title: `${data.invoice_no} imported`,
- body:
- `${data.lines} line${data.lines > 1 ? "s" : ""} · ${ZAR2(data.total_incl)} · ` +
- `${data.booked} booked` +
- (unassigned.length
- ? `. Needs a plant item: ${unassigned.join(", ")}`
- : data.fullyBooked
- ? ". All lines matched a plant item."
- : "."),
- });
- } else {
- setResult({
- ok: false,
- title: data.status === "failed" ? "Saved to the problem inbox" : "Could not import",
- body: data.error ?? "Unknown error",
- });
- }
- router.refresh();
- } catch {
- setResult({ ok: false, title: "Could not reach the server", body: "Check the app is running." });
- } finally {
- setBusy(false);
- }
- },
- [router],
- );
+ const reset = useCallback(() => {
+ setFile(null);
+ setResult(null);
+ setDragging(false);
+ }, []);
+ const close = useCallback(() => {
+ if (busy) return;
+ setOpen(false);
+ reset();
+ }, [busy, reset]);
+
+ // Escape closes, and the page behind the dialog must not scroll under it.
useEffect(() => {
if (!open) return;
- const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape") close();
+ };
document.addEventListener("keydown", onKey);
- return () => document.removeEventListener("keydown", onKey);
- }, [open]);
+ const prev = document.body.style.overflow;
+ document.body.style.overflow = "hidden";
+ return () => {
+ document.removeEventListener("keydown", onKey);
+ document.body.style.overflow = prev;
+ };
+ }, [open, close]);
- if (!open) {
- return (
+ const accept = (f: File | undefined) => {
+ if (!f) return;
+ if (f.type !== "application/pdf" && !f.name.toLowerCase().endsWith(".pdf")) {
+ setResult({ ok: false, title: "Not a PDF", body: `${f.name} — only PDF invoices are accepted.` });
+ setFile(null);
+ return;
+ }
+ if (f.size > 20 * 1024 * 1024) {
+ setResult({
+ ok: false,
+ title: "Too large",
+ body: `${(f.size / 1048576).toFixed(1)} MB — the limit is 20 MB.`,
+ });
+ setFile(null);
+ return;
+ }
+ setResult(null);
+ setFile(f);
+ };
+
+ const upload = async () => {
+ if (!file) return;
+ setBusy(true);
+ setResult(null);
+ const fd = new FormData();
+ fd.append("file", file);
+ try {
+ const res = await fetch("/api/invoices/intake", { method: "POST", body: fd });
+ const data = await res.json().catch(() => ({ ok: false, error: "Unexpected response" }));
+ if (data.ok) {
+ const unassigned = (data.unassigned ?? []) as string[];
+ setResult({
+ ok: true,
+ title: `${data.invoice_no} imported`,
+ body:
+ `${data.lines} line${data.lines > 1 ? "s" : ""} · ${ZAR2(data.total_incl)} · ` +
+ `${data.booked} booked` +
+ (unassigned.length
+ ? `. Needs a plant item: ${unassigned.join(", ")}`
+ : data.fullyBooked
+ ? ". All lines matched a plant item."
+ : "."),
+ });
+ router.refresh();
+ } else {
+ setResult({
+ ok: false,
+ title: data.status === "failed" ? "Saved to the problem inbox" : "Could not import",
+ body: data.error ?? "Unknown error",
+ });
+ router.refresh();
+ }
+ } catch {
+ setResult({ ok: false, title: "Could not reach the server", body: "Check the app is running." });
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ return (
+ <>
setOpen(true)}
@@ -75,96 +115,154 @@ export function AddInvoiceButton() {
Add Invoice
- );
- }
- return (
-
-
-
Add invoice
- setOpen(false)}
- aria-label="Close"
- className="grid place-items-center w-11 h-11 -mr-2 -mt-1 rounded-lg text-gray-500 hover:bg-gray-100 dark:hover:bg-white/5"
- >
-
-
-
+ {open && (
+
+ {/* Backdrop */}
+
-
!busy && inputRef.current?.click()}
- onKeyDown={(e) => {
- if ((e.key === "Enter" || e.key === " ") && !busy) {
- e.preventDefault();
- inputRef.current?.click();
- }
- }}
- onDragOver={(e) => {
- e.preventDefault();
- setDragging(true);
- }}
- onDragLeave={() => setDragging(false)}
- onDrop={(e) => {
- e.preventDefault();
- setDragging(false);
- const f = e.dataTransfer.files?.[0];
- if (f) send(f);
- }}
- className={`flex flex-col sm:flex-row sm:items-center gap-3 px-4 py-4 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]"
- } ${busy ? "opacity-60 pointer-events-none" : ""}`}
- >
- {busy ? (
-
- ) : (
-
- )}
-
-
- {busy ? "Reading the invoice…" : "Drop the invoice PDF here, or tap to browse"}
-
-
- PDF only · up to 20 MB · figures are read from the invoice and checked before saving
-
-
-
+
+
+
Add invoice
+
+
+
+
-
{
- const f = e.target.files?.[0];
- if (f) send(f);
- e.target.value = "";
- }}
- />
+
+
!busy && inputRef.current?.click()}
+ onKeyDown={(e) => {
+ if ((e.key === "Enter" || e.key === " ") && !busy) {
+ e.preventDefault();
+ inputRef.current?.click();
+ }
+ }}
+ onDragOver={(e) => {
+ e.preventDefault();
+ setDragging(true);
+ }}
+ onDragLeave={() => setDragging(false)}
+ onDrop={(e) => {
+ e.preventDefault();
+ setDragging(false);
+ accept(e.dataTransfer.files?.[0]);
+ }}
+ className={`flex flex-col items-center justify-center gap-2 text-center px-4 py-8 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]"
+ } ${busy ? "opacity-60 pointer-events-none" : ""}`}
+ >
+
+
+ Drop the invoice PDF here
+
+
+ or tap to browse your files
+
+
- {result && (
-
- {result.ok ? (
-
- ) : (
-
- )}
-
-
{result.title}
-
{result.body}
+
{
+ accept(e.target.files?.[0]);
+ e.target.value = "";
+ }}
+ />
+
+ {file && (
+
+
+
+
+ {file.name}
+
+
+ {(file.size / 1024).toFixed(0)} KB · ready to upload
+
+
+ {!busy && (
+
+
+
+ )}
+
+ )}
+
+ {result && (
+
+ {result.ok ? (
+
+ ) : (
+
+ )}
+
+
{result.title}
+
{result.body}
+
+
+ )}
+
+
+ Figures are read from the invoice and checked before anything is saved. If it
+ does not reconcile it is kept in the problem inbox instead of imported.
+
+
+
+
+
+ Cancel
+
+
+ {busy ? : }
+ {busy ? "Importing…" : "OK — Import"}
+
+
)}
-
+ >
);
}
diff --git a/components/InvoiceList.tsx b/components/InvoiceList.tsx
index 2412e19..759de3e 100644
--- a/components/InvoiceList.tsx
+++ b/components/InvoiceList.tsx
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
-import { ChevronRight, AlertTriangle, CheckCircle2, MapPin, FileText, Repeat } from "lucide-react";
+import { ChevronRight, AlertTriangle, CheckCircle2, MapPin, FileText } from "lucide-react";
import type { Asset, BookedLine, Invoice } from "@/lib/data";
import { BOOK_INCLUSIVE_VAT, BOOKED_LONG_LABEL } from "@/lib/invoice-booking";
import { ZAR, ZAR2 } from "@/lib/utils";
@@ -17,14 +17,10 @@ export function InvoiceList({
invoices,
linesByInvoice,
assets,
- multiSite,
}: {
invoices: Invoice[];
linesByInvoice: Record
;
assets: Asset[];
- // line id -> other invoice numbers billing the same machine over the same period.
- // Informational only: a machine hired to two sites in a month is normal, not an error.
- multiSite: Map;
}) {
const [open, setOpen] = useState(null);
@@ -86,7 +82,6 @@ export function InvoiceList({
const lines = linesByInvoice[inv.id] ?? [];
const booked = lines.filter((l) => l.isBooked);
const unbooked = lines.filter((l) => !l.isBooked);
- const shared = lines.filter((l) => multiSite.get(l.id)?.length);
// Only an unbooked line is a problem. A machine used at two sites in a
// month is expected, so it must not tint the row or badge it as an error.
const hasProblem = unbooked.length > 0;
@@ -95,7 +90,6 @@ export function InvoiceList({
ls.reduce((s, l) => s + (l[k] || 0), 0);
const bookedExVat = sum(booked, "amount_excl");
const bookedVat = sum(booked, "vat");
- const bookedTotal = bookedAmount(bookedExVat, bookedVat);
return (
({ machineCode: l.bookedCode || l.machine_code, others: multiSite.get(l.id) ?? [] }))}
hasProblem={hasProblem}
isOpen={isOpen}
onToggle={() => setOpen(isOpen ? null : inv.id)}
@@ -129,16 +119,12 @@ export function InvoiceList({
}
function InvoiceRow({
- invoice, lineCount, unbooked, bookedExVat, bookedVat, bookedTotal, shared,
+ invoice, lineCount, unbooked,
hasProblem, isOpen, onToggle, expanded,
}: {
invoice: Invoice;
lineCount: number;
unbooked: number;
- bookedExVat: number;
- bookedVat: number;
- bookedTotal: number;
- shared: { machineCode: string; others: string[] }[];
hasProblem: boolean;
isOpen: boolean;
onToggle: () => void;
@@ -195,35 +181,17 @@ function InvoiceRow({
{ZAR2(invoice.total_incl)}
-
- {hasProblem ? (
-
-
- {unbooked} unbooked
-
- ) : (
-
-
- All booked
-
- )}
- {shared.length > 0 && (
-
-
-
- {shared.length === 1 ? "1 machine" : `${shared.length} machines`} also used on{" "}
- {[...new Set(shared.flatMap((d) => d.others))].join(", ")}
-
-
- )}
-
- booked {ZAR2(bookedTotal)}
- {BOOK_INCLUSIVE_VAT && bookedVat > 0 && <> · {ZAR2(bookedVat)} VAT>}
+ {hasProblem ? (
+
+
+ {unbooked} unbooked
-
+ ) : (
+
+
+ All booked
+
+ )}