Files
edr-platform/apps/edr-freight-web/backoffice/src/features/bookingWindows/DocReviewAlertButton.tsx

138 lines
5.5 KiB
TypeScript

import { Text, Tooltip, UnstyledButton } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { AlertTriangle, ChevronRight } from "lucide-react";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "@/auth/useAuth";
import { canSeeDocReviewAlert } from "@/lib/permissions";
import { api } from "@/services/api";
/**
* Booking-request statuses that count as "nobody decided yet". These are the
* exact statuses the window engine expires when document review ends
* (findUnacceptedForRouteDay), so the deep-linked list shows precisely the
* requests the countdown is warning about.
*/
const UNDECIDED_STATUSES = [
"OPERATION_REQUESTED",
"OPERATION_REQUEST_PENDING",
"OPERATION_CHANGES_REQUESTED",
"OPERATION_PRICE_PENDING_CONFIRM",
].join(",");
const pendingRequestsHref = (tradeDirection: string) =>
`/dashboard/booking-requests?statuses=${UNDECIDED_STATUSES}&tradeDirection=${tradeDirection}`;
/** mm:ss (or h:mm:ss past an hour), fixed width so the pill never jitters. */
function formatRemaining(ms: number): string {
const total = Math.max(0, Math.floor(ms / 1000));
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const seconds = total % 60;
const mm = String(minutes).padStart(2, "0");
const ss = String(seconds).padStart(2, "0");
return hours > 0 ? `${hours}:${mm}:${ss}` : `${mm}:${ss}`;
}
/**
* Header alarm for the document-review deadline. Runs for the whole review
* phase — from the moment it opens until the clock runs out — whenever requests
* are still undecided. Everything left pending at the deadline is expired
* automatically, so staff get the full window to accept or reject rather than
* only its back half. Clicking opens the booking requests already filtered to
* those undecided requests.
*/
export default function DocReviewAlertButton() {
const navigate = useNavigate();
const { user } = useAuth();
const { data: alert } = useQuery({
...api.trainScheduling.docReviewAlert.queryOptions(),
// Dedicated permission: only the position types granted it are alarmed.
enabled: canSeeDocReviewAlert(user),
// The window engine ticks every 10s; a minute is close enough for a header
// chip — the countdown itself runs locally.
refetchInterval: 60_000,
});
const deadlineMs = alert ? new Date(alert.docReviewEndsAt).getTime() : 0;
const [remaining, setRemaining] = useState(() => deadlineMs - Date.now());
useEffect(() => {
if (!deadlineMs) return;
const tick = () => setRemaining(deadlineMs - Date.now());
tick();
const id = window.setInterval(tick, 1000);
return () => window.clearInterval(id);
}, [deadlineMs]);
if (!alert) return null;
// Alarm for the WHOLE review phase, from the moment it opens: anything still
// undecided when the clock runs out is expired automatically, so staff need
// the full window to act, not the back half of it. The endpoint only returns
// a schedule that is in DOC_REVIEW with undecided requests behind it, so the
// remaining check just hides the pill once the deadline passes.
if (remaining <= 0) return null;
const requestLabel = alert.pendingCount === 1 ? "request" : "requests";
return (
<Tooltip
withArrow
openDelay={200}
multiline
w={260}
label={`Document review ends in ${formatRemaining(remaining)}. ${alert.pendingCount} ${alert.tradeDirection.toLowerCase()} booking ${requestLabel} ${alert.pendingCount === 1 ? "is" : "are"} still neither accepted nor rejected and will expire automatically. Click to review them.`}
>
<UnstyledButton
onClick={() => navigate(pendingRequestsHref(alert.tradeDirection))}
aria-label={`${alert.pendingCount} import booking ${requestLabel} awaiting a decision — document review ends in ${formatRemaining(remaining)}`}
className="group flex h-9 shrink-0 items-center gap-2 rounded-full pl-2.5 pr-2 transition-transform hover:scale-[1.02]"
// Inline, not Tailwind: Mantine's UnstyledButton resets the background
// in unlayered CSS, which beats a `@layer utilities` class whatever its
// specificity — `bg-red-600` alone renders the pill white.
style={{
background: "var(--mantine-color-red-6)",
border: "1px solid var(--mantine-color-red-7)",
color: "#fff",
boxShadow: "0 2px 10px rgba(220, 38, 38, 0.35)",
}}
>
{/* Live dot: a ping ring behind a solid core, so the pill reads as
active without animating the whole chip. */}
<span className="relative flex size-2 shrink-0">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-white opacity-75" />
<span className="relative inline-flex size-2 rounded-full bg-white" />
</span>
<AlertTriangle size={15} strokeWidth={2.2} className="shrink-0" />
<Text
size="xs"
fw={700}
visibleFrom="sm"
className="whitespace-nowrap text-white!"
>
{alert.pendingCount} undecided
</Text>
<Text
size="xs"
fw={700}
className="text-white!"
style={{ fontVariantNumeric: "tabular-nums", letterSpacing: "0.02em" }}
>
{formatRemaining(remaining)}
</Text>
<ChevronRight
size={14}
strokeWidth={2.2}
className="shrink-0 opacity-80 transition-transform group-hover:translate-x-0.5"
/>
</UnstyledButton>
</Tooltip>
);
}