feat: add recovery incident management module with CRUD operations and incident logging form
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user