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:
parent
cb2beb4016
commit
63e4a654bf
2 changed files with 161 additions and 143 deletions
|
|
@ -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.
|
||||
|
|
|
|||
153
src/components/orders/attachment-list.tsx
Normal file
153
src/components/orders/attachment-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue