refactor(orders): extract AttachmentList into a shared component

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 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-08-30 12:16:31 +02:00
parent cb2beb4016
commit 63e4a654bf
2 changed files with 161 additions and 143 deletions

View file

@ -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<MatchedAttachment[]>`
* 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 (
<div>
<div className="text-[10px] uppercase tracking-wide text-muted-foreground mb-1">
{label}
</div>
<ul className="space-y-1">
{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 = (
<div className="flex items-center gap-2 rounded border px-2 py-1.5 bg-background hover:bg-muted/40 transition-colors">
<Icon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="flex-1 truncate font-medium" title={a.filename}>
{a.filename}
</span>
{sizeKb && (
<span className="text-[10px] text-muted-foreground tabular-nums shrink-0">
{sizeKb}
</span>
)}
<span
className={cn(
"text-[10px] px-1.5 py-0.5 rounded border shrink-0",
dirClass
)}
>
{dirLabel}
</span>
{href && isViewable && (
<ExternalLink className="h-3 w-3 text-muted-foreground shrink-0" />
)}
</div>
);
return (
<li key={a.id}>
{href ? (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="block"
title={`Open ${a.filename}`}
>
{inner}
</a>
) : (
inner
)}
</li>
);
})}
</ul>
</div>
);
}
/* ============================================================================
* TotalsCard — live money picture at the top of the process page.

View file

@ -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 (
<div>
<div className="text-[10px] uppercase tracking-wide text-muted-foreground mb-1">
{label}
</div>
<ul className="space-y-1">
{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 = (
<div className="flex items-center gap-2 rounded border px-2 py-1.5 bg-background hover:bg-muted/40 transition-colors">
<Icon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="flex-1 truncate font-medium" title={a.filename}>
{a.filename}
</span>
{sizeKb && (
<span className="text-[10px] text-muted-foreground tabular-nums shrink-0">
{sizeKb}
</span>
)}
<span
className={cn(
"text-[10px] px-1.5 py-0.5 rounded border shrink-0",
dirClass,
)}
>
{dirLabel}
</span>
{href && isViewable && (
<ExternalLink className="h-3 w-3 text-muted-foreground shrink-0" />
)}
</div>
);
return (
<li key={a.id}>
{href ? (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="block"
title={`Open ${a.filename}`}
>
{inner}
</a>
) : (
inner
)}
</li>
);
})}
</ul>
</div>
);
}