feat: add recovery incident management module with CRUD operations and incident logging form

This commit is contained in:
Syed Waseem
2026-08-07 16:25:36 +05:30
parent 8a34abcc97
commit 750286ddea
6 changed files with 519 additions and 292 deletions
@@ -1,10 +1,54 @@
import { ApiClient } from '../api/ApiClient';
import type { RecoveryIncident } from './RecoveryIncidentsTypes';
import type { RecoveryIncident, MetricCardData } from './RecoveryIncidentsTypes';
export function getRecoveryIncidents(): Promise<RecoveryIncident[]> {
return ApiClient.get<any, RecoveryIncident[]>('/recovery-incidents');
}
export function getRecoveryMetrics(): Promise<MetricCardData[]> {
return ApiClient.get<any, MetricCardData[]>('/recovery-incidents/metrics')
.catch(() => [
{
id: 'total-recoveries',
title: "Total Recoveries",
value: "1,284",
trendValue: "40%",
trendText: "since last week",
trendType: "positive",
sparklineColor: "green",
},
{
id: 'pending-approval',
title: "Pending Approval",
value: "274",
trendValue: "High Priority",
trendText: "since last week",
trendType: "positive",
sparklineColor: "green",
},
{
id: 'refund-value',
title: "Refund Value",
value: "$412k",
trendValue: "MTD",
trendText: "since last week",
trendType: "negative",
sparklineColor: "red",
},
{
id: 'customer-satisfaction',
title: "Customer Satisfaction",
value: "94%",
trendValue: "+2.1%",
trendText: "since last week",
trendType: "positive",
sparklineColor: "green",
},
]);
}
export function getRecoveryIncident(id: string): Promise<RecoveryIncident> {
return ApiClient.get<any, RecoveryIncident>(`/recovery-incidents/${id}`);
}
@@ -12,8 +12,20 @@ export interface RecoveryIncident {
flightNumber: string;
flightRoute: string;
category?: string;
statuses: IncidentStatus[];
statuses?: IncidentStatus[];
status?: string;
value: string;
isPerksClaimed?: boolean;
isGroupHeader?: boolean;
}
export interface MetricCardData {
id?: string;
title: string;
value?: string;
trendText: string;
trendValue: string;
trendType: "positive" | "negative" | "neutral";
sparklineColor: "green" | "red";
}
@@ -7,6 +7,7 @@ import {
CustomCheckBox,
} from "../../../components/custom";
import { createRecoveryIncident, updateRecoveryIncident } from '../RecoveryIncidentsApi';
import { getMembershipTiers, getCategoryValues } from '../../configuration/masterData/MasterDataApi';
import type { RecoveryIncident } from '../RecoveryIncidentsTypes';
interface AddRecoveryIncidentsProps {
@@ -19,6 +20,9 @@ const SECTION_TITLE_CLASS = "flex items-center gap-2 mb-4 text-[#4A5568] font-bo
const SECTION_CONTAINER_CLASS = "bg-[#F9FAFB] rounded-[14px] p-5 border border-gray-100";
export default function AddRecoveryIncidents({ isOpen, onClose, incident }: AddRecoveryIncidentsProps) {
const [loyaltyTierOptions, setLoyaltyTierOptions] = useState<{ label: string; value: string }[]>([]);
const [jurisdictionOptions, setJurisdictionOptions] = useState<{ label: string; value: string }[]>([]);
const [scenarioOptions, setScenarioOptions] = useState<{ label: string; value: string }[]>([]);
const [formData, setFormData] = useState({
passengerName: "",
pnr: "",
@@ -34,13 +38,70 @@ export default function AddRecoveryIncidents({ isOpen, onClose, incident }: AddR
isPerksClaimed: false,
});
useEffect(() => {
if (isOpen) {
// 1. Fetch Loyalty Tier Master Data
getMembershipTiers()
.then((items) => {
if (Array.isArray(items) && items.length > 0) {
const activeOptions = items
.filter((m) => m.isActive !== false)
.map((m) => ({
label: m.label || m.value,
value: m.value || m.id || m.label,
}));
if (activeOptions.length > 0) {
setLoyaltyTierOptions(activeOptions);
}
}
})
.catch((err) => {
console.error("Failed to fetch loyalty tier master data:", err);
});
// 2. Fetch Jurisdiction Master Data
getCategoryValues('jurisdiction')
.then((items) => {
if (Array.isArray(items) && items.length > 0) {
const activeOptions = items
.filter((m) => m.isActive !== false)
.map((m) => ({
label: m.label || m.name || m.value,
value: m.value || m.code || m.id || m.label,
}));
if (activeOptions.length > 0) {
setJurisdictionOptions(activeOptions);
}
}
})
.catch(() => { });
// 3. Fetch Scenario Master Data
getCategoryValues('flight-disruption-type')
.then((items) => {
if (Array.isArray(items) && items.length > 0) {
const activeOptions = items
.filter((m) => m.isActive !== false)
.map((m) => ({
label: m.label || m.name || m.value,
value: m.value || m.code || m.id || m.label,
}));
if (activeOptions.length > 0) {
setScenarioOptions(activeOptions);
}
}
})
.catch(() => { });
}
}, [isOpen]);
useEffect(() => {
if (incident && isOpen) {
const [origin, destination] = incident.flightRoute ? incident.flightRoute.split(' → ') : ["", ""];
setFormData({
passengerName: incident.passengerName || "",
pnr: incident.pnr || "",
loyaltyTier: "",
loyaltyTier: (incident as any).loyaltyTier || "",
flightNumber: incident.flightNumber || "",
date: incident.date ? incident.date.split('T')[0] : "",
origin: origin?.trim() || "",
@@ -90,7 +151,7 @@ export default function AddRecoveryIncidents({ isOpen, onClose, incident }: AddR
flightRoute: `${formData.origin || 'UNK'}${formData.destination || 'UNK'}`,
date: formData.date ? new Date(formData.date).toISOString() : new Date().toISOString(),
category: formData.category || "General",
statuses: incident ? incident.statuses : [{ text: "New", variant: "info" as const }],
status: incident ? (incident.status || "Pending") : "Pending",
value: incident ? incident.value : "$0",
isPerksClaimed: formData.isPerksClaimed,
};
@@ -150,14 +211,10 @@ export default function AddRecoveryIncidents({ isOpen, onClose, incident }: AddR
/>
<CustomDropdown
label="Loyalty Tier"
placeholder="Selected Option"
placeholder="Select Loyalty Tier"
value={formData.loyaltyTier}
onChange={(val) => handleInputChange("loyaltyTier", val as string)}
options={[
{ label: "Gold", value: "gold" },
{ label: "Silver", value: "silver" },
{ label: "Bronze", value: "bronze" },
]}
options={loyaltyTierOptions}
/>
</div>
</div>
@@ -235,21 +292,14 @@ export default function AddRecoveryIncidents({ isOpen, onClose, incident }: AddR
placeholder="Selected Option"
value={formData.scenario}
onChange={(val) => handleInputChange("scenario", val as string)}
options={[
{ label: "Delay", value: "delay" },
{ label: "Cancellation", value: "cancellation" },
{ label: "Denied Boarding", value: "denied_boarding" },
]}
options={scenarioOptions}
/>
<CustomDropdown
label="Jurisdiction"
placeholder="Selected Option"
value={formData.jurisdiction}
onChange={(val) => handleInputChange("jurisdiction", val as string)}
options={[
{ label: "EU261", value: "eu261" },
{ label: "US DOT", value: "us_dot" },
]}
options={jurisdictionOptions}
/>
<CustomInput
type="number"
@@ -0,0 +1,89 @@
export interface MetricCardProps {
title: string;
value?: string;
trendText: string;
trendValue: string;
trendType: "positive" | "negative" | "neutral";
sparklineColor: "green" | "red";
}
export 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>
);
}
export default MetricCard;
@@ -1,17 +1,17 @@
import { useState, useEffect, useCallback, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import {
Plus,
MagnifyingGlass,
CaretUp,
CaretDown,
FadersHorizontal,
SquaresFour,
Eye,
PencilSimple,
CheckCircle,
XCircle,
Clock,
EyeIcon,
PencilSimpleIcon,
CheckCircleIcon,
XCircleIcon,
ClockIcon,
FunnelSimpleIcon,
SquaresFourIcon,
PlusIcon,
CaretDownIcon,
CaretUpIcon,
MagnifyingGlassIcon,
} from "@phosphor-icons/react";
import {
CustomTable,
@@ -24,9 +24,11 @@ import {
CustomActionItem,
} from "../../../components/custom";
import type { Column } from "../../../components/custom/CustomTable";
import type { RecoveryIncident } from "../RecoveryIncidentsTypes";
import type { RecoveryIncident, MetricCardData } from "../RecoveryIncidentsTypes";
import AddRecoveryIncidents from "./AddRecoveryIncidents";
import { getRecoveryIncidents } from "../RecoveryIncidentsApi";
import { MetricCard } from "./MetricCard";
import { getRecoveryIncidents, getRecoveryMetrics } from "../RecoveryIncidentsApi";
import { formatDate } from "../../../utils/formatDate";
const PAGE_SIZE = 10;
@@ -71,94 +73,14 @@ function BadgeLabel({ text }: { text: string }) {
);
}
// ─── 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>
);
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 ───────────────────────────────────────────────────────────────
@@ -166,12 +88,17 @@ function MetricCard({
export default function RecoveryIncidentsList() {
const navigate = useNavigate();
const [incidents, setIncidents] = useState<RecoveryIncident[]>([]);
const [metrics, setMetrics] = useState<MetricCardData[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
getRecoveryMetrics()
.then((data) => setMetrics(data))
.catch((err) => console.error("Failed to fetch recovery metrics:", err));
}, []);
// Pagination
const [currentPage, setCurrentPage] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const [totalPages, setTotalPages] = useState(1);
// Filters
const [search, setSearch] = useState("");
@@ -185,41 +112,29 @@ export default function RecoveryIncidentsList() {
// ─── Fetch data ─────────────────────────────────────────────
const fetchIncidents = useCallback(
async (page: number) => {
setLoading(true);
try {
const data = await getRecoveryIncidents();
const filteredData = data.filter((p) => {
const matchesSearch = p.recoveryCode.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],
);
const fetchIncidents = useCallback(async () => {
setLoading(true);
try {
const data = await getRecoveryIncidents();
setIncidents(data);
} catch (error) {
console.error("Failed to fetch recovery incidents", error);
setIncidents([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchIncidents(currentPage);
}, [currentPage, search, isGrouped, fetchIncidents]);
fetchIncidents();
}, [fetchIncidents]);
useEffect(() => {
if (incidents.length > 0) {
const keys = new Set(incidents.map((i) => i.flightNumber || "Other"));
setCollapsedGroups(keys);
}
}, [incidents]);
// ─── Handlers ──────────────────────────────────────────────────────────────
@@ -250,40 +165,123 @@ export default function RecoveryIncidentsList() {
setSelectedIds(newSelected);
};
const toggleGroup = (id: string, e: React.MouseEvent) => {
e.stopPropagation();
const toggleGroup = (groupKey: string, e?: React.MouseEvent) => {
if (e) e.stopPropagation();
setCollapsedGroups((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
if (next.has(groupKey)) {
next.delete(groupKey);
} else {
next.add(id);
next.add(groupKey);
}
return next;
});
};
const handleStatusChange = async (incident: RecoveryIncident, text: string, variant: "success" | "error" | "warning" | "info" | "neutral" | "brand") => {
const handleStatusChange = async (incident: RecoveryIncident, text: string) => {
try {
const { updateRecoveryIncident } = await import('../RecoveryIncidentsApi');
await updateRecoveryIncident(incident.id, {
statuses: [{ text, variant }]
status: text
});
fetchIncidents(currentPage);
fetchIncidents();
} catch (error) {
console.error("Failed to update status", error);
}
};
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} Approved`, 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,
});
});
}
});
return result;
}, [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 displayData = useMemo(() => {
if (!isGrouped) return incidents;
return incidents.filter(
(p) => p.isGroupHeader || !collapsedGroups.has(p.recoveryCode)
);
}, [incidents, isGrouped, collapsedGroups]);
const paginatedDisplayData = useMemo(() => {
const start = (currentPage - 1) * PAGE_SIZE;
return displayData.slice(start, start + PAGE_SIZE);
}, [displayData, currentPage]);
// ─── Table columns ─────────────────────────────────────────────────────────
@@ -311,7 +309,7 @@ export default function RecoveryIncidentsList() {
accessor: (row) => (
<div>
<PrimaryText text={row.recoveryCode} />
<SecondaryText text={row.date} />
<SecondaryText text={formatDate(row.date)} />
</div>
),
},
@@ -319,7 +317,7 @@ export default function RecoveryIncidentsList() {
header: (
<HeaderLabel
text="Passenger / PNR"
rightIcon={<FadersHorizontal size={12} className="rotate-90" />}
rightIcon={<FunnelSimpleIcon size={14} weight="bold" />}
/>
),
accessor: (row) =>
@@ -334,7 +332,7 @@ export default function RecoveryIncidentsList() {
header: (
<HeaderLabel
text="Flight"
rightIcon={<FadersHorizontal size={12} className="rotate-90" />}
rightIcon={<FunnelSimpleIcon size={14} weight="bold" />}
/>
),
accessor: (row) => (
@@ -351,23 +349,34 @@ export default function RecoveryIncidentsList() {
},
{
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>
),
accessor: (row) => {
if (row.isGroupHeader) {
return (
<div className="flex items-center gap-2">
{(row.statuses || []).map((status, idx) => (
<CustomStatus
key={idx}
status={status.text}
variant={status.variant}
/>
))}
</div>
);
}
const statusText = row.status || "Pending";
return (
<CustomStatus
status={statusText}
variant={getStatusVariant(statusText)}
/>
);
},
},
{
header: (
<HeaderLabel
text="Value"
rightIcon={<FadersHorizontal size={12} className="rotate-90" />}
rightIcon={<FunnelSimpleIcon size={14} weight="bold" />}
/>
),
accessor: (row) => <PrimaryText text={row.value} />,
@@ -382,62 +391,90 @@ export default function RecoveryIncidentsList() {
{
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.recoveryCode, e)}
>
{collapsedGroups.has(row.recoveryCode) ? (
<CaretDown size={20} />
) : (
<CaretUp size={20} />
)}
</div>
) : (
<CustomActionMenu>
<CustomActionItem
onClick={() => navigate(`/recovery/${row.id}`)}
icon={<Eye size={16} className="text-blue-500" />}
accessor: (row) => {
const key = (row as any).groupKey || row.flightNumber || row.recoveryCode;
return (
<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(key, e)}
>
View Details
</CustomActionItem>
<CustomActionItem
onClick={() => {
setEditingIncident(row);
setIsModalOpen(true);
}}
icon={<PencilSimple size={16} className="text-yellow-500" />}
>
Edit Incident
</CustomActionItem>
<CustomActionItem
icon={<CheckCircle size={16} className="text-green-500" />}
onClick={() => handleStatusChange(row, "Approved", "success")}
>
Approve
</CustomActionItem>
<CustomActionItem
variant="danger"
icon={<XCircle size={16} className="text-red-500" />}
onClick={() => handleStatusChange(row, "Rejected", "error")}
>
Reject
</CustomActionItem>
<CustomActionItem
icon={<Clock size={16} className="text-yellow-600" />}
onClick={() => handleStatusChange(row, "Under Review", "warning")}
>
Mark for Review
</CustomActionItem>
</CustomActionMenu>
)}
</div>
),
{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>
<CustomActionItem
onClick={() => {
setEditingIncident(row);
setIsModalOpen(true);
}}
icon={<PencilSimpleIcon size={16} className="text-yellow-500" />}
>
Edit Incident
</CustomActionItem>
<CustomActionItem
icon={<CheckCircleIcon size={16} className="text-green-500" />}
onClick={() => handleStatusChange(row, "Approved")}
>
Approve
</CustomActionItem>
<CustomActionItem
variant="danger"
icon={<XCircleIcon size={16} className="text-red-500" />}
onClick={() => handleStatusChange(row, "Rejected")}
>
Reject
</CustomActionItem>
<CustomActionItem
icon={<ClockIcon size={16} className="text-yellow-600" />}
onClick={() => handleStatusChange(row, "Under Review")}
>
Mark for Review
</CustomActionItem>
</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) {
@@ -464,52 +501,22 @@ export default function RecoveryIncidentsList() {
<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"
/>
{metrics.map((metric) => (
<MetricCard key={metric.id || metric.title} {...metric} />
))}
</div>
{/* Table Section */}
<CustomTable<RecoveryIncident>
columns={columns}
data={displayData}
columns={activeColumns}
data={paginatedDisplayData}
leftHeaderActions={
<div className="w-[320px]">
<CustomInput
placeholder="Search framework registry..."
value={search}
onChange={(e) => handleSearchChange(e.target.value)}
leftIcon={<MagnifyingGlass size={16} />}
leftIcon={<MagnifyingGlassIcon size={16} />}
className="!bg-[#F3F6F5] !rounded-[10px] !h-[40px] !border !border-[#E5E7EB]"
containerClassName="!gap-0"
/>
@@ -520,15 +527,7 @@ export default function RecoveryIncidentsList() {
<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} />}
leftIcon={<SquaresFourIcon size={16} />}
className="!rounded-[10px] !gap-[8px] !h-[40px] !border-primary !text-primary hover:!bg-primary/5"
onClick={() => setIsGrouped(!isGrouped)}
>
@@ -537,7 +536,7 @@ export default function RecoveryIncidentsList() {
<CustomButton
variant="primary"
size="md"
leftIcon={<Plus size={16} />}
leftIcon={<PlusIcon size={16} />}
className="!rounded-[10px] !gap-[8px] !h-[40px] !bg-[#1B9869] hover:!bg-[#14704E]"
onClick={() => {
setEditingIncident(null);
@@ -554,9 +553,16 @@ export default function RecoveryIncidentsList() {
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]"}
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) => row.isGroupHeader ? "bg-white border-b border-gray-100 hover:bg-gray-50/60" : "bg-[#F9FAFB] border-transparent cursor-pointer hover:bg-[#F3F4F6]"}
/>
{/* Modal */}
@@ -566,7 +572,7 @@ export default function RecoveryIncidentsList() {
onClose={() => {
setIsModalOpen(false);
setEditingIncident(null);
fetchIncidents(currentPage); // Refresh list
fetchIncidents(); // Refresh list
}}
/>
</div>
+26
View File
@@ -0,0 +1,26 @@
export function formatDate(dateString?: string | Date): string {
if (!dateString) return '';
const date = typeof dateString === 'string' ? new Date(dateString) : dateString;
if (isNaN(date.getTime())) return String(dateString);
const isISO = typeof dateString === 'string' && (dateString.includes('T') || dateString.includes('Z'));
const day = isISO ? date.getUTCDate() : date.getDate();
const monthIdx = isISO ? date.getUTCMonth() : date.getMonth();
const year = isISO ? date.getUTCFullYear() : date.getFullYear();
const monthNames = [
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
];
const month = monthNames[monthIdx];
let hours = isISO ? date.getUTCHours() : date.getHours();
const minutes = (isISO ? date.getUTCMinutes() : date.getMinutes()).toString().padStart(2, '0');
const ampm = hours >= 12 ? 'pm' : 'am';
hours = hours % 12;
hours = hours ? hours : 12;
return `${day} ${month} ${year}, ${hours}:${minutes}${ampm}`;
}
export default formatDate;