fix(quotes): finish migrating quote detail page to split attachment fields

The 2026-09-09 attachment split (cd359b7) moved outbound/internal
attachments to `quotes.outbound_attachments` / `internal_attachments`,
migrating the approvals and process pages but missing the main quote
detail page. It was still reading/writing the legacy
`quote_attachments` collection (`direction="outbound"/"internal"`),
which nothing downstream reads anymore.

Effect: the Attachments tab and Approve & Send modal on this page
couldn't see files uploaded via the new field, so the modal's
"selected attachments" checkboxes were built from an empty list —
operator-attached outbound files silently never made it into
`email_outbound_attachment_ids`, and the customer got an email with
no attachments even when the operator had attached one.

- Outbound/internal tabs, upload, and delete now go through
  `/api/v1/quotes/[id]/outbound-attachments` and `/internal-attachments`
  instead of writing `quote_attachments` rows directly.
- Send modal's attachment list is synthesized from the quote row's
  token fields (same pattern as the approvals page), so
  `selectedAttachmentIds` actually match what the server validates.
- Attachment preview dialog fetches via the `/api/v1/files/quotes/...`
  proxy instead of PB's raw `/api/files/quote_attachments/...`, which
  pointed at the wrong collection/id after this fix.

Also fixes the n8n "WFDASH — Quote Outbound Send" workflow: three
Code nodes filtered quote_attachments rows to filenames containing
'Quote-CNL' or 'Quote-ISHERWOOD' before picking the latest PDF. Since
PDF filenames are `Quote-{letterheadCode}-{number}.pdf` and there's a
third valid letterhead (WESTSCOTT GROUP OF COMPANIES), any quote sent
under that letterhead would starve the whole send chain of an input
item and silently no-op. The upstream PB query already scopes
correctly by quote id + kind, so the filter was redundant as well as
wrong — removed it, kept the latest-PDF-per-quote dedup. Repo
snapshot in n8n_workfows/ synced to match the deployed workflow
(PIEAkPYYYZLGl6Pj).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-09-09 15:44:29 +02:00
parent e84b0dbccb
commit a19ad698cd
2 changed files with 197 additions and 113 deletions

File diff suppressed because one or more lines are too long

View file

@ -389,17 +389,98 @@ function ResubmitBanner({
);
}
/**
* Synthesize a `QuoteAttachment`-shaped record from a PB file token
* living on `quotes.outbound_attachments` / `quotes.internal_attachments`.
*
* PB stores file fields with `<random>_<originalName>` tokens. The
* operator-visible filename is the suffix after the first
* underscore. We synthesize the rest of the shape with placeholders
* so `AttachmentsCard` and `SendQuoteModal`-equivalent selection
* (both of which expect `QuoteAttachment[]`) can render + pre-select
* by `id`/token. Other fields (`content_type`, `size_bytes`) are
* best-effort — degrade gracefully since we don't have a real PB
* row to read them from.
*
* Mirrors `tokenToQuoteAttachment` in the approvals review page —
* kept as a local copy rather than a shared import since each page
* owns its own small attachment-list surface.
*/
function tokenToQuoteAttachment(
token: string,
quoteId: string,
direction: "outbound" | "internal",
): QuoteAttachment {
const underscoreIdx = token.indexOf("_");
const filename = underscoreIdx >= 0 ? token.slice(underscoreIdx + 1) : token;
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
const contentTypeByExt: Record<string, string> = {
pdf: "application/pdf",
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
doc: "application/msword",
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
xls: "application/vnd.ms-excel",
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
txt: "text/plain",
csv: "text/csv",
zip: "application/zip",
dwg: "application/acad",
};
const kind: QuoteAttachment["kind"] =
ext === "pdf"
? "pdf"
: ["png", "jpg", "jpeg", "gif", "webp"].includes(ext)
? "image"
: ["doc", "docx", "xls", "xlsx", "csv", "txt"].includes(ext)
? "doc"
: "other";
return {
// `id` is the PB token — callers use it for selection tracking
// (the `SendQuoteModal`'s `selectedAttachmentIds` payload) and
// as the React key. The server validates these tokens against
// `quotes.outbound_attachments` / `internal_attachments` (see
// `sendQuoteEmail` / the attachment routes in quotes-service.ts).
id: token,
collectionId: "",
collectionName: "quotes",
created: "",
updated: "",
filename,
file: [token],
content_type: contentTypeByExt[ext] ?? "application/octet-stream",
size_bytes: 0,
email_message_id: "",
uploaded_at: "",
kind,
direction,
quote: quoteId,
};
}
/**
* AttachmentsCard — generic uploader card shared by the outbound
* (for the buyer) and internal (price evidence) attachment
* sections. Both sections write to the same `quote_attachments`
* PB collection; they differ only in the `direction` value the
* uploader stamps on the row and the labels/icon shown in the UI.
* sections.
*
* - `direction = "outbound"` → files the customer receives
* (picked up by the n8n email-send workflow)
* - `direction = "internal"` → price-evidence files the office
* uses to justify the prices (never sent to the buyer)
* - outbound → files the customer receives (picked up by the n8n
* email-send workflow); stored as PB file tokens on
* `quotes.outbound_attachments`, via
* `/api/v1/quotes/[id]/outbound-attachments`.
* - internal → price-evidence files the office uses to justify
* the prices (never sent to the buyer); stored on
* `quotes.internal_attachments`, via
* `/api/v1/quotes/[id]/internal-attachments`.
*
* These live directly on the quote row (as of the 2026-09-09 split)
* rather than in the legacy `quote_attachments` collection's
* `direction` field — the old direction-filtered rows are a dead
* end for outbound/internal now; only `kind = "pdf"` / `"inbound"`
* rows in that collection still matter (the generated PDF + the
* original RFQ email attachments).
*
* UX:
* - Drag-and-drop or click-to-pick upload zone (single file at a
@ -1092,10 +1173,30 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri
setItems(quoteItems);
const quoteAttachments = await getQuoteAttachments(id);
setAttachments(quoteAttachments);
const outbound = await getQuoteAttachments(id, "outbound");
setOutboundAttachments(outbound);
const internal = await getQuoteAttachments(id, "internal");
setInternalAttachments(internal);
// Outbound/internal files live on the quote row itself
// (`quotes.outbound_attachments` / `internal_attachments`,
// added in the 2026-09-09 attachment split) — NOT in the
// legacy `quote_attachments` collection's `direction` field,
// which only the generated PDF (`kind = "pdf"`) and inbound
// RFQ email attachments still populate.
const outboundTokensRaw = data.outbound_attachments;
const outboundTokens = Array.isArray(outboundTokensRaw)
? outboundTokensRaw
: outboundTokensRaw
? [outboundTokensRaw]
: [];
setOutboundAttachments(
outboundTokens.map((t) => tokenToQuoteAttachment(t, id, "outbound")),
);
const internalTokensRaw = data.internal_attachments;
const internalTokens = Array.isArray(internalTokensRaw)
? internalTokensRaw
: internalTokensRaw
? [internalTokensRaw]
: [];
setInternalAttachments(
internalTokens.map((t) => tokenToQuoteAttachment(t, id, "internal")),
);
const linkedOrder = await getOrderByQuote(id);
setLinkedOrderId(linkedOrder?.id || null);
// Also pull every order whose `potential_quote` points at us
@ -1168,30 +1269,27 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri
}
}, [id, router]);
// Re-fetch only the outbound attachments — cheaper than a full
// quote reload after upload/delete.
const refreshOutboundAttachments = useCallback(async () => {
try {
const outbound = await getQuoteAttachments(id, "outbound");
setOutboundAttachments(outbound);
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : "Failed to refresh attachments");
}
}, [id]);
// Upload/delete on `/api/v1/quotes/[id]/outbound-attachments` and
// `/internal-attachments` both respond with the field's full,
// post-mutation token list — so we can resync state directly from
// the response instead of round-tripping through PB again.
const applyOutboundTokens = useCallback(
(tokens: string[]) => {
setOutboundAttachments(
tokens.map((t) => tokenToQuoteAttachment(t, id, "outbound")),
);
},
[id],
);
// Same idea for the internal / price-evidence attachments —
// separate fetch so an upload doesn't trigger a full quote
// reload. Uses the "internal" direction value, which must exist
// on the `direction` select in the PB admin (see comment on
// the internalAttachments state above).
const refreshInternalAttachments = useCallback(async () => {
try {
const internal = await getQuoteAttachments(id, "internal");
setInternalAttachments(internal);
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : "Failed to refresh attachments");
}
}, [id]);
const applyInternalTokens = useCallback(
(tokens: string[]) => {
setInternalAttachments(
tokens.map((t) => tokenToQuoteAttachment(t, id, "internal")),
);
},
[id],
);
useEffect(() => {
fetchQuote();
@ -1457,29 +1555,28 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri
/**
* Upload an outbound attachment — a file the operator wants to
* send with the quote email when it's approved / sent. We POST
* multipart directly to PB's collection (no API route needed —
* the file field is a standard PB file field on quote_attachments).
*
* The new row carries `direction="outbound"` so the email-send
* workflow can pick it up alongside the generated PDF.
* send with the quote email when it's approved / sent. Goes to
* `quotes.outbound_attachments` via the API route (which the n8n
* outbound-send workflow and `sendQuoteEmail` both read directly
* off the quote row) — NOT the legacy `quote_attachments`
* collection, which nothing downstream reads for this anymore.
*/
const handleOutboundUpload = async (file: File) => {
setOutboundUploading(true);
try {
const pb = getPocketBase();
const form = new FormData();
form.append("quote", id);
form.append("filename", file.name);
form.append("file", file);
form.append("content_type", file.type || "application/octet-stream");
form.append("size_bytes", String(file.size));
form.append("kind", classifyFileKind(file));
form.append("direction", "outbound");
form.append("email_message_id", ""); // not from email intake
await pb.collection("quote_attachments").create(form);
const res = await fetch(`/api/v1/quotes/${id}/outbound-attachments`, {
method: "POST",
body: form,
});
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(body.error || `HTTP ${res.status}`);
}
const body = (await res.json()) as { attachments: string[] };
applyOutboundTokens(body.attachments);
toast.success(`Added ${file.name}`);
await refreshOutboundAttachments();
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : "Failed to upload file");
} finally {
@ -1488,19 +1585,25 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri
};
/**
* Delete an outbound attachment row from PB (which also removes
* the underlying file via the collection's cascade rule). We only
* allow deleting outbound files from this UI — inbound attachments
* Remove a token from `quotes.outbound_attachments`. We only allow
* deleting outbound files from this UI — inbound attachments
* belong to the email history and shouldn't be edited here.
*/
const handleOutboundDelete = async (att: QuoteAttachment) => {
if (att.direction !== "outbound") return;
setOutboundDeleting(att.id);
try {
const pb = getPocketBase();
await pb.collection("quote_attachments").delete(att.id);
const res = await fetch(
`/api/v1/quotes/${id}/outbound-attachments/${encodeURIComponent(att.id)}`,
{ method: "DELETE" },
);
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(body.error || `HTTP ${res.status}`);
}
const body = (await res.json()) as { attachments: string[] };
applyOutboundTokens(body.attachments);
toast.success(`Removed ${att.filename}`);
await refreshOutboundAttachments();
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : "Failed to delete attachment");
} finally {
@ -1510,30 +1613,25 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri
/**
* Internal / price-evidence upload. Mirrors handleOutboundUpload
* but tags the row with `direction = "internal"` so the n8n
* outbound-email workflow skips it. The "internal" value must
* exist on the `direction` select in the PB admin
* (Collections → quote_attachments → direction → Options). If
* the operator hits an upload that 500s with "Failed to create
* record", the value is missing and needs to be added in the
* PB admin.
* but writes to `quotes.internal_attachments`, which the outbound
* email pipeline never reads.
*/
const handleInternalUpload = async (file: File) => {
setInternalUploading(true);
try {
const pb = getPocketBase();
const form = new FormData();
form.append("quote", id);
form.append("filename", file.name);
form.append("file", file);
form.append("content_type", file.type || "application/octet-stream");
form.append("size_bytes", String(file.size));
form.append("kind", classifyFileKind(file));
form.append("direction", "internal");
form.append("email_message_id", ""); // not from email intake
await pb.collection("quote_attachments").create(form);
const res = await fetch(`/api/v1/quotes/${id}/internal-attachments`, {
method: "POST",
body: form,
});
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(body.error || `HTTP ${res.status}`);
}
const body = (await res.json()) as { attachments: string[] };
applyInternalTokens(body.attachments);
toast.success(`Added ${file.name}`);
await refreshInternalAttachments();
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : "Failed to upload file");
} finally {
@ -1542,7 +1640,7 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri
};
/**
* Delete an internal / price-evidence attachment. Guarded the
* Remove a token from `quotes.internal_attachments`. Guarded the
* same way as the outbound delete — only rows whose `direction`
* is "internal" are eligible, so an accidentally-routed inbound
* or generated row can't be deleted from this UI.
@ -1551,10 +1649,17 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri
if (att.direction !== "internal") return;
setInternalDeleting(att.id);
try {
const pb = getPocketBase();
await pb.collection("quote_attachments").delete(att.id);
const res = await fetch(
`/api/v1/quotes/${id}/internal-attachments/${encodeURIComponent(att.id)}`,
{ method: "DELETE" },
);
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(body.error || `HTTP ${res.status}`);
}
const body = (await res.json()) as { attachments: string[] };
applyInternalTokens(body.attachments);
toast.success(`Removed ${att.filename}`);
await refreshInternalAttachments();
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : "Failed to delete attachment");
} finally {
@ -3456,27 +3561,6 @@ function roundTo1(n: number): number {
return Math.round(n * 10) / 10;
}
/**
* Map a `File` to the `kind` enum used by `quote_attachments`. Keeps
* the Outbound Attachments card's icon consistent with the inbound
* email card without forcing the operator to pick a kind.
*/
function classifyFileKind(file: File): "pdf" | "image" | "doc" | "other" {
const ct = (file.type || "").toLowerCase();
const fn = (file.name || "").toLowerCase();
if (ct === "application/pdf" || fn.endsWith(".pdf")) return "pdf";
if (ct.startsWith("image/")) return "image";
if (
ct.includes("officedocument") ||
ct.includes("msword") ||
ct.includes("ms-excel") ||
/\.(docx?|xlsx?|csv|ods|rtf)$/.test(fn)
) {
return "doc";
}
return "other";
}
/**
* Structured error shape for the PDF viewer modal. We map the route's
* `code` field onto a kind we render distinctly — the raw error
@ -4560,14 +4644,14 @@ function OutboundAttachmentPreviewDialog({
setLoading(true);
setError(null);
try {
const pb = getPocketBase();
const token = pb.authStore.token;
const storedName = Array.isArray(attachment.file) ? attachment.file[0] : attachment.file;
const base = process.env.NEXT_PUBLIC_POCKETBASE_URL || "http://127.0.0.1:8090";
const fileUrl = `${base}/api/files/quote_attachments/${attachment.id}/${storedName}`;
const res = await fetch(fileUrl, {
headers: token ? { Authorization: token } : undefined,
});
// Outbound/internal attachments are file tokens living on
// the `quotes` record itself (`attachment.quote`), not rows
// in the legacy `quote_attachments` collection — fetch via
// the WFDASH file proxy per AGENTS.md, never PB's raw
// `/api/files/...` directly.
const fileUrl = `/api/v1/files/quotes/${attachment.quote}/${storedName}`;
const res = await fetch(fileUrl);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const blob = await res.blob();
if (cancelled) return;