feat: implement recovery incident management and simulation modules with supporting types and UI components

This commit is contained in:
Syed Waseem
2026-08-19 16:32:47 +05:30
parent 0dc64824c9
commit 1c562adf29
11 changed files with 793 additions and 21 deletions
+11 -18
View File
@@ -2,24 +2,16 @@ import { lazy, Suspense } from "react";
import { Route, Routes, Navigate } from "react-router-dom";
import Layout from "./layout/AppLayout";
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"));
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'))
const SimulationPage = lazy(() => import('./app/simulation'))
function AppRoutes() {
return (
@@ -27,6 +19,7 @@ function AppRoutes() {
<Suspense fallback={null}>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/simulation" element={<SimulationPage />} />
<Route path="/cohorts" element={<CohortManage />} />
<Route path="/policy-engine" element={<PolicyEngineList />} />
<Route path="/policy-engine/add" element={<AddPolicyEngine />} />
@@ -57,6 +57,7 @@ export interface RecoveryIncident {
status?: string;
value: string;
isPerksClaimed?: boolean;
recoverySource?: string;
isGroupHeader?: boolean;
evaluation?: IncidentEvaluation;
}
@@ -336,6 +336,7 @@ export default function AddRecoveryIncidents({ isOpen, onClose, incident }: AddR
jurisdiction: formData.jurisdiction || undefined,
delayDuration: formData.delayDuration ? Number(formData.delayDuration) : undefined,
scenario: formData.scenario || undefined,
recoverySource: existingInc ? (existingInc.recoverySource || 'Manual Disruption Entry') : 'Manual Disruption Entry',
};
if (existingInc) {
@@ -102,7 +102,7 @@ export default function RecoveryIncidentsList() {
// Filters
const [search, setSearch] = useState("");
const [isGrouped, setIsGrouped] = useState(true);
const [isGrouped, setIsGrouped] = useState(false);
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingIncident, setEditingIncident] = useState<RecoveryIncident | null>(null);
@@ -328,7 +328,7 @@ export function getMockFlightNumbers(): { label: string; value: string; descript
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];
@@ -47,7 +47,7 @@ export default function SummaryTab({ incident }: SummaryTabProps) {
Recovery Source
</span>
<span className="block text-[15px] font-semibold text-gray-900">
Policy Evaluation Engine
{incident?.recoverySource || 'Policy Evaluation Engine'}
</span>
</div>
+11
View File
@@ -0,0 +1,11 @@
import { ApiClient } from '../api/ApiClient';
// ─── Evaluation Engine & Simulation API ──────────────────────────────────────────
export function evaluateIncidentPolicy(input: any): Promise<any> {
return ApiClient.post<any, any>('/policy-engine/evaluate', input);
}
export function evaluateBatchSimulation(inputs: any[]): Promise<any[]> {
return ApiClient.post<any, any[]>('/policy-engine/evaluate-batch', inputs);
}
+30
View File
@@ -0,0 +1,30 @@
export interface SimulatedPassenger {
id: string;
name: string;
pnr: string;
tier: string;
eligibility: 'ELIGIBLE' | 'INELIGIBLE' | 'REVIEW';
refund: string;
comp: string;
statusBadges: string[];
cabinClass?: string;
originalCabin?: string;
actualCabin?: string;
passengerType?: string;
nationality?: string;
specialAssistance?: string;
}
export interface SimulationFormState {
category: 'Flight' | 'Travel' | 'Ancillary';
scenario: string;
scenarioSubType: string;
flightNumber: string;
airline: string;
origin: string;
destination: string;
flightDistance: string;
arrDelay: string;
depDelay: string;
jurisdiction: string;
}
@@ -0,0 +1,730 @@
import { useState, useMemo, useEffect } from 'react';
import {
MagnifyingGlassIcon,
FloppyDiskIcon,
CaretLeftIcon,
CaretRightIcon,
CheckCircleIcon,
AirplaneTiltIcon,
} from '@phosphor-icons/react';
import { useNavigate } from 'react-router-dom';
import {
CustomInput,
CustomDropdown,
CustomButton,
CustomTabs,
} from '../../../components/custom';
import type { TabItem } from '../../../components/custom/CustomTabs';
import type { Option } from '../../../components/custom/CustomDropdown';
import type { SimulatedPassenger, SimulationFormState } from '../SimulationTypes';
import { createRecoveryIncident } from '../../recoveryIncidents/RecoveryIncidentsApi';
import { getCategoryValues } from '../../configuration/masterData/MasterDataApi';
import { evaluateBatchSimulation } from '../SimulationApi';
const CATEGORY_TABS: TabItem[] = [
{ id: 'Flight', label: 'Flight', content: null },
{ id: 'Travel', label: 'Travel', content: null },
{ id: 'Ancillary', label: 'Ancillary', content: null },
];
const SIMULATION_PASSENGER_POOL: Array<Omit<SimulatedPassenger, 'refund' | 'comp' | 'statusBadges' | 'eligibility'>> = [
{
id: 'p-1',
name: 'Alexander Wright',
pnr: 'PNR-A1',
tier: 'Platinum',
cabinClass: 'First Class',
originalCabin: 'First Class',
actualCabin: 'First Class',
passengerType: 'VIP Adult',
nationality: 'British',
},
{
id: 'p-2',
name: 'Sarah Jenkins',
pnr: 'PNR-A2',
tier: 'Platinum',
cabinClass: 'First Class',
originalCabin: 'First Class',
actualCabin: 'First Class',
passengerType: 'High Value Adult',
nationality: 'British',
},
{
id: 'p-3',
name: 'The Miller Family',
pnr: 'PNR-B3',
tier: 'Gold',
cabinClass: 'Business Class',
originalCabin: 'Business Class',
actualCabin: 'Business Class',
passengerType: 'Family Group',
nationality: 'German',
},
{
id: 'p-4',
name: 'Marcus Chen',
pnr: 'PNR-C4',
tier: 'Silver',
cabinClass: 'Business Class',
originalCabin: 'Business Class',
actualCabin: 'Business Class',
passengerType: 'Adult',
nationality: 'American',
},
];
const SCENARIO_OPTIONS: Option[] = [
{ label: 'e.g. Passenger Compensation', value: 'e.g. Passenger Compensation' },
{ label: 'Flight Delay Disruption', value: 'Flight Delay Disruption' },
{ label: 'Flight Cancellation', value: 'Flight Cancellation' },
{ label: 'Denied Boarding / Involuntary Bumping', value: 'Denied Boarding' },
{ label: 'Missed Connection Delay', value: 'Missed Connection' },
];
const SCENARIO_SUBTYPE_OPTIONS: Option[] = [
{ label: 'e.g. Passenger Compensation', value: 'e.g. Passenger Compensation' },
{ label: 'Delay > 3 Hours (Long Haul)', value: 'Delay > 3 Hours' },
{ label: 'Delay > 4 Hours (Standard)', value: 'Delay > 4 Hours' },
{ label: 'Short Notice Cancellation (< 14 Days)', value: 'Short Notice Cancellation' },
{ label: 'Involuntary Cabin Downgrade', value: 'Involuntary Downgrade' },
];
const DISTANCE_OPTIONS: Option[] = [
{ label: '1500', value: '1500' },
{ label: '3500', value: '3500' },
{ label: '6200', value: '6200' },
{ label: '8500', value: '8500' },
{ label: '11200', value: '11200' },
];
export default function SimulationTerminal() {
const navigate = useNavigate();
// Form State
const [formData, setFormData] = useState<SimulationFormState>({
category: 'Flight',
scenario: 'e.g. Passenger Compensation',
scenarioSubType: 'e.g. Passenger Compensation',
flightNumber: 'LH450',
airline: 'Lufthansa',
origin: 'FRA',
destination: 'JFK',
flightDistance: '6200',
arrDelay: '240',
depDelay: '180',
jurisdiction: '',
});
const [jurisdictionOptions, setJurisdictionOptions] = useState<Option[]>([]);
const [passengers, setPassengers] = useState<SimulatedPassenger[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [isSimulating, setIsSimulating] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [savedSuccessCount, setSavedSuccessCount] = useState<number | null>(null);
// Load Master Data Jurisdictions
useEffect(() => {
let isMounted = true;
getCategoryValues('jurisdiction')
.then((items) => {
if (!isMounted) return;
if (Array.isArray(items) && items.length > 0) {
const activeOptions: Option[] = items
.filter((m) => m.isActive !== false)
.map((m) => ({
label: m.label || m.name || m.value,
value: m.label || m.value || m.name,
}));
if (activeOptions.length > 0) {
setJurisdictionOptions(activeOptions);
setFormData((prev) => ({
...prev,
jurisdiction: prev.jurisdiction || String(activeOptions[0].value),
}));
}
}
})
.catch((err) => {
console.error('Failed to load master data jurisdictions:', err);
});
return () => {
isMounted = false;
};
}, []);
// Pagination State
const [currentPage, setCurrentPage] = useState(1);
const pageSize = 4;
const handleInputChange = (field: keyof SimulationFormState, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
// Run Simulation Handler with real Cohort & Policy Engine evaluation
const handleRunSimulation = async () => {
setIsSimulating(true);
try {
const delay = parseInt(formData.arrDelay, 10) || 240;
// Build payload for each simulated passenger in the pool
const evaluationPayloads = SIMULATION_PASSENGER_POOL.map((p) => ({
recoveryCode: `SIM-${p.id}`,
passengerName: p.name,
pnr: p.pnr,
loyaltyTier: p.tier,
passengerType: p.passengerType,
nationality: p.nationality,
cabinClass: p.cabinClass,
originalCabin: p.originalCabin,
actualCabin: p.actualCabin,
flightNumber: formData.flightNumber || 'LH450',
flightRoute: `${formData.origin || 'FRA'}${formData.destination || 'JFK'}`,
origin: formData.origin || 'FRA',
destination: formData.destination || 'JFK',
date: new Date().toISOString(),
category: formData.category,
scenario: formData.scenarioSubType || formData.scenario,
jurisdiction: formData.jurisdiction,
delayDuration: delay,
}));
// Call the live Policy & Cohort Engine backend
const results = await evaluateBatchSimulation(evaluationPayloads);
const simulatedPassengers: SimulatedPassenger[] = SIMULATION_PASSENGER_POOL.map((p, index) => {
const res = Array.isArray(results) ? results[index] : null;
let computedComp = '--';
let computedRefund = '--';
const badges: string[] = [];
let eligibility: 'ELIGIBLE' | 'INELIGIBLE' = 'INELIGIBLE';
if (res) {
const isMatched = res.decision === 'MATCHED';
eligibility = isMatched ? 'ELIGIBLE' : 'INELIGIBLE';
// Extract actions resolved by Policy Engine
const rawActions = Array.isArray(res.actions) ? res.actions : [];
const normalizedActions = Array.isArray(res.normalizedActions) ? res.normalizedActions : [];
// Find refund action if any
const refundAction =
rawActions.find(
(a: any) =>
(a.category && /refund/i.test(a.category)) ||
(a.actionTypeCode && /REFUND/i.test(a.actionTypeCode)) ||
(a.actionName && /refund/i.test(a.actionName))
) ||
normalizedActions.find(
(a: any) =>
(a.category && /refund/i.test(a.category)) ||
(a.actionType && /refund/i.test(a.actionType))
);
if (refundAction) {
const refundAmt =
refundAction.amount ||
refundAction.configuration?.['Refund Amount'] ||
refundAction.configuration?.['Amount'] ||
'';
const refundCurr = refundAction.currency || refundAction.configuration?.['Currency'] || 'USD';
computedRefund = refundAmt ? `${refundAmt} ${refundCurr}`.trim() : 'Applied';
}
// Find compensation action if any
const compAction =
rawActions.find(
(a: any) =>
(a.category && /compensation|payout/i.test(a.category)) ||
(a.actionTypeCode && /COMP|PAYOUT/i.test(a.actionTypeCode)) ||
(a.actionName && /comp|cash|payout/i.test(a.actionName))
) ||
normalizedActions.find(
(a: any) =>
(a.category && /compensation|payout/i.test(a.category)) ||
(a.actionType && /comp|cash|payout/i.test(a.actionType))
);
if (compAction) {
const amt =
compAction.amount ||
compAction.configuration?.['Compensation Amount'] ||
compAction.configuration?.['Amount'] ||
'';
const curr = compAction.currency || compAction.configuration?.['Currency'] || 'EUR';
computedComp = amt ? `${amt} ${curr}`.trim() : 'Applied';
}
// Extract ONLY applied action types for STATUS badges (strictly from policy engine, no hardcoded fallbacks)
rawActions.forEach((act: any) => {
const actionName = act.actionName || act.actionTypeCode || act.category;
if (actionName && !badges.includes(actionName)) {
badges.push(actionName);
}
});
if (badges.length === 0) {
normalizedActions.forEach((act: any) => {
const actionTitle = act.title || act.actionType || act.category;
if (actionTitle && !badges.includes(actionTitle)) {
badges.push(actionTitle);
}
});
}
}
return {
...p,
refund: computedRefund,
comp: computedComp,
statusBadges: badges,
eligibility,
};
});
setPassengers(simulatedPassengers);
setCurrentPage(1);
} catch (err) {
console.error('Failed to run live policy evaluation:', err);
const simulatedPassengers: SimulatedPassenger[] = SIMULATION_PASSENGER_POOL.map((p) => ({
...p,
refund: '--',
comp: '--',
statusBadges: [],
eligibility: 'INELIGIBLE',
}));
setPassengers(simulatedPassengers);
setCurrentPage(1);
} finally {
setIsSimulating(false);
}
};
// Save to Recovery Database
const handleSaveAsRecovery = async () => {
try {
setIsSaving(true);
const promises = passengers.map((p) => {
const payload = {
recoveryCode: `REC-${Math.floor(100000 + Math.random() * 900000)}`,
passengerName: p.name,
pnr: p.pnr,
loyaltyTier: p.tier,
passengerType: p.passengerType,
nationality: p.nationality,
cabinClass: p.cabinClass,
originalCabin: p.originalCabin,
actualCabin: p.actualCabin,
flightNumber: formData.flightNumber || 'LH450',
flightRoute: `${formData.origin || 'FRA'}${formData.destination || 'JFK'}`,
origin: formData.origin || 'FRA',
destination: formData.destination || 'JFK',
date: new Date().toISOString(),
category: formData.scenario.includes('Compensation') ? 'Passenger Care' : 'Flight Ops',
scenario: formData.scenarioSubType || 'Flight Delay',
jurisdiction: formData.jurisdiction,
delayDuration: parseInt(formData.arrDelay, 10) || 240,
status: 'Pending',
value: p.comp,
isPerksClaimed: false,
recoverySource: 'Simulation Engine',
};
return createRecoveryIncident(payload);
});
await Promise.all(promises);
setSavedSuccessCount(passengers.length);
} catch (err) {
console.error('Failed to save simulated cases:', err);
alert('Unable to save simulation manifest. Please try again.');
} finally {
setIsSaving(false);
}
};
// Filter passengers based on search
const filteredPassengers = useMemo(() => {
if (!searchQuery.trim()) return passengers;
const q = searchQuery.toLowerCase();
return passengers.filter(
(p) =>
p.name.toLowerCase().includes(q) ||
p.pnr.toLowerCase().includes(q) ||
p.tier.toLowerCase().includes(q)
);
}, [passengers, searchQuery]);
const totalPages = Math.ceil(filteredPassengers.length / pageSize) || 1;
const paginatedPassengers = filteredPassengers.slice(
(currentPage - 1) * pageSize,
currentPage * pageSize
);
return (
<div className="w-full h-full flex flex-col gap-6 pb-4">
{/* 2-Column Grid Layout */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 items-start">
{/* Left Column: Assessment Setup */}
<div className="lg:col-span-5 xl:col-span-5 bg-white rounded-2xl p-6 border border-gray-100/90 shadow-[0_2px_10px_-4px_rgba(0,0,0,0.04)] flex flex-col gap-5">
<h2 className="text-[17px] font-bold text-[#111827] tracking-tight">
Assessment Setup
</h2>
{/* Segmented Category Tabs */}
<CustomTabs
tabs={CATEGORY_TABS}
value={formData.category}
onChange={(tabId) => handleInputChange('category', tabId as SimulationFormState['category'])}
tabListClassName="w-full !bg-[#F4F7F6] !p-1 !rounded-xl !border !border-gray-200/40 !shadow-none !gap-0"
tabClassName="!flex-1 !py-2 !text-xs !font-semibold !rounded-lg !px-2 !shadow-none"
activeTabClassName="!bg-[#143D30] !text-white !shadow-sm"
contentClassName="!hidden"
/>
{/* Form Fields using Custom Components */}
<div className="flex flex-col gap-3.5">
{/* Scenario */}
<CustomDropdown
label="Scenario"
options={SCENARIO_OPTIONS}
value={formData.scenario}
onChange={(val) => handleInputChange('scenario', val)}
searchable={false}
size="sm"
/>
{/* Scenario Sub-Type */}
<CustomDropdown
label="Scenario Sub-Type"
options={SCENARIO_SUBTYPE_OPTIONS}
value={formData.scenarioSubType}
onChange={(val) => handleInputChange('scenarioSubType', val)}
searchable={false}
size="sm"
/>
{/* Flight No & Airline */}
<div className="grid grid-cols-2 gap-3">
<CustomInput
label="Flight No"
value={formData.flightNumber}
onChange={(e) => handleInputChange('flightNumber', e.target.value)}
placeholder="LH450"
size="sm"
/>
<CustomInput
label="Airline"
value={formData.airline}
onChange={(e) => handleInputChange('airline', e.target.value)}
placeholder="Lufthansa"
size="sm"
/>
</div>
{/* Origin & Dest */}
<div className="grid grid-cols-2 gap-3">
<CustomInput
label="Origin"
value={formData.origin}
onChange={(e) => handleInputChange('origin', e.target.value)}
placeholder="FRA"
size="sm"
/>
<CustomInput
label="Dest"
value={formData.destination}
onChange={(e) => handleInputChange('destination', e.target.value)}
placeholder="JFK"
size="sm"
/>
</div>
{/* Flight Distance (KM) */}
<CustomDropdown
label="Flight Distance (KM)"
options={DISTANCE_OPTIONS}
value={formData.flightDistance}
onChange={(val) => handleInputChange('flightDistance', val)}
searchable={false}
size="sm"
/>
{/* Arr Delay (Mins) & Dep Delay */}
<div className="grid grid-cols-2 gap-3">
<CustomInput
label="Arr Delay (Mins)"
type="number"
value={formData.arrDelay}
onChange={(e) => handleInputChange('arrDelay', e.target.value)}
placeholder="240"
size="sm"
/>
<CustomInput
label="Dep Delay"
type="number"
value={formData.depDelay}
onChange={(e) => handleInputChange('depDelay', e.target.value)}
placeholder="180"
size="sm"
/>
</div>
{/* Jurisdiction */}
<CustomDropdown
label="Jurisdiction"
options={jurisdictionOptions}
value={formData.jurisdiction}
onChange={(val) => handleInputChange('jurisdiction', val)}
searchable={false}
size="sm"
/>
</div>
{/* Action Button using CustomButton */}
<div className="w-full pt-1">
<CustomButton
variant="primary"
loading={isSimulating}
onClick={handleRunSimulation}
className="w-full !rounded-xl !py-3.5 !text-[13px] !font-bold tracking-wide shadow-sm"
>
Run Manifest Simulation
</CustomButton>
</div>
</div>
{/* Right Column: Assessment Manifest */}
<div className="lg:col-span-7 xl:col-span-7 bg-white rounded-2xl p-6 border border-gray-100/90 shadow-[0_2px_10px_-4px_rgba(0,0,0,0.04)] flex flex-col justify-between min-h-[580px]">
<div className="flex flex-col gap-5 flex-1">
{/* Header */}
<div>
<h2 className="text-[17px] font-bold text-[#111827] tracking-tight">
Assessment Manifest
</h2>
<p className="text-[12px] font-medium text-gray-400 mt-0.5">
Real-time recovery outcomes.
</p>
</div>
{passengers.length === 0 ? (
/* Clean Empty Placeholder (No Search bar, No Save as Recovery button, No Table headers) */
<div className="flex-1 flex flex-col items-center justify-center text-center p-8 border border-dashed border-gray-200 rounded-2xl bg-gray-50/40 my-auto min-h-[380px]">
<div className="w-16 h-16 rounded-2xl bg-[#1B9869]/10 text-[#1B9869] flex items-center justify-center mb-4 shadow-xs">
<AirplaneTiltIcon size={32} weight="duotone" />
</div>
<h3 className="text-[15px] font-bold text-gray-800 tracking-tight">
No Simulation Manifest Generated
</h3>
<p className="text-[13px] text-gray-500 max-w-md mt-1.5 leading-relaxed">
Configure the flight disruption parameters under <span className="font-semibold text-gray-700">Assessment Setup</span> and click <span className="font-semibold text-[#1B9869]">"Run Manifest Simulation"</span> to evaluate passenger recovery entitlements and policy rules.
</p>
</div>
) : (
<>
{/* Action Bar (Search & Save as Recovery) */}
<div className="flex items-center gap-3">
<div className="flex-1">
<CustomInput
placeholder="Search by Passenger Name..."
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
setCurrentPage(1);
}}
leftIcon={<MagnifyingGlassIcon size={16} />}
size="sm"
containerClassName="!gap-0"
/>
</div>
<CustomButton
variant="outlined"
size="sm"
loading={isSaving}
disabled={filteredPassengers.length === 0}
leftIcon={<FloppyDiskIcon size={16} weight="bold" />}
onClick={handleSaveAsRecovery}
className="!border-[#1B9869] !text-[#1B9869] hover:!bg-[#1B9869]/5 !rounded-xl !px-4 !py-2 shrink-0 font-semibold text-xs"
>
Save as Recovery
</CustomButton>
</div>
{/* Manifest Table */}
<div className="w-full overflow-x-auto mt-1">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b border-gray-100">
<th className="pb-3 text-[11px] font-bold text-gray-500 uppercase tracking-wider w-[36%]">
PASSENGER
</th>
<th className="pb-3 text-[11px] font-bold text-gray-500 uppercase tracking-wider w-[18%]">
ELIGIBILITY
</th>
<th className="pb-3 text-[11px] font-bold text-gray-500 uppercase tracking-wider w-[12%]">
REFUND
</th>
<th className="pb-3 text-[11px] font-bold text-gray-500 uppercase tracking-wider w-[14%]">
COMP
</th>
<th className="pb-3 text-[11px] font-bold text-gray-500 uppercase tracking-wider">
STATUS
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100/70">
{paginatedPassengers.length > 0 ? (
paginatedPassengers.map((passenger) => (
<tr key={passenger.id} className="group hover:bg-gray-50/50 transition-colors">
{/* Passenger */}
<td className="py-4 pr-2">
<div className="flex flex-col">
<span className="font-bold text-[13px] text-[#111827] leading-snug">
{passenger.name}
</span>
<span className="text-[11px] text-gray-400 font-medium mt-0.5">
{passenger.pnr} {passenger.tier}
</span>
</div>
</td>
{/* Eligibility */}
<td className="py-4 pr-2 align-middle">
<span
className={`inline-block px-2.5 py-0.5 text-[10.5px] font-bold rounded-full tracking-wide ${passenger.eligibility === 'ELIGIBLE'
? 'bg-[#E8F8F0] text-[#1B9869] border border-[#BDEFD7]'
: 'bg-gray-100 text-gray-500 border border-gray-200'
}`}
>
{passenger.eligibility}
</span>
</td>
{/* Refund */}
<td className="py-4 pr-2 align-middle text-[12px] font-semibold text-gray-400">
{passenger.refund}
</td>
{/* Comp */}
<td className="py-4 pr-2 align-middle font-bold text-[12px] text-[#111827]">
{passenger.comp}
</td>
{/* Status Badges */}
<td className="py-4 align-middle">
{passenger.statusBadges && passenger.statusBadges.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5">
{passenger.statusBadges.map((badge, idx) => (
<span
key={idx}
className="bg-[#F3F4F6] text-gray-600 border border-gray-200/80 text-[11px] font-medium px-2 py-0.5 rounded-md whitespace-nowrap"
>
{badge}
</span>
))}
</div>
) : (
<span className="text-gray-400 text-[12px] font-medium">--</span>
)}
</td>
</tr>
))
) : (
<tr>
<td colSpan={5} className="py-10 text-center text-xs text-gray-400">
No matching passengers found in this manifest simulation.
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Footer & Pagination */}
<div className="flex items-center justify-between pt-4 border-t border-gray-100 mt-4">
<span className="text-[12px] font-medium text-gray-400">
Showing {filteredPassengers.length > 0 ? (currentPage - 1) * pageSize + 1 : 0} to{' '}
{Math.min(currentPage * pageSize, filteredPassengers.length)} of{' '}
{filteredPassengers.length} entries
</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage <= 1 || filteredPassengers.length === 0}
aria-label="Previous page"
className="p-1 rounded-lg text-gray-400 hover:text-gray-700 hover:bg-gray-100 disabled:opacity-30 disabled:hover:bg-transparent transition-colors cursor-pointer"
>
<CaretLeftIcon size={14} weight="bold" />
</button>
<span className="text-[12px] font-semibold text-gray-600 px-1">
{filteredPassengers.length > 0 ? `${currentPage}/${totalPages}` : '0/0'}
</span>
<button
type="button"
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage >= totalPages || filteredPassengers.length === 0}
aria-label="Next page"
className="p-1 rounded-lg text-gray-400 hover:text-gray-700 hover:bg-gray-100 disabled:opacity-30 disabled:hover:bg-transparent transition-colors cursor-pointer"
>
<CaretRightIcon size={14} weight="bold" />
</button>
</div>
</div>
</>
)}
</div>
</div>
</div>
{/* Success Modal after Saving Incidents */}
{savedSuccessCount !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-xs p-4 animate-in fade-in duration-200">
<div className="bg-white rounded-2xl p-6 max-w-md w-full shadow-2xl border border-gray-100 flex flex-col items-center text-center gap-4 animate-in zoom-in-95 duration-200">
<div className="w-12 h-12 rounded-full bg-emerald-50 text-[#1B9869] flex items-center justify-center">
<CheckCircleIcon size={30} weight="fill" />
</div>
<div className="flex flex-col gap-1">
<h3 className="text-lg font-bold text-gray-900">
Manifest Saved to Recovery Database
</h3>
<p className="text-xs text-gray-500">
Successfully created {savedSuccessCount} recovery incident case(s) for flight{' '}
<span className="font-semibold text-gray-800">{formData.flightNumber}</span> with
active entitlement rules.
</p>
</div>
<div className="flex items-center gap-3 w-full mt-2">
<button
type="button"
onClick={() => setSavedSuccessCount(null)}
className="flex-1 py-2.5 border border-gray-200 text-gray-700 hover:bg-gray-50 font-semibold text-xs rounded-xl transition-colors cursor-pointer"
>
Continue Simulating
</button>
<button
type="button"
onClick={() => {
setSavedSuccessCount(null);
navigate('/recovery');
}}
className="flex-1 py-2.5 bg-[#1B9869] hover:bg-[#158057] text-white font-semibold text-xs rounded-xl transition-colors cursor-pointer"
>
View Recovery Cases
</button>
</div>
</div>
</div>
)}
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
import SimulationTerminal from './components/SimulationTerminal';
export default function SimulationPage() {
return <SimulationTerminal />;
}
+1
View File
@@ -3,6 +3,7 @@ import { useLocation } from 'react-router-dom';
const PAGE_META: Record<string, { title: string; subtitle: string }> = {
'/': { title: 'Dashboard', subtitle: 'Overview of system status and active incidents.' },
'/simulation': { title: 'Simulation Terminal', subtitle: 'Integrated manifest-level assessment engine.' },
'/cohorts': { title: 'Cohort Management', subtitle: 'Dynamic passenger segmentation for targeted recovery and recovery intelligence.' },
'/policy-engine': { title: 'Policy Engine Framework Registry', subtitle: 'Manage framework policies, conditions, and automated actions.' },
'/policy-engine/add': { title: 'Deploy New Policy', subtitle: 'Configure policy framework details, targeting rules, and action payloads.' },