636 lines
19 KiB
TypeScript
636 lines
19 KiB
TypeScript
import { useState, useEffect, useCallback, useMemo } from "react";
|
|
import { useNavigate } from "react-router-dom";
|
|
import {
|
|
Plus,
|
|
MagnifyingGlass,
|
|
CaretUp,
|
|
CaretDown,
|
|
DotsThreeVertical,
|
|
FadersHorizontal,
|
|
SquaresFour,
|
|
} from "@phosphor-icons/react";
|
|
import {
|
|
CustomTable,
|
|
CustomInput,
|
|
CustomButton,
|
|
CustomStatus,
|
|
CustomCheckBox,
|
|
Skeleton,
|
|
} from "../../../components/custom";
|
|
import type { Column } from "../../../components/custom/CustomTable";
|
|
import type { RecoveryIncident } from "../RecoveryIncidentsTypes";
|
|
import AddRecoveryIncidents from "./AddRecoveryIncidents";
|
|
import { RecoveryIncidentsApi } from "../RecoveryIncidentsApi";
|
|
|
|
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
|
|
const PAGE_SIZE = 10;
|
|
|
|
// ─── Mock Data ───────────────────────────────────────────────────────────────
|
|
|
|
const MOCK_INCIDENTS: RecoveryIncident[] = [
|
|
// GROUP 1: Q23SXD (FRA -> JFK)
|
|
{
|
|
id: "1",
|
|
recoveryId: "juD4IpfxKVzIyU0bogQ2",
|
|
date: "4 Jun 2026, 4:09pm",
|
|
flightNumber: "Q23SXD",
|
|
flightRoute: "FRA → JFK",
|
|
statuses: [
|
|
{ text: "0 Pending", variant: "warning" },
|
|
{ text: "0 App", variant: "success" },
|
|
],
|
|
value: "$400",
|
|
isGroupHeader: true,
|
|
},
|
|
{
|
|
id: "2",
|
|
recoveryId: "juD4IpfxKVzIyU0bogQ2",
|
|
date: "4 Jun 2026, 4:09pm",
|
|
passengerName: "John Doe",
|
|
pnr: "XZW34R",
|
|
flightNumber: "Q23SXD",
|
|
flightRoute: "FRA → JFK",
|
|
category: "Travel Exp",
|
|
statuses: [{ text: "Active", variant: "success" }],
|
|
value: "$200",
|
|
},
|
|
{
|
|
id: "3",
|
|
recoveryId: "juD4IpfxKVzIyU0bogQ2",
|
|
date: "4 Jun 2026, 4:09pm",
|
|
passengerName: "Jane Smith",
|
|
pnr: "ABC12D",
|
|
flightNumber: "Q23SXD",
|
|
flightRoute: "FRA → JFK",
|
|
category: "Travel Exp",
|
|
statuses: [{ text: "Active", variant: "success" }],
|
|
value: "$200",
|
|
},
|
|
|
|
// GROUP 2: AZ404 (LHR -> CDG)
|
|
{
|
|
id: "4",
|
|
recoveryId: "aZ99LmNoPRtUyX1xyzF3",
|
|
date: "5 Jun 2026, 9:15am",
|
|
flightNumber: "AZ404",
|
|
flightRoute: "LHR → CDG",
|
|
statuses: [
|
|
{ text: "1 Pending", variant: "warning" },
|
|
{ text: "1 App", variant: "success" },
|
|
],
|
|
value: "$550",
|
|
isGroupHeader: true,
|
|
},
|
|
{
|
|
id: "5",
|
|
recoveryId: "aZ99LmNoPRtUyX1xyzF3",
|
|
date: "5 Jun 2026, 9:15am",
|
|
passengerName: "Michael Chang",
|
|
pnr: "LMN89P",
|
|
flightNumber: "AZ404",
|
|
flightRoute: "LHR → CDG",
|
|
category: "Flight Ops",
|
|
statuses: [{ text: "Pending", variant: "warning" }],
|
|
value: "$300",
|
|
},
|
|
{
|
|
id: "6",
|
|
recoveryId: "aZ99LmNoPRtUyX1xyzF3",
|
|
date: "5 Jun 2026, 9:15am",
|
|
passengerName: "Sarah Connor",
|
|
pnr: "QWE45T",
|
|
flightNumber: "AZ404",
|
|
flightRoute: "LHR → CDG",
|
|
category: "Flight Ops",
|
|
statuses: [{ text: "Active", variant: "success" }],
|
|
value: "$250",
|
|
},
|
|
|
|
// GROUP 3: UA990 (SFO -> NRT)
|
|
{
|
|
id: "7",
|
|
recoveryId: "uA77KhJxWVyTz2abcD4",
|
|
date: "6 Jun 2026, 2:30pm",
|
|
flightNumber: "UA990",
|
|
flightRoute: "SFO → NRT",
|
|
statuses: [
|
|
{ text: "0 Pending", variant: "warning" },
|
|
{ text: "0 App", variant: "success" },
|
|
],
|
|
value: "$800",
|
|
isGroupHeader: true,
|
|
},
|
|
{
|
|
id: "8",
|
|
recoveryId: "uA77KhJxWVyTz2abcD4",
|
|
date: "6 Jun 2026, 2:30pm",
|
|
passengerName: "Emily Davis",
|
|
pnr: "RTY56U",
|
|
flightNumber: "UA990",
|
|
flightRoute: "SFO → NRT",
|
|
category: "Travel Exp",
|
|
statuses: [{ text: "Active", variant: "success" }],
|
|
value: "$400",
|
|
},
|
|
{
|
|
id: "9",
|
|
recoveryId: "uA77KhJxWVyTz2abcD4",
|
|
date: "6 Jun 2026, 2:30pm",
|
|
passengerName: "David Miller",
|
|
pnr: "IOP78J",
|
|
flightNumber: "UA990",
|
|
flightRoute: "SFO → NRT",
|
|
category: "Travel Exp",
|
|
statuses: [{ text: "Active", variant: "success" }],
|
|
value: "$400",
|
|
},
|
|
];
|
|
|
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
function HeaderLabel({
|
|
text,
|
|
rightIcon,
|
|
}: {
|
|
text: string;
|
|
rightIcon?: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div className="flex items-center gap-1">
|
|
<span className="text-[13px] font-semibold text-[#6C766D] tracking-[0px]">
|
|
{text}
|
|
</span>
|
|
{rightIcon && <span className="text-[#6C766D]">{rightIcon}</span>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PrimaryText({ text }: { text: string }) {
|
|
return (
|
|
<div className="text-[13px] font-semibold text-[#0F172B] leading-[18px] tracking-[0px]">
|
|
{text}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SecondaryText({ text }: { text: string }) {
|
|
return (
|
|
<div className="text-[12px] font-medium text-[#6C766D] leading-[16px] tracking-[0px] mt-0.5">
|
|
{text}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function BadgeLabel({ text }: { text: string }) {
|
|
return (
|
|
<span className="px-3 py-1 bg-gray-100 text-gray-500 rounded-full text-xs font-semibold">
|
|
{text}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// ─── Metric Card Component ───────────────────────────────────────────────────
|
|
|
|
interface MetricCardProps {
|
|
title: string;
|
|
value?: string;
|
|
trendText: string;
|
|
trendValue: string;
|
|
trendType: "positive" | "negative" | "neutral";
|
|
sparklineColor: "green" | "red";
|
|
}
|
|
|
|
function MetricCard({
|
|
title,
|
|
value,
|
|
trendText,
|
|
trendValue,
|
|
trendType,
|
|
sparklineColor,
|
|
}: MetricCardProps) {
|
|
return (
|
|
<div className="bg-[#F8F9FA] rounded-[16px] p-5 flex flex-col gap-4 flex-1 min-w-[220px]">
|
|
<div className="flex justify-between items-center">
|
|
<span className="text-[14px] font-semibold text-gray-700">{title}</span>
|
|
</div>
|
|
<div className="flex items-end justify-between">
|
|
<div className="flex flex-col gap-1">
|
|
{value && (
|
|
<span className="text-[28px] font-bold text-gray-900 leading-none">
|
|
{value}
|
|
</span>
|
|
)}
|
|
<div className="flex items-center gap-1 mt-1">
|
|
<span
|
|
className={`text-[12px] font-bold ${
|
|
trendType === "positive"
|
|
? "text-[#1B9869]"
|
|
: trendType === "negative"
|
|
? "text-red-500"
|
|
: "text-gray-500"
|
|
}`}
|
|
>
|
|
{trendValue}
|
|
</span>
|
|
<span className="text-[12px] font-medium text-gray-500">
|
|
{trendText}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Simple SVG Sparkline placeholder based on color */}
|
|
<div className="w-[60px] h-[30px] flex items-center justify-end">
|
|
{sparklineColor === "green" ? (
|
|
<svg
|
|
width="60"
|
|
height="24"
|
|
viewBox="0 0 60 24"
|
|
fill="none"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
>
|
|
<path
|
|
d="M2 20C10 20 12 12 20 12C28 12 32 18 40 18C48 18 52 4 58 4"
|
|
stroke="#1B9869"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
/>
|
|
</svg>
|
|
) : (
|
|
<svg
|
|
width="60"
|
|
height="24"
|
|
viewBox="0 0 60 24"
|
|
fill="none"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
>
|
|
<path
|
|
d="M2 4C10 4 12 12 20 12C28 12 32 6 40 6C48 6 52 20 58 20"
|
|
stroke="#EF4444"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
/>
|
|
</svg>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Component ───────────────────────────────────────────────────────────────
|
|
|
|
export default function RecoveryIncidentsList() {
|
|
const navigate = useNavigate();
|
|
const [incidents, setIncidents] = useState<RecoveryIncident[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
// Pagination
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [totalItems, setTotalItems] = useState(0);
|
|
const [totalPages, setTotalPages] = useState(1);
|
|
|
|
// Filters
|
|
const [search, setSearch] = useState("");
|
|
const [isGrouped, setIsGrouped] = useState(true);
|
|
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
|
|
// Selection
|
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
|
|
|
// ─── Fetch data ─────────────────────────────────────────────
|
|
|
|
const fetchIncidents = useCallback(
|
|
async (page: number) => {
|
|
setLoading(true);
|
|
|
|
try {
|
|
const data = await RecoveryIncidentsApi.getAll();
|
|
|
|
const filteredData = data.filter((p) => {
|
|
const matchesSearch = p.recoveryId.toLowerCase().includes(search.toLowerCase()) ||
|
|
p.flightNumber.toLowerCase().includes(search.toLowerCase());
|
|
const matchesGroup = isGrouped ? true : !p.isGroupHeader;
|
|
return matchesSearch && matchesGroup;
|
|
});
|
|
|
|
const total = filteredData.length;
|
|
const pages = Math.ceil(total / PAGE_SIZE);
|
|
const start = (page - 1) * PAGE_SIZE;
|
|
const paginatedData = filteredData.slice(start, start + PAGE_SIZE);
|
|
|
|
setIncidents(paginatedData);
|
|
setTotalItems(total);
|
|
setTotalPages(pages || 1);
|
|
} catch (error) {
|
|
console.error("Failed to fetch recovery incidents", error);
|
|
setIncidents([]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
},
|
|
[search, isGrouped],
|
|
);
|
|
|
|
useEffect(() => {
|
|
fetchIncidents(currentPage);
|
|
}, [currentPage, search, isGrouped, fetchIncidents]);
|
|
|
|
// ─── Handlers ──────────────────────────────────────────────────────────────
|
|
|
|
const handlePageChange = (page: number) => {
|
|
setCurrentPage(page);
|
|
};
|
|
|
|
const handleSearchChange = (val: string) => {
|
|
setSearch(val);
|
|
setCurrentPage(1);
|
|
};
|
|
|
|
const toggleSelectAll = () => {
|
|
if (selectedIds.size === incidents.length && incidents.length > 0) {
|
|
setSelectedIds(new Set());
|
|
} else {
|
|
setSelectedIds(new Set(incidents.map((i) => i.id)));
|
|
}
|
|
};
|
|
|
|
const toggleSelectOne = (id: string) => {
|
|
const newSelected = new Set(selectedIds);
|
|
if (newSelected.has(id)) {
|
|
newSelected.delete(id);
|
|
} else {
|
|
newSelected.add(id);
|
|
}
|
|
setSelectedIds(newSelected);
|
|
};
|
|
|
|
const toggleGroup = (id: string, e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
setCollapsedGroups((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(id)) {
|
|
next.delete(id);
|
|
} else {
|
|
next.add(id);
|
|
}
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const startIndex = totalItems > 0 ? (currentPage - 1) * PAGE_SIZE + 1 : 0;
|
|
const endIndex = Math.min(currentPage * PAGE_SIZE, totalItems);
|
|
|
|
const displayData = useMemo(() => {
|
|
if (!isGrouped) return incidents;
|
|
return incidents.filter(
|
|
(p) => p.isGroupHeader || !collapsedGroups.has(p.recoveryId)
|
|
);
|
|
}, [incidents, isGrouped, collapsedGroups]);
|
|
|
|
// ─── Table columns ─────────────────────────────────────────────────────────
|
|
|
|
const columns: Column<RecoveryIncident>[] = [
|
|
{
|
|
header: (
|
|
<CustomCheckBox
|
|
checked={
|
|
incidents.length > 0 && selectedIds.size === incidents.length
|
|
}
|
|
onChange={toggleSelectAll}
|
|
/>
|
|
),
|
|
className: "w-[40px] pr-0",
|
|
accessor: (row) => row.isGroupHeader ? null : (
|
|
<CustomCheckBox
|
|
checked={selectedIds.has(row.id)}
|
|
onChange={() => toggleSelectOne(row.id)}
|
|
onClick={(e) => e.stopPropagation()}
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
header: <HeaderLabel text="Recovery ID" />,
|
|
accessor: (row) => (
|
|
<div>
|
|
<PrimaryText text={row.recoveryId} />
|
|
<SecondaryText text={row.date} />
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
header: (
|
|
<HeaderLabel
|
|
text="Passenger / PNR"
|
|
rightIcon={<FadersHorizontal size={12} className="rotate-90" />}
|
|
/>
|
|
),
|
|
accessor: (row) =>
|
|
row.passengerName ? (
|
|
<div>
|
|
<PrimaryText text={row.passengerName} />
|
|
<SecondaryText text={row.pnr || ""} />
|
|
</div>
|
|
) : null,
|
|
},
|
|
{
|
|
header: (
|
|
<HeaderLabel
|
|
text="Flight"
|
|
rightIcon={<FadersHorizontal size={12} className="rotate-90" />}
|
|
/>
|
|
),
|
|
accessor: (row) => (
|
|
<div>
|
|
<PrimaryText text={row.flightNumber} />
|
|
<SecondaryText text={row.flightRoute} />
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
header: <HeaderLabel text="Category" />,
|
|
accessor: (row) =>
|
|
row.category ? <BadgeLabel text={row.category} /> : null,
|
|
},
|
|
{
|
|
header: <HeaderLabel text="Status" />,
|
|
accessor: (row) => (
|
|
<div className="flex items-center gap-2">
|
|
{row.statuses.map((status, idx) => (
|
|
<CustomStatus
|
|
key={idx}
|
|
status={status.text}
|
|
variant={status.variant}
|
|
/>
|
|
))}
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
header: (
|
|
<HeaderLabel
|
|
text="Value"
|
|
rightIcon={<FadersHorizontal size={12} className="rotate-90" />}
|
|
/>
|
|
),
|
|
accessor: (row) => <PrimaryText text={row.value} />,
|
|
},
|
|
{
|
|
header: <HeaderLabel text="Action" />,
|
|
className: "text-right",
|
|
accessor: (row) => (
|
|
<div className="flex justify-end pr-2">
|
|
{row.isGroupHeader ? (
|
|
<div
|
|
className="cursor-pointer text-gray-500 hover:text-gray-900 transition-colors p-1"
|
|
onClick={(e) => toggleGroup(row.recoveryId, e)}
|
|
>
|
|
{collapsedGroups.has(row.recoveryId) ? (
|
|
<CaretDown size={20} />
|
|
) : (
|
|
<CaretUp size={20} />
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="cursor-pointer text-gray-500 hover:text-gray-900 transition-colors p-1">
|
|
<DotsThreeVertical size={20} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
),
|
|
},
|
|
];
|
|
|
|
// ─── Loading skeleton ──────────────────────────────────────────────────────
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="w-full flex flex-col bg-white rounded-[20px] shadow-sm border border-gray-100 overflow-hidden">
|
|
<div className="flex items-center justify-between p-4 border-b border-gray-100">
|
|
<Skeleton width={320} height={36} />
|
|
<div className="flex items-center gap-3">
|
|
<Skeleton width={148} height={36} />
|
|
</div>
|
|
</div>
|
|
<div className="p-6 flex flex-col gap-4">
|
|
{Array.from({ length: 6 }).map((_, i) => (
|
|
<Skeleton key={i} height={52} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Render ────────────────────────────────────────────────────────────────
|
|
|
|
return (
|
|
<div className="w-full flex flex-col gap-6">
|
|
{/* Metrics Row */}
|
|
<div className="flex gap-4 w-full">
|
|
<MetricCard
|
|
title="Total Recoveries"
|
|
value="1,284"
|
|
trendValue="40%"
|
|
trendText="since last week"
|
|
trendType="positive"
|
|
sparklineColor="green"
|
|
|
|
/>
|
|
<MetricCard
|
|
title="Pending Approval"
|
|
trendValue="High Priority"
|
|
trendText="since last week"
|
|
trendType="positive"
|
|
value="274"
|
|
sparklineColor="green"
|
|
/>
|
|
<MetricCard
|
|
title="Refund Value"
|
|
value="$412k"
|
|
trendValue="MTD"
|
|
trendText="since last week"
|
|
trendType="negative"
|
|
sparklineColor="red"
|
|
/>
|
|
<MetricCard
|
|
title="Customer Satisfaction"
|
|
value="94%"
|
|
trendValue="+2.1%"
|
|
trendText="since last week"
|
|
trendType="positive"
|
|
sparklineColor="green"
|
|
/>
|
|
</div>
|
|
|
|
{/* Table Section */}
|
|
<CustomTable<RecoveryIncident>
|
|
columns={columns}
|
|
data={displayData}
|
|
leftHeaderActions={
|
|
<div className="w-[320px]">
|
|
<CustomInput
|
|
placeholder="Search framework registry..."
|
|
value={search}
|
|
onChange={(e) => handleSearchChange(e.target.value)}
|
|
leftIcon={<MagnifyingGlass size={16} />}
|
|
className="!bg-[#F3F6F5] !rounded-[10px] !h-[40px] !border !border-[#E5E7EB]"
|
|
containerClassName="!gap-0"
|
|
/>
|
|
</div>
|
|
}
|
|
rightHeaderActions={
|
|
<>
|
|
<CustomButton
|
|
variant="outlined"
|
|
size="md"
|
|
leftIcon={<FadersHorizontal size={16} />}
|
|
className="!rounded-[10px] !gap-[8px] !h-[40px] !border-primary !text-primary hover:!bg-primary/5"
|
|
>
|
|
Filters
|
|
</CustomButton>
|
|
<CustomButton
|
|
variant="outlined"
|
|
size="md"
|
|
leftIcon={<SquaresFour size={16} />}
|
|
className="!rounded-[10px] !gap-[8px] !h-[40px] !border-primary !text-primary hover:!bg-primary/5"
|
|
onClick={() => setIsGrouped(!isGrouped)}
|
|
>
|
|
{isGrouped ? "Ungroup" : "Group by Flight"}
|
|
</CustomButton>
|
|
<CustomButton
|
|
variant="primary"
|
|
size="md"
|
|
leftIcon={<Plus size={16} />}
|
|
className="!rounded-[10px] !gap-[8px] !h-[40px] !bg-[#1B9869] hover:!bg-[#14704E]"
|
|
onClick={() => setIsModalOpen(true)}
|
|
>
|
|
New Incident
|
|
</CustomButton>
|
|
</>
|
|
}
|
|
currentPage={currentPage}
|
|
totalPages={totalPages}
|
|
totalItems={totalItems}
|
|
startIndex={startIndex}
|
|
endIndex={endIndex}
|
|
onPageChange={handlePageChange}
|
|
itemName="Policies"
|
|
onRowClick={(row) => !row.isGroupHeader && navigate(`/recovery/${row.id}`)}
|
|
rowClassName={(row) => row.isGroupHeader ? "bg-white" : "bg-[#F9FAFB] border-transparent cursor-pointer hover:bg-[#F3F4F6]"}
|
|
/>
|
|
|
|
{/* Modal */}
|
|
<AddRecoveryIncidents
|
|
isOpen={isModalOpen}
|
|
onClose={() => {
|
|
setIsModalOpen(false);
|
|
fetchIncidents(currentPage); // Refresh list
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|