mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 07:10:57 +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;
|
||||
Reference in New Issue
Block a user