mega-plant/components/SupplierCell.tsx
David 80ca4788a2 Give external invoices a supplier name; keep them off the sites page
The name on an invoice means two different things. On an internal invoice it is a
SITE, transcribed from the invoice's own description. On an external invoice it is
the SUPPLIER - and the PDF does not print the supplier's company name at all, so
there is nothing to transcribe and a human has to type it.

`invoices.site` stays the single field; the column header follows the page (Site on
Internal, Supplier on External) and only the external one is editable, via
components/SupplierCell.tsx and PATCH /api/invoices/:id/site. It saves on blur or
Enter, never per keystroke, and an empty value clears the name so a stray
description fragment can be removed rather than displayed as a supplier.

Sites stop taking their names from external invoices, in both places:

- syncSitesFromInvoices only creates sites from internal invoices, so a job
  description can never be registered as a yard.
- buildSiteSummaries only counts internal invoice lines towards a site, which is
  what /sites and the dashboard's per-site chart read.

This is the "Move containers" case. PHI 192's description is "Crane Truck Hire 17
September 2026 Move containers", which had registered a site literally called
"Move containers" - a job, not a yard - on /sites, on the dashboard's per-site
chart and in the plant register's site filter. The record stays in the collection
(sites are never deleted) but is no longer surfaced; the count went 13 -> 12.
External income is not lost by this - it is booked to a machine and still appears on
/income and /charts. It is simply not a site.

The intake route stops resolving an external invoice's text against the site list,
which would have invented a site out of a description, and reports needsSupplier so
the import toast says the name is outstanding.

npm run seed now captures existing invoice names before it wipes the collection and
re-applies them where data/source does not specify one - the same data-loss trap the
uploaded PDFs already had. A JSON name always wins, so internal sites are unchanged.

Verified: typecheck clean, 45 tests pass (4 new, covering the "Move containers" case
directly), production build green, the new PATCH route 401s when anonymous, and
/sites plus both invoice pages pass the mobile checklist at 375x812 and 768x1024. The
supplier edit was exercised end to end and the test value reverted.

Still open: the three external invoices (PHI 178/186/192) have no supplier name yet
and none can be derived from the data. They show "Set supplier" until the client
supplies them.

Note: lib/rows.ts, lib/server-data.ts and the running/insurance feature are
someone else's in-flight work and were deliberately left uncommitted here.
2026-10-08 12:04:42 +02:00

146 lines
No EOL
5.2 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Building2, Check, Loader2, Pencil } from "lucide-react";
import { cn } from "@/lib/utils";
// The supplier name on an EXTERNAL invoice, edited in place.
//
// On an internal invoice the same field means a site, and that name is transcribed from
// the invoice's own description — so it is shown read-only. On an external invoice the
// description is not a company name at all ("Crane Truck Hire Sep 2026 Springs" yields
// "Springs"), so there is nothing to transcribe and a human has to type it.
//
// Saves on blur and on Enter, never on every keystroke: this is one field among many on a
// wide table, and a request per character would be both slow and impossible to undo. An
// empty value CLEARS the name — that is how a stray description fragment is removed.
export function SupplierCell({
invoiceId,
value,
onSaved,
}: {
invoiceId: string;
value: string;
/** Called with the saved name so the table can show it immediately, before the refresh. */
onSaved?: (name: string) => void;
}) {
const [draft, setDraft] = useState(value);
const [editing, setEditing] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const router = useRouter();
// The row is re-rendered from the server after a refresh, so a rename made elsewhere
// must not be overwritten by a stale local draft.
useEffect(() => {
if (!editing) setDraft(value);
}, [value, editing]);
useEffect(() => {
if (editing) inputRef.current?.focus();
}, [editing]);
const save = async (next: string) => {
const clean = next.trim();
if (clean === value.trim()) {
setEditing(false);
setDraft(value);
return;
}
setBusy(true);
setError(null);
try {
const res = await fetch(`/api/invoices/${invoiceId}/site`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ site: clean }),
});
const data = await res.json().catch(() => ({ ok: false }));
if (!res.ok || !data.ok) {
setError(data.error || "Could not save");
return;
}
setDraft(data.site);
setEditing(false);
onSaved?.(data.site);
router.refresh();
} catch {
setError("Could not reach the server");
} finally {
setBusy(false);
}
};
if (editing) {
return (
<div className="flex items-center gap-1">
<input
ref={inputRef}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={() => save(draft)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
save(draft);
} else if (e.key === "Escape") {
// Abandons the edit; a save already in flight is left to finish.
e.preventDefault();
setDraft(value);
setEditing(false);
}
}}
disabled={busy}
aria-label="Supplier name"
placeholder="Supplier name"
maxLength={120}
className="w-[150px] min-h-[44px] px-2 text-sm rounded-lg border border-[#30638E] bg-white dark:bg-surface text-[#003D5B] dark:text-white focus:outline-none focus:ring-2 focus:ring-[#30638E]/40"
/>
{busy ? (
<Loader2 className="w-4 h-4 text-[#00798C] animate-spin shrink-0" aria-label="Saving" />
) : (
<Check className="w-4 h-4 text-[#00798C] shrink-0" aria-hidden="true" />
)}
{error && <span className="text-xs text-[#D1495B] whitespace-nowrap">{error}</span>}
</div>
);
}
return (
<button
type="button"
onClick={() => setEditing(true)}
aria-label={value ? `Edit supplier ${value}` : "Add a supplier name"}
className={cn(
"group inline-flex items-center gap-1.5 min-h-[44px] px-1.5 -ml-1.5 rounded-lg text-left transition hover:bg-[#30638E]/[0.06] dark:hover:bg-white/5",
"focus:outline-none focus-visible:ring-2 focus-visible:ring-[#30638E]",
)}
>
{value ? (
<span className="inline-flex items-center gap-1.5 text-sm text-[#003D5B] dark:text-white whitespace-nowrap">
<Building2 className="w-3.5 h-3.5 text-[#30638E] shrink-0" />
{value}
</span>
) : (
<span className="inline-flex items-center gap-1.5 text-sm text-[#30638E] dark:text-[#00798C] whitespace-nowrap">
<Pencil className="w-3.5 h-3.5 shrink-0" />
Set supplier
</span>
)}
<Pencil className="w-3 h-3 text-gray-400 opacity-0 group-hover:opacity-100 transition shrink-0" aria-hidden="true" />
</button>
);
}
/** The read-only counterpart, used on the internal page where the name IS a site. */
export function ReadOnlyName({ value }: { value: string }) {
if (!value) return <span className="text-sm text-gray-400 dark:text-text-soft">—</span>;
return (
<span className="inline-flex items-center gap-1.5 text-sm text-[#003D5B] dark:text-white">
<Building2 className="w-3.5 h-3.5 text-[#EDAE49] shrink-0" />
{value}
</span>
);
}