feat: implement recovery incident management and simulation modules with supporting types and UI components
This commit is contained in:
+11
-18
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import SimulationTerminal from './components/SimulationTerminal';
|
||||
|
||||
export default function SimulationPage() {
|
||||
return <SimulationTerminal />;
|
||||
}
|
||||
@@ -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.' },
|
||||
|
||||
Reference in New Issue
Block a user