mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
- Add TransferFulfillModal for fulfilling wagon transfer requests. - Create TransferRequestFormModal for filing new wagon transfer requests. - Introduce TransferCloseShortModal for closing requests that cannot be fully fulfilled. - Develop WagonTransfersPage to manage and display wagon transfer requests. - Implement utility functions for handling wagon transfer request data and UI components. - Enhance UI with Mantine components for better user experience.
629 lines
22 KiB
TypeScript
629 lines
22 KiB
TypeScript
import { Freight } from "@edr/types";
|
||
import {
|
||
Badge,
|
||
Box,
|
||
Button,
|
||
Card,
|
||
Divider,
|
||
Grid,
|
||
Group,
|
||
Loader,
|
||
Modal,
|
||
NumberInput,
|
||
Progress,
|
||
Select,
|
||
Slider,
|
||
Stack,
|
||
Text,
|
||
Textarea,
|
||
ThemeIcon,
|
||
} from "@mantine/core";
|
||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||
import { ArrowRight, ArrowRightLeft, CheckCircle2, CircleSlash, Layers, Warehouse } from "lucide-react";
|
||
import { useEffect, useMemo, useState } from "react";
|
||
|
||
import { api } from "@/services/api";
|
||
import { useToast } from "@/hooks/use-toast";
|
||
import type { Wagon } from "@/services/wagon.service";
|
||
|
||
export interface WagonYardWorkspaceModalProps {
|
||
opened: boolean;
|
||
onClose: () => void;
|
||
}
|
||
|
||
const AVAILABLE = Freight.WagonStatus.Available;
|
||
const ASSIGNED = Freight.WagonStatus.Assigned;
|
||
|
||
const clampInt = (v: number | string, max: number): number => {
|
||
const n = typeof v === "number" ? v : Number(v);
|
||
if (!Number.isFinite(n) || n < 0) return 0;
|
||
return Math.min(Math.floor(n), max);
|
||
};
|
||
|
||
/**
|
||
* NumberInput + Slider + All/Half presets, kept in sync. `max` bounds the field
|
||
* for actions that move real wagons; omit it for a transfer REQUEST, which may
|
||
* legitimately ask for more than the yard holds today (OCC fulfils it in
|
||
* instalments) — the slider then just tracks the current value.
|
||
*/
|
||
const QuantityField = ({
|
||
value,
|
||
onChange,
|
||
max,
|
||
disabled,
|
||
}: {
|
||
value: number;
|
||
onChange: (n: number) => void;
|
||
max?: number;
|
||
disabled?: boolean;
|
||
}) => {
|
||
const capped = max ?? Number.MAX_SAFE_INTEGER;
|
||
const set = (v: number | string) => onChange(clampInt(v, capped));
|
||
const off = disabled || max === 0;
|
||
return (
|
||
<Stack gap={8}>
|
||
<Group gap="sm" align="center" wrap="nowrap">
|
||
<NumberInput
|
||
value={value}
|
||
onChange={set}
|
||
min={0}
|
||
max={max}
|
||
allowNegative={false}
|
||
clampBehavior="strict"
|
||
disabled={off}
|
||
radius="md"
|
||
w={92}
|
||
/>
|
||
<Slider
|
||
style={{ flex: 1 }}
|
||
value={value}
|
||
onChange={set}
|
||
min={0}
|
||
max={Math.max(max ?? Math.max(value, 10), 1)}
|
||
disabled={off}
|
||
label={(v) => `${v}`}
|
||
color="edr-green"
|
||
/>
|
||
</Group>
|
||
<Group gap={6}>
|
||
{/* Presets only make sense against a real ceiling — an uncapped request
|
||
field (transfer ask) shows the manual input alone. */}
|
||
{max != null ? (
|
||
<>
|
||
<Button size="compact-xs" variant="light" color="gray" disabled={off} onClick={() => set(Math.ceil(max / 2))}>
|
||
Half
|
||
</Button>
|
||
<Button size="compact-xs" variant="light" color="gray" disabled={off} onClick={() => set(max)}>
|
||
All ({max})
|
||
</Button>
|
||
</>
|
||
) : null}
|
||
{value > 0 ? (
|
||
<Button size="compact-xs" variant="subtle" color="gray" onClick={() => set(0)}>
|
||
Clear
|
||
</Button>
|
||
) : null}
|
||
</Group>
|
||
</Stack>
|
||
);
|
||
};
|
||
|
||
const LegendDot = ({ color, label, value }: { color: string; label: string; value: number }) => (
|
||
<Group gap={6} wrap="nowrap">
|
||
<Box w={10} h={10} style={{ borderRadius: 3, background: `var(--mantine-color-${color}-6)` }} />
|
||
<Text size="sm" c="dimmed">
|
||
{label}
|
||
</Text>
|
||
<Text size="sm" fw={700}>
|
||
{value}
|
||
</Text>
|
||
</Group>
|
||
);
|
||
|
||
/**
|
||
* Bulk yard operations. Pick a yard + wagon type (the two selects filter each
|
||
* other to combinations that actually hold stock), read the live Available /
|
||
* Assigned split, then move a quantity to another yard or flip a quantity
|
||
* between Available and Assigned — replacing one-wagon-at-a-time edits.
|
||
*/
|
||
const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalProps) => {
|
||
const { toast } = useToast();
|
||
|
||
const { data: wagons = [], isLoading } = useQuery(api.wagons.list.queryOptions({ input: {} }));
|
||
const { data: yards = [] } = useQuery(api.routes.yards.queryOptions());
|
||
const { data: wagonTypes = [] } = useQuery(api.wagonTypes.list.queryOptions());
|
||
|
||
const [yardId, setYardId] = useState<string | null>(null);
|
||
const [typeId, setTypeId] = useState<string | null>(null);
|
||
|
||
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);
|
||
|
||
const createRequest = useMutation(
|
||
api.wagonTransferRequests.create.mutationOptions(),
|
||
);
|
||
const setStatus = useMutation(api.wagons.bulkSetStatus.mutationOptions());
|
||
|
||
const yardName = useMemo(() => {
|
||
const byId = new Map(yards.map((y) => [y.id, y.label || y.code || y.id]));
|
||
return (id: string) => byId.get(id) ?? id;
|
||
}, [yards]);
|
||
|
||
const typeInfo = useMemo(() => {
|
||
const byId = new Map(wagonTypes.map((t) => [t.id, t]));
|
||
return {
|
||
label: (id: string) => {
|
||
const t = byId.get(id);
|
||
return t ? `${t.code}${t.name ? ` - ${t.name}` : ""}` : id;
|
||
},
|
||
code: (id: string) => byId.get(id)?.code ?? id,
|
||
};
|
||
}, [wagonTypes]);
|
||
|
||
const yardWagons = useMemo(
|
||
() => wagons.filter((w): w is Wagon & { currentYardId: string } => Boolean(w.currentYardId)),
|
||
[wagons],
|
||
);
|
||
|
||
const yardOptions = useMemo(() => {
|
||
const ids = new Set<string>();
|
||
for (const w of yardWagons) {
|
||
if (typeId && w.wagonTypeId !== typeId) continue;
|
||
ids.add(w.currentYardId);
|
||
}
|
||
return [...ids]
|
||
.map((id) => ({ value: id, label: yardName(id) }))
|
||
.sort((a, b) => a.label.localeCompare(b.label));
|
||
}, [yardWagons, typeId, yardName]);
|
||
|
||
const typeOptions = useMemo(() => {
|
||
const ids = new Set<string>();
|
||
for (const w of yardWagons) {
|
||
if (yardId && w.currentYardId !== yardId) continue;
|
||
ids.add(w.wagonTypeId);
|
||
}
|
||
return [...ids]
|
||
.map((id) => ({ value: id, label: typeInfo.label(id) }))
|
||
.sort((a, b) => a.label.localeCompare(b.label));
|
||
}, [yardWagons, yardId, typeInfo]);
|
||
|
||
const matching = useMemo(() => {
|
||
if (!yardId || !typeId) return [] as Wagon[];
|
||
return yardWagons.filter((w) => w.currentYardId === yardId && w.wagonTypeId === typeId);
|
||
}, [yardWagons, yardId, typeId]);
|
||
|
||
// Wagons coupled to a built train (trainId set) are managed through the
|
||
// train-builder flow — they can't be bulk-flipped or transferred here, so
|
||
// keep them out of the action pools and bucket them under "Other".
|
||
const availableWagons = useMemo(
|
||
() => matching.filter((w) => w.status === AVAILABLE && !w.trainId),
|
||
[matching],
|
||
);
|
||
const assignedWagons = useMemo(
|
||
() => matching.filter((w) => w.status === ASSIGNED && !w.trainId),
|
||
[matching],
|
||
);
|
||
const otherWagons = useMemo(
|
||
() =>
|
||
matching.filter(
|
||
(w) => w.trainId != null || (w.status !== AVAILABLE && w.status !== ASSIGNED),
|
||
),
|
||
[matching],
|
||
);
|
||
const total = matching.length;
|
||
const availableCount = availableWagons.length;
|
||
const assignedCount = assignedWagons.length;
|
||
const otherCount = otherWagons.length;
|
||
// Split "Other" so a coupled wagon is visible as such. The Available/Assigned
|
||
// buckets deliberately count only UNCOUPLED wagons (see above), so a yard
|
||
// holding 54 assigned wagons of which 53 are on a train shows "Assigned 1" —
|
||
// accurate for shunting, but unreadable unless the other 53 are named.
|
||
const onTrainCount = useMemo(
|
||
() => matching.filter((w) => w.trainId != null).length,
|
||
[matching],
|
||
);
|
||
|
||
const destinationYardOptions = useMemo(
|
||
() =>
|
||
yards
|
||
.filter((y) => y.id !== yardId)
|
||
.map((y) => ({ value: y.id, label: y.label || y.code || y.id }))
|
||
.sort((a, b) => a.label.localeCompare(b.label)),
|
||
[yards, yardId],
|
||
);
|
||
|
||
const bothSelected = Boolean(yardId && typeId);
|
||
|
||
// Reset action inputs when the selection changes.
|
||
useEffect(() => {
|
||
setTransferYardId(null);
|
||
setTransferQty(0);
|
||
setTransferReason("");
|
||
setToAssignedQty(0);
|
||
setToAvailableQty(0);
|
||
}, [yardId, typeId]);
|
||
|
||
// Reset the whole workspace when closed.
|
||
useEffect(() => {
|
||
if (!opened) {
|
||
setYardId(null);
|
||
setTypeId(null);
|
||
}
|
||
}, [opened]);
|
||
|
||
// Keep quantities within bounds as counts shift after each action. A TRANSFER
|
||
// REQUEST is deliberately uncapped: OCC delivers in instalments, so asking for
|
||
// 50 where 20 sit today is normal — only the status flips below are bounded by
|
||
// what is physically in the yard.
|
||
useEffect(() => setToAssignedQty((q) => Math.min(q, availableCount)), [availableCount]);
|
||
useEffect(() => setToAvailableQty((q) => Math.min(q, assignedCount)), [assignedCount]);
|
||
|
||
const showError = (err: unknown, fallback: string) => {
|
||
const message =
|
||
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? fallback;
|
||
toast({ title: fallback, description: String(message), variant: "destructive" });
|
||
};
|
||
|
||
// 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 ||
|
||
!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(
|
||
yardId,
|
||
)} → ${yardName(transferYardId)}`,
|
||
description: "OCC will pick the wagons and complete the move.",
|
||
});
|
||
setTransferQty(0);
|
||
setTransferYardId(null);
|
||
setTransferReason("");
|
||
} catch (err) {
|
||
showError(err, "Request failed");
|
||
}
|
||
};
|
||
|
||
const handleFlip = async (
|
||
pool: Wagon[],
|
||
qty: number,
|
||
status: Freight.WagonStatus,
|
||
label: string,
|
||
reset: () => void,
|
||
) => {
|
||
if (qty < 1) return;
|
||
const ids = pool.slice(0, qty).map((w) => w.id);
|
||
if (!ids.length) return;
|
||
try {
|
||
const res = await setStatus.mutateAsync({ wagonIds: ids, status });
|
||
toast({ title: `${res.updated} wagon(s) set to ${label}` });
|
||
reset();
|
||
} catch (err) {
|
||
showError(err, "Status update failed");
|
||
}
|
||
};
|
||
|
||
const busy = createRequest.isPending || setStatus.isPending;
|
||
const pct = (n: number) => (total > 0 ? (n / total) * 100 : 0);
|
||
|
||
return (
|
||
<Modal
|
||
opened={opened}
|
||
onClose={onClose}
|
||
size="min(1080px, 96vw)"
|
||
radius="lg"
|
||
centered
|
||
overlayProps={{ blur: 2 }}
|
||
title={
|
||
<Group gap="sm">
|
||
<ThemeIcon variant="light" color="edr-green" radius="md" size="lg">
|
||
<Warehouse size={18} />
|
||
</ThemeIcon>
|
||
<div>
|
||
<Text fw={700}>Wagon Yard Operations</Text>
|
||
<Text size="xs" c="dimmed">
|
||
Move and re-status wagons in bulk — no one-by-one edits
|
||
</Text>
|
||
</div>
|
||
</Group>
|
||
}
|
||
>
|
||
<Stack gap="lg">
|
||
{/* ---- Selection ---- */}
|
||
<Card withBorder radius="md" padding="md" bg="var(--mantine-color-gray-0)">
|
||
<Grid gap="md" align="flex-end">
|
||
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||
<Select
|
||
label="Yard"
|
||
placeholder="Select a yard"
|
||
data={yardOptions}
|
||
value={yardId}
|
||
onChange={setYardId}
|
||
searchable
|
||
clearable
|
||
leftSection={<Warehouse size={16} />}
|
||
nothingFoundMessage="No yards with stock"
|
||
radius="md"
|
||
/>
|
||
</Grid.Col>
|
||
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||
<Select
|
||
label="Wagon type"
|
||
placeholder="Select a wagon type"
|
||
data={typeOptions}
|
||
value={typeId}
|
||
onChange={setTypeId}
|
||
searchable
|
||
clearable
|
||
leftSection={<Layers size={16} />}
|
||
nothingFoundMessage="No wagon types here"
|
||
radius="md"
|
||
/>
|
||
</Grid.Col>
|
||
</Grid>
|
||
</Card>
|
||
|
||
{isLoading ? (
|
||
<Group justify="center" p="xl">
|
||
<Loader />
|
||
</Group>
|
||
) : !bothSelected ? (
|
||
<Card withBorder radius="md" padding="xl">
|
||
<Stack align="center" gap={6}>
|
||
<ThemeIcon variant="light" color="gray" radius="xl" size={48}>
|
||
<Layers size={22} />
|
||
</ThemeIcon>
|
||
<Text fw={600}>Pick a yard and a wagon type</Text>
|
||
<Text size="sm" c="dimmed" ta="center" maw={420}>
|
||
You'll see how many wagons of that type sit in that yard, how many are available
|
||
vs assigned, and can move or re-status them all at once.
|
||
</Text>
|
||
</Stack>
|
||
</Card>
|
||
) : (
|
||
<>
|
||
{/* ---- Overview hero ---- */}
|
||
<Card withBorder radius="md" padding="lg">
|
||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
||
<Group gap="lg" align="center" wrap="nowrap">
|
||
<div>
|
||
<Text size="3rem" fw={800} lh={1}>
|
||
{total}
|
||
</Text>
|
||
</div>
|
||
<div>
|
||
<Text fw={700} size="lg">
|
||
{typeInfo.code(typeId!)} wagons
|
||
</Text>
|
||
<Group gap={6} c="dimmed">
|
||
<Warehouse size={14} />
|
||
<Text size="sm">{yardName(yardId!)}</Text>
|
||
</Group>
|
||
</div>
|
||
</Group>
|
||
<Group gap="lg" wrap="wrap">
|
||
<LegendDot color="teal" label="Available in yard" value={availableCount} />
|
||
<LegendDot color="blue" label="Assigned in yard" value={assignedCount} />
|
||
{onTrainCount > 0 ? (
|
||
<LegendDot color="gray" label="On train" value={onTrainCount} />
|
||
) : null}
|
||
{otherCount - onTrainCount > 0 ? (
|
||
<LegendDot color="gray" label="Other" value={otherCount - onTrainCount} />
|
||
) : null}
|
||
</Group>
|
||
</Group>
|
||
|
||
<Progress.Root size={22} radius="md" mt="md">
|
||
<Progress.Section value={pct(availableCount)} color="teal">
|
||
{availableCount > 0 ? <Progress.Label>{availableCount}</Progress.Label> : null}
|
||
</Progress.Section>
|
||
<Progress.Section value={pct(assignedCount)} color="blue">
|
||
{assignedCount > 0 ? <Progress.Label>{assignedCount}</Progress.Label> : null}
|
||
</Progress.Section>
|
||
<Progress.Section value={pct(otherCount)} color="gray">
|
||
{otherCount > 0 ? <Progress.Label>{otherCount}</Progress.Label> : null}
|
||
</Progress.Section>
|
||
</Progress.Root>
|
||
</Card>
|
||
|
||
{/* ---- Actions ---- */}
|
||
<Grid gap="lg">
|
||
{/* Transfer */}
|
||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||
<Card withBorder radius="md" h="100%" padding="lg">
|
||
<Group gap="xs" mb={4}>
|
||
<ThemeIcon variant="light" color="grape" radius="md" size="md">
|
||
<ArrowRightLeft size={16} />
|
||
</ThemeIcon>
|
||
<Text fw={700}>Request transfer to another yard</Text>
|
||
</Group>
|
||
<Text size="xs" c="dimmed" mb="md">
|
||
Sends a request to OCC — they pick the wagons and complete the move.
|
||
</Text>
|
||
<Stack gap="md">
|
||
<div>
|
||
<Group justify="space-between" mb={4}>
|
||
<Text size="sm" fw={500}>
|
||
How many wagons
|
||
</Text>
|
||
<Badge color="teal" variant="light">
|
||
{availableCount} available
|
||
</Badge>
|
||
</Group>
|
||
{/* No max: the request may exceed what the yard holds
|
||
today — OCC fulfils it in instalments. */}
|
||
<QuantityField value={transferQty} onChange={setTransferQty} />
|
||
</div>
|
||
<Select
|
||
label="Destination yard"
|
||
placeholder="Select destination"
|
||
data={destinationYardOptions}
|
||
value={transferYardId}
|
||
onChange={setTransferYardId}
|
||
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">
|
||
<Text size="sm" fw={600}>
|
||
{yardName(yardId!)} {total}
|
||
<Text span c="red.6" fw={700}>
|
||
{" "}
|
||
−{transferQty}
|
||
</Text>
|
||
</Text>
|
||
<ArrowRight size={16} />
|
||
<Text size="sm" fw={600}>
|
||
{yardName(transferYardId)}
|
||
<Text span c="teal.7" fw={700}>
|
||
{" "}
|
||
+{transferQty}
|
||
</Text>
|
||
</Text>
|
||
</Group>
|
||
</Card>
|
||
) : null}
|
||
<Button
|
||
leftSection={<ArrowRightLeft size={16} />}
|
||
onClick={handleRequest}
|
||
loading={createRequest.isPending}
|
||
disabled={
|
||
busy ||
|
||
!transferYardId ||
|
||
transferQty < 1 ||
|
||
!transferReason.trim()
|
||
}
|
||
color="edr-green"
|
||
>
|
||
Request {transferQty > 0 ? `${transferQty} ` : ""}wagon
|
||
{transferQty === 1 ? "" : "s"}
|
||
</Button>
|
||
</Stack>
|
||
</Card>
|
||
</Grid.Col>
|
||
|
||
{/* Re-status */}
|
||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||
<Card withBorder radius="md" h="100%" padding="lg">
|
||
<Group gap="xs" mb="md">
|
||
<ThemeIcon variant="light" color="orange" radius="md" size="md">
|
||
<ArrowRightLeft size={16} />
|
||
</ThemeIcon>
|
||
<Text fw={700}>Change availability</Text>
|
||
</Group>
|
||
<Stack gap="lg">
|
||
<Box>
|
||
<Group justify="space-between" mb={6}>
|
||
<Group gap={6}>
|
||
<ThemeIcon variant="light" color="blue" radius="sm" size="sm">
|
||
<CircleSlash size={12} />
|
||
</ThemeIcon>
|
||
<Text size="sm" fw={600}>
|
||
Available → Assigned
|
||
</Text>
|
||
</Group>
|
||
<Badge color="teal" variant="light">
|
||
{availableCount} free
|
||
</Badge>
|
||
</Group>
|
||
<QuantityField
|
||
value={toAssignedQty}
|
||
onChange={setToAssignedQty}
|
||
max={availableCount}
|
||
/>
|
||
<Button
|
||
mt="sm"
|
||
fullWidth
|
||
variant="light"
|
||
color="blue"
|
||
disabled={busy || toAssignedQty < 1}
|
||
loading={setStatus.isPending}
|
||
onClick={() =>
|
||
handleFlip(availableWagons, toAssignedQty, ASSIGNED, "Assigned", () =>
|
||
setToAssignedQty(0),
|
||
)
|
||
}
|
||
>
|
||
Assign {toAssignedQty > 0 ? `${toAssignedQty} ` : ""}wagon
|
||
{toAssignedQty === 1 ? "" : "s"}
|
||
</Button>
|
||
</Box>
|
||
|
||
<Divider variant="dashed" />
|
||
|
||
<Box>
|
||
<Group justify="space-between" mb={6}>
|
||
<Group gap={6}>
|
||
<ThemeIcon variant="light" color="teal" radius="sm" size="sm">
|
||
<CheckCircle2 size={12} />
|
||
</ThemeIcon>
|
||
<Text size="sm" fw={600}>
|
||
Assigned → Available
|
||
</Text>
|
||
</Group>
|
||
<Badge color="blue" variant="light">
|
||
{assignedCount} assigned
|
||
</Badge>
|
||
</Group>
|
||
<QuantityField
|
||
value={toAvailableQty}
|
||
onChange={setToAvailableQty}
|
||
max={assignedCount}
|
||
/>
|
||
<Button
|
||
mt="sm"
|
||
fullWidth
|
||
variant="light"
|
||
color="teal"
|
||
disabled={busy || toAvailableQty < 1}
|
||
loading={setStatus.isPending}
|
||
onClick={() =>
|
||
handleFlip(assignedWagons, toAvailableQty, AVAILABLE, "Available", () =>
|
||
setToAvailableQty(0),
|
||
)
|
||
}
|
||
>
|
||
Free up {toAvailableQty > 0 ? `${toAvailableQty} ` : ""}wagon
|
||
{toAvailableQty === 1 ? "" : "s"}
|
||
</Button>
|
||
</Box>
|
||
</Stack>
|
||
</Card>
|
||
</Grid.Col>
|
||
</Grid>
|
||
</>
|
||
)}
|
||
</Stack>
|
||
</Modal>
|
||
);
|
||
};
|
||
|
||
export default WagonYardWorkspaceModal;
|