mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
354 lines
9.5 KiB
TypeScript
354 lines
9.5 KiB
TypeScript
import type { ReactNode } from "react";
|
|
import { Box, Button, Group, Paper, Stack, Text, ThemeIcon, Title } from "@mantine/core";
|
|
import type { LucideIcon } from "lucide-react";
|
|
import {
|
|
AlertTriangle,
|
|
CheckCircle2,
|
|
Clock,
|
|
Inbox,
|
|
LayoutList,
|
|
Plus,
|
|
RefreshCw,
|
|
} from "lucide-react";
|
|
|
|
import { freightBrand } from "@/theme/freight-brand";
|
|
import type {
|
|
BookingListSummaryMetrics,
|
|
BookingListSummaryTabs,
|
|
} from "@/services/bookings.service";
|
|
|
|
const HERO_GRADIENT = `linear-gradient(135deg, ${freightBrand.primaryDark} 0%, ${freightBrand.primary} 48%, ${freightBrand.primaryLight} 120%)`;
|
|
|
|
/** Lifecycle stages for the pipeline distribution bar (in flow order). */
|
|
const PIPELINE_STAGES: Array<{
|
|
key: keyof BookingListSummaryTabs;
|
|
label: string;
|
|
color: string;
|
|
}> = [
|
|
{ key: "intake", label: "Intake", color: "#38bdf8" },
|
|
{ key: "in_approval", label: "Approval", color: "#fbbf24" },
|
|
{ key: "approved_contract", label: "Contract", color: "#a78bfa" },
|
|
{ key: "payment", label: "Payment", color: "#fb923c" },
|
|
{ key: "operations", label: "Operations", color: "#2dd4bf" },
|
|
{ key: "completed", label: "Completed", color: "#86efac" },
|
|
];
|
|
|
|
export interface BookingRequestsHeaderProps {
|
|
metrics?: BookingListSummaryMetrics;
|
|
tabs?: BookingListSummaryTabs;
|
|
loading?: boolean;
|
|
isFetching?: boolean;
|
|
onCreate: () => void;
|
|
onRefresh: () => void;
|
|
}
|
|
|
|
export function BookingRequestsHeader({
|
|
metrics,
|
|
tabs,
|
|
loading,
|
|
isFetching,
|
|
onCreate,
|
|
onRefresh,
|
|
}: BookingRequestsHeaderProps) {
|
|
const val = (n?: number) => (loading ? "—" : (n ?? 0));
|
|
|
|
return (
|
|
<Paper
|
|
radius="xl"
|
|
p="xl"
|
|
style={{
|
|
position: "relative",
|
|
overflow: "hidden",
|
|
background: HERO_GRADIENT,
|
|
boxShadow: freightBrand.shadow,
|
|
}}
|
|
>
|
|
{/* decorative glows */}
|
|
<Box
|
|
style={{
|
|
position: "absolute",
|
|
top: -100,
|
|
right: -60,
|
|
width: 300,
|
|
height: 300,
|
|
borderRadius: "50%",
|
|
background: "rgba(255,255,255,0.12)",
|
|
pointerEvents: "none",
|
|
}}
|
|
/>
|
|
<Box
|
|
style={{
|
|
position: "absolute",
|
|
bottom: -130,
|
|
right: 160,
|
|
width: 240,
|
|
height: 240,
|
|
borderRadius: "50%",
|
|
background: "rgba(255,255,255,0.06)",
|
|
pointerEvents: "none",
|
|
}}
|
|
/>
|
|
|
|
<Stack gap="lg" style={{ position: "relative" }}>
|
|
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
|
<Group gap="md" align="center" wrap="nowrap">
|
|
<ThemeIcon size={56} radius="lg" variant="white" style={{ color: freightBrand.primary }}>
|
|
<Inbox size={28} />
|
|
</ThemeIcon>
|
|
<Stack gap={4}>
|
|
<Text size="xs" fw={700} c="rgba(255,255,255,0.8)" tt="uppercase" style={{ letterSpacing: 1 }}>
|
|
Operations
|
|
</Text>
|
|
<Title order={2} c="white" fw={700}>
|
|
Booking Requests
|
|
</Title>
|
|
<Text size="sm" c="rgba(255,255,255,0.85)" maw={520}>
|
|
Track every booking from submission through approval, payment, and
|
|
dispatch — prioritize what needs action.
|
|
</Text>
|
|
</Stack>
|
|
</Group>
|
|
<Group gap="sm">
|
|
<Button
|
|
variant="white"
|
|
c="green.8"
|
|
radius="lg"
|
|
leftSection={<Plus size={18} />}
|
|
onClick={onCreate}
|
|
>
|
|
Create booking
|
|
</Button>
|
|
<Button
|
|
variant="light"
|
|
color="white"
|
|
radius="lg"
|
|
c="white"
|
|
leftSection={<RefreshCw size={16} />}
|
|
loading={isFetching}
|
|
onClick={onRefresh}
|
|
style={{ background: "rgba(255,255,255,0.15)" }}
|
|
>
|
|
Refresh
|
|
</Button>
|
|
</Group>
|
|
</Group>
|
|
|
|
<Group grow gap="md" align="stretch" wrap="wrap">
|
|
<HeroStat
|
|
icon={LayoutList}
|
|
label="In queue"
|
|
value={val(metrics?.inQueue)}
|
|
hint="Matching current filter"
|
|
/>
|
|
<HeroStat
|
|
icon={Clock}
|
|
label="Needs action"
|
|
value={val(metrics?.needsAction)}
|
|
hint="Submitted or pending"
|
|
ratio={metrics?.inQueue ? (metrics.needsAction ?? 0) / metrics.inQueue : 0}
|
|
ratioColor="#fbbf24"
|
|
/>
|
|
<HeroStat
|
|
icon={AlertTriangle}
|
|
label="Urgent"
|
|
value={val(metrics?.urgent)}
|
|
hint="High priority score"
|
|
ratio={metrics?.inQueue ? (metrics.urgent ?? 0) / metrics.inQueue : 0}
|
|
ratioColor="#fca5a5"
|
|
/>
|
|
<HeroStat
|
|
icon={CheckCircle2}
|
|
label="Completed"
|
|
value={val(tabs?.completed)}
|
|
hint="Fully executed"
|
|
/>
|
|
</Group>
|
|
|
|
{tabs ? <PipelineBar tabs={tabs} /> : null}
|
|
</Stack>
|
|
</Paper>
|
|
);
|
|
}
|
|
|
|
/** Compact ring gauge with the stat icon at its center. */
|
|
function MiniDonut({
|
|
pct,
|
|
color = "white",
|
|
children,
|
|
size = 52,
|
|
stroke = 5,
|
|
}: {
|
|
pct?: number | null;
|
|
color?: string;
|
|
children: ReactNode;
|
|
size?: number;
|
|
stroke?: number;
|
|
}) {
|
|
const radius = (size - stroke) / 2;
|
|
const circumference = 2 * Math.PI * radius;
|
|
const clamped =
|
|
pct != null ? Math.min(100, Math.max(0, Math.round(pct))) : null;
|
|
const dash = clamped != null ? (clamped / 100) * circumference : 0;
|
|
|
|
return (
|
|
<Box style={{ position: "relative", width: size, height: size, flexShrink: 0 }}>
|
|
<svg
|
|
width={size}
|
|
height={size}
|
|
style={{ transform: "rotate(-90deg)", display: "block" }}
|
|
>
|
|
<circle
|
|
cx={size / 2}
|
|
cy={size / 2}
|
|
r={radius}
|
|
fill="none"
|
|
stroke="rgba(255,255,255,0.18)"
|
|
strokeWidth={stroke}
|
|
/>
|
|
{clamped != null ? (
|
|
<circle
|
|
cx={size / 2}
|
|
cy={size / 2}
|
|
r={radius}
|
|
fill="none"
|
|
stroke={color}
|
|
strokeWidth={stroke}
|
|
strokeLinecap="round"
|
|
strokeDasharray={`${dash} ${circumference}`}
|
|
style={{ transition: "stroke-dasharray 400ms ease" }}
|
|
/>
|
|
) : null}
|
|
</svg>
|
|
<Box
|
|
style={{
|
|
position: "absolute",
|
|
inset: 0,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
color: "white",
|
|
}}
|
|
>
|
|
{children}
|
|
</Box>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
function HeroStat({
|
|
icon: Icon,
|
|
label,
|
|
value,
|
|
hint,
|
|
ratio,
|
|
ratioColor = "white",
|
|
}: {
|
|
icon: LucideIcon;
|
|
label: string;
|
|
value: ReactNode;
|
|
hint?: string;
|
|
ratio?: number;
|
|
ratioColor?: string;
|
|
}) {
|
|
const pct = ratio != null ? Math.round(Math.min(1, Math.max(0, ratio)) * 100) : null;
|
|
return (
|
|
<Paper
|
|
p="md"
|
|
radius="lg"
|
|
style={{
|
|
flex: "1 1 180px",
|
|
minWidth: 160,
|
|
background: "rgba(255,255,255,0.12)",
|
|
border: "1px solid rgba(255,255,255,0.18)",
|
|
backdropFilter: "blur(6px)",
|
|
}}
|
|
>
|
|
<Group gap="md" wrap="nowrap" align="center">
|
|
<MiniDonut pct={pct} color={ratioColor}>
|
|
<Icon size={19} />
|
|
</MiniDonut>
|
|
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
|
|
<Text size="xs" fw={600} tt="uppercase" c="rgba(255,255,255,0.78)" style={{ letterSpacing: 0.4 }}>
|
|
{label}
|
|
</Text>
|
|
<Text fw={700} size="28px" c="white" lh={1}>
|
|
{value}
|
|
</Text>
|
|
<Text size="xs" c="rgba(255,255,255,0.72)" truncate>
|
|
{pct != null ? `${pct}% of queue` : hint}
|
|
</Text>
|
|
</Stack>
|
|
</Group>
|
|
</Paper>
|
|
);
|
|
}
|
|
|
|
function PipelineBar({ tabs }: { tabs: BookingListSummaryTabs }) {
|
|
const segments = PIPELINE_STAGES.map((s) => ({ ...s, count: tabs[s.key] ?? 0 }));
|
|
const total = segments.reduce((sum, s) => sum + s.count, 0);
|
|
|
|
return (
|
|
<Paper
|
|
p="md"
|
|
radius="lg"
|
|
style={{
|
|
background: "rgba(255,255,255,0.12)",
|
|
border: "1px solid rgba(255,255,255,0.18)",
|
|
backdropFilter: "blur(6px)",
|
|
}}
|
|
>
|
|
<Group justify="space-between" mb={10}>
|
|
<Text size="sm" fw={700} c="white">
|
|
Booking pipeline
|
|
</Text>
|
|
<Text size="xs" c="rgba(255,255,255,0.75)">
|
|
{total} active
|
|
</Text>
|
|
</Group>
|
|
|
|
<Box
|
|
style={{
|
|
display: "flex",
|
|
height: 14,
|
|
borderRadius: 999,
|
|
overflow: "hidden",
|
|
background: "rgba(255,255,255,0.18)",
|
|
gap: 2,
|
|
}}
|
|
>
|
|
{total > 0 ? (
|
|
segments.map((s) =>
|
|
s.count > 0 ? (
|
|
<Box
|
|
key={s.key}
|
|
title={`${s.label}: ${s.count}`}
|
|
style={{
|
|
width: `${(s.count / total) * 100}%`,
|
|
background: s.color,
|
|
transition: "width 200ms ease",
|
|
}}
|
|
/>
|
|
) : null,
|
|
)
|
|
) : (
|
|
<Box style={{ width: "100%" }} />
|
|
)}
|
|
</Box>
|
|
|
|
<Group gap="md" mt={10} wrap="wrap">
|
|
{segments.map((s) => (
|
|
<Group key={s.key} gap={6} wrap="nowrap">
|
|
<Box style={{ width: 9, height: 9, borderRadius: 3, background: s.color }} />
|
|
<Text size="xs" c="rgba(255,255,255,0.85)">
|
|
{s.label}
|
|
</Text>
|
|
<Text size="xs" fw={700} c="white">
|
|
{s.count}
|
|
</Text>
|
|
</Group>
|
|
))}
|
|
</Group>
|
|
</Paper>
|
|
);
|
|
}
|