mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
494 lines
16 KiB
TypeScript
494 lines
16 KiB
TypeScript
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>
|
||
);
|
||
}
|