Merge pull request 'feat: add simulation and recovery incident modules, including new input components and UI scaffolding' (#58) from waseem into development

Reviewed-on: https://gitea.maskantech.in/gitea_admin/aeroresolve_frontend/pulls/58
This commit is contained in:
Syed Waseem khadri Rafai
2026-08-29 08:57:09 +00:00
9 changed files with 1208 additions and 445 deletions
@@ -58,6 +58,18 @@ interface FieldDefinitionFormModalProps {
onSubmit: (e: React.FormEvent) => void;
}
const VALIDATION_RELEVANT_TYPES: FieldType[] = [
'textbox',
'textarea',
'email',
'phone',
'url',
'number',
'decimal',
'percentage',
'currency',
];
export function FieldDefinitionFormModal({
isOpen,
editingField,
@@ -94,6 +106,12 @@ export function FieldDefinitionFormModal({
})),
];
const isNumericType = ['number', 'decimal', 'percentage', 'currency'].includes(formData.fieldType);
const minLabel = isNumericType ? 'Min Value' : 'Min Length';
const maxLabel = isNumericType ? 'Max Value' : 'Max Length';
const minPlaceholder = isNumericType ? 'e.g. 0' : 'e.g. 3';
const maxPlaceholder = isNumericType ? 'e.g. 1000' : 'e.g. 250';
return (
<CustomModal
isOpen={isOpen}
@@ -153,11 +171,12 @@ export function FieldDefinitionFormModal({
onChange={(val) => {
const newType = val as FieldType;
const isLookupType = newType === 'dropdown' || newType === 'multi_select';
const isValRelevant = VALIDATION_RELEVANT_TYPES.includes(newType);
setFormData((prev) => ({
...prev,
fieldType: newType,
lookupSource: isLookupType ? prev.lookupSource : undefined,
validationJson: isLookupType ? undefined : prev.validationJson,
validationJson: isValRelevant ? prev.validationJson : undefined,
}));
if (setError && !isLookupType) {
setError(null);
@@ -168,12 +187,12 @@ export function FieldDefinitionFormModal({
</div>
<div
className={`grid grid-cols-1 ${formData.fieldType === 'dropdown' || formData.fieldType === 'multi_select'
className={`grid grid-cols-1 ${formData.fieldType === 'dropdown' || formData.fieldType === 'multi_select' || formData.fieldType === 'currency'
? 'md:grid-cols-3'
: 'md:grid-cols-2'
} gap-4`}
>
{(formData.fieldType === 'dropdown' || formData.fieldType === 'multi_select') && (
{(formData.fieldType === 'dropdown' || formData.fieldType === 'multi_select' || formData.fieldType === 'currency') && (
<CustomDropdown
label="Master Data Lookup Source"
required
@@ -235,13 +254,13 @@ export function FieldDefinitionFormModal({
rows={2}
/>
{/* Validation JSON Rules */}
{formData.fieldType !== 'dropdown' && formData.fieldType !== 'multi_select' && (
{/* Validation JSON Rules - Only shown for relevant field types */}
{VALIDATION_RELEVANT_TYPES.includes(formData.fieldType) && (
<div className="p-4 bg-slate-50 border border-slate-200 rounded-[14px] space-y-3">
<h5 className="text-[13px] font-bold text-slate-800">Validation Rules (validation_json)</h5>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<CustomInput
label="Min Value / Length"
label={minLabel}
type="number"
value={formData.validationJson?.min !== undefined ? String(formData.validationJson.min) : ''}
onChange={(e) =>
@@ -253,11 +272,11 @@ export function FieldDefinitionFormModal({
},
}))
}
placeholder="e.g. 312"
placeholder={minPlaceholder}
/>
<CustomInput
label="Max Value / Length"
label={maxLabel}
type="number"
value={formData.validationJson?.max !== undefined ? String(formData.validationJson.max) : ''}
onChange={(e) =>
@@ -269,7 +288,7 @@ export function FieldDefinitionFormModal({
},
}))
}
placeholder="e.g. 245"
placeholder={maxPlaceholder}
/>
<CustomInput
@@ -1387,138 +1387,219 @@ export default function AddPolicyEngine() {
return null;
}
// Group visible fields by section / group name
const groupedSectionsMap = new Map<string, typeof visibleFields>();
visibleFields.forEach((field) => {
const secName = field.section?.trim() || '';
const fields = groupedSectionsMap.get(secName) || [];
fields.push(field);
groupedSectionsMap.set(secName, fields);
});
return (
<div className="grid grid-cols-12 gap-4">
{visibleFields.map((field) => {
const widthClass = WIDTH_GRID_MAP[field.width || 'full'] || 'col-span-12';
const fieldVal = action.fieldValues?.[field.fieldCode];
const lookupOpts = fieldLookupOptionsMap[field.lookupSource || ''] || [];
const helpText = field.helpText || (field as any).help_text;
const valRules = field.validationJson || (field as any).validation_json;
<div className="space-y-4">
{Array.from(groupedSectionsMap.entries()).map(([secName, secFields]) => (
<div key={secName || 'default-section'} className="space-y-3">
{secName && (
<div className="flex items-center gap-2 pt-1 border-b border-emerald-100 pb-1 mt-1">
<div className="w-2 h-2 rounded-full bg-[#1B9869]"></div>
<h5 className="text-[12px] font-bold text-[#14704E] tracking-wider uppercase">
{secName}
</h5>
</div>
)}
<div className="grid grid-cols-12 gap-4">
{secFields.map((field) => {
const widthClass = WIDTH_GRID_MAP[field.width || 'full'] || 'col-span-12';
const rawVal = action.fieldValues?.[field.fieldCode];
const defaultVal =
field.defaultValue ??
(field as any).default_value ??
(field as any).defaultValueJson ??
(field as any).default_value_json;
return (
<div key={field.id} className={widthClass}>
{field.fieldType === 'textarea' ? (
<CustomTextArea
label={field.fieldName}
required={field.isRequired}
validationJson={valRules}
value={fieldVal || ''}
onChange={(e) => handleFieldValueChange(rule.id, action.id, field.fieldCode, e.target.value)}
placeholder={field.placeholder || 'Enter details...'}
/>
) : field.fieldType === 'currency' ? (
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
{field.fieldName}
{field.isRequired && <span className="text-red-500 ml-1">*</span>}
</label>
<div className="grid grid-cols-3 gap-2">
<div className="col-span-2">
<CustomInput
const effectiveVal =
rawVal !== undefined && rawVal !== null && rawVal !== ''
? rawVal
: defaultVal;
const lookupOpts = fieldLookupOptionsMap[field.lookupSource || ''] || [];
const helpText = field.helpText || (field as any).help_text;
const valRules = field.validationJson || (field as any).validation_json;
const getBoolVal = (v: any) => {
if (typeof v === 'boolean') return v;
if (typeof v === 'string') {
return v.toLowerCase() === 'true' || v === '1' || v.toLowerCase() === 'yes';
}
return !!v;
};
const getMultiVal = (v: any): string[] => {
if (Array.isArray(v)) return v;
if (typeof v === 'string') {
if (v.trim().startsWith('[')) {
try {
const p = JSON.parse(v);
if (Array.isArray(p)) return p;
} catch { }
}
if (v.includes(',')) return v.split(',').map((s) => s.trim());
return v.trim() ? [v.trim()] : [];
}
return [];
};
const getCurrencyVal = (v: any) => {
let currObj = v;
if (typeof currObj === 'string' && currObj.trim().startsWith('{')) {
try { currObj = JSON.parse(currObj); } catch { }
}
const amt =
typeof currObj === 'object' && currObj !== null && 'amount' in currObj
? currObj.amount
: typeof currObj === 'object' && currObj !== null
? ''
: currObj ?? '';
const curr =
typeof currObj === 'object' && currObj !== null && 'currency' in currObj
? currObj.currency
: undefined;
return { amount: amt, currency: curr };
};
return (
<div key={field.id} className={widthClass}>
{field.fieldType === 'textarea' ? (
<CustomTextArea
label={field.fieldName}
required={field.isRequired}
validationJson={valRules}
type="number"
value={fieldVal?.amount || ''}
onChange={(e) =>
handleFieldValueChange(rule.id, action.id, field.fieldCode, {
...(fieldVal || {}),
amount: e.target.value,
})
}
placeholder={field.placeholder || 'Amount'}
value={effectiveVal !== undefined && effectiveVal !== null ? String(effectiveVal) : ''}
onChange={(e) => handleFieldValueChange(rule.id, action.id, field.fieldCode, e.target.value)}
placeholder={field.placeholder || 'Enter details...'}
/>
</div>
<CustomDropdown
options={lookupOpts}
value={fieldVal?.currency}
onChange={(val) =>
handleFieldValueChange(rule.id, action.id, field.fieldCode, {
...(fieldVal || {}),
currency: val,
})
}
/>
</div>
</div>
) : field.fieldType === 'dropdown' ? (
<CustomDropdown
label={field.fieldName}
required={field.isRequired}
options={lookupOpts}
value={fieldVal || ''}
onChange={(val) => handleFieldValueChange(rule.id, action.id, field.fieldCode, val)}
placeholder={field.placeholder || 'Select option...'}
/>
) : field.fieldType === 'multi_select' ? (
<CustomMultiSelect
label={field.fieldName}
required={field.isRequired}
options={lookupOpts}
value={Array.isArray(fieldVal) ? fieldVal : []}
onChange={(vals) => handleFieldValueChange(rule.id, action.id, field.fieldCode, vals)}
placeholder={field.placeholder || 'Select multiple options...'}
/>
) : field.fieldType === 'checkbox' ? (
<CustomCheckBox
checked={!!fieldVal}
onChange={(e) => handleFieldValueChange(rule.id, action.id, field.fieldCode, e.target.checked)}
label={field.fieldName}
/>
) : field.fieldType === 'switch' ? (
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
{field.fieldName}
{field.isRequired && <span className="text-red-500 ml-1">*</span>}
</label>
<div className="flex items-center gap-3 pt-1">
<CustomSwitch
checked={!!fieldVal}
onChange={(e) => handleFieldValueChange(rule.id, action.id, field.fieldCode, e.target.checked)}
/>
<span className="text-[13px] font-medium text-slate-700">
{fieldVal ? 'Enabled' : 'Disabled'}
</span>
</div>
</div>
) : (
<CustomInput
label={field.fieldName}
required={field.isRequired}
validationJson={valRules}
type={
field.fieldType === 'number' || field.fieldType === 'decimal' || field.fieldType === 'percentage'
? 'number'
: field.fieldType === 'email'
? 'email'
: field.fieldType === 'date'
? 'date'
: field.fieldType === 'time'
? 'time'
) : field.fieldType === 'currency' ? (() => {
const currParsed = getCurrencyVal(effectiveVal);
return (
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
{field.fieldName}
{field.isRequired && <span className="text-red-500 ml-1">*</span>}
</label>
<div className="grid grid-cols-3 gap-2">
<div className="col-span-2">
<CustomInput
validationJson={valRules}
type="number"
value={currParsed.amount || ''}
onChange={(e) =>
handleFieldValueChange(rule.id, action.id, field.fieldCode, {
...(currParsed || {}),
amount: e.target.value,
})
}
placeholder={field.placeholder || 'Amount'}
/>
</div>
<CustomDropdown
options={lookupOpts}
value={currParsed.currency}
onChange={(val) =>
handleFieldValueChange(rule.id, action.id, field.fieldCode, {
...(currParsed || {}),
currency: val,
})
}
/>
</div>
</div>
);
})() : field.fieldType === 'dropdown' ? (
<CustomDropdown
label={field.fieldName}
required={field.isRequired}
options={lookupOpts}
value={effectiveVal !== undefined && effectiveVal !== null ? String(effectiveVal) : ''}
onChange={(val) => handleFieldValueChange(rule.id, action.id, field.fieldCode, val)}
placeholder={field.placeholder || 'Select option...'}
/>
) : field.fieldType === 'multi_select' ? (
<CustomMultiSelect
label={field.fieldName}
required={field.isRequired}
options={lookupOpts}
value={getMultiVal(effectiveVal)}
onChange={(vals) => handleFieldValueChange(rule.id, action.id, field.fieldCode, vals)}
placeholder={field.placeholder || 'Select multiple options...'}
/>
) : field.fieldType === 'checkbox' ? (
<CustomCheckBox
checked={getBoolVal(effectiveVal)}
onChange={(e) => handleFieldValueChange(rule.id, action.id, field.fieldCode, e.target.checked)}
label={field.fieldName}
/>
) : field.fieldType === 'switch' ? (
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
{field.fieldName}
{field.isRequired && <span className="text-red-500 ml-1">*</span>}
</label>
<div className="flex items-center gap-3 pt-1">
<CustomSwitch
checked={getBoolVal(effectiveVal)}
onChange={(e) => handleFieldValueChange(rule.id, action.id, field.fieldCode, e.target.checked)}
/>
<span className="text-[13px] font-medium text-slate-700">
{getBoolVal(effectiveVal) ? 'Enabled' : 'Disabled'}
</span>
</div>
</div>
) : (
<CustomInput
label={field.fieldName}
required={field.isRequired}
validationJson={valRules}
type={
field.fieldType === 'number' || field.fieldType === 'decimal' || field.fieldType === 'percentage'
? 'number'
: field.fieldType === 'email'
? 'email'
: field.fieldType === 'date'
? 'date'
: field.fieldType === 'time'
? 'time'
: field.fieldType === 'datetime'
? 'datetime-local'
: 'text'
}
value={effectiveVal !== undefined && effectiveVal !== null ? String(effectiveVal) : ''}
onChange={(e) => handleFieldValueChange(rule.id, action.id, field.fieldCode, e.target.value)}
placeholder={field.placeholder || 'Enter value...'}
min={
field.fieldType === 'date'
? new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().split('T')[0]
: field.fieldType === 'datetime'
? 'datetime-local'
: 'text'
}
value={fieldVal ?? ''}
onChange={(e) => handleFieldValueChange(rule.id, action.id, field.fieldCode, e.target.value)}
placeholder={field.placeholder || 'Enter value...'}
min={
field.fieldType === 'date'
? new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().split('T')[0]
: field.fieldType === 'datetime'
? new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().slice(0, 16)
: undefined
}
/>
)}
? new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().slice(0, 16)
: undefined
}
/>
)}
{helpText && (
<p className="text-[11.5px] text-gray-500 mt-1 leading-snug">
{helpText}
</p>
)}
{helpText && (
<p className="text-[11.5px] text-gray-500 mt-1 leading-snug">
{helpText}
</p>
)}
</div>
);
})}
</div>
);
})}
</div>
))}
</div>
);
})()}
@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { useNavigate, useLocation } from "react-router-dom";
import {
EyeIcon,
PencilSimpleIcon,
@@ -100,12 +100,20 @@ function getStatusVariant(
export default function RecoveryIncidentsList() {
const navigate = useNavigate();
const location = useLocation();
const [incidents, setIncidents] = useState<RecoveryIncident[]>([]);
const [metrics, setMetrics] = useState<MetricCardData[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [successMsg, setSuccessMsg] = useState<string | null>(null);
useEffect(() => {
if (location.state?.successMsg) {
setSuccessMsg(location.state.successMsg);
window.history.replaceState({}, document.title);
}
}, [location]);
// Pagination
const [currentPage, setCurrentPage] = useState(1);
+30 -2
View File
@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { User, Checks, X, ClockCounterClockwiseIcon, ArrowLeftIcon, ArrowsClockwiseIcon, AirplaneTiltIcon } from '@phosphor-icons/react';
import { CustomButton, CustomTabs, CustomBackButton, CustomStatus, Skeleton } from '../../../components/custom';
import { CustomButton, CustomTabs, CustomBackButton, CustomStatus, Skeleton, CustomAlertBanner } from '../../../components/custom';
import SummaryTab from './SummaryTab';
import CaseDetailsTab from './CaseDetailsTab';
import RecoveryPlanTab from './RecoveryPlanTab';
@@ -27,6 +27,8 @@ export default function RecoveryIncidentTabs() {
const [incident, setIncident] = useState<RecoveryIncident | null>(null);
const [loading, setLoading] = useState(true);
const [updating, setUpdating] = useState(false);
const [error, setError] = useState<string | null>(null);
const [successMsg, setSuccessMsg] = useState<string | null>(null);
const fetchIncident = async () => {
if (!id) return;
@@ -48,12 +50,19 @@ export default function RecoveryIncidentTabs() {
const handleStatusChange = async (newStatus: string) => {
if (!id || updating) return;
setUpdating(true);
setError(null);
try {
const updated = await updateIncidentStatus(id, newStatus);
setIncident(updated);
navigate('/recovery');
const code = updated?.recoveryCode || incident?.recoveryCode || id;
navigate('/recovery', {
state: {
successMsg: `Incident ${code} status updated to ${newStatus} successfully.`
}
});
} catch (err) {
console.error("Failed to update incident status:", err);
setError("Failed to update incident status. Please try again.");
} finally {
setUpdating(false);
}
@@ -62,11 +71,15 @@ export default function RecoveryIncidentTabs() {
const handleReRunEngine = async () => {
if (!id || updating) return;
setUpdating(true);
setError(null);
setSuccessMsg(null);
try {
const updated = await reRunPolicyEngine(id);
setIncident(updated);
setSuccessMsg("Policy engine re-evaluated successfully.");
} catch (err) {
console.error("Failed to re-run policy engine:", err);
setError("Failed to re-run policy engine. Please try again.");
} finally {
setUpdating(false);
}
@@ -154,6 +167,21 @@ export default function RecoveryIncidentTabs() {
return (
<div className="w-full flex flex-col h-full relative">
{error && (
<CustomAlertBanner
message={error}
type="error"
onClose={() => setError(null)}
/>
)}
{successMsg && (
<CustomAlertBanner
message={successMsg}
type="success"
onClose={() => setSuccessMsg(null)}
/>
)}
{/* Header */}
<div className="flex items-start justify-between pb-6 border-b border-gray-100">
<div className="flex flex-col gap-2">
+13 -12
View File
@@ -1,30 +1,31 @@
export interface SimulatedPassenger {
export interface SimulatedPassengerInput {
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;
bookingRef?: string;
ancillaryItem?: string;
[key: string]: any;
}
export interface SimulatedPassenger extends SimulatedPassengerInput {
eligibility: 'ELIGIBLE' | 'INELIGIBLE' | 'REVIEW';
refund: string;
comp: string;
perks: string[];
statusBadges?: 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;
[key: string]: any;
}
@@ -13,114 +13,76 @@ import {
CustomDropdown,
CustomButton,
CustomTabs,
CustomSwitch,
} from '../../../components/custom';
import type { TabItem } from '../../../components/custom/CustomTabs';
import type { Option } from '../../../components/custom/CustomDropdown';
import type { TabItem } from '../../../components/custom/CustomTabs';
import type { SimulatedPassenger, SimulationFormState } from '../SimulationTypes';
import { createRecoveryIncident } from '../../recoveryIncidents/RecoveryIncidentsApi';
import { getCategoryValues } from '../../configuration/masterData/MasterDataApi';
import { evaluateBatchSimulation } from '../SimulationApi';
import {
CATEGORY_SIMULATION_CONFIGS,
type CategorySimulationConfig,
type FormFieldConfig,
} from '../simulationConfig';
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: '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: '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' },
const DEFAULT_JURISDICTIONS: Option[] = [
{ label: 'Global Policy', value: 'Global Policy' },
{ label: 'European Union (EU261)', value: 'European Union' },
{ label: 'United States (DOT)', value: 'United States' },
{ label: 'United Kingdom (UK261)', value: 'United Kingdom' },
{ label: 'India (DGCA)', value: 'India' },
{ label: 'United Arab Emirates', value: 'United Arab Emirates' },
{ label: 'Asia Pacific', value: 'Asia Pacific' },
];
export default function SimulationTerminal() {
const navigate = useNavigate();
// Form State
// Initial Form State (no default selections)
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: '',
...CATEGORY_SIMULATION_CONFIGS.Flight.defaultFormState,
});
const [jurisdictionOptions, setJurisdictionOptions] = useState<Option[]>([]);
const [jurisdictionOptions, setJurisdictionOptions] = useState<Option[]>(DEFAULT_JURISDICTIONS);
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);
// Category Tabs with Icons
const categoryTabs: TabItem[] = useMemo(() => [
{
id: 'Flight',
label: 'FLIGHT',
content: null,
},
{
id: 'Travel',
label: 'TRAVEL',
content: null,
},
{
id: 'Ancillary',
label: 'ANCILLARY',
content: null,
},
], []);
// Active Category Config
const activeCategory = (formData.category as 'Flight' | 'Travel' | 'Ancillary') || 'Flight';
const categoryConfig: CategorySimulationConfig =
CATEGORY_SIMULATION_CONFIGS[activeCategory] || CATEGORY_SIMULATION_CONFIGS.Flight;
// Dynamically derived sub-type options based on selected scenario
const subTypeOptions: Option[] = useMemo(() => {
return categoryConfig.scenarioSubTypes[formData.scenario] || [];
}, [categoryConfig, formData.scenario]);
// Load Master Data Jurisdictions
useEffect(() => {
let isMounted = true;
@@ -130,16 +92,25 @@ export default function SimulationTerminal() {
if (Array.isArray(items) && items.length > 0) {
const activeOptions: Option[] = items
.filter((m) => m.isActive !== false)
.map((m) => ({
label: String(m.label || m.name || m.value || ''),
value: String(m.label || m.value || m.name || ''),
}));
.map((m) => {
let label = String(m.label || m.name || m.value || '');
if (label.toLowerCase() === 'global' || label.toLowerCase() === 'global policy') {
label = 'Global Policy';
}
return {
label,
value: label,
};
});
if (activeOptions.length > 0) {
setJurisdictionOptions(activeOptions);
setFormData((prev) => ({
...prev,
jurisdiction: prev.jurisdiction || String(activeOptions[0].value),
}));
const merged = [...activeOptions];
if (!merged.some((o) => o.value === 'Global Policy')) {
merged.unshift({ label: 'Global Policy', value: 'Global Policy' });
}
const unique = merged.filter(
(opt, idx, arr) => arr.findIndex((x) => x.value === opt.value) === idx
);
setJurisdictionOptions(unique);
}
}
})
@@ -156,18 +127,45 @@ export default function SimulationTerminal() {
const [currentPage, setCurrentPage] = useState(1);
const pageSize = 4;
const handleInputChange = (field: keyof SimulationFormState, value: string) => {
const handleInputChange = (field: string, value: any) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
// Run Simulation Handler with real Cohort & Policy Engine evaluation
// Handler for changing Scenario (do not set any default sub-type)
const handleScenarioChange = (newScenario: string) => {
setFormData((prev) => ({
...prev,
scenario: newScenario,
scenarioSubType: '',
}));
};
// Switch Strategic Category Tab (do not set any default scenario or fields)
const handleCategoryChange = (newCategory: 'Flight' | 'Travel' | 'Ancillary') => {
const newConfig = CATEGORY_SIMULATION_CONFIGS[newCategory] || CATEGORY_SIMULATION_CONFIGS.Flight;
setFormData((prev) => ({
...prev,
category: newCategory,
jurisdiction: prev.jurisdiction || '',
...newConfig.defaultFormState,
scenario: '',
scenarioSubType: '',
}));
setPassengers([]);
setSearchQuery('');
setCurrentPage(1);
};
// Run Simulation Handler with Pure Policy-Engine Evaluation
const handleRunSimulation = async () => {
setIsSimulating(true);
try {
const delay = parseInt(formData.arrDelay, 10) || 240;
const passengerPool = categoryConfig.passengerPool;
const delay = parseInt(formData.arrDelay || formData.deprivationHours || '240', 10) || 240;
// Build payload for each simulated passenger in the pool
const evaluationPayloads = SIMULATION_PASSENGER_POOL.map((p) => ({
// Build payload for each simulated passenger in the category pool
const evaluationPayloads = passengerPool.map((p) => ({
recoveryCode: `SIM-${p.id}`,
passengerName: p.name,
pnr: p.pnr,
@@ -177,37 +175,46 @@ export default function SimulationTerminal() {
cabinClass: p.cabinClass,
originalCabin: p.originalCabin,
actualCabin: p.actualCabin,
flightNumber: formData.flightNumber || 'LH450',
flightRoute: `${formData.origin || 'FRA'}${formData.destination || 'JFK'}`,
origin: formData.origin || 'FRA',
flightNumber: formData.flightNumber || formData.bookingRef || formData.receiptId || 'SIM-101',
flightRoute:
formData.origin && formData.destination
? `${formData.origin}${formData.destination}`
: formData.location || 'Terminal Hub',
origin: formData.origin || formData.location || 'FRA',
destination: formData.destination || 'JFK',
date: new Date().toISOString(),
category: formData.category,
scenario: formData.scenarioSubType || formData.scenario,
jurisdiction: formData.jurisdiction,
scenarioType: formData.scenario,
scenarioSubType: formData.scenarioSubType,
jurisdiction: formData.jurisdiction || 'Global Policy',
delayDuration: delay,
ancillaryType: formData.scenario || p.ancillaryItem,
ancillaryPurchased: formData.scenario || p.ancillaryItem,
ancillaryDelivered: formData.delivered === true || formData.delivered === 'true',
serviceValue: Number(formData.serviceCost || 25),
serviceCost: Number(formData.serviceCost || 25),
}));
// Call the live Policy & Cohort Engine backend
// Call Policy & Cohort Engine backend
const results = await evaluateBatchSimulation(evaluationPayloads);
const simulatedPassengers: SimulatedPassenger[] = SIMULATION_PASSENGER_POOL.map((p, index) => {
const simulatedPassengers: SimulatedPassenger[] = passengerPool.map((p, index): SimulatedPassenger => {
const res = Array.isArray(results) ? results[index] : null;
let computedComp = '--';
let computedRefund = '--';
const badges: string[] = [];
const perksList: 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
// Refund Action resolved by Policy Engine
const refundAction =
rawActions.find(
(a: any) =>
@@ -231,7 +238,7 @@ export default function SimulationTerminal() {
computedRefund = refundAmt ? `${refundAmt} ${refundCurr}`.trim() : 'Applied';
}
// Find compensation action if any
// Compensation Action resolved by Policy Engine
const compAction =
rawActions.find(
(a: any) =>
@@ -255,29 +262,41 @@ export default function SimulationTerminal() {
computedComp = amt ? `${amt} ${curr}`.trim() : 'Applied';
}
// Extract ONLY applied action types for STATUS badges (strictly from policy engine, no hardcoded fallbacks)
// Perks and actions resolved by Policy Engine
rawActions.forEach((act: any) => {
const actionName = act.actionName || act.actionTypeCode || act.category;
if (actionName && !badges.includes(actionName)) {
badges.push(actionName);
let actionName = act.actionName || act.normalizedAction?.title;
if (!actionName && act.actionTypeCode) {
actionName = act.actionTypeCode
.replace(/_/g, ' ')
.toLowerCase()
.replace(/\b\w/g, (c: string) => c.toUpperCase());
}
if (actionName && !perksList.includes(actionName)) {
perksList.push(actionName);
}
});
if (badges.length === 0) {
if (perksList.length === 0) {
normalizedActions.forEach((act: any) => {
const actionTitle = act.title || act.actionType || act.category;
if (actionTitle && !badges.includes(actionTitle)) {
badges.push(actionTitle);
if (actionTitle && !perksList.includes(actionTitle)) {
perksList.push(actionTitle);
}
});
}
if (isMatched && perksList.length === 0) {
if (computedComp !== '--') perksList.push(`Compensation (${computedComp})`);
if (computedRefund !== '--') perksList.push(`Refund (${computedRefund})`);
}
}
return {
...p,
refund: computedRefund,
comp: computedComp,
statusBadges: badges,
perks: eligibility === 'ELIGIBLE' ? perksList : [],
statusBadges: perksList,
eligibility,
};
});
@@ -286,10 +305,11 @@ export default function SimulationTerminal() {
setCurrentPage(1);
} catch (err) {
console.error('Failed to run live policy evaluation:', err);
const simulatedPassengers: SimulatedPassenger[] = SIMULATION_PASSENGER_POOL.map((p) => ({
const simulatedPassengers: SimulatedPassenger[] = categoryConfig.passengerPool.map((p): SimulatedPassenger => ({
...p,
refund: '--',
comp: '--',
perks: [],
statusBadges: [],
eligibility: 'INELIGIBLE',
}));
@@ -315,17 +335,20 @@ export default function SimulationTerminal() {
cabinClass: p.cabinClass,
originalCabin: p.originalCabin,
actualCabin: p.actualCabin,
flightNumber: formData.flightNumber || 'LH450',
flightRoute: `${formData.origin || 'FRA'}${formData.destination || 'JFK'}`,
origin: formData.origin || 'FRA',
flightNumber: formData.flightNumber || formData.bookingRef || formData.receiptId || 'REC-101',
flightRoute:
formData.origin && formData.destination
? `${formData.origin}${formData.destination}`
: formData.location || 'Terminal Hub',
origin: formData.origin || formData.location || '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,
category: formData.category === 'Flight' ? 'Flight Ops' : formData.category,
scenario: formData.scenarioSubType || formData.scenario,
jurisdiction: formData.jurisdiction || 'Global Policy',
delayDuration: parseInt(formData.arrDelay || formData.deprivationHours || '240', 10) || 240,
status: 'Pending',
value: p.comp,
value: p.comp !== '--' ? p.comp : p.refund !== '--' ? p.refund : `${formData.serviceCost || 25} USD`,
isPerksClaimed: false,
recoverySource: 'Simulation Engine',
};
@@ -350,7 +373,9 @@ export default function SimulationTerminal() {
(p) =>
p.name.toLowerCase().includes(q) ||
p.pnr.toLowerCase().includes(q) ||
p.tier.toLowerCase().includes(q)
p.tier.toLowerCase().includes(q) ||
(p.bookingRef && p.bookingRef.toLowerCase().includes(q)) ||
(p.ancillaryItem && p.ancillaryItem.toLowerCase().includes(q))
);
}, [passengers, searchQuery]);
@@ -366,118 +391,118 @@ export default function SimulationTerminal() {
<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>
<div className="flex flex-col gap-1">
<h2 className="text-[17px] font-bold text-[#111827] tracking-tight">
Assessment Setup
</h2>
<p className="text-xs text-gray-400 font-medium">
Configure parameters for <span className="text-[#143D30] font-semibold">{categoryConfig.label}</span>.
</p>
</div>
{/* 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"
/>
{/* Strategic Category Tabs */}
<div>
<label className="block text-xs font-bold text-gray-700 uppercase tracking-wider mb-1.5">
STRATEGIC CATEGORY
</label>
<CustomTabs
tabs={categoryTabs}
value={formData.category}
onChange={(tabId) => handleCategoryChange(tabId as 'Flight' | 'Travel' | 'Ancillary')}
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"
/>
</div>
{/* 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"
/>
{/* Jurisdiction Dropdown */}
<div>
{/* 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"
<CustomDropdown
label="JURISDICTION"
options={jurisdictionOptions}
value={formData.jurisdiction}
onChange={(val) => handleInputChange('jurisdiction', val)}
placeholder="Select Jurisdiction"
searchable={false}
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"
{/* Scenario Dropdown */}
<div>
<CustomDropdown
label="SCENARIO"
options={categoryConfig.scenarios}
value={formData.scenario}
onChange={(val) => handleScenarioChange(val)}
placeholder="Select Scenario"
searchable={false}
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"
/>
{/* Dynamic Scenario Sub-Type Dropdown */}
<div>
{/* 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"
<CustomDropdown
label="Scenario Sub-Type"
options={subTypeOptions}
value={formData.scenarioSubType}
onChange={(val) => handleInputChange('scenarioSubType', val)}
placeholder="Select Scenario Sub-Type"
searchable={false}
size="sm"
/>
</div>
{/* Jurisdiction */}
<CustomDropdown
label="Jurisdiction"
options={jurisdictionOptions}
value={formData.jurisdiction}
onChange={(val) => handleInputChange('jurisdiction', val)}
searchable={false}
size="sm"
/>
{/* Dynamic Category Fields */}
<div className="grid grid-cols-2 gap-3">
{categoryConfig.fields.map((field: FormFieldConfig) => {
const isFullWidth = field.gridSpan === 2;
return (
<div
key={field.id}
className={isFullWidth ? 'col-span-2' : 'col-span-1'}
>
{field.type === 'switch' ? (
<div className="flex items-center justify-between px-3.5 py-2.5 bg-gray-50/80 border border-gray-200/90 rounded-xl">
<span className="text-[12px] font-bold text-gray-800 tracking-wide uppercase">
{field.label}
</span>
<CustomSwitch
checked={Boolean(formData[field.id])}
onChange={(e) => handleInputChange(field.id, e.target.checked)}
/>
</div>
) : field.type === 'select' ? (
<CustomDropdown
label={field.label}
options={field.options || []}
value={formData[field.id] !== undefined ? formData[field.id] : field.defaultValue}
onChange={(val) => handleInputChange(field.id, val)}
placeholder={field.placeholder || `Select ${field.label}`}
searchable={false}
size="sm"
/>
) : (
<CustomInput
label={field.label}
type={field.type}
value={formData[field.id] !== undefined ? formData[field.id] : field.defaultValue}
onChange={(e) => handleInputChange(field.id, e.target.value)}
placeholder={field.placeholder}
size="sm"
/>
)}
</div>
);
})}
</div>
</div>
{/* Action Button using CustomButton */}
@@ -502,32 +527,30 @@ export default function SimulationTerminal() {
Assessment Manifest
</h2>
<p className="text-[12px] font-medium text-gray-400 mt-0.5">
Real-time recovery outcomes.
Real-time recovery outcomes for <span className="font-semibold text-gray-600">{formData.category}</span> simulation.
</p>
</div>
{passengers.length === 0 ? (
/* Clean Empty Placeholder (No Search bar, No Save as Recovery button, No Table headers) */
/* Clean Empty Placeholder */
<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
No {formData.category} 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.
Configure the 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 recovery entitlements and policy rules.
</p>
</div>
) : (
<>
{/* Action Bar (Search & Save as Recovery) */}
{/* Action Bar */}
<div className="flex items-center gap-3">
<div className="flex-1">
<CustomInput
placeholder="Search by Passenger Name..."
placeholder="Search Manifest..."
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
@@ -552,25 +575,24 @@ export default function SimulationTerminal() {
</CustomButton>
</div>
{/* Manifest Table */}
{/* Dynamic 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%]">
{categoryConfig.manifestColumns.map((col) => (
<th
key={col.key}
className="pb-3 text-[11px] font-bold text-gray-500 uppercase tracking-wider pr-3"
>
{col.label}
</th>
))}
<th className="pb-3 text-[11px] font-bold text-gray-500 uppercase tracking-wider">
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
PERKS
</th>
</tr>
</thead>
@@ -578,20 +600,88 @@ export default function SimulationTerminal() {
{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>
{/* Dynamic Columns based on Category */}
{categoryConfig.manifestColumns.map((col) => {
if (col.key === 'name') {
return (
<td key={col.key} className="py-4 pr-3">
<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.nationality || 'Verified Customer'}
</span>
</div>
</td>
);
}
{/* Eligibility */}
<td className="py-4 pr-2 align-middle">
if (col.key === 'tier') {
return (
<td key={col.key} className="py-4 pr-3 align-middle text-[12px] font-semibold text-gray-700">
{passenger.tier} <span className="text-gray-400 text-[11px] font-normal">({passenger.passengerType || 'Adult'})</span>
</td>
);
}
if (col.key === 'cabin') {
return (
<td key={col.key} className="py-4 pr-3 align-middle text-[12px] font-medium text-gray-600">
{passenger.cabinClass || 'Economy'}
</td>
);
}
if (col.key === 'pnr') {
return (
<td key={col.key} className="py-4 pr-3 align-middle text-[12px] font-mono text-gray-500">
{passenger.pnr}
</td>
);
}
if (col.key === 'bookingRef') {
return (
<td key={col.key} className="py-4 pr-3 align-middle text-[12px] font-mono text-gray-600">
{passenger.bookingRef || '--'}
</td>
);
}
if (col.key === 'ancillaryItem') {
return (
<td key={col.key} className="py-4 pr-3 align-middle text-[12px] font-medium text-gray-700 max-w-[160px] truncate">
{passenger.ancillaryItem || '--'}
</td>
);
}
if (col.key === 'refund') {
return (
<td key={col.key} className="py-4 pr-3 align-middle text-[12px] font-semibold text-gray-500">
{passenger.refund}
</td>
);
}
if (col.key === 'comp') {
return (
<td key={col.key} className="py-4 pr-3 align-middle font-bold text-[12px] text-[#111827]">
{passenger.comp}
</td>
);
}
return (
<td key={col.key} className="py-4 pr-3 align-middle text-[12px] text-gray-600">
{passenger[col.key] || '--'}
</td>
);
})}
{/* Eligibility Status */}
<td className="py-4 pr-3 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]'
@@ -602,26 +692,16 @@ export default function SimulationTerminal() {
</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 */}
{/* Applied Perks */}
<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) => (
{passenger.perks && passenger.perks.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5 max-w-[280px]">
{passenger.perks.map((perk, 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"
className="bg-[#F3F4F6] text-gray-700 border border-gray-200/80 text-[11px] font-medium px-2 py-0.5 rounded-md whitespace-nowrap"
>
{badge}
{perk}
</span>
))}
</div>
@@ -633,7 +713,10 @@ export default function SimulationTerminal() {
))
) : (
<tr>
<td colSpan={5} className="py-10 text-center text-xs text-gray-400">
<td
colSpan={categoryConfig.manifestColumns.length + 2}
className="py-10 text-center text-xs text-gray-400"
>
No matching passengers found in this manifest simulation.
</td>
</tr>
@@ -695,8 +778,8 @@ export default function SimulationTerminal() {
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
Successfully created {savedSuccessCount} recovery incident case(s) for{' '}
<span className="font-semibold text-gray-800">{formData.category}</span> simulation with
active entitlement rules.
</p>
</div>
+557
View File
@@ -0,0 +1,557 @@
import type { Option } from '../../components/custom/CustomDropdown';
import type { TabItem } from '../../components/custom/CustomTabs';
import type { SimulatedPassengerInput } from './SimulationTypes';
export interface FormFieldConfig {
id: string;
label: string;
type: 'text' | 'number' | 'select' | 'switch';
options?: Option[];
placeholder?: string;
defaultValue: any;
gridSpan?: 1 | 2; // 1 = half width (grid-cols-2), 2 = full width (grid-cols-1)
}
export interface ManifestColumnConfig {
key: string;
label: string;
}
export interface CategorySimulationConfig {
id: 'Flight' | 'Travel' | 'Ancillary';
label: string;
description: string;
scenarios: Option[];
scenarioSubTypes: Record<string, Option[]>;
fields: FormFieldConfig[];
passengerPool: SimulatedPassengerInput[];
manifestColumns: ManifestColumnConfig[];
defaultFormState: {
scenario: string;
scenarioSubType: string;
[key: string]: any;
};
}
export const CATEGORY_TABS: TabItem[] = [
{ id: 'Flight', label: 'FLIGHT', content: null },
{ id: 'Travel', label: 'TRAVEL', content: null },
{ id: 'Ancillary', label: 'ANCILLARY', content: null },
];
export const CATEGORY_SIMULATION_CONFIGS: Record<
'Flight' | 'Travel' | 'Ancillary',
CategorySimulationConfig
> = {
Flight: {
id: 'Flight',
label: 'Flight Operations Disruption',
description: 'Simulate flight delays, cancellations, denied boarding, and missed connections.',
scenarios: [
{ 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' },
],
scenarioSubTypes: {
'Flight Delay Disruption': [
{ label: 'Short Delay (< 2 Hours)', value: 'Short Delay' },
{ label: 'Long Delay (3-4 Hours)', value: 'Long Delay' },
{ label: 'Extreme Delay (> 4 Hours)', value: 'Extreme Delay' },
{ label: 'Overnight Delay', value: 'Overnight Delay' },
],
'Flight Cancellation': [
{ label: 'Same-Day Cancellation', value: 'Same-Day Cancellation' },
{ label: 'Short Notice Cancellation (< 14 Days)', value: 'Short Notice Cancellation' },
{ label: 'Advance Cancellation (> 14 Days)', value: 'Advance Cancellation' },
{ label: 'Weather Extraordinary Cancellation', value: 'Weather Cancellation' },
],
'Denied Boarding': [
{ label: 'Involuntary Denied Boarding', value: 'Involuntary Denied Boarding' },
{ label: 'Voluntary Denied Boarding', value: 'Voluntary Denied Boarding' },
{ label: 'Involuntary Cabin Downgrade', value: 'Cabin Downgrade' },
{ label: 'Oversale Flight Capacity', value: 'Oversale' },
],
'Missed Connection': [
{ label: 'Short Connection Miss (< 2 Hours)', value: 'Short Connection Miss' },
{ label: 'Long Connection Miss (> 3 Hours)', value: 'Long Connection Miss' },
{ label: 'Overnight Misconnection', value: 'Overnight Misconnection' },
{ label: 'Final Leg Connection Miss', value: 'Final Connection Miss' },
],
},
fields: [
{
id: 'flightNumber',
label: 'Flight No',
type: 'text',
placeholder: 'LH450',
defaultValue: '',
gridSpan: 1,
},
{
id: 'airline',
label: 'Airline',
type: 'text',
placeholder: 'Lufthansa',
defaultValue: '',
gridSpan: 1,
},
{
id: 'origin',
label: 'Origin',
type: 'text',
placeholder: 'FRA',
defaultValue: '',
gridSpan: 1,
},
{
id: 'destination',
label: 'Dest',
type: 'text',
placeholder: 'JFK',
defaultValue: '',
gridSpan: 1,
},
{
id: 'flightDistance',
label: 'Flight Distance (KM)',
type: 'select',
options: [
{ label: '1500', value: '1500' },
{ label: '3500', value: '3500' },
{ label: '6200', value: '6200' },
{ label: '8500', value: '8500' },
{ label: '11200', value: '11200' },
],
defaultValue: '',
gridSpan: 2,
},
{
id: 'arrDelay',
label: 'Arr Delay (Mins)',
type: 'number',
placeholder: '240',
defaultValue: '',
gridSpan: 1,
},
{
id: 'depDelay',
label: 'Dep Delay (Mins)',
type: 'number',
placeholder: '180',
defaultValue: '',
gridSpan: 1,
},
],
defaultFormState: {
scenario: '',
scenarioSubType: '',
flightNumber: '',
airline: '',
origin: '',
destination: '',
flightDistance: '',
arrDelay: '',
depDelay: '',
},
passengerPool: [
{
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',
},
],
manifestColumns: [
{ key: 'name', label: 'Passenger' },
{ key: 'tier', label: 'Tier & Type' },
{ key: 'cabin', label: 'Cabin Class' },
{ key: 'pnr', label: 'PNR' },
{ key: 'comp', label: 'Compensation' },
{ key: 'refund', label: 'Refund' },
],
},
Travel: {
id: 'Travel',
label: 'Travel & Accommodation Disruption',
description: 'Simulate hotel overbookings, missed ground transfers, and package itinerary disruptions.',
scenarios: [
{ label: 'Hotel Overbooking / Relocation', value: 'Hotel Overbooking' },
{ label: 'Missed Ground Transit / Transfer', value: 'Missed Ground Transit' },
{ label: 'Package Itinerary Interruption', value: 'Itinerary Interruption' },
{ label: 'VIP Accommodation Downgrade', value: 'Accommodation Downgrade' },
],
scenarioSubTypes: {
'Hotel Overbooking': [
{ label: 'Relocation to Lower Star Hotel', value: 'Relocation to Lower Star' },
{ label: 'Relocation Outside Airport Zone', value: 'Relocation Outside Airport Zone' },
{ label: 'Same Star Rating Relocation', value: 'Same Star Relocation' },
{ label: 'Unscheduled Hotel Night Incurred', value: 'Unscheduled Hotel Night' },
],
'Missed Ground Transit': [
{ label: 'Ground Transfer Delay (> 4 Hours)', value: 'Ground Transfer Delay' },
{ label: 'Chauffeur Transit Breakdown', value: 'Chauffeur Transit Breakdown' },
{ label: 'Shuttle Service Cancellation', value: 'Shuttle Service Cancellation' },
{ label: 'Missed Connecting Rail/Bus Transit', value: 'Missed Rail/Bus Transit' },
],
'Itinerary Interruption': [
{ label: 'Package Tour Cancellation', value: 'Package Tour Cancellation' },
{ label: 'Excursion Schedule Disruption', value: 'Excursion Disruption' },
{ label: 'Prepaid Activity Ticket Loss', value: 'Activity Ticket Loss' },
{ label: 'Cruiseline Departure Missed', value: 'Cruiseline Missed Departure' },
],
'Accommodation Downgrade': [
{ label: 'Room Category Downgrade', value: 'Room Category Downgrade' },
{ label: 'Promised Amenities Non-Availability', value: 'Amenities Non-Availability' },
{ label: 'Shared Facility Relocation', value: 'Shared Facility Downgrade' },
{ label: 'Executive Lounge Access Loss', value: 'Executive Lounge Loss' },
],
},
fields: [
{
id: 'bookingRef',
label: 'Booking Ref',
type: 'text',
placeholder: 'TRV-8829',
defaultValue: '',
gridSpan: 1,
},
{
id: 'travelProvider',
label: 'Provider / Partner',
type: 'text',
placeholder: 'Marriott Bonvoy',
defaultValue: '',
gridSpan: 1,
},
{
id: 'location',
label: 'Location / City',
type: 'text',
placeholder: 'Frankfurt Airport City',
defaultValue: '',
gridSpan: 1,
},
{
id: 'accommodationClass',
label: 'Accommodation Rating',
type: 'select',
options: [
{ label: '5-Star Luxury', value: '5-Star Luxury' },
{ label: '4-Star Executive', value: '4-Star Executive' },
{ label: '3-Star Standard', value: '3-Star Standard' },
{ label: 'Transit Lodge', value: 'Transit Lodge' },
],
defaultValue: '',
gridSpan: 1,
},
{
id: 'expenseIncurred',
label: 'Expense Incurred ($)',
type: 'number',
placeholder: '350',
defaultValue: '',
gridSpan: 1,
},
{
id: 'disruptionDuration',
label: 'Duration (Nights/Hrs)',
type: 'text',
placeholder: '1 Night',
defaultValue: '',
gridSpan: 1,
},
],
defaultFormState: {
scenario: '',
scenarioSubType: '',
bookingRef: '',
travelProvider: '',
location: '',
accommodationClass: '',
expenseIncurred: '',
disruptionDuration: '',
},
passengerPool: [
{
id: 'p-1',
name: 'Alexander Wright',
pnr: 'TRV-PNR1',
tier: 'Platinum',
passengerType: 'VIP Traveler',
nationality: 'British',
bookingRef: 'TRV-8829',
},
{
id: 'p-2',
name: 'Sarah Jenkins',
pnr: 'TRV-PNR2',
tier: 'Platinum',
passengerType: 'Corporate Traveler',
nationality: 'British',
bookingRef: 'TRV-8830',
},
{
id: 'p-3',
name: 'The Miller Family',
pnr: 'TRV-PNR3',
tier: 'Gold',
passengerType: 'Family Group',
nationality: 'German',
bookingRef: 'TRV-8831',
},
{
id: 'p-4',
name: 'Marcus Chen',
pnr: 'TRV-PNR4',
tier: 'Silver',
passengerType: 'Solo Traveler',
nationality: 'American',
bookingRef: 'TRV-8832',
},
],
manifestColumns: [
{ key: 'name', label: 'Traveler' },
{ key: 'tier', label: 'Tier & Type' },
{ key: 'bookingRef', label: 'Booking Ref' },
{ key: 'comp', label: 'Compensation' },
{ key: 'refund', label: 'Refund' },
],
},
Ancillary: {
id: 'Ancillary',
label: 'Ancillary Services & Perks Disruption',
description: 'Simulate ancillary failures, non-delivery, seat defects, lounge denials, and baggage issues.',
scenarios: [
{ label: 'Preferred Seat', value: 'Preferred Seat' },
{ label: 'Wi-Fi', value: 'Wi-Fi' },
{ label: 'Lounge Access', value: 'Lounge Access' },
{ label: 'Priority Boarding', value: 'Priority Boarding' },
{ label: 'Fast Track Security', value: 'Fast Track Security' },
{ label: 'Special Meal', value: 'Special Meal' },
{ label: 'Paid Meal', value: 'Paid Meal' },
{ label: 'Extra Baggage', value: 'Extra Baggage' },
{ label: 'Sports Equipment', value: 'Sports Equipment' },
{ label: 'Musical Instrument', value: 'Musical Instrument' },
{ label: 'Upgrade Purchase', value: 'Upgrade Purchase' },
{ label: 'In-flight Entertainment', value: 'In-flight Entertainment' },
{ label: 'Airport Transfer', value: 'Airport Transfer' },
{ label: 'Chauffeur Service', value: 'Chauffeur Service' },
{ label: 'Power Outlet', value: 'Power Outlet' },
{ label: 'Extra Legroom', value: 'Extra Legroom' },
{ label: 'Carbon Offset', value: 'Carbon Offset' },
],
scenarioSubTypes: {
'Wi-Fi': [
{ label: 'Purchased but Unavailable', value: 'Purchased but Unavailable' },
{ label: 'Fee Paid Service Non-delivery', value: 'Fee Paid Service Non-delivery' },
{ label: 'System Outage / Inoperative', value: 'System Outage' },
{ label: 'Slow Speed / Unusable', value: 'Slow Speed' },
{ label: 'Partial Flight Unavailable', value: 'Partial Flight Unavailable' },
],
'Preferred Seat': [
{ label: 'Purchased but Unavailable', value: 'Purchased but Unavailable' },
{ label: 'Seat Hardware Defect', value: 'Seat Hardware Defect' },
{ label: 'Involuntary Seat Reassignment', value: 'Involuntary Seat Reassignment' },
{ label: 'Non-Reclining Exit Row Seat', value: 'Non-Reclining Exit Row Seat' },
{ label: 'Extra Legroom Feature Defect', value: 'Extra Legroom Feature Defect' },
],
'Lounge Access': [
{ label: 'Purchased but Unavailable', value: 'Purchased but Unavailable' },
{ label: 'Overcapacity Entry Refusal', value: 'Overcapacity Entry Refusal' },
{ label: 'Operating Hours Premature Closure', value: 'Operating Hours Premature Closure' },
{ label: 'Partner Airline Lounge Access Denial', value: 'Partner Lounge Denial' },
{ label: 'Pass Registration System Error', value: 'Pass Registration System Error' },
],
'Priority Boarding': [
{ label: 'Purchased but Unavailable', value: 'Purchased but Unavailable' },
{ label: 'Gate Priority Not Honored', value: 'Gate Priority Not Honored' },
{ label: 'Boarding Zone Error', value: 'Boarding Zone Error' },
{ label: 'Late Jet Bridge Call', value: 'Late Jet Bridge Call' },
],
'Fast Track Security': [
{ label: 'Purchased but Unavailable', value: 'Purchased but Unavailable' },
{ label: 'Fast Track Lane Closed', value: 'Fast Track Lane Closed' },
{ label: 'Security Voucher Rejected', value: 'Security Voucher Rejected' },
{ label: 'Terminal Access Error', value: 'Terminal Access Error' },
],
'Special Meal': [
{ label: 'Purchased but Unavailable', value: 'Purchased but Unavailable' },
{ label: 'Meal Not Loaded', value: 'Meal Not Loaded' },
{ label: 'Incorrect Dietary Meal', value: 'Incorrect Dietary Meal' },
{ label: 'Contaminated / Spoiled', value: 'Contaminated Meal' },
],
'Paid Meal': [
{ label: 'Purchased but Unavailable', value: 'Purchased but Unavailable' },
{ label: 'Paid Meal Out of Stock', value: 'Paid Meal Out of Stock' },
{ label: 'Incorrect Meal Delivered', value: 'Incorrect Meal Delivered' },
{ label: 'Quality Substandard', value: 'Quality Substandard' },
],
'Extra Baggage': [
{ label: 'Purchased but Unavailable', value: 'Purchased but Unavailable' },
{ label: 'Baggage Left Behind', value: 'Baggage Left Behind' },
{ label: 'Overcharge Dispute', value: 'Overcharge Dispute' },
{ label: 'Priority Baggage Delayed', value: 'Priority Baggage Delayed' },
],
'Sports Equipment': [
{ label: 'Purchased but Unavailable', value: 'Purchased but Unavailable' },
{ label: 'Equipment Refused at Check-in', value: 'Equipment Refused at Check-in' },
{ label: 'Equipment Damaged', value: 'Equipment Damaged' },
{ label: 'Delayed Delivery', value: 'Delayed Delivery' },
],
'Musical Instrument': [
{ label: 'Purchased but Unavailable', value: 'Purchased but Unavailable' },
{ label: 'Cabin Stowage Refused', value: 'Cabin Stowage Refused' },
{ label: 'Involuntary Gate Check', value: 'Involuntary Gate Check' },
{ label: 'Transit Damage', value: 'Transit Damage' },
],
'Upgrade Purchase': [
{ label: 'Purchased but Unavailable', value: 'Purchased but Unavailable' },
{ label: 'Involuntary Cabin Downgrade', value: 'Involuntary Cabin Downgrade' },
{ label: 'Seat Feature Inoperative', value: 'Seat Feature Inoperative' },
{ label: 'Overbooked Premium Cabin', value: 'Overbooked Premium Cabin' },
],
'In-flight Entertainment': [
{ label: 'Purchased but Unavailable', value: 'Purchased but Unavailable' },
{ label: 'In-Flight Screen Hardware Fault', value: 'Screen Hardware Fault' },
{ label: 'Audio / Headset Jack Fault', value: 'Audio Headset Fault' },
{ label: 'Content Library Inaccessible', value: 'Content Library Inaccessible' },
],
'Airport Transfer': [
{ label: 'Purchased but Unavailable', value: 'Purchased but Unavailable' },
{ label: 'Driver No-Show', value: 'Driver No-Show' },
{ label: 'Transfer Vehicle Delay (> 2 Hours)', value: 'Transfer Vehicle Delay' },
{ label: 'Vehicle Class Downgrade', value: 'Vehicle Class Downgrade' },
],
'Chauffeur Service': [
{ label: 'Purchased but Unavailable', value: 'Purchased but Unavailable' },
{ label: 'Chauffeur Missed Pickup', value: 'Chauffeur Missed Pickup' },
{ label: 'Vehicle Breakdown', value: 'Vehicle Breakdown' },
{ label: 'Unscheduled Cancellation', value: 'Unscheduled Cancellation' },
],
'Power Outlet': [
{ label: 'Purchased but Unavailable', value: 'Purchased but Unavailable' },
{ label: 'In-Seat Power Outage', value: 'Power Outage' },
{ label: 'Low Voltage / Non-Functional', value: 'Low Voltage' },
{ label: 'Physical Port Broken', value: 'Physical Port Broken' },
],
'Extra Legroom': [
{ label: 'Purchased but Unavailable', value: 'Purchased but Unavailable' },
{ label: 'Involuntary Seat Reassignment', value: 'Involuntary Seat Reassignment' },
{ label: 'Obstructed Legroom', value: 'Obstructed Legroom' },
{ label: 'Aircraft Swap Downgrade', value: 'Aircraft Swap Downgrade' },
],
'Carbon Offset': [
{ label: 'Purchased but Unavailable', value: 'Purchased but Unavailable' },
{ label: 'Offset Certificate Failed', value: 'Offset Certificate Failed' },
{ label: 'Billing Mismatch', value: 'Billing Mismatch' },
],
},
fields: [
{
id: 'serviceCost',
label: 'SERVICE COST',
type: 'number',
placeholder: '25',
defaultValue: '',
gridSpan: 2,
},
{
id: 'delivered',
label: 'DELIVERED',
type: 'switch',
defaultValue: false,
gridSpan: 2,
},
],
defaultFormState: {
scenario: '',
scenarioSubType: '',
serviceCost: '',
delivered: false,
},
passengerPool: [
{
id: 'p-1',
name: 'Alexander Wright',
pnr: 'ANC-PNR1',
tier: 'Platinum',
passengerType: 'VIP Guest',
nationality: 'British',
ancillaryItem: 'Wi-Fi',
},
{
id: 'p-2',
name: 'Sarah Jenkins',
pnr: 'ANC-PNR2',
tier: 'Platinum',
passengerType: 'Frequent Flyer',
nationality: 'British',
ancillaryItem: 'Wi-Fi',
},
{
id: 'p-3',
name: 'The Miller Family',
pnr: 'ANC-PNR3',
tier: 'Gold',
passengerType: 'Family Group',
nationality: 'German',
ancillaryItem: 'Wi-Fi',
},
{
id: 'p-4',
name: 'Marcus Chen',
pnr: 'ANC-PNR4',
tier: 'Silver',
passengerType: 'Solo Traveler',
nationality: 'American',
ancillaryItem: 'Wi-Fi',
},
],
manifestColumns: [
{ key: 'name', label: 'Passenger' },
{ key: 'tier', label: 'Tier & Type' },
{ key: 'ancillaryItem', label: 'Service / Item' },
{ key: 'comp', label: 'Compensation' },
{ key: 'refund', label: 'Refund' },
],
},
};
+8 -22
View File
@@ -75,33 +75,19 @@ const CustomInput = forwardRef<HTMLInputElement, CustomInputProps>(
const validateInput = (val: string) => {
const fieldLabel = label ? `"${label}"` : "This field";
// 1. Max length / value check
// 1. Max length check (character count)
if (effectiveMax !== undefined && effectiveMax > 0) {
if (type === "number") {
if (val !== "" && Number(val) > effectiveMax) {
setValidationError(`${fieldLabel} cannot be greater than ${effectiveMax}`);
return;
}
} else {
if (val.length >= effectiveMax) {
setValidationError(`${fieldLabel} cannot exceed ${effectiveMax} characters`);
return;
}
if (val.length > effectiveMax) {
setValidationError(`${fieldLabel} cannot exceed ${effectiveMax} characters`);
return;
}
}
// 2. Min length / value check
// 2. Min length check (character count)
if (effectiveMin !== undefined && effectiveMin > 0) {
if (type === "number") {
if (val !== "" && Number(val) < effectiveMin) {
setValidationError(`${fieldLabel} must be at least ${effectiveMin}`);
return;
}
} else {
if (val.length > 0 && val.length < effectiveMin) {
setValidationError(`${fieldLabel} must be at least ${effectiveMin} characters`);
return;
}
if (val.length > 0 && val.length < effectiveMin) {
setValidationError(`${fieldLabel} must be at least ${effectiveMin} characters`);
return;
}
}
+1 -1
View File
@@ -64,7 +64,7 @@ const CustomTextArea = forwardRef<HTMLTextAreaElement, CustomTextAreaProps>(
const fieldLabel = label ? `"${label}"` : "This field";
if (effectiveMax !== undefined && effectiveMax > 0) {
if (val.length >= effectiveMax) {
if (val.length > effectiveMax) {
setValidationError(`${fieldLabel} cannot exceed ${effectiveMax} characters`);
return;
}