mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-06 22:55:02 +00:00
changes
This commit is contained in:
@@ -1,19 +1,13 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Anchor,
|
||||
Button,
|
||||
Modal,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import { Button, Modal, Stack, Text, Textarea } from "@mantine/core";
|
||||
import {
|
||||
Check,
|
||||
FilePen,
|
||||
FileSignature,
|
||||
MessageSquareWarning,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
XCircle,
|
||||
@@ -23,6 +17,7 @@ import type { Freight } from "@edr/types";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import { ContractDocumentEditorModal } from "@/components/contracts/ContractDocumentEditorModal";
|
||||
import type { useContractMutations } from "@/hooks/contracts/useContracts";
|
||||
|
||||
/** Dropdown-settings code holding the admin-configured contract validity days. */
|
||||
@@ -54,8 +49,8 @@ export function ContractActionsToolbar({
|
||||
const navigate = useNavigate();
|
||||
const { status } = contract;
|
||||
|
||||
const [acceptOpen, setAcceptOpen] = useState(false);
|
||||
const [validityDays, setValidityDays] = useState<string | null>(null);
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [editorMode, setEditorMode] = useState<"accept" | "edit">("accept");
|
||||
const [changesOpen, setChangesOpen] = useState(false);
|
||||
const [changesNote, setChangesNote] = useState("");
|
||||
const [rejectOpen, setRejectOpen] = useState(false);
|
||||
@@ -76,12 +71,6 @@ export function ContractActionsToolbar({
|
||||
.map((o) => ({ value: String(o.value), label: o.label })),
|
||||
[validitySetting],
|
||||
);
|
||||
// Default the selection to the first configured option when the dialog opens.
|
||||
useEffect(() => {
|
||||
if (acceptOpen && !validityDays && validityOptions.length > 0) {
|
||||
setValidityDays(validityOptions[0].value);
|
||||
}
|
||||
}, [acceptOpen, validityDays, validityOptions]);
|
||||
|
||||
if (["REJECTED", "CANCELLED", "EXPIRED", "CONTRACT_CLOSED"].includes(status)) {
|
||||
return null;
|
||||
@@ -98,10 +87,16 @@ export function ContractActionsToolbar({
|
||||
}
|
||||
|
||||
const canAccept = status === "SUBMITTED";
|
||||
// Generation only becomes available once EVERY approval step is complete and
|
||||
// the contract reaches APPROVED. While any step is still pending the contract
|
||||
// stays in PENDING_APPROVAL, so this button does not appear after only the
|
||||
// first (line-staff) approval — the director step must land first.
|
||||
// While the contract is PENDING_APPROVAL and NO approver has acted yet, staff
|
||||
// can edit this contract's articles and (re)generate its PDF. The first
|
||||
// approval action locks the document.
|
||||
const docLocked =
|
||||
status !== "PENDING_APPROVAL" ||
|
||||
(contract.approvalSteps ?? []).some((s) => s.status !== "PENDING");
|
||||
const canEditGenerate = status === "PENDING_APPROVAL" && !docLocked;
|
||||
const documentGenerated = Boolean(contract.contractGeneratedAt);
|
||||
// Legacy fallback: if a contract ever lands on APPROVED without a document
|
||||
// (older flow), still offer a manual generate that moves it to CONTRACT_READY.
|
||||
const needsManualGenerate =
|
||||
status === "APPROVED" && !contract.contractGeneratedAt;
|
||||
// Signing now happens on the contract VIEW page (staff must open and read the
|
||||
@@ -131,7 +126,10 @@ export function ContractActionsToolbar({
|
||||
fullWidth
|
||||
color="edr-green"
|
||||
leftSection={<Check size={16} />}
|
||||
onClick={() => setAcceptOpen(true)}
|
||||
onClick={() => {
|
||||
setEditorMode("accept");
|
||||
setEditorOpen(true);
|
||||
}}
|
||||
>
|
||||
Accept for approval
|
||||
</Button>
|
||||
@@ -156,6 +154,43 @@ export function ContractActionsToolbar({
|
||||
</>
|
||||
)}
|
||||
|
||||
{canEditGenerate && (
|
||||
<>
|
||||
<Text size="xs" c="dimmed">
|
||||
{documentGenerated
|
||||
? "Document generated. Approvers can now review it. You can still edit and regenerate until the first approval."
|
||||
: "Review the contract document, edit its articles if needed, then generate it so approvers can review."}
|
||||
</Text>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="light"
|
||||
color="gray"
|
||||
leftSection={<FilePen size={16} />}
|
||||
onClick={() => {
|
||||
setEditorMode("edit");
|
||||
setEditorOpen(true);
|
||||
}}
|
||||
>
|
||||
Edit contract articles
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
color="edr-green"
|
||||
leftSection={
|
||||
documentGenerated ? (
|
||||
<RefreshCw size={16} />
|
||||
) : (
|
||||
<Sparkles size={16} />
|
||||
)
|
||||
}
|
||||
loading={mutations.generateContract.isPending}
|
||||
onClick={() => mutations.generateContract.mutate()}
|
||||
>
|
||||
{documentGenerated ? "Regenerate contract" : "Generate contract"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{needsManualGenerate && (
|
||||
<Button
|
||||
fullWidth
|
||||
@@ -198,6 +233,7 @@ export function ContractActionsToolbar({
|
||||
the customer creates the booking in the portal. */}
|
||||
|
||||
{!canAccept &&
|
||||
!canEditGenerate &&
|
||||
!needsManualGenerate &&
|
||||
!canViewContract &&
|
||||
!canReviewClearance && (
|
||||
@@ -208,62 +244,28 @@ export function ContractActionsToolbar({
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* Accept — sets the contract validity window */}
|
||||
<Modal
|
||||
opened={acceptOpen}
|
||||
onClose={() => setAcceptOpen(false)}
|
||||
title="Accept contract for approval"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Pick the contract validity window, then start the approval chain.
|
||||
</Text>
|
||||
{validityOptions.length > 0 ? (
|
||||
<Select
|
||||
label="Validity"
|
||||
placeholder="Select a validity period"
|
||||
data={validityOptions}
|
||||
value={validityDays}
|
||||
onChange={setValidityDays}
|
||||
allowDeselect={false}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
/>
|
||||
) : (
|
||||
<Text size="sm" c="orange.7">
|
||||
{validityLoading
|
||||
? "Loading validity periods…"
|
||||
: "No validity periods are configured yet. Add them under "}
|
||||
{!validityLoading && (
|
||||
<Anchor
|
||||
href="/dashboard/dropdown-settings"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
navigate("/dashboard/dropdown-settings");
|
||||
}}
|
||||
>
|
||||
Dropdown Settings
|
||||
</Anchor>
|
||||
)}
|
||||
{!validityLoading && "."}
|
||||
</Text>
|
||||
)}
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={mutations.staffAccept.isPending}
|
||||
disabled={!validityDays}
|
||||
onClick={() => {
|
||||
const days = Number(validityDays);
|
||||
if (!days) return;
|
||||
mutations.staffAccept.mutate(days, {
|
||||
onSuccess: () => setAcceptOpen(false),
|
||||
});
|
||||
}}
|
||||
>
|
||||
Accept
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
{/* Accept / edit — review + optionally edit this contract's articles */}
|
||||
<ContractDocumentEditorModal
|
||||
opened={editorOpen}
|
||||
onClose={() => setEditorOpen(false)}
|
||||
contractId={contract.id}
|
||||
mode={editorMode}
|
||||
validityOptions={validityOptions}
|
||||
validityLoading={validityLoading}
|
||||
accepting={mutations.staffAccept.isPending}
|
||||
saving={mutations.updateDocument.isPending}
|
||||
onAccept={(days, snapshot) =>
|
||||
mutations.staffAccept.mutate(
|
||||
{ validityDays: days, documentSnapshot: snapshot },
|
||||
{ onSuccess: () => setEditorOpen(false) },
|
||||
)
|
||||
}
|
||||
onSaveEdit={(snapshot) =>
|
||||
mutations.updateDocument.mutate(snapshot, {
|
||||
onSuccess: () => setEditorOpen(false),
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Request changes */}
|
||||
<Modal
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
FileText,
|
||||
Info,
|
||||
Lock,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
|
||||
/** New client-side article id (server keeps whatever id we send). */
|
||||
function newArticleId(): string {
|
||||
const c = globalThis.crypto;
|
||||
if (c && typeof c.randomUUID === "function") return c.randomUUID();
|
||||
return `art-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
|
||||
}
|
||||
|
||||
interface EditableArticle {
|
||||
id: string;
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface ContractDocumentEditorModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
contractId: string;
|
||||
/**
|
||||
* "accept" — shown from the Accept-for-approval action: pick a validity window
|
||||
* and (optionally) edit the articles, then start the approval chain.
|
||||
* "edit" — re-edit the frozen articles of an already-accepted contract before
|
||||
* generating/regenerating its PDF.
|
||||
*/
|
||||
mode: "accept" | "edit";
|
||||
/** Validity options (accept mode only). */
|
||||
validityOptions?: Array<{ value: string; label: string }>;
|
||||
validityLoading?: boolean;
|
||||
accepting?: boolean;
|
||||
saving?: boolean;
|
||||
onAccept?: (
|
||||
validityDays: number,
|
||||
snapshot: Freight.IContractDocumentSnapshot,
|
||||
) => void;
|
||||
onSaveEdit?: (snapshot: Freight.IContractDocumentSnapshot) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-contract contract-document editor. Loads the resolved template (or this
|
||||
* contract's frozen snapshot) and lets staff add/remove/reorder/edit articles
|
||||
* for THIS contract only — it never writes back to the shared six templates.
|
||||
*/
|
||||
export function ContractDocumentEditorModal({
|
||||
opened,
|
||||
onClose,
|
||||
contractId,
|
||||
mode,
|
||||
validityOptions = [],
|
||||
validityLoading = false,
|
||||
accepting = false,
|
||||
saving = false,
|
||||
onAccept,
|
||||
onSaveEdit,
|
||||
}: ContractDocumentEditorModalProps) {
|
||||
const { data: draft, isLoading } = useQuery({
|
||||
queryKey: ["contracts", contractId, "document-draft"],
|
||||
queryFn: () => contractsService.getContractDocumentDraft(contractId),
|
||||
enabled: opened && Boolean(contractId),
|
||||
// Always refetch the current draft when the dialog opens.
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const [documentTitle, setDocumentTitle] = useState("");
|
||||
const [whereasClauses, setWhereasClauses] = useState<string[]>([]);
|
||||
const [articles, setArticles] = useState<EditableArticle[]>([]);
|
||||
const [validityDays, setValidityDays] = useState<string | null>(null);
|
||||
|
||||
// Seed the editor from the loaded draft whenever the dialog (re)opens.
|
||||
useEffect(() => {
|
||||
if (!opened || !draft) return;
|
||||
setDocumentTitle(draft.documentTitle ?? "");
|
||||
setWhereasClauses(draft.whereasClauses ?? []);
|
||||
setArticles(
|
||||
(draft.articles ?? []).map((a) => ({
|
||||
id: a.id || newArticleId(),
|
||||
title: a.title,
|
||||
body: a.body,
|
||||
})),
|
||||
);
|
||||
}, [opened, draft]);
|
||||
|
||||
// Default validity to the first configured option (accept mode).
|
||||
useEffect(() => {
|
||||
if (mode === "accept" && !validityDays && validityOptions.length > 0) {
|
||||
setValidityDays(validityOptions[0].value);
|
||||
}
|
||||
}, [mode, validityDays, validityOptions]);
|
||||
|
||||
const locked = mode === "edit" && Boolean(draft?.locked);
|
||||
|
||||
const moveArticle = (index: number, delta: number) => {
|
||||
setArticles((prev) => {
|
||||
const next = [...prev];
|
||||
const target = index + delta;
|
||||
if (target < 0 || target >= next.length) return prev;
|
||||
[next[index], next[target]] = [next[target], next[index]];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const updateArticle = (id: string, patch: Partial<EditableArticle>) =>
|
||||
setArticles((prev) =>
|
||||
prev.map((a) => (a.id === id ? { ...a, ...patch } : a)),
|
||||
);
|
||||
|
||||
const removeArticle = (id: string) =>
|
||||
setArticles((prev) => prev.filter((a) => a.id !== id));
|
||||
|
||||
const addArticle = () =>
|
||||
setArticles((prev) => [
|
||||
...prev,
|
||||
{ id: newArticleId(), title: "", body: "" },
|
||||
]);
|
||||
|
||||
const buildSnapshot = (): Freight.IContractDocumentSnapshot => ({
|
||||
code: draft?.code ?? null,
|
||||
name: draft?.name ?? null,
|
||||
documentTitle: documentTitle.trim() || null,
|
||||
whereasClauses: whereasClauses
|
||||
.map((c) => c.trim())
|
||||
.filter((c) => c.length > 0),
|
||||
articles: articles
|
||||
.filter((a) => a.title.trim().length > 0 || a.body.trim().length > 0)
|
||||
.map((a, index) => ({
|
||||
id: a.id,
|
||||
title: a.title.trim(),
|
||||
body: a.body,
|
||||
order: index + 1,
|
||||
})),
|
||||
});
|
||||
|
||||
const hasArticles = useMemo(
|
||||
() => articles.some((a) => a.title.trim() || a.body.trim()),
|
||||
[articles],
|
||||
);
|
||||
|
||||
const submit = () => {
|
||||
const snapshot = buildSnapshot();
|
||||
if (mode === "accept") {
|
||||
const days = Number(validityDays);
|
||||
if (!days) return;
|
||||
onAccept?.(days, snapshot);
|
||||
} else {
|
||||
onSaveEdit?.(snapshot);
|
||||
}
|
||||
};
|
||||
|
||||
const submitting = accepting || saving;
|
||||
const canSubmit =
|
||||
hasArticles &&
|
||||
!locked &&
|
||||
(mode === "edit" || Boolean(validityDays)) &&
|
||||
!submitting;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
size="xl"
|
||||
centered
|
||||
radius="md"
|
||||
title={
|
||||
<Group gap="xs">
|
||||
<FileText size={18} />
|
||||
<Text fw={700}>
|
||||
{mode === "accept"
|
||||
? "Review contract document & accept"
|
||||
: "Edit contract document"}
|
||||
</Text>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader size="sm" color="gray" />
|
||||
<Text size="sm" c="dimmed">
|
||||
Loading document…
|
||||
</Text>
|
||||
</Group>
|
||||
) : (
|
||||
<Stack gap="md">
|
||||
<Alert
|
||||
variant="light"
|
||||
color={locked ? "orange" : "blue"}
|
||||
icon={locked ? <Lock size={16} /> : <Info size={16} />}
|
||||
>
|
||||
{locked
|
||||
? "This document is locked — an approver has already acted, so it can no longer be edited."
|
||||
: "Edits apply to THIS contract only. The six shared templates are never changed."}
|
||||
</Alert>
|
||||
|
||||
<TextInput
|
||||
label="Document title"
|
||||
placeholder="e.g. Bulk Cargo Transportation and Customs Clearance Services"
|
||||
value={documentTitle}
|
||||
onChange={(e) => setDocumentTitle(e.currentTarget.value)}
|
||||
disabled={locked}
|
||||
/>
|
||||
|
||||
<Box>
|
||||
<Group justify="space-between" mb={6}>
|
||||
<Text size="sm" fw={600}>
|
||||
WHEREAS recitals
|
||||
</Text>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="gray"
|
||||
leftSection={<Plus size={13} />}
|
||||
disabled={locked}
|
||||
onClick={() => setWhereasClauses((p) => [...p, ""])}
|
||||
>
|
||||
Add recital
|
||||
</Button>
|
||||
</Group>
|
||||
{whereasClauses.length === 0 ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
No recitals.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
{whereasClauses.map((clause, i) => (
|
||||
<Group key={i} gap="xs" wrap="nowrap" align="flex-start">
|
||||
<Textarea
|
||||
style={{ flex: 1 }}
|
||||
autosize
|
||||
minRows={1}
|
||||
value={clause}
|
||||
disabled={locked}
|
||||
onChange={(e) =>
|
||||
setWhereasClauses((prev) =>
|
||||
prev.map((c, idx) =>
|
||||
idx === i ? e.currentTarget.value : c,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
disabled={locked}
|
||||
onClick={() =>
|
||||
setWhereasClauses((prev) =>
|
||||
prev.filter((_, idx) => idx !== i),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Divider label="Articles" labelPosition="left" />
|
||||
|
||||
<Stack gap="md">
|
||||
{articles.map((article, index) => (
|
||||
<Box
|
||||
key={article.id}
|
||||
p="sm"
|
||||
style={{
|
||||
border: "1px solid var(--mantine-color-gray-3)",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" mb="xs" wrap="nowrap">
|
||||
<Text size="sm" fw={700} c="dimmed">
|
||||
Article {index + 1}
|
||||
</Text>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Tooltip label="Move up" withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
disabled={locked || index === 0}
|
||||
onClick={() => moveArticle(index, -1)}
|
||||
>
|
||||
<ArrowUp size={15} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="Move down" withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
disabled={locked || index === articles.length - 1}
|
||||
onClick={() => moveArticle(index, 1)}
|
||||
>
|
||||
<ArrowDown size={15} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="Delete article" withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
disabled={locked}
|
||||
onClick={() => removeArticle(article.id)}
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Group>
|
||||
<Stack gap="xs">
|
||||
<TextInput
|
||||
placeholder="Article title"
|
||||
value={article.title}
|
||||
disabled={locked}
|
||||
onChange={(e) =>
|
||||
updateArticle(article.id, { title: e.currentTarget.value })
|
||||
}
|
||||
/>
|
||||
<Textarea
|
||||
placeholder="Article body — each line becomes a numbered clause. Use '- ' for bullets. Placeholders like {{client.companyName}} are supported."
|
||||
autosize
|
||||
minRows={3}
|
||||
styles={{ input: { fontFamily: "var(--mantine-font-family-monospace)" } }}
|
||||
value={article.body}
|
||||
disabled={locked}
|
||||
onChange={(e) =>
|
||||
updateArticle(article.id, { body: e.currentTarget.value })
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
leftSection={<Plus size={15} />}
|
||||
disabled={locked}
|
||||
onClick={addArticle}
|
||||
>
|
||||
Add article
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
{mode === "accept" && (
|
||||
<>
|
||||
{validityOptions.length > 0 ? (
|
||||
<Select
|
||||
label="Contract validity"
|
||||
placeholder="Select a validity period"
|
||||
data={validityOptions}
|
||||
value={validityDays}
|
||||
onChange={setValidityDays}
|
||||
allowDeselect={false}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
/>
|
||||
) : (
|
||||
<Text size="sm" c="orange.7">
|
||||
{validityLoading
|
||||
? "Loading validity periods…"
|
||||
: "No validity periods are configured yet. Add them under Dropdown Settings."}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={onClose} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={submitting}
|
||||
disabled={!canSubmit}
|
||||
onClick={submit}
|
||||
>
|
||||
{mode === "accept"
|
||||
? "Accept & start approval"
|
||||
: "Save document changes"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -40,7 +40,7 @@ export const formatFleetCell = (
|
||||
if (s === "INACTIVE") return "gray";
|
||||
if (s === "SUSPENDED" || s === "OUT_OF_SERVICE") return "red";
|
||||
if (s === "MAINTENANCE" || s === "ON_LEAVE") return "orange";
|
||||
if (s === "RETIRED") return "gray";
|
||||
if (s === "RETIRED" || s === "DETAINED") return "gray";
|
||||
return "gray";
|
||||
};
|
||||
const color = getStatusColor(status);
|
||||
|
||||
@@ -168,6 +168,11 @@ function HistoryPanel({ opened }: { opened: boolean }) {
|
||||
</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
{r.reason ? (
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
Reason: {r.reason}
|
||||
</Text>
|
||||
) : null}
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
@@ -233,6 +238,8 @@ const WagonTransferRequestsModal = ({
|
||||
const [tab, setTab] = useState<string | null>("queue");
|
||||
const [active, setActive] = useState<WagonTransferRequest | null>(null);
|
||||
const [picked, setPicked] = useState<Set<string>>(new Set());
|
||||
// Bulk accept-and-execute: the subset of pending requests OCC ticked.
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
|
||||
const { data: requests = [], isLoading } = useQuery({
|
||||
...api.wagonTransferRequests.list.queryOptions({ input: { status: PENDING } }),
|
||||
@@ -256,6 +263,9 @@ const WagonTransferRequestsModal = ({
|
||||
});
|
||||
|
||||
const fulfill = useMutation(api.wagonTransferRequests.fulfill.mutationOptions());
|
||||
const bulkFulfill = useMutation(
|
||||
api.wagonTransferRequests.bulkFulfill.mutationOptions(),
|
||||
);
|
||||
const cancel = useMutation(api.wagonTransferRequests.cancel.mutationOptions());
|
||||
|
||||
const showError = (err: unknown, fallback: string) => {
|
||||
@@ -310,6 +320,36 @@ const WagonTransferRequestsModal = ({
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSelected = (id: string) =>
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
// Execute the ticked subset; whatever cannot run (not enough available
|
||||
// wagons, already decided) is reported and simply stays PENDING.
|
||||
const handleBulkFulfill = async () => {
|
||||
if (selected.size === 0) return;
|
||||
try {
|
||||
const res = await bulkFulfill.mutateAsync({ requestIds: [...selected] });
|
||||
setSelected(new Set());
|
||||
const skippedNote = res.skipped.length
|
||||
? ` · ${res.skipped.length} left pending (${res.skipped
|
||||
.map((s) => s.reason)
|
||||
.join('; ')})`
|
||||
: "";
|
||||
toast({
|
||||
title: `Executed ${res.fulfilled.length} transfer request(s)`,
|
||||
description: skippedNote || undefined,
|
||||
variant: res.fulfilled.length === 0 ? "destructive" : undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
showError(err, "Bulk execute failed");
|
||||
}
|
||||
};
|
||||
|
||||
const sortedWagons = useMemo(
|
||||
() => [...wagons].sort((a, b) => a.wagonNumber.localeCompare(b.wagonNumber)),
|
||||
[wagons],
|
||||
@@ -371,17 +411,65 @@ const WagonTransferRequestsModal = ({
|
||||
</Card>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
{/* Bulk accept-and-execute action bar: tick a subset, run it, and
|
||||
everything unticked (or unexecutable) stays PENDING. */}
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Checkbox
|
||||
label={
|
||||
selected.size > 0
|
||||
? `${selected.size} of ${requests.length} selected`
|
||||
: "Select all"
|
||||
}
|
||||
checked={selected.size === requests.length && requests.length > 0}
|
||||
indeterminate={selected.size > 0 && selected.size < requests.length}
|
||||
onChange={() =>
|
||||
setSelected(
|
||||
selected.size === requests.length
|
||||
? new Set()
|
||||
: new Set(requests.map((r) => r.id)),
|
||||
)
|
||||
}
|
||||
color="edr-green"
|
||||
/>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
leftSection={<PackageCheck size={14} />}
|
||||
loading={bulkFulfill.isPending}
|
||||
disabled={selected.size === 0}
|
||||
onClick={handleBulkFulfill}
|
||||
>
|
||||
Accept & execute {selected.size > 0 ? `(${selected.size})` : ""}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{requests.map((r) => (
|
||||
<Card key={r.id} withBorder radius="md" padding="md">
|
||||
<Group justify="space-between" wrap="nowrap" align="flex-start">
|
||||
<Stack gap={6} style={{ minWidth: 0 }}>
|
||||
<RequestSummary r={r} />
|
||||
{r.note ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
“{r.note}”
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start" style={{ minWidth: 0 }}>
|
||||
<Checkbox
|
||||
checked={selected.has(r.id)}
|
||||
onChange={() => toggleSelected(r.id)}
|
||||
color="edr-green"
|
||||
mt={2}
|
||||
/>
|
||||
<Stack gap={6} style={{ minWidth: 0 }}>
|
||||
<RequestSummary r={r} />
|
||||
{r.reason ? (
|
||||
<Text size="xs">
|
||||
<Text span fw={600}>
|
||||
Reason:
|
||||
</Text>{" "}
|
||||
{r.reason}
|
||||
</Text>
|
||||
) : null}
|
||||
{r.note ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
“{r.note}”
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Group>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Button
|
||||
size="compact-sm"
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Slider,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
@@ -125,6 +126,7 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
||||
|
||||
const [transferYardId, setTransferYardId] = useState<string | null>(null);
|
||||
const [transferQty, setTransferQty] = useState(0);
|
||||
const [transferReason, setTransferReason] = useState("");
|
||||
const [toAssignedQty, setToAssignedQty] = useState(0);
|
||||
const [toAvailableQty, setToAvailableQty] = useState(0);
|
||||
|
||||
@@ -207,6 +209,7 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
||||
useEffect(() => {
|
||||
setTransferYardId(null);
|
||||
setTransferQty(0);
|
||||
setTransferReason("");
|
||||
setToAssignedQty(0);
|
||||
setToAvailableQty(0);
|
||||
}, [yardId, typeId]);
|
||||
@@ -219,8 +222,9 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
// Keep quantities within bounds as counts shift after each action.
|
||||
useEffect(() => setTransferQty((q) => Math.min(q, total)), [total]);
|
||||
// Keep quantities within bounds as counts shift after each action. Transfers
|
||||
// may only ask for AVAILABLE wagons, so the request cap is availableCount.
|
||||
useEffect(() => setTransferQty((q) => Math.min(q, availableCount)), [availableCount]);
|
||||
useEffect(() => setToAssignedQty((q) => Math.min(q, availableCount)), [availableCount]);
|
||||
useEffect(() => setToAvailableQty((q) => Math.min(q, assignedCount)), [assignedCount]);
|
||||
|
||||
@@ -233,13 +237,21 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
||||
// Request-only: the requester specifies count + destination; OCC later picks
|
||||
// the physical wagons and executes the move. No wagons are moved here.
|
||||
const handleRequest = async () => {
|
||||
if (!yardId || !typeId || !transferYardId || transferQty < 1) return;
|
||||
if (
|
||||
!yardId ||
|
||||
!typeId ||
|
||||
!transferYardId ||
|
||||
transferQty < 1 ||
|
||||
!transferReason.trim()
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await createRequest.mutateAsync({
|
||||
fromYardId: yardId,
|
||||
toYardId: transferYardId,
|
||||
wagonTypeId: typeId,
|
||||
quantity: transferQty,
|
||||
reason: transferReason.trim(),
|
||||
});
|
||||
toast({
|
||||
title: `Requested ${transferQty} ${typeInfo.code(typeId)} wagon(s) · ${yardName(
|
||||
@@ -249,6 +261,7 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
||||
});
|
||||
setTransferQty(0);
|
||||
setTransferYardId(null);
|
||||
setTransferReason("");
|
||||
} catch (err) {
|
||||
showError(err, "Request failed");
|
||||
}
|
||||
@@ -407,10 +420,19 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
||||
</Text>
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb={4}>
|
||||
How many wagons
|
||||
</Text>
|
||||
<QuantityField value={transferQty} onChange={setTransferQty} max={total} />
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Text size="sm" fw={500}>
|
||||
How many wagons
|
||||
</Text>
|
||||
<Badge color="teal" variant="light">
|
||||
{availableCount} available
|
||||
</Badge>
|
||||
</Group>
|
||||
<QuantityField
|
||||
value={transferQty}
|
||||
onChange={setTransferQty}
|
||||
max={availableCount}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
label="Destination yard"
|
||||
@@ -421,6 +443,16 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
||||
searchable
|
||||
radius="md"
|
||||
/>
|
||||
<Textarea
|
||||
label="Reason"
|
||||
placeholder="Why are these wagons needed?"
|
||||
value={transferReason}
|
||||
onChange={(e) => setTransferReason(e.currentTarget.value)}
|
||||
required
|
||||
autosize
|
||||
minRows={2}
|
||||
radius="md"
|
||||
/>
|
||||
{transferYardId && transferQty > 0 ? (
|
||||
<Card bg="var(--mantine-color-gray-0)" radius="md" padding="sm" withBorder>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
@@ -446,7 +478,12 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
||||
leftSection={<ArrowRightLeft size={16} />}
|
||||
onClick={handleRequest}
|
||||
loading={createRequest.isPending}
|
||||
disabled={busy || !transferYardId || transferQty < 1}
|
||||
disabled={
|
||||
busy ||
|
||||
!transferYardId ||
|
||||
transferQty < 1 ||
|
||||
!transferReason.trim()
|
||||
}
|
||||
color="edr-green"
|
||||
>
|
||||
Request {transferQty > 0 ? `${transferQty} ` : ""}wagon
|
||||
|
||||
@@ -166,6 +166,7 @@ export const QUERY_KEYS = {
|
||||
) => ["rule-engine", "select-options", resource, params ?? {}] as const,
|
||||
orderList: (resource: RuleEngineResourceSlug | string) =>
|
||||
["rule-engine", "order-list", resource] as const,
|
||||
priorityRuleChanges: ["rule-engine", "priority-rule-changes"] as const,
|
||||
},
|
||||
|
||||
OVERVIEW: {
|
||||
|
||||
@@ -176,6 +176,9 @@ export const URL_CONSTANTS = {
|
||||
CONTRACT_VIEW: (id: string) => `/contracts/${id}/contract/view`,
|
||||
CONTRACT_DOCUMENT: (id: string) => `/contracts/${id}/contract/document`,
|
||||
CONTRACT_SIGN: (id: string) => `/contracts/${id}/contract/sign`,
|
||||
CONTRACT_DOCUMENT_DRAFT: (id: string) => `/contracts/${id}/document/draft`,
|
||||
CONTRACT_DOCUMENT_ARTICLES: (id: string) =>
|
||||
`/contracts/${id}/document/articles`,
|
||||
CLEARANCE_QUEUE: "/contracts/clearance/queue",
|
||||
CLEARANCE: (id: string) => `/contracts/${id}/clearance`,
|
||||
CLEARANCE_REVIEW: (id: string) => `/contracts/${id}/clearance/review`,
|
||||
|
||||
@@ -125,12 +125,27 @@ export function useContractMutations(contractId: string) {
|
||||
};
|
||||
|
||||
const staffAccept = useMutation({
|
||||
mutationFn: (validityDays: number) =>
|
||||
contractsService.staffAccept(contractId, validityDays),
|
||||
mutationFn: (payload: {
|
||||
validityDays: number;
|
||||
documentSnapshot?: Freight.IContractDocumentSnapshot;
|
||||
}) =>
|
||||
contractsService.staffAccept(
|
||||
contractId,
|
||||
payload.validityDays,
|
||||
payload.documentSnapshot,
|
||||
),
|
||||
onSuccess: (data) => onSuccess(data, "Contract accepted for approval"),
|
||||
onError: () => toast.error("Failed to accept contract"),
|
||||
});
|
||||
|
||||
// Edit THIS contract's document articles (per-contract; never the templates).
|
||||
const updateDocument = useMutation({
|
||||
mutationFn: (snapshot: Freight.IContractDocumentSnapshot) =>
|
||||
contractsService.updateContractDocument(contractId, snapshot),
|
||||
onSuccess: (data) => onSuccess(data, "Contract document updated"),
|
||||
onError: () => toast.error("Failed to update contract document"),
|
||||
});
|
||||
|
||||
const requestChanges = useMutation({
|
||||
mutationFn: (note: string) =>
|
||||
contractsService.requestChanges(contractId, note),
|
||||
@@ -144,11 +159,6 @@ export function useContractMutations(contractId: string) {
|
||||
onError: () => toast.error("Failed to reject contract"),
|
||||
});
|
||||
|
||||
// Statuses that mean every approval step is done and the contract is ready to
|
||||
// be generated. Once the final approval lands we generate the PDF
|
||||
// automatically — staff no longer click a separate "Generate" button.
|
||||
const READY_TO_GENERATE = ["APPROVED", "APPROVED_PENDING_SIGNATURE"];
|
||||
|
||||
const approveStep = useMutation({
|
||||
mutationFn: ({
|
||||
stepId,
|
||||
@@ -158,25 +168,15 @@ export function useContractMutations(contractId: string) {
|
||||
requiredRole: string;
|
||||
}) =>
|
||||
contractsService.approveStep({ id: contractId, stepId, requiredRole }),
|
||||
onSuccess: async (data) => {
|
||||
// If this was the LAST approval, auto-generate the contract so it goes
|
||||
// straight to CONTRACT_READY without a manual step.
|
||||
const alreadyGenerated = Boolean(
|
||||
(data as Freight.IContract).contractGeneratedAt,
|
||||
);
|
||||
if (READY_TO_GENERATE.includes(data.status) && !alreadyGenerated) {
|
||||
toast.success("Final approval complete — generating contract…");
|
||||
try {
|
||||
const generated = await contractsService.generateContract(data.id);
|
||||
onSuccess(generated, "Contract generated and ready to sign");
|
||||
return;
|
||||
} catch {
|
||||
toast.error("Approved, but contract generation failed. Retry below.");
|
||||
void invalidateContractDetail(qc, data.id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
onSuccess(data, "Approval step completed");
|
||||
onSuccess: (data) => {
|
||||
// The document is generated at the accept stage and reviewed during
|
||||
// approval, so the final approval moves the contract straight to
|
||||
// CONTRACT_READY on the server — no client-side generate call here.
|
||||
const message =
|
||||
data.status === "CONTRACT_READY"
|
||||
? "Final approval complete — contract ready to sign"
|
||||
: "Approval step completed";
|
||||
onSuccess(data, message);
|
||||
},
|
||||
onError: () => toast.error("Failed to approve step"),
|
||||
});
|
||||
@@ -239,6 +239,7 @@ export function useContractMutations(contractId: string) {
|
||||
|
||||
const isPending =
|
||||
staffAccept.isPending ||
|
||||
updateDocument.isPending ||
|
||||
requestChanges.isPending ||
|
||||
reject.isPending ||
|
||||
approveStep.isPending ||
|
||||
@@ -249,6 +250,7 @@ export function useContractMutations(contractId: string) {
|
||||
|
||||
return {
|
||||
staffAccept,
|
||||
updateDocument,
|
||||
requestChanges,
|
||||
reject,
|
||||
approveStep,
|
||||
|
||||
@@ -3,7 +3,11 @@ import toast from "react-hot-toast";
|
||||
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import { api } from "@/services/api";
|
||||
import { ruleEngineService, type RuleEngineListParams } from "@/services/ruleEngine/ruleEngine.service";
|
||||
import {
|
||||
ruleEngineService,
|
||||
type RuleEngineListParams,
|
||||
type SubmitPriorityRuleChangePayload,
|
||||
} from "@/services/ruleEngine/ruleEngine.service";
|
||||
import { RULE_ENGINE_SELECT_NONE } from "@/pages/ruleEngine/config/resources";
|
||||
import type {
|
||||
RuleEngineRecord,
|
||||
@@ -239,6 +243,68 @@ export const useRuleEngineMutations = (resource: RuleEngineResourceSlug) => {
|
||||
return { create, update, remove };
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export const usePriorityRuleWorkflow = (enabled: boolean) => {
|
||||
const qc = useQueryClient();
|
||||
|
||||
const backendMessage = (err: unknown, fallback: string) => {
|
||||
const msg = (err as { response?: { data?: { message?: string | string[] } } })
|
||||
?.response?.data?.message;
|
||||
if (Array.isArray(msg)) return msg.join(", ");
|
||||
return msg || fallback;
|
||||
};
|
||||
|
||||
const pending = useQuery({
|
||||
queryKey: QUERY_KEYS.RULE_ENGINE.priorityRuleChanges,
|
||||
queryFn: () => ruleEngineService.listPriorityRuleChanges("PENDING"),
|
||||
enabled,
|
||||
});
|
||||
|
||||
const invalidate = async () => {
|
||||
await qc.invalidateQueries({
|
||||
queryKey: QUERY_KEYS.RULE_ENGINE.priorityRuleChanges,
|
||||
});
|
||||
await invalidateRuleEngineList(qc, "priority-configs");
|
||||
};
|
||||
|
||||
const submit = useMutation({
|
||||
mutationFn: (payload: SubmitPriorityRuleChangePayload) =>
|
||||
ruleEngineService.submitPriorityRuleChange(payload),
|
||||
onSuccess: async () => {
|
||||
toast.success("Change submitted for approval — the team has been notified");
|
||||
await invalidate();
|
||||
},
|
||||
onError: (err) => toast.error(backendMessage(err, "Failed to submit change")),
|
||||
});
|
||||
|
||||
const approve = useMutation({
|
||||
mutationFn: ({ id, decisionNote }: { id: string; decisionNote?: string }) =>
|
||||
ruleEngineService.approvePriorityRuleChange(id, decisionNote),
|
||||
onSuccess: async () => {
|
||||
toast.success("Change approved and applied");
|
||||
await invalidate();
|
||||
},
|
||||
onError: (err) => toast.error(backendMessage(err, "Failed to approve change")),
|
||||
});
|
||||
|
||||
const reject = useMutation({
|
||||
mutationFn: ({ id, decisionNote }: { id: string; decisionNote?: string }) =>
|
||||
ruleEngineService.rejectPriorityRuleChange(id, decisionNote),
|
||||
onSuccess: async () => {
|
||||
toast.success("Change rejected");
|
||||
await invalidate();
|
||||
},
|
||||
onError: (err) => toast.error(backendMessage(err, "Failed to reject change")),
|
||||
});
|
||||
|
||||
return { pending, submit, approve, reject };
|
||||
};
|
||||
|
||||
export const useRateWorkflow = () => {
|
||||
const qc = useQueryClient();
|
||||
|
||||
|
||||
@@ -965,7 +965,7 @@ export function WagonsCrudPage() {
|
||||
{ value: 'EXPORT_READY', label: 'Export ready' },
|
||||
{ value: 'ASSIGNED', label: 'Assigned' },
|
||||
{ value: 'MAINTENANCE', label: 'Maintenance' },
|
||||
{ value: 'RETIRED', label: 'Retired' },
|
||||
{ value: 'DETAINED', label: 'Detained' },
|
||||
],
|
||||
},
|
||||
{ key: 'notes', label: 'Notes' },
|
||||
|
||||
@@ -113,7 +113,7 @@ const WAGON_STATUS_OPTIONS = [
|
||||
{ label: "Available", value: Freight.WagonStatus.Available },
|
||||
{ label: "Assigned", value: Freight.WagonStatus.Assigned },
|
||||
{ label: "Maintenance", value: Freight.WagonStatus.Maintenance },
|
||||
{ label: "Retired", value: Freight.WagonStatus.Retired },
|
||||
{ label: "Detained", value: Freight.WagonStatus.Detained },
|
||||
];
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { Badge, Button, Card, Group, Stack, Text } from "@mantine/core";
|
||||
import type { UseMutationResult } from "@tanstack/react-query";
|
||||
import { CheckCircle2, XCircle } from "lucide-react";
|
||||
|
||||
import type { PriorityRuleChangeRequest } from "@/services/ruleEngine/ruleEngine.service";
|
||||
|
||||
const ACTION_COLOR: Record<PriorityRuleChangeRequest["action"], string> = {
|
||||
CREATE: "teal",
|
||||
UPDATE: "blue",
|
||||
DELETE: "red",
|
||||
};
|
||||
|
||||
const fmtDateTime = (iso: string) =>
|
||||
new Date(iso).toLocaleString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
/** "WAGON 1–5 · 30 pts" from a change payload / target rule. */
|
||||
const ruleSummary = (
|
||||
r: PriorityRuleChangeRequest,
|
||||
): string => {
|
||||
const source = (r.payload ?? r.priorityConfig ?? {}) as Record<string, unknown>;
|
||||
const base = (r.priorityConfig ?? {}) as Record<string, unknown>;
|
||||
const pick = (key: string) => source[key] ?? base[key];
|
||||
const type = pick("type");
|
||||
const currency = pick("currency");
|
||||
const min = pick("minWagonCount");
|
||||
const max = pick("maxWagonCount");
|
||||
const pts = pick("scorePoints");
|
||||
const parts = [
|
||||
type ? String(type) : null,
|
||||
currency ? String(currency) : null,
|
||||
min != null && max != null ? `${min}–${max} wagons` : null,
|
||||
pts != null ? `${pts} pts` : null,
|
||||
].filter(Boolean);
|
||||
return parts.join(" · ") || "—";
|
||||
};
|
||||
|
||||
type Decide = UseMutationResult<
|
||||
PriorityRuleChangeRequest,
|
||||
unknown,
|
||||
{ id: string; decisionNote?: string }
|
||||
>;
|
||||
|
||||
interface PriorityRuleApprovalsSectionProps {
|
||||
requests: PriorityRuleChangeRequest[];
|
||||
canDecide: boolean;
|
||||
approve: Decide;
|
||||
reject: Decide;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pending priority-rule change requests awaiting approval. Rendered above the
|
||||
* rules table on the priority-configs page; every rule change lands here first
|
||||
* and only an approval applies it.
|
||||
*/
|
||||
const PriorityRuleApprovalsSection = ({
|
||||
requests,
|
||||
canDecide,
|
||||
approve,
|
||||
reject,
|
||||
}: PriorityRuleApprovalsSectionProps) => {
|
||||
if (requests.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Card withBorder radius="md" padding="md" mb="md">
|
||||
<Group gap={8} mb="sm">
|
||||
<Text fw={700}>Pending approvals</Text>
|
||||
<Badge variant="light" color="yellow">
|
||||
{requests.length}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Stack gap={8}>
|
||||
{requests.map((r) => (
|
||||
<Card key={r.id} withBorder radius="md" padding="sm">
|
||||
<Group justify="space-between" wrap="nowrap" align="flex-start">
|
||||
<Stack gap={4} style={{ minWidth: 0 }}>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Badge variant="light" color={ACTION_COLOR[r.action]} radius="sm">
|
||||
{r.action.toLowerCase()}
|
||||
</Badge>
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{ruleSummary(r)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
Submitted {fmtDateTime(r.createdAt)}
|
||||
</Text>
|
||||
</Stack>
|
||||
{canDecide ? (
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
leftSection={<XCircle size={14} />}
|
||||
loading={reject.isPending}
|
||||
onClick={() => reject.mutate({ id: r.id })}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
leftSection={<CheckCircle2 size={14} />}
|
||||
loading={approve.isPending}
|
||||
onClick={() => approve.mutate({ id: r.id })}
|
||||
>
|
||||
Approve & apply
|
||||
</Button>
|
||||
</Group>
|
||||
) : null}
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default PriorityRuleApprovalsSection;
|
||||
@@ -18,6 +18,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 RuleEngineCardGrid from "@/components/ruleEngine/RuleEngineCardGrid";
|
||||
import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog";
|
||||
import RuleEngineOrderControls from "@/components/ruleEngine/RuleEngineOrderControls";
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
useContainerTypeOptions,
|
||||
useLiveRateOptions,
|
||||
useWagonTypeOptions,
|
||||
usePriorityRuleWorkflow,
|
||||
useRateWorkflow,
|
||||
useRuleEngineList,
|
||||
useRuleEngineMutations,
|
||||
@@ -142,6 +144,13 @@ const RuleEngineResourcePage = () => {
|
||||
chainOpen && config?.slug === "approval-rules",
|
||||
);
|
||||
|
||||
// Priority rules never mutate directly: changes are filed for approval and a
|
||||
// pending queue renders above the table.
|
||||
const isPriorityRules = config?.slug === "priority-configs";
|
||||
const priorityWorkflow = usePriorityRuleWorkflow(
|
||||
Boolean(isPriorityRules && canView),
|
||||
);
|
||||
|
||||
const editingId = editing?.id ? String(editing.id) : undefined;
|
||||
const usesContainerTypeField = Boolean(
|
||||
config?.formFields.some((f) => f.name === "containerTypeId"),
|
||||
@@ -360,9 +369,37 @@ const RuleEngineResourcePage = () => {
|
||||
currency: "USD",
|
||||
trigger: isSurcharge ? values.trigger : "ALWAYS",
|
||||
};
|
||||
} else if (config.slug === "priority-configs") {
|
||||
} else if (isPriorityRules) {
|
||||
// Label is required by the backend but hidden in the UI for now.
|
||||
payload = { ...values, label: String(Date.now()) };
|
||||
// Approval workflow: file a change request instead of mutating directly.
|
||||
// On update, keep the target's existing label rather than a fresh stamp.
|
||||
if (editing?.id) {
|
||||
priorityWorkflow.submit.mutate(
|
||||
{
|
||||
action: "UPDATE",
|
||||
priorityConfigId: String(editing.id),
|
||||
update: { ...values, label: String(editing.label ?? Date.now()) },
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setFormOpen(false);
|
||||
setEditing(null);
|
||||
},
|
||||
},
|
||||
);
|
||||
} else {
|
||||
priorityWorkflow.submit.mutate(
|
||||
{ action: "CREATE", create: payload },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setFormOpen(false);
|
||||
setEditing(null);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
return;
|
||||
} else if (config.slug === "weight-limit-rules") {
|
||||
// Empty max capacity means "no ceiling" — send null explicitly so an
|
||||
// edit can clear a previously-set ceiling (omitting the key keeps it).
|
||||
@@ -406,6 +443,15 @@ const RuleEngineResourcePage = () => {
|
||||
}
|
||||
/>
|
||||
|
||||
{isPriorityRules ? (
|
||||
<PriorityRuleApprovalsSection
|
||||
requests={priorityWorkflow.pending.data ?? []}
|
||||
canDecide={canManage}
|
||||
approve={priorityWorkflow.approve}
|
||||
reject={priorityWorkflow.reject}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Card p={0}>
|
||||
<Stack gap={0}>
|
||||
<Box px="md" pt="md" pb="sm" w="100%">
|
||||
@@ -519,7 +565,9 @@ const RuleEngineResourcePage = () => {
|
||||
}
|
||||
fields={formFields}
|
||||
initialRecord={editing}
|
||||
isSubmitting={create.isPending || update.isPending}
|
||||
isSubmitting={
|
||||
create.isPending || update.isPending || priorityWorkflow.submit.isPending
|
||||
}
|
||||
selectOptionsLoading={
|
||||
(config.slug === "cargo-types" && cargoParentOptionsLoading) ||
|
||||
(usesContainerTypeField && containerTypeOptionsLoading) ||
|
||||
@@ -557,8 +605,9 @@ const RuleEngineResourcePage = () => {
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm">
|
||||
This will soft-delete the selected {config.label.toLowerCase()}{" "}
|
||||
record.
|
||||
{isPriorityRules
|
||||
? "This files a delete request for approval — the rule is removed once an approver confirms."
|
||||
: `This will soft-delete the selected ${config.label.toLowerCase()} record.`}
|
||||
</Text>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={() => setDeleteTarget(null)}>
|
||||
@@ -566,15 +615,25 @@ const RuleEngineResourcePage = () => {
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
loading={remove.isPending}
|
||||
loading={remove.isPending || priorityWorkflow.submit.isPending}
|
||||
onClick={() => {
|
||||
if (!deleteTarget) return;
|
||||
if (isPriorityRules) {
|
||||
priorityWorkflow.submit.mutate(
|
||||
{
|
||||
action: "DELETE",
|
||||
priorityConfigId: String(deleteTarget.id),
|
||||
},
|
||||
{ onSuccess: () => setDeleteTarget(null) },
|
||||
);
|
||||
return;
|
||||
}
|
||||
remove.mutate(deleteTarget.id, {
|
||||
onSuccess: () => setDeleteTarget(null),
|
||||
});
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
{isPriorityRules ? "Request delete" : "Delete"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -68,6 +68,7 @@ import {
|
||||
ScheduleWarningsAlert,
|
||||
} from "@/components/trainScheduling/ScheduleWarningsAlert";
|
||||
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
|
||||
import { TrainConsistView } from "@/components/trainScheduling/compositionEditor";
|
||||
import { WagonPlanGrid } from "@/components/trainScheduling/WagonPlanGrid";
|
||||
import { WorkflowRail, WorkflowStep } from "@/components/trainScheduling/WorkflowStep";
|
||||
import { openPdfBlob } from "@/components/warehouses/pdf";
|
||||
@@ -252,12 +253,6 @@ export default function TrainScheduleV2DetailPage() {
|
||||
() => (isExportDisplay ? [...displayWagonPlan].reverse() : displayWagonPlan),
|
||||
[displayWagonPlan, isExportDisplay],
|
||||
);
|
||||
const diagramWagons = useMemo(() => {
|
||||
const source = schedule?.trainSet?.wagons?.length
|
||||
? schedule.trainSet.wagons
|
||||
: displayWagonPlan;
|
||||
return isExportDisplay ? [...source].reverse() : source;
|
||||
}, [schedule?.trainSet?.wagons, displayWagonPlan, isExportDisplay]);
|
||||
|
||||
const runPreview = useCallback(
|
||||
async (options?: { silent?: boolean; advanceStep?: boolean }) => {
|
||||
@@ -774,22 +769,26 @@ export default function TrainScheduleV2DetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// finalize
|
||||
// finalize — the train is known here, so draw the full composition the
|
||||
// same way the batch board's composition tab does (interactive consist).
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<TrainCompositionDiagram
|
||||
locomotive={schedule.trainSet?.locomotive}
|
||||
locomotives={locomotives}
|
||||
wagons={diagramWagons}
|
||||
freightType={freightType}
|
||||
trainNumber={schedule.trainNumber ?? schedule.train?.code ?? null}
|
||||
totalLengthMeters={schedule.trainSet?.totalLengthMeters}
|
||||
/>
|
||||
{isExportDisplay && diagramWagons.length ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
Shown rear-first (export direction) — positions keep their original numbers.
|
||||
</Text>
|
||||
) : null}
|
||||
{schedule.trainSet ? (
|
||||
<TrainConsistView
|
||||
scheduleDetail={schedule}
|
||||
scheduleId={scheduleId ?? ""}
|
||||
maxWagons={schedule.maxWagons ?? 53}
|
||||
/>
|
||||
) : (
|
||||
<TrainCompositionDiagram
|
||||
locomotive={null}
|
||||
locomotives={locomotives}
|
||||
wagons={[]}
|
||||
freightType={freightType}
|
||||
trainNumber={schedule.trainNumber ?? schedule.train?.code ?? null}
|
||||
totalLengthMeters={null}
|
||||
/>
|
||||
)}
|
||||
<Paper
|
||||
p="lg"
|
||||
radius="lg"
|
||||
|
||||
@@ -202,6 +202,7 @@ import {
|
||||
type WagonMovementRecord,
|
||||
type WagonTransferRequest,
|
||||
type CreateTransferRequestPayload,
|
||||
type BulkFulfillResult,
|
||||
type TransferHistory,
|
||||
} from "./wagon.service";
|
||||
import { warehouseService } from "./warehouse.service";
|
||||
@@ -1721,6 +1722,15 @@ export const api = {
|
||||
() => [["wagonTransferRequests"], ["wagons"]],
|
||||
),
|
||||
|
||||
bulkFulfill: endpoint<{ requestIds: string[] }, BulkFulfillResult>(
|
||||
"wagonTransferRequests",
|
||||
"bulkFulfill",
|
||||
({ requestIds }) =>
|
||||
wagonTransferRequestService.bulkFulfill(requestIds).then((r) => r.data),
|
||||
undefined,
|
||||
() => [["wagonTransferRequests"], ["wagons"]],
|
||||
),
|
||||
|
||||
cancel: endpoint<{ id: string }, WagonTransferRequest>(
|
||||
"wagonTransferRequests",
|
||||
"cancel",
|
||||
|
||||
@@ -170,8 +170,35 @@ export const contractsService = {
|
||||
},
|
||||
|
||||
// ── Staff review ──
|
||||
staffAccept: (id: string, validityDays: number) =>
|
||||
postContract<Freight.IContract>(C.STAFF_ACCEPT(id), { validityDays }),
|
||||
staffAccept: (
|
||||
id: string,
|
||||
validityDays: number,
|
||||
documentSnapshot?: Freight.IContractDocumentSnapshot,
|
||||
) =>
|
||||
postContract<Freight.IContract>(C.STAFF_ACCEPT(id), {
|
||||
validityDays,
|
||||
documentSnapshot,
|
||||
}),
|
||||
|
||||
/** The editable per-contract document draft (snapshot or live template). */
|
||||
getContractDocumentDraft: async (
|
||||
id: string,
|
||||
): Promise<Freight.IContractDocumentDraft> => {
|
||||
const response = await client.get(C.CONTRACT_DOCUMENT_DRAFT(id));
|
||||
return unwrap(response.data) as Freight.IContractDocumentDraft;
|
||||
},
|
||||
|
||||
/** Save this contract's edited document articles (never touches the templates). */
|
||||
updateContractDocument: async (
|
||||
id: string,
|
||||
snapshot: Freight.IContractDocumentSnapshot,
|
||||
): Promise<Freight.IContract> => {
|
||||
const response = await client.put(
|
||||
C.CONTRACT_DOCUMENT_ARTICLES(id),
|
||||
snapshot,
|
||||
);
|
||||
return unwrap(response.data) as Freight.IContract;
|
||||
},
|
||||
|
||||
requestChanges: (id: string, note: string) =>
|
||||
postContract<Freight.IContract>(C.STAFF_REQUEST_CHANGES(id), { note }),
|
||||
|
||||
@@ -24,6 +24,30 @@ export interface RuleEngineReorderPayload {
|
||||
requiresDirectorApproval?: boolean;
|
||||
}
|
||||
|
||||
/** Priority-rule approval workflow (all priority-config changes go through it). */
|
||||
const PRIORITY_RULE_CHANGES_BASE = "/priority-rule-change-requests";
|
||||
|
||||
export interface PriorityRuleChangeRequest {
|
||||
id: string;
|
||||
action: "CREATE" | "UPDATE" | "DELETE";
|
||||
priorityConfigId: string | null;
|
||||
priorityConfig?: RuleEngineRecord | null;
|
||||
payload: Record<string, unknown> | null;
|
||||
status: "PENDING" | "APPROVED" | "REJECTED";
|
||||
requestedByUserId: string | null;
|
||||
decidedByUserId: string | null;
|
||||
decidedAt: string | null;
|
||||
decisionNote: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface SubmitPriorityRuleChangePayload {
|
||||
action: "CREATE" | "UPDATE" | "DELETE";
|
||||
priorityConfigId?: string;
|
||||
create?: Record<string, unknown>;
|
||||
update?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const RESOURCE_BASE: Record<RuleEngineResourceSlug, string> = {
|
||||
"cargo-types": URL_CONSTANTS.RULE_ENGINE.CARGO_TYPES,
|
||||
"container-types": URL_CONSTANTS.RULE_ENGINE.CONTAINER_TYPES,
|
||||
@@ -242,6 +266,46 @@ export const ruleEngineService = {
|
||||
return normalizeEntity<T>(response.data);
|
||||
},
|
||||
|
||||
/** File a priority-rule change (create/update/delete) for approval. */
|
||||
submitPriorityRuleChange: async (
|
||||
payload: SubmitPriorityRuleChangePayload,
|
||||
): Promise<PriorityRuleChangeRequest> => {
|
||||
const response = await client.post(PRIORITY_RULE_CHANGES_BASE, payload);
|
||||
return unwrap(response.data) as PriorityRuleChangeRequest;
|
||||
},
|
||||
|
||||
listPriorityRuleChanges: async (
|
||||
status?: PriorityRuleChangeRequest["status"],
|
||||
): Promise<PriorityRuleChangeRequest[]> => {
|
||||
const response = await client.get(PRIORITY_RULE_CHANGES_BASE, {
|
||||
params: status ? { status } : undefined,
|
||||
});
|
||||
const body = unwrap(response.data) as unknown;
|
||||
return Array.isArray(body) ? (body as PriorityRuleChangeRequest[]) : [];
|
||||
},
|
||||
|
||||
approvePriorityRuleChange: async (
|
||||
id: string,
|
||||
decisionNote?: string,
|
||||
): Promise<PriorityRuleChangeRequest> => {
|
||||
const response = await client.post(
|
||||
`${PRIORITY_RULE_CHANGES_BASE}/${id}/approve`,
|
||||
{ decisionNote },
|
||||
);
|
||||
return unwrap(response.data) as PriorityRuleChangeRequest;
|
||||
},
|
||||
|
||||
rejectPriorityRuleChange: async (
|
||||
id: string,
|
||||
decisionNote?: string,
|
||||
): Promise<PriorityRuleChangeRequest> => {
|
||||
const response = await client.post(
|
||||
`${PRIORITY_RULE_CHANGES_BASE}/${id}/reject`,
|
||||
{ decisionNote },
|
||||
);
|
||||
return unwrap(response.data) as PriorityRuleChangeRequest;
|
||||
},
|
||||
|
||||
getApprovalChain: async (
|
||||
requiresDirectorApproval = true,
|
||||
): Promise<RuleEngineRecord[]> => {
|
||||
|
||||
@@ -108,6 +108,8 @@ export interface WagonTransferRequest {
|
||||
requestedByUserId: string | null;
|
||||
fulfilledByUserId: string | null;
|
||||
fulfilledAt: string | null;
|
||||
/** Why the wagons are needed — required for new requests, shown on the queue. */
|
||||
reason?: string | null;
|
||||
note: string | null;
|
||||
fromYard?: { id: string; label?: string; code?: string } | null;
|
||||
toYard?: { id: string; label?: string; code?: string } | null;
|
||||
@@ -120,9 +122,17 @@ export interface CreateTransferRequestPayload {
|
||||
toYardId: string;
|
||||
wagonTypeId: string;
|
||||
quantity: number;
|
||||
/** Mandatory: why the wagons are needed. */
|
||||
reason: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
/** Bulk accept-and-execute result: what ran, what stayed PENDING and why. */
|
||||
export interface BulkFulfillResult {
|
||||
fulfilled: WagonTransferRequest[];
|
||||
skipped: Array<{ id: string; reason: string }>;
|
||||
}
|
||||
|
||||
/** Per-user activity: requests filed/fulfilled + the wagons physically moved. */
|
||||
export interface TransferHistory {
|
||||
requests: WagonTransferRequest[];
|
||||
@@ -146,6 +156,11 @@ export const wagonTransferRequestService = {
|
||||
apiClient.get<WagonTransferRequest>(`/wagon-transfer-requests/${id}`),
|
||||
create: (data: CreateTransferRequestPayload) =>
|
||||
apiClient.post<WagonTransferRequest>('/wagon-transfer-requests', data),
|
||||
/** OCC: accept-and-execute a subset of pending requests (auto-picked wagons). */
|
||||
bulkFulfill: (requestIds: string[]) =>
|
||||
apiClient.post<BulkFulfillResult>('/wagon-transfer-requests/bulk-fulfill', {
|
||||
requestIds,
|
||||
}),
|
||||
/** OCC: execute the transfer with the hand-picked wagons. */
|
||||
fulfill: (id: string, wagonIds: string[]) =>
|
||||
apiClient.post<WagonTransferRequest>(
|
||||
|
||||
@@ -528,6 +528,8 @@ export interface TrainScheduleDetail {
|
||||
deferredBookings?: DeferredBookingRow[];
|
||||
freightType?: FreightType | null;
|
||||
trainNumber?: string | null;
|
||||
/** Wagon cap for this departure (built-train consist size or configured limit). */
|
||||
maxWagons?: number | null;
|
||||
/** Built train (Train Builder) behind this departure, when scheduled by train. */
|
||||
train?: {
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user