This commit is contained in:
Marshal
2026-07-15 13:29:01 +00:00
parent c71a0043d6
commit 19c9da28ae
59 changed files with 3008 additions and 284 deletions

View File

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

View File

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

View File

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

View File

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

View File

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