mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 12:58:13 +00:00
Merge pull request #717 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -4,12 +4,12 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { Button, Modal, Stack, Text, Textarea } from "@mantine/core";
|
||||
import {
|
||||
Check,
|
||||
FileCheck,
|
||||
FilePen,
|
||||
FileSignature,
|
||||
MessageSquareWarning,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
XCircle,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
@@ -180,7 +180,7 @@ export function ContractActionsToolbar({
|
||||
documentGenerated ? (
|
||||
<RefreshCw size={16} />
|
||||
) : (
|
||||
<Sparkles size={16} />
|
||||
<FileCheck size={16} />
|
||||
)
|
||||
}
|
||||
loading={mutations.generateContract.isPending}
|
||||
@@ -196,7 +196,7 @@ export function ContractActionsToolbar({
|
||||
fullWidth
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<Sparkles size={16} />}
|
||||
leftSection={<FileCheck size={16} />}
|
||||
loading={mutations.generateContract.isPending}
|
||||
onClick={() => mutations.generateContract.mutate()}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Check, ShieldCheck, X } from "lucide-react";
|
||||
import { AlertTriangle, Check, FileCheck, ShieldCheck, X } from "lucide-react";
|
||||
import {
|
||||
Stack,
|
||||
Group,
|
||||
@@ -31,6 +31,7 @@ export function ContractApprovalStepsCard({
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [pendingStep, setPendingStep] =
|
||||
useState<Freight.IContractApprovalStep | null>(null);
|
||||
const [needsGenerateOpen, setNeedsGenerateOpen] = useState(false);
|
||||
const [rejectOpen, setRejectOpen] = useState(false);
|
||||
const [rejectStepRow, setRejectStepRow] =
|
||||
useState<Freight.IContractApprovalStep | null>(null);
|
||||
@@ -47,7 +48,17 @@ export function ContractApprovalStepsCard({
|
||||
const nextPending = steps.find((s) => s.status === "PENDING");
|
||||
const summary = formatContractApprovalProgress(contract.status, steps);
|
||||
|
||||
// Approvers must review the GENERATED contract document before approving. If
|
||||
// it has not been generated yet, block the approval and tell staff to generate
|
||||
// it first (via "Generate contract" in Staff actions) — mirrors the server
|
||||
// guard so the user sees a clear reason, not a generic failure toast.
|
||||
const documentGenerated = Boolean(contract.contractGeneratedAt);
|
||||
|
||||
const openApprove = (step: Freight.IContractApprovalStep) => {
|
||||
if (contract.status === "PENDING_APPROVAL" && !documentGenerated) {
|
||||
setNeedsGenerateOpen(true);
|
||||
return;
|
||||
}
|
||||
setPendingStep(step);
|
||||
setConfirmOpen(true);
|
||||
};
|
||||
@@ -181,6 +192,47 @@ export function ContractApprovalStepsCard({
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={needsGenerateOpen}
|
||||
onClose={() => setNeedsGenerateOpen(false)}
|
||||
title={
|
||||
<Group gap="xs">
|
||||
<AlertTriangle size={18} color="var(--mantine-color-orange-6)" />
|
||||
<Text fw={700}>Generate the contract first</Text>
|
||||
</Group>
|
||||
}
|
||||
radius="md"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
The contract document for{" "}
|
||||
<Text span fw={600} c="dark">
|
||||
{contract.reference}
|
||||
</Text>{" "}
|
||||
has not been generated yet. Approvers must review the generated
|
||||
document before it can be approved.
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Use{" "}
|
||||
<Text span fw={600} c="dark">
|
||||
Generate contract
|
||||
</Text>{" "}
|
||||
in the Staff actions panel — edit the articles first if needed — then
|
||||
return here to approve.
|
||||
</Text>
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<FileCheck size={16} />}
|
||||
onClick={() => setNeedsGenerateOpen(false)}
|
||||
>
|
||||
Got it
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={rejectOpen}
|
||||
onClose={closeReject}
|
||||
|
||||
@@ -201,7 +201,10 @@ const RuleEngineFormDialog = ({
|
||||
const payload: Record<string, unknown> = {};
|
||||
|
||||
for (const field of visibleFields) {
|
||||
const raw = values[field.name];
|
||||
// Derived fields always submit their computed value — never stale state.
|
||||
const raw = field.computeValue
|
||||
? (field.computeValue(values) ?? "")
|
||||
: values[field.name];
|
||||
if (field.type === "multiselect") {
|
||||
// Always the full replacement list — the API syncs the relation to it.
|
||||
payload[field.name] = Array.isArray(raw) ? raw : [];
|
||||
@@ -348,6 +351,7 @@ const RuleEngineFormDialog = ({
|
||||
}
|
||||
|
||||
const isNumber = field.type === "number";
|
||||
const computed = field.computeValue ? field.computeValue(values) : undefined;
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
@@ -359,7 +363,8 @@ const RuleEngineFormDialog = ({
|
||||
// display order) is a non-negative magnitude — reject negatives outright
|
||||
// rather than letting a typed "-" reach the API.
|
||||
min={isNumber ? 0 : undefined}
|
||||
value={String(values[field.name] ?? "")}
|
||||
disabled={field.disabled || computed !== undefined}
|
||||
value={String((computed !== undefined ? computed : values[field.name]) ?? "")}
|
||||
onChange={(e) => {
|
||||
const next = e.currentTarget.value;
|
||||
if (isNumber && next.trim().startsWith("-")) return;
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { Button, Group, Modal, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Pencil } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { api } from "@/services/api";
|
||||
import type { BuiltTrainSummary } from "@/services/trainBuilder.service";
|
||||
|
||||
export interface EditTrainDetailsModalProps {
|
||||
/** Train being edited; null closes the modal. */
|
||||
train: BuiltTrainSummary | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit a built train's display identity from the list: its name and its fixed
|
||||
* import/export run numbers. Composition (yard, locomotives, wagons) is edited
|
||||
* on the detail page. Number collisions come back as a 409 with the owning
|
||||
* train's code and surface verbatim.
|
||||
*/
|
||||
const EditTrainDetailsModal = ({ train, onClose }: EditTrainDetailsModalProps) => {
|
||||
const { toast } = useToast();
|
||||
const [name, setName] = useState("");
|
||||
const [importNo, setImportNo] = useState("");
|
||||
const [exportNo, setExportNo] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (train) {
|
||||
setName(train.trainName ?? "");
|
||||
setImportNo(train.importTrainNumber ?? "");
|
||||
setExportNo(train.exportTrainNumber ?? "");
|
||||
}
|
||||
}, [train]);
|
||||
|
||||
const update = useMutation(api.trainBuilder.updateDetails.mutationOptions());
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!train) return;
|
||||
try {
|
||||
await update.mutateAsync({
|
||||
id: train.id,
|
||||
payload: {
|
||||
trainName: name.trim(),
|
||||
// Numbers cannot be cleared — only replaced; empty inputs keep the
|
||||
// current value (legacy trains may have none yet).
|
||||
...(importNo.trim() ? { importTrainNumber: importNo.trim() } : {}),
|
||||
...(exportNo.trim() ? { exportTrainNumber: exportNo.trim() } : {}),
|
||||
},
|
||||
});
|
||||
toast({ title: `Train ${train.code} updated` });
|
||||
onClose();
|
||||
} catch (err) {
|
||||
const message =
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Update failed";
|
||||
toast({
|
||||
title: "Could not update train",
|
||||
description: String(message),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={Boolean(train)}
|
||||
onClose={onClose}
|
||||
title={
|
||||
<Group gap={8}>
|
||||
<Pencil size={16} />
|
||||
<Text fw={700}>Edit train {train?.code ?? ""}</Text>
|
||||
</Group>
|
||||
}
|
||||
centered
|
||||
size="md"
|
||||
radius="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label="Train name"
|
||||
placeholder="Optional display name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.currentTarget.value)}
|
||||
maxLength={100}
|
||||
radius="md"
|
||||
/>
|
||||
<Group grow>
|
||||
<TextInput
|
||||
label="Import train no."
|
||||
placeholder="e.g. 8002"
|
||||
value={importNo}
|
||||
onChange={(e) => setImportNo(e.currentTarget.value)}
|
||||
maxLength={20}
|
||||
radius="md"
|
||||
/>
|
||||
<TextInput
|
||||
label="Export train no."
|
||||
placeholder="e.g. 8001"
|
||||
value={exportNo}
|
||||
onChange={(e) => setExportNo(e.currentTarget.value)}
|
||||
maxLength={20}
|
||||
radius="md"
|
||||
/>
|
||||
</Group>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={update.isPending}
|
||||
onClick={handleSave}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditTrainDetailsModal;
|
||||
@@ -285,7 +285,12 @@ export const BOOKING_LIST_TABS = [
|
||||
{
|
||||
key: "clearance",
|
||||
label: "Clearance",
|
||||
statuses: ["AWAITING_DOCUMENTS", "DOCUMENTS_UNDER_REVIEW", "CLEARANCE_READY"],
|
||||
statuses: [
|
||||
"AWAITING_CLEARANCE_PAYMENT",
|
||||
"AWAITING_DOCUMENTS",
|
||||
"DOCUMENTS_UNDER_REVIEW",
|
||||
"CLEARANCE_READY",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "payment",
|
||||
|
||||
@@ -51,6 +51,10 @@ export const CONTRACT_STATUS_STYLES: Record<string, StatusStyle> = {
|
||||
label: "Active",
|
||||
color: "bg-[color:var(--freight-brand-muted)] text-[color:var(--freight-brand)] border-[color:var(--freight-brand-border)]",
|
||||
},
|
||||
AWAITING_CLEARANCE_PAYMENT: {
|
||||
label: "Clearance Fee Due",
|
||||
color: "bg-orange-50 text-orange-700 border-orange-200",
|
||||
},
|
||||
AWAITING_CLEARANCE_DOCUMENTS: {
|
||||
label: "Awaiting Documents",
|
||||
color: "bg-amber-50 text-amber-700 border-amber-200",
|
||||
@@ -118,6 +122,7 @@ export const CONTRACT_STATUS_COLOR: Record<string, string> = {
|
||||
SIGNED_CUSTOMER: "cyan",
|
||||
FULLY_EXECUTED: "indigo",
|
||||
CONTRACT_ACTIVE: "edr-green",
|
||||
AWAITING_CLEARANCE_PAYMENT: "orange",
|
||||
AWAITING_CLEARANCE_DOCUMENTS: "yellow",
|
||||
CLEARANCE_UNDER_REVIEW: "yellow",
|
||||
CLEARANCE_READY_FOR_BOOKING: "edr-green",
|
||||
@@ -207,6 +212,13 @@ export const CONTRACT_STATUS_META: Record<string, StatusMeta> = {
|
||||
color: "text-[color:var(--freight-brand)]",
|
||||
stage: 3,
|
||||
},
|
||||
AWAITING_CLEARANCE_PAYMENT: {
|
||||
title: "Clearance Fee Due",
|
||||
description:
|
||||
"Customer must pay the prepaid clearance service fee before uploading documents.",
|
||||
color: "text-orange-600",
|
||||
stage: 3,
|
||||
},
|
||||
AWAITING_CLEARANCE_DOCUMENTS: {
|
||||
title: "Awaiting Documents",
|
||||
description: "Customer is uploading pre-booking clearance documents.",
|
||||
|
||||
@@ -246,10 +246,13 @@ export const useRuleEngineMutations = (resource: RuleEngineResourceSlug) => {
|
||||
/**
|
||||
* Priority-rule approval workflow. Every create/update/delete of a priority
|
||||
* config is SUBMITTED as a change request; an approver applies or rejects it.
|
||||
* Error toasts surface the backend message so range-collision rejections
|
||||
* ("1–5 overlaps existing rule …") reach the user verbatim.
|
||||
* Backend messages (range collision, gap, ceiling) surface verbatim — through
|
||||
* `onErrorMessage` (the page shows them in a modal) or a toast as fallback.
|
||||
*/
|
||||
export const usePriorityRuleWorkflow = (enabled: boolean) => {
|
||||
export const usePriorityRuleWorkflow = (
|
||||
enabled: boolean,
|
||||
onErrorMessage?: (message: string) => void,
|
||||
) => {
|
||||
const qc = useQueryClient();
|
||||
|
||||
const backendMessage = (err: unknown, fallback: string) => {
|
||||
@@ -259,6 +262,12 @@ export const usePriorityRuleWorkflow = (enabled: boolean) => {
|
||||
return msg || fallback;
|
||||
};
|
||||
|
||||
const showError = (err: unknown, fallback: string) => {
|
||||
const message = backendMessage(err, fallback);
|
||||
if (onErrorMessage) onErrorMessage(message);
|
||||
else toast.error(message);
|
||||
};
|
||||
|
||||
const pending = useQuery({
|
||||
queryKey: QUERY_KEYS.RULE_ENGINE.priorityRuleChanges,
|
||||
queryFn: () => ruleEngineService.listPriorityRuleChanges("PENDING"),
|
||||
@@ -270,6 +279,10 @@ export const usePriorityRuleWorkflow = (enabled: boolean) => {
|
||||
queryKey: QUERY_KEYS.RULE_ENGINE.priorityRuleChanges,
|
||||
});
|
||||
await invalidateRuleEngineList(qc, "priority-configs");
|
||||
// The full order-list backs the auto-filled min field — keep it fresh too.
|
||||
await qc.invalidateQueries({
|
||||
queryKey: QUERY_KEYS.RULE_ENGINE.orderList("priority-configs"),
|
||||
});
|
||||
};
|
||||
|
||||
const submit = useMutation({
|
||||
@@ -279,7 +292,7 @@ export const usePriorityRuleWorkflow = (enabled: boolean) => {
|
||||
toast.success("Change submitted for approval — the team has been notified");
|
||||
await invalidate();
|
||||
},
|
||||
onError: (err) => toast.error(backendMessage(err, "Failed to submit change")),
|
||||
onError: (err) => showError(err, "Failed to submit change"),
|
||||
});
|
||||
|
||||
const approve = useMutation({
|
||||
@@ -289,7 +302,7 @@ export const usePriorityRuleWorkflow = (enabled: boolean) => {
|
||||
toast.success("Change approved and applied");
|
||||
await invalidate();
|
||||
},
|
||||
onError: (err) => toast.error(backendMessage(err, "Failed to approve change")),
|
||||
onError: (err) => showError(err, "Failed to approve change"),
|
||||
});
|
||||
|
||||
const reject = useMutation({
|
||||
@@ -299,7 +312,7 @@ export const usePriorityRuleWorkflow = (enabled: boolean) => {
|
||||
toast.success("Change rejected");
|
||||
await invalidate();
|
||||
},
|
||||
onError: (err) => toast.error(backendMessage(err, "Failed to reject change")),
|
||||
onError: (err) => showError(err, "Failed to reject change"),
|
||||
});
|
||||
|
||||
return { pending, submit, approve, reject };
|
||||
|
||||
@@ -19,6 +19,7 @@ import { Navigate, useLocation, useParams } from "react-router-dom";
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import ManageRuleEngineOrderDialog from "@/components/ruleEngine/ManageRuleEngineOrderDialog";
|
||||
import PriorityRuleApprovalsSection from "@/pages/ruleEngine/PriorityRuleApprovalsSection";
|
||||
import { nextPriorityRangeStart } from "@/pages/ruleEngine/priorityRuleRange";
|
||||
import RuleEngineCardGrid from "@/components/ruleEngine/RuleEngineCardGrid";
|
||||
import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog";
|
||||
import RuleEngineOrderControls from "@/components/ruleEngine/RuleEngineOrderControls";
|
||||
@@ -145,10 +146,13 @@ const RuleEngineResourcePage = () => {
|
||||
);
|
||||
|
||||
// Priority rules never mutate directly: changes are filed for approval and a
|
||||
// pending queue renders above the table.
|
||||
// pending queue renders above the table. Validation errors (range collision,
|
||||
// gap, ceiling) surface in a modal so the text is impossible to miss.
|
||||
const isPriorityRules = config?.slug === "priority-configs";
|
||||
const [priorityError, setPriorityError] = useState<string | null>(null);
|
||||
const priorityWorkflow = usePriorityRuleWorkflow(
|
||||
Boolean(isPriorityRules && canView),
|
||||
setPriorityError,
|
||||
);
|
||||
|
||||
const editingId = editing?.id ? String(editing.id) : undefined;
|
||||
@@ -178,9 +182,36 @@ const RuleEngineResourcePage = () => {
|
||||
const { data: wagonTypeOptions, isLoading: wagonTypeOptionsLoading } =
|
||||
useWagonTypeOptions(usesWagonTypeField);
|
||||
|
||||
// Full rule list backing the auto-filled "min wagon count": the next range
|
||||
// always continues the chain for the selected type (per currency), so the
|
||||
// form needs every existing rule, not the current page.
|
||||
const { data: allPriorityRules } = useRuleEngineOrderList(
|
||||
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
|
||||
Boolean(isPriorityRules && formOpen),
|
||||
config?.orderConfig?.field,
|
||||
);
|
||||
|
||||
const formFields = useMemo(() => {
|
||||
if (!config) return [];
|
||||
return config.formFields.map((field) => {
|
||||
if (isPriorityRules && field.name === "minWagonCount") {
|
||||
return {
|
||||
...field,
|
||||
// Editing keeps the rule's own start (a lower gap never forces it to
|
||||
// move); creating always continues the chain / fills the lowest gap.
|
||||
computeValue: (values: Record<string, unknown>) =>
|
||||
editing?.minWagonCount != null
|
||||
? Number(editing.minWagonCount)
|
||||
: nextPriorityRangeStart(
|
||||
allPriorityRules ?? [],
|
||||
String(values.type ?? ""),
|
||||
!values.currency || values.currency === RULE_ENGINE_SELECT_NONE
|
||||
? null
|
||||
: String(values.currency),
|
||||
editingId,
|
||||
),
|
||||
};
|
||||
}
|
||||
if (config.slug === "cargo-types" && field.name === "parentGroupId") {
|
||||
return {
|
||||
...field,
|
||||
@@ -226,7 +257,7 @@ const RuleEngineResourcePage = () => {
|
||||
}
|
||||
return field;
|
||||
});
|
||||
}, [config, cargoParentOptions, cargoLeafOptions, containerTypeOptions, liveRateOptions, wagonTypeOptions]);
|
||||
}, [config, cargoParentOptions, cargoLeafOptions, containerTypeOptions, liveRateOptions, wagonTypeOptions, isPriorityRules, allPriorityRules, editing, editingId]);
|
||||
|
||||
const rows = data?.items ?? [];
|
||||
const meta = data?.meta;
|
||||
@@ -452,6 +483,25 @@ const RuleEngineResourcePage = () => {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Modal
|
||||
opened={priorityError != null}
|
||||
onClose={() => setPriorityError(null)}
|
||||
title="Cannot save priority rule"
|
||||
centered
|
||||
size="md"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="red.8">
|
||||
{priorityError}
|
||||
</Text>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setPriorityError(null)}>
|
||||
OK
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<Card p={0}>
|
||||
<Stack gap={0}>
|
||||
<Box px="md" pt="md" pb="sm" w="100%">
|
||||
|
||||
@@ -61,6 +61,13 @@ export interface FormFieldDef {
|
||||
* relation list (`wagonTypeIds` read from `record.wagonTypes`).
|
||||
*/
|
||||
getInitialValue?: (record: Record<string, unknown>) => unknown;
|
||||
/**
|
||||
* Fully derived field: its value is computed from the live form values on
|
||||
* every render and the input is locked. Used for the priority-rule min
|
||||
* wagon count, which always continues the previous range for the selected
|
||||
* type. Return null/undefined to leave the field empty (e.g. chain full).
|
||||
*/
|
||||
computeValue?: (values: Record<string, unknown>) => number | string | null;
|
||||
}
|
||||
|
||||
export interface RuleEngineOrderConfig {
|
||||
@@ -134,6 +141,7 @@ const RATE_TRIGGERS = [
|
||||
{ label: "Cancellation", value: "CANCELLATION" },
|
||||
{ label: "Demurrage", value: "DEMURRAGE" },
|
||||
{ label: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" },
|
||||
{ label: "Customs clearance service fee (prepaid)", value: "CUSTOMS_CLEARANCE" },
|
||||
];
|
||||
|
||||
const unitOption = (value: string) => ({ label: value.replace(/_/g, " "), value });
|
||||
@@ -155,6 +163,9 @@ const allowedRateUnits = (appliesTo: string, trigger: string): string[] => {
|
||||
return ["PER_CONTAINER", "PER_TON"];
|
||||
case "CANCELLATION":
|
||||
return ["FLAT", "PER_INVOICE"];
|
||||
case "CUSTOMS_CLEARANCE":
|
||||
// Flat per clearance (ONE_TIME) / per shipment request (GENERAL).
|
||||
return ["FLAT"];
|
||||
case "CONSOLIDATION":
|
||||
case "SHIPPING_LINE":
|
||||
case "PIL_EXTRA_FEE":
|
||||
@@ -356,8 +367,21 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
placeholder: "Select a currency",
|
||||
hideWhen: { field: "type", equals: ["WAGON", "CUSTOMS"] },
|
||||
},
|
||||
{ name: "minWagonCount", label: "Min wagon count", type: "number", required: true },
|
||||
{ name: "maxWagonCount", label: "Max wagon count", type: "number", required: true },
|
||||
{
|
||||
name: "minWagonCount",
|
||||
label: "Min wagon count",
|
||||
type: "number",
|
||||
required: true,
|
||||
disabled: true,
|
||||
description: "Auto-filled — continues the previous range for the selected type",
|
||||
},
|
||||
{
|
||||
name: "maxWagonCount",
|
||||
label: "Max wagon count",
|
||||
type: "number",
|
||||
required: true,
|
||||
description: "Ceiling per type: WAGON 50 · CURRENCY 35 · CUSTOMS 15",
|
||||
},
|
||||
{ name: "scorePoints", label: "Score points", type: "number", required: true },
|
||||
{ name: "isActive", label: "Active", type: "boolean" },
|
||||
],
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Client mirror of the backend's contiguous-range rules for priority configs
|
||||
* (see PriorityConfigsService.assertNoRangeCollision): ranges per type — per
|
||||
* currency for CURRENCY — run 1..cap with no gaps and no overlaps, so the next
|
||||
* range always starts at the lowest uncovered wagon count. The backend
|
||||
* re-validates on submit AND on approval; this only drives the form prefill.
|
||||
*/
|
||||
|
||||
export type PriorityRuleType = "WAGON" | "CURRENCY" | "CUSTOMS";
|
||||
|
||||
/** Hard ceiling of each type's chain — keep in sync with the API's RANGE_CAPS. */
|
||||
export const PRIORITY_RANGE_CAPS: Record<PriorityRuleType, number> = {
|
||||
WAGON: 50,
|
||||
CURRENCY: 35,
|
||||
CUSTOMS: 15,
|
||||
};
|
||||
|
||||
export interface PriorityRangeRule {
|
||||
id?: unknown;
|
||||
type?: unknown;
|
||||
currency?: unknown;
|
||||
minWagonCount?: unknown;
|
||||
maxWagonCount?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the next range for `type` (+`currency`) must start, excluding
|
||||
* `excludeId` (the rule being edited). Null when the chain already covers
|
||||
* 1..cap — no further rule fits.
|
||||
*/
|
||||
export function nextPriorityRangeStart(
|
||||
rules: PriorityRangeRule[],
|
||||
type: string,
|
||||
currency: string | null | undefined,
|
||||
excludeId?: string,
|
||||
): number | null {
|
||||
const cap = PRIORITY_RANGE_CAPS[type as PriorityRuleType];
|
||||
if (!cap) return null;
|
||||
|
||||
const scoped = rules
|
||||
.filter(
|
||||
(r) =>
|
||||
String(r.type ?? "") === type &&
|
||||
(excludeId === undefined || String(r.id ?? "") !== excludeId) &&
|
||||
(type !== "CURRENCY" ||
|
||||
String(r.currency ?? "") === String(currency ?? "")),
|
||||
)
|
||||
.map((r) => ({
|
||||
min: Number(r.minWagonCount ?? 0),
|
||||
max: Number(r.maxWagonCount ?? 0),
|
||||
}))
|
||||
.sort((a, b) => a.min - b.min);
|
||||
|
||||
let next = 1;
|
||||
for (const r of scoped) {
|
||||
if (r.min > next) break; // gap before this rule — fill it first
|
||||
next = Math.max(next, r.max + 1);
|
||||
}
|
||||
return next > cap ? null : next;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ColumnDef } from "@edr/ui-common";
|
||||
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
@@ -15,6 +16,7 @@ import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Hammer,
|
||||
Pencil,
|
||||
Ruler,
|
||||
Search,
|
||||
Train as TrainIcon,
|
||||
@@ -27,6 +29,7 @@ import { useNavigate } from "react-router-dom";
|
||||
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
import BuildTrainModal from "@/components/trainBuilder/BuildTrainModal";
|
||||
import EditTrainDetailsModal from "@/components/trainBuilder/EditTrainDetailsModal";
|
||||
import {
|
||||
directionColor,
|
||||
directionRowStyle,
|
||||
@@ -52,6 +55,7 @@ export default function TrainBuilderListPage() {
|
||||
const [statusFilter, setStatusFilter] = useState<"ALL" | BuiltTrainStatus>("ALL");
|
||||
const [yardFilter, setYardFilter] = useState("ALL");
|
||||
const [buildOpen, setBuildOpen] = useState(false);
|
||||
const [editTarget, setEditTarget] = useState<BuiltTrainSummary | null>(null);
|
||||
|
||||
const resetPage = useCallback(() => {
|
||||
setPagination((prev) =>
|
||||
@@ -239,6 +243,26 @@ export default function TrainBuilderListPage() {
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={`Edit train ${row.original.code}`}
|
||||
title="Edit name & train numbers"
|
||||
onClick={(e) => {
|
||||
// Row click navigates to the detail page — keep the edit local.
|
||||
e.stopPropagation();
|
||||
setEditTarget(row.original);
|
||||
}}
|
||||
>
|
||||
<Pencil size={15} />
|
||||
</ActionIcon>
|
||||
),
|
||||
},
|
||||
];
|
||||
}, []);
|
||||
|
||||
@@ -363,6 +387,8 @@ export default function TrainBuilderListPage() {
|
||||
onClose={() => setBuildOpen(false)}
|
||||
onBuilt={(composition) => navigate(`/dashboard/train-builder/${composition.id}`)}
|
||||
/>
|
||||
|
||||
<EditTrainDetailsModal train={editTarget} onClose={() => setEditTarget(null)} />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -191,6 +191,7 @@ import {
|
||||
type BuiltTrainListResponse,
|
||||
type ScheduleConsist,
|
||||
type TrainComposition,
|
||||
type UpdateTrainDetailsPayload,
|
||||
} from "./trainBuilder.service";
|
||||
import { trainSchedulingService } from "./trainScheduling.service";
|
||||
import { wagonTypesService, type WagonType } from "./wagon-types.service";
|
||||
@@ -1842,6 +1843,18 @@ export const api = {
|
||||
() => TRAIN_BUILDER_INVALIDATIONS,
|
||||
),
|
||||
|
||||
updateDetails: endpoint<
|
||||
{ id: string; payload: UpdateTrainDetailsPayload },
|
||||
TrainComposition
|
||||
>(
|
||||
"train-builder",
|
||||
"updateDetails",
|
||||
({ id, payload }) =>
|
||||
trainBuilderService.updateDetails(id, payload).then((r) => r.data),
|
||||
undefined,
|
||||
() => TRAIN_BUILDER_INVALIDATIONS,
|
||||
),
|
||||
|
||||
assignWagons: endpoint<{ id: string; wagonIds: string[] }, TrainComposition>(
|
||||
"train-builder",
|
||||
"assignWagons",
|
||||
|
||||
@@ -140,6 +140,14 @@ export interface BuildTrainPayload {
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
/** Edit a built train's display identity; omitted fields keep their value. */
|
||||
export interface UpdateTrainDetailsPayload {
|
||||
/** Empty string clears the name. */
|
||||
trainName?: string;
|
||||
importTrainNumber?: string;
|
||||
exportTrainNumber?: string;
|
||||
}
|
||||
|
||||
/** Built train annotated for the schedule-creation picker. */
|
||||
export interface AvailableTrain {
|
||||
id: string;
|
||||
@@ -241,6 +249,9 @@ export const trainBuilderService = {
|
||||
build: (payload: BuildTrainPayload) => apiClient.post<TrainComposition>(BASE, payload),
|
||||
setLocomotives: (id: string, locomotiveIds: string[]) =>
|
||||
apiClient.put<TrainComposition>(`${BASE}/${id}/locomotives`, { locomotiveIds }),
|
||||
/** Edit the train's name and fixed import/export run numbers. */
|
||||
updateDetails: (id: string, payload: UpdateTrainDetailsPayload) =>
|
||||
apiClient.patch<TrainComposition>(`${BASE}/${id}/details`, payload),
|
||||
/** Relocate the train — coupled locomotives and wagons move with it. */
|
||||
setYard: (id: string, currentYardId: string) =>
|
||||
apiClient.patch<TrainComposition>(`${BASE}/${id}/yard`, { currentYardId }),
|
||||
|
||||
@@ -28,6 +28,7 @@ export const BOOKING_STATUSES = [
|
||||
"CONTRACT_ACTIVE",
|
||||
"CONTRACT_CLOSED",
|
||||
// Post counter-sign document-clearance gate.
|
||||
"AWAITING_CLEARANCE_PAYMENT",
|
||||
"AWAITING_DOCUMENTS",
|
||||
"DOCUMENTS_UNDER_REVIEW",
|
||||
"CLEARANCE_READY",
|
||||
|
||||
Reference in New Issue
Block a user