Invoices: Add Invoice becomes a real dialog; quieten the invoice table
The upload panel expanded inline above the table, which pushed the list down the page every time it opened. It is now a standalone modal: backdrop, Escape to close, body scroll locked, sheet on small screens and a centred dialog from sm up. The file is chosen or dropped into the modal and nothing is sent until OK — a mis-drop can be cleared without leaving a stray record in the inbox. Cancel, a busy state and inline size/type errors round it out. The status column carried two extra lines on every single row — a multi-site note and a "booked R X · R Y VAT" figure — which is repetition: the figures are already in the Revenue/VAT/Total columns, and the note restated something the client ruled on. Both are gone, leaving just the booked/unbooked badge, which is the only thing that needs acting on. The per-row booking figures stay available in the expanded detail. The plumbing those two lines used — the multi-site cross-invoice lookup and the per-row booked/VAT totals — is now unused and removed with them. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5caa923375
commit
9e6235af92
3 changed files with 244 additions and 182 deletions
|
|
@ -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 (
|
||||
<div className="p-4 sm:p-8 max-w-[1400px]">
|
||||
|
|
@ -49,7 +45,7 @@ export default async function InvoicesPage() {
|
|||
{invoices.length === 0 ? (
|
||||
<EmptyInvoices />
|
||||
) : (
|
||||
<InvoiceList invoices={invoices} linesByInvoice={linesByInvoice} assets={assets} multiSite={multiSite} />
|
||||
<InvoiceList invoices={invoices} linesByInvoice={linesByInvoice} assets={assets} />
|
||||
)}
|
||||
|
||||
{unbookedCount > 0 && (
|
||||
|
|
|
|||
|
|
@ -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<File | null>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [result, setResult] = useState<null | { ok: boolean; title: string; body: string }>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(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 (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
|
|
@ -75,96 +115,154 @@ export function AddInvoiceButton() {
|
|||
<FileUp className="w-4 h-4" />
|
||||
Add Invoice
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-5 bg-white dark:bg-[#131a21] border border-gray-100 dark:border-white/5 rounded-2xl shadow-sm p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm font-semibold text-[#003D5B] dark:text-white">Add invoice</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
{open && (
|
||||
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-0 sm:p-4">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
onClick={close}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Upload an invoice PDF"
|
||||
onClick={() => !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 ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin text-[#00798C] shrink-0" />
|
||||
) : (
|
||||
<FileUp className="w-5 h-5 text-[#00798C] shrink-0" />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-[#003D5B] dark:text-white">
|
||||
{busy ? "Reading the invoice…" : "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 · figures are read from the invoice and checked before saving
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Add invoice"
|
||||
className="relative w-full sm:max-w-lg bg-white dark:bg-[#131a21] rounded-t-2xl sm:rounded-2xl shadow-2xl border border-gray-100 dark:border-white/10 max-h-[90vh] overflow-y-auto"
|
||||
>
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100 dark:border-white/5">
|
||||
<h2 className="text-base font-semibold text-[#003D5B] dark:text-white">Add invoice</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={close}
|
||||
disabled={busy}
|
||||
aria-label="Close"
|
||||
className="grid place-items-center w-11 h-11 -mr-2 rounded-lg text-gray-500 hover:bg-gray-100 dark:hover:bg-white/5 transition disabled:opacity-50"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="application/pdf,.pdf"
|
||||
className="sr-only"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) send(f);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<div className="p-5">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Choose or drop an invoice PDF"
|
||||
onClick={() => !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" : ""}`}
|
||||
>
|
||||
<UploadCloud className="w-7 h-7 text-[#00798C]" />
|
||||
<div className="text-sm font-medium text-[#003D5B] dark:text-white">
|
||||
Drop the invoice PDF here
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
or tap to browse your files
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<div
|
||||
className={`mt-3 flex items-start gap-2.5 px-3 py-2.5 rounded-xl text-sm ${
|
||||
result.ok ? "bg-[#00798C]/10 text-[#00798C]" : "bg-[#D1495B]/10 text-[#D1495B]"
|
||||
}`}
|
||||
>
|
||||
{result.ok ? (
|
||||
<CheckCircle2 className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold">{result.title}</div>
|
||||
<div className="mt-0.5 break-words">{result.body}</div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="application/pdf,.pdf"
|
||||
className="sr-only"
|
||||
onChange={(e) => {
|
||||
accept(e.target.files?.[0]);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
|
||||
{file && (
|
||||
<div className="mt-3 flex items-center gap-3 px-3 py-2.5 rounded-xl bg-[#00798C]/10">
|
||||
<FileText className="w-4 h-4 text-[#00798C] shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-[#003D5B] dark:text-white truncate">
|
||||
{file.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{(file.size / 1024).toFixed(0)} KB · ready to upload
|
||||
</div>
|
||||
</div>
|
||||
{!busy && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
aria-label="Remove the selected file"
|
||||
className="grid place-items-center w-11 h-11 -mr-1 rounded-lg text-gray-500 hover:bg-white/60 dark:hover:bg-white/10 transition"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div
|
||||
className={`mt-3 flex items-start gap-2.5 px-3 py-2.5 rounded-xl text-sm ${
|
||||
result.ok ? "bg-[#00798C]/10 text-[#00798C]" : "bg-[#D1495B]/10 text-[#D1495B]"
|
||||
}`}
|
||||
>
|
||||
{result.ok ? (
|
||||
<CheckCircle2 className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold">{result.title}</div>
|
||||
<div className="mt-0.5 break-words">{result.body}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 px-5 py-4 border-t border-gray-100 dark:border-white/5 bg-gray-50/60 dark:bg-white/[0.02] rounded-b-2xl">
|
||||
<button
|
||||
type="button"
|
||||
onClick={close}
|
||||
disabled={busy}
|
||||
className="inline-flex items-center justify-center min-h-[44px] px-4 rounded-lg text-sm font-medium text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-white/5 transition disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={upload}
|
||||
disabled={!file || busy}
|
||||
className="inline-flex items-center gap-2 min-h-[44px] px-4 rounded-lg text-sm font-semibold text-white bg-gradient-to-br from-[#00798C] to-[#003D5B] hover:opacity-90 transition disabled:opacity-40 disabled:pointer-events-none"
|
||||
>
|
||||
{busy ? <Loader2 className="w-4 h-4 animate-spin" /> : <FileUp className="w-4 h-4" />}
|
||||
{busy ? "Importing…" : "OK — Import"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, BookedLine[]>;
|
||||
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<string, string[]>;
|
||||
}) {
|
||||
const [open, setOpen] = useState<string | null>(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 (
|
||||
<InvoiceRow
|
||||
|
|
@ -103,10 +97,6 @@ export function InvoiceList({
|
|||
invoice={inv}
|
||||
lineCount={lines.length}
|
||||
unbooked={unbooked.length}
|
||||
bookedExVat={bookedExVat}
|
||||
bookedVat={bookedVat}
|
||||
bookedTotal={bookedTotal}
|
||||
shared={shared.map((l) => ({ 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)}
|
||||
</td>
|
||||
<td className="py-2 px-4 whitespace-nowrap">
|
||||
<div className="flex flex-col gap-1">
|
||||
{hasProblem ? (
|
||||
<span className="inline-flex items-center gap-1.5 self-start px-2.5 py-1.5 rounded-lg bg-[#D1495B]/10 text-[#D1495B] text-xs font-semibold">
|
||||
<AlertTriangle className="w-3.5 h-3.5 shrink-0" />
|
||||
{unbooked} unbooked
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 self-start px-2.5 py-1.5 rounded-lg bg-[#00798C]/10 text-[#00798C] text-xs font-semibold">
|
||||
<CheckCircle2 className="w-3.5 h-3.5 shrink-0" />
|
||||
All booked
|
||||
</span>
|
||||
)}
|
||||
{shared.length > 0 && (
|
||||
<span
|
||||
title="The same machine hired to more than one site this month — normal, each hire books to that machine."
|
||||
className="text-xs text-[#8a6420] dark:text-[#EDAE49] flex items-start gap-1"
|
||||
>
|
||||
<Repeat className="w-3 h-3 mt-0.5 shrink-0" />
|
||||
<span>
|
||||
{shared.length === 1 ? "1 machine" : `${shared.length} machines`} also used on{" "}
|
||||
{[...new Set(shared.flatMap((d) => d.others))].join(", ")}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 tabular-nums">
|
||||
booked {ZAR2(bookedTotal)}
|
||||
{BOOK_INCLUSIVE_VAT && bookedVat > 0 && <> · {ZAR2(bookedVat)} VAT</>}
|
||||
{hasProblem ? (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-[#D1495B]/10 text-[#D1495B] text-xs font-semibold">
|
||||
<AlertTriangle className="w-3.5 h-3.5 shrink-0" />
|
||||
{unbooked} unbooked
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-[#00798C]/10 text-[#00798C] text-xs font-semibold">
|
||||
<CheckCircle2 className="w-3.5 h-3.5 shrink-0" />
|
||||
All booked
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue