feat: add simulation and recovery incident modules, including new input components and UI scaffolding
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user