feat(clearance): preview charge documents before and after upload

This commit is contained in:
Marshal
2026-08-21 07:04:22 +00:00
parent ce3fde676e
commit 4c549029fe
32 changed files with 1195 additions and 660 deletions

View File

@@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Alert,
@@ -59,11 +59,65 @@ const isLocked = (s: Freight.ClearanceChargeStatus) =>
type BillInput = { amount: number; currency: string; description: string };
/**
* A blob URL for a not-yet-uploaded File, so the staff member can open it in
* the shared viewer before committing the upload. Revoked whenever the pick
* changes or the form unmounts — a leaked object URL pins the whole file in
* memory for the life of the tab.
*/
function useLocalPreview(file: File | null) {
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
if (!file) {
setUrl(null);
return;
}
const next = URL.createObjectURL(file);
setUrl(next);
return () => URL.revokeObjectURL(next);
}, [file]);
return file && url ? { name: file.name, url, mimeType: file.type } : null;
}
/** "Preview" for a staged file — same viewer the uploaded documents open in. */
function StagedFilePreview({
file,
onViewFile,
}: {
file: File | null;
onViewFile: (file: { name: string; url: string; mimeType?: string | null }) => void;
}) {
const preview = useLocalPreview(file);
if (!file) return null;
return (
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
<FileText size={15} color="var(--mantine-color-edr-green-6)" />
<Text fz="12.5px" c="edr-text" truncate style={{ minWidth: 0, maxWidth: 220 }}>
{file.name}
</Text>
{preview && isViewable({ name: file.name, url: "" }) ? (
<Tooltip label="Preview before uploading">
<Button
size="compact-xs"
variant="light"
color="edr-green"
radius="md"
leftSection={<Eye size={13} />}
onClick={() => onViewFile(preview)}
>
Preview
</Button>
</Tooltip>
) : null}
</Group>
);
}
export interface ClearanceChargesTabProps {
bookingId: string;
/** DJ uploads the port document; ET bills, sends and creates miscellaneous. */
roleMode: "ET" | "DJ";
onViewFile: (file: { name: string; url: string }) => void;
onViewFile: (file: { name: string; url: string; mimeType?: string | null }) => void;
}
/**
@@ -171,24 +225,13 @@ export function ClearanceChargesTab({
onSend={() => port && send.mutate(port.id)}
djUpload={
roleMode === "DJ" && (!port || port.status === "DOC_UPLOADED") ? (
<FileButton
onChange={(f) => f && uploadPort.mutate(f)}
accept="application/pdf,image/*"
disabled={busy}
>
{(props) => (
<Button
{...props}
size="compact-sm"
color="edr-green"
radius="md"
leftSection={<Upload size={14} />}
loading={uploadPort.isPending}
>
{port ? "Replace document" : "Upload document"}
</Button>
)}
</FileButton>
<PortDocumentUpload
replacing={Boolean(port)}
busy={busy}
uploading={uploadPort.isPending}
onViewFile={onViewFile}
onUpload={(f) => uploadPort.mutate(f)}
/>
) : null
}
/>
@@ -227,6 +270,7 @@ export function ClearanceChargesTab({
<MiscCreateForm
key={miscCreated}
busy={createMisc.isPending}
onViewFile={onViewFile}
onCreate={(file, input) => createMisc.mutate({ file, ...input })}
/>
</Paper>
@@ -255,6 +299,66 @@ export function ClearanceChargesTab({
);
}
/**
* Port-charges document: pick, preview, then upload. The pick is staged rather
* than sent straight away so the wrong scan can be caught before it lands on
* the customer's charge.
*/
function PortDocumentUpload({
replacing,
busy,
uploading,
onViewFile,
onUpload,
}: {
replacing: boolean;
busy: boolean;
uploading: boolean;
onViewFile: (file: { name: string; url: string; mimeType?: string | null }) => void;
onUpload: (file: File) => void;
}) {
const [file, setFile] = useState<File | null>(null);
return (
<Group gap={8} align="center" wrap="wrap">
<FileButton
onChange={setFile}
accept="application/pdf,image/*"
disabled={busy}
>
{(props) => (
<Button
{...props}
size="compact-sm"
variant={file ? "light" : "filled"}
color="edr-green"
radius="md"
leftSection={<Upload size={14} />}
>
{file ? "Choose another" : replacing ? "Replace document" : "Choose document"}
</Button>
)}
</FileButton>
<StagedFilePreview file={file} onViewFile={onViewFile} />
{file ? (
<Button
size="compact-sm"
color="edr-green"
radius="md"
loading={uploading}
disabled={busy}
onClick={() => {
onUpload(file);
setFile(null);
}}
>
{replacing ? "Upload replacement" : "Upload document"}
</Button>
) : null}
</Group>
);
}
function ChargeCard({
title,
charge,
@@ -272,7 +376,7 @@ function ChargeCard({
roleMode: "ET" | "DJ";
busy: boolean;
emptyHint: string;
onViewFile: (file: { name: string; url: string }) => void;
onViewFile: (file: { name: string; url: string; mimeType?: string | null }) => void;
onBill: (input: BillInput) => void;
onSend: () => void;
djUpload?: React.ReactNode;
@@ -554,9 +658,11 @@ function ChargeCard({
function MiscCreateForm({
busy,
onCreate,
onViewFile,
}: {
busy: boolean;
onCreate: (file: File, input: BillInput) => void;
onViewFile: (file: { name: string; url: string; mimeType?: string | null }) => void;
}) {
const [file, setFile] = useState<File | null>(null);
const [amount, setAmount] = useState<number | string>("");
@@ -585,10 +691,11 @@ function MiscCreateForm({
radius="md"
leftSection={<Upload size={14} />}
>
{file ? file.name : "Choose document"}
{file ? "Choose another" : "Choose document"}
</Button>
)}
</FileButton>
<StagedFilePreview file={file} onViewFile={onViewFile} />
<NumberInput
label="Amount"
size="xs"

View File

@@ -29,7 +29,7 @@ export interface ClearanceOpsTabsProps {
*/
exchangeEntityId?: string;
tradeDirection?: string;
onViewFile?: (file: { name: string; url: string }) => void;
onViewFile?: (file: { name: string; url: string; mimeType?: string | null }) => void;
onDownloadFile?: (file: { id: string; name: string }) => void;
}

View File

@@ -1,20 +1,21 @@
import { useMemo, useState } from "react";
import {
useMemo,
useState,
} from "react";
import {
Alert,
Badge,
Button,
FileInput,
Group,
Modal,
NumberInput,
Paper,
Select,
Stack,
Stepper,
Text,
Textarea,
} from "@mantine/core";
import { DateInput } from "@mantine/dates";
import {
DateInput,
} from "@mantine/dates";
import {
AlertTriangle,
CheckCircle2,
@@ -30,9 +31,12 @@ import {
import type { Freight } from "@edr/types";
import toast from "react-hot-toast";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone";
import { TransitAssigneePanel } from "@/components/contracts/TransitAssigneePanel";
import {
SectionCard,
} from "@/components/bookings/detail/SectionCard";
import {
TransitAssigneePanel,
} from "@/components/contracts/TransitAssigneePanel";
import {
TransitPermitMultiUpload,
type TransitPermitUploadedRow,
@@ -51,8 +55,12 @@ import {
type ClearanceViewLike,
type MilestoneRow,
} from "@/components/contracts/PhasedClearanceActionPanel";
import { contractsService } from "@/services/contracts.service";
import { bookingsService } from "@/services/bookings.service";
import {
contractsService,
} from "@/services/contracts.service";
import {
bookingsService,
} from "@/services/bookings.service";
/** Today as `yyyy-MM-dd` in the browser's local zone (a DateInput `minDate`). */
function todayISODate(): string {
@@ -66,8 +74,11 @@ function todayISODate(): string {
* customer docs → transit assignee (DJ names officer) → declaration (ET,
* releases the export) → RO (DJ, auto-releases) → create booking (ET)
* → payment + wagons → transport document / T1 (ET) → train to Djibouti
* → accept T1 (DJ, one button after arrival) → gate pass (DJ)
* → final invoice (DJ) + customer slip + GL confirm.
* → accept T1 (DJ, one button after arrival) → gate pass (DJ) → offload.
*
* The post-offload GL Djibouti final invoice was removed from this flow: it
* never gated anything downstream, so the export now completes at the offload.
* Its API endpoints and any already-issued invoices are untouched.
*/
export function computeExportActiveStep(
clearance: ClearanceViewLike,
@@ -93,11 +104,10 @@ export function computeExportActiveStep(
if (!clearance.train?.arrivedAt) return 7;
if (!clearance.t1Closed) return 8;
if (!clearance.gatepassGranted) return 9;
// Step 10 is the read-only Offload step. It never gates the flow: the final
// invoice may be raised on a secured gate pass alone, so parking the stepper
// there would hide the invoice actions whenever operations lag on the offload.
if (clearance.finalInvoice?.status !== "PAID") return 11;
return 12;
// Step 10 is the read-only Offload step, recorded by operations. It never
// gated the flow and nothing follows it, so the stepper completes here rather
// than waiting on an offload stamp this desk does not control.
return 10;
}
export function exportTransitFilesFromWorkflow(
@@ -491,27 +501,6 @@ export function ExportClearanceStepper({
<OffloadStep clearance={clearance} />
</Stepper.Step>
<Stepper.Step
label="Final invoice & payment"
description="GL Djibouti invoices after offload; customer pays"
icon={
clearance.finalInvoice?.status === "PAID" ? (
<CheckCircle2 size={14} />
) : (
<Receipt size={14} />
)
}
>
<FinalInvoiceStep
bookingId={actionBookingId}
clearance={clearance}
canDjAct={showDj && canDj}
canConfirm={(showDj && canDj) || (showEt && canEt)}
onChanged={onChanged}
onViewFile={onViewFile}
onDownloadFile={onDownloadFile}
/>
</Stepper.Step>
</Stepper>
</Paper>
</Stack>
@@ -688,230 +677,6 @@ function AcceptT1Step({
);
}
function FinalInvoiceStep({
bookingId,
clearance,
canDjAct,
canConfirm,
onChanged,
onViewFile,
onDownloadFile,
}: {
bookingId: string | null;
clearance: ClearanceViewLike;
canDjAct: boolean;
canConfirm: boolean;
onChanged?: () => void;
onViewFile?: (file: { name: string; url: string }) => void;
onDownloadFile?: (file: { id: string; name: string }) => void;
}) {
const [opened, setOpened] = useState(false);
const [amount, setAmount] = useState<number | string>("");
const [currency, setCurrency] = useState("ETB");
const [description, setDescription] = useState("");
const [file, setFile] = useState<File | null>(null);
const [sending, setSending] = useState(false);
const [confirming, setConfirming] = useState(false);
const invoice = clearance.finalInvoice ?? null;
const paid = invoice?.status === "PAID";
// Raised as a draft — the customer approves it before paying.
const approved = Boolean(invoice?.approvedAt);
// Export: OFFLOADED is a DJ doc milestone that may never be recorded, so the
// secured gate pass is enough to open invoicing. Sending an invoice is optional.
if (!clearance.offloaded && !clearance.gatepassGranted && !invoice) {
return (
<StepStatus
done={false}
pendingLabel="Waiting for cargo offload (handled in operations)."
doneLabel=""
/>
);
}
return (
<Stack gap="sm">
{invoice ? (
<Paper withBorder radius="md" p="sm">
<Group justify="space-between" wrap="nowrap">
<div>
<Text fw={700} size="sm">
{invoice.invoiceNumber}
</Text>
<Text size="sm" c="dimmed">
{invoice.totalAmount.toLocaleString()} {invoice.currency}
{invoice.description ? `${invoice.description}` : ""}
</Text>
</div>
<Badge color={paid ? "edr-green" : "yellow"} variant="light">
{approved ? invoice.status : "AWAITING CUSTOMER APPROVAL"}
</Badge>
</Group>
</Paper>
) : null}
{invoice?.invoiceFile ? (
<PhasedUploadedFileRow
label="Final Invoice"
file={invoice.invoiceFile}
onView={onViewFile}
onDownload={onDownloadFile}
compact
/>
) : null}
{invoice?.slipFile ? (
<PhasedUploadedFileRow
label="Customer payment slip"
file={invoice.slipFile}
onView={onViewFile}
onDownload={onDownloadFile}
compact
/>
) : null}
{paid ? (
<StepStatus
done
pendingLabel=""
doneLabel={`Payment confirmed${
invoice?.confirmedAt ? ` · ${new Date(invoice.confirmedAt).toLocaleString()}` : ""
}`}
/>
) : invoice ? (
<>
<StepStatus
done={false}
pendingLabel={
!approved
? "Waiting for the customer to review and approve the invoice."
: invoice.slipFile
? "Payment slip attached — confirm to settle the invoice."
: "Waiting for the customer to pay and attach the payment slip."
}
doneLabel=""
/>
{canConfirm && bookingId && invoice.slipFile ? (
<Button
color="edr-green"
loading={confirming}
leftSection={<CheckCircle2 size={16} />}
onClick={async () => {
setConfirming(true);
try {
await contractsService.confirmFinalInvoicePaid(bookingId);
toast.success("Payment confirmed — invoice settled");
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed");
} finally {
setConfirming(false);
}
}}
>
Confirm payment received
</Button>
) : null}
</>
) : canDjAct && bookingId ? (
<>
<Text size="sm" c="dimmed">
Send the final invoice to the customer if post-arrival charges apply (optional).
The customer approves it before paying.
</Text>
<Button
color="edr-green"
leftSection={<Receipt size={16} />}
onClick={() => setOpened(true)}
>
Send invoice
</Button>
<Modal
opened={opened}
onClose={() => setOpened(false)}
title={<Text fw={700}>Send final invoice</Text>}
radius="md"
size="md"
>
<Stack gap="md">
<Group grow align="flex-start">
<NumberInput
label="Amount"
placeholder="0.00"
value={amount}
onChange={setAmount}
min={0}
thousandSeparator=","
required
/>
<Select
label="Currency"
data={["ETB", "USD"]}
value={currency}
onChange={(v) => setCurrency(v ?? "ETB")}
/>
</Group>
<Textarea
label="Description"
placeholder="What the invoice bills for"
value={description}
onChange={(e) => setDescription(e.currentTarget.value)}
minRows={2}
/>
<PhasedFileDropzone
label="Invoice document"
description="Any file type."
accept="*/*"
value={file}
onChange={setFile}
onPreview={onViewFile}
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setOpened(false)} disabled={sending}>
Cancel
</Button>
<Button
color="edr-green"
loading={sending}
disabled={amount === "" || Number(amount) <= 0 || !file}
leftSection={<Upload size={16} />}
onClick={async () => {
if (!file) return;
setSending(true);
try {
await contractsService.sendFinalInvoice(bookingId, {
amount: Number(amount),
currency,
description: description.trim() || undefined,
file,
});
toast.success("Final invoice sent to the customer");
setOpened(false);
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed");
} finally {
setSending(false);
}
}}
>
Send invoice
</Button>
</Group>
</Stack>
</Modal>
</>
) : (
<StepStatus
done={false}
pendingLabel="Waiting for GL Djibouti to send the final invoice."
doneLabel=""
/>
)}
</Stack>
);
}
export function ReleaseOrderActions({
entityId,
isBooking,

View File

@@ -6,10 +6,32 @@ import {
type DraggableStateSnapshot,
type DropResult,
} from "@hello-pangea/dnd";
import { ActionIcon, Badge, Box, Group, Menu, Stack, Text, Tooltip } from "@mantine/core";
import {
ActionIcon,
Badge,
Box,
Button,
Checkbox,
Group,
Menu,
Paper,
Select,
Stack,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { GripVertical, MapPin, Trash2, Wrench } from "lucide-react";
import { memo, useCallback, useMemo, type ReactNode } from "react";
import { GripVertical, MapPin, Search, Trash2, Wrench, X } from "lucide-react";
import {
memo,
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { createPortal } from "react-dom";
import { api } from "@/services/api";
@@ -41,25 +63,86 @@ function ConsistWagonList({
onRemove,
onMaintenance,
onChangeYard,
onChangeYardBulk,
busy = false,
}: ConsistWagonListProps) {
const onDragEnd = useCallback((result: DropResult) => {
if (!result.destination) return;
const from = result.source.index;
const to = result.destination.index;
if (from === to) return;
const next = [...wagons];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved!);
onReorder(next.map((w) => w.id));
}, [wagons, onReorder]);
// Multi-select for the bulk yard move. Only offered when the page passes a
// bulk handler — otherwise the checkbox column would lead nowhere.
const canSelect = Boolean(onChangeYardBulk) && editable;
const [selected, setSelected] = useState<Set<string>>(new Set());
const [bulkYardId, setBulkYardId] = useState<string | null>(null);
const bulkYardsQuery = useQuery(
api.routes.yards.queryOptions({
staleTime: 5 * 60_000,
enabled: canSelect,
}),
);
// A wagon detached elsewhere must not linger in the selection.
useEffect(() => {
setSelected((prev) => {
if (!prev.size) return prev;
const live = new Set(wagons.map((w) => w.id));
const next = new Set([...prev].filter((id) => live.has(id)));
return next.size === prev.size ? prev : next;
});
}, [wagons]);
const toggleSelected = useCallback((wagonId: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(wagonId)) next.delete(wagonId);
else next.add(wagonId);
return next;
});
}, []);
const clearSelection = useCallback(() => setSelected(new Set()), []);
const allSelected = canSelect && selected.size === wagons.length && wagons.length > 0;
const applyBulkYard = () => {
if (!onChangeYardBulk || !bulkYardId || !selected.size) return;
onChangeYardBulk([...selected], bulkYardId, () => {
clearSelection();
setBulkYardId(null);
});
};
// Search never filters: a consist is a physical order, hiding rows would
// make position numbers lie. It scrolls the first match into view instead.
const [search, setSearch] = useState("");
const listRef = useRef<HTMLDivElement>(null);
const matchId = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return null;
return (
wagons.find((w) => w.wagonNumber.toLowerCase().includes(q))?.id ?? null
);
}, [search, wagons]);
useEffect(() => {
if (!matchId) return;
listRef.current
?.querySelector(`[data-wagon-id="${matchId}"]`)
?.scrollIntoView({ block: "center", behavior: "smooth" });
}, [matchId]);
const onDragEnd = useCallback(
(result: DropResult) => {
if (!result.destination) return;
const from = result.source.index;
const to = result.destination.index;
if (from === to) return;
const next = [...wagons];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved!);
onReorder(next.map((w) => w.id));
},
[wagons, onReorder],
);
// Legend of the types actually coupled, in consist order — the colour code is
// only readable if the row tints are keyed somewhere.
const legend = useMemo(
() => [
...new Map(
wagons.filter((w) => w.wagonType).map((w) => [w.wagonType!.code, w.wagonType!]),
wagons
.filter((w) => w.wagonType)
.map((w) => [w.wagonType!.code, w.wagonType!]),
).values(),
],
[wagons],
@@ -74,52 +157,149 @@ function ConsistWagonList({
}
return (
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="train-consist-wagons" isDropDisabled={!editable || busy}>
{(dropProvided) => (
<Stack gap="xs" ref={dropProvided.innerRef} {...dropProvided.droppableProps}>
{legend.length > 1 ? (
<Group gap={6} wrap="wrap">
{legend.map((type) => (
<Badge
key={type.code}
size="sm"
radius="sm"
variant="light"
color={wagonTypeColor(type.code)}
>
{type.code} · {type.name}
</Badge>
))}
</Group>
) : null}
{wagons.map((wagon, index) => (
<Draggable
key={wagon.id}
draggableId={wagon.id}
index={index}
isDragDisabled={!editable || busy}
<Stack gap="xs">
<TextInput
size="xs"
placeholder="Find wagon number…"
leftSection={<Search size={14} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
error={
search.trim() && !matchId
? "No wagon in this consist matches"
: undefined
}
aria-label="Find wagon in consist"
/>
{canSelect ? (
<Group justify="space-between" wrap="nowrap">
<Checkbox
size="xs"
label={
selected.size
? `${selected.size} selected`
: "Select wagons to move together"
}
checked={allSelected}
indeterminate={selected.size > 0 && !allSelected}
disabled={busy}
onChange={() =>
setSelected(allSelected ? new Set() : new Set(wagons.map((w) => w.id)))
}
/>
{selected.size ? (
<Button
size="compact-xs"
variant="subtle"
color="gray"
leftSection={<X size={13} />}
onClick={clearSelection}
disabled={busy}
>
Clear
</Button>
) : null}
</Group>
) : null}
{/* Bulk bar appears only with a selection, so it never competes with the
per-wagon yard badge for attention. */}
{canSelect && selected.size ? (
<Paper withBorder p="xs" radius="md" bg="var(--mantine-color-blue-0)">
<Group gap="xs" wrap="nowrap" align="flex-end">
<Select
size="xs"
style={{ flex: 1 }}
label={`Move ${selected.size} wagon${selected.size === 1 ? "" : "s"} to yard`}
placeholder="Select yard"
data={(bulkYardsQuery.data ?? []).map((y) => ({
value: y.id,
label: y.label ?? y.code,
}))}
value={bulkYardId}
onChange={setBulkYardId}
searchable
disabled={busy}
/>
<Button
size="xs"
leftSection={<MapPin size={14} />}
disabled={busy || !bulkYardId}
onClick={applyBulkYard}
>
Move
</Button>
</Group>
</Paper>
) : null}
{legend.length > 1 ? (
<Group gap={6} wrap="wrap">
{legend.map((type) => (
<Badge
key={type.code}
size="sm"
radius="sm"
variant="light"
color={wagonTypeColor(type.code)}
>
{type.code} · {type.name}
</Badge>
))}
</Group>
) : null}
<DragDropContext onDragEnd={onDragEnd}>
<Droppable
droppableId="train-consist-wagons"
isDropDisabled={!editable || busy}
>
{(dropProvided) => (
<Box
ref={listRef}
p="xs"
style={{
maxHeight: 520,
overflowY: "auto",
border: "1px solid var(--mantine-color-gray-3)",
borderRadius: "var(--mantine-radius-md)",
}}
>
<Stack
gap="xs"
ref={dropProvided.innerRef}
{...dropProvided.droppableProps}
>
{(dragProvided, snapshot) => (
<WagonRow
wagon={wagon}
{wagons.map((wagon, index) => (
<Draggable
key={wagon.id}
draggableId={wagon.id}
index={index}
dragProvided={dragProvided}
snapshot={snapshot}
editable={editable}
busy={busy}
onRemove={onRemove}
onMaintenance={onMaintenance}
onChangeYard={onChangeYard}
/>
)}
</Draggable>
))}
{dropProvided.placeholder}
</Stack>
)}
</Droppable>
</DragDropContext>
isDragDisabled={!editable || busy}
>
{(dragProvided, snapshot) => (
<WagonRow
wagon={wagon}
index={index}
dragProvided={dragProvided}
snapshot={snapshot}
editable={editable}
busy={busy}
highlighted={wagon.id === matchId}
selectable={canSelect}
selected={selected.has(wagon.id)}
onToggleSelected={toggleSelected}
onRemove={onRemove}
onMaintenance={onMaintenance}
onChangeYard={onChangeYard}
/>
)}
</Draggable>
))}
{dropProvided.placeholder}
</Stack>
</Box>
)}
</Droppable>
</DragDropContext>
</Stack>
);
}
@@ -135,6 +315,15 @@ export interface ConsistWagonListProps {
onMaintenance: (wagon: TrainCompositionWagon) => void;
/** Move one wagon to another yard from its yard badge; absent = read-only badge. */
onChangeYard?: (wagonId: string, currentYardId: string) => void;
/**
* Move every selected wagon to one yard in a single request. Absent hides the
* selection column entirely.
*/
onChangeYardBulk?: (
wagonIds: string[],
currentYardId: string,
onDone: () => void,
) => void;
busy?: boolean;
}
@@ -148,13 +337,23 @@ function WagonYardBadge({
busy: boolean;
onChange?: (wagonId: string, currentYardId: string) => void;
}) {
const label = wagon.currentYard?.label ?? wagon.currentYard?.code ?? "No yard";
const label =
wagon.currentYard?.label ?? wagon.currentYard?.code ?? "No yard";
const yardsQuery = useQuery(
api.routes.yards.queryOptions({ staleTime: 5 * 60_000, enabled: Boolean(onChange) }),
api.routes.yards.queryOptions({
staleTime: 5 * 60_000,
enabled: Boolean(onChange),
}),
);
if (!onChange) {
return wagon.currentYard ? (
<Badge variant="outline" color="gray" size="xs" radius="sm" leftSection={<MapPin size={10} />}>
<Badge
variant="outline"
color="gray"
size="xs"
radius="sm"
leftSection={<MapPin size={10} />}
>
{label}
</Badge>
) : null;
@@ -202,6 +401,10 @@ const WagonRow = memo(function WagonRow({
snapshot,
editable,
busy,
highlighted,
selectable,
selected,
onToggleSelected,
onRemove,
onMaintenance,
onChangeYard,
@@ -212,6 +415,10 @@ const WagonRow = memo(function WagonRow({
snapshot: DraggableStateSnapshot;
editable: boolean;
busy: boolean;
highlighted: boolean;
selectable: boolean;
selected: boolean;
onToggleSelected: (wagonId: string) => void;
onRemove: (wagonId: string) => void;
onMaintenance: (wagon: TrainCompositionWagon) => void;
onChangeYard?: (wagonId: string, currentYardId: string) => void;
@@ -224,6 +431,7 @@ const WagonRow = memo(function WagonRow({
ref={dragProvided.innerRef}
{...dragProvided.draggableProps}
{...dragProvided.dragHandleProps}
data-wagon-id={wagon.id}
gap="sm"
wrap="nowrap"
p="sm"
@@ -237,11 +445,33 @@ const WagonRow = memo(function WagonRow({
background: snapshot.isDragging
? "white"
: `var(--mantine-color-${color}-0)`,
boxShadow: snapshot.isDragging ? "0 8px 24px rgba(0, 0, 0, 0.12)" : undefined,
cursor: editable ? (snapshot.isDragging ? "grabbing" : "grab") : "default",
boxShadow: snapshot.isDragging
? "0 8px 24px rgba(0, 0, 0, 0.12)"
: highlighted
? "0 0 0 3px var(--mantine-color-yellow-4)"
: selected
? "0 0 0 2px var(--mantine-color-blue-5)"
: undefined,
cursor: editable
? snapshot.isDragging
? "grabbing"
: "grab"
: "default",
userSelect: "none",
}}
>
{selectable ? (
<Checkbox
size="sm"
checked={selected}
disabled={busy}
aria-label={`Select wagon ${wagon.wagonNumber}`}
// The row is a drag handle — keep the click on the checkbox.
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
onChange={() => onToggleSelected(wagon.id)}
/>
) : null}
{editable ? (
<Box c="dimmed" style={{ display: "flex", alignItems: "center" }}>
<GripVertical size={18} />