refactor(collection-pool): split PoolRow + SupplierGroup into components.tsx
This commit is contained in:
parent
a5eaae6f7b
commit
da9b3e8b58
2 changed files with 350 additions and 318 deletions
347
src/app/(app)/collection-pool/components.tsx
Normal file
347
src/app/(app)/collection-pool/components.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue