diff --git a/src/AppRoutes.tsx b/src/AppRoutes.tsx index b0fe4bd..c6abb0f 100644 --- a/src/AppRoutes.tsx +++ b/src/AppRoutes.tsx @@ -1,28 +1,34 @@ +import { lazy, Suspense } from 'react' import { Route, Routes, Navigate } from 'react-router-dom' import Layout from './layout/AppLayout' -import HomePage from './app/dashboard' -import CohortManage from './app/cohartManage' -import PolicyEngineList from './app/policyEngine/components/PolicyEngineList' -import AddPolicyEngine from './app/policyEngine/components/AddPolicyEngine' -import RecoveryIncidentsList from './app/recoveryIncidents/components/RecoveryIncidentsList' -import RecoveryIncidentTabs from './app/recoveryIncidents/tabs/index' -import AuditLogsList from './app/auditLogs/components/AuditLogsList' -import ConfigurationPage from './app/configuration' +import CustomAppLoader from './components/custom/CustomAppLoader' + +const HomePage = lazy(() => import('./app/dashboard')) +const CohortManage = lazy(() => import('./app/cohartManage')) +const PolicyEngineList = lazy(() => import('./app/policyEngine/components/PolicyEngineList')) +const AddPolicyEngine = lazy(() => import('./app/policyEngine/components/AddPolicyEngine')) +const RecoveryIncidentsList = lazy(() => import('./app/recoveryIncidents/components/RecoveryIncidentsList')) +const RecoveryIncidentTabs = lazy(() => import('./app/recoveryIncidents/tabs/index')) +const AuditLogsList = lazy(() => import('./app/auditLogs/components/AuditLogsList')) +const ConfigurationPage = lazy(() => import('./app/configuration')) + function AppRoutes() { return ( - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - + }> + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + ) } diff --git a/src/app/dashboard/DashboardApi.ts b/src/app/dashboard/DashboardApi.ts new file mode 100644 index 0000000..c5a90b8 --- /dev/null +++ b/src/app/dashboard/DashboardApi.ts @@ -0,0 +1,23 @@ +import { ApiClient } from '../api/ApiClient'; +import type { + DashboardMetric, + ExposureChartItem, + DisruptionMixItem, + RecentIncident, +} from './DashboardTypes'; + +export function getDashboardMetrics(): Promise { + return ApiClient.get('/dashboard/metrics'); +} + +export function getExposureData(): Promise { + return ApiClient.get('/dashboard/exposure'); +} + +export function getDisruptionMixData(): Promise { + return ApiClient.get('/dashboard/disruption-mix'); +} + +export function getRecentIncidents(): Promise { + return ApiClient.get('/dashboard/recent-incidents'); +} diff --git a/src/app/dashboard/DashboardTypes.ts b/src/app/dashboard/DashboardTypes.ts new file mode 100644 index 0000000..10c475d --- /dev/null +++ b/src/app/dashboard/DashboardTypes.ts @@ -0,0 +1,36 @@ +export interface DashboardMetric { + id: string; + title: string; + value: string; + trend: string; + isPositive?: boolean; + sparklineData: number[]; +} + +export interface ExposureChartItem { + category: string; + amount: number; + displayAmount: string; + color: string; + views?: string; + date?: string; + hasTooltip?: boolean; +} + +export interface DisruptionMixItem { + label: string; + percentage: number; + value: string; + color: string; +} + +export interface RecentIncident { + id: string; + recoveryId: string; + passengerName: string; + pnr: string; + flightNumber: string; + category: string; + status: string; + value: string; +} diff --git a/src/app/dashboard/components/DisruptionMixChart.tsx b/src/app/dashboard/components/DisruptionMixChart.tsx new file mode 100644 index 0000000..12e135c --- /dev/null +++ b/src/app/dashboard/components/DisruptionMixChart.tsx @@ -0,0 +1,103 @@ +import React, { useState } from "react"; +import type { DisruptionMixItem } from "../DashboardTypes"; + +interface DisruptionMixChartProps { + data: DisruptionMixItem[]; +} + +export const DisruptionMixChart: React.FC = ({ data }) => { + const [hoveredIndex, setHoveredIndex] = useState(null); + + // Calculate SVG donut stroke offsets + const size = 180; + const strokeWidth = 24; + const radius = (size - strokeWidth) / 2; + const circumference = 2 * Math.PI * radius; + + let accumulatedPercent = 0; + + return ( +
+ {/* Header */} +
+

+ Disruption Mix +

+

+ Distribution of passenger recovery cases. +

+
+ + {/* Donut Chart Visual */} +
+
+ + {data.map((item, index) => { + const strokeDasharray = `${(item.percentage / 100) * circumference} ${circumference}`; + const strokeDashoffset = -((accumulatedPercent / 100) * circumference); + accumulatedPercent += item.percentage; + + const isHovered = hoveredIndex === index; + + return ( + setHoveredIndex(index)} + onMouseLeave={() => setHoveredIndex(null)} + /> + ); + })} + + + {/* Donut Center Info */} +
+ + {hoveredIndex !== null ? `${data[hoveredIndex].percentage}%` : "100%"} + + + {hoveredIndex !== null ? data[hoveredIndex].label : "Total Cases"} + +
+
+
+ + {/* Legend Grid */} +
+ {data.map((item, index) => ( +
setHoveredIndex(index)} + onMouseLeave={() => setHoveredIndex(null)} + > +
+ + + {item.label} + +
+ + {item.percentage}% + +
+ ))} +
+
+ ); +}; + +export default DisruptionMixChart; diff --git a/src/app/dashboard/components/ExposureBarChart.tsx b/src/app/dashboard/components/ExposureBarChart.tsx new file mode 100644 index 0000000..9d9bf81 --- /dev/null +++ b/src/app/dashboard/components/ExposureBarChart.tsx @@ -0,0 +1,107 @@ +import React, { useState } from "react"; +import type { ExposureChartItem } from "../DashboardTypes"; + +interface ExposureBarChartProps { + data: ExposureChartItem[]; +} + +export const ExposureBarChart: React.FC = ({ data }) => { + const [activeBar, setActiveBar] = useState("Baggage"); + + const maxAmount = 60; + const yAxisTicks = ["$50", "$50", "$50", "$30", "$10"]; + + return ( +
+ {/* Chart Title Header */} +
+

+ Refund & Compensation Exposure +

+

+ Real-time assessment log of refund and compensation cases. +

+
+ + {/* Bar Chart Body */} +
+ {/* Horizontal Background Grid Lines */} +
+ {yAxisTicks.map((_, idx) => ( +
+ ))} +
+ + {/* Chart Content Area */} +
+ {/* Y-Axis Labels */} +
+ {yAxisTicks.map((tick, idx) => ( + + {tick} + + ))} +
+ + {/* Bars Grid */} +
+ {data.map((item) => { + const heightPercent = Math.min(100, Math.max(10, (item.amount / maxAmount) * 100)); + const isSelected = activeBar === item.category || (item.hasTooltip && !activeBar); + + return ( +
setActiveBar(item.category)} + onMouseLeave={() => setActiveBar("Baggage")} + > + {/* Tooltip Bubble (Visible for selected bar e.g. Baggage) */} + {isSelected && (item.hasTooltip || activeBar === item.category) && ( +
+
+

+ {item.views || `${item.amount} cases`} +

+

+ {item.date || "Monday, April 22nd"} +

+
+ {/* Tooltip Caret */} +
+
+ )} + + {/* Bar Element */} +
+
+ ); + })} +
+
+ + {/* X-Axis Category Labels */} +
+ {data.map((item) => ( + + {item.category} + + ))} +
+
+
+ ); +}; + +export default ExposureBarChart; diff --git a/src/app/dashboard/components/RecentIncidentsTable.tsx b/src/app/dashboard/components/RecentIncidentsTable.tsx new file mode 100644 index 0000000..17c9121 --- /dev/null +++ b/src/app/dashboard/components/RecentIncidentsTable.tsx @@ -0,0 +1,85 @@ +import React from "react"; +import type { RecentIncident } from "../DashboardTypes"; +import { CustomTable, CustomStatus } from "../../../components/custom"; +import type { Column } from "../../../components/custom/CustomTable"; + +interface RecentIncidentsTableProps { + incidents: RecentIncident[]; +} + +export const RecentIncidentsTable: React.FC = ({ incidents }) => { + const columns: Column[] = [ + { + header: "Recovery ID", + accessor: (row) => ( + + {row.recoveryId} + + ), + }, + { + header: "Passenger / PNR", + accessor: (row) => ( + + {row.passengerName} + + ), + }, + { + header: "Flight", + accessor: (row) => ( + + {row.flightNumber} + + ), + }, + { + header: "Category", + accessor: (row) => ( + + {row.category} + + ), + }, + { + header: "Status", + accessor: (row) => , + }, + { + header: "Value", + accessor: (row) => ( + + {row.value} + + ), + }, + ]; + + return ( +
+ {/* Table Header Section */} +
+

+ Recent Recovery Incidents +

+

+ Real-time assessment log of refund and compensation cases. +

+
+ + {/* Custom Table Component */} + + columns={columns} + data={incidents} + itemName="incidents" + totalItems={incidents.length} + startIndex={incidents.length > 0 ? 1 : 0} + endIndex={incidents.length} + totalPages={1} + currentPage={1} + /> +
+ ); +}; + +export default RecentIncidentsTable; diff --git a/src/app/dashboard/components/StatCard.tsx b/src/app/dashboard/components/StatCard.tsx new file mode 100644 index 0000000..204738a --- /dev/null +++ b/src/app/dashboard/components/StatCard.tsx @@ -0,0 +1,45 @@ +import React from "react"; +import type { DashboardMetric } from "../DashboardTypes"; + +export const StatCard: React.FC<{ metric: DashboardMetric }> = ({ metric }) => { + return ( +
+
+ + {metric.title} + + + {metric.value} + +
+ +
+ + {metric.trend} + + + {/* Sparkline curve */} +
+ + + +
+
+
+ ); +}; + +export default StatCard; diff --git a/src/app/dashboard/components/index.tsx b/src/app/dashboard/components/index.tsx index bc54285..fa20f6d 100644 --- a/src/app/dashboard/components/index.tsx +++ b/src/app/dashboard/components/index.tsx @@ -1,10 +1,4 @@ -function HomePage() { - return ( -
-

Home Page

-

Welcome to the home page. Use the navigation links to switch pages.

-
- ) -} - -export default HomePage +export { StatCard } from "./StatCard"; +export { ExposureBarChart } from "./ExposureBarChart"; +export { DisruptionMixChart } from "./DisruptionMixChart"; +export { RecentIncidentsTable } from "./RecentIncidentsTable"; diff --git a/src/app/dashboard/index.tsx b/src/app/dashboard/index.tsx index aa6514f..d53f19b 100644 --- a/src/app/dashboard/index.tsx +++ b/src/app/dashboard/index.tsx @@ -1,383 +1,149 @@ -import { useState } from "react"; -import CustomButton from "../../components/custom/CustomButton"; -import CustomSearchableDropdown from "../../components/custom/CustomSearchableDropdown"; -import CustomDropdown from "../../components/custom/CustomDropdown"; -import CustomMultiSelect from "../../components/custom/CustomMultiSelect"; -import CustomModal from "../../components/custom/CustomModal"; -import CustomInput from "../../components/custom/CustomInput"; -import CustomTextArea from "../../components/custom/CustomTextArea"; -import CustomStatus from "../../components/custom/CustomStatus"; -import CustomCheckBox from "../../components/custom/CustomCheckBox"; -import CustomRadio from "../../components/custom/CustomRadio"; -import CustomSwitch from "../../components/custom/CustomSwitch"; -import CustomTable, { type Column } from "../../components/custom/CustomTable"; -import CustomActionMenu, { CustomActionItem } from "../../components/custom/CustomActionMenu"; -import { FileTextIcon, CaretRightIcon, CheckIcon, XIcon, CopyIcon, PencilSimpleIcon, TrashIcon, PlusIcon } from "@phosphor-icons/react"; +import { useState, useEffect } from "react"; +import { Skeleton } from "../../components/custom"; +import { + StatCard, + ExposureBarChart, + DisruptionMixChart, + RecentIncidentsTable, +} from "./components"; +import type { + DashboardMetric, + ExposureChartItem, + DisruptionMixItem, + RecentIncident, +} from "./DashboardTypes"; +import { + getDashboardMetrics, + getExposureData, + getDisruptionMixData, + getRecentIncidents, +} from "./DashboardApi"; -function HomePage() { - const [searchValue, setSearchValue] = useState(""); - const [currentPage, setCurrentPage] = useState(1); - const [dropdownValue, setDropdownValue] = useState("active"); - const [multiSelectValue, setMultiSelectValue] = useState<(string | number)[]>(["active"]); - const [isModalOpen, setIsModalOpen] = useState(false); - const [isChecked, setIsChecked] = useState(true); - const [radioValue, setRadioValue] = useState("option1"); - const [isSwitchOn, setIsSwitchOn] = useState(true); +export default function HomePage() { + const [metrics, setMetrics] = useState([]); + const [exposureData, setExposureData] = useState([]); + const [disruptionMix, setDisruptionMix] = useState([]); + const [recentIncidents, setRecentIncidents] = useState([]); + const [loading, setLoading] = useState(true); - const tableData = [ - { - name: "Strategic Accounts", - description: "Key corporate account travelers.", - status: "active", - lastModified: "4 Jun 2026, 4:09pm", - lastModifiedBy: "John Doe", - }, - { - name: "Families with Infants", - description: "Passengers traveling with children < 2yrs.", - status: "active", - lastModified: "4 Jun 2026, 4:09pm", - lastModifiedBy: "John Doe", - }, - { - name: "Inactive Users", - description: "Users who haven't booked in 12 months.", - status: "inactive", - lastModified: "3 Jun 2026, 2:15pm", - lastModifiedBy: "Jane Smith", - }, - ]; + useEffect(() => { + async function loadDashboardData() { + try { + setLoading(true); + const [metricsRes, exposureRes, mixRes, incidentsRes] = await Promise.all([ + getDashboardMetrics(), + getExposureData(), + getDisruptionMixData(), + getRecentIncidents(), + ]); - const tableColumns: Column[] = [ - { - header: "Cohort Name", - accessor: (row) => {row.name}, - }, - { - header: "Description", - accessor: "description", - }, - { - header: "Status", - accessor: (row) => , - }, - { - header: "Last Modified", - accessor: "lastModified", - sortable: true, - }, - { - header: "Last Modified By", - accessor: "lastModifiedBy", - filterable: true, - }, - { - header: "Action", - accessor: () => ( -
- - }>Activate - }>Deactivate - }>Duplicate - }>Edit - }>Delete - + setMetrics(metricsRes); + setExposureData(exposureRes); + setDisruptionMix(mixRes); + setRecentIncidents(incidentsRes); + } catch (error) { + console.error("Error loading dashboard data:", error); + } finally { + setLoading(false); + } + } + + loadDashboardData(); + }, []); + + if (loading) { + return ( +
+ {/* Top Stat Cards Skeleton */} +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+
+ + +
+
+ + +
+
+ ))}
- ), - className: "text-center w-24", - }, - ]; - const options = [ - { label: "Draft", value: "draft" }, - { label: "Active", value: "active" }, - { label: "Inactive", value: "inactive" }, - ]; + {/* Middle Section Skeleton */} +
+
+
+ + +
+
+ + + + +
+
+ +
+
+ + +
+
+ +
+
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+
+
+ + {/* Bottom Table Skeleton */} +
+
+ + +
+
+ + {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+
+
+ ); + } return ( -
+
+ {/* 1. Top Stat Cards Row */} +
+ {metrics.map((metric) => ( + + ))} +
+ + {/* 2. Middle Section: Refund Exposure + Disruption Mix */} +
+ {/* Refund & Compensation Exposure (approx 7 cols) */} +
+ +
+ + {/* Disruption Mix (approx 4 cols) */} +
+ +
+
+ + {/* 3. Bottom Section: Recent Recovery Incidents */}
-

Dashboard (UI Test Page)

-

Test all button variants and the custom components here.

+
- -
-

Table Component

- - -
- -
- }> - Create Cohort - -
- } - /> -
- -
-

Modal Components

- -
- setIsModalOpen(true)}> - Open Test Modal - -
-
- -
-

Status Pills & Toggles

- -
-
- Status Pills: - - - - - -
- -
- Checkboxes: - setIsChecked(!isChecked)} label="Checked State" /> - {}} label="Unchecked State" /> -
- -
- Radios: - setRadioValue("option1")} label="Option 1" /> - setRadioValue("option2")} label="Option 2" /> -
- -
- Switches: - setIsSwitchOn(!isSwitchOn)} label="Toggle Feature" /> - {}} label="Off Toggle" /> -
-
-
- -
-

Button Variants

- -
- Primary - Secondary - Outlined - Text Button - Link Button -
- -
- Small - Medium - Large - Loading - Disabled -
-
- -
-

Input & TextArea Variants

- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
-
- -
-

Standard Dropdown Variants

- -
-
- -
-
- -
-
-
- -
-

Searchable Dropdown Variants

- -
-
- -
-
- -
-
- -
-
-
- -
-

Multi-Select Dropdown

- -
- -
-
- - setIsModalOpen(false)} - title="New Dynamic Cohorts" - description="Configure targeting criteria for high-precision recovery." - icon={} - primaryAction={{ - label: "Next Step", - onClick: () => setIsModalOpen(false), - icon: - }} - secondaryAction={{ - label: "Cancel", - onClick: () => setIsModalOpen(false) - }} - size="md" - > -
-
-
- 1 - Information -

Cohort Identity

-
-
- 2 - Targeting -

Targeting Criteria

-
-
-
-
- - -
-
- -
-
-
- - -
-
-
- ) + ); } - -export default HomePage diff --git a/src/app/policyEngine/PolicyEngineApi.ts b/src/app/policyEngine/PolicyEngineApi.ts index 73fbe74..340fffb 100644 --- a/src/app/policyEngine/PolicyEngineApi.ts +++ b/src/app/policyEngine/PolicyEngineApi.ts @@ -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 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, diff --git a/src/app/policyEngine/components/AddPolicyEngine.tsx b/src/app/policyEngine/components/AddPolicyEngine.tsx index 9711d29..0596614 100644 --- a/src/app/policyEngine/components/AddPolicyEngine.tsx +++ b/src/app/policyEngine/components/AddPolicyEngine.tsx @@ -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() { />
-
diff --git a/src/app/recoveryIncidents/RecoveryIncidentsApi.ts b/src/app/recoveryIncidents/RecoveryIncidentsApi.ts index 0d827fd..da8fd6d 100644 --- a/src/app/recoveryIncidents/RecoveryIncidentsApi.ts +++ b/src/app/recoveryIncidents/RecoveryIncidentsApi.ts @@ -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 { return ApiClient.get('/recovery-incidents'); @@ -53,7 +59,11 @@ export function getRecoveryIncident(id: string): Promise { return ApiClient.get(`/recovery-incidents/${id}`); } -export function createRecoveryIncident(data: Omit): Promise { +export function getIncidentAuditTrail(id: string): Promise { + return ApiClient.get(`/recovery-incidents/${id}/audit-trail`); +} + +export function createRecoveryIncident(data: Omit): Promise { return ApiClient.post('/recovery-incidents', data); } @@ -61,6 +71,10 @@ export function updateRecoveryIncident(id: string, data: Partial(`/recovery-incidents/${id}`, data); } +export function reRunPolicyEngine(id: string): Promise { + return ApiClient.post(`/recovery-incidents/${id}/evaluate`, {}); +} + export function updateIncidentStatus(id: string, status: string): Promise { return ApiClient.patch(`/recovery-incidents/${id}/status`, { status }); } diff --git a/src/app/recoveryIncidents/RecoveryIncidentsTypes.ts b/src/app/recoveryIncidents/RecoveryIncidentsTypes.ts index 22b6d4d..0c4255d 100644 --- a/src/app/recoveryIncidents/RecoveryIncidentsTypes.ts +++ b/src/app/recoveryIncidents/RecoveryIncidentsTypes.ts @@ -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"; } - diff --git a/src/app/recoveryIncidents/components/AddRecoveryIncidents.tsx b/src/app/recoveryIncidents/components/AddRecoveryIncidents.tsx index 5858fa1..610e623 100644 --- a/src/app/recoveryIncidents/components/AddRecoveryIncidents.tsx +++ b/src/app/recoveryIncidents/components/AddRecoveryIncidents.tsx @@ -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(''); + const [passengersList, setPassengersList] = useState([]); + const [selectedPassengerIds, setSelectedPassengerIds] = useState([]); + const [autoFilledNotice, setAutoFilledNotice] = useState(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 ( } 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: + icon: , }} secondaryAction={{ label: "Discard", @@ -190,69 +382,158 @@ export default function AddRecoveryIncidents({ isOpen, onClose, incident }: AddR }} >
- {/* PASSENGER IDENTITY */} + {/* SEARCH FLIGHT NUMBER / PNR CARD */}
-
- - Passenger Identity -
- handleInputChange("passengerName", e.target.value)} - /> - handleInputChange("pnr", e.target.value)} - /> - handleInputChange("loyaltyTier", val as string)} - options={loyaltyTierOptions} - /> +
+ + handleQueryChange(val as string)} + options={flightNumberOptions} + /> +
+
+ handleInputChange("date", e.target.value)} + /> +
- {/* FLIGHT CONTEXT */} + {/* AUTO FILLED NOTICE BANNER */} + {autoFilledNotice && ( +
+ + {autoFilledNotice} +
+ )} + + {/* PASSENGERS TABLE (SUPPORTING SELECT ALL & MULTI-SELECT) */} + {passengersList.length > 0 && ( +
+
+ + + Passenger Manifest Details (Selected: {selectedPassengerIds.length} of {passengersList.length}) + + +
+
+ + + + + + + + + + + + + + + + {passengersList.map((p) => { + const isSelected = selectedPassengerIds.includes(p.id); + const isDowngraded = Boolean(p.originalCabin && p.actualCabin && p.originalCabin !== p.actualCabin); + return ( + handleTogglePassengerRow(p.id)} + className={`cursor-pointer transition-colors ${isSelected + ? 'bg-emerald-50/70 border-l-4 border-l-[#1B9869]' + : 'hover:bg-gray-50/80' + }`} + > + + + + + + + + + + + ); + })} + +
+ + PNR NumberPassanger namePassanger typeBooked CabinAssigned CabinNationalityloyality typeSpecial assisstance
e.stopPropagation()}> + handleTogglePassengerRow(p.id)} + className="w-4 h-4 text-[#1B9869] rounded border-gray-300 focus:ring-[#1B9869] cursor-pointer" + /> + {p.pnr}{p.passengerName}{p.passengerType}{p.originalCabin || p.cabinClass || 'Economy'} + + {p.actualCabin || p.cabinClass || 'Economy'} + {isDowngraded && (Downgraded)} + + {p.nationality} + + {p.loyaltyTier} + + {p.specialAssistance}
+
+
+ )} + + {/* FLIGHT CONTEXT DETAILS */}
- Flight Context + Flight Context & Route
- handleInputChange("flightNumber", val as string)} - options={[ - { label: "B7687YT", value: "B7687YT" }, - { label: "Q23SXD", value: "Q23SXD" }, - { label: "AZ404", value: "AZ404" }, - ]} - /> - handleInputChange("date", e.target.value)} - /> 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" }, ]} /> 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" }, ]} />
@@ -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} /> setMetrics(data)).catch(() => {}); + getRecoveryMetrics().then((data) => setMetrics(data)).catch(() => { }); } catch (error) { console.error("Failed to update status", error); } diff --git a/src/app/recoveryIncidents/disruptionMockService.ts b/src/app/recoveryIncidents/disruptionMockService.ts new file mode 100644 index 0000000..f67429b --- /dev/null +++ b/src/app/recoveryIncidents/disruptionMockService.ts @@ -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 = { + 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); +} diff --git a/src/app/recoveryIncidents/tabs/AuditTrailTab.tsx b/src/app/recoveryIncidents/tabs/AuditTrailTab.tsx index 0ffb2ab..5e5a951 100644 --- a/src/app/recoveryIncidents/tabs/AuditTrailTab.tsx +++ b/src/app/recoveryIncidents/tabs/AuditTrailTab.tsx @@ -1,100 +1,144 @@ +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([]); + 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 = ; + + 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 (
-
- -

Lifecycle Timeline & Audit Trail

+
+
+ +

+ Lifecycle Timeline & Audit Trail ({code}) +

+
+ {loading && ( + Loading updates... + )}
- {/* Step 1: Flight Disruption Recorded */} -
-
-
- -
- -
-
-

Flight Disruption Recorded

-

Denied Boarding identified for flight Q23SXD.

-
- Assessment Point -
-
+ {displaySteps.map((step, idx) => { + const isLast = idx === displaySteps.length - 1; + const rawTime = (step as any).createdAt || step.timestamp; - {/* Step 2: Simulation Engine Executed */} -
-
-
- -
-
-

Simulation Engine Executed

-

Automated eligibility assessment performed against active frameworks.

-
- T-10m -
-
+ return ( +
+ {/* Connecting Line */} + {!isLast && ( +
+ )} - {/* Step 3: Policy Evaluated */} -
-
-
- -
-
-

Policy Evaluated

-

Pending final approval from Case Officer.

-
- T-8m -
-
+ {/* Node Icon Indicator */} +
+
{checkIcon}
+
- {/* Step 4: Status: Under Review */} -
-
-
- -
-
-

Status: Under Review

-

Tuesday, 28 May 2024

-
- Current -
-
+ {/* Step Details */} +
+
+

{step.title}

+

+ {step.description} +

+
- {/* Step 5: Policy Engine Rerun */} -
-
-
- -
-
-

Policy Engine Rerun

-

Manual re-assessment triggered. Applied: Standard Policy.

+ + {formatDate(rawTime)} + +
- Recent -
-
- - {/* Step 6: Recovery Resolution */} -
-
- -
-
-

Recovery Resolution

-

Refund and compensation settlement will initiate upon final approval.

-
- Pending -
-
- + ); + })}
diff --git a/src/app/recoveryIncidents/tabs/CaseDetailsTab.tsx b/src/app/recoveryIncidents/tabs/CaseDetailsTab.tsx index 25f8d9e..3388c34 100644 --- a/src/app/recoveryIncidents/tabs/CaseDetailsTab.tsx +++ b/src/app/recoveryIncidents/tabs/CaseDetailsTab.tsx @@ -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 (
{/* PASSENGER INFORMATION */} @@ -28,12 +34,24 @@ export default function CaseDetailsTab({ incident }: CaseDetailsTabProps) {
LOYALTY TIER - None + {incident.loyaltyTier || 'Regular'}
PASSENGER TYPE - Adult + {incident.passengerType || 'Adult'}
+ {incident.nationality && ( +
+ NATIONALITY + {incident.nationality} +
+ )} + {incident.specialAssistance && ( +
+ SPECIAL ASSISTANCE + {incident.specialAssistance} +
+ )}
@@ -55,19 +73,32 @@ export default function CaseDetailsTab({ incident }: CaseDetailsTabProps) {
CABIN - Economy + + {incident.actualCabin || incident.cabinClass || 'Economy'} +
DELAY (ARRIVAL) - -- + + {incident.delayDuration ? `${incident.delayDuration} mins` : '--'} +
ORIGINAL CABIN - Economy + + {incident.originalCabin || incident.cabinClass || 'Economy'} +
ACTUAL CABIN - Economy + + {incident.actualCabin || incident.cabinClass || 'Economy'} + {isDowngraded && ( + + Downgraded + + )} +
@@ -86,16 +117,20 @@ export default function CaseDetailsTab({ incident }: CaseDetailsTabProps) {
SCENARIO - N/A + {incident.scenario || 'Delayed Flight'}
- SUB-TYPE - None + JURISDICTION + {incident.jurisdiction || 'EU261'}
ROOT CAUSE ANALYSIS - 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.'}
diff --git a/src/app/recoveryIncidents/tabs/RecoveryPlanTab.tsx b/src/app/recoveryIncidents/tabs/RecoveryPlanTab.tsx index 4522487..950acae 100644 --- a/src/app/recoveryIncidents/tabs/RecoveryPlanTab.tsx +++ b/src/app/recoveryIncidents/tabs/RecoveryPlanTab.tsx @@ -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(); + + 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 ( +
+
+ +
+
+

No Policy Actions Evaluated

+

+ {incident?.evaluation?.aiAssessment || + 'No active policy rules in the Policy Engine matched the flight disruption parameters for this incident.'} +

+
+
+ ); + } -export default function RecoveryPlanTab() { return (
- {/* FINANCIAL REFUND */} -
-
- -

FINANCIAL REFUND

-
+ {Array.from(categoriesMap.entries()).map(([categoryName, categoryActions]) => ( +
+
+
+ +

+ {categoryName} +

+
-
-
- REFUND AMOUNT - EUR 0
-
- REFUND STATUS - Pending Approval -
-
- REFUND METHOD - Original Payment Method -
-
-
- {/* COMPENSATION & PERKS */} -
-
- -

COMPENSATION & PERKS

-
+
+ {categoryActions.map((action, idx) => { + // Strip any legacy brackets from description sentence + const cleanDescription = (action.description || '') + .replace(/\[(Inputs|Configured Inputs):\s*.*?\]/, '') + .trim(); -
-
- CASH COMPENSATION - EUR 0 -
-
- VOUCHER ALTERNATIVE - Available (120%) -
-
- LOYALTY MILES - 5,000 Points (Bonus) -
-
-
+ return ( +
+
+
+ + {action.title} + + {action.actionTypeCode && ( + + {action.actionTypeCode.replace(/_/g, ' ')} + + )} +
- {/* PASSENGER CARE */} -
-
- -

PASSENGER CARE

-
+

+ {cleanDescription || 'Action executed per policy configuration.'} +

+
-
-
- MEAL VOUCHERS - 2 x $15.00 Issued -
-
- HOTEL ACCOMMODATION - 1 Night (Pending) -
-
- GROUND TRANSPORT - Airport to City Center +
+ + {action.amount + ? `${action.currency || ''} ${action.amount.toLocaleString()}`.trim() + : 'Included / Configured'} + +
+
+ ); + })}
-
+ ))}
); } diff --git a/src/app/recoveryIncidents/tabs/SummaryTab.tsx b/src/app/recoveryIncidents/tabs/SummaryTab.tsx index a99a89a..8664d26 100644 --- a/src/app/recoveryIncidents/tabs/SummaryTab.tsx +++ b/src/app/recoveryIncidents/tabs/SummaryTab.tsx @@ -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 (
{/* AI STRATEGIC ASSESSMENT */} @@ -19,9 +30,7 @@ export default function SummaryTab() {

- "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}"

@@ -38,7 +47,7 @@ export default function SummaryTab() { Recovery Source - Simulation Engine + Policy Evaluation Engine
@@ -47,7 +56,7 @@ export default function SummaryTab() { Policy Applied - Standard Policy + {policyName}
@@ -56,7 +65,7 @@ export default function SummaryTab() { Jurisdiction - EU261 + {incident?.jurisdiction || 'N/A'}
@@ -102,14 +111,13 @@ export default function SummaryTab() {
- 75 + {recoveryScore} / 100

- Manual review recommended. Aligns with standard EU261 recovery - logic. + Automated evaluation score derived from configuration rules.

diff --git a/src/app/recoveryIncidents/tabs/index.tsx b/src/app/recoveryIncidents/tabs/index.tsx index b24fb9c..3e1a616 100644 --- a/src/app/recoveryIncidents/tabs/index.tsx +++ b/src/app/recoveryIncidents/tabs/index.tsx @@ -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: + content: }, { id: 'Case Details', @@ -71,12 +84,12 @@ export default function RecoveryIncidentTabs() { { id: 'Recovery Plan', label: 'Recovery Plan', - content: + content: }, { id: 'Audit Trail', label: 'Audit Trail', - content: + content: } ]; @@ -107,10 +120,12 @@ export default function RecoveryIncidentTabs() { } - className="!bg-[#1B9869] hover:!bg-[#14704E] !text-white !font-semibold !rounded-lg !px-5 !py-2.5" + disabled={updating} + onClick={handleReRunEngine} + leftIcon={} + 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"} @@ -150,14 +165,13 @@ export default function RecoveryIncidentTabs() { {/* Right Column - Sidebar */}
- {/* Blur Overlay */}

"Coming soon"

{/* Sidebar Content */} -
+

AI RECOMMENDATION

@@ -166,7 +180,7 @@ export default function RecoveryIncidentTabs() {
SATISFACTION PREDICT - 84% + {incident?.evaluation?.recoveryScore || 84}%
@@ -186,7 +200,9 @@ export default function RecoveryIncidentTabs() {

NEXT RECOMMENDED ACTION

-

Approve the automated recovery payout of [250 EUR]. This will prevent a regulatory complaint and retain this high-value Platinum member.

+

+ Approve automated recovery under "{incident?.evaluation?.policyName || 'Standard Policy'}". +

-
{/* Spacer */} +
{ const [currentIndex, setCurrentIndex] = useState(0); - // HRM related icons + // AeroResolve domain icons const icons = [ - { component: UsersIcon, key: "users" }, - { component: BriefcaseIcon, key: "briefcase" }, - { component: BuildingsIcon, key: "building" }, + { component: AirplaneTiltIcon, key: "flight", label: "Flight Operations" }, + { component: ArrowsClockwiseIcon, key: "recovery", label: "Recovery Incidents" }, + { component: ShieldCheckIcon, key: "policy", label: "Policy Engine" }, + { component: UsersFourIcon, key: "cohorts", label: "Passenger Cohorts" }, ]; useEffect(() => { const interval = setInterval(() => { setCurrentIndex((prev) => (prev + 1) % icons.length); - }, 800); // Slower, smoother transition + }, 700); return () => clearInterval(interval); }, [icons.length]); @@ -22,28 +28,34 @@ const CustomAppLoader = () => { const ActiveIcon = icons[currentIndex].component; return ( -
+
+ {/* Outer Glowing Pulsing Aura */} +
+ {/* Outer Spinning Ring */} -
+
{/* Icon Container */} -
+
-
-

- HRM System +
+

+ AeroResolve

- - Loading resources... + + Passenger Recovery & Resolution Platform + + + Loading {icons[currentIndex].label}...

diff --git a/src/components/custom/CustomDropdown.tsx b/src/components/custom/CustomDropdown.tsx index 3738071..5642253 100644 --- a/src/components/custom/CustomDropdown.tsx +++ b/src/components/custom/CustomDropdown.tsx @@ -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( }, [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 (
@@ -115,14 +129,14 @@ const CustomDropdown = React.forwardRef( 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(
{selectedOption ? ( - {selectedOption.label} + + {selectedOption.groupName + ? `${selectedOption.groupName} › ${selectedOption.label}` + : selectedOption.label} + ) : ( - {placeholder} + {placeholder} )}
@@ -155,7 +173,7 @@ const CustomDropdown = React.forwardRef( 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 && (
@@ -174,13 +192,24 @@ const CustomDropdown = React.forwardRef(
)} -
+
{filteredOptions.length === 0 ? (
{searchQuery ? "No matching options" : "No options available"}
) : ( - filteredOptions.map((option) => { + filteredOptions.map((option, index) => { + if (option.groupHeader) { + return ( +
+ {option.label} +
+ ); + } + const isSelected = String(option.value) === String(value); return ( - +
{getPageNumbers().map(page => (
-