mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 03:05:42 +00:00
Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -537,6 +537,8 @@ export const URL_CONSTANTS = {
|
||||
`/train-scheduling/schedules/${id}/import-djibouti/load-list/document`,
|
||||
EXPORT_LOAD_LIST_DOCUMENT: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/export/load-list/document`,
|
||||
SCHEDULE_WAGONS_EXPORT: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/wagons/export`,
|
||||
INTERCITY_MARSHALLING_DOCUMENT: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/intercity/marshalling/document`,
|
||||
MARSHALLING_STOPS: (id: string) =>
|
||||
|
||||
@@ -27,8 +27,24 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
cargoTypeId: "Cargo type",
|
||||
originYardId: "Origin yard",
|
||||
destinationYardId: "Destination yard",
|
||||
minKm: "From km",
|
||||
maxKm: "To km",
|
||||
baseLiters: "Base liters",
|
||||
rateType: "Rate type",
|
||||
};
|
||||
|
||||
/**
|
||||
* A key the backend diffed but the UI has no label for still names a real
|
||||
* change, so turn "baseLiters" into "Base liters" rather than hiding it.
|
||||
*/
|
||||
const labelFor = (field: string): string =>
|
||||
FIELD_LABELS[field] ??
|
||||
field
|
||||
.replace(/([A-Z])/g, " $1")
|
||||
.replace(/^./, (c) => c.toUpperCase())
|
||||
.replace(/\bId\b/, "")
|
||||
.trim();
|
||||
|
||||
const fmtDateTime = (iso: string) =>
|
||||
new Date(iso).toLocaleString("en-GB", {
|
||||
day: "numeric",
|
||||
@@ -43,13 +59,16 @@ const fmtValue = (
|
||||
value: unknown,
|
||||
labels?: Record<string, string>,
|
||||
): string => {
|
||||
if (value === null || value === undefined || value === "") return "—";
|
||||
// "Not set" reads as a real before-state; a bare em dash on both sides of the
|
||||
// arrow made a newly-set field look like no change at all.
|
||||
if (value === null || value === undefined || value === "") return "Not set";
|
||||
if (field === "rateValue") {
|
||||
const num = Number(value);
|
||||
return Number.isNaN(num) ? String(value) : num.toLocaleString();
|
||||
}
|
||||
// Yard ids are unreadable — an approver decides on the route, not a UUID.
|
||||
if (field === "originYardId" || field === "destinationYardId") {
|
||||
// Any id is unreadable — an approver decides on "Perishable → Truck", not on
|
||||
// a pair of uuids. Covers yards, cargo types, container types and lines.
|
||||
if (field.endsWith("Id")) {
|
||||
return labels?.[String(value)] ?? String(value);
|
||||
}
|
||||
return String(value).replace(/_/g, " ");
|
||||
@@ -66,13 +85,33 @@ const rateSummary = (r: RateChangeRequest): string => {
|
||||
return parts.join(" · ") || "Rate";
|
||||
};
|
||||
|
||||
/** The headline change, so the queue is scannable without expanding: "100 → 200 USD". */
|
||||
const headline = (r: RateChangeRequest): string | null => {
|
||||
if (!("rateValue" in r.payload)) return null;
|
||||
const currency = String(r.payload.currency ?? r.previousValues.currency ?? (r.rate as Record<string, unknown> | undefined)?.currency ?? "");
|
||||
const before = fmtValue("rateValue", r.previousValues.rateValue);
|
||||
const after = fmtValue("rateValue", r.payload.rateValue);
|
||||
return `${before} → ${after}${currency ? ` ${currency}` : ""}`;
|
||||
/**
|
||||
* Every change in the request, as readable before→after pairs. The queue must
|
||||
* be scannable without expanding: a cargo or direction change is just as much
|
||||
* the point as a repricing, so it gets the same one-line treatment as the rate.
|
||||
*/
|
||||
const summaryRows = (
|
||||
r: RateChangeRequest,
|
||||
labels?: Record<string, string>,
|
||||
): Array<{ field: string; label: string; before: string; after: string; suffix: string }> => {
|
||||
const currency = String(
|
||||
r.payload.currency ??
|
||||
r.previousValues.currency ??
|
||||
(r.rate as Record<string, unknown> | undefined)?.currency ??
|
||||
"",
|
||||
);
|
||||
// Rate first — it is what most changes are about — then the rest in a stable
|
||||
// order so the same edit always reads the same way.
|
||||
const fields = Object.keys(r.payload).sort((a, b) =>
|
||||
a === "rateValue" ? -1 : b === "rateValue" ? 1 : a.localeCompare(b),
|
||||
);
|
||||
return fields.map((field) => ({
|
||||
field,
|
||||
label: labelFor(field),
|
||||
before: fmtValue(field, r.previousValues[field], labels),
|
||||
after: fmtValue(field, r.payload[field], labels),
|
||||
suffix: field === "rateValue" && currency ? ` ${currency}` : "",
|
||||
}));
|
||||
};
|
||||
|
||||
type Decide = UseMutationResult<
|
||||
@@ -87,8 +126,9 @@ interface RateApprovalsSectionProps {
|
||||
canDecide: boolean;
|
||||
approve: Decide;
|
||||
reject: Decide;
|
||||
/** yardId → label, so a re-routed rate reads as yards, not UUIDs. */
|
||||
yardLabels?: Record<string, string>;
|
||||
/** id → label for every reference a diff can name (yards, cargo/container
|
||||
* types, shipping lines), so a change reads as names, not UUIDs. */
|
||||
refLabels?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -101,7 +141,7 @@ const RateApprovalsSection = ({
|
||||
canDecide,
|
||||
approve,
|
||||
reject,
|
||||
yardLabels,
|
||||
refLabels,
|
||||
}: RateApprovalsSectionProps) => {
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const [notes, setNotes] = useState<Record<string, string>>({});
|
||||
@@ -127,7 +167,7 @@ const RateApprovalsSection = ({
|
||||
{requests.map((r) => {
|
||||
const isOpen = openId === r.id;
|
||||
const fields = Object.keys(r.payload);
|
||||
const summaryLine = headline(r);
|
||||
const rows = summaryRows(r, refLabels);
|
||||
// Only the row being decided shows a spinner — the mutation's
|
||||
// isPending is shared across every row.
|
||||
const busy = decidingId === r.id;
|
||||
@@ -145,38 +185,36 @@ const RateApprovalsSection = ({
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{summaryLine ? (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{rows.map((row) => (
|
||||
<Group key={row.field} gap={6} wrap="wrap" align="center">
|
||||
<Text size="xs" c="dimmed">
|
||||
{row.label}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" td="line-through">
|
||||
{fmtValue("rateValue", r.previousValues.rateValue)}
|
||||
{row.before}
|
||||
</Text>
|
||||
<ArrowRight size={13} />
|
||||
<ArrowRight size={13} style={{ flexShrink: 0 }} />
|
||||
<Text size="sm" fw={700} c="edr-green">
|
||||
{fmtValue("rateValue", r.payload.rateValue)}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{String(
|
||||
r.payload.currency ??
|
||||
r.previousValues.currency ??
|
||||
(r.rate as Record<string, unknown> | undefined)?.currency ??
|
||||
"",
|
||||
)}
|
||||
{row.after}
|
||||
{row.suffix}
|
||||
</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
))}
|
||||
|
||||
<Group gap={6}>
|
||||
<Text size="xs" c="dimmed">
|
||||
Submitted {fmtDateTime(r.createdAt)} · {fields.length}{" "}
|
||||
{fields.length === 1 ? "field" : "fields"} changed
|
||||
</Text>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
onClick={() => setOpenId(isOpen ? null : r.id)}
|
||||
>
|
||||
{isOpen ? "Hide details" : "See all changes"}
|
||||
</Button>
|
||||
{canDecide ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
onClick={() => setOpenId(isOpen ? null : r.id)}
|
||||
>
|
||||
{isOpen ? "Hide note" : "Add a note"}
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -218,24 +256,11 @@ const RateApprovalsSection = ({
|
||||
</Group>
|
||||
|
||||
<Collapse in={isOpen}>
|
||||
<Stack gap={6} mt="sm" pt="sm" style={{ borderTop: "1px solid var(--mantine-color-default-border)" }}>
|
||||
{fields.map((field) => (
|
||||
<Group key={field} gap={8} wrap="nowrap">
|
||||
<Text size="xs" c="dimmed" w={110} style={{ flexShrink: 0 }}>
|
||||
{FIELD_LABELS[field] ?? field}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" td="line-through">
|
||||
{fmtValue(field, r.previousValues[field], yardLabels)}
|
||||
</Text>
|
||||
<ArrowRight size={13} />
|
||||
<Text size="sm" fw={600}>
|
||||
{fmtValue(field, r.payload[field], yardLabels)}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
{canDecide ? (
|
||||
{canDecide ? (
|
||||
<Stack gap={6} mt="sm" pt="sm" style={{ borderTop: "1px solid var(--mantine-color-default-border)" }}>
|
||||
{/* The change itself is always visible above, so this panel
|
||||
carries only what the approver adds. */}
|
||||
<Textarea
|
||||
mt={4}
|
||||
size="xs"
|
||||
autosize
|
||||
minRows={2}
|
||||
@@ -246,8 +271,8 @@ const RateApprovalsSection = ({
|
||||
setNotes((prev) => ({ ...prev, [r.id]: e.currentTarget.value }))
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Stack>
|
||||
) : null}
|
||||
</Collapse>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -326,6 +326,23 @@ const RuleEngineResourcePage = () => {
|
||||
() => Object.fromEntries((yardOptions ?? []).map((y) => [y.value, y.label])),
|
||||
[yardOptions],
|
||||
);
|
||||
/**
|
||||
* Every id a rate diff can name, in one map. A pending change that swaps the
|
||||
* cargo type or the container size stores raw uuids, so without this the
|
||||
* approver reads "a1b2… → c3d4…" instead of "Perishable → Truck".
|
||||
*/
|
||||
const rateRefLabelById = useMemo(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
[
|
||||
...(yardOptions ?? []),
|
||||
...(cargoLeafOptions ?? []),
|
||||
...(containerTypeOptions ?? []),
|
||||
...(shippingLineOptions ?? []),
|
||||
].map((o) => [o.value, o.label]),
|
||||
),
|
||||
[yardOptions, cargoLeafOptions, containerTypeOptions, shippingLineOptions],
|
||||
);
|
||||
const usesApprovalRoleField = Boolean(
|
||||
config?.formFields.some(
|
||||
(f) => f.name === "requiredRole" || f.name === "blocksRole",
|
||||
@@ -868,7 +885,7 @@ const RuleEngineResourcePage = () => {
|
||||
canDecide={canApproveRates}
|
||||
approve={rateChangeWorkflow.approve}
|
||||
reject={rateChangeWorkflow.reject}
|
||||
yardLabels={yardLabelById}
|
||||
refLabels={rateRefLabelById}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
Merge,
|
||||
Container as ContainerIcon,
|
||||
Eye,
|
||||
FileSpreadsheet,
|
||||
FileText,
|
||||
History as HistoryIcon,
|
||||
LayoutGrid,
|
||||
@@ -102,6 +103,7 @@ import { useBookingWindowSocket } from "@/features/bookingWindows/useBookingWind
|
||||
import { api } from "@/services/api";
|
||||
import { trainSchedulingService } from "@/services/trainScheduling.service";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { extractDownloadErrorMessage } from "@/components/warehouses/options";
|
||||
import type {
|
||||
ContainerPlacement,
|
||||
EligibleContainerBooking,
|
||||
@@ -125,6 +127,7 @@ export default function TrainScheduleV2DetailPage() {
|
||||
const { user: authUser } = useAuth();
|
||||
const { scheduleId } = useParams<{ scheduleId: string }>();
|
||||
const { toast } = useToast();
|
||||
const [exportingWagons, setExportingWagons] = useState(false);
|
||||
const [activeStep, setActiveStep] = useState(0);
|
||||
const [selectedBookingIds, setSelectedBookingIds] = useState<string[]>([]);
|
||||
const [forceAssign, setForceAssign] = useState(false);
|
||||
@@ -314,6 +317,31 @@ export default function TrainScheduleV2DetailPage() {
|
||||
);
|
||||
const marshallingStops = marshallingStopsQuery.data ?? [];
|
||||
|
||||
/** Wagon list (one row per container) as an .xlsx download. */
|
||||
const handleExportWagons = useCallback(async () => {
|
||||
if (!scheduleId) return;
|
||||
setExportingWagons(true);
|
||||
try {
|
||||
const blob =
|
||||
await trainSchedulingService.downloadScheduleWagonsWorkbook(scheduleId);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `wagon-list-${schedule?.reference ?? scheduleId}.xlsx`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
// Blob response: the JSON reason rides inside the Blob, so the sync
|
||||
// decoder would surface only "Request failed with status code 400".
|
||||
toast({
|
||||
title: await extractDownloadErrorMessage(error),
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setExportingWagons(false);
|
||||
}
|
||||
}, [scheduleId, schedule?.reference, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
const operation = gatepassQuery.data;
|
||||
if (!operation) return;
|
||||
@@ -1323,6 +1351,18 @@ export default function TrainScheduleV2DetailPage() {
|
||||
Load Empty Container
|
||||
</Button>
|
||||
) : null}
|
||||
{(schedule.trainSet?.wagons?.length ?? 0) > 0 ? (
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
size="compact-sm"
|
||||
leftSection={<FileSpreadsheet size={14} />}
|
||||
loading={exportingWagons}
|
||||
onClick={() => void handleExportWagons()}
|
||||
>
|
||||
Export wagons
|
||||
</Button>
|
||||
) : null}
|
||||
{(schedule.trainSet?.wagons?.length ?? 0) > 0 ? (
|
||||
<Button
|
||||
variant="gradient"
|
||||
|
||||
@@ -756,6 +756,15 @@ export const trainSchedulingService = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/** The schedule detail page's wagon-list Excel export. */
|
||||
downloadScheduleWagonsWorkbook: async (scheduleId: string): Promise<Blob> => {
|
||||
const response = await client.get(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.SCHEDULE_WAGONS_EXPORT(scheduleId),
|
||||
{ responseType: "blob" },
|
||||
);
|
||||
return response.data as Blob;
|
||||
},
|
||||
|
||||
downloadIntercityMarshallingDocument: async (
|
||||
scheduleId: string,
|
||||
): Promise<Blob> => {
|
||||
|
||||
Reference in New Issue
Block a user