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