Files
aeroresolve_frontend/src/app/recoveryIncidents/components/RecoveryIncidentsList.tsx
T
2026-08-03 14:25:40 +05:30

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>
);
}