Files
aeroresolve_frontend/src/app/recoveryIncidents/components/RecoveryIncidentsList.tsx
T

716 lines
22 KiB
TypeScript

import { useState, useEffect, useCallback, useMemo } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import {
EyeIcon,
PencilSimpleIcon,
CheckCircleIcon,
XCircleIcon,
ClockIcon,
FunnelSimpleIcon,
SquaresFourIcon,
PlusIcon,
CaretDownIcon,
CaretUpIcon,
MagnifyingGlassIcon,
} from "@phosphor-icons/react";
import {
CustomTable,
CustomInput,
CustomButton,
CustomStatus,
CustomCheckBox,
Skeleton,
CustomActionMenu,
CustomActionItem,
CustomAlertBanner,
CustomSuccessModal,
} from "../../../components/custom";
import Can from "../../../components/common/Can";
import type { Column } from "../../../components/custom/CustomTable";
import type {
RecoveryIncident,
MetricCardData,
} from "../RecoveryIncidentsTypes";
import AddRecoveryIncidents from "./AddRecoveryIncidents";
import { MetricCard } from "./MetricCard";
import {
getRecoveryIncidents,
getRecoveryMetrics,
} from "../RecoveryIncidentsApi";
import { formatDate } from "../../../utils/formatDate";
const PAGE_SIZE = 10;
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>
);
}
function getStatusVariant(
status?: string,
): "success" | "error" | "warning" | "info" | "neutral" | "brand" {
if (!status) return "neutral";
const s = status.toLowerCase();
if (s.includes("appr") || s.includes("active") || s.includes("success"))
return "success";
if (s.includes("reject") || s.includes("denied") || s.includes("error"))
return "error";
if (s.includes("pend") || s.includes("review") || s.includes("warn"))
return "warning";
if (s.includes("new") || s.includes("info")) return "info";
return "neutral";
}
// ─── Component ───────────────────────────────────────────────────────────────
export default function RecoveryIncidentsList() {
const navigate = useNavigate();
const location = useLocation();
const [incidents, setIncidents] = useState<RecoveryIncident[]>([]);
const [metrics, setMetrics] = useState<MetricCardData[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [successMsg, setSuccessMsg] = useState<string | null>(null);
useEffect(() => {
if (location.state?.successMsg) {
setSuccessMsg(location.state.successMsg);
window.history.replaceState({}, document.title);
}
}, [location]);
// Pagination
const [currentPage, setCurrentPage] = useState(1);
// Filters
const [search, setSearch] = useState("");
const [isGrouped, setIsGrouped] = useState(() => {
return sessionStorage.getItem("recovery_isGrouped") === "true";
});
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(() => {
const saved = sessionStorage.getItem("recovery_collapsedGroups");
return saved ? new Set(JSON.parse(saved)) : new Set();
});
useEffect(() => {
sessionStorage.setItem("recovery_isGrouped", String(isGrouped));
}, [isGrouped]);
useEffect(() => {
sessionStorage.setItem(
"recovery_collapsedGroups",
JSON.stringify(Array.from(collapsedGroups)),
);
}, [collapsedGroups]);
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingIncident, setEditingIncident] =
useState<RecoveryIncident | null>(null);
// Selection
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
// Success Modal
const [successModalData, setSuccessModalData] = useState<{
isOpen: boolean;
count: number;
flightNumber: string;
passengerName: string;
isEdit?: boolean;
}>({ isOpen: false, count: 0, flightNumber: "", passengerName: "", isEdit: false });
// ─── Fetch data ─────────────────────────────────────────────
const fetchIncidents = useCallback(async () => {
setLoading(true);
try {
const data = await getRecoveryIncidents();
setIncidents(data);
getRecoveryMetrics()
.then((m) => setMetrics(m))
.catch((err) =>
console.error("Failed to fetch recovery metrics:", err),
);
} catch (error) {
console.error("Failed to fetch recovery incidents", error);
setIncidents([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchIncidents();
}, [fetchIncidents]);
useEffect(() => {
if (incidents.length > 0) {
setCollapsedGroups((prev) => {
const hasSaved = sessionStorage.getItem("recovery_hasSavedGroups");
if (hasSaved) return prev;
sessionStorage.setItem("recovery_hasSavedGroups", "true");
return new Set(incidents.map((i) => i.flightNumber || "Other"));
});
}
}, [incidents]);
// ─── 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 = (groupKey: string, e?: React.MouseEvent) => {
if (e) e.stopPropagation();
setCollapsedGroups((prev) => {
const next = new Set(prev);
if (next.has(groupKey)) {
next.delete(groupKey);
} else {
next.add(groupKey);
}
return next;
});
};
const handleStatusChange = async (
incident: RecoveryIncident,
text: string,
) => {
try {
setError(null);
setSuccessMsg(null);
const { updateIncidentStatus } = await import("../RecoveryIncidentsApi");
await updateIncidentStatus(incident.id, text);
fetchIncidents();
setSuccessMsg(`Incident status updated to ${text} successfully.`);
} catch (error) {
console.error("Failed to update status", error);
setError("Failed to update status. Please try again.");
}
};
const filteredIncidents = useMemo(() => {
return incidents.filter((p) => {
const q = search.toLowerCase();
return (
(p.recoveryCode || "").toLowerCase().includes(q) ||
(p.flightNumber || "").toLowerCase().includes(q) ||
(p.passengerName || "").toLowerCase().includes(q) ||
(p.pnr || "").toLowerCase().includes(q)
);
});
}, [incidents, search]);
const displayData = useMemo(() => {
if (!isGrouped) {
return filteredIncidents;
}
// Group items by flight number
const groupMap = new Map<string, RecoveryIncident[]>();
filteredIncidents.forEach((item) => {
const key = item.flightNumber || "Other";
if (!groupMap.has(key)) {
groupMap.set(key, []);
}
groupMap.get(key)!.push(item);
});
const result: (RecoveryIncident & { groupKey?: string })[] = [];
groupMap.forEach((groupItems, flightNo) => {
const firstItem = groupItems[0];
// Calculate status summary for group header
const pendingCount = groupItems.filter((i) => {
const s = (i.status || "").toLowerCase();
return (
s.includes("pending") || s.includes("review") || s.includes("new")
);
}).length;
const approvedCount = groupItems.filter((i) => {
const s = (i.status || "").toLowerCase();
return s.includes("approved") || s.includes("active");
}).length;
const headerStatuses = [
{ text: `${pendingCount} Pending`, variant: "warning" as const },
{ text: `${approvedCount} App`, variant: "success" as const },
];
// Calculate sum of values
const sumVal = groupItems.reduce((acc, curr) => {
const num = parseFloat((curr.value || "").replace(/[^0-9.]/g, "")) || 0;
return acc + num;
}, 0);
const groupKey = flightNo;
// Group Header Row
result.push({
id: `header-${groupKey}`,
recoveryCode: firstItem.recoveryCode,
date: firstItem.date,
flightNumber: flightNo,
flightRoute: firstItem.flightRoute,
category: firstItem.category,
statuses: headerStatuses,
value: `$${sumVal}`,
isGroupHeader: true,
groupKey: groupKey,
});
// Child Rows (if group is expanded)
if (!collapsedGroups.has(groupKey)) {
groupItems.forEach((child) => {
result.push({
...child,
groupKey: groupKey,
} as any);
});
}
});
return result.map((r, i) => ({ ...r, globalIndex: i }));
}, [filteredIncidents, isGrouped, collapsedGroups]);
const totalItems = displayData.length;
const totalPages = Math.ceil(totalItems / PAGE_SIZE) || 1;
const startIndex = totalItems > 0 ? (currentPage - 1) * PAGE_SIZE + 1 : 0;
const endIndex = Math.min(currentPage * PAGE_SIZE, totalItems);
const paginatedDisplayData = useMemo(() => {
const start = (currentPage - 1) * PAGE_SIZE;
return displayData.slice(start, start + PAGE_SIZE);
}, [displayData, currentPage]);
// ─── 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 className={row.isGroupHeader ? "-ml-[40px]" : ""}>
<PrimaryText text={row.recoveryCode} />
<SecondaryText text={formatDate(row.date)} />
</div>
),
},
{
header: (
<HeaderLabel
text="Passenger / PNR"
rightIcon={<FunnelSimpleIcon size={14} weight="bold" />}
/>
),
accessor: (row) =>
row.passengerName ? (
<div>
<PrimaryText text={row.passengerName} />
<SecondaryText text={row.pnr || ""} />
</div>
) : null,
},
{
header: (
<HeaderLabel
text="Flight"
rightIcon={<FunnelSimpleIcon size={14} weight="bold" />}
/>
),
accessor: (row) => (
<div>
<PrimaryText text={row.flightNumber} />
<SecondaryText text={row.flightRoute} />
</div>
),
},
{
header: <HeaderLabel text="Category" />,
accessor: (row) =>
row.isGroupHeader ? null : row.category ? (
<BadgeLabel text={row.category} />
) : null,
},
{
header: <HeaderLabel text="Status" />,
accessor: (row) => {
if (row.isGroupHeader) {
return (
<div className="flex items-center gap-2">
{(row.statuses || []).map((status, idx) => (
<div
key={idx}
className={`px-2.5 py-1 rounded-full text-[11px] font-semibold leading-[16.5px] ${status.variant === "warning" ? "bg-[#FFFBE6] text-[#D48806]" : "bg-[#E4FAE7] text-[#1B9869]"}`}
>
{status.text}
</div>
))}
</div>
);
}
const statusText = row.status || "Pending";
return (
<CustomStatus
status={statusText}
variant={getStatusVariant(statusText)}
/>
);
},
},
{
header: (
<HeaderLabel
text="Value"
rightIcon={<FunnelSimpleIcon size={14} weight="bold" />}
/>
),
accessor: (row) => <PrimaryText text={row.value} />,
},
{
header: <HeaderLabel text="Perks Claimed" />,
accessor: (row) =>
row.isGroupHeader ? null : (
<BadgeLabel text={row.isPerksClaimed ? "Yes" : "No"} />
),
},
{
header: <HeaderLabel text="Action" />,
accessor: (row) => {
const key =
(row as any).groupKey || row.flightNumber || row.recoveryCode;
return (
<div className="flex items-center pl-2">
{row.isGroupHeader ? (
<div
className="cursor-pointer text-gray-500 hover:text-gray-900 transition-colors p-1"
onClick={(e) => toggleGroup(key, e)}
>
{collapsedGroups.has(key) ? (
<CaretDownIcon size={20} />
) : (
<CaretUpIcon size={20} />
)}
</div>
) : (
<CustomActionMenu>
<CustomActionItem
onClick={() => navigate(`/recovery/${row.id}`)}
icon={<EyeIcon size={16} className="text-blue-500" />}
>
View Details
</CustomActionItem>
<Can permission="recovery:edit">
<CustomActionItem
onClick={() => {
setEditingIncident(row);
setIsModalOpen(true);
}}
icon={
<PencilSimpleIcon size={16} className="text-yellow-500" />
}
>
Edit Incident
</CustomActionItem>
</Can>
<Can permission="recovery:override">
<CustomActionItem
icon={
<CheckCircleIcon size={16} className="text-green-500" />
}
onClick={() => handleStatusChange(row, "Approved")}
>
Approve
</CustomActionItem>
</Can>
<Can permission="recovery:override">
<CustomActionItem
variant="danger"
icon={<XCircleIcon size={16} className="text-red-500" />}
onClick={() => handleStatusChange(row, "Rejected")}
>
Reject
</CustomActionItem>
</Can>
<Can permission="recovery:edit">
<CustomActionItem
icon={<ClockIcon size={16} className="text-yellow-600" />}
onClick={() => handleStatusChange(row, "Under Review")}
>
Mark for Review
</CustomActionItem>
</Can>
</CustomActionMenu>
)}
</div>
);
},
},
];
const isAnyGroupExpanded = useMemo(() => {
if (!isGrouped) return true;
const allGroupKeys = Array.from(
new Set(incidents.map((i) => i.flightNumber || "Other")),
);
return allGroupKeys.some((key) => !collapsedGroups.has(key));
}, [isGrouped, incidents, collapsedGroups]);
const activeColumns = useMemo(() => {
if (!isGrouped || isAnyGroupExpanded) {
return columns;
}
return columns.filter((col) => {
const headerText =
typeof col.header === "object" &&
col.header !== null &&
"props" in col.header
? (col.header as any).props.text
: "";
return (
headerText !== "Passenger / PNR" &&
headerText !== "Category" &&
headerText !== "Perks Claimed"
);
});
}, [columns, isGrouped, isAnyGroupExpanded]);
// ─── 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">
{error && (
<CustomAlertBanner
message={error}
type="error"
onClose={() => setError(null)}
/>
)}
{successMsg && (
<CustomAlertBanner
message={successMsg}
type="success"
onClose={() => setSuccessMsg(null)}
/>
)}
{/* Metrics Row */}
<div className="flex gap-4 w-full">
{metrics.map((metric) => (
<MetricCard key={metric.id || metric.title} {...metric} />
))}
</div>
{/* Table Section */}
<CustomTable<RecoveryIncident>
columns={activeColumns}
data={paginatedDisplayData}
leftHeaderActions={
<div className="w-[320px]">
<CustomInput
placeholder="Search framework registry..."
value={search}
onChange={(e) => handleSearchChange(e.target.value)}
leftIcon={<MagnifyingGlassIcon size={16} />}
className="!bg-[#F3F6F5] !rounded-[10px] !h-[40px] !border !border-[#E5E7EB]"
containerClassName="!gap-0"
/>
</div>
}
rightHeaderActions={
<>
<CustomButton
variant="outlined"
size="md"
leftIcon={<SquaresFourIcon 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>
<Can permission="recovery:create">
<CustomButton
variant="primary"
size="md"
leftIcon={<PlusIcon size={16} />}
className="!rounded-[10px] !gap-[8px] !h-[40px] !bg-[#1B9869] hover:!bg-[#14704E]"
onClick={() => {
setEditingIncident(null);
setIsModalOpen(true);
}}
>
New Incident
</CustomButton>
</Can>
</>
}
currentPage={currentPage}
totalPages={totalPages}
totalItems={totalItems}
startIndex={startIndex}
endIndex={endIndex}
onPageChange={handlePageChange}
itemName="Recovery Incidents"
onRowClick={(row) => {
if (row.isGroupHeader) {
const key =
(row as any).groupKey || row.flightNumber || row.recoveryCode;
toggleGroup(key);
} else {
navigate(`/recovery/${row.id}`);
}
}}
rowClassName={(row: any) => {
if (row.isGroupHeader) {
return "bg-white border-b border-gray-100 cursor-pointer hover:bg-gray-50/60";
}
return "bg-[#F9FAFB] border-transparent cursor-pointer hover:bg-[#F3F4F6]";
}}
/>
{/* Modal */}
<AddRecoveryIncidents
isOpen={isModalOpen}
incident={editingIncident}
onClose={() => {
setIsModalOpen(false);
setEditingIncident(null);
fetchIncidents(); // Refresh list
}}
onSuccess={(createdCount, flightNumber, passengerName, isEdit) => {
setSuccessModalData({
isOpen: true,
count: createdCount,
flightNumber,
passengerName,
isEdit,
});
}}
/>
{/* Success Modal */}
<CustomSuccessModal
isOpen={successModalData.isOpen}
onClose={() =>
setSuccessModalData((prev) => ({ ...prev, isOpen: false }))
}
title={
successModalData.isEdit
? (successModalData.count > 1 ? "Recovery Incidents Updated." : "Recovery Incident Updated.")
: (successModalData.count > 1 ? "Recovery Incidents Created." : "Recovery Incident Created.")
}
label="FLIGHT & PASSENGER DETAILS"
cohortName={`Flight ${successModalData.flightNumber}`}
cohortDescription={
successModalData.isEdit
? (successModalData.count > 1
? `Successfully updated incidents for ${successModalData.count} passengers, including ${successModalData.passengerName}.`
: `Successfully updated incident for ${successModalData.passengerName}.`)
: (successModalData.count > 1
? `Successfully logged incidents for ${successModalData.count} passengers, including ${successModalData.passengerName}.`
: `Successfully logged incident for ${successModalData.passengerName}.`)
}
/>
</div>
);
}