Files
aeroresolve_frontend/src/app/recoveryIncidents/components/AddRecoveryIncidents.tsx
T

607 lines
25 KiB
TypeScript

import { useEffect, useState } from 'react';
import { FileText, User, Airplane, WarningCircle, CaretRight, Lightning } from '@phosphor-icons/react';
import {
CustomModal,
CustomInput,
CustomDropdown,
CustomCheckBox,
} from "../../../components/custom";
import { createRecoveryIncident, updateRecoveryIncident, getRecoveryIncidents } from '../RecoveryIncidentsApi';
import { getMembershipTiers, getCategoryValues } from '../../configuration/masterData/MasterDataApi';
import {
getMockFlightNumbers,
searchDisruptionOrPassenger,
type MockPassenger,
} from '../disruptionMockService';
import type { RecoveryIncident } from '../RecoveryIncidentsTypes';
interface AddRecoveryIncidentsProps {
isOpen: boolean;
onClose: () => void;
incident?: RecoveryIncident | null;
}
const SECTION_TITLE_CLASS = "flex items-center gap-2 mb-4 text-[#4A5568] font-bold text-xs tracking-wider uppercase";
const SECTION_CONTAINER_CLASS = "bg-[#F9FAFB] rounded-[14px] p-5 border border-gray-100";
const DEFAULT_CATEGORY_OPTIONS = [
{ label: "Flight Ops", value: "flight_ops" },
{ label: "Travel Exp", value: "travel_exp" },
{ label: "Weather", value: "weather" },
{ label: "Technical Fault", value: "technical_fault" },
];
const DEFAULT_SCENARIO_OPTIONS = [
{ label: "Delayed Flight", value: "delayed_flight" },
{ label: "Cancelled Flight", value: "cancelled_flight" },
{ label: "Missed Connection", value: "missed_connection" },
];
const DEFAULT_JURISDICTION_OPTIONS = [
{ label: "EU261 (European Union)", value: "EU261" },
{ label: "US DOT (United States)", value: "US_DOT" },
{ label: "UK261 (United Kingdom)", value: "UK261" },
{ label: "CAA SG (Singapore)", value: "CAA_SG" },
];
export default function AddRecoveryIncidents({ isOpen, onClose, incident }: AddRecoveryIncidentsProps) {
const [, setLoyaltyTierOptions] = useState<{ label: string; value: string }[]>([]);
const [jurisdictionOptions, setJurisdictionOptions] = useState<{ label: string; value: string }[]>(DEFAULT_JURISDICTION_OPTIONS);
const [scenarioOptions, setScenarioOptions] = useState<{ label: string; value: string }[]>(DEFAULT_SCENARIO_OPTIONS);
const [query, setQuery] = useState<string>('');
const [passengersList, setPassengersList] = useState<MockPassenger[]>([]);
const [selectedPassengerIds, setSelectedPassengerIds] = useState<string[]>([]);
const [autoFilledNotice, setAutoFilledNotice] = useState<string | null>(null);
const [perksClaimedMap, setPerksClaimedMap] = useState<Record<string, boolean>>({});
const [existingIncidents, setExistingIncidents] = useState<RecoveryIncident[]>([]);
const [formData, setFormData] = useState({
passengerName: "",
pnr: "",
loyaltyTier: "",
flightNumber: "",
date: "",
origin: "",
destination: "",
category: "",
scenario: "",
jurisdiction: "",
delayDuration: "",
isPerksClaimed: false,
});
const flightNumberOptions = getMockFlightNumbers();
// Flexible option matcher to bridge master data codes/labels with mock response codes
const matchOption = (val: string | undefined, options: { label: string; value: string }[]) => {
if (!val) return '';
const lowerVal = val.toLowerCase().replace(/[^a-z0-9]/g, '');
const found = options.find((opt) => {
const lowerValOpt = opt.value.toLowerCase().replace(/[^a-z0-9]/g, '');
const lowerLabelOpt = opt.label.toLowerCase().replace(/[^a-z0-9]/g, '');
return lowerValOpt === lowerVal || lowerLabelOpt === lowerVal || lowerValOpt.includes(lowerVal) || lowerVal.includes(lowerValOpt);
});
return found ? found.value : val;
};
useEffect(() => {
if (isOpen) {
getMembershipTiers()
.then((items) => {
if (Array.isArray(items) && items.length > 0) {
const activeOptions = items
.filter((m) => m.isActive !== false)
.map((m) => ({
label: m.label || m.value,
value: m.value || m.id || m.label,
}));
if (activeOptions.length > 0) setLoyaltyTierOptions(activeOptions);
}
})
.catch(() => { });
getCategoryValues('jurisdiction')
.then((items) => {
if (Array.isArray(items) && items.length > 0) {
const activeOptions = items
.filter((m) => m.isActive !== false)
.map((m) => ({
label: m.label || m.name || m.value,
value: m.value || m.code || m.id || m.label,
}));
if (activeOptions.length > 0) setJurisdictionOptions(activeOptions);
}
})
.catch(() => { });
getCategoryValues('flight-disruption-type')
.then((items) => {
if (Array.isArray(items) && items.length > 0) {
const activeOptions = items
.filter((m) => m.isActive !== false)
.map((m) => ({
label: m.label || m.name || m.value,
value: m.value || m.code || m.id || m.label,
}));
if (activeOptions.length > 0) setScenarioOptions(activeOptions);
}
})
.catch(() => { });
}
}, [isOpen]);
useEffect(() => {
if (incident && isOpen) {
const [origin, destination] = incident.flightRoute ? incident.flightRoute.split(' → ') : ["", ""];
setFormData({
passengerName: incident.passengerName || "",
pnr: incident.pnr || "",
loyaltyTier: (incident as any).loyaltyTier || "",
flightNumber: incident.flightNumber || "",
date: incident.date ? incident.date.split('T')[0] : "",
origin: origin?.trim() || "",
destination: destination?.trim() || "",
category: incident.category || "",
scenario: (incident as any).scenario || "",
jurisdiction: (incident as any).jurisdiction || "",
delayDuration: (incident as any).delayDuration ? String((incident as any).delayDuration) : "",
isPerksClaimed: incident.isPerksClaimed || false,
});
const initialQuery = incident.flightNumber || incident.pnr || "";
setQuery(initialQuery);
const res = searchDisruptionOrPassenger(initialQuery);
if (res.disruption) {
const passengers = res.disruption.passengers || [];
setPassengersList(passengers);
getRecoveryIncidents().then(all => {
const flightIncidents = all.filter(i => i.flightNumber === res.disruption!.flightNumber);
setExistingIncidents(flightIncidents);
const newPerks: Record<string, boolean> = {};
const newSelected: string[] = [];
flightIncidents.forEach(fi => {
const p = passengers.find(pass => pass.pnr === fi.pnr && pass.passengerName === fi.passengerName);
if (p) {
newSelected.push(p.id);
if (fi.isPerksClaimed) newPerks[p.id] = true;
}
});
const p = passengers.find(pass => pass.pnr === incident.pnr && pass.passengerName === incident.passengerName);
if (p) {
if (!newSelected.includes(p.id)) newSelected.push(p.id);
if (incident.isPerksClaimed) newPerks[p.id] = true;
}
setSelectedPassengerIds(newSelected.length > 0 ? newSelected : passengers.map(p => p.id));
setPerksClaimedMap(newPerks);
}).catch(() => {
setSelectedPassengerIds(passengers.map(p => p.id));
});
}
setAutoFilledNotice(null);
} else if (isOpen) {
setFormData({
passengerName: "",
pnr: "",
loyaltyTier: "",
flightNumber: "",
date: "",
origin: "",
destination: "",
category: "",
scenario: "",
jurisdiction: "",
delayDuration: "",
isPerksClaimed: false,
});
setQuery('');
setPassengersList([]);
setSelectedPassengerIds([]);
setAutoFilledNotice(null);
setPerksClaimedMap({});
setExistingIncidents([]);
}
}, [incident, isOpen]);
const [loading, setLoading] = useState(false);
const handleInputChange = (field: string, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
// Helper when user types or selects flight number or PNR
const handleQueryChange = (val: string) => {
setQuery(val);
const res = searchDisruptionOrPassenger(val);
if (res.disruption) {
const d = res.disruption;
const passengers = d.passengers || [];
setPassengersList(passengers);
if (res.matchedPassengerId) {
setSelectedPassengerIds([res.matchedPassengerId]);
} else {
// Select all passengers by default when loading a flight manifest
setSelectedPassengerIds(passengers.map((p) => p.id));
}
const matchedCategory = matchOption(d.category, DEFAULT_CATEGORY_OPTIONS);
const matchedScenario = matchOption(d.scenario, scenarioOptions);
const matchedJurisdiction = matchOption(d.jurisdiction, jurisdictionOptions);
const firstP = passengers[0];
setFormData((prev) => ({
...prev,
flightNumber: d.flightNumber,
date: d.date,
origin: d.origin,
destination: d.destination,
category: matchedCategory,
scenario: matchedScenario,
jurisdiction: matchedJurisdiction,
delayDuration: String(d.delayDuration),
passengerName: firstP ? firstP.passengerName : prev.passengerName,
pnr: firstP ? firstP.pnr : prev.pnr,
loyaltyTier: firstP ? firstP.loyaltyTier : prev.loyaltyTier,
}));
const categoryLabel = DEFAULT_CATEGORY_OPTIONS.find((c) => c.value === matchedCategory)?.label || matchedCategory;
const scenarioLabel = scenarioOptions.find((s) => s.value === matchedScenario)?.label || matchedScenario;
const jurisdictionLabel = jurisdictionOptions.find((j) => j.value === matchedJurisdiction)?.label || matchedJurisdiction;
setAutoFilledNotice(
`Disruption Mock Synced • Flight ${d.flightNumber} (${d.origin}${d.destination}) • Jurisdiction: ${jurisdictionLabel} • Category: ${categoryLabel} • Scenario: ${scenarioLabel} • Delay: ${d.delayDuration} mins • ${passengers.length} passenger(s) on manifest.`
);
} else {
setPassengersList([]);
setSelectedPassengerIds([]);
setAutoFilledNotice(null);
setFormData((prev) => ({ ...prev, flightNumber: val }));
}
};
// Select all / Deselect all passengers
const handleToggleSelectAll = () => {
if (selectedPassengerIds.length === passengersList.length) {
setSelectedPassengerIds([]);
} else {
setSelectedPassengerIds(passengersList.map((p) => p.id));
}
};
const handleTogglePerksClaimed = (passengerId: string, checked: boolean) => {
setPerksClaimedMap(prev => ({ ...prev, [passengerId]: checked }));
};
// Toggle individual passenger row selection
const handleTogglePassengerRow = (passengerId: string) => {
setSelectedPassengerIds((prev) => {
const exists = prev.includes(passengerId);
let updated: string[];
if (exists) {
updated = prev.filter((id) => id !== passengerId);
} else {
updated = [...prev, passengerId];
}
const primaryP = passengersList.find((p) => p.id === (updated[0] || passengerId));
if (primaryP) {
setFormData((f) => ({
...f,
passengerName: updated.length > 1 ? `${primaryP.passengerName} (+${updated.length - 1} more)` : primaryP.passengerName,
pnr: primaryP.pnr,
loyaltyTier: primaryP.loyaltyTier,
}));
}
return updated;
});
};
const handleSubmit = async () => {
setLoading(true);
try {
const selectedPassengers = passengersList.filter((p) => selectedPassengerIds.includes(p.id));
if (selectedPassengers.length > 0) {
const promises = selectedPassengers.map((p) => {
const existingInc = existingIncidents.find(fi => fi.pnr === p.pnr && fi.passengerName === p.passengerName)
|| (incident && incident.pnr === p.pnr && incident.passengerName === p.passengerName ? incident : null);
const payload = {
recoveryCode: existingInc ? existingInc.recoveryCode : "REC-" + Math.floor(Math.random() * 100000),
passengerName: p.passengerName,
pnr: p.pnr,
loyaltyTier: p.loyaltyTier,
passengerType: p.passengerType,
nationality: p.nationality,
specialAssistance: p.specialAssistance,
cabinClass: p.cabinClass,
originalCabin: p.originalCabin,
actualCabin: p.actualCabin,
flightNumber: formData.flightNumber || "TBD",
flightRoute: `${formData.origin || 'UNK'}${formData.destination || 'UNK'}`,
origin: formData.origin || undefined,
destination: formData.destination || undefined,
date: formData.date ? new Date(formData.date).toISOString() : new Date().toISOString(),
category: formData.category || "General",
status: existingInc ? (existingInc.status || "Pending") : "Pending",
value: existingInc ? existingInc.value : "$0",
isPerksClaimed: perksClaimedMap[p.id] || false,
jurisdiction: formData.jurisdiction || undefined,
delayDuration: formData.delayDuration ? Number(formData.delayDuration) : undefined,
scenario: formData.scenario || undefined,
recoverySource: existingInc ? (existingInc.recoverySource || 'Manual Disruption Entry') : 'Manual Disruption Entry',
};
if (existingInc) {
return updateRecoveryIncident(existingInc.id, payload);
} else {
return createRecoveryIncident(payload);
}
});
await Promise.all(promises);
}
onClose();
} catch (error: any) {
console.error("Error saving incident(s):", error.response?.data || error.message || error);
alert(`Failed to save incident(s): ${error.response?.data?.message || 'Unknown error'}`);
} finally {
setLoading(false);
}
};
const isAllSelected = passengersList.length > 0 && selectedPassengerIds.length === passengersList.length;
return (
<CustomModal
isOpen={isOpen}
onClose={onClose}
title={incident ? "Edit Recovery Incident" : "New Recovery Incident"}
description="Log disruption cases and assess against policy frameworks"
icon={<FileText className="text-[#1B9869]" />}
size="xl"
primaryAction={{
label: loading
? "Saving..."
: incident
? "Save Changes"
: selectedPassengerIds.length > 1
? `Assess & Log (${selectedPassengerIds.length}) Incidents`
: "Assess & Log Incident",
onClick: handleSubmit,
icon: <CaretRight size={16} />,
}}
secondaryAction={{
label: "Discard",
onClick: onClose,
}}
>
<div className="flex flex-col gap-5">
{/* SEARCH FLIGHT NUMBER / PNR CARD */}
<div className={SECTION_CONTAINER_CLASS}>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-[#4A5568] mb-1.5">
Flight Number or PNR Reference
</label>
<CustomDropdown
placeholder="e.g. FT7687T9I or B7687YT"
value={query}
onChange={(val) => handleQueryChange(val as string)}
options={flightNumberOptions}
/>
</div>
<div>
<CustomInput
type="date"
label="Date"
placeholder="Selected Option"
value={formData.date}
onChange={(e) => handleInputChange("date", e.target.value)}
/>
</div>
</div>
</div>
{/* AUTO FILLED NOTICE BANNER */}
{autoFilledNotice && (
<div className="flex items-center gap-3 p-3 bg-emerald-50 border border-emerald-200 text-emerald-800 rounded-xl text-xs font-medium">
<Lightning size={18} weight="fill" className="text-emerald-600 shrink-0" />
<span className="flex-1">{autoFilledNotice}</span>
</div>
)}
{/* PASSENGERS TABLE (SUPPORTING SELECT ALL & MULTI-SELECT) */}
{passengersList.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden shadow-xs">
<div className="px-4 py-3 bg-[#F9FAFB] border-b border-gray-200 flex items-center justify-between">
<span className="text-xs font-bold text-gray-700 tracking-wider uppercase flex items-center gap-2">
<User size={16} className="text-[#1B9869]" />
Passenger Manifest Details (Selected: {selectedPassengerIds.length} of {passengersList.length})
</span>
<button
type="button"
onClick={handleToggleSelectAll}
className="text-xs text-[#1B9869] hover:underline font-semibold cursor-pointer"
>
{isAllSelected ? "Deselect All" : "Select All Passengers"}
</button>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="bg-[#F8FAFC] border-b border-gray-200 text-[#718096] font-semibold">
<th className="py-3 px-4 w-10 text-center">
<input
type="checkbox"
checked={isAllSelected}
onChange={handleToggleSelectAll}
className="w-4 h-4 text-[#1B9869] rounded border-gray-300 focus:ring-[#1B9869] cursor-pointer"
title="Select All Passengers"
/>
</th>
<th className="py-3 px-4">PNR Number</th>
<th className="py-3 px-4">Passanger name</th>
<th className="py-3 px-4">Passanger type</th>
<th className="py-3 px-4">Booked Cabin</th>
<th className="py-3 px-4">Assigned Cabin</th>
<th className="py-3 px-4">Nationality</th>
<th className="py-3 px-4">loyality type</th>
<th className="py-3 px-4">Special assisstance</th>
<th className="py-3 px-4 text-center">Perks Claimed</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{passengersList.map((p) => {
const isSelected = selectedPassengerIds.includes(p.id);
const isDowngraded = Boolean(p.originalCabin && p.actualCabin && p.originalCabin !== p.actualCabin);
return (
<tr
key={p.id}
onClick={() => handleTogglePassengerRow(p.id)}
className={`cursor-pointer transition-colors ${isSelected
? 'bg-emerald-50/70 border-l-4 border-l-[#1B9869]'
: 'hover:bg-gray-50/80'
}`}
>
<td className="py-3 px-4 text-center" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={isSelected}
onChange={() => handleTogglePassengerRow(p.id)}
className="w-4 h-4 text-[#1B9869] rounded border-gray-300 focus:ring-[#1B9869] cursor-pointer"
/>
</td>
<td className="py-3 px-4 font-mono font-medium text-gray-900">{p.pnr}</td>
<td className="py-3 px-4 font-semibold text-gray-800">{p.passengerName}</td>
<td className="py-3 px-4 text-gray-600">{p.passengerType}</td>
<td className="py-3 px-4 text-gray-700 font-medium">{p.originalCabin || p.cabinClass || 'Economy'}</td>
<td className="py-3 px-4">
<span
className={`inline-flex items-center px-2 py-0.5 rounded text-[11px] font-semibold ${isDowngraded ? 'bg-amber-100 text-amber-900 border border-amber-300' : 'text-gray-700'
}`}
>
{p.actualCabin || p.cabinClass || 'Economy'}
{isDowngraded && <span className="ml-1 text-[10px] text-amber-700 font-bold">(Downgraded)</span>}
</span>
</td>
<td className="py-3 px-4 text-gray-600">{p.nationality}</td>
<td className="py-3 px-4">
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[11px] font-semibold ${p.loyaltyTier === 'Platinum'
? 'bg-purple-100 text-purple-800'
: p.loyaltyTier === 'Gold'
? 'bg-amber-100 text-amber-800'
: p.loyaltyTier === 'Silver'
? 'bg-slate-100 text-slate-700'
: 'bg-orange-100 text-orange-800'
}`}
>
{p.loyaltyTier}
</span>
</td>
<td className="py-3 px-4 text-gray-600">{p.specialAssistance}</td>
<td className="py-3 px-4 text-center" onClick={(e) => e.stopPropagation()}>
<CustomCheckBox
checked={perksClaimedMap[p.id] || false}
onChange={(e) => handleTogglePerksClaimed(p.id, e.target.checked)}
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
{/* FLIGHT CONTEXT DETAILS */}
<div className={SECTION_CONTAINER_CLASS}>
<div className={SECTION_TITLE_CLASS}>
<Airplane size={16} />
<span>Flight Context & Route</span>
</div>
<div className="grid grid-cols-2 gap-4">
<CustomDropdown
label="Origin (IATA)"
placeholder="Selected Option"
value={formData.origin}
onChange={(val) => handleInputChange("origin", val as string)}
options={[
{ label: "FRA - Frankfurt", value: "FRA" },
{ label: "LHR - London Heathrow", value: "LHR" },
{ label: "SFO - San Francisco", value: "SFO" },
{ label: "JFK - New York JFK", value: "JFK" },
{ label: "CDG - Paris Charles de Gaulle", value: "CDG" },
{ label: "NRT - Tokyo Narita", value: "NRT" },
{ label: "DXB - Dubai International", value: "DXB" },
]}
/>
<CustomDropdown
label="Destination (IATA)"
placeholder="Selected Option"
value={formData.destination}
onChange={(val) => handleInputChange("destination", val as string)}
options={[
{ label: "JFK - New York JFK", value: "JFK" },
{ label: "CDG - Paris Charles de Gaulle", value: "CDG" },
{ label: "NRT - Tokyo Narita", value: "NRT" },
{ label: "FRA - Frankfurt", value: "FRA" },
{ label: "LHR - London Heathrow", value: "LHR" },
{ label: "SFO - San Francisco", value: "SFO" },
{ label: "DXB - Dubai International", value: "DXB" },
]}
/>
</div>
</div>
{/* DISRUPTION & JURISDICTION */}
<div className={SECTION_CONTAINER_CLASS}>
<div className={SECTION_TITLE_CLASS}>
<WarningCircle size={16} />
<span>Disruption & Jurisdiction</span>
</div>
<div className="grid grid-cols-2 gap-4">
<CustomDropdown
label="Category"
placeholder="Selected Option"
value={formData.category}
onChange={(val) => handleInputChange("category", val as string)}
options={DEFAULT_CATEGORY_OPTIONS}
/>
<CustomDropdown
label="Scenario"
placeholder="Selected Option"
value={formData.scenario}
onChange={(val) => handleInputChange("scenario", val as string)}
options={scenarioOptions}
/>
<CustomDropdown
label="Jurisdiction"
placeholder="Selected Option"
value={formData.jurisdiction}
onChange={(val) => handleInputChange("jurisdiction", val as string)}
options={jurisdictionOptions}
/>
<CustomInput
type="number"
label="Delay Duration (Mins)"
placeholder="0"
value={formData.delayDuration}
onChange={(e) => handleInputChange("delayDuration", e.target.value)}
/>
</div>
</div>
</div>
</CustomModal>
);
}