Files
edr-platform/apps/edr-freight-web/backoffice/src/pages/bookings/ConsolidationApprovalsPage.tsx
2026-08-23 05:00:43 +00:00

494 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
Pagination,
Paper,
Stack,
Tabs,
Text,
Textarea,
ThemeIcon,
} from "@mantine/core";
import {
AlertCircle,
Check,
Clock,
FileText,
Link2,
User,
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"];
const PAGE_SIZE = 10;
type Status = ConsolidationApprovalRow["status"];
const TABS: { value: Status; label: string }[] = [
{ value: "PENDING", label: "Awaiting approval" },
{ value: "APPROVED", label: "Approved" },
{ value: "REJECTED", label: "Rejected" },
];
const STATUS_COLOR: Record<Status, string> = {
PENDING: "yellow",
APPROVED: "green",
REJECTED: "red",
};
const STATUS_LABEL: Record<Status, string> = {
PENDING: "Awaiting approval",
APPROVED: "Approved",
REJECTED: "Rejected",
};
const STATUS_VERB: Record<Status, string> = {
PENDING: "",
APPROVED: "Approved by",
REJECTED: "Rejected by",
};
const EMPTY_TEXT: Record<Status, string> = {
PENDING: "Nothing waiting for approval.",
APPROVED: "No shared wagon has been approved yet.",
REJECTED: "No shared wagon has been rejected.",
};
/**
* 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.
*
* Decided pairings stay on the page rather than vanishing: the decided tabs are
* the record of who signed off on which wagon and why. A rejection is not final
* either — a rejected pairing can still be approved from here once whatever
* blocked it is settled.
*/
export default function ConsolidationApprovalsPage() {
const qc = useQueryClient();
const [decision, setDecision] = useState<{
row: ConsolidationApprovalRow;
kind: "approve" | "reject";
} | null>(null);
const [note, setNote] = useState("");
const [tab, setTab] = useState<Status>("PENDING");
const [page, setPage] = useState(1);
const { data, isLoading, isError, isFetching } = useQuery({
queryKey: [...QUEUE_KEY, tab, page],
queryFn: () =>
bookingsService.consolidationApprovalQueue({
status: tab,
page,
pageSize: PAGE_SIZE,
}),
// Keeping the last page on screen while the next one loads stops the list
// from collapsing to a spinner on every page or tab click.
placeholderData: (previous) => previous,
});
const shown = data?.items ?? [];
const pageCount = Math.max(1, data?.meta.totalPages ?? 1);
const countOf = (status: Status) => data?.counts?.[status] ?? 0;
const goToTab = (next: Status) => {
setTab(next);
setPage(1);
};
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",
);
goToTab(decision?.kind === "approve" ? "APPROVED" : "REJECTED");
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>
) : (
<Tabs
value={tab}
onChange={(value) => goToTab((value as Status) ?? "PENDING")}
radius="md"
>
<Tabs.List mb="md">
{TABS.map(({ value, label }) => (
<Tabs.Tab
key={value}
value={value}
rightSection={
<Badge
size="sm"
variant="light"
color={STATUS_COLOR[value]}
radius="sm"
>
{countOf(value)}
</Badge>
}
>
{label}
</Tabs.Tab>
))}
</Tabs.List>
{!shown.length ? (
<Alert color="gray" radius="md" icon={<Check size={16} />}>
{EMPTY_TEXT[tab]}
</Alert>
) : (
<Stack gap="md">
{shown.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={STATUS_COLOR[row.status]}
variant="light"
radius="sm"
>
{STATUS_LABEL[row.status]}
</Badge>
</Group>
<Group gap="xl" wrap="wrap">
<BookingSide
id={row.bookingId}
reference={
row.booking?.reference ?? row.bookingReference
}
company={row.booking?.company?.name}
contractReference={row.contractReference}
contractId={row.booking?.contractId}
/>
<BookingSide
id={row.partnerBookingId}
reference={
row.partnerBooking?.reference ??
row.partnerBookingReference
}
company={row.partnerBooking?.company?.name}
contractReference={row.partnerContractReference}
contractId={row.partnerBooking?.contractId}
/>
</Group>
<Group gap={6} mt={12} c="dimmed">
<Clock size={13} />
<Text fz={12}>
Requested {formatDateTime(row.requestedAt)}
{row.requestedByName
? ` by ${row.requestedByName}`
: ""}
{row.scheduledDate
? ` · ships ${formatDateTime(row.scheduledDate)}`
: ""}
</Text>
</Group>
{row.status !== "PENDING" && (
<Group gap={6} mt={6} c="dimmed" align="flex-start">
<User size={13} style={{ marginTop: 2 }} />
<Box style={{ minWidth: 0 }}>
<Text fz={12}>
{STATUS_VERB[row.status]}{" "}
{row.decidedByName ?? "an unknown user"}
{row.decidedAt
? ` on ${formatDateTime(row.decidedAt)}`
: ""}
</Text>
{row.decisionNote && (
<Text fz={12} fs="italic">
{row.decisionNote}
</Text>
)}
</Box>
</Group>
)}
</Box>
{row.status !== "APPROVED" && (
<Group gap="sm">
<Button
color="edr-green"
radius="md"
leftSection={<Check size={15} />}
onClick={() => {
setDecision({ row, kind: "approve" });
setNote("");
}}
>
{row.status === "REJECTED"
? "Approve anyway"
: "Approve"}
</Button>
{row.status === "PENDING" && (
<Button
color="red"
variant="light"
radius="md"
leftSection={<X size={15} />}
onClick={() => {
setDecision({ row, kind: "reject" });
setNote("");
}}
>
Reject
</Button>
)}
</Group>
)}
</Group>
</Paper>
))}
{pageCount > 1 && (
<Group
justify="space-between"
align="center"
mt={4}
wrap="wrap"
>
<Text fz={12} c="dimmed">
Showing {(page - 1) * PAGE_SIZE + 1}
{Math.min(page * PAGE_SIZE, data?.total ?? 0)} of{" "}
{data?.total ?? 0}
</Text>
<Pagination
size="sm"
radius="md"
color="edr-ink"
total={pageCount}
value={page}
onChange={setPage}
disabled={isFetching}
siblings={1}
boundaries={1}
/>
</Group>
)}
</Stack>
)}
</Tabs>
)}
<Modal
opened={Boolean(decision)}
onClose={() => {
if (!decide.isPending) close();
}}
centered
radius="lg"
title={
<Text fw={800} fz={16}>
{decision?.kind !== "approve"
? "Reject this shared wagon?"
: decision.row.status === "REJECTED"
? "Approve this rejected shared wagon?"
: "Approve this shared wagon?"}
</Text>
}
>
<Stack gap="md">
<Text fz="sm" c="dimmed">
{decision?.kind !== "approve"
? "Both bookings go back to GL as “changes requested” with your reason. Neither reaches Operations."
: decision.row.status === "REJECTED"
? "This pairing was rejected before. Approving it now overrides that decision — both bookings leave the gate together and continue to Operations."
: "Both bookings leave the gate together and continue to Operations. Each is still invoiced and paid separately."}
</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 booking reference, the contract it was raised
* under, and whose cargo it is. Both references link out — a reviewer deciding
* a pairing usually wants the contract, not just the shipment.
*/
function BookingSide({
id,
reference,
company,
contractReference,
contractId,
}: {
id: string;
reference?: string | null;
company?: string | null;
contractReference?: string | null;
contractId?: 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>
{contractReference && (
<Group gap={4} wrap="nowrap" mt={2}>
<FileText
size={11}
className="shrink-0"
color="var(--mantine-color-dimmed)"
/>
{contractId ? (
<Text
component={Link}
to={`/dashboard/contract-requests/${contractId}/view`}
fz={12}
c="blue.7"
style={{ textDecoration: "none" }}
>
{contractReference}
</Text>
) : (
<Text fz={12} c="dimmed">
{contractReference}
</Text>
)}
</Group>
)}
<Text fz={12.5} c="dimmed">
{company ?? "—"}
</Text>
</Box>
);
}