mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 05:25:41 +00:00
Merge branch 'dev' into freight/nati-2
# Conflicts: # apps/edr-freight-api/src/app.module.ts # apps/edr-freight-api/src/seed/freight-permissions.registry.ts # apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx # apps/edr-freight-web/backoffice/src/constants/URLS.ts # apps/edr-freight-web/backoffice/src/lib/permissions.ts
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
||||
FolderOpen,
|
||||
Layers,
|
||||
LayoutGrid,
|
||||
Link2,
|
||||
Milestone,
|
||||
MoreHorizontal,
|
||||
Package,
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
@@ -47,6 +49,7 @@ import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge
|
||||
import { NextStepBanner } from "@/components/bookings/NextStepBanner";
|
||||
import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
|
||||
import { ConsolidationWaitingBanner } from "@/components/bookings/detail/ConsolidationWaitingBanner";
|
||||
import { ConsolidationApprovalCard } from "@/components/bookings/detail/ConsolidationApprovalCard";
|
||||
import {
|
||||
detailStyles,
|
||||
BookingRouteServiceCard,
|
||||
@@ -79,14 +82,50 @@ export default function BookingRequestDetailPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
// Deep-link from a warehouse fee invoice → this booking's warehouse section.
|
||||
useScrollToHash();
|
||||
|
||||
// Consolidated pair: `?booking=<partnerId>` swaps the WHOLE page over to the
|
||||
// other half of the shared wagon. Everything below — KPIs, stepper, the
|
||||
// overview/orders/documents/trucks sub-tabs, the action toolbar — then reads
|
||||
// from the selected booking, so each half gets its own complete detail page
|
||||
// under a top-level tab. The URL id stays put so Back still works.
|
||||
const selectedId = searchParams.get("booking") || id;
|
||||
const {
|
||||
data: booking,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
isFetching,
|
||||
} = useBookingDetail(id);
|
||||
const mutations = useBookingMutations(id ?? "");
|
||||
} = useBookingDetail(selectedId);
|
||||
const mutations = useBookingMutations(selectedId ?? "");
|
||||
|
||||
// The pair is discovered from whichever half is on screen: each booking
|
||||
// carries a reference to the other.
|
||||
const routeBookingId = id ?? "";
|
||||
const partnerId = booking?.consolidationPartnerId ?? null;
|
||||
const isPaired = Boolean(partnerId);
|
||||
const viewingPartner = selectedId !== routeBookingId;
|
||||
// Tab identities: the booking named by the URL is always the first tab, the
|
||||
// other half the second — regardless of which one is currently displayed.
|
||||
const firstTabId = routeBookingId;
|
||||
const secondTabId = viewingPartner ? selectedId : partnerId;
|
||||
|
||||
// Only for the tab label (reference + customer) — the displayed half is
|
||||
// loaded above. Skipped entirely when the booking is not part of a pair.
|
||||
const { data: otherBooking } = useBookingDetail(
|
||||
secondTabId && secondTabId !== selectedId ? secondTabId : undefined,
|
||||
);
|
||||
const firstTabBooking = viewingPartner ? otherBooking : booking;
|
||||
const secondTabBooking = viewingPartner ? booking : otherBooking;
|
||||
|
||||
const selectBooking = (bookingId: string) => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
if (bookingId === routeBookingId) next.delete("booking");
|
||||
else next.set("booking", bookingId);
|
||||
// Switching booking resets the sub-tab: the other half has its own content
|
||||
// and may not even have the tab that was open (e.g. Orders).
|
||||
next.delete("tab");
|
||||
setSearchParams(next, { replace: true });
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -349,6 +388,48 @@ export default function BookingRequestDetailPage() {
|
||||
/>
|
||||
|
||||
<Stack gap="lg">
|
||||
{/* Consolidated pair: one tab per booking, switching the ENTIRE page
|
||||
below. The overview/orders/documents/trucks tabs further down are
|
||||
sub-tabs of whichever booking is selected here. */}
|
||||
{isPaired && secondTabId ? (
|
||||
<Tabs
|
||||
value={selectedId ?? undefined}
|
||||
onChange={(value) => value && selectBooking(value)}
|
||||
variant="pills"
|
||||
radius="md"
|
||||
>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value={firstTabId} leftSection={<Link2 size={15} />}>
|
||||
<Stack gap={0} align="flex-start">
|
||||
<Text fz={13} fw={700}>
|
||||
{firstTabBooking?.reference ?? "Booking"}
|
||||
</Text>
|
||||
<Text fz={11} c="dimmed">
|
||||
{firstTabBooking?.company?.name ?? "—"}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value={secondTabId} leftSection={<Link2 size={15} />}>
|
||||
<Stack gap={0} align="flex-start">
|
||||
<Text fz={13} fw={700}>
|
||||
{secondTabBooking?.reference ?? "Partner booking"}
|
||||
</Text>
|
||||
<Text fz={11} c="dimmed">
|
||||
{secondTabBooking?.company?.name ?? "—"}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
) : null}
|
||||
|
||||
{isPaired ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
These two bookings share one wagon. Accepting or cancelling applies
|
||||
to both; each is invoiced and paid separately.
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<KpiStrip items={kpis} />
|
||||
|
||||
{booking.holdExpiresAt && booking.schedulingStatus === "HOLDING" ? (
|
||||
@@ -381,6 +462,21 @@ export default function BookingRequestDetailPage() {
|
||||
<ConsolidationWaitingBanner bookingId={booking.id} />
|
||||
)}
|
||||
|
||||
{booking.status === "CONSOLIDATION_APPROVAL_PENDING" && (
|
||||
<Alert
|
||||
color="yellow"
|
||||
radius="md"
|
||||
icon={<Link2 size={18} />}
|
||||
title="Waiting for shared-wagon approval"
|
||||
>
|
||||
<Text size="sm">
|
||||
This booking shares a wagon with another customer's booking.
|
||||
Both are held here until the pairing is approved — neither reaches
|
||||
Operations before then.
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Grid gap="lg">
|
||||
{/* LEFT — primary content, split into tabs to keep each view focused.
|
||||
The Documents tab is always present, so the tab bar always renders. */}
|
||||
@@ -455,6 +551,8 @@ export default function BookingRequestDetailPage() {
|
||||
that train and its clock must be readable before the approve
|
||||
button. */}
|
||||
<BookingSchedulingWindowCard booking={booking} />
|
||||
{/* Renders itself only when this booking has a shared wagon. */}
|
||||
<ConsolidationApprovalCard bookingId={booking.id} />
|
||||
<BookingActionsToolbar
|
||||
booking={booking}
|
||||
mutations={mutations}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
LayoutList,
|
||||
Link2,
|
||||
Package,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
@@ -211,10 +212,28 @@ export default function BookingRequestsPage() {
|
||||
|
||||
// Search is applied server-side (via the `search` filter param) — no
|
||||
// client-side filtering here.
|
||||
const rows = useMemo(
|
||||
() => (data?.items ?? []).map(toBookingListRow),
|
||||
[data?.items],
|
||||
);
|
||||
const rows = useMemo(() => {
|
||||
const mapped = (data?.items ?? []).map(toBookingListRow);
|
||||
// Consolidated pairs share one wagon and are decided together, so they show
|
||||
// as ONE row. Keep the half that appears first in the current sort and hang
|
||||
// the other on it as `pairedWith`; the row renders both bookings' details
|
||||
// and opens the detail page, where each half gets its own tab.
|
||||
const byId = new Map(mapped.map((row) => [row.id, row]));
|
||||
const absorbed = new Set<string>();
|
||||
const merged: BookingListRow[] = [];
|
||||
for (const row of mapped) {
|
||||
if (absorbed.has(row.id)) continue;
|
||||
const partnerId = row.consolidationPartnerId;
|
||||
const partner = partnerId ? byId.get(partnerId) : undefined;
|
||||
if (partner && !absorbed.has(partner.id)) {
|
||||
absorbed.add(partner.id);
|
||||
merged.push({ ...row, pairedWith: partner });
|
||||
continue;
|
||||
}
|
||||
merged.push(row);
|
||||
}
|
||||
return merged;
|
||||
}, [data?.items]);
|
||||
|
||||
const total = data?.total ?? 0;
|
||||
const hasSearch = controls.searchText.trim().length > 0;
|
||||
@@ -332,6 +351,22 @@ export default function BookingRequestsPage() {
|
||||
</Badge>
|
||||
) : null}
|
||||
</p>
|
||||
{/* Shared wagon: the second booking rides in the same row, so the
|
||||
operator sees both customers before opening the pair. */}
|
||||
{b.pairedWith ? (
|
||||
<div className="mt-1.5 border-l-2 border-muted pl-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Link2 className="size-3 shrink-0 opacity-70" />
|
||||
<p className="truncate text-xs font-medium text-foreground">
|
||||
{b.pairedWith.reference}
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
|
||||
<User className="size-3 shrink-0 opacity-70" />
|
||||
{b.pairedWith.customerLabel}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { AlertCircle, Check, Clock, Link2, X } from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import {
|
||||
bookingsService,
|
||||
type ConsolidationApprovalRow,
|
||||
} from "@/services/bookings.service";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { extractErrorMessage } from "@/utils/errorExtractor";
|
||||
|
||||
const QUEUE_KEY = ["consolidation-approvals", "queue"];
|
||||
|
||||
/**
|
||||
* Review queue for shared-wagon pairings.
|
||||
*
|
||||
* A booking that fills its own wagons goes straight to Operations. A
|
||||
* consolidated one waits here: two customers' cargo rides one physical wagon
|
||||
* under two separate invoices, so a person signs off on the pairing first.
|
||||
* Approving releases BOTH bookings to Operations; rejecting sends BOTH back to
|
||||
* GL with the reason.
|
||||
*/
|
||||
export default function ConsolidationApprovalsPage() {
|
||||
const qc = useQueryClient();
|
||||
const [decision, setDecision] = useState<{
|
||||
row: ConsolidationApprovalRow;
|
||||
kind: "approve" | "reject";
|
||||
} | null>(null);
|
||||
const [note, setNote] = useState("");
|
||||
|
||||
const {
|
||||
data: rows,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery({
|
||||
queryKey: QUEUE_KEY,
|
||||
queryFn: () => bookingsService.consolidationApprovalQueue(),
|
||||
});
|
||||
|
||||
const close = () => {
|
||||
setDecision(null);
|
||||
setNote("");
|
||||
};
|
||||
|
||||
const decide = useMutation({
|
||||
mutationFn: () => {
|
||||
if (!decision) throw new Error("No pairing selected");
|
||||
return decision.kind === "approve"
|
||||
? bookingsService.approveConsolidation(decision.row.id, note.trim() || undefined)
|
||||
: bookingsService.rejectConsolidation(decision.row.id, note.trim());
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(
|
||||
decision?.kind === "approve"
|
||||
? "Shared wagon approved — both bookings sent to Operations"
|
||||
: "Shared wagon rejected — both bookings returned to GL",
|
||||
);
|
||||
void qc.invalidateQueries({ queryKey: QUEUE_KEY });
|
||||
close();
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(extractErrorMessage(error, "Could not record the decision")),
|
||||
});
|
||||
|
||||
// A rejection has to tell GL what to fix, so the reason is mandatory there.
|
||||
const confirmDisabled =
|
||||
decide.isPending || (decision?.kind === "reject" && !note.trim());
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Shared wagon approvals"
|
||||
subtitle="Two customers' cargo on one wagon — review the pairing before it reaches Operations."
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<Center py={80}>
|
||||
<Loader color="edr-green" />
|
||||
</Center>
|
||||
) : isError ? (
|
||||
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
|
||||
Could not load the approval queue.
|
||||
</Alert>
|
||||
) : !rows?.length ? (
|
||||
<Alert color="gray" radius="md" icon={<Check size={16} />}>
|
||||
Nothing waiting for approval.
|
||||
</Alert>
|
||||
) : (
|
||||
<Stack gap="md">
|
||||
{rows.map((row) => (
|
||||
<Paper
|
||||
key={row.id}
|
||||
withBorder
|
||||
radius="lg"
|
||||
p="lg"
|
||||
style={{ borderColor: "#E6ECF2" }}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
||||
<Box style={{ minWidth: 0, flex: 1 }}>
|
||||
<Group gap={8} align="center" mb={10}>
|
||||
<ThemeIcon variant="light" color="blue" radius="md" size={30}>
|
||||
<Link2 size={16} />
|
||||
</ThemeIcon>
|
||||
<Text fw={800} fz={15}>
|
||||
Shared wagon
|
||||
</Text>
|
||||
<Badge color="yellow" variant="light" radius="sm">
|
||||
Awaiting approval
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Group gap="xl" wrap="wrap">
|
||||
<BookingSide
|
||||
id={row.bookingId}
|
||||
reference={row.booking?.reference ?? row.bookingReference}
|
||||
company={row.booking?.company?.name}
|
||||
/>
|
||||
<BookingSide
|
||||
id={row.partnerBookingId}
|
||||
reference={
|
||||
row.partnerBooking?.reference ??
|
||||
row.partnerBookingReference
|
||||
}
|
||||
company={row.partnerBooking?.company?.name}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group gap={6} mt={12} c="dimmed">
|
||||
<Clock size={13} />
|
||||
<Text fz={12}>
|
||||
Requested {formatDateTime(row.requestedAt)}
|
||||
{row.scheduledDate
|
||||
? ` · ships ${formatDateTime(row.scheduledDate)}`
|
||||
: ""}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Check size={15} />}
|
||||
onClick={() => {
|
||||
setDecision({ row, kind: "approve" });
|
||||
setNote("");
|
||||
}}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
variant="light"
|
||||
radius="md"
|
||||
leftSection={<X size={15} />}
|
||||
onClick={() => {
|
||||
setDecision({ row, kind: "reject" });
|
||||
setNote("");
|
||||
}}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
opened={Boolean(decision)}
|
||||
onClose={() => {
|
||||
if (!decide.isPending) close();
|
||||
}}
|
||||
centered
|
||||
radius="lg"
|
||||
title={
|
||||
<Text fw={800} fz={16}>
|
||||
{decision?.kind === "approve"
|
||||
? "Approve this shared wagon?"
|
||||
: "Reject this shared wagon?"}
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text fz="sm" c="dimmed">
|
||||
{decision?.kind === "approve"
|
||||
? "Both bookings leave the gate together and continue to Operations. Each is still invoiced and paid separately."
|
||||
: "Both bookings go back to GL as “changes requested” with your reason. Neither reaches Operations."}
|
||||
</Text>
|
||||
|
||||
<Textarea
|
||||
label={
|
||||
decision?.kind === "approve"
|
||||
? "Note (optional)"
|
||||
: "Reason (required)"
|
||||
}
|
||||
description={
|
||||
decision?.kind === "approve"
|
||||
? "Recorded with the approval for the audit trail."
|
||||
: "GL sees this on both bookings — say what has to change."
|
||||
}
|
||||
placeholder={
|
||||
decision?.kind === "approve"
|
||||
? "Anything worth recording…"
|
||||
: "e.g. the partner's cargo weights are unbalanced for one wagon"
|
||||
}
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.currentTarget.value)}
|
||||
autosize
|
||||
minRows={3}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
onClick={close}
|
||||
disabled={decide.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color={decision?.kind === "approve" ? "edr-green" : "red"}
|
||||
radius="md"
|
||||
loading={decide.isPending}
|
||||
disabled={confirmDisabled}
|
||||
onClick={() => decide.mutate()}
|
||||
>
|
||||
{decision?.kind === "approve" ? "Approve both" : "Reject both"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
/** One half of the wagon: its reference (linked) and whose cargo it is. */
|
||||
function BookingSide({
|
||||
id,
|
||||
reference,
|
||||
company,
|
||||
}: {
|
||||
id: string;
|
||||
reference?: string | null;
|
||||
company?: string | null;
|
||||
}) {
|
||||
return (
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text
|
||||
component={Link}
|
||||
to={`/dashboard/booking-requests/${id}`}
|
||||
fz={14}
|
||||
fw={700}
|
||||
c="blue.7"
|
||||
style={{ textDecoration: "none" }}
|
||||
>
|
||||
{reference ?? "—"}
|
||||
</Text>
|
||||
<Text fz={12.5} c="dimmed">
|
||||
{company ?? "—"}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -105,8 +105,15 @@ export default function DocumentClearanceDetailPage() {
|
||||
const queried = docs.filter((d) => d.reviewStatus === "QUERIED").length;
|
||||
const pending = total - approved - queried;
|
||||
const pct = total === 0 ? 0 : Math.round((approved / total) * 100);
|
||||
return { total, approved, queried, pending, pct };
|
||||
// Documents actually sitting with GL: a file is present but not approved.
|
||||
// Excludes required slots the customer never filled — those are on the
|
||||
// customer, not on GL.
|
||||
const awaitingReview = docs.filter(
|
||||
(d) => d.file && d.reviewStatus !== "APPROVED",
|
||||
).length;
|
||||
return { total, approved, queried, pending, pct, awaitingReview };
|
||||
}, [clearance]);
|
||||
const awaitingReview = stats.awaitingReview;
|
||||
|
||||
const reference = booking?.reference ?? "Clearance";
|
||||
// Phased customs clearance runs on every contract booking now — ONE_TIME and
|
||||
@@ -149,24 +156,12 @@ export default function DocumentClearanceDetailPage() {
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
)[0]?.note ?? null;
|
||||
|
||||
const docsPhaseComplete =
|
||||
clearance?.milestones?.some(
|
||||
(m) => m.milestoneCode === "DOCUMENTS_APPROVED" && m.status === "COMPLETED",
|
||||
) ?? false;
|
||||
// Querying a document is only possible while the booking is actually in
|
||||
// review — the server enforces exactly that (reviewDocument asserts
|
||||
// DOCUMENTS_UNDER_REVIEW), so once clearance is finalized the button could
|
||||
// only ever produce a 400.
|
||||
//
|
||||
// `preClearanceFinalized` alone was not enough: it is a phased-customs field,
|
||||
// so a non-customs booking (self-clearance, and every shipping-line booking)
|
||||
// never sets it and kept offering Query after Operations had finalized.
|
||||
const queriesLocked =
|
||||
Boolean(
|
||||
(clearance as Freight.ContractClearanceView | undefined)
|
||||
?.preClearanceFinalized,
|
||||
) ||
|
||||
(booking?.status != null && booking.status !== "DOCUMENTS_UNDER_REVIEW");
|
||||
// Documents stay reviewable for as long as the customer can still submit
|
||||
// them — until the shipment is paid, not merely until clearance is
|
||||
// finalized. `documentsOpen` is the server's own predicate (the same one
|
||||
// both the upload and review endpoints gate on), so the buttons are shown
|
||||
// exactly when the API would accept them.
|
||||
const documentsClosed = clearance?.documentsOpen === false;
|
||||
const workflowFiles =
|
||||
(clearance as Freight.ContractClearanceView | undefined)?.workflowFiles ?? [];
|
||||
|
||||
@@ -231,7 +226,19 @@ export default function DocumentClearanceDetailPage() {
|
||||
Customs
|
||||
</Badge>
|
||||
) : null}
|
||||
{clearance.allApproved ? (
|
||||
{/* Waiting on GL: an uploaded document with no decision yet, or
|
||||
one under query. `allApproved` only covers the REQUIRED set,
|
||||
so an ad-hoc file added after clearance never moves it. */}
|
||||
{awaitingReview > 0 ? (
|
||||
<Badge
|
||||
variant="filled"
|
||||
color="orange"
|
||||
radius="sm"
|
||||
leftSection={<Clock size={13} />}
|
||||
>
|
||||
{awaitingReview} needs approval
|
||||
</Badge>
|
||||
) : clearance.allApproved ? (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
@@ -346,8 +353,8 @@ export default function DocumentClearanceDetailPage() {
|
||||
<ClearanceReviewSection
|
||||
bookingId={id!}
|
||||
hideSummary
|
||||
approvalsLocked={isPhasedGeneral && docsPhaseComplete}
|
||||
queriesLocked={queriesLocked}
|
||||
approvalsLocked={documentsClosed}
|
||||
queriesLocked={documentsClosed}
|
||||
phasedCustoms={isPhasedGeneral}
|
||||
onChanged={() => void refetch()}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Alert, Button, Card, Center, Stack, Text } from "@mantine/core";
|
||||
import { MessageSquare, TriangleAlert } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import { chatApi } from "@/features/chat/chatApi";
|
||||
|
||||
/**
|
||||
* Chat itself lives at chat.edr.et (Element), not in this app — this page's
|
||||
* only job is a one-click sign-in link into it. No iframe: Element's own CSP
|
||||
* refuses to be framed.
|
||||
*
|
||||
* The link is minted per click, never on mount and never cached: Synapse's
|
||||
* login_token is single-use and expires in 5 minutes, and Element reports a
|
||||
* spent one as "Incorrect username and/or password". A held-onto url is
|
||||
* therefore wrong on the second click, on a remount served from cache, and on
|
||||
* any click more than 5 minutes after the page loaded.
|
||||
*/
|
||||
export default function ChatLaunchPage() {
|
||||
const [state, setState] = useState<"idle" | "loading" | "error">("idle");
|
||||
|
||||
const open = async () => {
|
||||
// Opened before the await so it still counts as the user's click — a
|
||||
// window.open() after it is treated as a popup and blocked.
|
||||
//
|
||||
// No "noopener" in the features: passing it makes window.open return null,
|
||||
// which would leave this blank tab orphaned and send Element into the
|
||||
// current tab instead. Clearing .opener on the handle does the same job.
|
||||
const tab = window.open("", "_blank");
|
||||
if (tab) tab.opener = null;
|
||||
setState("loading");
|
||||
try {
|
||||
const url = await chatApi.getSsoUrl();
|
||||
if (tab) tab.location.replace(url);
|
||||
else window.location.assign(url); // popup blocked — go in this tab
|
||||
setState("idle");
|
||||
} catch {
|
||||
tab?.close();
|
||||
setState("error");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader title="Chat" subtitle="Internal messaging for EDR staff" />
|
||||
<Card withBorder radius="md" p="xl">
|
||||
<Center>
|
||||
<Stack align="center" gap="md" py="xl">
|
||||
{state === "error" && (
|
||||
<Alert
|
||||
icon={<TriangleAlert size={18} />}
|
||||
color="red"
|
||||
title="Couldn't get a sign-in link"
|
||||
variant="light"
|
||||
>
|
||||
Something went wrong reaching chat. Try again.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Stack align="center" gap="sm">
|
||||
<MessageSquare size={40} strokeWidth={1.5} />
|
||||
<Text c="dimmed" ta="center" maw={360}>
|
||||
Opens EDR Chat in a new tab, already signed in as you.
|
||||
</Text>
|
||||
<Button
|
||||
onClick={open}
|
||||
loading={state === "loading"}
|
||||
leftSection={<MessageSquare size={16} />}
|
||||
>
|
||||
Open EDR Chat
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Center>
|
||||
</Card>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -164,6 +164,8 @@ export default function ContractClearanceListPage() {
|
||||
tradeDirection: b.tradeDirection ?? "—",
|
||||
freightType: b.freightType ?? "—",
|
||||
status: b.status,
|
||||
allDocsApproved: Boolean(b.allDocsApproved),
|
||||
hasDocumentsAwaitingReview: Boolean(b.hasDocumentsAwaitingReview),
|
||||
requested: requestedByBooking.get(b.id) ?? null,
|
||||
contractId: b.contractId ?? null,
|
||||
contractReference: b.contractReference ?? null,
|
||||
@@ -193,8 +195,13 @@ export default function ContractClearanceListPage() {
|
||||
const counts = useMemo(
|
||||
() => ({
|
||||
all: allRows.length,
|
||||
// Counts anything actually waiting on GL, including a document added
|
||||
// after clearance was finalized (the status stays CLEARANCE_READY).
|
||||
review: allRows.filter(
|
||||
(r) => r.status === "AWAITING_DOCUMENTS" || r.status === "DOCUMENTS_UNDER_REVIEW",
|
||||
(r) =>
|
||||
r.status === "AWAITING_DOCUMENTS" ||
|
||||
r.status === "DOCUMENTS_UNDER_REVIEW" ||
|
||||
r.hasDocumentsAwaitingReview,
|
||||
).length,
|
||||
ready: allRows.filter((r) => r.status === "CLEARANCE_READY" || r.bookingCreated)
|
||||
.length,
|
||||
@@ -344,6 +351,10 @@ interface ShipmentBookingRow {
|
||||
tradeDirection: string;
|
||||
freightType: string;
|
||||
status: string;
|
||||
/** Every required document approved, even before clearance is finalized. */
|
||||
allDocsApproved: boolean;
|
||||
/** A customer document is waiting on GL — including one added post-clearance. */
|
||||
hasDocumentsAwaitingReview: boolean;
|
||||
/** Requested quantities from the originating shipment request. */
|
||||
requested: Freight.RequestedShipmentLines | null;
|
||||
/** Contract this shipment booking was created under. */
|
||||
@@ -518,13 +529,29 @@ function ShipmentBookingsTable({
|
||||
header: () => <span className={bookingTable.headerCell}>Status</span>,
|
||||
cell: ({ row }) => (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Badge
|
||||
variant="light"
|
||||
color={shipmentStatusColor(row.original.status)}
|
||||
radius="sm"
|
||||
>
|
||||
{prettyStatus(row.original.status)}
|
||||
</Badge>
|
||||
{/* A document is waiting on GL. This outranks the booking status:
|
||||
a file added after clearance was finalized leaves the status at
|
||||
CLEARANCE_READY, and the row must still call for the review. */}
|
||||
{row.original.hasDocumentsAwaitingReview ? (
|
||||
<Badge variant="filled" color="orange" radius="sm">
|
||||
Needs approval
|
||||
</Badge>
|
||||
) : /* All docs approved but not yet finalized: the booking status is
|
||||
still DOCUMENTS_UNDER_REVIEW — show the real review state. */
|
||||
row.original.status === "DOCUMENTS_UNDER_REVIEW" &&
|
||||
row.original.allDocsApproved ? (
|
||||
<Badge variant="light" color="edr-green" radius="sm">
|
||||
Documents approved
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="light"
|
||||
color={shipmentStatusColor(row.original.status)}
|
||||
radius="sm"
|
||||
>
|
||||
{prettyStatus(row.original.status)}
|
||||
</Badge>
|
||||
)}
|
||||
{row.original.bookingCreated ? (
|
||||
<Tooltip label="Booking created by GL Ethiopia" withArrow>
|
||||
<Badge
|
||||
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
AlertTriangle,
|
||||
ClipboardList,
|
||||
FileText,
|
||||
History,
|
||||
Receipt,
|
||||
Share2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
@@ -36,6 +38,8 @@ import { BookingCompanyCard } from "@/components/bookings/detail/BookingCompanyC
|
||||
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
|
||||
import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel";
|
||||
import { GlExchangePanel } from "@/components/contracts/GlExchangePanel";
|
||||
import { ClearanceChargesTab } from "@/components/contracts/ClearanceChargesTab";
|
||||
import { ClearanceHistoryTab } from "@/components/contracts/ClearanceHistoryTab";
|
||||
import {
|
||||
GlClearanceUploadModal,
|
||||
type GlClearanceUploadKind,
|
||||
@@ -247,6 +251,16 @@ export default function GlClearanceDetailPage() {
|
||||
<Tabs.Tab value="exchange" leftSection={<Share2 size={14} />}>
|
||||
Document exchange
|
||||
</Tabs.Tab>
|
||||
{data.kind === "booking" ? (
|
||||
<Tabs.Tab value="charges" leftSection={<Receipt size={14} />}>
|
||||
Customer charges
|
||||
</Tabs.Tab>
|
||||
) : null}
|
||||
{data.kind === "booking" ? (
|
||||
<Tabs.Tab value="history" leftSection={<History size={14} />}>
|
||||
History
|
||||
</Tabs.Tab>
|
||||
) : null}
|
||||
{incidentBookingId ? (
|
||||
<Tabs.Tab value="incidents" leftSection={<AlertTriangle size={14} />}>
|
||||
Incidents
|
||||
@@ -369,6 +383,18 @@ export default function GlClearanceDetailPage() {
|
||||
<GlExchangePanel entityId={id!} />
|
||||
</Tabs.Panel>
|
||||
|
||||
{data.kind === "booking" ? (
|
||||
<Tabs.Panel value="charges">
|
||||
<ClearanceChargesTab bookingId={id!} roleMode="DJ" onViewFile={view} />
|
||||
</Tabs.Panel>
|
||||
) : null}
|
||||
|
||||
{data.kind === "booking" ? (
|
||||
<Tabs.Panel value="history">
|
||||
<ClearanceHistoryTab bookingId={id!} />
|
||||
</Tabs.Panel>
|
||||
) : null}
|
||||
|
||||
{incidentBookingId ? (
|
||||
<Tabs.Panel value="incidents">
|
||||
<SectionCard icon={AlertTriangle} title="Incident reports" accent="edr-green">
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
CompanyTimeline,
|
||||
CompanyTypeBadge,
|
||||
InvoiceStatusBadge,
|
||||
ManualRegistrationBadge,
|
||||
PaymentStatusBadge,
|
||||
PersonCard,
|
||||
ProfileApprovalActions,
|
||||
@@ -744,6 +745,10 @@ export default function CustomerDetailPage() {
|
||||
) : (
|
||||
<CompanyStatusBadge status={company.status} />
|
||||
)}
|
||||
<ManualRegistrationBadge
|
||||
cooperative={company.cooperative}
|
||||
investorLicence={company.investorLicence}
|
||||
/>
|
||||
<ChangeRequestPendingBadge companyId={company.id} />
|
||||
</Group>
|
||||
}
|
||||
@@ -792,6 +797,25 @@ export default function CustomerDetailPage() {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Nothing below came from eTrade for these customers. A
|
||||
co-operative holds no trade licence at all; a foreign investor's
|
||||
comes from the Investment Commission, not the trade registry.
|
||||
Either way every registration field was typed, and the reviewer
|
||||
is the only check there is. */}
|
||||
{(company.cooperative || company.investorLicence) && (
|
||||
<Alert
|
||||
color="orange"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<AlertTriangle size={18} />}
|
||||
title="Registration entered by hand — not verified against eTrade"
|
||||
>
|
||||
{company.cooperative
|
||||
? "This company onboarded as a co-operative union or farm, which holds no trade licence, so eTrade had no record to look its TIN up in. The company name, registration and address below are the customer's own statement. Check them against the Co-operative Registration Certificate on the Documents tab before approving."
|
||||
: "This company onboarded on a foreign investment licence, so we could not look its TIN up on eTrade. The company name, registration and address below are the customer's own statement. Check them against the Investment Licence on the Documents tab before approving."}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<ChangeRequestReview company={company} />
|
||||
|
||||
<KpiStrip
|
||||
@@ -861,7 +885,9 @@ export default function CustomerDetailPage() {
|
||||
value={
|
||||
company.cooperative
|
||||
? "Co-operative union / farm (no trade licence)"
|
||||
: "eTrade trade licence"
|
||||
: company.investorLicence
|
||||
? "Foreign investment licence — typed by the customer, not from eTrade"
|
||||
: "eTrade trade licence"
|
||||
}
|
||||
/>
|
||||
<InfoField label="Address" value={company.address} />
|
||||
|
||||
@@ -28,6 +28,7 @@ import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
CompanyNationalityBadge,
|
||||
CompanyStatusBadge,
|
||||
ManualRegistrationBadge,
|
||||
ProfileChips,
|
||||
formatDate,
|
||||
} from "@/components/customers";
|
||||
@@ -143,6 +144,10 @@ export default function CustomersPage() {
|
||||
{c.name}
|
||||
</Text>
|
||||
<CompanyNationalityBadge nationality={c.nationality} />
|
||||
<ManualRegistrationBadge
|
||||
cooperative={c.cooperative}
|
||||
investorLicence={c.investorLicence}
|
||||
/>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
TIN {c.tin}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Tabs } from "@mantine/core";
|
||||
import { Landmark, Receipt } from "lucide-react";
|
||||
import { Banknote, DollarSign, Receipt } from "lucide-react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { useManualPaymentSettingsQuery } from "@/hooks/useManualPaymentSettings";
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
|
||||
@@ -16,7 +17,9 @@ import UsdPaymentsPanel from "./UsdPaymentsPage";
|
||||
* before, and just doesn't render if the user lacks it.
|
||||
*
|
||||
* The Payments tab was removed; its summary (total collected, ETB/USD) now
|
||||
* lives as a card at the top of the Invoices tab instead.
|
||||
* lives as a card at the top of the Invoices tab instead. Manual payments are
|
||||
* split into one tab per currency — ETB keeps the original `?tab=manual-payments`
|
||||
* key so existing links and the old redirect still land somewhere valid.
|
||||
*/
|
||||
const TABS = [
|
||||
{
|
||||
@@ -30,13 +33,25 @@ const TABS = [
|
||||
},
|
||||
{
|
||||
key: "manual-payments",
|
||||
label: "Manual Payments",
|
||||
icon: Landmark,
|
||||
label: "Manual Payments (ETB)",
|
||||
icon: Banknote,
|
||||
// Same gate as Invoices, not a dedicated key — mirrors the old route.
|
||||
permission: FREIGHT_PERMS.invoices.view,
|
||||
/** Hidden unless manual settlement is switched on for this currency. */
|
||||
manualCurrency: "ETB",
|
||||
subtitle:
|
||||
"Import and export invoices in USD or ETB that Finance settles by hand (bank transfer or counter). Upload the customer's slip and confirm the payment before the pay window closes.",
|
||||
Panel: UsdPaymentsPanel,
|
||||
"Import and export invoices in ETB that Finance settles by hand (bank transfer or counter). Upload the customer's slip and confirm the payment before the pay window closes.",
|
||||
Panel: () => <UsdPaymentsPanel currency="ETB" />,
|
||||
},
|
||||
{
|
||||
key: "manual-payments-usd",
|
||||
label: "Manual Payments (USD)",
|
||||
icon: DollarSign,
|
||||
permission: FREIGHT_PERMS.invoices.view,
|
||||
manualCurrency: "USD",
|
||||
subtitle:
|
||||
"Import and export invoices in USD that Finance settles by hand (bank transfer or counter). Upload the customer's slip and confirm the payment before the pay window closes.",
|
||||
Panel: () => <UsdPaymentsPanel currency="USD" />,
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -46,7 +61,18 @@ export default function FinanceHubPage() {
|
||||
const { user } = useAuth();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const visibleTabs = TABS.filter((tab) => hasPermission(user, tab.permission));
|
||||
// A currency whose manual-payment channel is switched off has no tab at all
|
||||
// — the list would be empty and every confirmation refused.
|
||||
const { data: manualSettings } = useManualPaymentSettingsQuery();
|
||||
const manualEnabled = (currency: "ETB" | "USD") =>
|
||||
!manualSettings ||
|
||||
(currency === "ETB" ? manualSettings.etbEnabled : manualSettings.usdEnabled);
|
||||
|
||||
const visibleTabs = TABS.filter(
|
||||
(tab) =>
|
||||
hasPermission(user, tab.permission) &&
|
||||
(!("manualCurrency" in tab) || manualEnabled(tab.manualCurrency)),
|
||||
);
|
||||
const requested = searchParams.get("tab");
|
||||
const active: TabKey =
|
||||
visibleTabs.find((tab) => tab.key === requested)?.key ??
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from "@/components/customers";
|
||||
import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { useManualPaymentSettingsQuery } from "@/hooks/useManualPaymentSettings";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { api } from "@/services/api";
|
||||
import type { OfflineUsdInvoice } from "@/types/invoice";
|
||||
@@ -144,7 +145,11 @@ function ConfirmCell({
|
||||
* Finance settles by hand; confirming records the payment the same way an
|
||||
* online payment would, so the booking advances identically.
|
||||
*/
|
||||
export default function UsdPaymentsPanel() {
|
||||
export default function UsdPaymentsPanel({
|
||||
currency,
|
||||
}: {
|
||||
currency: "USD" | "ETB";
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [query, setQuery] = useState("");
|
||||
@@ -152,7 +157,6 @@ export default function UsdPaymentsPanel() {
|
||||
const [statusFilter, setStatusFilter] = useState<"" | Freight.InvoiceStatus>(
|
||||
"",
|
||||
);
|
||||
const [currency, setCurrency] = useState<"" | "USD" | "ETB">("");
|
||||
const [confirming, setConfirming] = useState<OfflineUsdInvoice | null>(null);
|
||||
const [slip, setSlip] = useState<File | null>(null);
|
||||
const [reference, setReference] = useState("");
|
||||
@@ -163,13 +167,23 @@ export default function UsdPaymentsPanel() {
|
||||
FREIGHT_PERMS.invoices.confirmOffline,
|
||||
);
|
||||
|
||||
// Manual settlement is switched on per currency in Configuration → Manual
|
||||
// payments. FinanceHubPage hides the tab for a disabled currency; this is
|
||||
// the fallback for a direct `?tab=` link, and the API refuses regardless.
|
||||
const { data: manualSettings } = useManualPaymentSettingsQuery();
|
||||
const currencyEnabled = manualSettings
|
||||
? currency === "ETB"
|
||||
? manualSettings.etbEnabled
|
||||
: manualSettings.usdEnabled
|
||||
: true;
|
||||
|
||||
const filter = useMemo(
|
||||
() => ({
|
||||
page: pagination.pageIndex + 1,
|
||||
pageSize: pagination.pageSize,
|
||||
search: debouncedQuery,
|
||||
status: statusFilter || undefined,
|
||||
currency: currency || undefined,
|
||||
currency,
|
||||
}),
|
||||
[
|
||||
pagination.pageIndex,
|
||||
@@ -180,9 +194,10 @@ export default function UsdPaymentsPanel() {
|
||||
],
|
||||
);
|
||||
|
||||
const { data, isLoading, isError, refetch, isFetching } = useQuery(
|
||||
api.invoices.listOfflineUsd.queryOptions({ input: { filter } }),
|
||||
);
|
||||
const { data, isLoading, isError, refetch, isFetching } = useQuery({
|
||||
...api.invoices.listOfflineUsd.queryOptions({ input: { filter } }),
|
||||
enabled: currencyEnabled,
|
||||
});
|
||||
|
||||
const confirm = useMutation(api.invoices.confirmOffline.mutationOptions());
|
||||
|
||||
@@ -289,20 +304,6 @@ export default function UsdPaymentsPanel() {
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "currency",
|
||||
header: "Currency",
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
radius="sm"
|
||||
color={row.original.currency?.toUpperCase() === "USD" ? "blue" : "teal"}
|
||||
>
|
||||
{row.original.currency}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
@@ -342,11 +343,12 @@ export default function UsdPaymentsPanel() {
|
||||
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
||||
cell: ({ row }) => {
|
||||
if (row.original.status === "PAID" || !canConfirm) return null;
|
||||
if (!currencyEnabled) return null;
|
||||
return <ConfirmCell row={row.original} onConfirm={setConfirming} />;
|
||||
},
|
||||
},
|
||||
],
|
||||
[canConfirm, navigate],
|
||||
[canConfirm, currencyEnabled, navigate],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -376,20 +378,6 @@ export default function UsdPaymentsPanel() {
|
||||
style={{ flex: 1, minWidth: "240px" }}
|
||||
radius="lg"
|
||||
/>
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
radius="md"
|
||||
value={currency || "all"}
|
||||
onChange={(v) => {
|
||||
setCurrency(v === "all" ? "" : (v as "USD" | "ETB"));
|
||||
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
|
||||
}}
|
||||
data={[
|
||||
{ label: "All", value: "all" },
|
||||
{ label: "ETB", value: "ETB" },
|
||||
{ label: "USD", value: "USD" },
|
||||
]}
|
||||
/>
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
radius="md"
|
||||
@@ -427,9 +415,11 @@ export default function UsdPaymentsPanel() {
|
||||
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||||
onRowClick={(row) => navigate(`/dashboard/invoices/${row.id}`)}
|
||||
emptyMessage={
|
||||
debouncedQuery
|
||||
? "No invoices match your search."
|
||||
: "No invoices awaiting manual payment confirmation."
|
||||
!currencyEnabled
|
||||
? `Manual payment is switched off for ${currency} invoices. Enable it in Configuration → Manual payments.`
|
||||
: debouncedQuery
|
||||
? "No invoices match your search."
|
||||
: `No ${currency} invoices awaiting manual payment confirmation.`
|
||||
}
|
||||
error={
|
||||
isError
|
||||
|
||||
@@ -26,6 +26,7 @@ import { PageContainer, PageHeader } from "@/components/page";
|
||||
import ManageRuleEngineOrderDialog from "@/components/ruleEngine/ManageRuleEngineOrderDialog";
|
||||
import PriorityRuleApprovalsSection from "@/pages/ruleEngine/PriorityRuleApprovalsSection";
|
||||
import RateApprovalsSection from "@/pages/ruleEngine/RateApprovalsSection";
|
||||
import { YardDesksModal } from "@/pages/ruleEngine/YardDesksModal";
|
||||
import { nextPriorityRangeStart } from "@/pages/ruleEngine/priorityRuleRange";
|
||||
import RuleEngineCardGrid from "@/components/ruleEngine/RuleEngineCardGrid";
|
||||
import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog";
|
||||
@@ -167,6 +168,10 @@ const RuleEngineResourcePage = () => {
|
||||
null,
|
||||
);
|
||||
const [chainOpen, setChainOpen] = useState(false);
|
||||
// Yards only: which desks work at this yard (input to yard access scoping).
|
||||
const [desksYard, setDesksYard] = useState<Record<string, unknown> | null>(
|
||||
null,
|
||||
);
|
||||
const [orderDialogOpen, setOrderDialogOpen] = useState(false);
|
||||
const { viewMode, setViewMode } = useRuleEngineViewMode(
|
||||
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
|
||||
@@ -582,6 +587,17 @@ const RuleEngineResourcePage = () => {
|
||||
cell: ({ row }) => (
|
||||
<div onClick={(e) => e.stopPropagation()} data-stop-row-click>
|
||||
<Group gap="xs" wrap="nowrap" justify="flex-end">
|
||||
{config.slug === "yards" ? (
|
||||
<Tooltip label="Desks that work at this yard">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
onClick={() => setDesksYard(row.original)}
|
||||
>
|
||||
Desks
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{config.orderConfig && canUpdateControls ? (
|
||||
<RuleEngineOrderControls
|
||||
record={row.original}
|
||||
@@ -984,6 +1000,21 @@ const RuleEngineResourcePage = () => {
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<YardDesksModal
|
||||
opened={!!desksYard}
|
||||
onClose={() => setDesksYard(null)}
|
||||
readOnly={!canUpdateControls}
|
||||
yard={
|
||||
desksYard
|
||||
? {
|
||||
id: String(desksYard.id),
|
||||
code: String(desksYard.code ?? ""),
|
||||
label: String(desksYard.label ?? ""),
|
||||
}
|
||||
: null
|
||||
}
|
||||
/>
|
||||
|
||||
<RuleEngineFormDialog
|
||||
open={formOpen}
|
||||
onOpenChange={setFormOpen}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
MultiSelect,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { extractErrorMessage } from "@/utils/errorExtractor";
|
||||
import { yardPositionsService } from "@/services/yardPositions.service";
|
||||
|
||||
interface YardDesksModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
yard: { id: string; code: string; label: string } | null;
|
||||
/** Read-only when the caller lacks the yards update permission. */
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
const positionLabel = (
|
||||
name: { am?: string; en?: string } | null,
|
||||
fallback: string,
|
||||
) => name?.en?.trim() || name?.am?.trim() || fallback;
|
||||
|
||||
/**
|
||||
* Which desks staff a yard — the input to yard access scoping.
|
||||
*
|
||||
* Saving REPLACES the yard's whole set (the API's PUT is a replace), which is
|
||||
* why the control is a multi-select holding the complete list rather than
|
||||
* add/remove buttons.
|
||||
*/
|
||||
export function YardDesksModal({
|
||||
opened,
|
||||
onClose,
|
||||
yard,
|
||||
readOnly = false,
|
||||
}: YardDesksModalProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
|
||||
const positions = useQuery({
|
||||
queryKey: ["yard-positions", "positions"],
|
||||
queryFn: yardPositionsService.listPositions,
|
||||
enabled: opened,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const mapping = useQuery({
|
||||
queryKey: ["yard-positions", "yard", yard?.id],
|
||||
queryFn: () => yardPositionsService.listByYard(yard!.id),
|
||||
enabled: opened && !!yard?.id,
|
||||
});
|
||||
|
||||
// Reset to what the server holds whenever the modal opens on a new yard, so a
|
||||
// cancelled edit never leaks into the next one.
|
||||
useEffect(() => {
|
||||
if (mapping.data) setSelected(mapping.data.map((row) => row.positionId));
|
||||
}, [mapping.data]);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => yardPositionsService.setForYard(yard!.id, selected),
|
||||
onSuccess: () => {
|
||||
toast.success("Yard desks updated");
|
||||
queryClient.invalidateQueries({ queryKey: ["yard-positions"] });
|
||||
onClose();
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(extractErrorMessage(error, "Failed to update yard desks")),
|
||||
});
|
||||
|
||||
const options = (positions.data ?? []).map((position) => ({
|
||||
value: position.id,
|
||||
label: positionLabel(position.name, position.id.slice(0, 8)),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={yard ? `Desks at ${yard.label} (${yard.code})` : "Desks"}
|
||||
size="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Alert color="blue" variant="light">
|
||||
<Text size="sm">
|
||||
Positions mapped here are the desks that work at this yard. Yard
|
||||
access scoping reads this mapping — a staff member acting on this
|
||||
desk is scoped to this yard.
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
{positions.isLoading || mapping.isLoading ? (
|
||||
<Group justify="center" py="lg">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
) : (
|
||||
<MultiSelect
|
||||
data={options}
|
||||
value={selected}
|
||||
onChange={setSelected}
|
||||
disabled={readOnly}
|
||||
label="Positions"
|
||||
placeholder={selected.length ? undefined : "Select positions"}
|
||||
description="Saving replaces the whole set — anything removed here loses this yard."
|
||||
searchable
|
||||
clearable
|
||||
hidePickedOptions
|
||||
maxDropdownHeight={280}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => save.mutate()}
|
||||
loading={save.isPending}
|
||||
disabled={readOnly || mapping.isLoading}
|
||||
title={readOnly ? "You cannot edit yards" : undefined}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/common/ui/card";
|
||||
import { Badge } from "@/shared/common/ui/badge";
|
||||
import { Switch } from "@/shared/common/ui/switch";
|
||||
import { Skeleton } from "@/shared/common/ui/skeleton";
|
||||
import { AlertTriangle, Banknote, Landmark } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import {
|
||||
useManualPaymentSettingsQuery,
|
||||
useUpdateManualPaymentSettings,
|
||||
} from "@/hooks/useManualPaymentSettings";
|
||||
|
||||
type Currency = "ETB" | "USD";
|
||||
|
||||
const CURRENCIES: {
|
||||
code: Currency;
|
||||
field: "etbEnabled" | "usdEnabled";
|
||||
icon: typeof Banknote;
|
||||
title: string;
|
||||
description: string;
|
||||
}[] = [
|
||||
{
|
||||
code: "ETB",
|
||||
field: "etbEnabled",
|
||||
icon: Banknote,
|
||||
title: "Birr (ETB) invoices",
|
||||
description:
|
||||
"ETB invoices are normally paid online by the customer. Switch this on when Finance also needs to settle them by hand — a bank transfer or a payment at the counter.",
|
||||
},
|
||||
{
|
||||
code: "USD",
|
||||
field: "usdEnabled",
|
||||
icon: Landmark,
|
||||
title: "Dollar (USD) invoices",
|
||||
description:
|
||||
"USD invoices are paid by bank transfer and have no online channel. Switching this off leaves USD customers with no way to be marked as paid.",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Switches the manual (offline) payment channel on or off per currency.
|
||||
*
|
||||
* Off means gone, not greyed out: the Manual Payments worklist lists only
|
||||
* enabled currencies, and the API refuses a confirmation in a disabled one —
|
||||
* so a stale tab or a direct call cannot slip a payment through.
|
||||
*/
|
||||
export default function ManualPaymentSettingsCard() {
|
||||
const { user } = useAuth();
|
||||
const canManage =
|
||||
hasPermission(user, FREIGHT_PERMS.settings.manualPayment.manage) ||
|
||||
hasPermission(user, FREIGHT_PERMS.admin);
|
||||
|
||||
const { data, isLoading } = useManualPaymentSettingsQuery();
|
||||
const update = useUpdateManualPaymentSettings();
|
||||
|
||||
const noneEnabled = Boolean(data && !data.etbEnabled && !data.usdEnabled);
|
||||
|
||||
return (
|
||||
<Card className="shadow-lg border-gray-200 dark:border-gray-700">
|
||||
<CardHeader>
|
||||
<CardTitle>Manual payments</CardTitle>
|
||||
<CardDescription>
|
||||
Whether Finance staff may mark invoices as paid by hand, from
|
||||
Invoices → Manual Payments. Each currency is switched separately.
|
||||
Confirming still requires the payment slip and the booking's pay
|
||||
window to be open.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{noneEnabled && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<p>
|
||||
Both currencies are off — the Manual Payments list is empty and
|
||||
Finance cannot settle any invoice by hand.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading || !data
|
||||
? CURRENCIES.map((c) => (
|
||||
<Skeleton key={c.code} className="h-[86px] w-full rounded-md" />
|
||||
))
|
||||
: CURRENCIES.map(({ code, field, icon: Icon, title, description }) => {
|
||||
const enabled = data[field];
|
||||
return (
|
||||
<div
|
||||
key={code}
|
||||
className="flex items-start justify-between gap-4 rounded-md border p-4 dark:border-gray-700"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="font-medium">{title}</p>
|
||||
<Badge variant={enabled ? "default" : "secondary"}>
|
||||
{enabled ? "Enabled" : "Disabled"}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
disabled={!canManage || update.isPending}
|
||||
aria-label={`Allow manual payment for ${code} invoices`}
|
||||
onCheckedChange={(checked) =>
|
||||
update.mutate({ [field]: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{!canManage && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You can see these settings but not change them — that needs the
|
||||
manual-payment settings permission.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
Weight,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import AvailableWagonsPanel from "@/components/trainBuilder/AvailableWagonsPanel";
|
||||
@@ -94,14 +94,23 @@ export default function TrainBuilderDetailPage() {
|
||||
const canAssign = hasPermission(user, FREIGHT_PERMS.trains.assignWagons);
|
||||
const canChangeLocomotives = hasPermission(user, FREIGHT_PERMS.trains.changeLocomotives);
|
||||
const canChangeYard = hasPermission(user, FREIGHT_PERMS.trains.changeYard);
|
||||
const canChangeWagonYard = hasPermission(user, FREIGHT_PERMS.trains.changeWagonYard);
|
||||
const canToggleActive = hasPermission(user, FREIGHT_PERMS.trains.toggleActive);
|
||||
const canDisband = hasPermission(user, FREIGHT_PERMS.trains.disband);
|
||||
|
||||
const compositionQuery = useQuery(
|
||||
api.trainBuilder.composition.queryOptions({ input: { id }, enabled: Boolean(id) }),
|
||||
api.trainBuilder.composition.queryOptions({
|
||||
input: { id },
|
||||
enabled: Boolean(id),
|
||||
// Mutations seed this key from their own response (see `seedComposition`
|
||||
// in services/api.ts), so the cached consist is authoritative — the
|
||||
// global staleTime of 0 would otherwise refetch it on every remount.
|
||||
staleTime: 30_000,
|
||||
}),
|
||||
);
|
||||
const assignWagons = useMutation(api.trainBuilder.assignWagons.mutationOptions());
|
||||
const removeWagon = useMutation(api.trainBuilder.removeWagon.mutationOptions());
|
||||
const setWagonYard = useMutation(api.trainBuilder.setWagonYard.mutationOptions());
|
||||
const maintenanceWagon = useMutation(
|
||||
api.trainBuilder.sendWagonToMaintenance.mutationOptions(),
|
||||
);
|
||||
@@ -111,6 +120,34 @@ export default function TrainBuilderDetailPage() {
|
||||
const activate = useMutation(api.trainBuilder.activate.mutationOptions());
|
||||
|
||||
const composition = compositionQuery.data;
|
||||
|
||||
// The diagram memoizes off its `locomotives`/`wagons` props; building those
|
||||
// arrays inline in JSX would hand it a new identity on every render and
|
||||
// re-normalize + repaint every car for each keystroke or pending mutation.
|
||||
const diagramLocomotives = useMemo(
|
||||
() =>
|
||||
(composition?.locomotives ?? []).map((loco) => ({
|
||||
code: loco.code,
|
||||
name: loco.name,
|
||||
maxPullWeightTons: loco.maxPullWeightTons,
|
||||
})),
|
||||
[composition?.locomotives],
|
||||
);
|
||||
const diagramWagons = useMemo(
|
||||
() =>
|
||||
(composition?.wagons ?? []).map((wagon, index) => ({
|
||||
sequenceNo: wagon.sequenceNumber ?? index + 1,
|
||||
capacityTons: wagon.wagonType?.capacityTons ?? 0,
|
||||
// No bookings at build time — wagons ride empty until allocation.
|
||||
assignedWeightTons: 0,
|
||||
tareWeightTons: wagon.wagonType?.tareWeightTons ?? 0,
|
||||
wagonTypeCode: wagon.wagonType?.code ?? null,
|
||||
physicalWagonNumber: wagon.wagonNumber,
|
||||
allocations: [],
|
||||
})),
|
||||
[composition?.wagons],
|
||||
);
|
||||
|
||||
// Staff identify a train by its operational run numbers, not the internal
|
||||
// code — mirrors formatTrainRunLabel on the API, which writes the history note.
|
||||
const trainRunLabel =
|
||||
@@ -127,20 +164,76 @@ export default function TrainBuilderDetailPage() {
|
||||
const busy =
|
||||
assignWagons.isPending ||
|
||||
removeWagon.isPending ||
|
||||
setWagonYard.isPending ||
|
||||
maintenanceWagon.isPending ||
|
||||
reorderWagons.isPending;
|
||||
|
||||
const withToast = async (action: () => Promise<unknown>, failTitle: string) => {
|
||||
try {
|
||||
await action();
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: failTitle,
|
||||
description: parseError(err, "Something went wrong"),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
const withToast = useCallback(
|
||||
async (action: () => Promise<unknown>, failTitle: string) => {
|
||||
try {
|
||||
await action();
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: failTitle,
|
||||
description: parseError(err, "Something went wrong"),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
},
|
||||
[toast],
|
||||
);
|
||||
|
||||
// Stable handlers: the consist list and wagon picker are memoized, so a new
|
||||
// closure each render would defeat the memo and re-render every wagon row
|
||||
// (and re-mount the drag context) on unrelated state changes.
|
||||
// `trainId` is only absent before the composition loads, and these handlers
|
||||
// are wired to controls that render after that — the guard keeps the promise
|
||||
// rather than leaning on a non-null assertion.
|
||||
const trainId = composition?.id;
|
||||
const handleAssign = useCallback(
|
||||
(wagonIds: string[]) => {
|
||||
if (!trainId) return;
|
||||
void withToast(
|
||||
() => assignWagons.mutateAsync({ id: trainId, wagonIds }),
|
||||
"Could not add wagons",
|
||||
);
|
||||
},
|
||||
[withToast, assignWagons.mutateAsync, trainId],
|
||||
);
|
||||
const handleReorder = useCallback(
|
||||
(wagonIds: string[]) => {
|
||||
if (!trainId) return;
|
||||
void withToast(
|
||||
() => reorderWagons.mutateAsync({ id: trainId, wagonIds }),
|
||||
"Could not reorder wagons",
|
||||
);
|
||||
},
|
||||
[withToast, reorderWagons.mutateAsync, trainId],
|
||||
);
|
||||
const handleRemove = useCallback(
|
||||
(wagonId: string) => {
|
||||
if (!trainId) return;
|
||||
void withToast(
|
||||
() => removeWagon.mutateAsync({ id: trainId, wagonId }),
|
||||
"Could not detach wagon",
|
||||
);
|
||||
},
|
||||
[withToast, removeWagon.mutateAsync, trainId],
|
||||
);
|
||||
const handleChangeWagonYard = useCallback(
|
||||
(wagonId: string, currentYardId: string) => {
|
||||
if (!trainId) return;
|
||||
void withToast(
|
||||
() => setWagonYard.mutateAsync({ id: trainId, wagonId, currentYardId }),
|
||||
"Could not change wagon yard",
|
||||
);
|
||||
},
|
||||
[withToast, setWagonYard.mutateAsync, trainId],
|
||||
);
|
||||
const handleMaintenance = useCallback(
|
||||
(wagon: TrainCompositionWagon) => setMaintenanceTarget(wagon),
|
||||
[],
|
||||
);
|
||||
|
||||
if (compositionQuery.isLoading) {
|
||||
return (
|
||||
@@ -278,6 +371,29 @@ export default function TrainBuilderDetailPage() {
|
||||
]}
|
||||
/>
|
||||
|
||||
{composition.wagonYards.length > 1 ? (
|
||||
<Alert color="blue" icon={<MapPin size={16} />}>
|
||||
<Stack gap={4}>
|
||||
<Text size="sm" fw={600}>
|
||||
This train's wagons stand in {composition.wagonYards.length} yards
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
{composition.wagonYards.map((group) => (
|
||||
<Badge key={group.yardId ?? "none"} variant="light" color="blue">
|
||||
{group.label ?? group.code ?? "No yard"} · {group.wagonCount} wagon
|
||||
{group.wagonCount === 1 ? "" : "s"}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
The train collects each group when it reaches that yard, so the schedule's route
|
||||
must pass through every one of them before its destination. Customers boarding at
|
||||
a yard can only book the wagons standing there.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{!composition.editable ? (
|
||||
<Alert color="yellow" icon={<AlertTriangle size={16} />}>
|
||||
This train is out on a dispatched run — its composition is frozen until arrival.
|
||||
@@ -341,21 +457,8 @@ export default function TrainBuilderDetailPage() {
|
||||
|
||||
<Stack gap="sm">
|
||||
<TrainCompositionDiagram
|
||||
locomotives={composition.locomotives.map((loco) => ({
|
||||
code: loco.code,
|
||||
name: loco.name,
|
||||
maxPullWeightTons: loco.maxPullWeightTons,
|
||||
}))}
|
||||
wagons={composition.wagons.map((wagon, index) => ({
|
||||
sequenceNo: wagon.sequenceNumber ?? index + 1,
|
||||
capacityTons: wagon.wagonType?.capacityTons ?? 0,
|
||||
// No bookings at build time — wagons ride empty until allocation.
|
||||
assignedWeightTons: 0,
|
||||
tareWeightTons: wagon.wagonType?.tareWeightTons ?? 0,
|
||||
wagonTypeCode: wagon.wagonType?.code ?? null,
|
||||
physicalWagonNumber: wagon.wagonNumber,
|
||||
allocations: [],
|
||||
}))}
|
||||
locomotives={diagramLocomotives}
|
||||
wagons={diagramWagons}
|
||||
trainNumber={composition.code}
|
||||
totalLengthMeters={totals.totalLengthMeters}
|
||||
/>
|
||||
@@ -379,22 +482,17 @@ export default function TrainBuilderDetailPage() {
|
||||
<Grid.Col span={{ base: 12, md: 5 }}>
|
||||
<Card h="100%">
|
||||
<Stack gap="sm">
|
||||
<Text fw={600}>Available wagons — {yard?.label ?? "yard"}</Text>
|
||||
<Text fw={600}>Available wagons — all yards</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Only AVAILABLE wagons standing in the train's own yard can be coupled.
|
||||
AVAILABLE, unassigned wagons from every yard can be coupled. The schedule's
|
||||
route must pass through each wagon's yard before its destination.
|
||||
</Text>
|
||||
<AvailableWagonsPanel
|
||||
yardId={yard?.id ?? ""}
|
||||
yardLabel={yard?.label}
|
||||
homeYardId={yard?.id ?? null}
|
||||
exportTrainNumber={composition.exportTrainNumber}
|
||||
importTrainNumber={composition.importTrainNumber}
|
||||
assigning={assignWagons.isPending}
|
||||
onAssign={(wagonIds) =>
|
||||
void withToast(
|
||||
() => assignWagons.mutateAsync({ id: composition.id, wagonIds }),
|
||||
"Could not add wagons",
|
||||
)
|
||||
}
|
||||
onAssign={handleAssign}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
@@ -411,19 +509,12 @@ export default function TrainBuilderDetailPage() {
|
||||
wagons={composition.wagons}
|
||||
editable={composition.editable && canAssign}
|
||||
busy={busy}
|
||||
onReorder={(wagonIds) =>
|
||||
void withToast(
|
||||
() => reorderWagons.mutateAsync({ id: composition.id, wagonIds }),
|
||||
"Could not reorder wagons",
|
||||
)
|
||||
onReorder={handleReorder}
|
||||
onRemove={handleRemove}
|
||||
onMaintenance={handleMaintenance}
|
||||
onChangeYard={
|
||||
composition.editable && canChangeWagonYard ? handleChangeWagonYard : undefined
|
||||
}
|
||||
onRemove={(wagonId) =>
|
||||
void withToast(
|
||||
() => removeWagon.mutateAsync({ id: composition.id, wagonId }),
|
||||
"Could not detach wagon",
|
||||
)
|
||||
}
|
||||
onMaintenance={(wagon) => setMaintenanceTarget(wagon)}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Badge, Card, Center, Divider, Group, Loader, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core';
|
||||
import {
|
||||
ClipboardCheck,
|
||||
ClipboardList,
|
||||
ShieldCheck,
|
||||
PackageCheck,
|
||||
PackageOpen,
|
||||
PackagePlus,
|
||||
PackageSearch,
|
||||
CircleCheck,
|
||||
Send,
|
||||
Train,
|
||||
Truck,
|
||||
Warehouse as WarehouseIcon,
|
||||
Boxes,
|
||||
Layers,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
@@ -53,13 +52,13 @@ const METRICS: Metric[] = [
|
||||
{ key: 'totalInventory', label: 'Total Inventory', icon: <Boxes size={22} />, to: '/dashboard/warehouse-inventory', theme: GREEN },
|
||||
{ key: 'receivedToday', label: 'Received Today', icon: <PackagePlus size={22} />, to: '/dashboard/warehouse-inventory?status=RECEIVED', theme: ORANGE },
|
||||
{ key: 'awaitingInspection', label: 'Awaiting Inspection', icon: <ClipboardList size={22} />, to: '/dashboard/warehouse-inventory?status=RECEIVED', theme: GREEN },
|
||||
{ key: 'inspected', label: 'Inspected', icon: <ShieldCheck size={22} />, to: '/dashboard/warehouse-inventory', theme: ORANGE },
|
||||
{ key: 'stored', label: 'Stored', icon: <Layers size={22} />, to: '/dashboard/warehouse-inventory?status=STORED', theme: GREEN },
|
||||
{ key: 'reserved', label: 'Reserved', icon: <ClipboardCheck size={22} />, to: '/dashboard/warehouse-inventory?status=RESERVED', theme: ORANGE },
|
||||
{ key: 'readyForLoading', label: 'Ready For Loading', icon: <PackageCheck size={22} />, to: '/dashboard/loading-queue', theme: GREEN },
|
||||
{ key: 'loaded', label: 'Loaded', icon: <Truck size={22} />, to: '/dashboard/loaded-inventory', theme: ORANGE },
|
||||
{ key: 'dispatched', label: 'Dispatched', icon: <Send size={22} />, to: '/dashboard/dispatch-queue', theme: GREEN },
|
||||
{ key: 'readyForPickup', label: 'Ready For Pickup', icon: <PackageSearch size={22} />, to: '/dashboard/warehouse-inventory?status=READY_FOR_PICKUP', theme: ORANGE },
|
||||
{ key: 'emptyContainers', label: 'Empty Containers', icon: <PackageOpen size={22} />, to: '/dashboard/containers', theme: ORANGE },
|
||||
{ key: 'importTrains', label: 'Import Trains', icon: <Train size={22} />, to: '/dashboard/import-warehouse', theme: GREEN },
|
||||
{ key: 'exportTrains', label: 'Export Trains', icon: <Train size={22} />, to: '/dashboard/export-warehouse', theme: ORANGE },
|
||||
{ key: 'loaded', label: 'Loaded', icon: <Truck size={22} />, to: '/dashboard/loaded-inventory', theme: GREEN },
|
||||
{ key: 'dispatched', label: 'Dispatched', icon: <Send size={22} />, to: '/dashboard/dispatch-queue', theme: ORANGE },
|
||||
{ key: 'readyForPickup', label: 'Ready For Pickup', icon: <PackageSearch size={22} />, to: '/dashboard/warehouse-inventory?status=READY_FOR_PICKUP', theme: GREEN },
|
||||
{ key: 'readyForLoading', label: 'Ready For Loading', icon: <PackageCheck size={22} />, to: '/dashboard/loading-queue', theme: ORANGE },
|
||||
{ key: 'delivered', label: 'Delivered', icon: <CircleCheck size={22} />, to: '/dashboard/warehouse-inventory?status=DELIVERED', theme: GREEN },
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user