feat(orders): inline attachment preview chain + email fallback

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-07-29 15:16:47 +02:00
parent c572d08e53
commit 32237f52c7
2 changed files with 218 additions and 19 deletions

View file

@ -248,4 +248,89 @@ describe("OrdersReviewModal", () => {
// Cost should now show 33.33.
expect(screen.getByDisplayValue("33.33")).toBeInTheDocument();
});
it("renders PDF preview via iframe when first attachment is a PDF", () => {
render(
<OrdersReviewModal
{...baseProps()}
attachments={[
{
id: "att-1",
created: "",
updated: "",
collectionId: "coll-1",
collectionName: "quote_attachments",
quote: "quote-1",
filename: "Acme-RFQ.pdf",
file: ["acme-token"],
content_type: "application/pdf",
size_bytes: 1000,
email_message_id: "",
kind: "pdf",
},
]}
/>,
);
const iframe = document.querySelector("iframe");
expect(iframe).toBeInTheDocument();
expect(iframe?.getAttribute("src")).toBe(
"/api/v1/files/coll-1/att-1/acme-token",
);
});
it("renders image preview via <img> when first attachment is an image", () => {
render(
<OrdersReviewModal
{...baseProps()}
attachments={[
{
id: "att-1",
created: "",
updated: "",
collectionId: "coll-1",
collectionName: "quote_attachments",
quote: "quote-1",
filename: "site-photo.jpg",
file: ["photo-token"],
content_type: "image/jpeg",
size_bytes: 1000,
email_message_id: "",
kind: "image",
},
]}
/>,
);
const img = document.querySelector("img");
expect(img).toBeInTheDocument();
expect(img?.getAttribute("src")).toBe(
"/api/v1/files/coll-1/att-1/photo-token",
);
});
it("renders 'no preview' list when attachments are not PDF/image", () => {
render(
<OrdersReviewModal
{...baseProps()}
attachments={[
{
id: "att-1",
created: "",
updated: "",
collectionId: "coll-1",
collectionName: "quote_attachments",
quote: "quote-1",
filename: "doc.docx",
file: ["docx-token"],
content_type:
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
size_bytes: 1000,
email_message_id: "",
kind: "doc",
},
]}
/>,
);
expect(screen.getByText(/no preview/i)).toBeInTheDocument();
expect(screen.getByText("doc.docx")).toBeInTheDocument();
});
});

View file

@ -514,7 +514,10 @@ export function OrdersReviewModal({
);
}
// --- Source-of-truth panel (filled out in Task 5) -------------------------
// --- Source-of-truth panel -----------------------------------------------
// Inline preview of the source attachment so the operator can scan
// the actual PDF/image against the order items in one screen.
// Falls back to the email body when no attachments exist.
function SourceOfTruth({
quoteEmail,
attachments,
@ -523,32 +526,143 @@ function SourceOfTruth({
attachments: QuoteAttachment[];
}) {
if (attachments.length === 0) {
return (
<div className="rounded-md border bg-muted/30 p-3 text-xs space-y-2">
<div className="flex items-center gap-1.5 font-semibold uppercase tracking-wide text-muted-foreground">
<Mail className="h-3 w-3" />
Email
</div>
<p className="text-foreground font-medium">{quoteEmail.subject || "(no subject)"}</p>
<pre className="whitespace-pre-wrap font-sans text-muted-foreground max-h-64 overflow-y-auto">
{quoteEmail.body || "(no body)"}
</pre>
</div>
);
return <EmailFallback quoteEmail={quoteEmail} />;
}
// Pick the first PDF if any, else first image, else the first
// entry of any other kind (which gets the "no preview" treatment).
const firstPdf = attachments.find((a) => a.kind === "pdf");
const firstImage = attachments.find((a) => a.kind === "image");
const firstOther = attachments.find(
(a) => a.kind !== "pdf" && a.kind !== "image",
);
const previewTarget = firstPdf ?? firstImage ?? null;
const othersAreUnpreviewable = attachments.some(
(a) => a.kind !== "pdf" && a.kind !== "image",
);
return (
<div className="rounded-md border bg-muted/30 p-3 text-xs space-y-2">
<div className="flex items-center gap-1.5 font-semibold uppercase tracking-wide text-muted-foreground">
<div className="rounded-md border bg-muted/30 text-xs space-y-3">
<div className="px-3 pt-3 flex items-center gap-1.5 font-semibold uppercase tracking-wide text-muted-foreground">
<Paperclip className="h-3 w-3" />
Attachments ({attachments.length}) — preview added in Task 5
Attachments ({attachments.length})
</div>
<ul className="space-y-1">
{/* Tab strip — one chip per attachment, primary chip = previewTarget */}
{attachments.length > 1 && (
<div className="px-3 flex flex-wrap gap-1.5 border-b border-border/40 pb-2">
{attachments.map((a) => {
const isActive = previewTarget && a.id === previewTarget.id;
const previewable = a.kind === "pdf" || a.kind === "image";
return (
<span
key={a.id}
className={cn(
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 leading-5 text-[10px]",
isActive
? "bg-primary/15 text-primary font-medium"
: "text-muted-foreground",
)}
title={`${a.filename} (${a.kind ?? "unknown"})`}
>
{previewable ? "●" : "○"} {a.filename}
</span>
);
})}
</div>
)}
{previewTarget && previewTarget.kind === "pdf" && (
<iframe
src={attachmentUrl(previewTarget)}
title={previewTarget.filename}
className="w-full h-[420px] bg-background border-t"
data-testid="review-attachment-iframe"
/>
)}
{previewTarget && previewTarget.kind === "image" && (
<div className="px-3 pb-3">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={attachmentUrl(previewTarget)}
alt={previewTarget.filename}
className="max-w-full max-h-[420px] rounded border bg-background"
data-testid="review-attachment-img"
/>
</div>
)}
{/* No previewable attachment at all — show a no-preview message. */}
{!previewTarget && firstOther && (
<div className="px-3 pb-3 space-y-1">
<p
className="text-muted-foreground italic"
data-testid="review-no-preview"
>
{`No preview available for this ${firstOther.kind ?? "file"} type.`}
</p>
</div>
)}
{othersAreUnpreviewable && previewTarget && (
<div className="px-3 pb-3">
<p className="text-[10px] text-muted-foreground">
+ {attachments.filter(
(a) => a.kind !== "pdf" && a.kind !== "image",
).length}{" "}
unpreviewable attachment(s) — open to view.
</p>
</div>
)}
<ul className="px-3 pb-3 space-y-1">
{attachments.map((a) => (
<li key={a.id} className="text-muted-foreground truncate">
{a.filename}
<li
key={a.id}
className="flex items-center justify-between gap-2 text-[11px]"
>
<span className="truncate text-muted-foreground">
{a.filename}
</span>
<a
href={attachmentUrl(a)}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-0.5"
>
open <ExternalLink className="h-2.5 w-2.5" />
</a>
</li>
))}
</ul>
</div>
);
}
function EmailFallback({
quoteEmail,
}: {
quoteEmail: { subject: string; body: string };
}) {
return (
<div className="rounded-md border bg-muted/30 p-3 text-xs space-y-2">
<div className="flex items-center gap-1.5 font-semibold uppercase tracking-wide text-muted-foreground">
<Mail className="h-3 w-3" />
Email
</div>
<p className="text-foreground font-medium">
{quoteEmail.subject || "(no subject)"}
</p>
<pre className="whitespace-pre-wrap font-sans text-muted-foreground max-h-[420px] overflow-y-auto">
{quoteEmail.body || "(no body)"}
</pre>
</div>
);
}
function attachmentUrl(a: QuoteAttachment): string {
const token = Array.isArray(a.file) ? a.file[0] : a.file;
return `/api/v1/files/${a.collectionId}/${a.id}/${token}`;
}