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:
parent
c572d08e53
commit
32237f52c7
2 changed files with 218 additions and 19 deletions
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue