Merge pull request 'waseem' (#35) from waseem into development

Reviewed-on: https://gitea.maskantech.in/gitea_admin/aeroresolve_frontend/pulls/35
This commit is contained in:
Syed Waseem khadri Rafai
2026-08-18 03:59:11 +00:00
25 changed files with 1858 additions and 766 deletions
+26 -20
View File
@@ -1,28 +1,34 @@
import { lazy, Suspense } from 'react'
import { Route, Routes, Navigate } from 'react-router-dom'
import Layout from './layout/AppLayout'
import HomePage from './app/dashboard'
import CohortManage from './app/cohartManage'
import PolicyEngineList from './app/policyEngine/components/PolicyEngineList'
import AddPolicyEngine from './app/policyEngine/components/AddPolicyEngine'
import RecoveryIncidentsList from './app/recoveryIncidents/components/RecoveryIncidentsList'
import RecoveryIncidentTabs from './app/recoveryIncidents/tabs/index'
import AuditLogsList from './app/auditLogs/components/AuditLogsList'
import ConfigurationPage from './app/configuration'
import CustomAppLoader from './components/custom/CustomAppLoader'
const HomePage = lazy(() => import('./app/dashboard'))
const CohortManage = lazy(() => import('./app/cohartManage'))
const PolicyEngineList = lazy(() => import('./app/policyEngine/components/PolicyEngineList'))
const AddPolicyEngine = lazy(() => import('./app/policyEngine/components/AddPolicyEngine'))
const RecoveryIncidentsList = lazy(() => import('./app/recoveryIncidents/components/RecoveryIncidentsList'))
const RecoveryIncidentTabs = lazy(() => import('./app/recoveryIncidents/tabs/index'))
const AuditLogsList = lazy(() => import('./app/auditLogs/components/AuditLogsList'))
const ConfigurationPage = lazy(() => import('./app/configuration'))
function AppRoutes() {
return (
<Layout>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/cohorts" element={<CohortManage />} />
<Route path="/policy-engine" element={<PolicyEngineList />} />
<Route path="/policy-engine/add" element={<AddPolicyEngine />} />
<Route path="/policy-engine/edit/:id" element={<AddPolicyEngine />} />
<Route path="/action-builder" element={<Navigate to="/config?tab=action-builder" replace />} />
<Route path="/config" element={<ConfigurationPage />} />
<Route path="/recovery" element={<RecoveryIncidentsList />} />
<Route path="/recovery/:id" element={<RecoveryIncidentTabs />} />
<Route path="/audit-logs" element={<AuditLogsList />} />
</Routes>
<Suspense fallback={<CustomAppLoader />}>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/cohorts" element={<CohortManage />} />
<Route path="/policy-engine" element={<PolicyEngineList />} />
<Route path="/policy-engine/add" element={<AddPolicyEngine />} />
<Route path="/policy-engine/edit/:id" element={<AddPolicyEngine />} />
<Route path="/action-builder" element={<Navigate to="/config?tab=action-builder" replace />} />
<Route path="/config" element={<ConfigurationPage />} />
<Route path="/recovery" element={<RecoveryIncidentsList />} />
<Route path="/recovery/:id" element={<RecoveryIncidentTabs />} />
<Route path="/audit-logs" element={<AuditLogsList />} />
</Routes>
</Suspense>
</Layout>
)
}
+23
View File
@@ -0,0 +1,23 @@
import { ApiClient } from '../api/ApiClient';
import type {
DashboardMetric,
ExposureChartItem,
DisruptionMixItem,
RecentIncident,
} from './DashboardTypes';
export function getDashboardMetrics(): Promise<DashboardMetric[]> {
return ApiClient.get<any, DashboardMetric[]>('/dashboard/metrics');
}
export function getExposureData(): Promise<ExposureChartItem[]> {
return ApiClient.get<any, ExposureChartItem[]>('/dashboard/exposure');
}
export function getDisruptionMixData(): Promise<DisruptionMixItem[]> {
return ApiClient.get<any, DisruptionMixItem[]>('/dashboard/disruption-mix');
}
export function getRecentIncidents(): Promise<RecentIncident[]> {
return ApiClient.get<any, RecentIncident[]>('/dashboard/recent-incidents');
}
+36
View File
@@ -0,0 +1,36 @@
export interface DashboardMetric {
id: string;
title: string;
value: string;
trend: string;
isPositive?: boolean;
sparklineData: number[];
}
export interface ExposureChartItem {
category: string;
amount: number;
displayAmount: string;
color: string;
views?: string;
date?: string;
hasTooltip?: boolean;
}
export interface DisruptionMixItem {
label: string;
percentage: number;
value: string;
color: string;
}
export interface RecentIncident {
id: string;
recoveryId: string;
passengerName: string;
pnr: string;
flightNumber: string;
category: string;
status: string;
value: string;
}
@@ -0,0 +1,103 @@
import React, { useState } from "react";
import type { DisruptionMixItem } from "../DashboardTypes";
interface DisruptionMixChartProps {
data: DisruptionMixItem[];
}
export const DisruptionMixChart: React.FC<DisruptionMixChartProps> = ({ data }) => {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
// Calculate SVG donut stroke offsets
const size = 180;
const strokeWidth = 24;
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
let accumulatedPercent = 0;
return (
<div className="bg-white rounded-[16px] border border-gray-100 p-6 shadow-[0_2px_4px_rgba(0,0,0,0.02)] flex flex-col justify-between h-full">
{/* Header */}
<div>
<h2 className="text-[17px] font-bold text-gray-900 leading-snug">
Disruption Mix
</h2>
<p className="text-[12px] text-gray-400 font-normal mt-0.5">
Distribution of passenger recovery cases.
</p>
</div>
{/* Donut Chart Visual */}
<div className="flex flex-col items-center justify-center my-6 relative">
<div className="relative w-[180px] h-[180px] flex items-center justify-center">
<svg width={size} height={size} className="transform -rotate-90">
{data.map((item, index) => {
const strokeDasharray = `${(item.percentage / 100) * circumference} ${circumference}`;
const strokeDashoffset = -((accumulatedPercent / 100) * circumference);
accumulatedPercent += item.percentage;
const isHovered = hoveredIndex === index;
return (
<circle
key={item.label}
cx={size / 2}
cy={size / 2}
r={radius}
fill="transparent"
stroke={item.color}
strokeWidth={isHovered ? strokeWidth + 4 : strokeWidth}
strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset}
className="transition-all duration-300 cursor-pointer"
onMouseEnter={() => setHoveredIndex(index)}
onMouseLeave={() => setHoveredIndex(null)}
/>
);
})}
</svg>
{/* Donut Center Info */}
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
<span className="text-[22px] font-bold text-gray-900 leading-none">
{hoveredIndex !== null ? `${data[hoveredIndex].percentage}%` : "100%"}
</span>
<span className="text-[11px] font-medium text-gray-400 mt-1">
{hoveredIndex !== null ? data[hoveredIndex].label : "Total Cases"}
</span>
</div>
</div>
</div>
{/* Legend Grid */}
<div className="grid grid-cols-2 gap-3 pt-2">
{data.map((item, index) => (
<div
key={item.label}
className={`flex items-center justify-between p-2 rounded-lg transition-colors cursor-pointer ${
hoveredIndex === index ? "bg-gray-50" : ""
}`}
onMouseEnter={() => setHoveredIndex(index)}
onMouseLeave={() => setHoveredIndex(null)}
>
<div className="flex items-center gap-2">
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: item.color }}
/>
<span className="text-[12px] font-medium text-gray-700">
{item.label}
</span>
</div>
<span className="text-[12px] font-bold text-gray-900">
{item.percentage}%
</span>
</div>
))}
</div>
</div>
);
};
export default DisruptionMixChart;
@@ -0,0 +1,107 @@
import React, { useState } from "react";
import type { ExposureChartItem } from "../DashboardTypes";
interface ExposureBarChartProps {
data: ExposureChartItem[];
}
export const ExposureBarChart: React.FC<ExposureBarChartProps> = ({ data }) => {
const [activeBar, setActiveBar] = useState<string | null>("Baggage");
const maxAmount = 60;
const yAxisTicks = ["$50", "$50", "$50", "$30", "$10"];
return (
<div className="bg-white rounded-[16px] border border-gray-100 p-6 shadow-[0_2px_4px_rgba(0,0,0,0.02)] flex flex-col justify-between h-full">
{/* Chart Title Header */}
<div className="mb-6">
<h2 className="text-[17px] font-bold text-gray-900 leading-snug">
Refund & Compensation Exposure
</h2>
<p className="text-[12px] text-gray-400 font-normal mt-0.5">
Real-time assessment log of refund and compensation cases.
</p>
</div>
{/* Bar Chart Body */}
<div className="relative w-full h-[280px] flex flex-col justify-end pt-8 pb-2">
{/* Horizontal Background Grid Lines */}
<div className="absolute inset-0 flex flex-col justify-between pointer-events-none pb-8 pt-8 pl-12 pr-4">
{yAxisTicks.map((_, idx) => (
<div key={idx} className="w-full border-b border-gray-100/80 h-0" />
))}
</div>
{/* Chart Content Area */}
<div className="flex w-full h-full relative z-10">
{/* Y-Axis Labels */}
<div className="flex flex-col justify-between pr-4 pb-8 text-[11px] font-medium text-gray-400 text-right w-12 select-none">
{yAxisTicks.map((tick, idx) => (
<span key={idx} className="leading-none">
{tick}
</span>
))}
</div>
{/* Bars Grid */}
<div className="flex-1 grid grid-cols-4 items-end gap-6 px-4 pb-8 h-full">
{data.map((item) => {
const heightPercent = Math.min(100, Math.max(10, (item.amount / maxAmount) * 100));
const isSelected = activeBar === item.category || (item.hasTooltip && !activeBar);
return (
<div
key={item.category}
className="relative flex flex-col items-center justify-end h-full group cursor-pointer"
onMouseEnter={() => setActiveBar(item.category)}
onMouseLeave={() => setActiveBar("Baggage")}
>
{/* Tooltip Bubble (Visible for selected bar e.g. Baggage) */}
{isSelected && (item.hasTooltip || activeBar === item.category) && (
<div className="absolute -top-14 z-20 flex flex-col items-center animate-fadeIn">
<div className="bg-white border border-gray-100 shadow-md rounded-[8px] px-3 py-1.5 text-center whitespace-nowrap min-w-[120px]">
<p className="text-[11px] font-bold text-gray-800 leading-tight">
{item.views || `${item.amount} cases`}
</p>
<p className="text-[10px] text-gray-400 font-medium leading-tight mt-0.5">
{item.date || "Monday, April 22nd"}
</p>
</div>
{/* Tooltip Caret */}
<div className="w-2.5 h-2.5 bg-white border-r border-b border-gray-100 transform rotate-45 -mt-1.5 shadow-xs" />
</div>
)}
{/* Bar Element */}
<div
className="w-full max-w-[110px] rounded-t-[4px] transition-all duration-300 relative group-hover:brightness-105"
style={{
height: `${heightPercent}%`,
backgroundColor: item.color,
}}
/>
</div>
);
})}
</div>
</div>
{/* X-Axis Category Labels */}
<div className="grid grid-cols-4 pl-12 pr-4 text-center">
{data.map((item) => (
<span
key={item.category}
className={`text-[12px] font-medium transition-colors ${
activeBar === item.category ? "text-gray-900 font-semibold" : "text-gray-500"
}`}
>
{item.category}
</span>
))}
</div>
</div>
</div>
);
};
export default ExposureBarChart;
@@ -0,0 +1,85 @@
import React from "react";
import type { RecentIncident } from "../DashboardTypes";
import { CustomTable, CustomStatus } from "../../../components/custom";
import type { Column } from "../../../components/custom/CustomTable";
interface RecentIncidentsTableProps {
incidents: RecentIncident[];
}
export const RecentIncidentsTable: React.FC<RecentIncidentsTableProps> = ({ incidents }) => {
const columns: Column<RecentIncident>[] = [
{
header: "Recovery ID",
accessor: (row) => (
<span className="text-[13px] font-bold text-gray-900 font-mono">
{row.recoveryId}
</span>
),
},
{
header: "Passenger / PNR",
accessor: (row) => (
<span className="text-[13px] font-bold text-gray-900">
{row.passengerName}
</span>
),
},
{
header: "Flight",
accessor: (row) => (
<span className="text-[13px] font-bold text-gray-900">
{row.flightNumber}
</span>
),
},
{
header: "Category",
accessor: (row) => (
<span className="inline-flex items-center px-3 py-1 rounded-full text-[11px] font-semibold bg-[#EFEFEF] text-gray-600">
{row.category}
</span>
),
},
{
header: "Status",
accessor: (row) => <CustomStatus status={row.status} />,
},
{
header: "Value",
accessor: (row) => (
<span className="text-[13px] font-bold text-gray-900">
{row.value}
</span>
),
},
];
return (
<div className="bg-white rounded-[16px] border border-gray-100 p-6 shadow-[0_2px_4px_rgba(0,0,0,0.02)] flex flex-col gap-5">
{/* Table Header Section */}
<div>
<h2 className="text-[17px] font-bold text-gray-900 leading-snug">
Recent Recovery Incidents
</h2>
<p className="text-[12px] text-gray-400 font-normal mt-0.5">
Real-time assessment log of refund and compensation cases.
</p>
</div>
{/* Custom Table Component */}
<CustomTable<RecentIncident>
columns={columns}
data={incidents}
itemName="incidents"
totalItems={incidents.length}
startIndex={incidents.length > 0 ? 1 : 0}
endIndex={incidents.length}
totalPages={1}
currentPage={1}
/>
</div>
);
};
export default RecentIncidentsTable;
+45
View File
@@ -0,0 +1,45 @@
import React from "react";
import type { DashboardMetric } from "../DashboardTypes";
export const StatCard: React.FC<{ metric: DashboardMetric }> = ({ metric }) => {
return (
<div className="bg-white rounded-[14px] p-4 px-5 border border-gray-100/80 shadow-[0_2px_4px_rgba(0,0,0,0.02)] flex flex-col justify-between flex-1 min-w-[170px] hover:shadow-md transition-all duration-200">
<div>
<span className="text-[13px] font-medium text-gray-600 block">
{metric.title}
</span>
<span className="text-[26px] font-bold text-gray-900 leading-tight tracking-tight mt-1.5 block">
{metric.value}
</span>
</div>
<div className="flex items-center justify-between mt-3 pt-1">
<span className="text-[12px] font-bold text-[#1B9869] bg-[#E4FAE7]/60 px-2 py-0.5 rounded-full inline-flex items-center">
{metric.trend}
</span>
{/* Sparkline curve */}
<div className="w-[60px] h-[26px] flex items-center justify-end">
<svg
width="60"
height="26"
viewBox="0 0 60 26"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="overflow-visible"
>
<path
d="M 2 20 Q 12 22, 20 15 T 40 18 T 58 6"
stroke="#1B9869"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
</div>
</div>
);
};
export default StatCard;
+4 -10
View File
@@ -1,10 +1,4 @@
function HomePage() {
return (
<section className="page home-page">
<h1>Home Page</h1>
<p>Welcome to the home page. Use the navigation links to switch pages.</p>
</section>
)
}
export default HomePage
export { StatCard } from "./StatCard";
export { ExposureBarChart } from "./ExposureBarChart";
export { DisruptionMixChart } from "./DisruptionMixChart";
export { RecentIncidentsTable } from "./RecentIncidentsTable";
+138 -372
View File
@@ -1,383 +1,149 @@
import { useState } from "react";
import CustomButton from "../../components/custom/CustomButton";
import CustomSearchableDropdown from "../../components/custom/CustomSearchableDropdown";
import CustomDropdown from "../../components/custom/CustomDropdown";
import CustomMultiSelect from "../../components/custom/CustomMultiSelect";
import CustomModal from "../../components/custom/CustomModal";
import CustomInput from "../../components/custom/CustomInput";
import CustomTextArea from "../../components/custom/CustomTextArea";
import CustomStatus from "../../components/custom/CustomStatus";
import CustomCheckBox from "../../components/custom/CustomCheckBox";
import CustomRadio from "../../components/custom/CustomRadio";
import CustomSwitch from "../../components/custom/CustomSwitch";
import CustomTable, { type Column } from "../../components/custom/CustomTable";
import CustomActionMenu, { CustomActionItem } from "../../components/custom/CustomActionMenu";
import { FileTextIcon, CaretRightIcon, CheckIcon, XIcon, CopyIcon, PencilSimpleIcon, TrashIcon, PlusIcon } from "@phosphor-icons/react";
import { useState, useEffect } from "react";
import { Skeleton } from "../../components/custom";
import {
StatCard,
ExposureBarChart,
DisruptionMixChart,
RecentIncidentsTable,
} from "./components";
import type {
DashboardMetric,
ExposureChartItem,
DisruptionMixItem,
RecentIncident,
} from "./DashboardTypes";
import {
getDashboardMetrics,
getExposureData,
getDisruptionMixData,
getRecentIncidents,
} from "./DashboardApi";
function HomePage() {
const [searchValue, setSearchValue] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [dropdownValue, setDropdownValue] = useState("active");
const [multiSelectValue, setMultiSelectValue] = useState<(string | number)[]>(["active"]);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isChecked, setIsChecked] = useState(true);
const [radioValue, setRadioValue] = useState("option1");
const [isSwitchOn, setIsSwitchOn] = useState(true);
export default function HomePage() {
const [metrics, setMetrics] = useState<DashboardMetric[]>([]);
const [exposureData, setExposureData] = useState<ExposureChartItem[]>([]);
const [disruptionMix, setDisruptionMix] = useState<DisruptionMixItem[]>([]);
const [recentIncidents, setRecentIncidents] = useState<RecentIncident[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const tableData = [
{
name: "Strategic Accounts",
description: "Key corporate account travelers.",
status: "active",
lastModified: "4 Jun 2026, 4:09pm",
lastModifiedBy: "John Doe",
},
{
name: "Families with Infants",
description: "Passengers traveling with children < 2yrs.",
status: "active",
lastModified: "4 Jun 2026, 4:09pm",
lastModifiedBy: "John Doe",
},
{
name: "Inactive Users",
description: "Users who haven't booked in 12 months.",
status: "inactive",
lastModified: "3 Jun 2026, 2:15pm",
lastModifiedBy: "Jane Smith",
},
];
useEffect(() => {
async function loadDashboardData() {
try {
setLoading(true);
const [metricsRes, exposureRes, mixRes, incidentsRes] = await Promise.all([
getDashboardMetrics(),
getExposureData(),
getDisruptionMixData(),
getRecentIncidents(),
]);
const tableColumns: Column<typeof tableData[0]>[] = [
{
header: "Cohort Name",
accessor: (row) => <span className="font-bold text-gray-900">{row.name}</span>,
},
{
header: "Description",
accessor: "description",
},
{
header: "Status",
accessor: (row) => <CustomStatus status={row.status} />,
},
{
header: "Last Modified",
accessor: "lastModified",
sortable: true,
},
{
header: "Last Modified By",
accessor: "lastModifiedBy",
filterable: true,
},
{
header: "Action",
accessor: () => (
<div className="flex justify-center w-full">
<CustomActionMenu>
<CustomActionItem variant="success" icon={<CheckIcon size={16} />}>Activate</CustomActionItem>
<CustomActionItem icon={<XIcon size={16} />}>Deactivate</CustomActionItem>
<CustomActionItem icon={<CopyIcon size={16} />}>Duplicate</CustomActionItem>
<CustomActionItem icon={<PencilSimpleIcon size={16} />}>Edit</CustomActionItem>
<CustomActionItem variant="danger" icon={<TrashIcon size={16} />}>Delete</CustomActionItem>
</CustomActionMenu>
setMetrics(metricsRes);
setExposureData(exposureRes);
setDisruptionMix(mixRes);
setRecentIncidents(incidentsRes);
} catch (error) {
console.error("Error loading dashboard data:", error);
} finally {
setLoading(false);
}
}
loadDashboardData();
}, []);
if (loading) {
return (
<div>
{/* Top Stat Cards Skeleton */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="bg-white rounded-[14px] p-5 border border-gray-100/80 flex flex-col justify-between h-[100px]">
<div className="space-y-2">
<Skeleton width="60%" height={16} />
<Skeleton width="40%" height={28} />
</div>
<div className="flex items-center justify-between mt-2">
<Skeleton width={45} height={18} />
<Skeleton width={50} height={20} />
</div>
</div>
))}
</div>
),
className: "text-center w-24",
},
];
const options = [
{ label: "Draft", value: "draft" },
{ label: "Active", value: "active" },
{ label: "Inactive", value: "inactive" },
];
{/* Middle Section Skeleton */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 items-stretch">
<div className="lg:col-span-8 bg-white rounded-[16px] border border-gray-100 p-6 flex flex-col justify-between h-[360px]">
<div className="space-y-2">
<Skeleton width="40%" height={22} />
<Skeleton width="60%" height={14} />
</div>
<div className="flex items-end gap-6 h-[240px] pt-8">
<Skeleton className="flex-1" height="60%" />
<Skeleton className="flex-1" height="90%" />
<Skeleton className="flex-1" height="40%" />
<Skeleton className="flex-1" height="85%" />
</div>
</div>
<div className="lg:col-span-4 bg-white rounded-[16px] border border-gray-100 p-6 flex flex-col justify-between h-[360px]">
<div className="space-y-2">
<Skeleton width="50%" height={22} />
<Skeleton width="70%" height={14} />
</div>
<div className="flex items-center justify-center my-4">
<Skeleton variant="circular" width={160} height={160} />
</div>
<div className="grid grid-cols-2 gap-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} height={24} />
))}
</div>
</div>
</div>
{/* Bottom Table Skeleton */}
<div className="bg-white rounded-[16px] border border-gray-100 p-6 space-y-4">
<div className="space-y-2">
<Skeleton width="30%" height={22} />
<Skeleton width="50%" height={14} />
</div>
<div className="space-y-3 pt-2">
<Skeleton height={40} />
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} height={48} />
))}
</div>
</div>
</div>
);
}
return (
<div className="p-8 max-w-5xl mx-auto flex flex-col gap-8">
<div className="p-4 md:p-6 lg:p-4 space-y-6">
{/* 1. Top Stat Cards Row */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
{metrics.map((metric) => (
<StatCard key={metric.id} metric={metric} />
))}
</div>
{/* 2. Middle Section: Refund Exposure + Disruption Mix */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 items-stretch">
{/* Refund & Compensation Exposure (approx 7 cols) */}
<div className="lg:col-span-8 flex flex-col">
<ExposureBarChart data={exposureData} />
</div>
{/* Disruption Mix (approx 4 cols) */}
<div className="lg:col-span-4 flex flex-col">
<DisruptionMixChart data={disruptionMix} />
</div>
</div>
{/* 3. Bottom Section: Recent Recovery Incidents */}
<div>
<h1 className="text-2xl font-bold mb-2 text-gray-900">Dashboard (UI Test Page)</h1>
<p className="text-gray-500">Test all button variants and the custom components here.</p>
<RecentIncidentsTable incidents={recentIncidents} />
</div>
<div className="bg-white p-8 rounded-[20px] border border-gray-100 shadow-sm flex flex-col gap-6">
<h2 className="text-[15px] font-bold text-gray-900 border-b border-gray-100 pb-3">Table Component</h2>
<CustomTable
columns={tableColumns}
data={tableData}
searchValue={searchValue}
onSearchChange={setSearchValue}
searchPlaceholder="Search cohorts..."
currentPage={currentPage}
totalPages={3}
totalItems={12}
startIndex={1}
endIndex={3}
onPageChange={setCurrentPage}
itemName="cohorts"
rightHeaderActions={
<div className="flex items-center gap-4">
<div className="w-40">
<CustomDropdown
options={options}
value="active"
placeholder="Choose Status"
size="md"
/>
</div>
<CustomButton variant="primary" size="md" leftIcon={<PlusIcon size={16} />}>
Create Cohort
</CustomButton>
</div>
}
/>
</div>
<div className="bg-white p-8 rounded-[20px] border border-gray-100 shadow-sm flex flex-col gap-6">
<h2 className="text-[15px] font-bold text-gray-900 border-b border-gray-100 pb-3">Modal Components</h2>
<div className="flex flex-wrap gap-4 items-center">
<CustomButton variant="primary" onClick={() => setIsModalOpen(true)}>
Open Test Modal
</CustomButton>
</div>
</div>
<div className="bg-white p-8 rounded-[20px] border border-gray-100 shadow-sm flex flex-col gap-6">
<h2 className="text-[15px] font-bold text-gray-900 border-b border-gray-100 pb-3">Status Pills & Toggles</h2>
<div className="flex flex-col gap-6">
<div className="flex flex-wrap gap-4 items-center">
<span className="text-sm font-semibold w-24">Status Pills:</span>
<CustomStatus status="active" />
<CustomStatus status="inactive" />
<CustomStatus status="pending" />
<CustomStatus status="shipped" />
<CustomStatus status="neutral" />
</div>
<div className="flex flex-wrap gap-4 items-center">
<span className="text-sm font-semibold w-24">Checkboxes:</span>
<CustomCheckBox checked={isChecked} onChange={() => setIsChecked(!isChecked)} label="Checked State" />
<CustomCheckBox checked={false} onChange={() => {}} label="Unchecked State" />
</div>
<div className="flex flex-wrap gap-4 items-center">
<span className="text-sm font-semibold w-24">Radios:</span>
<CustomRadio checked={radioValue === "option1"} onChange={() => setRadioValue("option1")} label="Option 1" />
<CustomRadio checked={radioValue === "option2"} onChange={() => setRadioValue("option2")} label="Option 2" />
</div>
<div className="flex flex-wrap gap-4 items-center">
<span className="text-sm font-semibold w-24">Switches:</span>
<CustomSwitch checked={isSwitchOn} onChange={() => setIsSwitchOn(!isSwitchOn)} label="Toggle Feature" />
<CustomSwitch checked={false} onChange={() => {}} label="Off Toggle" />
</div>
</div>
</div>
<div className="bg-white p-8 rounded-[20px] border border-gray-100 shadow-sm flex flex-col gap-6">
<h2 className="text-[15px] font-bold text-gray-900 border-b border-gray-100 pb-3">Button Variants</h2>
<div className="flex flex-wrap gap-4 items-center">
<CustomButton variant="primary">Primary</CustomButton>
<CustomButton variant="secondary">Secondary</CustomButton>
<CustomButton variant="outlined">Outlined</CustomButton>
<CustomButton variant="text">Text Button</CustomButton>
<CustomButton variant="link">Link Button</CustomButton>
</div>
<div className="flex flex-wrap gap-4 items-center mt-2">
<CustomButton variant="primary" size="sm">Small</CustomButton>
<CustomButton variant="primary" size="md">Medium</CustomButton>
<CustomButton variant="primary" size="lg">Large</CustomButton>
<CustomButton variant="primary" loading>Loading</CustomButton>
<CustomButton variant="primary" disabled>Disabled</CustomButton>
</div>
</div>
<div className="bg-white p-8 rounded-[20px] border border-gray-100 shadow-sm flex flex-col gap-6">
<h2 className="text-[15px] font-bold text-gray-900 border-b border-gray-100 pb-3">Input & TextArea Variants</h2>
<div className="flex flex-wrap gap-6 items-end">
<div className="w-48">
<CustomInput
label="Small (sm)"
size="sm"
placeholder="Enter text..."
/>
</div>
<div className="w-56">
<CustomInput
label="Medium (md)"
size="md"
placeholder="Enter text..."
/>
</div>
<div className="w-64">
<CustomInput
label="Large (lg)"
size="lg"
placeholder="Enter text..."
/>
</div>
</div>
<div className="flex flex-wrap gap-6 items-end mt-4">
<div className="w-48">
<CustomTextArea
label="Small (sm)"
size="sm"
placeholder="Enter text..."
rows={2}
/>
</div>
<div className="w-56">
<CustomTextArea
label="Medium (md)"
size="md"
placeholder="Enter text..."
rows={3}
/>
</div>
<div className="w-64">
<CustomTextArea
label="Large (lg)"
size="lg"
placeholder="Enter text..."
rows={4}
/>
</div>
</div>
</div>
<div className="bg-white p-8 rounded-[20px] border border-gray-100 shadow-sm flex flex-col gap-6">
<h2 className="text-[15px] font-bold text-gray-900 border-b border-gray-100 pb-3">Standard Dropdown Variants</h2>
<div className="flex flex-wrap gap-6 items-end">
<div className="w-48">
<CustomDropdown
label="Small (sm)"
size="sm"
options={options}
value={dropdownValue}
onChange={setDropdownValue}
/>
</div>
<div className="w-56">
<CustomDropdown
label="Medium (md)"
size="md"
options={options}
value={dropdownValue}
onChange={setDropdownValue}
/>
</div>
</div>
</div>
<div className="bg-white p-8 rounded-[20px] border border-gray-100 shadow-sm flex flex-col gap-6">
<h2 className="text-[15px] font-bold text-gray-900 border-b border-gray-100 pb-3">Searchable Dropdown Variants</h2>
<div className="flex flex-wrap gap-6 items-end">
<div className="w-48">
<CustomSearchableDropdown
label="Small (sm)"
size="sm"
options={options}
value={dropdownValue}
onChange={setDropdownValue}
placeholder="Small size..."
/>
</div>
<div className="w-56">
<CustomSearchableDropdown
label="Medium (md)"
size="md"
options={options}
value={dropdownValue}
onChange={setDropdownValue}
placeholder="Medium size..."
/>
</div>
<div className="w-64">
<CustomSearchableDropdown
label="Large (lg)"
size="lg"
options={options}
value={dropdownValue}
onChange={setDropdownValue}
placeholder="Large size..."
/>
</div>
</div>
</div>
<div className="bg-white p-8 rounded-[20px] border border-gray-100 shadow-sm flex flex-col gap-6 mb-32">
<h2 className="text-[15px] font-bold text-gray-900 border-b border-gray-100 pb-3">Multi-Select Dropdown</h2>
<div className="w-[400px]">
<CustomMultiSelect
label="Select Multiple Statuses"
options={options}
value={multiSelectValue}
onChange={setMultiSelectValue}
placeholder="Select statuses..."
/>
</div>
</div>
<CustomModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
title="New Dynamic Cohorts"
description="Configure targeting criteria for high-precision recovery."
icon={<FileTextIcon size={24} />}
primaryAction={{
label: "Next Step",
onClick: () => setIsModalOpen(false),
icon: <CaretRightIcon size={18} />
}}
secondaryAction={{
label: "Cancel",
onClick: () => setIsModalOpen(false)
}}
size="md"
>
<div className="flex flex-col gap-6 h-64">
<div className="flex justify-between border-b border-gray-100 pb-2">
<div>
<span className="text-[10px] font-bold text-[#1B9869] tracking-wider uppercase">1 - Information</span>
<h3 className="text-sm font-bold text-[#1B9869]">Cohort Identity</h3>
</div>
<div className="text-right">
<span className="text-[10px] font-bold text-gray-400 tracking-wider uppercase">2 - Targeting</span>
<h3 className="text-sm font-bold text-gray-400">Targeting Criteria</h3>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="w-full">
<label className="text-xs font-bold text-gray-700 mb-1 block">Cohort Name</label>
<input type="text" placeholder="Enter" className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-[#1B9869]" />
</div>
<div className="w-full">
<CustomDropdown
label="Status"
options={[{label: "Selected Option", value: "selected"}]}
value="selected"
/>
</div>
</div>
<div className="w-full">
<label className="text-xs font-bold text-gray-700 mb-1 block">Description</label>
<textarea placeholder="Enter" className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm h-24 focus:outline-none focus:border-[#1B9869]"></textarea>
</div>
</div>
</CustomModal>
</div>
)
);
}
export default HomePage
+14 -3
View File
@@ -6,6 +6,9 @@ export interface OptionItem {
value: string;
id?: string;
code?: string;
groupName?: string;
groupHeader?: boolean;
disabled?: boolean;
}
export interface ActionTypeField {
@@ -249,9 +252,17 @@ export function getPolicies(page = 1, limit = 10, search = ''): Promise<Paginate
})
: '—';
const jName = typeof p.jurisdiction === 'object' && p.jurisdiction !== null
? p.jurisdiction.label || p.jurisdiction.name || p.jurisdiction.code
: p.jurisdiction || 'GLOBAL';
let jName = 'GLOBAL';
if (Array.isArray(p.jurisdictions) && p.jurisdictions.length > 0) {
const names = p.jurisdictions
.map((j: any) => j.jurisdiction?.label || j.jurisdiction?.name || j.jurisdiction?.code || j.jurisdictionId)
.filter(Boolean);
jName = names.length > 0 ? names.join(', ') : 'GLOBAL';
} else if (typeof p.jurisdiction === 'object' && p.jurisdiction !== null) {
jName = p.jurisdiction.label || p.jurisdiction.name || p.jurisdiction.code || 'GLOBAL';
} else if (p.jurisdiction) {
jName = p.jurisdiction;
}
return {
...p,
@@ -189,16 +189,26 @@ export default function AddPolicyEngine() {
for (const g of groups) {
const fields = await getConditionFieldsForGroup(g.id || g.code);
fields.forEach((f: any) => {
const val = f.id || f.code;
if (fields && fields.length > 0) {
allFields.push({
label: `${g.name} ${f.name}`,
value: val,
id: f.id,
code: f.code,
label: g.name,
value: `header_${g.id || g.code}`,
groupHeader: true,
disabled: true,
});
metaMap[val] = f;
});
fields.forEach((f: any) => {
const val = f.id || f.code;
allFields.push({
label: f.name,
value: val,
id: f.id,
code: f.code,
groupName: g.name,
});
metaMap[val] = f;
});
}
}
setCategoryConditionMap((prev) => ({
@@ -326,7 +336,7 @@ export default function AddPolicyEngine() {
// Policy Info State
const [policyName, setPolicyName] = useState('');
const [jurisdiction, setJurisdiction] = useState('');
const [jurisdiction, setJurisdiction] = useState<(string | number)[]>([]);
const [status, setStatus] = useState<'Active' | 'Inactive'>('Active');
const [description, setDescription] = useState('');
@@ -354,11 +364,21 @@ export default function AddPolicyEngine() {
if (!data) return;
setPolicyName(data.policyName || data.name || '');
setJurisdiction(
typeof data.jurisdiction === 'object' && data.jurisdiction !== null
? data.jurisdiction.code || data.jurisdiction.id
: data.jurisdictionId || data.jurisdiction || ''
);
if (Array.isArray(data.jurisdictions)) {
setJurisdiction(
data.jurisdictions
.map((j: any) => (typeof j === 'object' ? j.jurisdictionId || j.jurisdiction?.id || j.jurisdiction?.code || j.id : j))
.filter(Boolean),
);
} else if (Array.isArray(data.jurisdictionIds)) {
setJurisdiction(data.jurisdictionIds);
} else {
const singleJur =
typeof data.jurisdiction === 'object' && data.jurisdiction !== null
? data.jurisdiction.code || data.jurisdiction.id
: data.jurisdictionId || data.jurisdiction || '';
setJurisdiction(singleJur ? [singleJur] : []);
}
setStatus(
data.status?.toLowerCase() === 'active' ? 'Active' : 'Inactive'
);
@@ -521,7 +541,8 @@ export default function AddPolicyEngine() {
try {
const payload = {
policyName: policyName.trim(),
jurisdictionId: jurisdiction || undefined,
jurisdictionId: Array.isArray(jurisdiction) && jurisdiction.length > 0 ? String(jurisdiction[0]) : undefined,
jurisdictionIds: Array.isArray(jurisdiction) ? jurisdiction.map(String) : [],
description: description || undefined,
status: isDeploy ? 'active' : 'draft',
audienceType: audienceType === 'Selected Cohorts' ? 'COHORT' : 'ALL',
@@ -785,12 +806,12 @@ export default function AddPolicyEngine() {
/>
</div>
<div className="flex flex-col gap-2">
<CustomDropdown
<CustomMultiSelect
label='Jurisdiction'
options={jurisdictionOptions}
value={jurisdiction}
onChange={setJurisdiction}
placeholder="Selected Option"
placeholder="Select Jurisdictions"
/>
</div>
<div className="flex flex-col gap-2">
@@ -1,7 +1,13 @@
import { ApiClient } from '../api/ApiClient';
import type { RecoveryIncident, MetricCardData } from './RecoveryIncidentsTypes';
export interface AuditTrailStepDto {
id: string;
title: string;
description: string;
timestamp: string;
status: 'completed' | 'current' | 'pending' | 'rejected';
}
export function getRecoveryIncidents(): Promise<RecoveryIncident[]> {
return ApiClient.get<any, RecoveryIncident[]>('/recovery-incidents');
@@ -53,6 +59,10 @@ export function getRecoveryIncident(id: string): Promise<RecoveryIncident> {
return ApiClient.get<any, RecoveryIncident>(`/recovery-incidents/${id}`);
}
export function getIncidentAuditTrail(id: string): Promise<AuditTrailStepDto[]> {
return ApiClient.get<any, AuditTrailStepDto[]>(`/recovery-incidents/${id}/audit-trail`);
}
export function createRecoveryIncident(data: Omit<RecoveryIncident, 'id'>): Promise<RecoveryIncident> {
return ApiClient.post<any, RecoveryIncident>('/recovery-incidents', data);
}
@@ -61,6 +71,10 @@ export function updateRecoveryIncident(id: string, data: Partial<RecoveryInciden
return ApiClient.patch<any, RecoveryIncident>(`/recovery-incidents/${id}`, data);
}
export function reRunPolicyEngine(id: string): Promise<RecoveryIncident> {
return ApiClient.post<any, RecoveryIncident>(`/recovery-incidents/${id}/evaluate`, {});
}
export function updateIncidentStatus(id: string, status: string): Promise<RecoveryIncident> {
return ApiClient.patch<any, RecoveryIncident>(`/recovery-incidents/${id}/status`, { status });
}
@@ -3,20 +3,62 @@ export interface IncidentStatus {
variant: "success" | "error" | "warning" | "info" | "neutral" | "brand";
}
export interface IncidentEvaluationAction {
id: string;
evaluationId?: string;
actionTypeCode?: string;
title: string;
category: string; // 'Financial Refund' | 'Compensation & Perks' | 'Passenger Care'
amount?: number;
currency?: string;
status: string; // 'Pending Approval' | 'Automated' | 'Issued'
sequence: number;
description?: string;
}
export interface IncidentEvaluation {
updatedAt: unknown;
createdAt: any;
id: string;
incidentId?: string;
policyId?: string;
policyName: string;
recoveryScore: number;
matchedCohortName?: string;
status: string;
aiAssessment?: string;
actions: IncidentEvaluationAction[];
}
export interface RecoveryIncident {
createdAt: string;
updatedAt: string;
id: string;
recoveryCode: string;
date: string;
passengerName?: string;
pnr?: string;
loyaltyTier?: string;
passengerType?: string;
nationality?: string;
specialAssistance?: string;
cabinClass?: string;
originalCabin?: string;
actualCabin?: string;
flightNumber: string;
flightRoute: string;
origin?: string;
destination?: string;
category?: string;
scenario?: string;
jurisdiction?: string;
delayDuration?: number;
statuses?: IncidentStatus[];
status?: string;
value: string;
isPerksClaimed?: boolean;
isGroupHeader?: boolean;
evaluation?: IncidentEvaluation;
}
export interface MetricCardData {
@@ -28,4 +70,3 @@ export interface MetricCardData {
trendType: "positive" | "negative" | "neutral";
sparklineColor: "green" | "red";
}
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { FileText, User, Airplane, WarningCircle, CaretRight } from '@phosphor-icons/react';
import { FileText, User, Airplane, WarningCircle, CaretRight, Lightning } from '@phosphor-icons/react';
import {
CustomModal,
CustomInput,
@@ -8,6 +8,11 @@ import {
} from "../../../components/custom";
import { createRecoveryIncident, updateRecoveryIncident } from '../RecoveryIncidentsApi';
import { getMembershipTiers, getCategoryValues } from '../../configuration/masterData/MasterDataApi';
import {
getMockFlightNumbers,
searchDisruptionOrPassenger,
type MockPassenger,
} from '../disruptionMockService';
import type { RecoveryIncident } from '../RecoveryIncidentsTypes';
interface AddRecoveryIncidentsProps {
@@ -19,10 +24,36 @@ interface AddRecoveryIncidentsProps {
const SECTION_TITLE_CLASS = "flex items-center gap-2 mb-4 text-[#4A5568] font-bold text-xs tracking-wider uppercase";
const SECTION_CONTAINER_CLASS = "bg-[#F9FAFB] rounded-[14px] p-5 border border-gray-100";
const DEFAULT_CATEGORY_OPTIONS = [
{ label: "Flight Ops", value: "flight_ops" },
{ label: "Travel Exp", value: "travel_exp" },
{ label: "Weather", value: "weather" },
{ label: "Technical Fault", value: "technical_fault" },
];
const DEFAULT_SCENARIO_OPTIONS = [
{ label: "Delayed Flight", value: "delayed_flight" },
{ label: "Cancelled Flight", value: "cancelled_flight" },
{ label: "Missed Connection", value: "missed_connection" },
];
const DEFAULT_JURISDICTION_OPTIONS = [
{ label: "EU261 (European Union)", value: "EU261" },
{ label: "US DOT (United States)", value: "US_DOT" },
{ label: "UK261 (United Kingdom)", value: "UK261" },
{ label: "CAA SG (Singapore)", value: "CAA_SG" },
];
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 [, setLoyaltyTierOptions] = useState<{ label: string; value: string }[]>([]);
const [jurisdictionOptions, setJurisdictionOptions] = useState<{ label: string; value: string }[]>(DEFAULT_JURISDICTION_OPTIONS);
const [scenarioOptions, setScenarioOptions] = useState<{ label: string; value: string }[]>(DEFAULT_SCENARIO_OPTIONS);
const [query, setQuery] = useState<string>('');
const [passengersList, setPassengersList] = useState<MockPassenger[]>([]);
const [selectedPassengerIds, setSelectedPassengerIds] = useState<string[]>([]);
const [autoFilledNotice, setAutoFilledNotice] = useState<string | null>(null);
const [formData, setFormData] = useState({
passengerName: "",
pnr: "",
@@ -38,9 +69,22 @@ export default function AddRecoveryIncidents({ isOpen, onClose, incident }: AddR
isPerksClaimed: false,
});
const flightNumberOptions = getMockFlightNumbers();
// Flexible option matcher to bridge master data codes/labels with mock response codes
const matchOption = (val: string | undefined, options: { label: string; value: string }[]) => {
if (!val) return '';
const lowerVal = val.toLowerCase().replace(/[^a-z0-9]/g, '');
const found = options.find((opt) => {
const lowerValOpt = opt.value.toLowerCase().replace(/[^a-z0-9]/g, '');
const lowerLabelOpt = opt.label.toLowerCase().replace(/[^a-z0-9]/g, '');
return lowerValOpt === lowerVal || lowerLabelOpt === lowerVal || lowerValOpt.includes(lowerVal) || lowerVal.includes(lowerValOpt);
});
return found ? found.value : val;
};
useEffect(() => {
if (isOpen) {
// 1. Fetch Loyalty Tier Master Data
getMembershipTiers()
.then((items) => {
if (Array.isArray(items) && items.length > 0) {
@@ -50,16 +94,11 @@ export default function AddRecoveryIncidents({ isOpen, onClose, incident }: AddR
label: m.label || m.value,
value: m.value || m.id || m.label,
}));
if (activeOptions.length > 0) {
setLoyaltyTierOptions(activeOptions);
}
if (activeOptions.length > 0) setLoyaltyTierOptions(activeOptions);
}
})
.catch((err) => {
console.error("Failed to fetch loyalty tier master data:", err);
});
.catch(() => { });
// 2. Fetch Jurisdiction Master Data
getCategoryValues('jurisdiction')
.then((items) => {
if (Array.isArray(items) && items.length > 0) {
@@ -69,14 +108,11 @@ export default function AddRecoveryIncidents({ isOpen, onClose, incident }: AddR
label: m.label || m.name || m.value,
value: m.value || m.code || m.id || m.label,
}));
if (activeOptions.length > 0) {
setJurisdictionOptions(activeOptions);
}
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) {
@@ -86,9 +122,7 @@ export default function AddRecoveryIncidents({ isOpen, onClose, incident }: AddR
label: m.label || m.name || m.value,
value: m.value || m.code || m.id || m.label,
}));
if (activeOptions.length > 0) {
setScenarioOptions(activeOptions);
}
if (activeOptions.length > 0) setScenarioOptions(activeOptions);
}
})
.catch(() => { });
@@ -107,11 +141,25 @@ export default function AddRecoveryIncidents({ isOpen, onClose, incident }: AddR
origin: origin?.trim() || "",
destination: destination?.trim() || "",
category: incident.category || "",
scenario: "",
jurisdiction: "",
delayDuration: "",
scenario: (incident as any).scenario || "",
jurisdiction: (incident as any).jurisdiction || "",
delayDuration: (incident as any).delayDuration ? String((incident as any).delayDuration) : "",
isPerksClaimed: incident.isPerksClaimed || false,
});
const initialQuery = incident.flightNumber || incident.pnr || "";
setQuery(initialQuery);
const res = searchDisruptionOrPassenger(initialQuery);
if (res.disruption) {
const passengers = res.disruption.passengers || [];
setPassengersList(passengers);
if (res.matchedPassengerId) {
setSelectedPassengerIds([res.matchedPassengerId]);
} else {
setSelectedPassengerIds(passengers.map((p) => p.id));
}
}
setAutoFilledNotice(null);
} else if (isOpen) {
setFormData({
passengerName: "",
@@ -127,6 +175,10 @@ export default function AddRecoveryIncidents({ isOpen, onClose, incident }: AddR
delayDuration: "",
isPerksClaimed: false,
});
setQuery('');
setPassengersList([]);
setSelectedPassengerIds([]);
setAutoFilledNotice(null);
}
}, [incident, isOpen]);
@@ -140,49 +192,189 @@ export default function AddRecoveryIncidents({ isOpen, onClose, incident }: AddR
setFormData((prev) => ({ ...prev, [field]: checked }));
};
// Helper when user types or selects flight number or PNR
const handleQueryChange = (val: string) => {
setQuery(val);
const res = searchDisruptionOrPassenger(val);
if (res.disruption) {
const d = res.disruption;
const passengers = d.passengers || [];
setPassengersList(passengers);
if (res.matchedPassengerId) {
setSelectedPassengerIds([res.matchedPassengerId]);
} else {
// Select all passengers by default when loading a flight manifest
setSelectedPassengerIds(passengers.map((p) => p.id));
}
const matchedCategory = matchOption(d.category, DEFAULT_CATEGORY_OPTIONS);
const matchedScenario = matchOption(d.scenario, scenarioOptions);
const matchedJurisdiction = matchOption(d.jurisdiction, jurisdictionOptions);
const firstP = passengers[0];
setFormData((prev) => ({
...prev,
flightNumber: d.flightNumber,
date: d.date,
origin: d.origin,
destination: d.destination,
category: matchedCategory,
scenario: matchedScenario,
jurisdiction: matchedJurisdiction,
delayDuration: String(d.delayDuration),
passengerName: firstP ? firstP.passengerName : prev.passengerName,
pnr: firstP ? firstP.pnr : prev.pnr,
loyaltyTier: firstP ? firstP.loyaltyTier : prev.loyaltyTier,
}));
const categoryLabel = DEFAULT_CATEGORY_OPTIONS.find((c) => c.value === matchedCategory)?.label || matchedCategory;
const scenarioLabel = scenarioOptions.find((s) => s.value === matchedScenario)?.label || matchedScenario;
const jurisdictionLabel = jurisdictionOptions.find((j) => j.value === matchedJurisdiction)?.label || matchedJurisdiction;
setAutoFilledNotice(
`Disruption Mock Synced • Flight ${d.flightNumber} (${d.origin}${d.destination}) • Jurisdiction: ${jurisdictionLabel} • Category: ${categoryLabel} • Scenario: ${scenarioLabel} • Delay: ${d.delayDuration} mins • ${passengers.length} passenger(s) on manifest.`
);
} else {
setPassengersList([]);
setSelectedPassengerIds([]);
setAutoFilledNotice(null);
setFormData((prev) => ({ ...prev, flightNumber: val }));
}
};
// Select all / Deselect all passengers
const handleToggleSelectAll = () => {
if (selectedPassengerIds.length === passengersList.length) {
setSelectedPassengerIds([]);
} else {
setSelectedPassengerIds(passengersList.map((p) => p.id));
}
};
// Toggle individual passenger row selection
const handleTogglePassengerRow = (passengerId: string) => {
setSelectedPassengerIds((prev) => {
const exists = prev.includes(passengerId);
let updated: string[];
if (exists) {
updated = prev.filter((id) => id !== passengerId);
} else {
updated = [...prev, passengerId];
}
const primaryP = passengersList.find((p) => p.id === (updated[0] || passengerId));
if (primaryP) {
setFormData((f) => ({
...f,
passengerName: updated.length > 1 ? `${primaryP.passengerName} (+${updated.length - 1} more)` : primaryP.passengerName,
pnr: primaryP.pnr,
loyaltyTier: primaryP.loyaltyTier,
}));
}
return updated;
});
};
const handleSubmit = async () => {
setLoading(true);
try {
const payload = {
recoveryCode: incident ? incident.recoveryCode : "REC-" + Math.floor(Math.random() * 10000),
passengerName: formData.passengerName || "Unknown",
pnr: formData.pnr || "N/A",
flightNumber: formData.flightNumber || "TBD",
flightRoute: `${formData.origin || 'UNK'}${formData.destination || 'UNK'}`,
date: formData.date ? new Date(formData.date).toISOString() : new Date().toISOString(),
category: formData.category || "General",
status: incident ? (incident.status || "Pending") : "Pending",
value: incident ? incident.value : "$0",
isPerksClaimed: formData.isPerksClaimed,
};
const selectedPassengers = passengersList.filter((p) => selectedPassengerIds.includes(p.id));
console.log("Submitting payload:", payload);
if (incident) {
await updateRecoveryIncident(incident.id, payload);
if (selectedPassengers.length > 1 && !incident) {
// Batch log incidents for all selected passengers
const promises = selectedPassengers.map((p) => {
const payload = {
recoveryCode: "REC-" + Math.floor(Math.random() * 100000),
passengerName: p.passengerName,
pnr: p.pnr,
loyaltyTier: p.loyaltyTier,
passengerType: p.passengerType,
nationality: p.nationality,
specialAssistance: p.specialAssistance,
cabinClass: p.cabinClass,
originalCabin: p.originalCabin,
actualCabin: p.actualCabin,
flightNumber: formData.flightNumber || "TBD",
flightRoute: `${formData.origin || 'UNK'}${formData.destination || 'UNK'}`,
origin: formData.origin || undefined,
destination: formData.destination || undefined,
date: formData.date ? new Date(formData.date).toISOString() : new Date().toISOString(),
category: formData.category || "General",
status: "Pending",
value: "$0",
isPerksClaimed: formData.isPerksClaimed,
jurisdiction: formData.jurisdiction || undefined,
delayDuration: formData.delayDuration ? Number(formData.delayDuration) : undefined,
scenario: formData.scenario || undefined,
};
return createRecoveryIncident(payload);
});
await Promise.all(promises);
} else {
await createRecoveryIncident(payload);
// Single passenger log or edit
const primaryPassenger = selectedPassengers[0];
const payload = {
recoveryCode: incident ? incident.recoveryCode : "REC-" + Math.floor(Math.random() * 100000),
passengerName: primaryPassenger ? primaryPassenger.passengerName : (formData.passengerName || "Unknown"),
pnr: primaryPassenger ? primaryPassenger.pnr : (formData.pnr || "N/A"),
loyaltyTier: primaryPassenger ? primaryPassenger.loyaltyTier : (formData.loyaltyTier || undefined),
passengerType: primaryPassenger ? primaryPassenger.passengerType : undefined,
nationality: primaryPassenger ? primaryPassenger.nationality : undefined,
specialAssistance: primaryPassenger ? primaryPassenger.specialAssistance : undefined,
cabinClass: primaryPassenger ? primaryPassenger.cabinClass : undefined,
originalCabin: primaryPassenger ? primaryPassenger.originalCabin : undefined,
actualCabin: primaryPassenger ? primaryPassenger.actualCabin : undefined,
flightNumber: formData.flightNumber || "TBD",
flightRoute: `${formData.origin || 'UNK'}${formData.destination || 'UNK'}`,
origin: formData.origin || undefined,
destination: formData.destination || undefined,
date: formData.date ? new Date(formData.date).toISOString() : new Date().toISOString(),
category: formData.category || "General",
status: incident ? (incident.status || "Pending") : "Pending",
value: incident ? incident.value : "$0",
isPerksClaimed: formData.isPerksClaimed,
jurisdiction: formData.jurisdiction || undefined,
delayDuration: formData.delayDuration ? Number(formData.delayDuration) : undefined,
scenario: formData.scenario || undefined,
};
if (incident) {
await updateRecoveryIncident(incident.id, payload);
} else {
await createRecoveryIncident(payload);
}
}
onClose(); // Will trigger refresh in parent list
onClose();
} catch (error: any) {
console.error("Error saving incident:", error.response?.data || error.message || error);
alert(`Failed to save incident: ${error.response?.data?.message || 'Unknown error'}`);
console.error("Error saving incident(s):", error.response?.data || error.message || error);
alert(`Failed to save incident(s): ${error.response?.data?.message || 'Unknown error'}`);
} finally {
setLoading(false);
}
};
const isAllSelected = passengersList.length > 0 && selectedPassengerIds.length === passengersList.length;
return (
<CustomModal
isOpen={isOpen}
onClose={onClose}
title={incident ? "Edit Recovery Incident" : "New Recovery Incident"}
description="Log a disruption case and assess against policy frameworks"
description="Log disruption cases and assess against policy frameworks"
icon={<FileText className="text-[#1B9869]" />}
size="lg"
primaryAction={{
label: loading ? "Saving..." : (incident ? "Save Changes" : "Assess & Log Incident"),
label: loading
? "Saving..."
: incident
? "Save Changes"
: selectedPassengerIds.length > 1
? `Assess & Log (${selectedPassengerIds.length}) Incidents`
: "Assess & Log Incident",
onClick: handleSubmit,
icon: <CaretRight size={16} />
icon: <CaretRight size={16} />,
}}
secondaryAction={{
label: "Discard",
@@ -190,69 +382,158 @@ export default function AddRecoveryIncidents({ isOpen, onClose, incident }: AddR
}}
>
<div className="flex flex-col gap-5">
{/* PASSENGER IDENTITY */}
{/* SEARCH FLIGHT NUMBER / PNR CARD */}
<div className={SECTION_CONTAINER_CLASS}>
<div className={SECTION_TITLE_CLASS}>
<User size={16} />
<span>Passenger Identity</span>
</div>
<div className="grid grid-cols-2 gap-4">
<CustomInput
label="Full Name"
placeholder="e.g. John Doe"
value={formData.passengerName}
onChange={(e) => handleInputChange("passengerName", e.target.value)}
/>
<CustomInput
label="PNR Reference"
placeholder="e.g. FT7687T9I"
value={formData.pnr}
onChange={(e) => handleInputChange("pnr", e.target.value)}
/>
<CustomDropdown
label="Loyalty Tier"
placeholder="Select Loyalty Tier"
value={formData.loyaltyTier}
onChange={(val) => handleInputChange("loyaltyTier", val as string)}
options={loyaltyTierOptions}
/>
<div>
<label className="block text-xs font-semibold text-[#4A5568] mb-1.5">
Flight Number or PNR Reference
</label>
<CustomDropdown
placeholder="e.g. FT7687T9I or B7687YT"
value={query}
onChange={(val) => handleQueryChange(val as string)}
options={flightNumberOptions}
/>
</div>
<div>
<CustomInput
type="date"
label="Date"
placeholder="Selected Option"
value={formData.date}
onChange={(e) => handleInputChange("date", e.target.value)}
/>
</div>
</div>
</div>
{/* FLIGHT CONTEXT */}
{/* AUTO FILLED NOTICE BANNER */}
{autoFilledNotice && (
<div className="flex items-center gap-3 p-3 bg-emerald-50 border border-emerald-200 text-emerald-800 rounded-xl text-xs font-medium">
<Lightning size={18} weight="fill" className="text-emerald-600 shrink-0" />
<span className="flex-1">{autoFilledNotice}</span>
</div>
)}
{/* PASSENGERS TABLE (SUPPORTING SELECT ALL & MULTI-SELECT) */}
{passengersList.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden shadow-xs">
<div className="px-4 py-3 bg-[#F9FAFB] border-b border-gray-200 flex items-center justify-between">
<span className="text-xs font-bold text-gray-700 tracking-wider uppercase flex items-center gap-2">
<User size={16} className="text-[#1B9869]" />
Passenger Manifest Details (Selected: {selectedPassengerIds.length} of {passengersList.length})
</span>
<button
type="button"
onClick={handleToggleSelectAll}
className="text-xs text-[#1B9869] hover:underline font-semibold cursor-pointer"
>
{isAllSelected ? "Deselect All" : "Select All Passengers"}
</button>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="bg-[#F8FAFC] border-b border-gray-200 text-[#718096] font-semibold">
<th className="py-3 px-4 w-10 text-center">
<input
type="checkbox"
checked={isAllSelected}
onChange={handleToggleSelectAll}
className="w-4 h-4 text-[#1B9869] rounded border-gray-300 focus:ring-[#1B9869] cursor-pointer"
title="Select All Passengers"
/>
</th>
<th className="py-3 px-4">PNR Number</th>
<th className="py-3 px-4">Passanger name</th>
<th className="py-3 px-4">Passanger type</th>
<th className="py-3 px-4">Booked Cabin</th>
<th className="py-3 px-4">Assigned Cabin</th>
<th className="py-3 px-4">Nationality</th>
<th className="py-3 px-4">loyality type</th>
<th className="py-3 px-4">Special assisstance</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{passengersList.map((p) => {
const isSelected = selectedPassengerIds.includes(p.id);
const isDowngraded = Boolean(p.originalCabin && p.actualCabin && p.originalCabin !== p.actualCabin);
return (
<tr
key={p.id}
onClick={() => handleTogglePassengerRow(p.id)}
className={`cursor-pointer transition-colors ${isSelected
? 'bg-emerald-50/70 border-l-4 border-l-[#1B9869]'
: 'hover:bg-gray-50/80'
}`}
>
<td className="py-3 px-4 text-center" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={isSelected}
onChange={() => handleTogglePassengerRow(p.id)}
className="w-4 h-4 text-[#1B9869] rounded border-gray-300 focus:ring-[#1B9869] cursor-pointer"
/>
</td>
<td className="py-3 px-4 font-mono font-medium text-gray-900">{p.pnr}</td>
<td className="py-3 px-4 font-semibold text-gray-800">{p.passengerName}</td>
<td className="py-3 px-4 text-gray-600">{p.passengerType}</td>
<td className="py-3 px-4 text-gray-700 font-medium">{p.originalCabin || p.cabinClass || 'Economy'}</td>
<td className="py-3 px-4">
<span
className={`inline-flex items-center px-2 py-0.5 rounded text-[11px] font-semibold ${isDowngraded ? 'bg-amber-100 text-amber-900 border border-amber-300' : 'text-gray-700'
}`}
>
{p.actualCabin || p.cabinClass || 'Economy'}
{isDowngraded && <span className="ml-1 text-[10px] text-amber-700 font-bold">(Downgraded)</span>}
</span>
</td>
<td className="py-3 px-4 text-gray-600">{p.nationality}</td>
<td className="py-3 px-4">
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[11px] font-semibold ${p.loyaltyTier === 'Platinum'
? 'bg-purple-100 text-purple-800'
: p.loyaltyTier === 'Gold'
? 'bg-amber-100 text-amber-800'
: p.loyaltyTier === 'Silver'
? 'bg-slate-100 text-slate-700'
: 'bg-orange-100 text-orange-800'
}`}
>
{p.loyaltyTier}
</span>
</td>
<td className="py-3 px-4 text-gray-600">{p.specialAssistance}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
{/* FLIGHT CONTEXT DETAILS */}
<div className={SECTION_CONTAINER_CLASS}>
<div className={SECTION_TITLE_CLASS}>
<Airplane size={16} />
<span>Flight Context</span>
<span>Flight Context & Route</span>
</div>
<div className="grid grid-cols-2 gap-4">
<CustomDropdown
label="Flight Number"
placeholder="e.g. B7687YT"
value={formData.flightNumber}
onChange={(val) => handleInputChange("flightNumber", val as string)}
options={[
{ label: "B7687YT", value: "B7687YT" },
{ label: "Q23SXD", value: "Q23SXD" },
{ label: "AZ404", value: "AZ404" },
]}
/>
<CustomInput
type="date"
label="Date"
placeholder="Selected Option"
value={formData.date}
onChange={(e) => handleInputChange("date", e.target.value)}
/>
<CustomDropdown
label="Origin (IATA)"
placeholder="Selected Option"
value={formData.origin}
onChange={(val) => handleInputChange("origin", val as string)}
options={[
{ label: "FRA", value: "FRA" },
{ label: "LHR", value: "LHR" },
{ label: "SFO", value: "SFO" },
{ label: "FRA - Frankfurt", value: "FRA" },
{ label: "LHR - London Heathrow", value: "LHR" },
{ label: "SFO - San Francisco", value: "SFO" },
{ label: "JFK - New York JFK", value: "JFK" },
{ label: "CDG - Paris Charles de Gaulle", value: "CDG" },
{ label: "NRT - Tokyo Narita", value: "NRT" },
{ label: "DXB - Dubai International", value: "DXB" },
]}
/>
<CustomDropdown
@@ -261,9 +542,13 @@ export default function AddRecoveryIncidents({ isOpen, onClose, incident }: AddR
value={formData.destination}
onChange={(val) => handleInputChange("destination", val as string)}
options={[
{ label: "JFK", value: "JFK" },
{ label: "CDG", value: "CDG" },
{ label: "NRT", value: "NRT" },
{ label: "JFK - New York JFK", value: "JFK" },
{ label: "CDG - Paris Charles de Gaulle", value: "CDG" },
{ label: "NRT - Tokyo Narita", value: "NRT" },
{ label: "FRA - Frankfurt", value: "FRA" },
{ label: "LHR - London Heathrow", value: "LHR" },
{ label: "SFO - San Francisco", value: "SFO" },
{ label: "DXB - Dubai International", value: "DXB" },
]}
/>
</div>
@@ -281,11 +566,7 @@ export default function AddRecoveryIncidents({ isOpen, onClose, incident }: AddR
placeholder="Selected Option"
value={formData.category}
onChange={(val) => handleInputChange("category", val as string)}
options={[
{ label: "Flight Ops", value: "flight_ops" },
{ label: "Travel Exp", value: "travel_exp" },
{ label: "Weather", value: "weather" },
]}
options={DEFAULT_CATEGORY_OPTIONS}
/>
<CustomDropdown
label="Scenario"
@@ -183,7 +183,7 @@ export default function RecoveryIncidentsList() {
const { updateIncidentStatus, getRecoveryMetrics } = await import('../RecoveryIncidentsApi');
await updateIncidentStatus(incident.id, text);
fetchIncidents();
getRecoveryMetrics().then((data) => setMetrics(data)).catch(() => {});
getRecoveryMetrics().then((data) => setMetrics(data)).catch(() => { });
} catch (error) {
console.error("Failed to update status", error);
}
@@ -0,0 +1,392 @@
export interface MockPassenger {
id: string;
passengerName: string;
pnr: string;
passengerType: string; // e.g. "VIP Adult", "Adult", "High Value", "Child", "Infant"
nationality: string; // e.g. "German", "British", "American", "Japanese", "French"
loyaltyTier: string; // e.g. "Platinum", "Gold", "Silver", "Bronze", "Regular"
specialAssistance: string; // e.g. "Wheelchair (WCHR)", "None", "Dietary Meal", "Medical"
originalCabin: string; // e.g. "First Class", "Business Class", "Premium Economy", "Economy"
actualCabin: string; // e.g. "Business Class", "Economy", "First Class" (assigned cabin after disruption)
cabinClass?: string; // Legacy fallback/alias
isHighValue?: boolean;
seatNumber?: string;
ticketNumber?: string;
}
export interface MockDisruption {
flightNumber: string;
date: string; // YYYY-MM-DD
origin: string; // IATA code
destination: string; // IATA code
category: string; // e.g. "flight_ops", "weather", "travel_exp"
scenario: string; // e.g. "delayed_flight", "cancelled_flight", "missed_connection"
jurisdiction: string; // e.g. "EU261", "US_DOT", "UK261"
delayDuration: number; // in minutes
status: string; // e.g. "Delayed", "Cancelled", "Diverted"
description?: string;
passengers: MockPassenger[];
}
export const MOCK_DISRUPTIONS: Record<string, MockDisruption> = {
B7687YT: {
flightNumber: 'B7687YT',
date: '2026-08-14',
origin: 'FRA',
destination: 'JFK',
category: 'flight_ops',
scenario: 'delayed_flight',
jurisdiction: 'EU261',
delayDuration: 240,
status: 'Delayed',
description: 'Technical engine maintenance delay at Frankfurt Airport.',
passengers: [
{
id: 'p-101',
passengerName: 'John Doe',
pnr: 'FT7687T9I',
passengerType: 'VIP Adult',
nationality: 'German',
loyaltyTier: 'Platinum',
specialAssistance: 'Wheelchair (WCHR)',
originalCabin: 'First Class',
actualCabin: 'Business Class', // Downgraded during disruption
cabinClass: 'First Class',
isHighValue: true,
seatNumber: '4A',
ticketNumber: '0162394810239',
},
{
id: 'p-102',
passengerName: 'Sarah Jenkins',
pnr: 'SJ98231FA',
passengerType: 'High Value Adult',
nationality: 'British',
loyaltyTier: 'Gold',
specialAssistance: 'None',
originalCabin: 'First Class',
actualCabin: 'First Class',
cabinClass: 'First Class',
isHighValue: true,
seatNumber: '2F',
ticketNumber: '0162394810240',
},
{
id: 'p-103',
passengerName: 'Michael Chen',
pnr: 'MC441298X',
passengerType: 'Adult',
nationality: 'American',
loyaltyTier: 'Silver',
specialAssistance: 'Dietary Meal (VGML)',
originalCabin: 'Business Class',
actualCabin: 'Economy', // Downgraded
cabinClass: 'Business Class',
isHighValue: false,
seatNumber: '28C',
ticketNumber: '0162394810241',
},
{
id: 'p-104',
passengerName: 'Amanda Lewis',
pnr: 'AL551029P',
passengerType: 'Adult',
nationality: 'Canadian',
loyaltyTier: 'Bronze',
specialAssistance: 'None',
originalCabin: 'Economy',
actualCabin: 'Economy',
cabinClass: 'Economy',
isHighValue: false,
seatNumber: '31D',
ticketNumber: '0162394810242',
},
],
},
Q23SXD: {
flightNumber: 'Q23SXD',
date: '2026-08-13',
origin: 'LHR',
destination: 'CDG',
category: 'weather',
scenario: 'cancelled_flight',
jurisdiction: 'UK261',
delayDuration: 360,
status: 'Cancelled',
description: 'Severe storm and heavy fog over London Heathrow.',
passengers: [
{
id: 'p-201',
passengerName: 'Robert Vance',
pnr: 'RV992104K',
passengerType: 'VIP Adult',
nationality: 'British',
loyaltyTier: 'Platinum',
specialAssistance: 'None',
originalCabin: 'First Class',
actualCabin: 'Business Class',
cabinClass: 'First Class',
isHighValue: true,
seatNumber: '1B',
ticketNumber: '1259920194812',
},
{
id: 'p-202',
passengerName: 'Emma Watson',
pnr: 'EW771029M',
passengerType: 'High Value Adult',
nationality: 'French',
loyaltyTier: 'Gold',
specialAssistance: 'Dietary Meal',
originalCabin: 'Business Class',
actualCabin: 'Business Class',
cabinClass: 'Business Class',
isHighValue: true,
seatNumber: '6D',
ticketNumber: '1259920194813',
},
{
id: 'p-203',
passengerName: 'David Miller',
pnr: 'DM334190Q',
passengerType: 'Adult',
nationality: 'Australian',
loyaltyTier: 'Regular',
specialAssistance: 'Wheelchair (WCHR)',
originalCabin: 'Economy',
actualCabin: 'Economy',
cabinClass: 'Economy',
isHighValue: false,
seatNumber: '19A',
ticketNumber: '1259920194814',
},
],
},
AZ404: {
flightNumber: 'AZ404',
date: '2026-08-13',
origin: 'SFO',
destination: 'NRT',
category: 'travel_exp',
scenario: 'missed_connection',
jurisdiction: 'US_DOT',
delayDuration: 180,
status: 'Delayed',
description: 'Late arrival of incoming aircraft causing connection breakdown.',
passengers: [
{
id: 'p-301',
passengerName: 'Kaito Tanaka',
pnr: 'KT883192Z',
passengerType: 'High Value Adult',
nationality: 'Japanese',
loyaltyTier: 'Platinum',
specialAssistance: 'None',
originalCabin: 'Business Class',
actualCabin: 'Business Class',
cabinClass: 'Business Class',
isHighValue: true,
seatNumber: '11K',
ticketNumber: '0571120938491',
},
{
id: 'p-302',
passengerName: 'Lisa Ray',
pnr: 'LR110293Y',
passengerType: 'Adult',
nationality: 'American',
loyaltyTier: 'Silver',
specialAssistance: 'Unaccompanied Minor',
originalCabin: 'Premium Economy',
actualCabin: 'Economy',
cabinClass: 'Premium Economy',
isHighValue: false,
seatNumber: '16C',
ticketNumber: '0571120938492',
},
{
id: 'p-303',
passengerName: 'Carlos Gomez',
pnr: 'CG559102X',
passengerType: 'VIP Adult',
nationality: 'Mexican',
loyaltyTier: 'Gold',
specialAssistance: 'Medical Assistance',
originalCabin: 'Business Class',
actualCabin: 'Business Class',
cabinClass: 'Business Class',
isHighValue: true,
seatNumber: '8A',
ticketNumber: '0571120938493',
},
],
},
BA178: {
flightNumber: 'BA178',
date: '2026-08-15',
origin: 'JFK',
destination: 'LHR',
category: 'flight_ops',
scenario: 'delayed_flight',
jurisdiction: 'UK261',
delayDuration: 300,
status: 'Delayed',
description: 'Air traffic control delay on transatlantic sector.',
passengers: [
{
id: 'p-401',
passengerName: 'Harrison Ford',
pnr: 'HF908123A',
passengerType: 'VIP Adult',
nationality: 'American',
loyaltyTier: 'Platinum',
specialAssistance: 'None',
originalCabin: 'First Class',
actualCabin: 'First Class',
cabinClass: 'First Class',
isHighValue: true,
seatNumber: '2A',
ticketNumber: '1250912837192',
},
{
id: 'p-402',
passengerName: 'Clara Oswald',
pnr: 'CO449182B',
passengerType: 'High Value Adult',
nationality: 'British',
loyaltyTier: 'Gold',
specialAssistance: 'None',
originalCabin: 'Business Class',
actualCabin: 'Premium Economy',
cabinClass: 'Business Class',
isHighValue: true,
seatNumber: '12E',
ticketNumber: '1250912837193',
},
],
},
EK202: {
flightNumber: 'EK202',
date: '2026-08-14',
origin: 'JFK',
destination: 'DXB',
category: 'weather',
scenario: 'delayed_flight',
jurisdiction: 'US_DOT',
delayDuration: 210,
status: 'Delayed',
description: 'Severe thunderstorm delay prior to pushback.',
passengers: [
{
id: 'p-501',
passengerName: 'Tariq Al-Mansoor',
pnr: 'TM771920K',
passengerType: 'VIP Adult',
nationality: 'Emirati',
loyaltyTier: 'Platinum',
specialAssistance: 'None',
originalCabin: 'First Class',
actualCabin: 'First Class',
cabinClass: 'First Class',
isHighValue: true,
seatNumber: '1A',
ticketNumber: '1769910293841',
},
{
id: 'p-502',
passengerName: 'Fatima Al-Sayed',
pnr: 'FA882019L',
passengerType: 'High Value Adult',
nationality: 'Emirati',
loyaltyTier: 'Gold',
specialAssistance: 'Dietary Meal',
originalCabin: 'Business Class',
actualCabin: 'Business Class',
cabinClass: 'Business Class',
isHighValue: true,
seatNumber: '7K',
ticketNumber: '1769910293842',
},
],
},
};
/**
* Get options list of flight numbers for dropdown selection
*/
export function getMockFlightNumbers(): { label: string; value: string; description: string }[] {
return Object.values(MOCK_DISRUPTIONS).map((item) => ({
label: `${item.flightNumber} (${item.origin}${item.destination}${item.delayDuration}m delay)`,
value: item.flightNumber,
description: item.description || '',
}));
}
/**
* Get disruption detail for a given flight number or PNR reference
*/
export function getMockDisruptionByFlightNumber(query: string): MockDisruption | undefined {
if (!query) return undefined;
const upper = query.trim().toUpperCase();
// 1. Direct flight number match
const flightKey = Object.keys(MOCK_DISRUPTIONS).find((k) => k.toUpperCase() === upper);
if (flightKey) return MOCK_DISRUPTIONS[flightKey];
// 2. Direct PNR match search across flights
for (const item of Object.values(MOCK_DISRUPTIONS)) {
const match = item.passengers.some((p) => p.pnr.toUpperCase() === upper);
if (match) return item;
}
return undefined;
}
/**
* Get list of passengers for a flight
*/
export function getPassengersForFlight(flightNumber: string): MockPassenger[] {
const disruption = getMockDisruptionByFlightNumber(flightNumber);
return disruption ? disruption.passengers : [];
}
/**
* Get specific passenger details by flight number and PNR
*/
export function getMockPassengerByPnr(flightNumber: string, pnr: string): MockPassenger | undefined {
const passengers = getPassengersForFlight(flightNumber);
if (!pnr) return undefined;
const upperPnr = pnr.trim().toUpperCase();
return passengers.find((p) => p.pnr.toUpperCase() === upperPnr);
}
/**
* Search across flights and PNRs, returning disruption and optionally matched passenger ID
*/
export function searchDisruptionOrPassenger(query: string): { disruption?: MockDisruption; matchedPassengerId?: string } {
if (!query) return {};
const upper = query.trim().toUpperCase();
// Search flight number
const flightKey = Object.keys(MOCK_DISRUPTIONS).find((k) => k.toUpperCase() === upper);
if (flightKey) {
return { disruption: MOCK_DISRUPTIONS[flightKey] };
}
// Search PNR
for (const item of Object.values(MOCK_DISRUPTIONS)) {
const passenger = item.passengers.find((p) => p.pnr.toUpperCase() === upper);
if (passenger) {
return { disruption: item, matchedPassengerId: passenger.id };
}
}
return {};
}
/**
* Get all mock disruptions
*/
export function getAllMockDisruptions(): MockDisruption[] {
return Object.values(MOCK_DISRUPTIONS);
}
+125 -84
View File
@@ -1,100 +1,141 @@
import { useState, useEffect } from 'react';
import { ClipboardTextIcon, CheckCircleIcon } from '@phosphor-icons/react';
import { formatDate } from '../../../utils/formatDate';
import { getIncidentAuditTrail, type AuditTrailStepDto } from '../RecoveryIncidentsApi';
import type { RecoveryIncident } from '../RecoveryIncidentsTypes';
interface AuditTrailTabProps {
incident?: RecoveryIncident | null;
}
export default function AuditTrailTab({ incident }: AuditTrailTabProps) {
const [apiSteps, setApiSteps] = useState<AuditTrailStepDto[]>([]);
const [loading, setLoading] = useState(false);
const code = incident?.recoveryCode || 'REC-INCIDENT';
const passenger = incident?.passengerName || 'Passenger';
const flight = incident?.flightNumber || 'Flight';
const route = incident?.flightRoute || 'Route';
const status = incident?.status || 'Pending';
const evaluation = incident?.evaluation;
const policyName = evaluation?.policyName || 'Standard Policy';
const cohortName = evaluation?.matchedCohortName || 'General Audience';
const recoveryScore = evaluation?.recoveryScore || 0;
useEffect(() => {
if (!incident?.id) return;
setLoading(true);
getIncidentAuditTrail(incident.id)
.then((data) => {
if (Array.isArray(data)) {
setApiSteps(data);
}
})
.catch((err) => {
console.error('Failed to load backend audit trail:', err);
})
.finally(() => {
setLoading(false);
});
}, [incident?.id, incident?.status, incident?.evaluation?.updatedAt]);
const checkIcon = <CheckCircleIcon size={24} weight="fill" className="text-[#1B9869]" />;
const isApproved = status.toLowerCase().includes('appr');
const isRejected = status.toLowerCase().includes('reject');
// Initial default steps if API response is loading
const defaultInitialSteps: AuditTrailStepDto[] = [
{
id: 'step-1',
title: 'Flight Disruption Recorded',
description: `Disruption identified for ${passenger} on flight ${flight} (${route}).`,
timestamp: incident?.createdAt || incident?.date || new Date().toISOString(),
status: 'completed',
},
{
id: 'step-2',
title: 'Target Audience Cohort Evaluated',
description: `Automated audience eligibility assessment performed against active frameworks. Matched cohort: "${cohortName}".`,
timestamp: incident?.createdAt || new Date().toISOString(),
status: 'completed',
},
{
id: 'step-3',
title: 'Policy Evaluated',
description: `Evaluated against active rules under "${policyName}". Recovery Score: ${recoveryScore}/100.`,
timestamp: evaluation?.createdAt || incident?.createdAt || new Date().toISOString(),
status: 'completed',
},
{
id: 'step-4',
title: `Status: ${status}`,
description: `Case officer assigned. Case status updated to "${status}".`,
timestamp: incident?.updatedAt || new Date().toISOString(),
status: 'completed',
},
{
id: 'step-5',
title: 'Recovery Resolution',
description: isApproved
? 'Recovery incident approved. Automated settlement and customer notification dispatched.'
: isRejected
? 'Recovery incident rejected by case officer.'
: 'Refund and compensation settlement will initiate upon final approval.',
timestamp: incident?.updatedAt || new Date().toISOString(),
status: isApproved ? 'completed' : isRejected ? 'rejected' : 'pending',
},
];
const displaySteps = apiSteps.length > 0 ? apiSteps : defaultInitialSteps;
export default function AuditTrailTab() {
return (
<div className="flex flex-col gap-4">
<div className="bg-white rounded-2xl p-6 border border-gray-100 shadow-sm flex flex-col gap-6">
<div className="flex items-center gap-2">
<ClipboardTextIcon size={20} weight="bold" className="text-[#143d30]" />
<h3 className="text-[13px] font-bold text-[#143d30] tracking-wider uppercase">Lifecycle Timeline & Audit Trail</h3>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<ClipboardTextIcon size={20} weight="bold" className="text-[#143d30]" />
<h3 className="text-[13px] font-bold text-[#143d30] tracking-wider uppercase">
Lifecycle Timeline & Audit Trail ({code})
</h3>
</div>
</div>
<div className="flex flex-col mt-4 pl-2">
{/* Step 1: Flight Disruption Recorded */}
<div className="relative pl-10 pb-8">
<div className="absolute left-[7px] top-7 bottom-0 w-[2px] bg-[#1B9869]"></div>
<div className="absolute left-[-4px] top-0.5 bg-white">
<CheckCircleIcon size={24} weight="fill" className="text-[#1B9869]" />
</div>
<div className="flex justify-between items-start">
<div>
<h4 className="text-sm font-semibold text-gray-900 mb-1">Flight Disruption Recorded</h4>
<p className="text-[13px] text-gray-500">Denied Boarding identified for flight Q23SXD.</p>
</div>
<span className="text-[11px] font-medium text-gray-400 tracking-wider">Assessment Point</span>
</div>
</div>
{displaySteps.map((step, idx) => {
const isLast = idx === displaySteps.length - 1;
const rawTime = (step as any).createdAt || step.timestamp;
{/* Step 2: Simulation Engine Executed */}
<div className="relative pl-10 pb-8">
<div className="absolute left-[7px] top-7 bottom-0 w-[2px] bg-gray-200"></div>
<div className="absolute left-0 top-1 w-4 h-4 rounded-full bg-[#1B9869] ring-4 ring-[#E5F0EB]"></div>
<div className="flex justify-between items-start">
<div>
<h4 className="text-sm font-semibold text-gray-900 mb-1">Simulation Engine Executed</h4>
<p className="text-[13px] text-gray-500">Automated eligibility assessment performed against active frameworks.</p>
</div>
<span className="text-[11px] font-medium text-gray-400 tracking-wider">T-10m</span>
</div>
</div>
return (
<div key={step.id || idx} className={`relative pl-10 ${isLast ? '' : 'pb-8'}`}>
{/* Connecting Line */}
{!isLast && (
<div
className="absolute left-[7px] top-7 bottom-0 w-[2px] bg-[#1B9869]"
></div>
)}
{/* Step 3: Policy Evaluated */}
<div className="relative pl-10 pb-8">
<div className="absolute left-[7px] top-7 bottom-0 w-[2px] bg-gray-200"></div>
<div className="absolute left-[-2px] top-1 w-5 h-5 rounded-full border-2 border-gray-200 bg-white"></div>
<div className="flex justify-between items-start">
<div>
<h4 className="text-sm font-semibold text-gray-900 mb-1">Policy Evaluated</h4>
<p className="text-[13px] text-gray-500">Pending final approval from Case Officer.</p>
</div>
<span className="text-[11px] font-medium text-gray-400 tracking-wider">T-8m</span>
</div>
</div>
{/* Node Icon Indicator */}
<div className="absolute left-[-4px] top-0.5 bg-white flex items-center justify-center">
<div className="text-[#1B9869]">{checkIcon}</div>
</div>
{/* Step 4: Status: Under Review */}
<div className="relative pl-10 pb-8">
<div className="absolute left-[7px] top-7 bottom-0 w-[2px] bg-gray-200"></div>
<div className="absolute left-[-2px] top-1 w-5 h-5 rounded-full border-2 border-gray-200 bg-white"></div>
<div className="flex justify-between items-start">
<div>
<h4 className="text-sm font-semibold text-gray-900 mb-1">Status: Under Review</h4>
<p className="text-[13px] text-gray-500">Tuesday, 28 May 2024</p>
</div>
<span className="text-[11px] font-medium text-gray-400 tracking-wider">Current</span>
</div>
</div>
{/* Step Details */}
<div className="flex justify-between items-start gap-4">
<div className="flex flex-col gap-1 max-w-xl">
<h4 className="text-sm font-semibold text-gray-900">{step.title}</h4>
<p className="text-[13px] text-gray-500 leading-relaxed">
{step.description}
</p>
</div>
{/* Step 5: Policy Engine Rerun */}
<div className="relative pl-10 pb-8">
<div className="absolute left-[7px] top-7 bottom-0 w-[2px] bg-gray-200"></div>
<div className="absolute left-[-2px] top-1 w-5 h-5 rounded-full border-2 border-gray-200 bg-white"></div>
<div className="flex justify-between items-start">
<div>
<h4 className="text-sm font-semibold text-gray-900 mb-1">Policy Engine Rerun</h4>
<p className="text-[13px] text-gray-500">Manual re-assessment triggered. Applied: Standard Policy.</p>
<span className="text-[11px] font-medium text-gray-400 tracking-wider shrink-0">
{formatDate(rawTime)}
</span>
</div>
</div>
<span className="text-[11px] font-medium text-gray-400 tracking-wider">Recent</span>
</div>
</div>
{/* Step 6: Recovery Resolution */}
<div className="relative pl-10">
<div className="absolute left-[-2px] top-1 w-5 h-5 rounded-full border-2 border-gray-200 bg-white"></div>
<div className="flex justify-between items-start">
<div>
<h4 className="text-sm font-semibold text-gray-900 mb-1">Recovery Resolution</h4>
<p className="text-[13px] text-gray-500">Refund and compensation settlement will initiate upon final approval.</p>
</div>
<span className="text-[11px] font-medium text-gray-400 tracking-wider">Pending</span>
</div>
</div>
);
})}
</div>
</div>
</div>
@@ -8,6 +8,12 @@ interface CaseDetailsTabProps {
export default function CaseDetailsTab({ incident }: CaseDetailsTabProps) {
if (!incident) return null;
const isDowngraded = Boolean(
incident.originalCabin &&
incident.actualCabin &&
incident.originalCabin !== incident.actualCabin
);
return (
<div className="flex flex-col gap-4">
{/* PASSENGER INFORMATION */}
@@ -28,12 +34,24 @@ export default function CaseDetailsTab({ incident }: CaseDetailsTabProps) {
</div>
<div>
<span className="block text-[11px] font-bold text-gray-400 tracking-wider mb-1">LOYALTY TIER</span>
<span className="block text-[15px] font-semibold text-[#1B9869]">None</span>
<span className="block text-[15px] font-semibold text-[#1B9869]">{incident.loyaltyTier || 'Regular'}</span>
</div>
<div>
<span className="block text-[11px] font-bold text-gray-400 tracking-wider mb-1">PASSENGER TYPE</span>
<span className="block text-[15px] font-semibold text-gray-900">Adult</span>
<span className="block text-[15px] font-semibold text-gray-900">{incident.passengerType || 'Adult'}</span>
</div>
{incident.nationality && (
<div>
<span className="block text-[11px] font-bold text-gray-400 tracking-wider mb-1">NATIONALITY</span>
<span className="block text-[15px] font-semibold text-gray-900">{incident.nationality}</span>
</div>
)}
{incident.specialAssistance && (
<div>
<span className="block text-[11px] font-bold text-gray-400 tracking-wider mb-1">SPECIAL ASSISTANCE</span>
<span className="block text-[15px] font-semibold text-gray-900">{incident.specialAssistance}</span>
</div>
)}
</div>
</div>
@@ -55,19 +73,32 @@ export default function CaseDetailsTab({ incident }: CaseDetailsTabProps) {
</div>
<div>
<span className="block text-[11px] font-bold text-gray-400 tracking-wider mb-1">CABIN</span>
<span className="block text-[15px] font-semibold text-[#1B9869]">Economy</span>
<span className="block text-[15px] font-semibold text-[#1B9869]">
{incident.actualCabin || incident.cabinClass || 'Economy'}
</span>
</div>
<div>
<span className="block text-[11px] font-bold text-gray-400 tracking-wider mb-1">DELAY (ARRIVAL)</span>
<span className="block text-[15px] font-semibold text-gray-900">--</span>
<span className="block text-[15px] font-semibold text-gray-900">
{incident.delayDuration ? `${incident.delayDuration} mins` : '--'}
</span>
</div>
<div>
<span className="block text-[11px] font-bold text-gray-400 tracking-wider mb-1">ORIGINAL CABIN</span>
<span className="block text-[15px] font-semibold text-gray-900">Economy</span>
<span className="block text-[15px] font-semibold text-gray-900">
{incident.originalCabin || incident.cabinClass || 'Economy'}
</span>
</div>
<div>
<span className="block text-[11px] font-bold text-gray-400 tracking-wider mb-1">ACTUAL CABIN</span>
<span className="block text-[15px] font-semibold text-gray-900">Economy</span>
<span className="block text-[15px] font-semibold text-gray-900 flex items-center gap-1.5">
{incident.actualCabin || incident.cabinClass || 'Economy'}
{isDowngraded && (
<span className="text-[10px] bg-amber-100 text-amber-800 font-bold px-2 py-0.5 rounded border border-amber-300">
Downgraded
</span>
)}
</span>
</div>
</div>
</div>
@@ -86,16 +117,20 @@ export default function CaseDetailsTab({ incident }: CaseDetailsTabProps) {
</div>
<div>
<span className="block text-[11px] font-bold text-gray-400 tracking-wider mb-1">SCENARIO</span>
<span className="block text-[15px] font-semibold text-gray-900">N/A</span>
<span className="block text-[15px] font-semibold text-gray-900">{incident.scenario || 'Delayed Flight'}</span>
</div>
<div>
<span className="block text-[11px] font-bold text-gray-400 tracking-wider mb-1">SUB-TYPE</span>
<span className="block text-[15px] font-semibold text-[#1B9869]">None</span>
<span className="block text-[11px] font-bold text-gray-400 tracking-wider mb-1">JURISDICTION</span>
<span className="block text-[15px] font-semibold text-[#1B9869]">{incident.jurisdiction || 'EU261'}</span>
</div>
<div className="col-span-3">
<span className="block text-[11px] font-bold text-gray-400 tracking-wider mb-1">ROOT CAUSE ANALYSIS</span>
<span className="block text-[15px] font-semibold text-gray-900">
Operational issues resulting in service disruption. Analysis pending manual confirmation.
{incident.category === 'weather'
? 'Severe weather disruption impacting airport operations and flight scheduling.'
: incident.category === 'flight_ops' || incident.category === 'technical_fault'
? 'Technical flight operations delay requiring maintenance clearance prior to departure.'
: 'Operational issues resulting in service disruption. Analysis verified by automated engine.'}
</span>
</div>
</div>
@@ -1,76 +1,97 @@
import { CreditCardIcon, GiftIcon, HandHeartIcon } from '@phosphor-icons/react';
import { CheckCircleIcon, InfoIcon } from '@phosphor-icons/react';
import type { RecoveryIncident, IncidentEvaluationAction } from '../RecoveryIncidentsTypes';
interface RecoveryPlanTabProps {
incident?: RecoveryIncident | null;
}
export default function RecoveryPlanTab({ incident }: RecoveryPlanTabProps) {
const actions: IncidentEvaluationAction[] = incident?.evaluation?.actions || [];
// Dynamically group evaluated actions by master ActionCategory name defined in DB
const categoriesMap = new Map<string, IncidentEvaluationAction[]>();
actions.forEach((act) => {
const catName = (act.category || 'General Actions').trim();
if (!categoriesMap.has(catName)) {
categoriesMap.set(catName, []);
}
categoriesMap.get(catName)!.push(act);
});
if (!incident?.evaluation || actions.length === 0) {
return (
<div className="bg-white rounded-2xl p-12 border border-gray-100 shadow-sm flex flex-col items-center justify-center text-center gap-4">
<div className="w-14 h-14 rounded-full bg-emerald-50 text-[#1B9869] flex items-center justify-center">
<InfoIcon size={28} />
</div>
<div className="max-w-md flex flex-col gap-1">
<h3 className="text-lg font-bold text-gray-900">No Policy Actions Evaluated</h3>
<p className="text-sm text-gray-500">
{incident?.evaluation?.aiAssessment ||
'No active policy rules in the Policy Engine matched the flight disruption parameters for this incident.'}
</p>
</div>
</div>
);
}
export default function RecoveryPlanTab() {
return (
<div className="flex flex-col gap-4">
{/* FINANCIAL REFUND */}
<div className="bg-white rounded-2xl p-6 border border-gray-100 shadow-sm flex flex-col gap-6">
<div className="flex items-center gap-2">
<CreditCardIcon size={20} weight="bold" className="text-[#143d30]" />
<h3 className="text-[13px] font-bold text-[#143d30] tracking-wider">FINANCIAL REFUND</h3>
</div>
{Array.from(categoriesMap.entries()).map(([categoryName, categoryActions]) => (
<div
key={categoryName}
className="bg-white rounded-2xl p-6 border border-gray-100 shadow-sm flex flex-col gap-6"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<CheckCircleIcon size={20} weight="bold" className="text-[#143d30]" />
<h3 className="text-[13px] font-bold text-[#143d30] tracking-wider uppercase">
{categoryName}
</h3>
</div>
<div className="grid grid-cols-3 gap-6">
<div>
<span className="block text-[11px] font-bold text-gray-400 tracking-wider mb-1">REFUND AMOUNT</span>
<span className="block text-[15px] font-semibold text-[#1B9869]">EUR 0</span>
</div>
<div>
<span className="block text-[11px] font-bold text-gray-400 tracking-wider mb-1">REFUND STATUS</span>
<span className="block text-[15px] font-semibold text-gray-900">Pending Approval</span>
</div>
<div>
<span className="block text-[11px] font-bold text-gray-400 tracking-wider mb-1">REFUND METHOD</span>
<span className="block text-[15px] font-semibold text-gray-900">Original Payment Method</span>
</div>
</div>
</div>
{/* COMPENSATION & PERKS */}
<div className="bg-white rounded-2xl p-6 border border-gray-100 shadow-sm flex flex-col gap-6">
<div className="flex items-center gap-2">
<GiftIcon size={20} weight="bold" className="text-[#143d30]" />
<h3 className="text-[13px] font-bold text-[#143d30] tracking-wider">COMPENSATION & PERKS</h3>
</div>
<div className="flex flex-col gap-4 divide-y divide-gray-100">
{categoryActions.map((action, idx) => {
// Strip any legacy brackets from description sentence
const cleanDescription = (action.description || '')
.replace(/\[(Inputs|Configured Inputs):\s*.*?\]/, '')
.trim();
<div className="grid grid-cols-3 gap-6">
<div>
<span className="block text-[11px] font-bold text-gray-400 tracking-wider mb-1">CASH COMPENSATION</span>
<span className="block text-[15px] font-semibold text-[#1B9869]">EUR 0</span>
</div>
<div>
<span className="block text-[11px] font-bold text-gray-400 tracking-wider mb-1">VOUCHER ALTERNATIVE</span>
<span className="block text-[15px] font-semibold text-gray-900">Available (120%)</span>
</div>
<div>
<span className="block text-[11px] font-bold text-gray-400 tracking-wider mb-1">LOYALTY MILES</span>
<span className="block text-[15px] font-semibold text-gray-900">5,000 Points (Bonus)</span>
</div>
</div>
</div>
return (
<div key={action.id || idx} className="pt-4 first:pt-0 flex items-start justify-between gap-4">
<div className="flex flex-col gap-1.5 max-w-2xl">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[15px] font-bold text-gray-900 leading-snug">
{action.title}
</span>
{action.actionTypeCode && (
<span className="px-2 py-0.5 rounded text-[11px] font-semibold bg-emerald-50 text-[#1B9869] border border-emerald-200 uppercase tracking-wide">
{action.actionTypeCode.replace(/_/g, ' ')}
</span>
)}
</div>
{/* PASSENGER CARE */}
<div className="bg-white rounded-2xl p-6 border border-gray-100 shadow-sm flex flex-col gap-6">
<div className="flex items-center gap-2">
<HandHeartIcon size={20} weight="bold" className="text-[#143d30]" />
<h3 className="text-[13px] font-bold text-[#143d30] tracking-wider">PASSENGER CARE</h3>
</div>
<p className="text-xs text-gray-600 leading-relaxed font-medium">
{cleanDescription || 'Action executed per policy configuration.'}
</p>
</div>
<div className="grid grid-cols-3 gap-6">
<div>
<span className="block text-[11px] font-bold text-gray-400 tracking-wider mb-1">MEAL VOUCHERS</span>
<span className="block text-[15px] font-semibold text-[#1B9869]">2 x $15.00 Issued</span>
</div>
<div>
<span className="block text-[11px] font-bold text-gray-400 tracking-wider mb-1">HOTEL ACCOMMODATION</span>
<span className="block text-[15px] font-semibold text-gray-900">1 Night (Pending)</span>
</div>
<div>
<span className="block text-[11px] font-bold text-gray-400 tracking-wider mb-1">GROUND TRANSPORT</span>
<span className="block text-[15px] font-semibold text-gray-900">Airport to City Center</span>
<div className="flex flex-col items-end gap-1.5 shrink-0">
<span className="text-[15px] font-bold text-[#1B9869]">
{action.amount
? `${action.currency || ''} ${action.amount.toLocaleString()}`.trim()
: 'Included / Configured'}
</span>
</div>
</div>
);
})}
</div>
</div>
</div>
))}
</div>
);
}
+18 -10
View File
@@ -1,7 +1,18 @@
import { SparkleIcon, UserIcon, ArrowRightIcon } from "@phosphor-icons/react";
import { CustomButton } from "../../../components/custom";
import type { RecoveryIncident } from "../RecoveryIncidentsTypes";
interface SummaryTabProps {
incident?: RecoveryIncident | null;
}
export default function SummaryTab({ incident }: SummaryTabProps) {
const policyName = incident?.evaluation?.policyName || 'No Policy Matched';
const recoveryScore = incident?.evaluation?.recoveryScore || 0;
const aiAssessmentText =
incident?.evaluation?.aiAssessment ||
`No active policy rules matched flight ${incident?.flightNumber || 'N/A'} for passenger ${incident?.passengerName || 'N/A'}. Click "RE-RUN ENGINE" to evaluate policy rules.`;
export default function SummaryTab() {
return (
<div className="flex flex-col gap-6">
{/* AI STRATEGIC ASSESSMENT */}
@@ -19,9 +30,7 @@ export default function SummaryTab() {
</div>
<p className="text-[15px] text-gray-700 italic leading-relaxed">
"Analysis of JOHN WICK's history and the flight disruption suggest
this is a high-retention opportunity. Automated settlement is
recommended to maintain NPS within the Platinum segment."
"{aiAssessmentText}"
</p>
</div>
@@ -38,7 +47,7 @@ export default function SummaryTab() {
Recovery Source
</span>
<span className="block text-[15px] font-semibold text-gray-900">
Simulation Engine
Policy Evaluation Engine
</span>
</div>
@@ -47,7 +56,7 @@ export default function SummaryTab() {
Policy Applied
</span>
<span className="block text-[15px] font-semibold text-gray-900">
Standard Policy
{policyName}
</span>
</div>
@@ -56,7 +65,7 @@ export default function SummaryTab() {
Jurisdiction
</span>
<span className="block text-[15px] font-semibold text-gray-900">
EU261
{incident?.jurisdiction || 'N/A'}
</span>
</div>
</div>
@@ -102,14 +111,13 @@ export default function SummaryTab() {
<div className="flex items-baseline gap-1 mt-2 mb-2">
<span className="text-[48px] font-bold text-gray-900 leading-none">
75
{recoveryScore}
</span>
<span className="text-xl text-gray-400 font-semibold">/ 100</span>
</div>
<p className="text-sm text-gray-600 leading-relaxed">
Manual review recommended. Aligns with standard EU261 recovery
logic.
Automated evaluation score derived from configuration rules.
</p>
</div>
</div>
+28 -12
View File
@@ -7,7 +7,7 @@ import CaseDetailsTab from './CaseDetailsTab';
import RecoveryPlanTab from './RecoveryPlanTab';
import AuditTrailTab from './AuditTrailTab';
import { SparkleIcon } from 'lucide-react';
import { getRecoveryIncident, updateIncidentStatus } from '../RecoveryIncidentsApi';
import { getRecoveryIncident, updateIncidentStatus, reRunPolicyEngine } from '../RecoveryIncidentsApi';
import type { RecoveryIncident } from '../RecoveryIncidentsTypes';
function getStatusVariant(status?: string): "success" | "error" | "warning" | "info" | "neutral" {
@@ -57,11 +57,24 @@ export default function RecoveryIncidentTabs() {
}
};
const handleReRunEngine = async () => {
if (!id || updating) return;
setUpdating(true);
try {
const updated = await reRunPolicyEngine(id);
setIncident(updated);
} catch (err) {
console.error("Failed to re-run policy engine:", err);
} finally {
setUpdating(false);
}
};
const tabItems = [
{
id: 'Summary',
label: 'Summary',
content: <SummaryTab />
content: <SummaryTab incident={incident} />
},
{
id: 'Case Details',
@@ -71,12 +84,12 @@ export default function RecoveryIncidentTabs() {
{
id: 'Recovery Plan',
label: 'Recovery Plan',
content: <RecoveryPlanTab />
content: <RecoveryPlanTab incident={incident} />
},
{
id: 'Audit Trail',
label: 'Audit Trail',
content: <AuditTrailTab />
content: <AuditTrailTab incident={incident} />
}
];
@@ -107,10 +120,12 @@ export default function RecoveryIncidentTabs() {
</div>
</div>
<CustomButton
leftIcon={<ArrowsClockwiseIcon size={18} weight="bold" />}
className="!bg-[#1B9869] hover:!bg-[#14704E] !text-white !font-semibold !rounded-lg !px-5 !py-2.5"
disabled={updating}
onClick={handleReRunEngine}
leftIcon={<ArrowsClockwiseIcon size={18} weight="bold" className={updating ? "animate-spin" : ""} />}
className="!bg-[#1B9869] hover:!bg-[#14704E] !text-white !font-semibold !rounded-lg !px-5 !py-2.5 disabled:opacity-50"
>
RE-RUN ENGINE
{updating ? "EVALUATING..." : "RE-RUN ENGINE"}
</CustomButton>
</div>
@@ -150,14 +165,13 @@ export default function RecoveryIncidentTabs() {
{/* Right Column - Sidebar */}
<div className="w-[360px] flex-shrink-0 bg-[#F8F9FA] rounded-[16px] border border-gray-100 relative">
{/* Blur Overlay */}
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/20 backdrop-blur-[3px] rounded-[16px]">
<h4 className="text-[18px] font-bold text-[#143d30] italic">"Coming soon"</h4>
</div>
{/* Sidebar Content */}
<div className="p-6 select-none pointer-events-none">
<div className="p-6 select-none pointer-events-none">
<div className="flex items-center gap-2 mb-6">
<span className="text-[#1B9869]"><SparkleIcon size={20} height="fill" /></span>
<h3 className="text-[13px] font-bold text-gray-400 tracking-wider">AI RECOMMENDATION</h3>
@@ -166,7 +180,7 @@ export default function RecoveryIncidentTabs() {
<div className="mb-6">
<div className="flex justify-between items-end mb-2">
<span className="text-xs font-bold text-gray-400 tracking-wider">SATISFACTION PREDICT</span>
<span className="text-sm font-bold text-[#1B9869]">84%</span>
<span className="text-sm font-bold text-[#1B9869]">{incident?.evaluation?.recoveryScore || 84}%</span>
</div>
<div className="h-2 bg-white rounded-full overflow-hidden border border-gray-100">
<div className="h-full bg-[#1B9869] w-[84%] rounded-full opacity-60"></div>
@@ -186,7 +200,9 @@ export default function RecoveryIncidentTabs() {
<div className="pt-6 border-t border-gray-200">
<h3 className="text-[13px] font-bold text-gray-400 tracking-wider mb-4">NEXT RECOMMENDED ACTION</h3>
<div className="bg-white rounded-xl p-5 mb-4 border border-gray-100 shadow-sm">
<p className="text-sm text-gray-500 text-center">Approve the automated recovery payout of [250 EUR]. This will prevent a regulatory complaint and retain this high-value Platinum member.</p>
<p className="text-sm text-gray-500 text-center">
Approve automated recovery under "{incident?.evaluation?.policyName || 'Standard Policy'}".
</p>
</div>
<CustomButton
@@ -203,7 +219,7 @@ export default function RecoveryIncidentTabs() {
{/* Bottom Sticky Action Bar */}
<div className="sticky bottom-[-24px] -mx-8 px-8 py-4 bg-white/90 backdrop-blur-md border-t border-gray-100 flex justify-between items-center z-10 mt-auto shadow-[0_-10px_20px_-10px_rgba(0,0,0,0.05)]">
<div></div> {/* Spacer */}
<div></div>
<div className="flex gap-4">
<CustomButton
variant="text"
+29 -17
View File
@@ -1,20 +1,26 @@
import { useState, useEffect } from "react";
import { UsersIcon, BriefcaseIcon, BuildingsIcon } from "@phosphor-icons/react";
import {
AirplaneTiltIcon,
ShieldCheckIcon,
ArrowsClockwiseIcon,
UsersFourIcon,
} from "@phosphor-icons/react";
const CustomAppLoader = () => {
const [currentIndex, setCurrentIndex] = useState(0);
// HRM related icons
// AeroResolve domain icons
const icons = [
{ component: UsersIcon, key: "users" },
{ component: BriefcaseIcon, key: "briefcase" },
{ component: BuildingsIcon, key: "building" },
{ component: AirplaneTiltIcon, key: "flight", label: "Flight Operations" },
{ component: ArrowsClockwiseIcon, key: "recovery", label: "Recovery Incidents" },
{ component: ShieldCheckIcon, key: "policy", label: "Policy Engine" },
{ component: UsersFourIcon, key: "cohorts", label: "Passenger Cohorts" },
];
useEffect(() => {
const interval = setInterval(() => {
setCurrentIndex((prev) => (prev + 1) % icons.length);
}, 800); // Slower, smoother transition
}, 700);
return () => clearInterval(interval);
}, [icons.length]);
@@ -22,28 +28,34 @@ const CustomAppLoader = () => {
const ActiveIcon = icons[currentIndex].component;
return (
<div className="flex flex-col items-center justify-center space-y-6 h-screen w-full ">
<div className="flex flex-col items-center justify-center space-y-6 min-h-[400px] h-screen w-full bg-[#F8FAFC] font-sans">
<div className="relative flex items-center justify-center">
{/* Outer Glowing Pulsing Aura */}
<div className="absolute w-24 h-24 bg-[#1B9869]/10 rounded-full animate-ping pointer-events-none" />
{/* Outer Spinning Ring */}
<div className="w-20 h-20 border-4 border-gray-200 border-t-[#0B3B6A] rounded-full animate-spin"></div>
<div className="w-20 h-20 border-4 border-gray-200/80 border-t-[#1B9869] border-r-[#1B9869]/40 rounded-full animate-spin" />
{/* Icon Container */}
<div className="absolute flex items-center justify-center">
<div className="absolute flex items-center justify-center w-12 h-12 bg-white rounded-full shadow-sm border border-gray-100">
<ActiveIcon
key={icons[currentIndex].key}
size={32}
className="text-[#0B3B6A] transition-all duration-500 opacity-100"
strokeWidth={1.5}
size={28}
className="text-[#1B9869] transition-all duration-300 transform scale-100"
weight="bold"
/>
</div>
</div>
<div className="flex flex-col items-center gap-1">
<h3 className="text-[#0B3B6A] font-semibold text-lg tracking-wide">
HRM System
<div className="flex flex-col items-center gap-1.5 text-center">
<h3 className="text-gray-900 font-bold text-xl tracking-tight flex items-center gap-2">
Aero<span className="text-[#1B9869]">Resolve</span>
</h3>
<span className="text-gray-500 text-sm animate-pulse">
Loading resources...
<span className="text-gray-500 text-xs font-semibold uppercase tracking-wider">
Passenger Recovery & Resolution Platform
</span>
<span className="text-[13px] font-medium text-[#1B9869] animate-pulse mt-2">
Loading {icons[currentIndex].label}...
</span>
</div>
</div>
+57 -25
View File
@@ -2,10 +2,13 @@ import React, { useState, useRef, useEffect } from "react";
import { CaretDownIcon, CheckIcon, MagnifyingGlassIcon } from "@phosphor-icons/react";
import DropdownPortal from "./DropdownPortal";
interface Option {
export interface Option {
label: string;
value: string | number;
disabled?: boolean;
groupHeader?: boolean;
groupName?: string;
[key: string]: any;
}
interface CustomDropdownProps {
@@ -84,21 +87,32 @@ const CustomDropdown = React.forwardRef<HTMLDivElement, CustomDropdownProps>(
}, [isOpen, searchable]);
const handleSelect = (option: Option) => {
if (option.disabled) return;
if (option.disabled || option.groupHeader) return;
onChange?.(String(option.value));
setIsOpen(false);
setSearchQuery("");
};
const selectedOption = value !== '' && value !== null && value !== undefined
? options.find((opt) => String(opt.value) === String(value))
: undefined;
const selectedOption =
value !== "" && value !== null && value !== undefined
? options.find((opt) => !opt.groupHeader && String(opt.value) === String(value))
: undefined;
const filteredOptions = searchable && searchQuery.trim()
? options.filter((opt) =>
opt.label.toLowerCase().includes(searchQuery.toLowerCase().trim())
)
: options;
const filteredOptions =
searchable && searchQuery.trim()
? options.filter((opt, idx) => {
const lowerQuery = searchQuery.toLowerCase().trim();
if (opt.groupHeader) {
if (opt.label.toLowerCase().includes(lowerQuery)) return true;
for (let i = idx + 1; i < options.length; i++) {
if (options[i].groupHeader) break;
if (options[i].label.toLowerCase().includes(lowerQuery)) return true;
}
return false;
}
return opt.label.toLowerCase().includes(lowerQuery);
})
: options;
return (
<div className="w-full flex flex-col gap-1.5" ref={ref}>
@@ -115,14 +129,14 @@ const CustomDropdown = React.forwardRef<HTMLDivElement, CustomDropdownProps>(
className={`
w-full rounded-lg
bg-white
border ${error ? 'border-red-500' : 'border-gray-300'}
border ${error ? "border-red-500" : "border-gray-300"}
${sizeClasses[size]}
px-3
outline-none
transition-all duration-200
flex items-center
${!disabled ? 'cursor-pointer hover:border-primary' : 'cursor-not-allowed bg-gray-50 text-gray-500'}
${isOpen ? 'border-primary ring-2 ring-primary/20' : ''}
${!disabled ? "cursor-pointer hover:border-primary" : "cursor-not-allowed bg-gray-50 text-gray-500"}
${isOpen ? "border-primary ring-2 ring-primary/20" : ""}
${leftIcon ? "pl-10" : ""}
pr-10
${className}
@@ -136,9 +150,13 @@ const CustomDropdown = React.forwardRef<HTMLDivElement, CustomDropdownProps>(
<div className="flex-1 truncate text-left text-[14px] font-medium tracking-[0.25px] leading-[15px]">
{selectedOption ? (
<span style={{ color: '#6C766D' }}>{selectedOption.label}</span>
<span style={{ color: "#032D20" }}>
{selectedOption.groupName
? `${selectedOption.groupName} ${selectedOption.label}`
: selectedOption.label}
</span>
) : (
<span style={{ color: '#6C766D' }}>{placeholder}</span>
<span style={{ color: "#6C766D" }}>{placeholder}</span>
)}
</div>
@@ -155,7 +173,7 @@ const CustomDropdown = React.forwardRef<HTMLDivElement, CustomDropdownProps>(
anchorRef={dropdownRef}
isOpen={isOpen && !disabled}
ref={panelRef}
className="bg-white border border-gray-200 rounded-lg shadow-lg overflow-hidden flex flex-col max-h-[300px]"
className="bg-white border border-gray-200 rounded-lg shadow-lg overflow-hidden flex flex-col max-h-[320px]"
>
{searchable && (
<div className="p-2 border-b border-gray-100 bg-white sticky top-0 z-10">
@@ -174,13 +192,24 @@ const CustomDropdown = React.forwardRef<HTMLDivElement, CustomDropdownProps>(
</div>
)}
<div className="overflow-y-auto flex-1 max-h-[240px]">
<div className="overflow-y-auto flex-1 max-h-[260px]">
{filteredOptions.length === 0 ? (
<div className="px-4 py-3 text-sm text-gray-500 text-center">
{searchQuery ? "No matching options" : "No options available"}
</div>
) : (
filteredOptions.map((option) => {
filteredOptions.map((option, index) => {
if (option.groupHeader) {
return (
<div
key={option.value || `group_${index}`}
className="px-3 py-1.5 text-[11px] font-bold tracking-wider text-gray-500 uppercase bg-gray-50/90 border-y border-gray-100 sticky top-0 z-[5] select-none"
>
{option.label}
</div>
);
}
const isSelected = String(option.value) === String(value);
return (
<button
@@ -192,22 +221,25 @@ const CustomDropdown = React.forwardRef<HTMLDivElement, CustomDropdownProps>(
handleSelect(option);
}}
className={`
w-full text-left px-3 min-h-[42px] text-[14px] leading-none
transition-colors duration-150 flex items-center gap-2
${isSelected
? "bg-[#EEF9EF]"
: option.disabled
w-full text-left px-3 min-h-[38px] text-[13px] leading-none
transition-colors duration-150 flex items-center gap-2
${
isSelected
? "bg-[#EEF9EF]"
: option.disabled
? "text-gray-400 cursor-not-allowed"
: "text-gray-700 hover:bg-gray-50"
}
`}
`}
>
{isSelected && <CheckIcon size={16} className="text-primary shrink-0" strokeWidth={2.5} />}
<span
className={
isSelected
? "ml-1 font-medium bg-gradient-to-b from-[#1B9869] to-[#14704E] bg-clip-text text-transparent"
: "ml-6"
: option.groupName
? "ml-4 text-gray-800 font-normal"
: "ml-6 text-gray-800 font-normal"
}
>
{option.label}
+15 -15
View File
@@ -13,14 +13,14 @@ export interface Column<T> {
interface CustomTableProps<T> {
columns: Column<T>[];
data: T[];
// Header Props
searchPlaceholder?: string;
searchValue?: string;
onSearchChange?: (val: string) => void;
leftHeaderActions?: React.ReactNode;
rightHeaderActions?: React.ReactNode;
// Pagination Props
currentPage?: number;
totalPages?: number;
@@ -29,7 +29,7 @@ interface CustomTableProps<T> {
endIndex?: number;
onPageChange?: (page: number) => void;
itemName?: string;
// Table Props
onRowClick?: (row: T) => void;
rowClassName?: (row: T) => string;
@@ -53,7 +53,7 @@ export function CustomTable<T>({
onRowClick,
rowClassName,
}: CustomTableProps<T>) {
const handlePageChange = (newPage: number) => {
if (newPage >= 1 && newPage <= totalPages && onPageChange) {
onPageChange(newPage);
@@ -70,13 +70,13 @@ export function CustomTable<T>({
return (
<div className="w-full flex flex-col bg-white rounded-[14px] shadow-sm border border-gray-100 overflow-hidden">
{/* Top Header Section */}
<div className="flex items-center justify-between p-4 border-b border-gray-100">
<div className="flex items-center gap-4 flex-1">
{onSearchChange !== undefined && (
<div className="w-80">
<CustomInput
<CustomInput
placeholder={searchPlaceholder}
value={searchValue}
onChange={(e) => onSearchChange(e.target.value)}
@@ -88,7 +88,7 @@ export function CustomTable<T>({
)}
{leftHeaderActions}
</div>
<div className="flex items-center gap-3">
{rightHeaderActions}
</div>
@@ -100,8 +100,8 @@ export function CustomTable<T>({
<thead>
<tr className="bg-[#F3F6F5] border-b border-[#F9FAFB]">
{columns.map((col, index) => (
<th
key={index}
<th
key={index}
className={`py-4 px-6 text-[13px] font-semibold text-gray-500 whitespace-nowrap ${col.className || ""}`}
>
<div className="flex items-center gap-2">
@@ -116,8 +116,8 @@ export function CustomTable<T>({
<tbody>
{data.length > 0 ? (
data.map((row, rowIndex) => (
<tr
key={rowIndex}
<tr
key={rowIndex}
onClick={() => onRowClick?.(row)}
className={`border-b border-gray-50 hover:bg-gray-50/50 transition-colors ${onRowClick ? "cursor-pointer" : ""} ${rowClassName ? rowClassName(row) : ""}`}
>
@@ -146,16 +146,16 @@ export function CustomTable<T>({
<div className="text-[13px] font-medium text-gray-500">
Showing {totalItems > 0 ? startIndex : 0} to {endIndex} of {totalItems} {itemName}
</div>
<div className="flex items-center gap-2">
<button
<button
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1}
className="w-8 h-8 flex items-center justify-center rounded-lg border border-[#9FACA1] bg-white text-[#9FACA1] hover:bg-gray-50 hover:text-gray-900 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<CaretLeftIcon size={16} />
</button>
<div className="flex items-center gap-1">
{getPageNumbers().map(page => (
<button
@@ -173,7 +173,7 @@ export function CustomTable<T>({
))}
</div>
<button
<button
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className="w-8 h-8 flex items-center justify-center rounded-lg border border-[#9FACA1] bg-white text-[#9FACA1] hover:bg-gray-50 hover:text-gray-900 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
+9 -7
View File
@@ -3,10 +3,12 @@ export function formatDate(dateString?: string | Date): string {
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();
// Only treat as UTC if the string explicitly says so (Z or +hh:mm/-hh:mm offset)
const isUTC = typeof dateString === 'string' && /Z$|[+-]\d{2}:?\d{2}$/.test(dateString.trim());
const day = isUTC ? date.getUTCDate() : date.getDate();
const monthIdx = isUTC ? date.getUTCMonth() : date.getMonth();
const year = isUTC ? date.getUTCFullYear() : date.getFullYear();
const monthNames = [
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
@@ -14,8 +16,8 @@ export function formatDate(dateString?: string | Date): string {
];
const month = monthNames[monthIdx];
let hours = isISO ? date.getUTCHours() : date.getHours();
const minutes = (isISO ? date.getUTCMinutes() : date.getMinutes()).toString().padStart(2, '0');
let hours = isUTC ? date.getUTCHours() : date.getHours();
const minutes = (isUTC ? date.getUTCMinutes() : date.getMinutes()).toString().padStart(2, '0');
const ampm = hours >= 12 ? 'pm' : 'am';
hours = hours % 12;
hours = hours ? hours : 12;
@@ -23,4 +25,4 @@ export function formatDate(dateString?: string | Date): string {
return `${day} ${month} ${year}, ${hours}:${minutes}${ampm}`;
}
export default formatDate;
export default formatDate;