Merge pull request #717 from Tria-plc/freight_feature/usermanagement

Freight feature/usermanagement
This commit is contained in:
marshal
2026-07-15 23:18:55 +03:00
committed by GitHub
53 changed files with 1524 additions and 39 deletions

View File

@@ -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()}
>

View File

@@ -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}

View File

@@ -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;

View File

@@ -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;