mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 23:00:57 +00:00
[200~feat: add CustomsPaymentsCard and PaymentsTab components for handling customs payments and payment summaries
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
@@ -19,9 +21,11 @@ import {
|
||||
Download,
|
||||
Eye,
|
||||
FileText,
|
||||
Lock,
|
||||
Receipt,
|
||||
Send,
|
||||
Upload,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
import type { Freight } from "@edr/types";
|
||||
@@ -42,11 +46,19 @@ const STATUS_META: Record<
|
||||
{ label: string; color: string }
|
||||
> = {
|
||||
DOC_UPLOADED: { label: "Awaiting billing", color: "yellow" },
|
||||
BILLED: { label: "Ready to send", color: "blue" },
|
||||
SENT: { label: "Sent — unpaid", color: "orange" },
|
||||
BILLED: { label: "Draft — not sent", color: "blue" },
|
||||
SENT: { label: "Awaiting customer approval", color: "orange" },
|
||||
REJECTED: { label: "Rejected by customer", color: "red" },
|
||||
ACCEPTED: { label: "Accepted — invoice unpaid", color: "teal" },
|
||||
PAID: { label: "Paid", color: "edr-green" },
|
||||
};
|
||||
|
||||
/** Once the customer accepts, the invoice exists and GL can no longer edit. */
|
||||
const isLocked = (s: Freight.ClearanceChargeStatus) =>
|
||||
s === "ACCEPTED" || s === "PAID";
|
||||
|
||||
type BillInput = { amount: number; currency: string; description: string };
|
||||
|
||||
export interface ClearanceChargesTabProps {
|
||||
bookingId: string;
|
||||
/** DJ uploads the port document; ET bills, sends and creates miscellaneous. */
|
||||
@@ -55,11 +67,12 @@ export interface ClearanceChargesTabProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-finalization charges billed to the customer, two levels: port charges
|
||||
* (document from GL Djibouti, billed by GL Ethiopia) then miscellaneous
|
||||
* (created whole by GL Ethiopia once the port charge is paid). Each level
|
||||
* issues its own payable invoice — ETB settles through the portal gateway
|
||||
* (CBE), other currencies through Finance's manual settlement.
|
||||
* Post-finalization charges billed to the customer: port charges (document
|
||||
* from GL Djibouti, priced by GL Ethiopia) and any number of miscellaneous
|
||||
* charges. GL prices + describes a charge and sends it; the customer accepts
|
||||
* (invoice issued, charge locked) or rejects with a note (GL revises and
|
||||
* re-sends). ETB settles through the portal gateway (CBE), other currencies
|
||||
* through Finance's manual settlement.
|
||||
*/
|
||||
export function ClearanceChargesTab({
|
||||
bookingId,
|
||||
@@ -89,7 +102,7 @@ export function ClearanceChargesTab({
|
||||
onError,
|
||||
});
|
||||
const bill = useMutation({
|
||||
mutationFn: (p: { chargeId: string; amount: number; currency: string }) =>
|
||||
mutationFn: (p: BillInput & { chargeId: string }) =>
|
||||
bookingsService.billClearanceCharge(bookingId, p.chargeId, p),
|
||||
onSuccess: (next) => {
|
||||
toast.success("Charge amount saved");
|
||||
@@ -101,13 +114,13 @@ export function ClearanceChargesTab({
|
||||
mutationFn: (chargeId: string) =>
|
||||
bookingsService.sendClearanceCharge(bookingId, chargeId),
|
||||
onSuccess: (next) => {
|
||||
toast.success("Invoice sent to the customer");
|
||||
toast.success("Sent to the customer for approval");
|
||||
refresh(next);
|
||||
},
|
||||
onError,
|
||||
});
|
||||
const createMisc = useMutation({
|
||||
mutationFn: (p: { file: File; amount: number; currency: string }) =>
|
||||
mutationFn: (p: BillInput & { file: File }) =>
|
||||
bookingsService.createMiscellaneousCharge(bookingId, p.file, p),
|
||||
onSuccess: (next) => {
|
||||
toast.success("Miscellaneous charge created");
|
||||
@@ -153,9 +166,7 @@ export function ClearanceChargesTab({
|
||||
: "Waiting for GL Djibouti to upload the port-charges document."
|
||||
}
|
||||
onViewFile={onViewFile}
|
||||
onBill={(amount, currency) =>
|
||||
port && bill.mutate({ chargeId: port.id, amount, currency })
|
||||
}
|
||||
onBill={(input) => port && bill.mutate({ chargeId: port.id, ...input })}
|
||||
onSend={() => port && send.mutate(port.id)}
|
||||
djUpload={
|
||||
roleMode === "DJ" && (!port || port.status === "DOC_UPLOADED") ? (
|
||||
@@ -196,9 +207,7 @@ export function ClearanceChargesTab({
|
||||
busy={busy}
|
||||
emptyHint=""
|
||||
onViewFile={onViewFile}
|
||||
onBill={(amount, currency) =>
|
||||
bill.mutate({ chargeId: c.id, amount, currency })
|
||||
}
|
||||
onBill={(input) => bill.mutate({ chargeId: c.id, ...input })}
|
||||
onSend={() => send.mutate(c.id)}
|
||||
/>
|
||||
))}
|
||||
@@ -211,15 +220,13 @@ export function ClearanceChargesTab({
|
||||
: "Add a miscellaneous charge"}
|
||||
</Text>
|
||||
<Text fz="12px" c="dimmed" mb="sm">
|
||||
Upload the supporting document and set the amount. You can raise as
|
||||
many as the shipment needs, before or after the port charge.
|
||||
Upload the supporting document, set the amount and say what it is
|
||||
for. The customer sees it once you send it for approval.
|
||||
</Text>
|
||||
<MiscCreateForm
|
||||
key={miscCreated}
|
||||
busy={createMisc.isPending}
|
||||
onCreate={(file, amount, currency) =>
|
||||
createMisc.mutate({ file, amount, currency })
|
||||
}
|
||||
onCreate={(file, input) => createMisc.mutate({ file, ...input })}
|
||||
/>
|
||||
</Paper>
|
||||
)}
|
||||
@@ -265,7 +272,7 @@ function ChargeCard({
|
||||
busy: boolean;
|
||||
emptyHint: string;
|
||||
onViewFile: (file: { name: string; url: string }) => void;
|
||||
onBill: (amount: number, currency: string) => void;
|
||||
onBill: (input: BillInput) => void;
|
||||
onSend: () => void;
|
||||
djUpload?: React.ReactNode;
|
||||
etCreate?: React.ReactNode;
|
||||
@@ -273,13 +280,17 @@ function ChargeCard({
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [amount, setAmount] = useState<number | string>(charge?.amount ?? "");
|
||||
const [currency, setCurrency] = useState<string>(charge?.currency ?? "ETB");
|
||||
const [description, setDescription] = useState(charge?.description ?? "");
|
||||
|
||||
const status = charge?.status ?? null;
|
||||
const meta = status ? STATUS_META[status] : null;
|
||||
// ET enters/revises the amount while the charge is unpaid.
|
||||
const locked = status != null && isLocked(status);
|
||||
const needsDescription = charge?.type === "MISCELLANEOUS";
|
||||
// ET enters/revises the price until the customer accepts it.
|
||||
const showBillForm =
|
||||
roleMode === "ET" &&
|
||||
charge != null &&
|
||||
!locked &&
|
||||
(charge.status === "DOC_UPLOADED" || editing);
|
||||
|
||||
return (
|
||||
@@ -304,6 +315,12 @@ function ChargeCard({
|
||||
{formatDateTime(charge.billedAt)}
|
||||
</Text>
|
||||
)}
|
||||
{charge?.status === "ACCEPTED" && charge.customerDecidedAt && (
|
||||
<Text fz="11.5px" c="teal.8" fw={600}>
|
||||
Accepted by the customer · {formatDateTime(charge.customerDecidedAt)}
|
||||
{charge.invoiceNumber ? ` (invoice ${charge.invoiceNumber})` : ""}
|
||||
</Text>
|
||||
)}
|
||||
{charge?.paidAt && (
|
||||
<Text fz="11.5px" c="edr-green.8" fw={600}>
|
||||
Paid · {formatDateTime(charge.paidAt)}
|
||||
@@ -379,6 +396,32 @@ function ChargeCard({
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{charge?.description && !showBillForm && (
|
||||
<Text fz="12.5px" c="edr-text" mt="xs">
|
||||
{charge.description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{charge?.status === "REJECTED" && charge.customerNote && (
|
||||
<Alert
|
||||
color="red"
|
||||
variant="light"
|
||||
radius="md"
|
||||
p="xs"
|
||||
mt="sm"
|
||||
icon={<XCircle size={16} />}
|
||||
title="Rejected by the customer"
|
||||
>
|
||||
<Text fz="12.5px">{charge.customerNote}</Text>
|
||||
{charge.customerDecidedAt && (
|
||||
<Text fz="11px" c="dimmed" mt={4}>
|
||||
{formatDateTime(charge.customerDecidedAt)} — fix the price or
|
||||
description and send it again.
|
||||
</Text>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!charge && (
|
||||
<Text fz="12.5px" c="dimmed" mt="xs">
|
||||
{emptyHint}
|
||||
@@ -389,6 +432,15 @@ function ChargeCard({
|
||||
|
||||
{showBillForm && (
|
||||
<Group mt="sm" gap={8} align="flex-end" wrap="wrap">
|
||||
<TextInput
|
||||
label={needsDescription ? "What is this charge for?" : "Description (optional)"}
|
||||
size="xs"
|
||||
radius="md"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.currentTarget.value)}
|
||||
maxLength={1000}
|
||||
w={320}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Amount"
|
||||
size="xs"
|
||||
@@ -412,13 +464,21 @@ function ChargeCard({
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
disabled={busy || !(Number(amount) > 0)}
|
||||
disabled={
|
||||
busy ||
|
||||
!(Number(amount) > 0) ||
|
||||
(needsDescription && !description.trim())
|
||||
}
|
||||
onClick={() => {
|
||||
onBill(Number(amount), currency);
|
||||
onBill({
|
||||
amount: Number(amount),
|
||||
currency,
|
||||
description: description.trim(),
|
||||
});
|
||||
setEditing(false);
|
||||
}}
|
||||
>
|
||||
Save amount
|
||||
Save
|
||||
</Button>
|
||||
{editing && (
|
||||
<Button
|
||||
@@ -435,7 +495,7 @@ function ChargeCard({
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{roleMode === "ET" && charge && !showBillForm && charge.status !== "PAID" && (
|
||||
{roleMode === "ET" && charge && !showBillForm && !locked && (
|
||||
<Group mt="sm" gap={8} justify="flex-end">
|
||||
<Button
|
||||
size="compact-sm"
|
||||
@@ -446,13 +506,14 @@ function ChargeCard({
|
||||
onClick={() => {
|
||||
setAmount(charge.amount ?? "");
|
||||
setCurrency(charge.currency ?? "ETB");
|
||||
setDescription(charge.description ?? "");
|
||||
setEditing(true);
|
||||
}}
|
||||
>
|
||||
{charge.status === "SENT" ? "Revise (cancels invoice)" : "Edit amount"}
|
||||
{charge.status === "SENT" ? "Revise" : "Edit"}
|
||||
</Button>
|
||||
{charge.status === "BILLED" && (
|
||||
<Tooltip label="ETB is payable online via CBE; other currencies go to Finance's manual settlement.">
|
||||
{(charge.status === "BILLED" || charge.status === "REJECTED") && (
|
||||
<Tooltip label="The customer accepts or rejects the price in the portal; the invoice is issued when they accept.">
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
@@ -461,15 +522,20 @@ function ChargeCard({
|
||||
disabled={busy}
|
||||
onClick={onSend}
|
||||
>
|
||||
Send invoice to customer
|
||||
{charge.status === "REJECTED"
|
||||
? "Send again for approval"
|
||||
: "Send to customer for approval"}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{charge.status === "SENT" && charge.invoiceNumber && (
|
||||
<Badge variant="light" color="orange" radius="sm">
|
||||
Invoice {charge.invoiceNumber}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
{charge?.status === "ACCEPTED" && (
|
||||
<Group mt="sm" gap={6} justify="flex-end">
|
||||
<Lock size={14} color="var(--mantine-color-teal-7)" />
|
||||
<Text fz="12px" c="teal.8" fw={600}>
|
||||
Locked — invoice {charge.invoiceNumber ?? ""} awaiting payment
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
{charge?.status === "PAID" && (
|
||||
@@ -489,14 +555,25 @@ function MiscCreateForm({
|
||||
onCreate,
|
||||
}: {
|
||||
busy: boolean;
|
||||
onCreate: (file: File, amount: number, currency: string) => void;
|
||||
onCreate: (file: File, input: BillInput) => void;
|
||||
}) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [amount, setAmount] = useState<number | string>("");
|
||||
const [currency, setCurrency] = useState("ETB");
|
||||
const [description, setDescription] = useState("");
|
||||
|
||||
return (
|
||||
<Group gap={8} align="flex-end" wrap="wrap">
|
||||
<TextInput
|
||||
label="What is this charge for?"
|
||||
placeholder="e.g. Container cleaning and weighbridge fee"
|
||||
size="xs"
|
||||
radius="md"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.currentTarget.value)}
|
||||
maxLength={1000}
|
||||
w={320}
|
||||
/>
|
||||
<FileButton onChange={setFile} accept="application/pdf,image/*" disabled={busy}>
|
||||
{(props) => (
|
||||
<Button
|
||||
@@ -534,9 +611,16 @@ function MiscCreateForm({
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
disabled={busy || !file || !(Number(amount) > 0)}
|
||||
disabled={busy || !file || !(Number(amount) > 0) || !description.trim()}
|
||||
loading={busy}
|
||||
onClick={() => file && onCreate(file, Number(amount), currency)}
|
||||
onClick={() =>
|
||||
file &&
|
||||
onCreate(file, {
|
||||
amount: Number(amount),
|
||||
currency,
|
||||
description: description.trim(),
|
||||
})
|
||||
}
|
||||
>
|
||||
Create charge
|
||||
</Button>
|
||||
|
||||
@@ -199,7 +199,13 @@ export function RequestServiceTypeCard({
|
||||
if (lastMile)
|
||||
chips.push({ label: "Last-mile delivery", color: "teal", icon: Warehouse });
|
||||
if (customs)
|
||||
chips.push({ label: "Customs clearance (GL)", color: "grape", icon: FileCheck });
|
||||
chips.push({
|
||||
label: st.includesEthiopianCustomsOnly
|
||||
? "Ethiopian customs clearance (GL)"
|
||||
: "Customs clearance (GL)",
|
||||
color: "grape",
|
||||
icon: FileCheck,
|
||||
});
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
NumberInput,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { isAxiosError } from "axios";
|
||||
import { AlertTriangle, Lock, MapPin } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { api } from "@/services/api";
|
||||
import type { ScheduleWagonYardRow } from "@/services/trainBuilder.service";
|
||||
|
||||
/**
|
||||
* Schedule yards tab: where THIS departure plans to board each consist wagon,
|
||||
* side by side with where the wagon physically stands (the train builder's
|
||||
* truth). Booking capacity per origin reads the plan; dispatch refuses to
|
||||
* leave until plan and physical yards agree. Edits are queued locally and
|
||||
* saved in one PATCH.
|
||||
*/
|
||||
|
||||
const parseError = (error: unknown, fallback: string) => {
|
||||
if (isAxiosError(error)) {
|
||||
const message = error.response?.data?.message;
|
||||
if (Array.isArray(message)) return message.join(", ");
|
||||
if (typeof message === "string") return message;
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
scheduleId: string;
|
||||
canEdit: boolean;
|
||||
}
|
||||
|
||||
export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
|
||||
const { toast } = useToast();
|
||||
const query = useQuery(
|
||||
api.trainScheduling.scheduleWagonYards.queryOptions({ input: { scheduleId } }),
|
||||
);
|
||||
const save = useMutation(api.trainScheduling.updateScheduleWagonYards.mutationOptions());
|
||||
const data = query.data;
|
||||
|
||||
/** wagonId → yardId queued but not yet saved. */
|
||||
const [pending, setPending] = useState<Record<string, string>>({});
|
||||
const [bulkType, setBulkType] = useState<string | null>(null);
|
||||
const [bulkFrom, setBulkFrom] = useState<string | null>(null);
|
||||
const [bulkTo, setBulkTo] = useState<string | null>(null);
|
||||
const [bulkCount, setBulkCount] = useState<number | string>(1);
|
||||
|
||||
const editable = Boolean(canEdit && data?.editable);
|
||||
const pickupStops = useMemo(() => (data?.stops ?? []).filter((s) => s.pickup), [data]);
|
||||
const yardOptions = pickupStops.map((s) => ({ value: s.yardId, label: s.label }));
|
||||
const yardLabel = (id: string | null) =>
|
||||
(data?.stops ?? []).find((s) => s.yardId === id)?.label ??
|
||||
data?.wagons.find((w) => w.plannedYardId === id)?.plannedYardLabel ??
|
||||
data?.wagons.find((w) => w.physicalYardId === id)?.physicalYardLabel ??
|
||||
id ??
|
||||
"—";
|
||||
|
||||
const effectiveYard = (w: ScheduleWagonYardRow) => pending[w.id] ?? w.plannedYardId;
|
||||
|
||||
const perStop = useMemo(
|
||||
() =>
|
||||
(data?.stops ?? []).map((s) => ({
|
||||
...s,
|
||||
planned: (data?.wagons ?? []).filter((w) => (pending[w.id] ?? w.plannedYardId) === s.yardId)
|
||||
.length,
|
||||
})),
|
||||
[data, pending],
|
||||
);
|
||||
const typeOptions = useMemo(() => {
|
||||
const seen = new Map<string, string>();
|
||||
for (const w of data?.wagons ?? []) seen.set(w.wagonType.id, w.wagonType.code);
|
||||
return [...seen].map(([value, label]) => ({ value, label }));
|
||||
}, [data]);
|
||||
|
||||
const pendingCount = Object.keys(pending).length;
|
||||
|
||||
const queueBulk = () => {
|
||||
if (!data || !bulkFrom || !bulkTo || bulkFrom === bulkTo) return;
|
||||
const n = Number(bulkCount) || 0;
|
||||
const picked = data.wagons
|
||||
.filter(
|
||||
(w) =>
|
||||
!w.locked &&
|
||||
effectiveYard(w) === bulkFrom &&
|
||||
(!bulkType || w.wagonType.id === bulkType),
|
||||
)
|
||||
.slice(0, n);
|
||||
if (!picked.length) {
|
||||
toast({ title: "No free wagons match", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
setPending((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const w of picked) {
|
||||
if (w.plannedYardId === bulkTo) delete next[w.id];
|
||||
else next[w.id] = bulkTo;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!pendingCount) return;
|
||||
try {
|
||||
const result = await save.mutateAsync({
|
||||
scheduleId,
|
||||
payload: {
|
||||
moves: Object.entries(pending).map(([wagonId, yardId]) => ({ wagonId, yardId })),
|
||||
},
|
||||
});
|
||||
setPending({});
|
||||
toast({
|
||||
title: `Schedule yards updated — ${pendingCount} wagon(s) re-planned`,
|
||||
description: result.warnings.length ? result.warnings.join(" ") : undefined,
|
||||
variant: result.warnings.length ? "destructive" : undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Update failed",
|
||||
description: parseError(err, "Could not update the schedule's wagon yards"),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (query.isLoading) return <Loader size="sm" />;
|
||||
if (query.isError || !data) {
|
||||
return (
|
||||
<Alert color="red" icon={<AlertTriangle size={16} />}>
|
||||
{parseError(
|
||||
query.error,
|
||||
"This schedule has no wagon yard plan (not created from a built train).",
|
||||
)}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Alert color="blue" icon={<MapPin size={16} />} variant="light">
|
||||
<b>Planned</b> = where this departure boards the wagon (what customers can book per
|
||||
origin). <b>Physical</b> = where the wagon stands now (train builder). Dispatch is blocked
|
||||
until every wagon stands at its planned yard.
|
||||
{data.misaligned > 0 ? (
|
||||
<Text component="span" c="orange" fw={600}>
|
||||
{" "}
|
||||
{data.misaligned} wagon(s) currently misaligned.
|
||||
</Text>
|
||||
) : null}
|
||||
</Alert>
|
||||
|
||||
<SimpleGrid cols={{ base: 2, sm: 3, md: Math.min(5, Math.max(2, perStop.length)) }}>
|
||||
{perStop.map((s) => (
|
||||
<Paper key={s.yardId} withBorder p="sm" radius="md">
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Text fw={600} size="sm">
|
||||
{s.label}
|
||||
</Text>
|
||||
{!s.pickup ? (
|
||||
<Badge size="xs" color="gray" variant="light">
|
||||
destination
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<Badge color="edr-green" variant="filled">
|
||||
Planned {s.planned}
|
||||
</Badge>
|
||||
<Badge color={s.physical === s.planned ? "gray" : "orange"} variant="light">
|
||||
Physical {s.physical}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Paper>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
{editable ? (
|
||||
<Paper withBorder p="sm" radius="md">
|
||||
<Group align="end" gap="sm" wrap="wrap">
|
||||
<NumberInput
|
||||
label="Move"
|
||||
min={1}
|
||||
max={data.wagons.length}
|
||||
value={bulkCount}
|
||||
onChange={setBulkCount}
|
||||
w={90}
|
||||
/>
|
||||
<Select
|
||||
label="Wagon type"
|
||||
placeholder="Any"
|
||||
clearable
|
||||
data={typeOptions}
|
||||
value={bulkType}
|
||||
onChange={setBulkType}
|
||||
w={140}
|
||||
/>
|
||||
<Select label="From" data={yardOptions} value={bulkFrom} onChange={setBulkFrom} w={170} />
|
||||
<Select label="To" data={yardOptions} value={bulkTo} onChange={setBulkTo} w={170} />
|
||||
<Button
|
||||
variant="light"
|
||||
onClick={queueBulk}
|
||||
disabled={!bulkFrom || !bulkTo || bulkFrom === bulkTo}
|
||||
>
|
||||
Queue
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
<Table striped highlightOnHover withTableBorder>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>#</Table.Th>
|
||||
<Table.Th>Wagon</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Physical yard</Table.Th>
|
||||
<Table.Th>Planned yard (this schedule)</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{data.wagons.map((w) => {
|
||||
const planned = effectiveYard(w);
|
||||
const changed = w.id in pending;
|
||||
return (
|
||||
<Table.Tr key={w.id} bg={changed ? "var(--mantine-color-yellow-light)" : undefined}>
|
||||
<Table.Td>{w.sequenceNumber ?? "—"}</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fw={600} size="sm">
|
||||
{w.wagonNumber}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{w.wagonType.code}</Table.Td>
|
||||
<Table.Td>{w.physicalYardLabel ?? "No yard"}</Table.Td>
|
||||
<Table.Td>
|
||||
{editable && !w.locked ? (
|
||||
<Select
|
||||
size="xs"
|
||||
data={yardOptions}
|
||||
value={planned}
|
||||
onChange={(v) =>
|
||||
setPending((prev) => {
|
||||
const next = { ...prev };
|
||||
if (!v || v === w.plannedYardId) delete next[w.id];
|
||||
else next[w.id] = v;
|
||||
return next;
|
||||
})
|
||||
}
|
||||
w={180}
|
||||
/>
|
||||
) : (
|
||||
<Group gap={4}>
|
||||
<Text size="sm">{yardLabel(planned)}</Text>
|
||||
{w.locked ? (
|
||||
<Tooltip label={w.lockReason ?? "Locked"}>
|
||||
<Lock size={14} />
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Group>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{planned === w.physicalYardId ? (
|
||||
<Badge color="teal" variant="light" size="sm">
|
||||
Aligned
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge color="orange" variant="light" size="sm">
|
||||
Needs move
|
||||
</Badge>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
|
||||
{editable ? (
|
||||
<Group justify="flex-end">
|
||||
<Text size="sm" c="dimmed">
|
||||
{pendingCount} pending change(s)
|
||||
</Text>
|
||||
<Button variant="default" onClick={() => setPending({})} disabled={!pendingCount}>
|
||||
Discard
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void handleSave()}
|
||||
loading={save.isPending}
|
||||
disabled={!pendingCount}
|
||||
>
|
||||
Save plan
|
||||
</Button>
|
||||
</Group>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user