From 63e4a654bf5e83830b057a3d4022aef37610b801 Mon Sep 17 00:00:00 2001 From: Dave Date: Sun, 30 Aug 2026 12:16:31 +0200 Subject: [PATCH] refactor(orders): extract AttachmentList into a shared component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure move — bodies copied verbatim from process/page.tsx into src/components/orders/attachment-list.tsx. The order detail page (Task 13) will now import the same component instead of growing a second copy. The 4 icon imports (FileText, Image as ImageIcon, FileQuestion, ExternalLink) became unused after the move and were removed from process/page.tsx. Re-exported from the new file alongside the MatchedAttachment type, attachmentHref helper, and inferKind helper. No behaviour change. tsc clean. No new lint errors in the touched files. Co-Authored-By: Claude --- src/app/(app)/orders/[id]/process/page.tsx | 151 ++------------------ src/components/orders/attachment-list.tsx | 153 +++++++++++++++++++++ 2 files changed, 161 insertions(+), 143 deletions(-) create mode 100644 src/components/orders/attachment-list.tsx diff --git a/src/app/(app)/orders/[id]/process/page.tsx b/src/app/(app)/orders/[id]/process/page.tsx index edc3827..bf8581e 100644 --- a/src/app/(app)/orders/[id]/process/page.tsx +++ b/src/app/(app)/orders/[id]/process/page.tsx @@ -37,10 +37,6 @@ import { Banknote, Lock, Unlock, - FileText, - Image as ImageIcon, - FileQuestion, - ExternalLink, Paperclip, MessageSquare, Split, @@ -61,6 +57,7 @@ import { cn } from "@/lib/utils"; import { FulfillFromStockCell } from "@/components/fulfill-from-stock-cell"; import { MarginApprovalDialog } from "@/components/margin-approval-dialog"; import { AiItemHistoryTrigger } from "@/components/ai-item-history-trigger"; +import { AttachmentList, type MatchedAttachment } from "@/components/orders/attachment-list"; import type { Order, OrderItem, Supplier, PurchaseOrder, QuoteItem, StockItem } from "@/lib/types"; /* ============================================================================ @@ -98,26 +95,12 @@ function parseDecimal(text: string): number | null { } /** - * Shape returned by /api/v1/orders/[id]/matched-attachments. Mirrors - * what we need to render an attachment row on the process page — - * `kind` drives the icon (pdf / image / doc / other), `direction` - * drives the badge label, `file[]` is PB's per-record file tokens - * (we use `file[0]` to build the /api/v1/files/{col}/{rec}/{fn} URL). - * - * Declared above the component so the `useState` - * hooks below can reference it without a hoisting wart. + * Shape returned by /api/v1/orders/[id]/matched-attachments is now + * `MatchedAttachment` from `@/components/orders/attachment-list` — + * shared between this page and the order detail page so the two never + * drift. The local declaration was deleted in Task 9 of the + * order↔quote linking plan. */ -type MatchedAttachment = { - id: string; - collectionId?: string; - filename: string; - file: string[]; - size_bytes: number; - content_type: string; - kind: string | null; - direction: string | null; - uploaded_at: string | null; -}; /** * Coerce a `number | null` to a finite number, defaulting to 0. @@ -2571,127 +2554,9 @@ function ItemCard({ } /* ============================================================================ - * AttachmentList — renders a list of attachments as clickable rows. - * - * Each row shows a kind-driven icon (pdf / image / doc / other), the - * filename, the direction badge (inbound / outbound / generated), and - * a click target that opens the file in a new tab via the auth-bypass - * proxy route `/api/v1/files/{collectionId}/{id}/{token}`. - * - * `kind` was added by the 2026-07-06 quote_attachments migration; older - * rows may have it as null/empty — we infer from `content_type` / - * filename extension as a fallback so the icon isn't generic for - * everything. + * AttachmentList lives at `@/components/orders/attachment-list` since + * Task 9 — shared by both order pages. The local copy was deleted. * ========================================================================== */ -function attachmentHref(a: MatchedAttachment): string | null { - if (!a.collectionId || !a.id || !a.file?.[0]) return null; - return `/api/v1/files/${a.collectionId}/${a.id}/${a.file[0]}`; -} - -function inferKind(a: MatchedAttachment): "pdf" | "image" | "doc" | "other" { - if (a.kind === "pdf" || a.kind === "image" || a.kind === "doc") { - return a.kind; - } - const ct = (a.content_type || "").toLowerCase(); - const fn = (a.filename || "").toLowerCase(); - if (ct.includes("pdf") || fn.endsWith(".pdf")) return "pdf"; - if (ct.startsWith("image/") || /\.(png|jpe?g|gif|webp|svg)$/.test(fn)) - return "image"; - if ( - ct.includes("word") || - ct.includes("excel") || - ct.includes("officedocument") || - /\.(docx?|xlsx?|csv|txt)$/.test(fn) - ) - return "doc"; - return "other"; -} - -function AttachmentList({ - label, - attachments, -}: { - label: string; - attachments: MatchedAttachment[]; -}) { - return ( -
-
- {label} -
-
    - {attachments.map((a) => { - const kind = inferKind(a); - const Icon = - kind === "pdf" - ? FileText - : kind === "image" - ? ImageIcon - : FileQuestion; - const href = attachmentHref(a); - const isViewable = - kind === "pdf" || kind === "image" || a.content_type.startsWith("text/"); - const sizeKb = - a.size_bytes > 0 ? `${Math.round(a.size_bytes / 1024)} KB` : ""; - const dir = a.direction || "inbound"; - const dirLabel = - dir === "outbound" - ? "outbound" - : dir === "generated" - ? "generated" - : "inbound"; - const dirClass = - dir === "outbound" - ? "border-primary/40 text-primary" - : dir === "generated" - ? "border-success/40 text-success" - : "border-muted-foreground/30 text-muted-foreground"; - const inner = ( -
    - - - {a.filename} - - {sizeKb && ( - - {sizeKb} - - )} - - {dirLabel} - - {href && isViewable && ( - - )} -
    - ); - return ( -
  • - {href ? ( - - {inner} - - ) : ( - inner - )} -
  • - ); - })} -
-
- ); -} /* ============================================================================ * TotalsCard — live money picture at the top of the process page. diff --git a/src/components/orders/attachment-list.tsx b/src/components/orders/attachment-list.tsx new file mode 100644 index 0000000..8b75035 --- /dev/null +++ b/src/components/orders/attachment-list.tsx @@ -0,0 +1,153 @@ +"use client"; + +import { ExternalLink, FileText, Image as ImageIcon, FileQuestion } from "lucide-react"; +import { cn } from "@/lib/utils"; + +/** + * Shape returned by /api/v1/orders/[id]/matched-attachments. Mirrors + * what we need to render an attachment row — + * `kind` drives the icon (pdf / image / doc / other), + * `direction` drives the badge label, + * `file[]` is PB's per-record file tokens (we use `file[0]` to build + * the /api/v1/files/{col}/{rec}/{fn} URL). + * + * `kind` was added by the 2026-07-06 quote_attachments migration; + * older rows may have it as null/empty — `inferKind` falls back from + * `content_type` / filename extension so the icon isn't generic for + * everything. + */ +export type MatchedAttachment = { + id: string; + collectionId?: string; + filename: string; + file: string[]; + size_bytes: number; + content_type: string; + kind: string | null; + direction: string | null; + uploaded_at: string | null; +}; + +export function attachmentHref(a: MatchedAttachment): string | null { + if (!a.collectionId || !a.id || !a.file?.[0]) return null; + return `/api/v1/files/${a.collectionId}/${a.id}/${a.file[0]}`; +} + +export function inferKind( + a: MatchedAttachment, +): "pdf" | "image" | "doc" | "other" { + if (a.kind === "pdf" || a.kind === "image" || a.kind === "doc") { + return a.kind; + } + const ct = (a.content_type || "").toLowerCase(); + const fn = (a.filename || "").toLowerCase(); + if (ct.includes("pdf") || fn.endsWith(".pdf")) return "pdf"; + if (ct.startsWith("image/") || /\.(png|jpe?g|gif|webp|svg)$/.test(fn)) + return "image"; + if ( + ct.includes("word") || + ct.includes("excel") || + ct.includes("officedocument") || + /\.(docx?|xlsx?|csv|txt)$/.test(fn) + ) + return "doc"; + return "other"; +} + +/** + * AttachmentList — renders a list of attachments as clickable rows. + * + * Each row shows a kind-driven icon (pdf / image / doc / other), the + * filename, the direction badge (inbound / outbound / generated), and + * a click target that opens the file in a new tab via the auth-bypass + * proxy route `/api/v1/files/{collectionId}/{id}/{token}`. + * + * Used by both the order detail page and the process page so the two + * never drift. Extracted from process/page.tsx in Task 9 of the + * order↔quote linking plan. + */ +export function AttachmentList({ + label, + attachments, +}: { + label: string; + attachments: MatchedAttachment[]; +}) { + return ( +
+
+ {label} +
+
    + {attachments.map((a) => { + const kind = inferKind(a); + const Icon = + kind === "pdf" + ? FileText + : kind === "image" + ? ImageIcon + : FileQuestion; + const href = attachmentHref(a); + const isViewable = + kind === "pdf" || kind === "image" || a.content_type.startsWith("text/"); + const sizeKb = + a.size_bytes > 0 ? `${Math.round(a.size_bytes / 1024)} KB` : ""; + const dir = a.direction || "inbound"; + const dirLabel = + dir === "outbound" + ? "outbound" + : dir === "generated" + ? "generated" + : "inbound"; + const dirClass = + dir === "outbound" + ? "border-primary/40 text-primary" + : dir === "generated" + ? "border-success/40 text-success" + : "border-muted-foreground/30 text-muted-foreground"; + const inner = ( +
    + + + {a.filename} + + {sizeKb && ( + + {sizeKb} + + )} + + {dirLabel} + + {href && isViewable && ( + + )} +
    + ); + return ( +
  • + {href ? ( + + {inner} + + ) : ( + inner + )} +
  • + ); + })} +
+
+ ); +}