feat: implement recovery incident management module with UI components and API integration for resolution tracking.
This commit is contained in:
@@ -6,6 +6,9 @@ export interface OptionItem {
|
||||
value: string;
|
||||
id?: string;
|
||||
code?: string;
|
||||
groupName?: string;
|
||||
groupHeader?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ActionTypeField {
|
||||
@@ -249,9 +252,17 @@ export function getPolicies(page = 1, limit = 10, search = ''): Promise<Paginate
|
||||
})
|
||||
: '—';
|
||||
|
||||
const jName = typeof p.jurisdiction === 'object' && p.jurisdiction !== null
|
||||
? p.jurisdiction.label || p.jurisdiction.name || p.jurisdiction.code
|
||||
: p.jurisdiction || 'GLOBAL';
|
||||
let jName = 'GLOBAL';
|
||||
if (Array.isArray(p.jurisdictions) && p.jurisdictions.length > 0) {
|
||||
const names = p.jurisdictions
|
||||
.map((j: any) => j.jurisdiction?.label || j.jurisdiction?.name || j.jurisdiction?.code || j.jurisdictionId)
|
||||
.filter(Boolean);
|
||||
jName = names.length > 0 ? names.join(', ') : 'GLOBAL';
|
||||
} else if (typeof p.jurisdiction === 'object' && p.jurisdiction !== null) {
|
||||
jName = p.jurisdiction.label || p.jurisdiction.name || p.jurisdiction.code || 'GLOBAL';
|
||||
} else if (p.jurisdiction) {
|
||||
jName = p.jurisdiction;
|
||||
}
|
||||
|
||||
return {
|
||||
...p,
|
||||
|
||||
@@ -189,16 +189,26 @@ export default function AddPolicyEngine() {
|
||||
|
||||
for (const g of groups) {
|
||||
const fields = await getConditionFieldsForGroup(g.id || g.code);
|
||||
fields.forEach((f: any) => {
|
||||
const val = f.id || f.code;
|
||||
if (fields && fields.length > 0) {
|
||||
allFields.push({
|
||||
label: `${g.name} › ${f.name}`,
|
||||
value: val,
|
||||
id: f.id,
|
||||
code: f.code,
|
||||
label: g.name,
|
||||
value: `header_${g.id || g.code}`,
|
||||
groupHeader: true,
|
||||
disabled: true,
|
||||
});
|
||||
metaMap[val] = f;
|
||||
});
|
||||
|
||||
fields.forEach((f: any) => {
|
||||
const val = f.id || f.code;
|
||||
allFields.push({
|
||||
label: f.name,
|
||||
value: val,
|
||||
id: f.id,
|
||||
code: f.code,
|
||||
groupName: g.name,
|
||||
});
|
||||
metaMap[val] = f;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setCategoryConditionMap((prev) => ({
|
||||
@@ -326,7 +336,7 @@ export default function AddPolicyEngine() {
|
||||
|
||||
// Policy Info State
|
||||
const [policyName, setPolicyName] = useState('');
|
||||
const [jurisdiction, setJurisdiction] = useState('');
|
||||
const [jurisdiction, setJurisdiction] = useState<(string | number)[]>([]);
|
||||
const [status, setStatus] = useState<'Active' | 'Inactive'>('Active');
|
||||
const [description, setDescription] = useState('');
|
||||
|
||||
@@ -354,11 +364,21 @@ export default function AddPolicyEngine() {
|
||||
if (!data) return;
|
||||
|
||||
setPolicyName(data.policyName || data.name || '');
|
||||
setJurisdiction(
|
||||
typeof data.jurisdiction === 'object' && data.jurisdiction !== null
|
||||
? data.jurisdiction.code || data.jurisdiction.id
|
||||
: data.jurisdictionId || data.jurisdiction || ''
|
||||
);
|
||||
if (Array.isArray(data.jurisdictions)) {
|
||||
setJurisdiction(
|
||||
data.jurisdictions
|
||||
.map((j: any) => (typeof j === 'object' ? j.jurisdictionId || j.jurisdiction?.id || j.jurisdiction?.code || j.id : j))
|
||||
.filter(Boolean),
|
||||
);
|
||||
} else if (Array.isArray(data.jurisdictionIds)) {
|
||||
setJurisdiction(data.jurisdictionIds);
|
||||
} else {
|
||||
const singleJur =
|
||||
typeof data.jurisdiction === 'object' && data.jurisdiction !== null
|
||||
? data.jurisdiction.code || data.jurisdiction.id
|
||||
: data.jurisdictionId || data.jurisdiction || '';
|
||||
setJurisdiction(singleJur ? [singleJur] : []);
|
||||
}
|
||||
setStatus(
|
||||
data.status?.toLowerCase() === 'active' ? 'Active' : 'Inactive'
|
||||
);
|
||||
@@ -521,7 +541,8 @@ export default function AddPolicyEngine() {
|
||||
try {
|
||||
const payload = {
|
||||
policyName: policyName.trim(),
|
||||
jurisdictionId: jurisdiction || undefined,
|
||||
jurisdictionId: Array.isArray(jurisdiction) && jurisdiction.length > 0 ? String(jurisdiction[0]) : undefined,
|
||||
jurisdictionIds: Array.isArray(jurisdiction) ? jurisdiction.map(String) : [],
|
||||
description: description || undefined,
|
||||
status: isDeploy ? 'active' : 'draft',
|
||||
audienceType: audienceType === 'Selected Cohorts' ? 'COHORT' : 'ALL',
|
||||
@@ -785,12 +806,12 @@ export default function AddPolicyEngine() {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<CustomDropdown
|
||||
<CustomMultiSelect
|
||||
label='Jurisdiction'
|
||||
options={jurisdictionOptions}
|
||||
value={jurisdiction}
|
||||
onChange={setJurisdiction}
|
||||
placeholder="Selected Option"
|
||||
placeholder="Select Jurisdictions"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { ApiClient } from '../api/ApiClient';
|
||||
import type { RecoveryIncident, MetricCardData } from './RecoveryIncidentsTypes';
|
||||
|
||||
|
||||
export interface AuditTrailStepDto {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
timestamp: string;
|
||||
status: 'completed' | 'current' | 'pending' | 'rejected';
|
||||
}
|
||||
|
||||
export function getRecoveryIncidents(): Promise<RecoveryIncident[]> {
|
||||
return ApiClient.get<any, RecoveryIncident[]>('/recovery-incidents');
|
||||
@@ -53,6 +59,10 @@ export function getRecoveryIncident(id: string): Promise<RecoveryIncident> {
|
||||
return ApiClient.get<any, RecoveryIncident>(`/recovery-incidents/${id}`);
|
||||
}
|
||||
|
||||
export function getIncidentAuditTrail(id: string): Promise<AuditTrailStepDto[]> {
|
||||
return ApiClient.get<any, AuditTrailStepDto[]>(`/recovery-incidents/${id}/audit-trail`);
|
||||
}
|
||||
|
||||
export function createRecoveryIncident(data: Omit<RecoveryIncident, 'id'>): Promise<RecoveryIncident> {
|
||||
return ApiClient.post<any, RecoveryIncident>('/recovery-incidents', data);
|
||||
}
|
||||
@@ -61,6 +71,10 @@ export function updateRecoveryIncident(id: string, data: Partial<RecoveryInciden
|
||||
return ApiClient.patch<any, RecoveryIncident>(`/recovery-incidents/${id}`, data);
|
||||
}
|
||||
|
||||
export function reRunPolicyEngine(id: string): Promise<RecoveryIncident> {
|
||||
return ApiClient.post<any, RecoveryIncident>(`/recovery-incidents/${id}/evaluate`, {});
|
||||
}
|
||||
|
||||
export function updateIncidentStatus(id: string, status: string): Promise<RecoveryIncident> {
|
||||
return ApiClient.patch<any, RecoveryIncident>(`/recovery-incidents/${id}/status`, { status });
|
||||
}
|
||||
|
||||
@@ -3,20 +3,62 @@ export interface IncidentStatus {
|
||||
variant: "success" | "error" | "warning" | "info" | "neutral" | "brand";
|
||||
}
|
||||
|
||||
export interface IncidentEvaluationAction {
|
||||
id: string;
|
||||
evaluationId?: string;
|
||||
actionTypeCode?: string;
|
||||
title: string;
|
||||
category: string; // 'Financial Refund' | 'Compensation & Perks' | 'Passenger Care'
|
||||
amount?: number;
|
||||
currency?: string;
|
||||
status: string; // 'Pending Approval' | 'Automated' | 'Issued'
|
||||
sequence: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface IncidentEvaluation {
|
||||
updatedAt: unknown;
|
||||
createdAt: any;
|
||||
id: string;
|
||||
incidentId?: string;
|
||||
policyId?: string;
|
||||
policyName: string;
|
||||
recoveryScore: number;
|
||||
matchedCohortName?: string;
|
||||
status: string;
|
||||
aiAssessment?: string;
|
||||
actions: IncidentEvaluationAction[];
|
||||
}
|
||||
|
||||
export interface RecoveryIncident {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
id: string;
|
||||
recoveryCode: string;
|
||||
date: string;
|
||||
passengerName?: string;
|
||||
pnr?: string;
|
||||
loyaltyTier?: string;
|
||||
passengerType?: string;
|
||||
nationality?: string;
|
||||
specialAssistance?: string;
|
||||
cabinClass?: string;
|
||||
originalCabin?: string;
|
||||
actualCabin?: string;
|
||||
flightNumber: string;
|
||||
flightRoute: string;
|
||||
origin?: string;
|
||||
destination?: string;
|
||||
category?: string;
|
||||
scenario?: string;
|
||||
jurisdiction?: string;
|
||||
delayDuration?: number;
|
||||
statuses?: IncidentStatus[];
|
||||
status?: string;
|
||||
value: string;
|
||||
isPerksClaimed?: boolean;
|
||||
isGroupHeader?: boolean;
|
||||
evaluation?: IncidentEvaluation;
|
||||
}
|
||||
|
||||
export interface MetricCardData {
|
||||
@@ -28,4 +70,3 @@ export interface MetricCardData {
|
||||
trendType: "positive" | "negative" | "neutral";
|
||||
sparklineColor: "green" | "red";
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { FileText, User, Airplane, WarningCircle, CaretRight } from '@phosphor-icons/react';
|
||||
import { FileText, User, Airplane, WarningCircle, CaretRight, Lightning } from '@phosphor-icons/react';
|
||||
import {
|
||||
CustomModal,
|
||||
CustomInput,
|
||||
@@ -8,6 +8,11 @@ import {
|
||||
} from "../../../components/custom";
|
||||
import { createRecoveryIncident, updateRecoveryIncident } from '../RecoveryIncidentsApi';
|
||||
import { getMembershipTiers, getCategoryValues } from '../../configuration/masterData/MasterDataApi';
|
||||
import {
|
||||
getMockFlightNumbers,
|
||||
searchDisruptionOrPassenger,
|
||||
type MockPassenger,
|
||||
} from '../disruptionMockService';
|
||||
import type { RecoveryIncident } from '../RecoveryIncidentsTypes';
|
||||
|
||||
interface AddRecoveryIncidentsProps {
|
||||
@@ -19,10 +24,36 @@ interface AddRecoveryIncidentsProps {
|
||||
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 [loyaltyTierOptions, setLoyaltyTierOptions] = useState<{ label: string; value: string }[]>([]);
|
||||
const [jurisdictionOptions, setJurisdictionOptions] = useState<{ label: string; value: string }[]>([]);
|
||||
const [scenarioOptions, setScenarioOptions] = useState<{ label: string; value: string }[]>([]);
|
||||
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 [formData, setFormData] = useState({
|
||||
passengerName: "",
|
||||
pnr: "",
|
||||
@@ -38,9 +69,22 @@ export default function AddRecoveryIncidents({ isOpen, onClose, incident }: AddR
|
||||
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) {
|
||||
// 1. Fetch Loyalty Tier Master Data
|
||||
getMembershipTiers()
|
||||
.then((items) => {
|
||||
if (Array.isArray(items) && items.length > 0) {
|
||||
@@ -50,16 +94,11 @@ export default function AddRecoveryIncidents({ isOpen, onClose, incident }: AddR
|
||||
label: m.label || m.value,
|
||||
value: m.value || m.id || m.label,
|
||||
}));
|
||||
if (activeOptions.length > 0) {
|
||||
setLoyaltyTierOptions(activeOptions);
|
||||
}
|
||||
if (activeOptions.length > 0) setLoyaltyTierOptions(activeOptions);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to fetch loyalty tier master data:", err);
|
||||
});
|
||||
.catch(() => { });
|
||||
|
||||
// 2. Fetch Jurisdiction Master Data
|
||||
getCategoryValues('jurisdiction')
|
||||
.then((items) => {
|
||||
if (Array.isArray(items) && items.length > 0) {
|
||||
@@ -69,14 +108,11 @@ export default function AddRecoveryIncidents({ isOpen, onClose, incident }: AddR
|
||||
label: m.label || m.name || m.value,
|
||||
value: m.value || m.code || m.id || m.label,
|
||||
}));
|
||||
if (activeOptions.length > 0) {
|
||||
setJurisdictionOptions(activeOptions);
|
||||
}
|
||||
if (activeOptions.length > 0) setJurisdictionOptions(activeOptions);
|
||||
}
|
||||
})
|
||||
.catch(() => { });
|
||||
|
||||
// 3. Fetch Scenario Master Data
|
||||
getCategoryValues('flight-disruption-type')
|
||||
.then((items) => {
|
||||
if (Array.isArray(items) && items.length > 0) {
|
||||
@@ -86,9 +122,7 @@ export default function AddRecoveryIncidents({ isOpen, onClose, incident }: AddR
|
||||
label: m.label || m.name || m.value,
|
||||
value: m.value || m.code || m.id || m.label,
|
||||
}));
|
||||
if (activeOptions.length > 0) {
|
||||
setScenarioOptions(activeOptions);
|
||||
}
|
||||
if (activeOptions.length > 0) setScenarioOptions(activeOptions);
|
||||
}
|
||||
})
|
||||
.catch(() => { });
|
||||
@@ -107,11 +141,25 @@ export default function AddRecoveryIncidents({ isOpen, onClose, incident }: AddR
|
||||
origin: origin?.trim() || "",
|
||||
destination: destination?.trim() || "",
|
||||
category: incident.category || "",
|
||||
scenario: "",
|
||||
jurisdiction: "",
|
||||
delayDuration: "",
|
||||
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);
|
||||
if (res.matchedPassengerId) {
|
||||
setSelectedPassengerIds([res.matchedPassengerId]);
|
||||
} else {
|
||||
setSelectedPassengerIds(passengers.map((p) => p.id));
|
||||
}
|
||||
}
|
||||
setAutoFilledNotice(null);
|
||||
} else if (isOpen) {
|
||||
setFormData({
|
||||
passengerName: "",
|
||||
@@ -127,6 +175,10 @@ export default function AddRecoveryIncidents({ isOpen, onClose, incident }: AddR
|
||||
delayDuration: "",
|
||||
isPerksClaimed: false,
|
||||
});
|
||||
setQuery('');
|
||||
setPassengersList([]);
|
||||
setSelectedPassengerIds([]);
|
||||
setAutoFilledNotice(null);
|
||||
}
|
||||
}, [incident, isOpen]);
|
||||
|
||||
@@ -140,49 +192,189 @@ export default function AddRecoveryIncidents({ isOpen, onClose, incident }: AddR
|
||||
setFormData((prev) => ({ ...prev, [field]: checked }));
|
||||
};
|
||||
|
||||
// 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));
|
||||
}
|
||||
};
|
||||
|
||||
// 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 payload = {
|
||||
recoveryCode: incident ? incident.recoveryCode : "REC-" + Math.floor(Math.random() * 10000),
|
||||
passengerName: formData.passengerName || "Unknown",
|
||||
pnr: formData.pnr || "N/A",
|
||||
flightNumber: formData.flightNumber || "TBD",
|
||||
flightRoute: `${formData.origin || 'UNK'} → ${formData.destination || 'UNK'}`,
|
||||
date: formData.date ? new Date(formData.date).toISOString() : new Date().toISOString(),
|
||||
category: formData.category || "General",
|
||||
status: incident ? (incident.status || "Pending") : "Pending",
|
||||
value: incident ? incident.value : "$0",
|
||||
isPerksClaimed: formData.isPerksClaimed,
|
||||
};
|
||||
const selectedPassengers = passengersList.filter((p) => selectedPassengerIds.includes(p.id));
|
||||
|
||||
console.log("Submitting payload:", payload);
|
||||
if (incident) {
|
||||
await updateRecoveryIncident(incident.id, payload);
|
||||
if (selectedPassengers.length > 1 && !incident) {
|
||||
// Batch log incidents for all selected passengers
|
||||
const promises = selectedPassengers.map((p) => {
|
||||
const payload = {
|
||||
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: "Pending",
|
||||
value: "$0",
|
||||
isPerksClaimed: formData.isPerksClaimed,
|
||||
jurisdiction: formData.jurisdiction || undefined,
|
||||
delayDuration: formData.delayDuration ? Number(formData.delayDuration) : undefined,
|
||||
scenario: formData.scenario || undefined,
|
||||
};
|
||||
return createRecoveryIncident(payload);
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
} else {
|
||||
await createRecoveryIncident(payload);
|
||||
// Single passenger log or edit
|
||||
const primaryPassenger = selectedPassengers[0];
|
||||
const payload = {
|
||||
recoveryCode: incident ? incident.recoveryCode : "REC-" + Math.floor(Math.random() * 100000),
|
||||
passengerName: primaryPassenger ? primaryPassenger.passengerName : (formData.passengerName || "Unknown"),
|
||||
pnr: primaryPassenger ? primaryPassenger.pnr : (formData.pnr || "N/A"),
|
||||
loyaltyTier: primaryPassenger ? primaryPassenger.loyaltyTier : (formData.loyaltyTier || undefined),
|
||||
passengerType: primaryPassenger ? primaryPassenger.passengerType : undefined,
|
||||
nationality: primaryPassenger ? primaryPassenger.nationality : undefined,
|
||||
specialAssistance: primaryPassenger ? primaryPassenger.specialAssistance : undefined,
|
||||
cabinClass: primaryPassenger ? primaryPassenger.cabinClass : undefined,
|
||||
originalCabin: primaryPassenger ? primaryPassenger.originalCabin : undefined,
|
||||
actualCabin: primaryPassenger ? primaryPassenger.actualCabin : undefined,
|
||||
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: incident ? (incident.status || "Pending") : "Pending",
|
||||
value: incident ? incident.value : "$0",
|
||||
isPerksClaimed: formData.isPerksClaimed,
|
||||
jurisdiction: formData.jurisdiction || undefined,
|
||||
delayDuration: formData.delayDuration ? Number(formData.delayDuration) : undefined,
|
||||
scenario: formData.scenario || undefined,
|
||||
};
|
||||
|
||||
if (incident) {
|
||||
await updateRecoveryIncident(incident.id, payload);
|
||||
} else {
|
||||
await createRecoveryIncident(payload);
|
||||
}
|
||||
}
|
||||
onClose(); // Will trigger refresh in parent list
|
||||
onClose();
|
||||
} catch (error: any) {
|
||||
console.error("Error saving incident:", error.response?.data || error.message || error);
|
||||
alert(`Failed to save incident: ${error.response?.data?.message || 'Unknown error'}`);
|
||||
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 a disruption case and assess against policy frameworks"
|
||||
description="Log disruption cases and assess against policy frameworks"
|
||||
icon={<FileText className="text-[#1B9869]" />}
|
||||
size="lg"
|
||||
primaryAction={{
|
||||
label: loading ? "Saving..." : (incident ? "Save Changes" : "Assess & Log Incident"),
|
||||
label: loading
|
||||
? "Saving..."
|
||||
: incident
|
||||
? "Save Changes"
|
||||
: selectedPassengerIds.length > 1
|
||||
? `Assess & Log (${selectedPassengerIds.length}) Incidents`
|
||||
: "Assess & Log Incident",
|
||||
onClick: handleSubmit,
|
||||
icon: <CaretRight size={16} />
|
||||
icon: <CaretRight size={16} />,
|
||||
}}
|
||||
secondaryAction={{
|
||||
label: "Discard",
|
||||
@@ -190,69 +382,158 @@ export default function AddRecoveryIncidents({ isOpen, onClose, incident }: AddR
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* PASSENGER IDENTITY */}
|
||||
{/* SEARCH FLIGHT NUMBER / PNR CARD */}
|
||||
<div className={SECTION_CONTAINER_CLASS}>
|
||||
<div className={SECTION_TITLE_CLASS}>
|
||||
<User size={16} />
|
||||
<span>Passenger Identity</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<CustomInput
|
||||
label="Full Name"
|
||||
placeholder="e.g. John Doe"
|
||||
value={formData.passengerName}
|
||||
onChange={(e) => handleInputChange("passengerName", e.target.value)}
|
||||
/>
|
||||
<CustomInput
|
||||
label="PNR Reference"
|
||||
placeholder="e.g. FT7687T9I"
|
||||
value={formData.pnr}
|
||||
onChange={(e) => handleInputChange("pnr", e.target.value)}
|
||||
/>
|
||||
<CustomDropdown
|
||||
label="Loyalty Tier"
|
||||
placeholder="Select Loyalty Tier"
|
||||
value={formData.loyaltyTier}
|
||||
onChange={(val) => handleInputChange("loyaltyTier", val as string)}
|
||||
options={loyaltyTierOptions}
|
||||
/>
|
||||
<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>
|
||||
|
||||
{/* FLIGHT CONTEXT */}
|
||||
{/* 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>
|
||||
</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>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* FLIGHT CONTEXT DETAILS */}
|
||||
<div className={SECTION_CONTAINER_CLASS}>
|
||||
<div className={SECTION_TITLE_CLASS}>
|
||||
<Airplane size={16} />
|
||||
<span>Flight Context</span>
|
||||
<span>Flight Context & Route</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<CustomDropdown
|
||||
label="Flight Number"
|
||||
placeholder="e.g. B7687YT"
|
||||
value={formData.flightNumber}
|
||||
onChange={(val) => handleInputChange("flightNumber", val as string)}
|
||||
options={[
|
||||
{ label: "B7687YT", value: "B7687YT" },
|
||||
{ label: "Q23SXD", value: "Q23SXD" },
|
||||
{ label: "AZ404", value: "AZ404" },
|
||||
]}
|
||||
/>
|
||||
<CustomInput
|
||||
type="date"
|
||||
label="Date"
|
||||
placeholder="Selected Option"
|
||||
value={formData.date}
|
||||
onChange={(e) => handleInputChange("date", e.target.value)}
|
||||
/>
|
||||
<CustomDropdown
|
||||
label="Origin (IATA)"
|
||||
placeholder="Selected Option"
|
||||
value={formData.origin}
|
||||
onChange={(val) => handleInputChange("origin", val as string)}
|
||||
options={[
|
||||
{ label: "FRA", value: "FRA" },
|
||||
{ label: "LHR", value: "LHR" },
|
||||
{ label: "SFO", value: "SFO" },
|
||||
{ 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
|
||||
@@ -261,9 +542,13 @@ export default function AddRecoveryIncidents({ isOpen, onClose, incident }: AddR
|
||||
value={formData.destination}
|
||||
onChange={(val) => handleInputChange("destination", val as string)}
|
||||
options={[
|
||||
{ label: "JFK", value: "JFK" },
|
||||
{ label: "CDG", value: "CDG" },
|
||||
{ label: "NRT", value: "NRT" },
|
||||
{ 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>
|
||||
@@ -281,11 +566,7 @@ export default function AddRecoveryIncidents({ isOpen, onClose, incident }: AddR
|
||||
placeholder="Selected Option"
|
||||
value={formData.category}
|
||||
onChange={(val) => handleInputChange("category", val as string)}
|
||||
options={[
|
||||
{ label: "Flight Ops", value: "flight_ops" },
|
||||
{ label: "Travel Exp", value: "travel_exp" },
|
||||
{ label: "Weather", value: "weather" },
|
||||
]}
|
||||
options={DEFAULT_CATEGORY_OPTIONS}
|
||||
/>
|
||||
<CustomDropdown
|
||||
label="Scenario"
|
||||
|
||||
@@ -183,7 +183,7 @@ export default function RecoveryIncidentsList() {
|
||||
const { updateIncidentStatus, getRecoveryMetrics } = await import('../RecoveryIncidentsApi');
|
||||
await updateIncidentStatus(incident.id, text);
|
||||
fetchIncidents();
|
||||
getRecoveryMetrics().then((data) => setMetrics(data)).catch(() => {});
|
||||
getRecoveryMetrics().then((data) => setMetrics(data)).catch(() => { });
|
||||
} catch (error) {
|
||||
console.error("Failed to update status", error);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
export interface MockPassenger {
|
||||
id: string;
|
||||
passengerName: string;
|
||||
pnr: string;
|
||||
passengerType: string; // e.g. "VIP Adult", "Adult", "High Value", "Child", "Infant"
|
||||
nationality: string; // e.g. "German", "British", "American", "Japanese", "French"
|
||||
loyaltyTier: string; // e.g. "Platinum", "Gold", "Silver", "Bronze", "Regular"
|
||||
specialAssistance: string; // e.g. "Wheelchair (WCHR)", "None", "Dietary Meal", "Medical"
|
||||
originalCabin: string; // e.g. "First Class", "Business Class", "Premium Economy", "Economy"
|
||||
actualCabin: string; // e.g. "Business Class", "Economy", "First Class" (assigned cabin after disruption)
|
||||
cabinClass?: string; // Legacy fallback/alias
|
||||
isHighValue?: boolean;
|
||||
seatNumber?: string;
|
||||
ticketNumber?: string;
|
||||
}
|
||||
|
||||
export interface MockDisruption {
|
||||
flightNumber: string;
|
||||
date: string; // YYYY-MM-DD
|
||||
origin: string; // IATA code
|
||||
destination: string; // IATA code
|
||||
category: string; // e.g. "flight_ops", "weather", "travel_exp"
|
||||
scenario: string; // e.g. "delayed_flight", "cancelled_flight", "missed_connection"
|
||||
jurisdiction: string; // e.g. "EU261", "US_DOT", "UK261"
|
||||
delayDuration: number; // in minutes
|
||||
status: string; // e.g. "Delayed", "Cancelled", "Diverted"
|
||||
description?: string;
|
||||
passengers: MockPassenger[];
|
||||
}
|
||||
|
||||
export const MOCK_DISRUPTIONS: Record<string, MockDisruption> = {
|
||||
B7687YT: {
|
||||
flightNumber: 'B7687YT',
|
||||
date: '2026-08-14',
|
||||
origin: 'FRA',
|
||||
destination: 'JFK',
|
||||
category: 'flight_ops',
|
||||
scenario: 'delayed_flight',
|
||||
jurisdiction: 'EU261',
|
||||
delayDuration: 240,
|
||||
status: 'Delayed',
|
||||
description: 'Technical engine maintenance delay at Frankfurt Airport.',
|
||||
passengers: [
|
||||
{
|
||||
id: 'p-101',
|
||||
passengerName: 'John Doe',
|
||||
pnr: 'FT7687T9I',
|
||||
passengerType: 'VIP Adult',
|
||||
nationality: 'German',
|
||||
loyaltyTier: 'Platinum',
|
||||
specialAssistance: 'Wheelchair (WCHR)',
|
||||
originalCabin: 'First Class',
|
||||
actualCabin: 'Business Class', // Downgraded during disruption
|
||||
cabinClass: 'First Class',
|
||||
isHighValue: true,
|
||||
seatNumber: '4A',
|
||||
ticketNumber: '0162394810239',
|
||||
},
|
||||
{
|
||||
id: 'p-102',
|
||||
passengerName: 'Sarah Jenkins',
|
||||
pnr: 'SJ98231FA',
|
||||
passengerType: 'High Value Adult',
|
||||
nationality: 'British',
|
||||
loyaltyTier: 'Gold',
|
||||
specialAssistance: 'None',
|
||||
originalCabin: 'First Class',
|
||||
actualCabin: 'First Class',
|
||||
cabinClass: 'First Class',
|
||||
isHighValue: true,
|
||||
seatNumber: '2F',
|
||||
ticketNumber: '0162394810240',
|
||||
},
|
||||
{
|
||||
id: 'p-103',
|
||||
passengerName: 'Michael Chen',
|
||||
pnr: 'MC441298X',
|
||||
passengerType: 'Adult',
|
||||
nationality: 'American',
|
||||
loyaltyTier: 'Silver',
|
||||
specialAssistance: 'Dietary Meal (VGML)',
|
||||
originalCabin: 'Business Class',
|
||||
actualCabin: 'Economy', // Downgraded
|
||||
cabinClass: 'Business Class',
|
||||
isHighValue: false,
|
||||
seatNumber: '28C',
|
||||
ticketNumber: '0162394810241',
|
||||
},
|
||||
{
|
||||
id: 'p-104',
|
||||
passengerName: 'Amanda Lewis',
|
||||
pnr: 'AL551029P',
|
||||
passengerType: 'Adult',
|
||||
nationality: 'Canadian',
|
||||
loyaltyTier: 'Bronze',
|
||||
specialAssistance: 'None',
|
||||
originalCabin: 'Economy',
|
||||
actualCabin: 'Economy',
|
||||
cabinClass: 'Economy',
|
||||
isHighValue: false,
|
||||
seatNumber: '31D',
|
||||
ticketNumber: '0162394810242',
|
||||
},
|
||||
],
|
||||
},
|
||||
Q23SXD: {
|
||||
flightNumber: 'Q23SXD',
|
||||
date: '2026-08-13',
|
||||
origin: 'LHR',
|
||||
destination: 'CDG',
|
||||
category: 'weather',
|
||||
scenario: 'cancelled_flight',
|
||||
jurisdiction: 'UK261',
|
||||
delayDuration: 360,
|
||||
status: 'Cancelled',
|
||||
description: 'Severe storm and heavy fog over London Heathrow.',
|
||||
passengers: [
|
||||
{
|
||||
id: 'p-201',
|
||||
passengerName: 'Robert Vance',
|
||||
pnr: 'RV992104K',
|
||||
passengerType: 'VIP Adult',
|
||||
nationality: 'British',
|
||||
loyaltyTier: 'Platinum',
|
||||
specialAssistance: 'None',
|
||||
originalCabin: 'First Class',
|
||||
actualCabin: 'Business Class',
|
||||
cabinClass: 'First Class',
|
||||
isHighValue: true,
|
||||
seatNumber: '1B',
|
||||
ticketNumber: '1259920194812',
|
||||
},
|
||||
{
|
||||
id: 'p-202',
|
||||
passengerName: 'Emma Watson',
|
||||
pnr: 'EW771029M',
|
||||
passengerType: 'High Value Adult',
|
||||
nationality: 'French',
|
||||
loyaltyTier: 'Gold',
|
||||
specialAssistance: 'Dietary Meal',
|
||||
originalCabin: 'Business Class',
|
||||
actualCabin: 'Business Class',
|
||||
cabinClass: 'Business Class',
|
||||
isHighValue: true,
|
||||
seatNumber: '6D',
|
||||
ticketNumber: '1259920194813',
|
||||
},
|
||||
{
|
||||
id: 'p-203',
|
||||
passengerName: 'David Miller',
|
||||
pnr: 'DM334190Q',
|
||||
passengerType: 'Adult',
|
||||
nationality: 'Australian',
|
||||
loyaltyTier: 'Regular',
|
||||
specialAssistance: 'Wheelchair (WCHR)',
|
||||
originalCabin: 'Economy',
|
||||
actualCabin: 'Economy',
|
||||
cabinClass: 'Economy',
|
||||
isHighValue: false,
|
||||
seatNumber: '19A',
|
||||
ticketNumber: '1259920194814',
|
||||
},
|
||||
],
|
||||
},
|
||||
AZ404: {
|
||||
flightNumber: 'AZ404',
|
||||
date: '2026-08-13',
|
||||
origin: 'SFO',
|
||||
destination: 'NRT',
|
||||
category: 'travel_exp',
|
||||
scenario: 'missed_connection',
|
||||
jurisdiction: 'US_DOT',
|
||||
delayDuration: 180,
|
||||
status: 'Delayed',
|
||||
description: 'Late arrival of incoming aircraft causing connection breakdown.',
|
||||
passengers: [
|
||||
{
|
||||
id: 'p-301',
|
||||
passengerName: 'Kaito Tanaka',
|
||||
pnr: 'KT883192Z',
|
||||
passengerType: 'High Value Adult',
|
||||
nationality: 'Japanese',
|
||||
loyaltyTier: 'Platinum',
|
||||
specialAssistance: 'None',
|
||||
originalCabin: 'Business Class',
|
||||
actualCabin: 'Business Class',
|
||||
cabinClass: 'Business Class',
|
||||
isHighValue: true,
|
||||
seatNumber: '11K',
|
||||
ticketNumber: '0571120938491',
|
||||
},
|
||||
{
|
||||
id: 'p-302',
|
||||
passengerName: 'Lisa Ray',
|
||||
pnr: 'LR110293Y',
|
||||
passengerType: 'Adult',
|
||||
nationality: 'American',
|
||||
loyaltyTier: 'Silver',
|
||||
specialAssistance: 'Unaccompanied Minor',
|
||||
originalCabin: 'Premium Economy',
|
||||
actualCabin: 'Economy',
|
||||
cabinClass: 'Premium Economy',
|
||||
isHighValue: false,
|
||||
seatNumber: '16C',
|
||||
ticketNumber: '0571120938492',
|
||||
},
|
||||
{
|
||||
id: 'p-303',
|
||||
passengerName: 'Carlos Gomez',
|
||||
pnr: 'CG559102X',
|
||||
passengerType: 'VIP Adult',
|
||||
nationality: 'Mexican',
|
||||
loyaltyTier: 'Gold',
|
||||
specialAssistance: 'Medical Assistance',
|
||||
originalCabin: 'Business Class',
|
||||
actualCabin: 'Business Class',
|
||||
cabinClass: 'Business Class',
|
||||
isHighValue: true,
|
||||
seatNumber: '8A',
|
||||
ticketNumber: '0571120938493',
|
||||
},
|
||||
],
|
||||
},
|
||||
BA178: {
|
||||
flightNumber: 'BA178',
|
||||
date: '2026-08-15',
|
||||
origin: 'JFK',
|
||||
destination: 'LHR',
|
||||
category: 'flight_ops',
|
||||
scenario: 'delayed_flight',
|
||||
jurisdiction: 'UK261',
|
||||
delayDuration: 300,
|
||||
status: 'Delayed',
|
||||
description: 'Air traffic control delay on transatlantic sector.',
|
||||
passengers: [
|
||||
{
|
||||
id: 'p-401',
|
||||
passengerName: 'Harrison Ford',
|
||||
pnr: 'HF908123A',
|
||||
passengerType: 'VIP Adult',
|
||||
nationality: 'American',
|
||||
loyaltyTier: 'Platinum',
|
||||
specialAssistance: 'None',
|
||||
originalCabin: 'First Class',
|
||||
actualCabin: 'First Class',
|
||||
cabinClass: 'First Class',
|
||||
isHighValue: true,
|
||||
seatNumber: '2A',
|
||||
ticketNumber: '1250912837192',
|
||||
},
|
||||
{
|
||||
id: 'p-402',
|
||||
passengerName: 'Clara Oswald',
|
||||
pnr: 'CO449182B',
|
||||
passengerType: 'High Value Adult',
|
||||
nationality: 'British',
|
||||
loyaltyTier: 'Gold',
|
||||
specialAssistance: 'None',
|
||||
originalCabin: 'Business Class',
|
||||
actualCabin: 'Premium Economy',
|
||||
cabinClass: 'Business Class',
|
||||
isHighValue: true,
|
||||
seatNumber: '12E',
|
||||
ticketNumber: '1250912837193',
|
||||
},
|
||||
],
|
||||
},
|
||||
EK202: {
|
||||
flightNumber: 'EK202',
|
||||
date: '2026-08-14',
|
||||
origin: 'JFK',
|
||||
destination: 'DXB',
|
||||
category: 'weather',
|
||||
scenario: 'delayed_flight',
|
||||
jurisdiction: 'US_DOT',
|
||||
delayDuration: 210,
|
||||
status: 'Delayed',
|
||||
description: 'Severe thunderstorm delay prior to pushback.',
|
||||
passengers: [
|
||||
{
|
||||
id: 'p-501',
|
||||
passengerName: 'Tariq Al-Mansoor',
|
||||
pnr: 'TM771920K',
|
||||
passengerType: 'VIP Adult',
|
||||
nationality: 'Emirati',
|
||||
loyaltyTier: 'Platinum',
|
||||
specialAssistance: 'None',
|
||||
originalCabin: 'First Class',
|
||||
actualCabin: 'First Class',
|
||||
cabinClass: 'First Class',
|
||||
isHighValue: true,
|
||||
seatNumber: '1A',
|
||||
ticketNumber: '1769910293841',
|
||||
},
|
||||
{
|
||||
id: 'p-502',
|
||||
passengerName: 'Fatima Al-Sayed',
|
||||
pnr: 'FA882019L',
|
||||
passengerType: 'High Value Adult',
|
||||
nationality: 'Emirati',
|
||||
loyaltyTier: 'Gold',
|
||||
specialAssistance: 'Dietary Meal',
|
||||
originalCabin: 'Business Class',
|
||||
actualCabin: 'Business Class',
|
||||
cabinClass: 'Business Class',
|
||||
isHighValue: true,
|
||||
seatNumber: '7K',
|
||||
ticketNumber: '1769910293842',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Get options list of flight numbers for dropdown selection
|
||||
*/
|
||||
export function getMockFlightNumbers(): { label: string; value: string; description: string }[] {
|
||||
return Object.values(MOCK_DISRUPTIONS).map((item) => ({
|
||||
label: `${item.flightNumber} (${item.origin} → ${item.destination} • ${item.delayDuration}m delay)`,
|
||||
value: item.flightNumber,
|
||||
description: item.description || '',
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get disruption detail for a given flight number or PNR reference
|
||||
*/
|
||||
export function getMockDisruptionByFlightNumber(query: string): MockDisruption | undefined {
|
||||
if (!query) return undefined;
|
||||
const upper = query.trim().toUpperCase();
|
||||
|
||||
// 1. Direct flight number match
|
||||
const flightKey = Object.keys(MOCK_DISRUPTIONS).find((k) => k.toUpperCase() === upper);
|
||||
if (flightKey) return MOCK_DISRUPTIONS[flightKey];
|
||||
|
||||
// 2. Direct PNR match search across flights
|
||||
for (const item of Object.values(MOCK_DISRUPTIONS)) {
|
||||
const match = item.passengers.some((p) => p.pnr.toUpperCase() === upper);
|
||||
if (match) return item;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of passengers for a flight
|
||||
*/
|
||||
export function getPassengersForFlight(flightNumber: string): MockPassenger[] {
|
||||
const disruption = getMockDisruptionByFlightNumber(flightNumber);
|
||||
return disruption ? disruption.passengers : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get specific passenger details by flight number and PNR
|
||||
*/
|
||||
export function getMockPassengerByPnr(flightNumber: string, pnr: string): MockPassenger | undefined {
|
||||
const passengers = getPassengersForFlight(flightNumber);
|
||||
if (!pnr) return undefined;
|
||||
const upperPnr = pnr.trim().toUpperCase();
|
||||
return passengers.find((p) => p.pnr.toUpperCase() === upperPnr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search across flights and PNRs, returning disruption and optionally matched passenger ID
|
||||
*/
|
||||
export function searchDisruptionOrPassenger(query: string): { disruption?: MockDisruption; matchedPassengerId?: string } {
|
||||
if (!query) return {};
|
||||
const upper = query.trim().toUpperCase();
|
||||
|
||||
// Search flight number
|
||||
const flightKey = Object.keys(MOCK_DISRUPTIONS).find((k) => k.toUpperCase() === upper);
|
||||
if (flightKey) {
|
||||
return { disruption: MOCK_DISRUPTIONS[flightKey] };
|
||||
}
|
||||
|
||||
// Search PNR
|
||||
for (const item of Object.values(MOCK_DISRUPTIONS)) {
|
||||
const passenger = item.passengers.find((p) => p.pnr.toUpperCase() === upper);
|
||||
if (passenger) {
|
||||
return { disruption: item, matchedPassengerId: passenger.id };
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all mock disruptions
|
||||
*/
|
||||
export function getAllMockDisruptions(): MockDisruption[] {
|
||||
return Object.values(MOCK_DISRUPTIONS);
|
||||
}
|
||||
@@ -1,100 +1,141 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { ClipboardTextIcon, CheckCircleIcon } from '@phosphor-icons/react';
|
||||
import { formatDate } from '../../../utils/formatDate';
|
||||
import { getIncidentAuditTrail, type AuditTrailStepDto } from '../RecoveryIncidentsApi';
|
||||
import type { RecoveryIncident } from '../RecoveryIncidentsTypes';
|
||||
|
||||
interface AuditTrailTabProps {
|
||||
incident?: RecoveryIncident | null;
|
||||
}
|
||||
|
||||
export default function AuditTrailTab({ incident }: AuditTrailTabProps) {
|
||||
const [apiSteps, setApiSteps] = useState<AuditTrailStepDto[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const code = incident?.recoveryCode || 'REC-INCIDENT';
|
||||
const passenger = incident?.passengerName || 'Passenger';
|
||||
const flight = incident?.flightNumber || 'Flight';
|
||||
const route = incident?.flightRoute || 'Route';
|
||||
const status = incident?.status || 'Pending';
|
||||
const evaluation = incident?.evaluation;
|
||||
const policyName = evaluation?.policyName || 'Standard Policy';
|
||||
const cohortName = evaluation?.matchedCohortName || 'General Audience';
|
||||
const recoveryScore = evaluation?.recoveryScore || 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!incident?.id) return;
|
||||
setLoading(true);
|
||||
getIncidentAuditTrail(incident.id)
|
||||
.then((data) => {
|
||||
if (Array.isArray(data)) {
|
||||
setApiSteps(data);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to load backend audit trail:', err);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [incident?.id, incident?.status, incident?.evaluation?.updatedAt]);
|
||||
|
||||
const checkIcon = <CheckCircleIcon size={24} weight="fill" className="text-[#1B9869]" />;
|
||||
|
||||
const isApproved = status.toLowerCase().includes('appr');
|
||||
const isRejected = status.toLowerCase().includes('reject');
|
||||
|
||||
// Initial default steps if API response is loading
|
||||
const defaultInitialSteps: AuditTrailStepDto[] = [
|
||||
{
|
||||
id: 'step-1',
|
||||
title: 'Flight Disruption Recorded',
|
||||
description: `Disruption identified for ${passenger} on flight ${flight} (${route}).`,
|
||||
timestamp: incident?.createdAt || incident?.date || new Date().toISOString(),
|
||||
status: 'completed',
|
||||
},
|
||||
{
|
||||
id: 'step-2',
|
||||
title: 'Target Audience Cohort Evaluated',
|
||||
description: `Automated audience eligibility assessment performed against active frameworks. Matched cohort: "${cohortName}".`,
|
||||
timestamp: incident?.createdAt || new Date().toISOString(),
|
||||
status: 'completed',
|
||||
},
|
||||
{
|
||||
id: 'step-3',
|
||||
title: 'Policy Evaluated',
|
||||
description: `Evaluated against active rules under "${policyName}". Recovery Score: ${recoveryScore}/100.`,
|
||||
timestamp: evaluation?.createdAt || incident?.createdAt || new Date().toISOString(),
|
||||
status: 'completed',
|
||||
},
|
||||
{
|
||||
id: 'step-4',
|
||||
title: `Status: ${status}`,
|
||||
description: `Case officer assigned. Case status updated to "${status}".`,
|
||||
timestamp: incident?.updatedAt || new Date().toISOString(),
|
||||
status: 'completed',
|
||||
},
|
||||
{
|
||||
id: 'step-5',
|
||||
title: 'Recovery Resolution',
|
||||
description: isApproved
|
||||
? 'Recovery incident approved. Automated settlement and customer notification dispatched.'
|
||||
: isRejected
|
||||
? 'Recovery incident rejected by case officer.'
|
||||
: 'Refund and compensation settlement will initiate upon final approval.',
|
||||
timestamp: incident?.updatedAt || new Date().toISOString(),
|
||||
status: isApproved ? 'completed' : isRejected ? 'rejected' : 'pending',
|
||||
},
|
||||
];
|
||||
|
||||
const displaySteps = apiSteps.length > 0 ? apiSteps : defaultInitialSteps;
|
||||
|
||||
export default function AuditTrailTab() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="bg-white rounded-2xl p-6 border border-gray-100 shadow-sm flex flex-col gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardTextIcon size={20} weight="bold" className="text-[#143d30]" />
|
||||
<h3 className="text-[13px] font-bold text-[#143d30] tracking-wider uppercase">Lifecycle Timeline & Audit Trail</h3>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardTextIcon size={20} weight="bold" className="text-[#143d30]" />
|
||||
<h3 className="text-[13px] font-bold text-[#143d30] tracking-wider uppercase">
|
||||
Lifecycle Timeline & Audit Trail ({code})
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col mt-4 pl-2">
|
||||
{/* Step 1: Flight Disruption Recorded */}
|
||||
<div className="relative pl-10 pb-8">
|
||||
<div className="absolute left-[7px] top-7 bottom-0 w-[2px] bg-[#1B9869]"></div>
|
||||
<div className="absolute left-[-4px] top-0.5 bg-white">
|
||||
<CheckCircleIcon size={24} weight="fill" className="text-[#1B9869]" />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-900 mb-1">Flight Disruption Recorded</h4>
|
||||
<p className="text-[13px] text-gray-500">Denied Boarding identified for flight Q23SXD.</p>
|
||||
</div>
|
||||
<span className="text-[11px] font-medium text-gray-400 tracking-wider">Assessment Point</span>
|
||||
</div>
|
||||
</div>
|
||||
{displaySteps.map((step, idx) => {
|
||||
const isLast = idx === displaySteps.length - 1;
|
||||
const rawTime = (step as any).createdAt || step.timestamp;
|
||||
|
||||
{/* Step 2: Simulation Engine Executed */}
|
||||
<div className="relative pl-10 pb-8">
|
||||
<div className="absolute left-[7px] top-7 bottom-0 w-[2px] bg-gray-200"></div>
|
||||
<div className="absolute left-0 top-1 w-4 h-4 rounded-full bg-[#1B9869] ring-4 ring-[#E5F0EB]"></div>
|
||||
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-900 mb-1">Simulation Engine Executed</h4>
|
||||
<p className="text-[13px] text-gray-500">Automated eligibility assessment performed against active frameworks.</p>
|
||||
</div>
|
||||
<span className="text-[11px] font-medium text-gray-400 tracking-wider">T-10m</span>
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<div key={step.id || idx} className={`relative pl-10 ${isLast ? '' : 'pb-8'}`}>
|
||||
{/* Connecting Line */}
|
||||
{!isLast && (
|
||||
<div
|
||||
className="absolute left-[7px] top-7 bottom-0 w-[2px] bg-[#1B9869]"
|
||||
></div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Policy Evaluated */}
|
||||
<div className="relative pl-10 pb-8">
|
||||
<div className="absolute left-[7px] top-7 bottom-0 w-[2px] bg-gray-200"></div>
|
||||
<div className="absolute left-[-2px] top-1 w-5 h-5 rounded-full border-2 border-gray-200 bg-white"></div>
|
||||
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-900 mb-1">Policy Evaluated</h4>
|
||||
<p className="text-[13px] text-gray-500">Pending final approval from Case Officer.</p>
|
||||
</div>
|
||||
<span className="text-[11px] font-medium text-gray-400 tracking-wider">T-8m</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Node Icon Indicator */}
|
||||
<div className="absolute left-[-4px] top-0.5 bg-white flex items-center justify-center">
|
||||
<div className="text-[#1B9869]">{checkIcon}</div>
|
||||
</div>
|
||||
|
||||
{/* Step 4: Status: Under Review */}
|
||||
<div className="relative pl-10 pb-8">
|
||||
<div className="absolute left-[7px] top-7 bottom-0 w-[2px] bg-gray-200"></div>
|
||||
<div className="absolute left-[-2px] top-1 w-5 h-5 rounded-full border-2 border-gray-200 bg-white"></div>
|
||||
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-900 mb-1">Status: Under Review</h4>
|
||||
<p className="text-[13px] text-gray-500">Tuesday, 28 May 2024</p>
|
||||
</div>
|
||||
<span className="text-[11px] font-medium text-gray-400 tracking-wider">Current</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Step Details */}
|
||||
<div className="flex justify-between items-start gap-4">
|
||||
<div className="flex flex-col gap-1 max-w-xl">
|
||||
<h4 className="text-sm font-semibold text-gray-900">{step.title}</h4>
|
||||
<p className="text-[13px] text-gray-500 leading-relaxed">
|
||||
{step.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Step 5: Policy Engine Rerun */}
|
||||
<div className="relative pl-10 pb-8">
|
||||
<div className="absolute left-[7px] top-7 bottom-0 w-[2px] bg-gray-200"></div>
|
||||
<div className="absolute left-[-2px] top-1 w-5 h-5 rounded-full border-2 border-gray-200 bg-white"></div>
|
||||
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-900 mb-1">Policy Engine Rerun</h4>
|
||||
<p className="text-[13px] text-gray-500">Manual re-assessment triggered. Applied: Standard Policy.</p>
|
||||
<span className="text-[11px] font-medium text-gray-400 tracking-wider shrink-0">
|
||||
{formatDate(rawTime)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[11px] font-medium text-gray-400 tracking-wider">Recent</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 6: Recovery Resolution */}
|
||||
<div className="relative pl-10">
|
||||
<div className="absolute left-[-2px] top-1 w-5 h-5 rounded-full border-2 border-gray-200 bg-white"></div>
|
||||
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-900 mb-1">Recovery Resolution</h4>
|
||||
<p className="text-[13px] text-gray-500">Refund and compensation settlement will initiate upon final approval.</p>
|
||||
</div>
|
||||
<span className="text-[11px] font-medium text-gray-400 tracking-wider">Pending</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,12 @@ interface CaseDetailsTabProps {
|
||||
export default function CaseDetailsTab({ incident }: CaseDetailsTabProps) {
|
||||
if (!incident) return null;
|
||||
|
||||
const isDowngraded = Boolean(
|
||||
incident.originalCabin &&
|
||||
incident.actualCabin &&
|
||||
incident.originalCabin !== incident.actualCabin
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* PASSENGER INFORMATION */}
|
||||
@@ -28,12 +34,24 @@ export default function CaseDetailsTab({ incident }: CaseDetailsTabProps) {
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-[11px] font-bold text-gray-400 tracking-wider mb-1">LOYALTY TIER</span>
|
||||
<span className="block text-[15px] font-semibold text-[#1B9869]">None</span>
|
||||
<span className="block text-[15px] font-semibold text-[#1B9869]">{incident.loyaltyTier || 'Regular'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-[11px] font-bold text-gray-400 tracking-wider mb-1">PASSENGER TYPE</span>
|
||||
<span className="block text-[15px] font-semibold text-gray-900">Adult</span>
|
||||
<span className="block text-[15px] font-semibold text-gray-900">{incident.passengerType || 'Adult'}</span>
|
||||
</div>
|
||||
{incident.nationality && (
|
||||
<div>
|
||||
<span className="block text-[11px] font-bold text-gray-400 tracking-wider mb-1">NATIONALITY</span>
|
||||
<span className="block text-[15px] font-semibold text-gray-900">{incident.nationality}</span>
|
||||
</div>
|
||||
)}
|
||||
{incident.specialAssistance && (
|
||||
<div>
|
||||
<span className="block text-[11px] font-bold text-gray-400 tracking-wider mb-1">SPECIAL ASSISTANCE</span>
|
||||
<span className="block text-[15px] font-semibold text-gray-900">{incident.specialAssistance}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -55,19 +73,32 @@ export default function CaseDetailsTab({ incident }: CaseDetailsTabProps) {
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-[11px] font-bold text-gray-400 tracking-wider mb-1">CABIN</span>
|
||||
<span className="block text-[15px] font-semibold text-[#1B9869]">Economy</span>
|
||||
<span className="block text-[15px] font-semibold text-[#1B9869]">
|
||||
{incident.actualCabin || incident.cabinClass || 'Economy'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-[11px] font-bold text-gray-400 tracking-wider mb-1">DELAY (ARRIVAL)</span>
|
||||
<span className="block text-[15px] font-semibold text-gray-900">--</span>
|
||||
<span className="block text-[15px] font-semibold text-gray-900">
|
||||
{incident.delayDuration ? `${incident.delayDuration} mins` : '--'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-[11px] font-bold text-gray-400 tracking-wider mb-1">ORIGINAL CABIN</span>
|
||||
<span className="block text-[15px] font-semibold text-gray-900">Economy</span>
|
||||
<span className="block text-[15px] font-semibold text-gray-900">
|
||||
{incident.originalCabin || incident.cabinClass || 'Economy'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-[11px] font-bold text-gray-400 tracking-wider mb-1">ACTUAL CABIN</span>
|
||||
<span className="block text-[15px] font-semibold text-gray-900">Economy</span>
|
||||
<span className="block text-[15px] font-semibold text-gray-900 flex items-center gap-1.5">
|
||||
{incident.actualCabin || incident.cabinClass || 'Economy'}
|
||||
{isDowngraded && (
|
||||
<span className="text-[10px] bg-amber-100 text-amber-800 font-bold px-2 py-0.5 rounded border border-amber-300">
|
||||
Downgraded
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -86,16 +117,20 @@ export default function CaseDetailsTab({ incident }: CaseDetailsTabProps) {
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-[11px] font-bold text-gray-400 tracking-wider mb-1">SCENARIO</span>
|
||||
<span className="block text-[15px] font-semibold text-gray-900">N/A</span>
|
||||
<span className="block text-[15px] font-semibold text-gray-900">{incident.scenario || 'Delayed Flight'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-[11px] font-bold text-gray-400 tracking-wider mb-1">SUB-TYPE</span>
|
||||
<span className="block text-[15px] font-semibold text-[#1B9869]">None</span>
|
||||
<span className="block text-[11px] font-bold text-gray-400 tracking-wider mb-1">JURISDICTION</span>
|
||||
<span className="block text-[15px] font-semibold text-[#1B9869]">{incident.jurisdiction || 'EU261'}</span>
|
||||
</div>
|
||||
<div className="col-span-3">
|
||||
<span className="block text-[11px] font-bold text-gray-400 tracking-wider mb-1">ROOT CAUSE ANALYSIS</span>
|
||||
<span className="block text-[15px] font-semibold text-gray-900">
|
||||
Operational issues resulting in service disruption. Analysis pending manual confirmation.
|
||||
{incident.category === 'weather'
|
||||
? 'Severe weather disruption impacting airport operations and flight scheduling.'
|
||||
: incident.category === 'flight_ops' || incident.category === 'technical_fault'
|
||||
? 'Technical flight operations delay requiring maintenance clearance prior to departure.'
|
||||
: 'Operational issues resulting in service disruption. Analysis verified by automated engine.'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,76 +1,97 @@
|
||||
import { CreditCardIcon, GiftIcon, HandHeartIcon } from '@phosphor-icons/react';
|
||||
import { CheckCircleIcon, InfoIcon } from '@phosphor-icons/react';
|
||||
import type { RecoveryIncident, IncidentEvaluationAction } from '../RecoveryIncidentsTypes';
|
||||
|
||||
interface RecoveryPlanTabProps {
|
||||
incident?: RecoveryIncident | null;
|
||||
}
|
||||
|
||||
export default function RecoveryPlanTab({ incident }: RecoveryPlanTabProps) {
|
||||
const actions: IncidentEvaluationAction[] = incident?.evaluation?.actions || [];
|
||||
|
||||
// Dynamically group evaluated actions by master ActionCategory name defined in DB
|
||||
const categoriesMap = new Map<string, IncidentEvaluationAction[]>();
|
||||
|
||||
actions.forEach((act) => {
|
||||
const catName = (act.category || 'General Actions').trim();
|
||||
if (!categoriesMap.has(catName)) {
|
||||
categoriesMap.set(catName, []);
|
||||
}
|
||||
categoriesMap.get(catName)!.push(act);
|
||||
});
|
||||
|
||||
if (!incident?.evaluation || actions.length === 0) {
|
||||
return (
|
||||
<div className="bg-white rounded-2xl p-12 border border-gray-100 shadow-sm flex flex-col items-center justify-center text-center gap-4">
|
||||
<div className="w-14 h-14 rounded-full bg-emerald-50 text-[#1B9869] flex items-center justify-center">
|
||||
<InfoIcon size={28} />
|
||||
</div>
|
||||
<div className="max-w-md flex flex-col gap-1">
|
||||
<h3 className="text-lg font-bold text-gray-900">No Policy Actions Evaluated</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{incident?.evaluation?.aiAssessment ||
|
||||
'No active policy rules in the Policy Engine matched the flight disruption parameters for this incident.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RecoveryPlanTab() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* FINANCIAL REFUND */}
|
||||
<div className="bg-white rounded-2xl p-6 border border-gray-100 shadow-sm flex flex-col gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<CreditCardIcon size={20} weight="bold" className="text-[#143d30]" />
|
||||
<h3 className="text-[13px] font-bold text-[#143d30] tracking-wider">FINANCIAL REFUND</h3>
|
||||
</div>
|
||||
{Array.from(categoriesMap.entries()).map(([categoryName, categoryActions]) => (
|
||||
<div
|
||||
key={categoryName}
|
||||
className="bg-white rounded-2xl p-6 border border-gray-100 shadow-sm flex flex-col gap-6"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircleIcon size={20} weight="bold" className="text-[#143d30]" />
|
||||
<h3 className="text-[13px] font-bold text-[#143d30] tracking-wider uppercase">
|
||||
{categoryName}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
<div>
|
||||
<span className="block text-[11px] font-bold text-gray-400 tracking-wider mb-1">REFUND AMOUNT</span>
|
||||
<span className="block text-[15px] font-semibold text-[#1B9869]">EUR 0</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-[11px] font-bold text-gray-400 tracking-wider mb-1">REFUND STATUS</span>
|
||||
<span className="block text-[15px] font-semibold text-gray-900">Pending Approval</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-[11px] font-bold text-gray-400 tracking-wider mb-1">REFUND METHOD</span>
|
||||
<span className="block text-[15px] font-semibold text-gray-900">Original Payment Method</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* COMPENSATION & PERKS */}
|
||||
<div className="bg-white rounded-2xl p-6 border border-gray-100 shadow-sm flex flex-col gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<GiftIcon size={20} weight="bold" className="text-[#143d30]" />
|
||||
<h3 className="text-[13px] font-bold text-[#143d30] tracking-wider">COMPENSATION & PERKS</h3>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 divide-y divide-gray-100">
|
||||
{categoryActions.map((action, idx) => {
|
||||
// Strip any legacy brackets from description sentence
|
||||
const cleanDescription = (action.description || '')
|
||||
.replace(/\[(Inputs|Configured Inputs):\s*.*?\]/, '')
|
||||
.trim();
|
||||
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
<div>
|
||||
<span className="block text-[11px] font-bold text-gray-400 tracking-wider mb-1">CASH COMPENSATION</span>
|
||||
<span className="block text-[15px] font-semibold text-[#1B9869]">EUR 0</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-[11px] font-bold text-gray-400 tracking-wider mb-1">VOUCHER ALTERNATIVE</span>
|
||||
<span className="block text-[15px] font-semibold text-gray-900">Available (120%)</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-[11px] font-bold text-gray-400 tracking-wider mb-1">LOYALTY MILES</span>
|
||||
<span className="block text-[15px] font-semibold text-gray-900">5,000 Points (Bonus)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<div key={action.id || idx} className="pt-4 first:pt-0 flex items-start justify-between gap-4">
|
||||
<div className="flex flex-col gap-1.5 max-w-2xl">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-[15px] font-bold text-gray-900 leading-snug">
|
||||
{action.title}
|
||||
</span>
|
||||
{action.actionTypeCode && (
|
||||
<span className="px-2 py-0.5 rounded text-[11px] font-semibold bg-emerald-50 text-[#1B9869] border border-emerald-200 uppercase tracking-wide">
|
||||
{action.actionTypeCode.replace(/_/g, ' ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* PASSENGER CARE */}
|
||||
<div className="bg-white rounded-2xl p-6 border border-gray-100 shadow-sm flex flex-col gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<HandHeartIcon size={20} weight="bold" className="text-[#143d30]" />
|
||||
<h3 className="text-[13px] font-bold text-[#143d30] tracking-wider">PASSENGER CARE</h3>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 leading-relaxed font-medium">
|
||||
{cleanDescription || 'Action executed per policy configuration.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
<div>
|
||||
<span className="block text-[11px] font-bold text-gray-400 tracking-wider mb-1">MEAL VOUCHERS</span>
|
||||
<span className="block text-[15px] font-semibold text-[#1B9869]">2 x $15.00 Issued</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-[11px] font-bold text-gray-400 tracking-wider mb-1">HOTEL ACCOMMODATION</span>
|
||||
<span className="block text-[15px] font-semibold text-gray-900">1 Night (Pending)</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-[11px] font-bold text-gray-400 tracking-wider mb-1">GROUND TRANSPORT</span>
|
||||
<span className="block text-[15px] font-semibold text-gray-900">Airport to City Center</span>
|
||||
<div className="flex flex-col items-end gap-1.5 shrink-0">
|
||||
<span className="text-[15px] font-bold text-[#1B9869]">
|
||||
{action.amount
|
||||
? `${action.currency || ''} ${action.amount.toLocaleString()}`.trim()
|
||||
: 'Included / Configured'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import { SparkleIcon, UserIcon, ArrowRightIcon } from "@phosphor-icons/react";
|
||||
import { CustomButton } from "../../../components/custom";
|
||||
import type { RecoveryIncident } from "../RecoveryIncidentsTypes";
|
||||
|
||||
interface SummaryTabProps {
|
||||
incident?: RecoveryIncident | null;
|
||||
}
|
||||
|
||||
export default function SummaryTab({ incident }: SummaryTabProps) {
|
||||
const policyName = incident?.evaluation?.policyName || 'No Policy Matched';
|
||||
const recoveryScore = incident?.evaluation?.recoveryScore || 0;
|
||||
const aiAssessmentText =
|
||||
incident?.evaluation?.aiAssessment ||
|
||||
`No active policy rules matched flight ${incident?.flightNumber || 'N/A'} for passenger ${incident?.passengerName || 'N/A'}. Click "RE-RUN ENGINE" to evaluate policy rules.`;
|
||||
|
||||
export default function SummaryTab() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* AI STRATEGIC ASSESSMENT */}
|
||||
@@ -19,9 +30,7 @@ export default function SummaryTab() {
|
||||
</div>
|
||||
|
||||
<p className="text-[15px] text-gray-700 italic leading-relaxed">
|
||||
"Analysis of JOHN WICK's history and the flight disruption suggest
|
||||
this is a high-retention opportunity. Automated settlement is
|
||||
recommended to maintain NPS within the Platinum segment."
|
||||
"{aiAssessmentText}"
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -38,7 +47,7 @@ export default function SummaryTab() {
|
||||
Recovery Source
|
||||
</span>
|
||||
<span className="block text-[15px] font-semibold text-gray-900">
|
||||
Simulation Engine
|
||||
Policy Evaluation Engine
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -47,7 +56,7 @@ export default function SummaryTab() {
|
||||
Policy Applied
|
||||
</span>
|
||||
<span className="block text-[15px] font-semibold text-gray-900">
|
||||
Standard Policy
|
||||
{policyName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -56,7 +65,7 @@ export default function SummaryTab() {
|
||||
Jurisdiction
|
||||
</span>
|
||||
<span className="block text-[15px] font-semibold text-gray-900">
|
||||
EU261
|
||||
{incident?.jurisdiction || 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -102,14 +111,13 @@ export default function SummaryTab() {
|
||||
|
||||
<div className="flex items-baseline gap-1 mt-2 mb-2">
|
||||
<span className="text-[48px] font-bold text-gray-900 leading-none">
|
||||
75
|
||||
{recoveryScore}
|
||||
</span>
|
||||
<span className="text-xl text-gray-400 font-semibold">/ 100</span>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-600 leading-relaxed">
|
||||
Manual review recommended. Aligns with standard EU261 recovery
|
||||
logic.
|
||||
Automated evaluation score derived from configuration rules.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ import CaseDetailsTab from './CaseDetailsTab';
|
||||
import RecoveryPlanTab from './RecoveryPlanTab';
|
||||
import AuditTrailTab from './AuditTrailTab';
|
||||
import { SparkleIcon } from 'lucide-react';
|
||||
import { getRecoveryIncident, updateIncidentStatus } from '../RecoveryIncidentsApi';
|
||||
import { getRecoveryIncident, updateIncidentStatus, reRunPolicyEngine } from '../RecoveryIncidentsApi';
|
||||
import type { RecoveryIncident } from '../RecoveryIncidentsTypes';
|
||||
|
||||
function getStatusVariant(status?: string): "success" | "error" | "warning" | "info" | "neutral" {
|
||||
@@ -57,11 +57,24 @@ export default function RecoveryIncidentTabs() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleReRunEngine = async () => {
|
||||
if (!id || updating) return;
|
||||
setUpdating(true);
|
||||
try {
|
||||
const updated = await reRunPolicyEngine(id);
|
||||
setIncident(updated);
|
||||
} catch (err) {
|
||||
console.error("Failed to re-run policy engine:", err);
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const tabItems = [
|
||||
{
|
||||
id: 'Summary',
|
||||
label: 'Summary',
|
||||
content: <SummaryTab />
|
||||
content: <SummaryTab incident={incident} />
|
||||
},
|
||||
{
|
||||
id: 'Case Details',
|
||||
@@ -71,12 +84,12 @@ export default function RecoveryIncidentTabs() {
|
||||
{
|
||||
id: 'Recovery Plan',
|
||||
label: 'Recovery Plan',
|
||||
content: <RecoveryPlanTab />
|
||||
content: <RecoveryPlanTab incident={incident} />
|
||||
},
|
||||
{
|
||||
id: 'Audit Trail',
|
||||
label: 'Audit Trail',
|
||||
content: <AuditTrailTab />
|
||||
content: <AuditTrailTab incident={incident} />
|
||||
}
|
||||
];
|
||||
|
||||
@@ -107,10 +120,12 @@ export default function RecoveryIncidentTabs() {
|
||||
</div>
|
||||
</div>
|
||||
<CustomButton
|
||||
leftIcon={<ArrowsClockwiseIcon size={18} weight="bold" />}
|
||||
className="!bg-[#1B9869] hover:!bg-[#14704E] !text-white !font-semibold !rounded-lg !px-5 !py-2.5"
|
||||
disabled={updating}
|
||||
onClick={handleReRunEngine}
|
||||
leftIcon={<ArrowsClockwiseIcon size={18} weight="bold" className={updating ? "animate-spin" : ""} />}
|
||||
className="!bg-[#1B9869] hover:!bg-[#14704E] !text-white !font-semibold !rounded-lg !px-5 !py-2.5 disabled:opacity-50"
|
||||
>
|
||||
RE-RUN ENGINE
|
||||
{updating ? "EVALUATING..." : "RE-RUN ENGINE"}
|
||||
</CustomButton>
|
||||
</div>
|
||||
|
||||
@@ -150,14 +165,13 @@ export default function RecoveryIncidentTabs() {
|
||||
|
||||
{/* Right Column - Sidebar */}
|
||||
<div className="w-[360px] flex-shrink-0 bg-[#F8F9FA] rounded-[16px] border border-gray-100 relative">
|
||||
|
||||
{/* Blur Overlay */}
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/20 backdrop-blur-[3px] rounded-[16px]">
|
||||
<h4 className="text-[18px] font-bold text-[#143d30] italic">"Coming soon"</h4>
|
||||
</div>
|
||||
|
||||
{/* Sidebar Content */}
|
||||
<div className="p-6 select-none pointer-events-none">
|
||||
<div className="p-6 select-none pointer-events-none">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<span className="text-[#1B9869]"><SparkleIcon size={20} height="fill" /></span>
|
||||
<h3 className="text-[13px] font-bold text-gray-400 tracking-wider">AI RECOMMENDATION</h3>
|
||||
@@ -166,7 +180,7 @@ export default function RecoveryIncidentTabs() {
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between items-end mb-2">
|
||||
<span className="text-xs font-bold text-gray-400 tracking-wider">SATISFACTION PREDICT</span>
|
||||
<span className="text-sm font-bold text-[#1B9869]">84%</span>
|
||||
<span className="text-sm font-bold text-[#1B9869]">{incident?.evaluation?.recoveryScore || 84}%</span>
|
||||
</div>
|
||||
<div className="h-2 bg-white rounded-full overflow-hidden border border-gray-100">
|
||||
<div className="h-full bg-[#1B9869] w-[84%] rounded-full opacity-60"></div>
|
||||
@@ -186,7 +200,9 @@ export default function RecoveryIncidentTabs() {
|
||||
<div className="pt-6 border-t border-gray-200">
|
||||
<h3 className="text-[13px] font-bold text-gray-400 tracking-wider mb-4">NEXT RECOMMENDED ACTION</h3>
|
||||
<div className="bg-white rounded-xl p-5 mb-4 border border-gray-100 shadow-sm">
|
||||
<p className="text-sm text-gray-500 text-center">Approve the automated recovery payout of [250 EUR]. This will prevent a regulatory complaint and retain this high-value Platinum member.</p>
|
||||
<p className="text-sm text-gray-500 text-center">
|
||||
Approve automated recovery under "{incident?.evaluation?.policyName || 'Standard Policy'}".
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CustomButton
|
||||
@@ -203,7 +219,7 @@ export default function RecoveryIncidentTabs() {
|
||||
|
||||
{/* Bottom Sticky Action Bar */}
|
||||
<div className="sticky bottom-[-24px] -mx-8 px-8 py-4 bg-white/90 backdrop-blur-md border-t border-gray-100 flex justify-between items-center z-10 mt-auto shadow-[0_-10px_20px_-10px_rgba(0,0,0,0.05)]">
|
||||
<div></div> {/* Spacer */}
|
||||
<div></div>
|
||||
<div className="flex gap-4">
|
||||
<CustomButton
|
||||
variant="text"
|
||||
|
||||
@@ -2,10 +2,13 @@ import React, { useState, useRef, useEffect } from "react";
|
||||
import { CaretDownIcon, CheckIcon, MagnifyingGlassIcon } from "@phosphor-icons/react";
|
||||
import DropdownPortal from "./DropdownPortal";
|
||||
|
||||
interface Option {
|
||||
export interface Option {
|
||||
label: string;
|
||||
value: string | number;
|
||||
disabled?: boolean;
|
||||
groupHeader?: boolean;
|
||||
groupName?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface CustomDropdownProps {
|
||||
@@ -84,21 +87,32 @@ const CustomDropdown = React.forwardRef<HTMLDivElement, CustomDropdownProps>(
|
||||
}, [isOpen, searchable]);
|
||||
|
||||
const handleSelect = (option: Option) => {
|
||||
if (option.disabled) return;
|
||||
if (option.disabled || option.groupHeader) return;
|
||||
onChange?.(String(option.value));
|
||||
setIsOpen(false);
|
||||
setSearchQuery("");
|
||||
};
|
||||
|
||||
const selectedOption = value !== '' && value !== null && value !== undefined
|
||||
? options.find((opt) => String(opt.value) === String(value))
|
||||
: undefined;
|
||||
const selectedOption =
|
||||
value !== "" && value !== null && value !== undefined
|
||||
? options.find((opt) => !opt.groupHeader && String(opt.value) === String(value))
|
||||
: undefined;
|
||||
|
||||
const filteredOptions = searchable && searchQuery.trim()
|
||||
? options.filter((opt) =>
|
||||
opt.label.toLowerCase().includes(searchQuery.toLowerCase().trim())
|
||||
)
|
||||
: options;
|
||||
const filteredOptions =
|
||||
searchable && searchQuery.trim()
|
||||
? options.filter((opt, idx) => {
|
||||
const lowerQuery = searchQuery.toLowerCase().trim();
|
||||
if (opt.groupHeader) {
|
||||
if (opt.label.toLowerCase().includes(lowerQuery)) return true;
|
||||
for (let i = idx + 1; i < options.length; i++) {
|
||||
if (options[i].groupHeader) break;
|
||||
if (options[i].label.toLowerCase().includes(lowerQuery)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return opt.label.toLowerCase().includes(lowerQuery);
|
||||
})
|
||||
: options;
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col gap-1.5" ref={ref}>
|
||||
@@ -115,14 +129,14 @@ const CustomDropdown = React.forwardRef<HTMLDivElement, CustomDropdownProps>(
|
||||
className={`
|
||||
w-full rounded-lg
|
||||
bg-white
|
||||
border ${error ? 'border-red-500' : 'border-gray-300'}
|
||||
border ${error ? "border-red-500" : "border-gray-300"}
|
||||
${sizeClasses[size]}
|
||||
px-3
|
||||
outline-none
|
||||
transition-all duration-200
|
||||
flex items-center
|
||||
${!disabled ? 'cursor-pointer hover:border-primary' : 'cursor-not-allowed bg-gray-50 text-gray-500'}
|
||||
${isOpen ? 'border-primary ring-2 ring-primary/20' : ''}
|
||||
${!disabled ? "cursor-pointer hover:border-primary" : "cursor-not-allowed bg-gray-50 text-gray-500"}
|
||||
${isOpen ? "border-primary ring-2 ring-primary/20" : ""}
|
||||
${leftIcon ? "pl-10" : ""}
|
||||
pr-10
|
||||
${className}
|
||||
@@ -136,9 +150,13 @@ const CustomDropdown = React.forwardRef<HTMLDivElement, CustomDropdownProps>(
|
||||
|
||||
<div className="flex-1 truncate text-left text-[14px] font-medium tracking-[0.25px] leading-[15px]">
|
||||
{selectedOption ? (
|
||||
<span style={{ color: '#6C766D' }}>{selectedOption.label}</span>
|
||||
<span style={{ color: "#032D20" }}>
|
||||
{selectedOption.groupName
|
||||
? `${selectedOption.groupName} › ${selectedOption.label}`
|
||||
: selectedOption.label}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ color: '#6C766D' }}>{placeholder}</span>
|
||||
<span style={{ color: "#6C766D" }}>{placeholder}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -155,7 +173,7 @@ const CustomDropdown = React.forwardRef<HTMLDivElement, CustomDropdownProps>(
|
||||
anchorRef={dropdownRef}
|
||||
isOpen={isOpen && !disabled}
|
||||
ref={panelRef}
|
||||
className="bg-white border border-gray-200 rounded-lg shadow-lg overflow-hidden flex flex-col max-h-[300px]"
|
||||
className="bg-white border border-gray-200 rounded-lg shadow-lg overflow-hidden flex flex-col max-h-[320px]"
|
||||
>
|
||||
{searchable && (
|
||||
<div className="p-2 border-b border-gray-100 bg-white sticky top-0 z-10">
|
||||
@@ -174,13 +192,24 @@ const CustomDropdown = React.forwardRef<HTMLDivElement, CustomDropdownProps>(
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-y-auto flex-1 max-h-[240px]">
|
||||
<div className="overflow-y-auto flex-1 max-h-[260px]">
|
||||
{filteredOptions.length === 0 ? (
|
||||
<div className="px-4 py-3 text-sm text-gray-500 text-center">
|
||||
{searchQuery ? "No matching options" : "No options available"}
|
||||
</div>
|
||||
) : (
|
||||
filteredOptions.map((option) => {
|
||||
filteredOptions.map((option, index) => {
|
||||
if (option.groupHeader) {
|
||||
return (
|
||||
<div
|
||||
key={option.value || `group_${index}`}
|
||||
className="px-3 py-1.5 text-[11px] font-bold tracking-wider text-gray-500 uppercase bg-gray-50/90 border-y border-gray-100 sticky top-0 z-[5] select-none"
|
||||
>
|
||||
{option.label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isSelected = String(option.value) === String(value);
|
||||
return (
|
||||
<button
|
||||
@@ -192,22 +221,25 @@ const CustomDropdown = React.forwardRef<HTMLDivElement, CustomDropdownProps>(
|
||||
handleSelect(option);
|
||||
}}
|
||||
className={`
|
||||
w-full text-left px-3 min-h-[42px] text-[14px] leading-none
|
||||
transition-colors duration-150 flex items-center gap-2
|
||||
${isSelected
|
||||
? "bg-[#EEF9EF]"
|
||||
: option.disabled
|
||||
w-full text-left px-3 min-h-[38px] text-[13px] leading-none
|
||||
transition-colors duration-150 flex items-center gap-2
|
||||
${
|
||||
isSelected
|
||||
? "bg-[#EEF9EF]"
|
||||
: option.disabled
|
||||
? "text-gray-400 cursor-not-allowed"
|
||||
: "text-gray-700 hover:bg-gray-50"
|
||||
}
|
||||
`}
|
||||
`}
|
||||
>
|
||||
{isSelected && <CheckIcon size={16} className="text-primary shrink-0" strokeWidth={2.5} />}
|
||||
<span
|
||||
className={
|
||||
isSelected
|
||||
? "ml-1 font-medium bg-gradient-to-b from-[#1B9869] to-[#14704E] bg-clip-text text-transparent"
|
||||
: "ml-6"
|
||||
: option.groupName
|
||||
? "ml-4 text-gray-800 font-normal"
|
||||
: "ml-6 text-gray-800 font-normal"
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
|
||||
@@ -13,14 +13,14 @@ export interface Column<T> {
|
||||
interface CustomTableProps<T> {
|
||||
columns: Column<T>[];
|
||||
data: T[];
|
||||
|
||||
|
||||
// Header Props
|
||||
searchPlaceholder?: string;
|
||||
searchValue?: string;
|
||||
onSearchChange?: (val: string) => void;
|
||||
leftHeaderActions?: React.ReactNode;
|
||||
rightHeaderActions?: React.ReactNode;
|
||||
|
||||
|
||||
// Pagination Props
|
||||
currentPage?: number;
|
||||
totalPages?: number;
|
||||
@@ -29,7 +29,7 @@ interface CustomTableProps<T> {
|
||||
endIndex?: number;
|
||||
onPageChange?: (page: number) => void;
|
||||
itemName?: string;
|
||||
|
||||
|
||||
// Table Props
|
||||
onRowClick?: (row: T) => void;
|
||||
rowClassName?: (row: T) => string;
|
||||
@@ -53,7 +53,7 @@ export function CustomTable<T>({
|
||||
onRowClick,
|
||||
rowClassName,
|
||||
}: CustomTableProps<T>) {
|
||||
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
if (newPage >= 1 && newPage <= totalPages && onPageChange) {
|
||||
onPageChange(newPage);
|
||||
@@ -70,13 +70,13 @@ export function CustomTable<T>({
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col bg-white rounded-[14px] shadow-sm border border-gray-100 overflow-hidden">
|
||||
|
||||
|
||||
{/* Top Header Section */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-100">
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
{onSearchChange !== undefined && (
|
||||
<div className="w-80">
|
||||
<CustomInput
|
||||
<CustomInput
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchValue}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
@@ -88,7 +88,7 @@ export function CustomTable<T>({
|
||||
)}
|
||||
{leftHeaderActions}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{rightHeaderActions}
|
||||
</div>
|
||||
@@ -100,8 +100,8 @@ export function CustomTable<T>({
|
||||
<thead>
|
||||
<tr className="bg-[#F3F6F5] border-b border-[#F9FAFB]">
|
||||
{columns.map((col, index) => (
|
||||
<th
|
||||
key={index}
|
||||
<th
|
||||
key={index}
|
||||
className={`py-4 px-6 text-[13px] font-semibold text-gray-500 whitespace-nowrap ${col.className || ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -116,8 +116,8 @@ export function CustomTable<T>({
|
||||
<tbody>
|
||||
{data.length > 0 ? (
|
||||
data.map((row, rowIndex) => (
|
||||
<tr
|
||||
key={rowIndex}
|
||||
<tr
|
||||
key={rowIndex}
|
||||
onClick={() => onRowClick?.(row)}
|
||||
className={`border-b border-gray-50 hover:bg-gray-50/50 transition-colors ${onRowClick ? "cursor-pointer" : ""} ${rowClassName ? rowClassName(row) : ""}`}
|
||||
>
|
||||
@@ -146,16 +146,16 @@ export function CustomTable<T>({
|
||||
<div className="text-[13px] font-medium text-gray-500">
|
||||
Showing {totalItems > 0 ? startIndex : 0} to {endIndex} of {totalItems} {itemName}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
<button
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-lg border border-[#9FACA1] bg-white text-[#9FACA1] hover:bg-gray-50 hover:text-gray-900 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<CaretLeftIcon size={16} />
|
||||
</button>
|
||||
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{getPageNumbers().map(page => (
|
||||
<button
|
||||
@@ -173,7 +173,7 @@ export function CustomTable<T>({
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
<button
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-lg border border-[#9FACA1] bg-white text-[#9FACA1] hover:bg-gray-50 hover:text-gray-900 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
|
||||
@@ -3,10 +3,12 @@ export function formatDate(dateString?: string | Date): string {
|
||||
const date = typeof dateString === 'string' ? new Date(dateString) : dateString;
|
||||
if (isNaN(date.getTime())) return String(dateString);
|
||||
|
||||
const isISO = typeof dateString === 'string' && (dateString.includes('T') || dateString.includes('Z'));
|
||||
const day = isISO ? date.getUTCDate() : date.getDate();
|
||||
const monthIdx = isISO ? date.getUTCMonth() : date.getMonth();
|
||||
const year = isISO ? date.getUTCFullYear() : date.getFullYear();
|
||||
// Only treat as UTC if the string explicitly says so (Z or +hh:mm/-hh:mm offset)
|
||||
const isUTC = typeof dateString === 'string' && /Z$|[+-]\d{2}:?\d{2}$/.test(dateString.trim());
|
||||
|
||||
const day = isUTC ? date.getUTCDate() : date.getDate();
|
||||
const monthIdx = isUTC ? date.getUTCMonth() : date.getMonth();
|
||||
const year = isUTC ? date.getUTCFullYear() : date.getFullYear();
|
||||
|
||||
const monthNames = [
|
||||
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||
@@ -14,8 +16,8 @@ export function formatDate(dateString?: string | Date): string {
|
||||
];
|
||||
const month = monthNames[monthIdx];
|
||||
|
||||
let hours = isISO ? date.getUTCHours() : date.getHours();
|
||||
const minutes = (isISO ? date.getUTCMinutes() : date.getMinutes()).toString().padStart(2, '0');
|
||||
let hours = isUTC ? date.getUTCHours() : date.getHours();
|
||||
const minutes = (isUTC ? date.getUTCMinutes() : date.getMinutes()).toString().padStart(2, '0');
|
||||
const ampm = hours >= 12 ? 'pm' : 'am';
|
||||
hours = hours % 12;
|
||||
hours = hours ? hours : 12;
|
||||
@@ -23,4 +25,4 @@ export function formatDate(dateString?: string | Date): string {
|
||||
return `${day} ${month} ${year}, ${hours}:${minutes}${ampm}`;
|
||||
}
|
||||
|
||||
export default formatDate;
|
||||
export default formatDate;
|
||||
Reference in New Issue
Block a user