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.
146 lines
No EOL
5.2 KiB
TypeScript
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>
|
|
);
|
|
} |