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:
parent
e84b0dbccb
commit
a19ad698cd
2 changed files with 197 additions and 113 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue