chore(playwright-mcp): add test snapshot and console logs

This commit is contained in:
SJCoetz 2026-08-06 01:56:47 +02:00
parent 2bd8fcf247
commit e2bcafe4d8
10 changed files with 594 additions and 364 deletions

View file

@ -0,0 +1,79 @@
[ 544ms] [INFO] %cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 599ms] [LOG] [HMR] connected @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 1044ms] [LOG] SW registered: http://localhost:4001/ @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 1684ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 1765ms] [LOG] [Fast Refresh] done in 81ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 7254ms] [INFO] %cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 7298ms] [LOG] [HMR] connected @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 7314ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 7659ms] [LOG] SW registered: http://localhost:4001/ @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 8318ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 8381ms] [LOG] [Fast Refresh] done in 63ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 8791ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 8815ms] [LOG] [Fast Refresh] done in 25ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 8840ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 8881ms] [LOG] [Fast Refresh] done in 42ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 8891ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 9165ms] [LOG] [Fast Refresh] done in 274ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 9209ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 9236ms] [LOG] [Fast Refresh] done in 26ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 9683ms] [LOG] [Fast Refresh] done in 474ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 9735ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 10844ms] Invalid or unexpected token
[ 12299ms] [INFO] %cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 12333ms] [LOG] [HMR] connected @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 12574ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 12980ms] [ERROR] Can't perform a React state update on a component that hasn't mounted yet. This indicates that you have a side-effect in your render function that asynchronously tries to update the component. Move this work to useEffect instead. @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/userspace/app/errors/intercept-console-error.js:57
[ 12990ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 13867ms] [LOG] [Fast Refresh] done in 876ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 13980ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 14031ms] [LOG] [Fast Refresh] done in 51ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 14194ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 14235ms] [LOG] [Fast Refresh] done in 42ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 14397ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 14470ms] [LOG] [Fast Refresh] done in 72ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 14682ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 14853ms] [LOG] [Fast Refresh] done in 170ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 15173ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 15213ms] [LOG] [Fast Refresh] done in 40ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 15380ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 15411ms] [LOG] [Fast Refresh] done in 31ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 15550ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 16518ms] [LOG] [Fast Refresh] done in 969ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 16720ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 16803ms] [LOG] [Fast Refresh] done in 84ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 16956ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 17950ms] [LOG] [Fast Refresh] done in 994ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 18202ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 19219ms] [LOG] [Fast Refresh] done in 1018ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 23326ms] [INFO] %cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 23475ms] [LOG] [HMR] connected @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 23499ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 23517ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 23740ms] [LOG] SW registered: http://localhost:4001/ @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 24424ms] [LOG] [Fast Refresh] done in 907ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 24503ms] [LOG] [Fast Refresh] done in 986ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 26965ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 27025ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 27070ms] [LOG] [Fast Refresh] done in 45ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 27081ms] [LOG] [Fast Refresh] done in 57ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 27081ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 28248ms] [LOG] [Fast Refresh] done in 1166ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 28322ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 28371ms] [LOG] [Fast Refresh] done in 49ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 30632ms] [LOG] [Fast Refresh] done in 2311ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 30657ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 31856ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 32591ms] [LOG] [Fast Refresh] done in 736ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 35593ms] [WARNING] The resource http://localhost:4001/_next/static/media/4de1fea1a954a5b6-s.p.woff2?v=1785974148275 was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://localhost:4001/dashboard:0
[ 35593ms] [WARNING] The resource http://localhost:4001/_next/static/css/app/layout.css?v=1785974159605 was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://localhost:4001/dashboard:0
[ 35593ms] [WARNING] The resource http://localhost:4001/_next/static/media/22a5144ee8d83bca-s.p.woff2?v=1785974148275 was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://localhost:4001/dashboard:0
[ 35593ms] [WARNING] The resource http://localhost:4001/_next/static/media/36966cca54120369-s.p.woff2?v=1785974148275 was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://localhost:4001/dashboard:0
[ 35593ms] [WARNING] The resource http://localhost:4001/_next/static/media/eaead17c7dbfcd5d-s.p.woff2?v=1785974148275 was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://localhost:4001/dashboard:0
[ 35593ms] [WARNING] The resource http://localhost:4001/_next/static/media/ee40bb094c99a29a-s.p.woff2?v=1785974148275 was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://localhost:4001/dashboard:0
[ 35593ms] [WARNING] The resource http://localhost:4001/_next/static/media/558ca1a6aa3cb55e-s.p.woff2?v=1785974148275 was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://localhost:4001/dashboard:0
[ 35594ms] [WARNING] The resource http://localhost:4001/_next/static/media/7b0b24f36b1a6d0b-s.p.woff2?v=1785974148275 was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://localhost:4001/dashboard:0
[ 35594ms] [WARNING] The resource http://localhost:4001/_next/static/media/e4af272ccee01ff0-s.p.woff2?v=1785974148275 was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://localhost:4001/dashboard:0
[ 35594ms] [WARNING] The resource http://localhost:4001/_next/static/media/7d4881bb7e1bf84d-s.p.woff2?v=1785974148275 was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://localhost:4001/dashboard:0
[ 35594ms] [WARNING] The resource http://localhost:4001/_next/static/media/13971731025ec697-s.p.woff2?v=1785974148275 was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://localhost:4001/dashboard:0
[ 35594ms] [WARNING] The resource http://localhost:4001/_next/static/media/6d664cce900333ee-s.p.woff2?v=1785974148275 was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://localhost:4001/dashboard:0

View file

@ -0,0 +1,11 @@
[ 5124ms] [WARNING] The resource http://localhost:4001/_next/static/media/6d664cce900333ee-s.p.woff2?v=1785974168950 was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://localhost:4001/quotes/ybqit9d6150vmg0:0
[ 5124ms] [WARNING] The resource http://localhost:4001/_next/static/media/4de1fea1a954a5b6-s.p.woff2?v=1785974168950 was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://localhost:4001/quotes/ybqit9d6150vmg0:0
[ 5124ms] [WARNING] The resource http://localhost:4001/_next/static/media/22a5144ee8d83bca-s.p.woff2?v=1785974168950 was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://localhost:4001/quotes/ybqit9d6150vmg0:0
[ 5124ms] [WARNING] The resource http://localhost:4001/_next/static/media/36966cca54120369-s.p.woff2?v=1785974168950 was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://localhost:4001/quotes/ybqit9d6150vmg0:0
[ 5124ms] [WARNING] The resource http://localhost:4001/_next/static/media/13971731025ec697-s.p.woff2?v=1785974168950 was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://localhost:4001/quotes/ybqit9d6150vmg0:0
[ 5124ms] [WARNING] The resource http://localhost:4001/_next/static/media/7b0b24f36b1a6d0b-s.p.woff2?v=1785974168950 was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://localhost:4001/quotes/ybqit9d6150vmg0:0
[ 5124ms] [WARNING] The resource http://localhost:4001/_next/static/media/7d4881bb7e1bf84d-s.p.woff2?v=1785974168950 was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://localhost:4001/quotes/ybqit9d6150vmg0:0
[ 5124ms] [WARNING] The resource http://localhost:4001/_next/static/media/558ca1a6aa3cb55e-s.p.woff2?v=1785974168950 was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://localhost:4001/quotes/ybqit9d6150vmg0:0
[ 5124ms] [WARNING] The resource http://localhost:4001/_next/static/media/eaead17c7dbfcd5d-s.p.woff2?v=1785974168950 was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://localhost:4001/quotes/ybqit9d6150vmg0:0
[ 5124ms] [WARNING] The resource http://localhost:4001/_next/static/media/ee40bb094c99a29a-s.p.woff2?v=1785974168950 was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://localhost:4001/quotes/ybqit9d6150vmg0:0
[ 5124ms] [WARNING] The resource http://localhost:4001/_next/static/media/e4af272ccee01ff0-s.p.woff2?v=1785974168950 was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://localhost:4001/quotes/ybqit9d6150vmg0:0

View file

@ -0,0 +1,3 @@
- generic [active] [ref=e1]:
- region "Notifications alt+T"
- button "Open Next.js Dev Tools" [ref=e9] [cursor=pointer]

View file

@ -0,0 +1,10 @@
- generic [active] [ref=f1e1]:
- region "Notifications alt+T"
- button "Open Next.js Dev Tools" [ref=f1e7] [cursor=pointer]:
- generic [ref=f1e10]:
- text: Compiling
- generic [ref=f1e11]:
- generic [ref=f1e12]: .
- generic [ref=f1e13]: .
- generic [ref=f1e14]: .
- alert [ref=f1e15]

View file

@ -0,0 +1,2 @@
- generic [active] [ref=f3e1]:
- region "Notifications alt+T"

View file

@ -0,0 +1,44 @@
- generic [active] [ref=f3e1]:
- generic [ref=f3e20]:
- complementary [ref=f3e21]:
- generic [ref=f3e22]:
- link "WFDASH WFDASH Westcott Fasteners" [ref=f3e23] [cursor=pointer]:
- /url: /dashboard
- img "WFDASH" [ref=f3e24]
- generic [ref=f3e25]:
- generic [ref=f3e26]: WFDASH
- generic [ref=f3e27]: Westcott Fasteners
- separator [ref=f3e28]
- navigation [ref=f3e31]:
- link "Dashboard" [ref=f3e32] [cursor=pointer]:
- /url: /dashboard
- separator [ref=f3e41]
- button "Management" [ref=f3e43]
- button "Quotes" [ref=f3e47]
- button "Orders" [ref=f3e51]
- button "Logistics" [ref=f3e55]
- button "Operations" [ref=f3e59]
- button "Stock" [ref=f3e63]
- button "Other" [ref=f3e67]
- separator [ref=f3e70]
- generic [ref=f3e71]:
- link "Settings" [ref=f3e72] [cursor=pointer]:
- /url: /settings
- button "Dark mode" [ref=f3e76]
- separator [ref=f3e79]
- generic [ref=f3e80]:
- generic [ref=f3e81]: D
- generic [ref=f3e83]:
- paragraph [ref=f3e84]: David Els
- paragraph [ref=f3e85]: david.els1@outlook.com
- button "Logout" [ref=f3e86]
- main [ref=f3e91]
- region "Notifications alt+T"
- button "Open Next.js Dev Tools" [ref=f3e117] [cursor=pointer]:
- generic [ref=f3e120]:
- text: Rendering
- generic [ref=f3e121]:
- generic [ref=f3e122]: .
- generic [ref=f3e123]: .
- generic [ref=f3e124]: .
- alert [ref=f3e17]

View file

@ -0,0 +1,2 @@
- generic [active] [ref=f5e1]:
- region "Notifications alt+T"

View file

@ -0,0 +1,2 @@
- generic [active] [ref=f5e1]:
- region "Notifications alt+T"

View file

@ -0,0 +1,2 @@
- generic [active] [ref=f5e1]:
- region "Notifications alt+T"

View file

@ -34,6 +34,12 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Tabs,
TabsList,
TabsTrigger,
TabsContent,
} from "@/components/ui/tabs";
import {
ArrowLeft,
Trash2,
@ -75,6 +81,7 @@ import {
RotateCcw,
Loader2,
Settings,
Activity,
type LucideIcon,
} from "lucide-react";
import { toast } from "sonner";
@ -234,6 +241,7 @@ function AttachmentsCard({
onUpload,
onDelete,
onPreview,
noWrap,
}: {
title: string;
subtitle: string;
@ -247,6 +255,13 @@ function AttachmentsCard({
onUpload: (file: File) => Promise<void>;
onDelete: (att: QuoteAttachment) => Promise<void>;
onPreview: (att: QuoteAttachment) => void;
/**
* When true, skip the outer Card + CardHeader + CardTitle wrappers
* and render only the body (upload zone + list). Used inside tabbed
* containers where the parent already provides the Card chrome and
* tab headers — without this, we'd render a card-within-a-card.
*/
noWrap?: boolean;
}) {
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [dragOver, setDragOver] = useState(false);
@ -257,6 +272,82 @@ function AttachmentsCard({
if (fileInputRef.current) fileInputRef.current.value = "";
};
const body = (
<>
<div
onDragOver={(e) => {
e.preventDefault();
setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={async (e) => {
e.preventDefault();
setDragOver(false);
const file = e.dataTransfer.files?.[0];
await handleFile(file);
}}
className={`rounded-md border-2 border-dashed p-6 text-center transition-colors ${
dragOver
? "border-primary bg-primary/5"
: "border-border bg-muted/20 hover:bg-muted/30"
} ${uploading ? "opacity-60 pointer-events-none" : "cursor-pointer"}`}
onClick={() => fileInputRef.current?.click()}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") fileInputRef.current?.click();
}}
aria-label={uploadAriaLabel}
>
<Paperclip className="h-6 w-6 mx-auto mb-2 text-muted-foreground" />
{uploading ? (
<p className="text-sm text-muted-foreground">
<RefreshCw className="inline h-3.5 w-3.5 animate-spin mr-1" />
Uploading…
</p>
) : (
<>
<p className="text-sm">Drop a file here or click to upload</p>
<p className="text-xs text-muted-foreground mt-1">
PDFs, images, Word / Excel docs
</p>
</>
)}
<input
ref={fileInputRef}
type="file"
className="hidden"
onChange={async (e) => {
await handleFile(e.target.files?.[0]);
}}
/>
</div>
{attachments.length === 0 ? (
<p className="text-xs text-muted-foreground">{emptyText}</p>
) : (
<ul className="space-y-1.5" data-testid={listTestId}>
{attachments.map((a) => (
<OutboundAttachmentRow
key={a.id}
attachment={a}
deleting={deletingId === a.id}
onDelete={() => onDelete(a)}
onPreview={() => onPreview(a)}
/>
))}
</ul>
)}
</>
);
if (noWrap) {
// Body-only — the parent already provides the Card chrome and
// tab headers. Wrapping in <div className="space-y-4"> keeps the
// spacing between the upload zone and the file list.
return <div className="space-y-4">{body}</div>;
}
return (
<Card>
<CardHeader>
@ -268,72 +359,7 @@ function AttachmentsCard({
</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div
onDragOver={(e) => {
e.preventDefault();
setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={async (e) => {
e.preventDefault();
setDragOver(false);
const file = e.dataTransfer.files?.[0];
await handleFile(file);
}}
className={`rounded-md border-2 border-dashed p-6 text-center transition-colors ${
dragOver
? "border-primary bg-primary/5"
: "border-border bg-muted/20 hover:bg-muted/30"
} ${uploading ? "opacity-60 pointer-events-none" : "cursor-pointer"}`}
onClick={() => fileInputRef.current?.click()}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") fileInputRef.current?.click();
}}
aria-label={uploadAriaLabel}
>
<Paperclip className="h-6 w-6 mx-auto mb-2 text-muted-foreground" />
{uploading ? (
<p className="text-sm text-muted-foreground">
<RefreshCw className="inline h-3.5 w-3.5 animate-spin mr-1" />
Uploading…
</p>
) : (
<>
<p className="text-sm">Drop a file here or click to upload</p>
<p className="text-xs text-muted-foreground mt-1">
PDFs, images, Word / Excel docs
</p>
</>
)}
<input
ref={fileInputRef}
type="file"
className="hidden"
onChange={async (e) => {
await handleFile(e.target.files?.[0]);
}}
/>
</div>
{attachments.length === 0 ? (
<p className="text-xs text-muted-foreground">{emptyText}</p>
) : (
<ul className="space-y-1.5" data-testid={listTestId}>
{attachments.map((a) => (
<OutboundAttachmentRow
key={a.id}
attachment={a}
deleting={deletingId === a.id}
onDelete={() => onDelete(a)}
onPreview={() => onPreview(a)}
/>
))}
</ul>
)}
</CardContent>
<CardContent className="space-y-4">{body}</CardContent>
</Card>
);
}
@ -779,6 +805,12 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri
// collection exists yet). Full audit trail needs a migration; see the
// History card's NOTE for details.
const [historyOpen, setHistoryOpen] = useState(false);
// "Original Request" (the inbound email) section toggle. Default
// closed — when an operator opens a quote they want to land on the
// items table, not on a wall of email text. The header strip still
// shows the customer + subject at a glance so the operator can
// decide whether to expand the email for context.
const [emailOpen, setEmailOpen] = useState(false);
// Approve & Send modal — opens when the operator clicks the primary
// Approve button. Mirrors the old Django approve modal's layout
// (customer block, send-email toggle, To/CC/BCC/Subject/Message
@ -1904,6 +1936,106 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri
</div>
</div>
{/*
Compact customer strip — replaces the old "Customer" sidebar
card. Lifts the customer / company / dates / letterhead /
claimed-by info onto a single horizontal band directly below
the header so the operator sees "who is this quote for"
before they decide which sidebar panel to open. Mobile wraps
the columns; desktop shows them inline.
*/}
<Card>
<CardContent className="pt-4 pb-4">
<div className="flex flex-wrap items-start gap-x-6 gap-y-3 text-sm">
<div>
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">Customer</p>
<p className="font-medium">{quote.expand?.customer?.customer || "—"}</p>
{quote.expand?.customer?.expand?.company && (
<p className="text-xs text-muted-foreground">
{quote.expand.customer.expand.company.company}
</p>
)}
</div>
<div>
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">Company</p>
<p className="font-medium">{quote.expand?.company?.company || "—"}</p>
</div>
<div>
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">Created</p>
<p className="font-medium">{formatDate(quote.created)}</p>
</div>
{quote.quote_date && (
<div>
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">Quote date</p>
<p className="font-medium">{formatDate(quote.quote_date)}</p>
</div>
)}
<div>
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">Letterhead</p>
<p className="font-medium">{quote.company_letterhead || "CNL"}</p>
</div>
<div>
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">Claimed by</p>
{quote.claimed_by ? (
<div className="flex items-center gap-1.5 mt-0.5">
<Avatar size="sm">
<AvatarFallback>{(claimerName || quote.claimed_by).slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<p className="font-medium truncate" title={claimerName || quote.claimed_by}>
{claimerName || (quote.claimed_by.length > 12 ? `${quote.claimed_by.slice(0, 12)}…` : quote.claimed_by)}
</p>
</div>
) : (
<p className="text-sm text-muted-foreground">Unclaimed</p>
)}
</div>
</div>
</CardContent>
</Card>
{/*
Original request (the customer's email + inbound attachments).
Moved here from the BOTTOM of the page — operators wanted it
visible at the same time as the items so they can cross-check
"did the customer say 5L or 4L?" while pricing without scrolling
past everything else. Default closed (email body is verbose);
the header still shows the customer + email subject at a glance
so the operator can decide whether to expand.
*/}
{(quote.email_body || attachments.some((a) => a.direction === "inbound")) && (
<Card>
<button
type="button"
onClick={() => setEmailOpen((s) => !s)}
className="w-full text-left px-6 py-4 flex items-center justify-between gap-3 cursor-pointer hover:bg-accent/30 transition-colors rounded-xl"
aria-expanded={emailOpen}
aria-controls="quote-email-body"
data-testid="toggle-original-request"
>
<span className="text-sm font-medium flex items-center gap-2">
<Mail className="h-4 w-4" />
Original request
<span className="text-xs font-normal text-muted-foreground">
{quote.email_subject || "what the customer sent"}
</span>
</span>
{emailOpen ? (
<ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
)}
</button>
{emailOpen && (
<CardContent id="quote-email-body" className="pt-0">
<QuoteEmailCard
quote={quote}
attachments={attachments.filter((a) => a.direction === "inbound")}
/>
</CardContent>
)}
</Card>
)}
{showReject && (
<Card className="border-destructive">
<CardContent className="pt-4 space-y-2">
@ -2154,109 +2286,95 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri
)}
</div>
{/* RIGHT COLUMN — meta + admin panels. The eye should land on the
Items card first; the panels on the right are reference / config
and sit quietly. Mobile stacks them below the items. */}
{/* RIGHT COLUMN — admin panels. The eye should land on the Items
card first; the panels on the right are reference / config
and sit quietly. Customer info lives in the strip below the
header (lifted out of this column during the v3 redesign).
Mobile stacks everything below the items.
Three cards now (down from six):
1. Attachments — tabbed (Outbound / Internal)
2. Linked Orders — its own card (specific, deserves focus)
3. Activity — tabbed (History / Email Status) */}
<div className="space-y-6">
{/* Compact meta card — Customer / Company / Dates / Letterhead /
Claimed by, packed into a single card with a header icon
rather than five separate micro-cards. */}
{/*
Attachments — single tabbed card with Outbound and Internal
tabs. Both write to the same `quote_attachments` PB
collection; they differ only in the `direction` value
stamped on the row and the labels/icon shown in the UI.
- `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)
*/}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm flex items-center gap-2">
<Building2 className="h-4 w-4 text-muted-foreground" />
Customer
<Paperclip className="h-4 w-4 text-muted-foreground" />
Attachments
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div>
<p className="font-medium">{quote.expand?.customer?.customer || "—"}</p>
{quote.expand?.customer?.expand?.company && (
<p className="text-xs text-muted-foreground">{quote.expand.customer.expand.company.company}</p>
)}
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
<div>
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">Company</p>
<p className="font-medium">{quote.expand?.company?.company || "—"}</p>
</div>
<div>
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">Letterhead</p>
<p className="font-medium">{quote.company_letterhead || "CNL"}</p>
</div>
<div>
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">Created</p>
<p className="font-medium">{formatDate(quote.created)}</p>
</div>
{quote.quote_date && (
<div>
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">Quote date</p>
<p className="font-medium">{formatDate(quote.quote_date)}</p>
</div>
)}
</div>
<div className="pt-2 border-t">
<p className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">Claimed by</p>
{quote.claimed_by ? (
<div className="flex items-center gap-2 min-w-0">
<Avatar size="sm">
<AvatarFallback>{(claimerName || quote.claimed_by).slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<p className="font-medium truncate" title={claimerName || quote.claimed_by}>
{claimerName || (quote.claimed_by.length > 12 ? `${quote.claimed_by.slice(0, 12)}…` : quote.claimed_by)}
</p>
</div>
) : (
<p className="text-sm text-muted-foreground">Unclaimed</p>
)}
</div>
<CardContent className="pt-0">
<Tabs defaultValue="outbound" className="w-full">
<TabsList className="w-full grid grid-cols-2">
<TabsTrigger value="outbound" data-testid="tab-outbound-attachments">
Outbound
<span className="ml-1.5 text-[10px] font-normal text-muted-foreground">
{outboundAttachments.length}
</span>
</TabsTrigger>
<TabsTrigger value="internal" data-testid="tab-internal-attachments">
Internal
<span className="ml-1.5 text-[10px] font-normal text-muted-foreground">
{internalAttachments.length}
</span>
</TabsTrigger>
</TabsList>
<TabsContent value="outbound" className="mt-3">
<AttachmentsCard
noWrap
title="Outbound attachments"
subtitle="files to send with this quote"
icon={Send}
emptyText="No outbound attachments yet. The generated quote PDF will be added automatically when you approve and email the quote."
listTestId="outbound-attachments"
uploadAriaLabel="Upload outbound attachment"
attachments={outboundAttachments}
uploading={outboundUploading}
deletingId={outboundDeleting}
onUpload={handleOutboundUpload}
onDelete={handleOutboundDelete}
onPreview={(att) => {
// Outbound file preview reuses the email-card's
// dialog via a tiny inline state — opening the
// same attachment preview pattern the office
// already trusts.
setOutboundPreviewAtt(att);
}}
/>
</TabsContent>
<TabsContent value="internal" className="mt-3">
<AttachmentsCard
noWrap
title="Internal attachments"
subtitle="price evidence — never sent to the buyer"
icon={FileLock}
emptyText="No internal attachments yet. Drop screenshots of supplier quotes, price-list PDFs, or distributor web pages here to justify the prices on each item."
listTestId="internal-attachments"
uploadAriaLabel="Upload internal attachment"
attachments={internalAttachments}
uploading={internalUploading}
deletingId={internalDeleting}
onUpload={handleInternalUpload}
onDelete={handleInternalDelete}
onPreview={(att) => setInternalPreviewAtt(att)}
/>
</TabsContent>
</Tabs>
</CardContent>
</Card>
<AttachmentsCard
title="Outbound attachments"
subtitle="files to send with this quote"
icon={Send}
emptyText="No outbound attachments yet. The generated quote PDF will be added automatically when you approve and email the quote."
listTestId="outbound-attachments"
uploadAriaLabel="Upload outbound attachment"
attachments={outboundAttachments}
uploading={outboundUploading}
deletingId={outboundDeleting}
onUpload={handleOutboundUpload}
onDelete={handleOutboundDelete}
onPreview={(att) => {
// Outbound file preview reuses the email-card's dialog via
// a tiny inline state — opening the same attachment preview
// pattern the office already trusts. Implementation lives
// alongside the card to keep the modal scope local.
setOutboundPreviewAtt(att);
}}
/>
{/*
* Internal / price-evidence attachments — the quoter
* drops screenshots of supplier quotes, price-list PDFs,
* distributor web pages, etc. here to justify the prices
* they set on each item. These NEVER go to the buyer —
* the `direction = "internal"` tag on the PB row tells
* the n8n outbound-email workflow to skip them.
*/}
<AttachmentsCard
title="Internal attachments"
subtitle="price evidence — never sent to the buyer"
icon={FileLock}
emptyText="No internal attachments yet. Drop screenshots of supplier quotes, price-list PDFs, or distributor web pages here to justify the prices on each item."
listTestId="internal-attachments"
uploadAriaLabel="Upload internal attachment"
attachments={internalAttachments}
uploading={internalUploading}
deletingId={internalDeleting}
onUpload={handleInternalUpload}
onDelete={handleInternalDelete}
onPreview={(att) => setInternalPreviewAtt(att)}
/>
{/* Linked orders — every order that has been hard-linked to
this quote, OR that has us as a `potential_quote` suggestion
awaiting accept. Single surface for the operator to see
@ -2296,178 +2414,174 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri
</CardContent>
</Card>
{/* History sits below Linked Orders — it's a reference panel,
the same accordion pattern as before. Quote Settings has
moved to the kebab menu ("Quote Settings…") so this card
doesn't need to exist inline anymore. */}
<Card>
<button
type="button"
onClick={() => setHistoryOpen((s) => !s)}
className="w-full text-left px-6 py-4 flex items-center justify-between gap-3 cursor-pointer hover:bg-accent/30 transition-colors rounded-xl"
aria-expanded={historyOpen}
aria-controls="quote-history-body"
>
<span className="text-sm font-medium flex items-center gap-2">
<History className="h-4 w-4" />
History
<span className="text-xs font-normal text-muted-foreground">
basic — full audit needs a migration
</span>
</span>
{historyOpen ? (
<ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
)}
</button>
{historyOpen && (
<CardContent id="quote-history-body" className="space-y-3 pt-0">
<p className="text-xs text-muted-foreground">
Derived from the existing quote + email fields. Full audit trail
(with actor + per-field diffs) requires a new PB collection and
write hooks — tracked as a follow-up migration.
</p>
<ul className="space-y-2 text-sm">
<HistoryRow
icon={<FileText className="h-3.5 w-3.5" />}
label="Quote created"
timestamp={quote.created}
/>
<HistoryRow
icon={<RefreshCw className="h-3.5 w-3.5" />}
label="Last updated"
timestamp={quote.updated}
muted={quote.updated === quote.created}
/>
<HistoryRow
icon={<User className="h-3.5 w-3.5" />}
label={`Status: ${statusLabels[quote.status] || quote.status}`}
timestamp={quote.updated}
/>
{quote.claimed_by && (
<HistoryRow
icon={<User className="h-3.5 w-3.5" />}
label={`Claimed by ${claimerName || quote.claimed_by}`}
timestamp={null}
muted
/>
)}
{quote.email_received_at && (
<HistoryRow
icon={<Mail className="h-3.5 w-3.5" />}
label="Original email received"
timestamp={quote.email_received_at}
/>
)}
{quote.email_delivered && (
<HistoryRow
icon={<Mail className="h-3.5 w-3.5" />}
label="Quote email delivered"
timestamp={quote.email_delivered_at}
/>
)}
{quote.email_opened && (
<HistoryRow
icon={<MailOpen className="h-3.5 w-3.5" />}
label="Quote email opened"
timestamp={quote.email_opened_at}
/>
)}
{quote.last_reply_at && (
<HistoryRow
icon={<Mail className="h-3.5 w-3.5" />}
label="Customer replied"
timestamp={quote.last_reply_at}
/>
)}
{quote.pdf_generated_at && (
<HistoryRow
icon={<FileDown className="h-3.5 w-3.5" />}
label="PDF generated"
timestamp={quote.pdf_generated_at}
/>
)}
{quote.email_failed_at && (
<HistoryRow
icon={<XCircle className="h-3.5 w-3.5" />}
label={
quote.email_error
? `Email send failed — ${quote.email_error}`
: "Email send failed"
}
timestamp={quote.email_failed_at}
destructive
/>
)}
{quote.status === "sending" && quote.sending_started_at && (
<HistoryRow
icon={<Loader2 className="h-3.5 w-3.5 animate-spin" />}
label="Email send in progress"
timestamp={quote.sending_started_at}
/>
)}
{quote.approved_by && (
<HistoryRow
icon={<CheckCircle className="h-3.5 w-3.5" />}
label={`Approved by ${claimerName || quote.approved_by}`}
timestamp={quote.updated}
/>
)}
{quote.follow_up_due && (
<HistoryRow
icon={<Clock className="h-3.5 w-3.5" />}
label={`Follow-up due ${formatDate(quote.follow_up_due)}`}
timestamp={null}
muted
/>
)}
</ul>
</CardContent>
)}
</Card>
{/*
Activity — single tabbed card combining History (event log)
and Email Status (delivery state). Both are reference data
the operator checks occasionally, not work surfaces — putting
them under one roof reduces sidebar noise without hiding
either. Defaults to Email Status because that's what the
operator checks when triaging a "did the customer get it?"
question; History is one click away.
*/}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm flex items-center gap-2">
<Mail className="h-4 w-4 text-muted-foreground" />
Email Status
<Activity className="h-4 w-4 text-muted-foreground" />
Activity
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
<div>
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">Delivered</p>
<div className="flex items-center gap-1.5 mt-0.5">
{quote.email_delivered ? (
<Mail className="h-4 w-4 text-primary" />
) : (
<Mail className="h-4 w-4 text-muted-foreground" />
)}
<span>{quote.email_delivered ? "Yes" : "No"}</span>
<CardContent className="pt-0">
<Tabs defaultValue="email" className="w-full">
<TabsList className="w-full grid grid-cols-2">
<TabsTrigger value="email" data-testid="tab-email-status">
Email Status
</TabsTrigger>
<TabsTrigger value="history" data-testid="tab-history">
History
</TabsTrigger>
</TabsList>
<TabsContent value="email" className="mt-3">
<div className="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
<div>
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">Delivered</p>
<div className="flex items-center gap-1.5 mt-0.5">
{quote.email_delivered ? (
<Mail className="h-4 w-4 text-primary" />
) : (
<Mail className="h-4 w-4 text-muted-foreground" />
)}
<span>{quote.email_delivered ? "Yes" : "No"}</span>
</div>
</div>
<div>
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">Opened</p>
<div className="flex items-center gap-1.5 mt-0.5">
{quote.email_opened ? (
<MailOpen className="h-4 w-4 text-primary" />
) : (
<MailOpen className="h-4 w-4 text-muted-foreground" />
)}
<span>{quote.email_opened ? "Yes" : "No"}</span>
</div>
</div>
<div className="col-span-2">
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">Sender</p>
<p className="truncate">{quote.email_sender || "—"}</p>
</div>
<div className="col-span-2">
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">Subject</p>
<p className="truncate">{quote.email_subject || "—"}</p>
</div>
</div>
</div>
<div>
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">Opened</p>
<div className="flex items-center gap-1.5 mt-0.5">
{quote.email_opened ? (
<MailOpen className="h-4 w-4 text-primary" />
) : (
<MailOpen className="h-4 w-4 text-muted-foreground" />
)}
<span>{quote.email_opened ? "Yes" : "No"}</span>
</TabsContent>
<TabsContent value="history" className="mt-3">
{/* History body — always visible inside its tab. No
outer accordion anymore; the tab itself is the
affordance for "show less / show different". */}
<div className="space-y-3">
<p className="text-xs text-muted-foreground">
Derived from the existing quote + email fields. Full audit trail
(with actor + per-field diffs) requires a new PB collection and
write hooks — tracked as a follow-up migration.
</p>
<ul className="space-y-2 text-sm" data-testid="history-list">
<HistoryRow
icon={<FileText className="h-3.5 w-3.5" />}
label="Quote created"
timestamp={quote.created}
/>
<HistoryRow
icon={<RefreshCw className="h-3.5 w-3.5" />}
label="Last updated"
timestamp={quote.updated}
muted={quote.updated === quote.created}
/>
<HistoryRow
icon={<User className="h-3.5 w-3.5" />}
label={`Status: ${statusLabels[quote.status] || quote.status}`}
timestamp={quote.updated}
/>
{quote.claimed_by && (
<HistoryRow
icon={<User className="h-3.5 w-3.5" />}
label={`Claimed by ${claimerName || quote.claimed_by}`}
timestamp={null}
muted
/>
)}
{quote.email_received_at && (
<HistoryRow
icon={<Mail className="h-3.5 w-3.5" />}
label="Original email received"
timestamp={quote.email_received_at}
/>
)}
{quote.email_delivered && (
<HistoryRow
icon={<Mail className="h-3.5 w-3.5" />}
label="Quote email delivered"
timestamp={quote.email_delivered_at}
/>
)}
{quote.email_opened && (
<HistoryRow
icon={<MailOpen className="h-3.5 w-3.5" />}
label="Quote email opened"
timestamp={quote.email_opened_at}
/>
)}
{quote.last_reply_at && (
<HistoryRow
icon={<Mail className="h-3.5 w-3.5" />}
label="Customer replied"
timestamp={quote.last_reply_at}
/>
)}
{quote.pdf_generated_at && (
<HistoryRow
icon={<FileDown className="h-3.5 w-3.5" />}
label="PDF generated"
timestamp={quote.pdf_generated_at}
/>
)}
{quote.email_failed_at && (
<HistoryRow
icon={<XCircle className="h-3.5 w-3.5" />}
label={
quote.email_error
? `Email send failed — ${quote.email_error}`
: "Email send failed"
}
timestamp={quote.email_failed_at}
destructive
/>
)}
{quote.status === "sending" && quote.sending_started_at && (
<HistoryRow
icon={<Loader2 className="h-3.5 w-3.5 animate-spin" />}
label="Email send in progress"
timestamp={quote.sending_started_at}
/>
)}
{quote.approved_by && (
<HistoryRow
icon={<CheckCircle className="h-3.5 w-3.5" />}
label={`Approved by ${claimerName || quote.approved_by}`}
timestamp={quote.updated}
/>
)}
{quote.follow_up_due && (
<HistoryRow
icon={<Clock className="h-3.5 w-3.5" />}
label={`Follow-up due ${formatDate(quote.follow_up_due)}`}
timestamp={null}
muted
/>
)}
</ul>
</div>
</div>
<div className="col-span-2">
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">Sender</p>
<p className="truncate">{quote.email_sender || "—"}</p>
</div>
<div className="col-span-2">
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">Subject</p>
<p className="truncate">{quote.email_subject || "—"}</p>
</div>
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
</div>
@ -2541,45 +2655,6 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri
onClose={() => setInternalPreviewAtt(null)}
/>
{/*
Original request — moved to the BOTTOM of the page per
operator feedback. The previous "Compare with original
request" toggle above the items was disruptive: it pushed
the items table down when the approver opened it, and
most of the time the operator just wanted a quick
reference (e.g. "did the customer say 5L or 4L?") without
it being in the way of the pricing work.
Now it's a dedicated section at the bottom. The same
`QuoteEmailCard` component renders the original email body
+ the inbound attachments (PDFs, drawings, specs) that
the customer sent — all direction="inbound" rows.
The "inbound" direction is filtered out of the outbound
and internal cards above, so there's exactly one place
to see each attachment.
*/}
{(quote.email_body || attachments.some((a) => a.direction === "inbound")) && (
<section
id="quote-original-request"
aria-labelledby="quote-original-request-heading"
>
<h2
id="quote-original-request-heading"
className="text-base font-semibold mb-3 flex items-center gap-2"
>
<Mail className="h-4 w-4 text-muted-foreground" />
Original request
<span className="text-xs font-normal text-muted-foreground">
what the customer sent
</span>
</h2>
<QuoteEmailCard
quote={quote}
attachments={attachments.filter((a) => a.direction === "inbound")}
/>
</section>
)}
<ApproveAndSendDialog
open={approveOpen}
onOpenChange={setApproveOpen}