refactor(collection-pool): split PoolRow + SupplierGroup into components.tsx

This commit is contained in:
SJCoetz 2026-07-11 00:13:20 +02:00
parent a5eaae6f7b
commit da9b3e8b58
2 changed files with 350 additions and 318 deletions

View file

@ -0,0 +1,347 @@
"use client";
import Link from "next/link";
import { AlertTriangle, ChevronDown, Loader2, Truck } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { OrderItem } from "@/lib/types";
import {
type CollectionPoolGroup,
getAssignmentBadge,
getAssignmentState,
getPoolAgeLabel,
} from "./view-model";
type CollectionPoolRow = OrderItem & {
ready_for_collection?: boolean | null;
expand?: {
purchase_order?: {
id: string;
po_number?: string;
supplier?: string;
expand?: { supplier?: { id: string; suppliername?: string; email?: string } };
status?: string;
};
order?: {
id: string;
order_number?: string;
company?: string;
customer?: string;
expand?: {
company?: { id: string; company?: string };
customer?: { id: string; customer?: string };
};
};
};
};
export type CollectionPoolVisibleRow = CollectionPoolRow & {
supplierId: string;
supplierName: string;
companyName: string;
customerName: string;
poNumber: string;
orderNumber: string;
};
function markerTokenToClassName(token?: string): string {
switch (token) {
case "marker-blue":
return "bg-blue-500";
case "marker-emerald":
return "bg-emerald-500";
case "marker-violet":
return "bg-violet-500";
case "marker-amber":
return "bg-amber-500";
default:
return "bg-slate-300";
}
}
export function PoolRow({
row,
busy,
markerToken,
todayYmd,
selected,
onToggleSelected,
onOpenAssign,
onToggleReady,
}: {
row: CollectionPoolVisibleRow;
busy: boolean;
markerToken?: string;
todayYmd: string;
selected: boolean;
onToggleSelected: (rowId: string) => void;
onOpenAssign: (row: CollectionPoolVisibleRow) => void;
onToggleReady: (row: CollectionPoolVisibleRow, ready: boolean) => void;
}) {
const assignmentState = getAssignmentState(row, todayYmd);
const assignmentBadge = getAssignmentBadge(assignmentState);
const isAssigned = assignmentState !== "unassigned";
const readyLabel = row.ready_for_collection ? "Ready" : "Pending";
const assignedDateLabel = row.assigned_at
? new Date(row.assigned_at).toLocaleDateString(undefined, {
weekday: "short",
day: "numeric",
month: "short",
})
: null;
return (
<li
className={cn(
"grid grid-cols-[6px_minmax(0,1fr)]",
selected && "bg-primary/5",
)}
aria-busy={busy}
>
<span className={cn("min-h-full", markerTokenToClassName(markerToken))} aria-hidden />
<div className="grid gap-4 p-4 lg:grid-cols-[minmax(0,1.35fr)_minmax(0,0.95fr)_auto_auto] lg:items-center">
<div className="flex min-w-0 items-start gap-2">
<input
type="checkbox"
checked={selected}
onChange={() => onToggleSelected(row.id)}
disabled={busy}
aria-label={`Select ${row.description ?? "item"} at ${row.supplierName ?? "unknown supplier"}`}
className="mt-1 size-4 shrink-0 cursor-pointer accent-primary disabled:cursor-not-allowed"
/>
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-foreground">{row.supplierName}</p>
<p className="mt-1 truncate text-sm text-foreground">{row.description || "(no description)"}</p>
<div className="mt-1 flex flex-wrap gap-x-2 gap-y-1 text-xs text-muted-foreground">
<span>{row.customerName || "No customer"}</span>
<span className="text-border">/</span>
<span>{row.companyName || "No company"}</span>
</div>
{!row.purchase_order && (
<div className="mt-2 flex items-start gap-2 rounded-md border border-amber-200/80 bg-amber-50/70 px-2.5 py-2 text-[11px] leading-5 text-amber-900">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>This row has no purchase order link. The PO may have been deleted.</span>
</div>
)}
</div>
</div>
<div className="min-w-0 text-sm">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
<span className="font-medium uppercase tracking-wide">Order</span>
<Link href={`/orders/${row.order}`} className="min-w-0 truncate text-foreground hover:underline">
{row.orderNumber || row.order}
</Link>
</div>
<div className="mt-1 font-mono text-xs text-muted-foreground">{row.poNumber || "No PO"}</div>
<div className="mt-1 text-base font-semibold text-primary">
{getPoolAgeLabel(row.updated)}
</div>
{row.updated && (
<div className="mt-0.5 text-[11px] text-muted-foreground">
In pool since {new Date(row.updated).toLocaleDateString(undefined, {
day: "numeric",
month: "short",
})}
</div>
)}
{assignedDateLabel && <div className="mt-1 text-xs text-muted-foreground">Scheduled {assignedDateLabel}</div>}
</div>
<div className="flex flex-wrap items-center gap-2 lg:flex-col lg:items-start">
<span className={cn("inline-flex rounded-full px-2 py-0.5 text-xs font-medium", assignmentBadge.className)}>
{assignmentBadge.label}
</span>
<span
className={cn(
"inline-flex rounded-full px-2 py-0.5 text-xs font-medium",
row.ready_for_collection
? "bg-emerald-100 text-emerald-800 ring-1 ring-emerald-200/60"
: "bg-amber-100 text-amber-800 ring-1 ring-amber-200/60",
)}
>
{readyLabel}
</span>
{row.notes && row.notes.trim().length > 0 && (
<span className="group/notes relative inline-flex items-center rounded-md border bg-muted/30 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
Notes
<span
role="tooltip"
className="pointer-events-none absolute left-0 top-full z-20 mt-1 hidden w-72 rounded-md border bg-popover px-3 py-2 text-xs font-normal normal-case tracking-normal text-popover-foreground shadow-md group-hover/notes:block group-focus-within/notes:block"
>
{row.notes.length > 280 ? `${row.notes.slice(0, 280)}…` : row.notes}
</span>
</span>
)}
</div>
<div className="flex flex-col items-stretch gap-2 lg:min-w-[11rem]">
<Button size="sm" variant={isAssigned ? "outline" : "default"} onClick={() => onOpenAssign(row)} disabled={busy}>
{isAssigned ? "Reschedule" : "Assign Driver"}
</Button>
<Button
size="sm"
variant={row.ready_for_collection ? "outline" : "default"}
onClick={() => onToggleReady(row, !row.ready_for_collection)}
disabled={busy}
className={
row.ready_for_collection
? undefined
: "border-emerald-600 bg-emerald-600 text-white shadow-sm shadow-emerald-900/10 hover:border-emerald-700 hover:bg-emerald-700"
}
>
{row.ready_for_collection ? "Mark Pending" : "Mark Ready"}
</Button>
{busy && (
<span className="inline-flex items-center justify-end gap-1 text-[11px] text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" aria-hidden />
Updating...
</span>
)}
</div>
</div>
</li>
);
}
export function SupplierGroup({
group,
busy,
todayYmd,
selectedIds,
collapsed,
onToggleRowSelected,
onToggleSectionSelected,
onToggleCollapsed,
onOpenAssign,
onOpenBulkAssign,
onToggleReady,
}: {
// `group.rows` is typed as `CollectionPoolViewRow[]` in the helper, but
// `PoolRow` (this page's existing row component) expects the richer
// `CollectionPoolVisibleRow` (which extends `OrderItem`). The widening is
// sound because `buildSupplierGroups` is fed `CollectionPoolVisibleRow[]`
// — see Task 7 wiring.
group: Omit<CollectionPoolGroup, "rows"> & { rows: CollectionPoolVisibleRow[] };
busy: boolean;
todayYmd: string;
selectedIds: Set<string>;
collapsed: boolean;
onToggleRowSelected: (rowId: string) => void;
onToggleSectionSelected: (rowIds: string[]) => void;
onToggleCollapsed: (supplierId: string) => void;
onOpenAssign: (row: CollectionPoolVisibleRow) => void;
onOpenBulkAssign: (supplierId: string, supplierName: string, rowIds: string[]) => void;
onToggleReady: (row: CollectionPoolVisibleRow, ready: boolean) => void;
}) {
const rowIds = group.rows.map((r) => r.id);
const selectedCount = rowIds.filter((id) => selectedIds.has(id)).length;
const allSelected = selectedCount === rowIds.length && rowIds.length > 0;
const someSelected = selectedCount > 0 && !allSelected;
const allAssigned = group.rows.every(
(r) => getAssignmentState(r, todayYmd) !== "unassigned",
);
const eyebrowText = (() => {
if (group.topPoNumbers.length === 0) return null;
const head = group.topPoNumbers.map((p) => `PO ${p}`).join(", ");
return group.remainingPoCount > 0
? `${head} (+${group.remainingPoCount} more)`
: head;
})();
const assignLabel = allSelected
? `Assign ${rowIds.length} stops`
: selectedCount > 0
? `Assign ${selectedCount} selected`
: `Assign all ${rowIds.length}`;
const onAssignClick = () => {
const ids = allSelected || selectedCount === 0 ? rowIds : rowIds.filter((id) => selectedIds.has(id));
onOpenBulkAssign(group.supplierId, group.supplierName, ids);
};
return (
<section
aria-labelledby={`supplier-${group.supplierId}`}
data-od-id={`collection-pool-supplier-${group.supplierId}`}
className="border-b last:border-b-0"
>
<header className="sticky top-0 z-10 flex flex-wrap items-center gap-3 border-b bg-muted/30 px-4 py-2.5">
<span
className={cn("size-2.5 shrink-0 rounded-full", markerTokenToClassName(group.markerToken))}
aria-hidden
/>
<h3
id={`supplier-${group.supplierId}`}
className="text-sm font-semibold text-foreground"
>
{group.supplierName || "Unknown supplier"}
</h3>
<span className="text-xs text-muted-foreground">
· {group.rows.length} {group.rows.length === 1 ? "stop" : "stops"}
{eyebrowText ? <> · {eyebrowText}</> : null}
</span>
<div className="ml-auto flex items-center gap-2">
<label className="inline-flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
<input
type="checkbox"
checked={allSelected}
ref={(el) => {
if (el) el.indeterminate = someSelected;
}}
onChange={() => onToggleSectionSelected(rowIds)}
disabled={busy}
className="size-4 cursor-pointer accent-primary disabled:cursor-not-allowed"
aria-label={`Select all ${group.rows.length} stops at ${group.supplierName || "unknown supplier"}`}
/>
Select all
</label>
<Button
size="sm"
onClick={onAssignClick}
disabled={busy || allAssigned}
title={allAssigned ? "Every stop at this supplier is already assigned" : undefined}
>
<Truck className="mr-1.5 size-3.5" />
{assignLabel}
</Button>
<button
type="button"
onClick={() => onToggleCollapsed(group.supplierId)}
aria-expanded={!collapsed}
aria-controls={`supplier-${group.supplierId}-body`}
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<ChevronDown className={cn("size-4 transition-transform", collapsed && "-rotate-90")} />
</button>
</div>
</header>
<div
id={`supplier-${group.supplierId}-body`}
className={cn(
"grid overflow-hidden transition-[grid-template-rows] duration-150 ease-out",
collapsed ? "grid-rows-[0fr]" : "grid-rows-[1fr]",
)}
>
<ul className="divide-y min-h-0">
{group.rows.map((row) => (
<PoolRow
key={row.id}
row={row}
busy={busy}
markerToken={group.markerToken}
todayYmd={todayYmd}
selected={selectedIds.has(row.id)}
onToggleSelected={onToggleRowSelected}
onOpenAssign={onOpenAssign}
onToggleReady={onToggleReady}
/>
))}
</ul>
</div>
</section>
);
}

View file

@ -1,14 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
AlertTriangle,
ChevronDown,
Loader2,
Search,
Truck,
} from "lucide-react";
import Link from "next/link";
import { Search, Truck } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@ -34,6 +27,8 @@ import {
getPoolAgeLabel,
} from "./view-model";
import { PoolRow, SupplierGroup, type CollectionPoolVisibleRow } from "./components";
type CollectionPoolRow = OrderItem & {
ready_for_collection?: boolean | null;
expand?: {
@ -57,15 +52,6 @@ type CollectionPoolRow = OrderItem & {
};
};
type CollectionPoolVisibleRow = CollectionPoolRow & {
supplierId: string;
supplierName: string;
companyName: string;
customerName: string;
poNumber: string;
orderNumber: string;
};
type CollectionPoolAssignDialogState = {
row: CollectionPoolVisibleRow;
initialDriverId: string;
@ -81,21 +67,6 @@ function assignedAtToYmd(iso: string | undefined | null): string | undefined {
return d.toISOString().slice(0, 10);
}
function markerTokenToClassName(token?: string): string {
switch (token) {
case "marker-blue":
return "bg-blue-500";
case "marker-emerald":
return "bg-emerald-500";
case "marker-violet":
return "bg-violet-500";
case "marker-amber":
return "bg-amber-500";
default:
return "bg-slate-300";
}
}
function EmptyState({ message }: { message: string }) {
return (
<div className="flex flex-col items-center justify-center rounded-md border border-dashed py-16 text-center">
@ -683,289 +654,3 @@ export default function CollectionPoolPage() {
</div>
);
}
function PoolRow({
row,
busy,
markerToken,
todayYmd,
selected,
onToggleSelected,
onOpenAssign,
onToggleReady,
}: {
row: CollectionPoolVisibleRow;
busy: boolean;
markerToken?: string;
todayYmd: string;
selected: boolean;
onToggleSelected: (rowId: string) => void;
onOpenAssign: (row: CollectionPoolVisibleRow) => void;
onToggleReady: (row: CollectionPoolVisibleRow, ready: boolean) => void;
}) {
const assignmentState = getAssignmentState(row, todayYmd);
const assignmentBadge = getAssignmentBadge(assignmentState);
const isAssigned = assignmentState !== "unassigned";
const readyLabel = row.ready_for_collection ? "Ready" : "Pending";
const assignedDateLabel = row.assigned_at
? new Date(row.assigned_at).toLocaleDateString(undefined, {
weekday: "short",
day: "numeric",
month: "short",
})
: null;
return (
<li
className={cn(
"grid grid-cols-[6px_minmax(0,1fr)]",
selected && "bg-primary/5",
)}
aria-busy={busy}
>
<span className={cn("min-h-full", markerTokenToClassName(markerToken))} aria-hidden />
<div className="grid gap-4 p-4 lg:grid-cols-[minmax(0,1.35fr)_minmax(0,0.95fr)_auto_auto] lg:items-center">
<div className="flex min-w-0 items-start gap-2">
<input
type="checkbox"
checked={selected}
onChange={() => onToggleSelected(row.id)}
disabled={busy}
aria-label={`Select ${row.description ?? "item"} at ${row.supplierName ?? "unknown supplier"}`}
className="mt-1 size-4 shrink-0 cursor-pointer accent-primary disabled:cursor-not-allowed"
/>
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-foreground">{row.supplierName}</p>
<p className="mt-1 truncate text-sm text-foreground">{row.description || "(no description)"}</p>
<div className="mt-1 flex flex-wrap gap-x-2 gap-y-1 text-xs text-muted-foreground">
<span>{row.customerName || "No customer"}</span>
<span className="text-border">/</span>
<span>{row.companyName || "No company"}</span>
</div>
{!row.purchase_order && (
<div className="mt-2 flex items-start gap-2 rounded-md border border-amber-200/80 bg-amber-50/70 px-2.5 py-2 text-[11px] leading-5 text-amber-900">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>This row has no purchase order link. The PO may have been deleted.</span>
</div>
)}
</div>
</div>
<div className="min-w-0 text-sm">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
<span className="font-medium uppercase tracking-wide">Order</span>
<Link href={`/orders/${row.order}`} className="min-w-0 truncate text-foreground hover:underline">
{row.orderNumber || row.order}
</Link>
</div>
<div className="mt-1 font-mono text-xs text-muted-foreground">{row.poNumber || "No PO"}</div>
<div className="mt-1 text-base font-semibold text-primary">
{getPoolAgeLabel(row.updated)}
</div>
{row.updated && (
<div className="mt-0.5 text-[11px] text-muted-foreground">
In pool since {new Date(row.updated).toLocaleDateString(undefined, {
day: "numeric",
month: "short",
})}
</div>
)}
{assignedDateLabel && <div className="mt-1 text-xs text-muted-foreground">Scheduled {assignedDateLabel}</div>}
</div>
<div className="flex flex-wrap items-center gap-2 lg:flex-col lg:items-start">
<span className={cn("inline-flex rounded-full px-2 py-0.5 text-xs font-medium", assignmentBadge.className)}>
{assignmentBadge.label}
</span>
<span
className={cn(
"inline-flex rounded-full px-2 py-0.5 text-xs font-medium",
row.ready_for_collection
? "bg-emerald-100 text-emerald-800 ring-1 ring-emerald-200/60"
: "bg-amber-100 text-amber-800 ring-1 ring-amber-200/60",
)}
>
{readyLabel}
</span>
{row.notes && row.notes.trim().length > 0 && (
<span className="group/notes relative inline-flex items-center rounded-md border bg-muted/30 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
Notes
<span
role="tooltip"
className="pointer-events-none absolute left-0 top-full z-20 mt-1 hidden w-72 rounded-md border bg-popover px-3 py-2 text-xs font-normal normal-case tracking-normal text-popover-foreground shadow-md group-hover/notes:block group-focus-within/notes:block"
>
{row.notes.length > 280 ? `${row.notes.slice(0, 280)}…` : row.notes}
</span>
</span>
)}
</div>
<div className="flex flex-col items-stretch gap-2 lg:min-w-[11rem]">
<Button size="sm" variant={isAssigned ? "outline" : "default"} onClick={() => onOpenAssign(row)} disabled={busy}>
{isAssigned ? "Reschedule" : "Assign Driver"}
</Button>
<Button
size="sm"
variant={row.ready_for_collection ? "outline" : "default"}
onClick={() => onToggleReady(row, !row.ready_for_collection)}
disabled={busy}
className={
row.ready_for_collection
? undefined
: "border-emerald-600 bg-emerald-600 text-white shadow-sm shadow-emerald-900/10 hover:border-emerald-700 hover:bg-emerald-700"
}
>
{row.ready_for_collection ? "Mark Pending" : "Mark Ready"}
</Button>
{busy && (
<span className="inline-flex items-center justify-end gap-1 text-[11px] text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" aria-hidden />
Updating...
</span>
)}
</div>
</div>
</li>
);
}
function SupplierGroup({
group,
busy,
todayYmd,
selectedIds,
collapsed,
onToggleRowSelected,
onToggleSectionSelected,
onToggleCollapsed,
onOpenAssign,
onOpenBulkAssign,
onToggleReady,
}: {
// `group.rows` is typed as `CollectionPoolViewRow[]` in the helper, but
// `PoolRow` (this page's existing row component) expects the richer
// `CollectionPoolVisibleRow` (which extends `OrderItem`). The widening is
// sound because `buildSupplierGroups` is fed `CollectionPoolVisibleRow[]`
// — see Task 7 wiring.
group: Omit<CollectionPoolGroup, "rows"> & { rows: CollectionPoolVisibleRow[] };
busy: boolean;
todayYmd: string;
selectedIds: Set<string>;
collapsed: boolean;
onToggleRowSelected: (rowId: string) => void;
onToggleSectionSelected: (rowIds: string[]) => void;
onToggleCollapsed: (supplierId: string) => void;
onOpenAssign: (row: CollectionPoolVisibleRow) => void;
onOpenBulkAssign: (supplierId: string, supplierName: string, rowIds: string[]) => void;
onToggleReady: (row: CollectionPoolVisibleRow, ready: boolean) => void;
}) {
const rowIds = group.rows.map((r) => r.id);
const selectedCount = rowIds.filter((id) => selectedIds.has(id)).length;
const allSelected = selectedCount === rowIds.length && rowIds.length > 0;
const someSelected = selectedCount > 0 && !allSelected;
const allAssigned = group.rows.every(
(r) => getAssignmentState(r, todayYmd) !== "unassigned",
);
const eyebrowText = (() => {
if (group.topPoNumbers.length === 0) return null;
const head = group.topPoNumbers.map((p) => `PO ${p}`).join(", ");
return group.remainingPoCount > 0
? `${head} (+${group.remainingPoCount} more)`
: head;
})();
const assignLabel = allSelected
? `Assign ${rowIds.length} stops`
: selectedCount > 0
? `Assign ${selectedCount} selected`
: `Assign all ${rowIds.length}`;
const onAssignClick = () => {
const ids = allSelected || selectedCount === 0 ? rowIds : rowIds.filter((id) => selectedIds.has(id));
onOpenBulkAssign(group.supplierId, group.supplierName, ids);
};
return (
<section
aria-labelledby={`supplier-${group.supplierId}`}
data-od-id={`collection-pool-supplier-${group.supplierId}`}
className="border-b last:border-b-0"
>
<header className="sticky top-0 z-10 flex flex-wrap items-center gap-3 border-b bg-muted/30 px-4 py-2.5">
<span
className={cn("size-2.5 shrink-0 rounded-full", markerTokenToClassName(group.markerToken))}
aria-hidden
/>
<h3
id={`supplier-${group.supplierId}`}
className="text-sm font-semibold text-foreground"
>
{group.supplierName || "Unknown supplier"}
</h3>
<span className="text-xs text-muted-foreground">
· {group.rows.length} {group.rows.length === 1 ? "stop" : "stops"}
{eyebrowText ? <> · {eyebrowText}</> : null}
</span>
<div className="ml-auto flex items-center gap-2">
<label className="inline-flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
<input
type="checkbox"
checked={allSelected}
ref={(el) => {
if (el) el.indeterminate = someSelected;
}}
onChange={() => onToggleSectionSelected(rowIds)}
disabled={busy}
className="size-4 cursor-pointer accent-primary disabled:cursor-not-allowed"
aria-label={`Select all ${group.rows.length} stops at ${group.supplierName || "unknown supplier"}`}
/>
Select all
</label>
<Button
size="sm"
onClick={onAssignClick}
disabled={busy || allAssigned}
title={allAssigned ? "Every stop at this supplier is already assigned" : undefined}
>
<Truck className="mr-1.5 size-3.5" />
{assignLabel}
</Button>
<button
type="button"
onClick={() => onToggleCollapsed(group.supplierId)}
aria-expanded={!collapsed}
aria-controls={`supplier-${group.supplierId}-body`}
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<ChevronDown className={cn("size-4 transition-transform", collapsed && "-rotate-90")} />
</button>
</div>
</header>
<div
id={`supplier-${group.supplierId}-body`}
className={cn(
"grid overflow-hidden transition-[grid-template-rows] duration-150 ease-out",
collapsed ? "grid-rows-[0fr]" : "grid-rows-[1fr]",
)}
>
<ul className="divide-y min-h-0">
{group.rows.map((row) => (
<PoolRow
key={row.id}
row={row}
busy={busy}
markerToken={group.markerToken}
todayYmd={todayYmd}
selected={selectedIds.has(row.id)}
onToggleSelected={onToggleRowSelected}
onOpenAssign={onOpenAssign}
onToggleReady={onToggleReady}
/>
))}
</ul>
</div>
</section>
);
}