Merge pull request 'waseem' (#35) from waseem into development
Reviewed-on: https://gitea.maskantech.in/gitea_admin/aeroresolve_frontend/pulls/35
This commit is contained in:
+26
-20
@@ -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 (
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/cohorts" element={<CohortManage />} />
|
||||
<Route path="/policy-engine" element={<PolicyEngineList />} />
|
||||
<Route path="/policy-engine/add" element={<AddPolicyEngine />} />
|
||||
<Route path="/policy-engine/edit/:id" element={<AddPolicyEngine />} />
|
||||
<Route path="/action-builder" element={<Navigate to="/config?tab=action-builder" replace />} />
|
||||
<Route path="/config" element={<ConfigurationPage />} />
|
||||
<Route path="/recovery" element={<RecoveryIncidentsList />} />
|
||||
<Route path="/recovery/:id" element={<RecoveryIncidentTabs />} />
|
||||
<Route path="/audit-logs" element={<AuditLogsList />} />
|
||||
</Routes>
|
||||
<Suspense fallback={<CustomAppLoader />}>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/cohorts" element={<CohortManage />} />
|
||||
<Route path="/policy-engine" element={<PolicyEngineList />} />
|
||||
<Route path="/policy-engine/add" element={<AddPolicyEngine />} />
|
||||
<Route path="/policy-engine/edit/:id" element={<AddPolicyEngine />} />
|
||||
<Route path="/action-builder" element={<Navigate to="/config?tab=action-builder" replace />} />
|
||||
<Route path="/config" element={<ConfigurationPage />} />
|
||||
<Route path="/recovery" element={<RecoveryIncidentsList />} />
|
||||
<Route path="/recovery/:id" element={<RecoveryIncidentTabs />} />
|
||||
<Route path="/audit-logs" element={<AuditLogsList />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ApiClient } from '../api/ApiClient';
|
||||
import type {
|
||||
DashboardMetric,
|
||||
ExposureChartItem,
|
||||
DisruptionMixItem,
|
||||
RecentIncident,
|
||||
} from './DashboardTypes';
|
||||
|
||||
export function getDashboardMetrics(): Promise<DashboardMetric[]> {
|
||||
return ApiClient.get<any, DashboardMetric[]>('/dashboard/metrics');
|
||||
}
|
||||
|
||||
export function getExposureData(): Promise<ExposureChartItem[]> {
|
||||
return ApiClient.get<any, ExposureChartItem[]>('/dashboard/exposure');
|
||||
}
|
||||
|
||||
export function getDisruptionMixData(): Promise<DisruptionMixItem[]> {
|
||||
return ApiClient.get<any, DisruptionMixItem[]>('/dashboard/disruption-mix');
|
||||
}
|
||||
|
||||
export function getRecentIncidents(): Promise<RecentIncident[]> {
|
||||
return ApiClient.get<any, RecentIncident[]>('/dashboard/recent-incidents');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import React, { useState } from "react";
|
||||
import type { DisruptionMixItem } from "../DashboardTypes";
|
||||
|
||||
interface DisruptionMixChartProps {
|
||||
data: DisruptionMixItem[];
|
||||
}
|
||||
|
||||
export const DisruptionMixChart: React.FC<DisruptionMixChartProps> = ({ data }) => {
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(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 (
|
||||
<div className="bg-white rounded-[16px] border border-gray-100 p-6 shadow-[0_2px_4px_rgba(0,0,0,0.02)] flex flex-col justify-between h-full">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h2 className="text-[17px] font-bold text-gray-900 leading-snug">
|
||||
Disruption Mix
|
||||
</h2>
|
||||
<p className="text-[12px] text-gray-400 font-normal mt-0.5">
|
||||
Distribution of passenger recovery cases.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Donut Chart Visual */}
|
||||
<div className="flex flex-col items-center justify-center my-6 relative">
|
||||
<div className="relative w-[180px] h-[180px] flex items-center justify-center">
|
||||
<svg width={size} height={size} className="transform -rotate-90">
|
||||
{data.map((item, index) => {
|
||||
const strokeDasharray = `${(item.percentage / 100) * circumference} ${circumference}`;
|
||||
const strokeDashoffset = -((accumulatedPercent / 100) * circumference);
|
||||
accumulatedPercent += item.percentage;
|
||||
|
||||
const isHovered = hoveredIndex === index;
|
||||
|
||||
return (
|
||||
<circle
|
||||
key={item.label}
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="transparent"
|
||||
stroke={item.color}
|
||||
strokeWidth={isHovered ? strokeWidth + 4 : strokeWidth}
|
||||
strokeDasharray={strokeDasharray}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
className="transition-all duration-300 cursor-pointer"
|
||||
onMouseEnter={() => setHoveredIndex(index)}
|
||||
onMouseLeave={() => setHoveredIndex(null)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{/* Donut Center Info */}
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
|
||||
<span className="text-[22px] font-bold text-gray-900 leading-none">
|
||||
{hoveredIndex !== null ? `${data[hoveredIndex].percentage}%` : "100%"}
|
||||
</span>
|
||||
<span className="text-[11px] font-medium text-gray-400 mt-1">
|
||||
{hoveredIndex !== null ? data[hoveredIndex].label : "Total Cases"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend Grid */}
|
||||
<div className="grid grid-cols-2 gap-3 pt-2">
|
||||
{data.map((item, index) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className={`flex items-center justify-between p-2 rounded-lg transition-colors cursor-pointer ${
|
||||
hoveredIndex === index ? "bg-gray-50" : ""
|
||||
}`}
|
||||
onMouseEnter={() => setHoveredIndex(index)}
|
||||
onMouseLeave={() => setHoveredIndex(null)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
<span className="text-[12px] font-medium text-gray-700">
|
||||
{item.label}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[12px] font-bold text-gray-900">
|
||||
{item.percentage}%
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DisruptionMixChart;
|
||||
@@ -0,0 +1,107 @@
|
||||
import React, { useState } from "react";
|
||||
import type { ExposureChartItem } from "../DashboardTypes";
|
||||
|
||||
interface ExposureBarChartProps {
|
||||
data: ExposureChartItem[];
|
||||
}
|
||||
|
||||
export const ExposureBarChart: React.FC<ExposureBarChartProps> = ({ data }) => {
|
||||
const [activeBar, setActiveBar] = useState<string | null>("Baggage");
|
||||
|
||||
const maxAmount = 60;
|
||||
const yAxisTicks = ["$50", "$50", "$50", "$30", "$10"];
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-[16px] border border-gray-100 p-6 shadow-[0_2px_4px_rgba(0,0,0,0.02)] flex flex-col justify-between h-full">
|
||||
{/* Chart Title Header */}
|
||||
<div className="mb-6">
|
||||
<h2 className="text-[17px] font-bold text-gray-900 leading-snug">
|
||||
Refund & Compensation Exposure
|
||||
</h2>
|
||||
<p className="text-[12px] text-gray-400 font-normal mt-0.5">
|
||||
Real-time assessment log of refund and compensation cases.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Bar Chart Body */}
|
||||
<div className="relative w-full h-[280px] flex flex-col justify-end pt-8 pb-2">
|
||||
{/* Horizontal Background Grid Lines */}
|
||||
<div className="absolute inset-0 flex flex-col justify-between pointer-events-none pb-8 pt-8 pl-12 pr-4">
|
||||
{yAxisTicks.map((_, idx) => (
|
||||
<div key={idx} className="w-full border-b border-gray-100/80 h-0" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Chart Content Area */}
|
||||
<div className="flex w-full h-full relative z-10">
|
||||
{/* Y-Axis Labels */}
|
||||
<div className="flex flex-col justify-between pr-4 pb-8 text-[11px] font-medium text-gray-400 text-right w-12 select-none">
|
||||
{yAxisTicks.map((tick, idx) => (
|
||||
<span key={idx} className="leading-none">
|
||||
{tick}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Bars Grid */}
|
||||
<div className="flex-1 grid grid-cols-4 items-end gap-6 px-4 pb-8 h-full">
|
||||
{data.map((item) => {
|
||||
const heightPercent = Math.min(100, Math.max(10, (item.amount / maxAmount) * 100));
|
||||
const isSelected = activeBar === item.category || (item.hasTooltip && !activeBar);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.category}
|
||||
className="relative flex flex-col items-center justify-end h-full group cursor-pointer"
|
||||
onMouseEnter={() => setActiveBar(item.category)}
|
||||
onMouseLeave={() => setActiveBar("Baggage")}
|
||||
>
|
||||
{/* Tooltip Bubble (Visible for selected bar e.g. Baggage) */}
|
||||
{isSelected && (item.hasTooltip || activeBar === item.category) && (
|
||||
<div className="absolute -top-14 z-20 flex flex-col items-center animate-fadeIn">
|
||||
<div className="bg-white border border-gray-100 shadow-md rounded-[8px] px-3 py-1.5 text-center whitespace-nowrap min-w-[120px]">
|
||||
<p className="text-[11px] font-bold text-gray-800 leading-tight">
|
||||
{item.views || `${item.amount} cases`}
|
||||
</p>
|
||||
<p className="text-[10px] text-gray-400 font-medium leading-tight mt-0.5">
|
||||
{item.date || "Monday, April 22nd"}
|
||||
</p>
|
||||
</div>
|
||||
{/* Tooltip Caret */}
|
||||
<div className="w-2.5 h-2.5 bg-white border-r border-b border-gray-100 transform rotate-45 -mt-1.5 shadow-xs" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bar Element */}
|
||||
<div
|
||||
className="w-full max-w-[110px] rounded-t-[4px] transition-all duration-300 relative group-hover:brightness-105"
|
||||
style={{
|
||||
height: `${heightPercent}%`,
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* X-Axis Category Labels */}
|
||||
<div className="grid grid-cols-4 pl-12 pr-4 text-center">
|
||||
{data.map((item) => (
|
||||
<span
|
||||
key={item.category}
|
||||
className={`text-[12px] font-medium transition-colors ${
|
||||
activeBar === item.category ? "text-gray-900 font-semibold" : "text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{item.category}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExposureBarChart;
|
||||
@@ -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<RecentIncidentsTableProps> = ({ incidents }) => {
|
||||
const columns: Column<RecentIncident>[] = [
|
||||
{
|
||||
header: "Recovery ID",
|
||||
accessor: (row) => (
|
||||
<span className="text-[13px] font-bold text-gray-900 font-mono">
|
||||
{row.recoveryId}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Passenger / PNR",
|
||||
accessor: (row) => (
|
||||
<span className="text-[13px] font-bold text-gray-900">
|
||||
{row.passengerName}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Flight",
|
||||
accessor: (row) => (
|
||||
<span className="text-[13px] font-bold text-gray-900">
|
||||
{row.flightNumber}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Category",
|
||||
accessor: (row) => (
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-[11px] font-semibold bg-[#EFEFEF] text-gray-600">
|
||||
{row.category}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Status",
|
||||
accessor: (row) => <CustomStatus status={row.status} />,
|
||||
},
|
||||
{
|
||||
header: "Value",
|
||||
accessor: (row) => (
|
||||
<span className="text-[13px] font-bold text-gray-900">
|
||||
{row.value}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-[16px] border border-gray-100 p-6 shadow-[0_2px_4px_rgba(0,0,0,0.02)] flex flex-col gap-5">
|
||||
{/* Table Header Section */}
|
||||
<div>
|
||||
<h2 className="text-[17px] font-bold text-gray-900 leading-snug">
|
||||
Recent Recovery Incidents
|
||||
</h2>
|
||||
<p className="text-[12px] text-gray-400 font-normal mt-0.5">
|
||||
Real-time assessment log of refund and compensation cases.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Custom Table Component */}
|
||||
<CustomTable<RecentIncident>
|
||||
columns={columns}
|
||||
data={incidents}
|
||||
itemName="incidents"
|
||||
totalItems={incidents.length}
|
||||
startIndex={incidents.length > 0 ? 1 : 0}
|
||||
endIndex={incidents.length}
|
||||
totalPages={1}
|
||||
currentPage={1}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecentIncidentsTable;
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from "react";
|
||||
import type { DashboardMetric } from "../DashboardTypes";
|
||||
|
||||
export const StatCard: React.FC<{ metric: DashboardMetric }> = ({ metric }) => {
|
||||
return (
|
||||
<div className="bg-white rounded-[14px] p-4 px-5 border border-gray-100/80 shadow-[0_2px_4px_rgba(0,0,0,0.02)] flex flex-col justify-between flex-1 min-w-[170px] hover:shadow-md transition-all duration-200">
|
||||
<div>
|
||||
<span className="text-[13px] font-medium text-gray-600 block">
|
||||
{metric.title}
|
||||
</span>
|
||||
<span className="text-[26px] font-bold text-gray-900 leading-tight tracking-tight mt-1.5 block">
|
||||
{metric.value}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mt-3 pt-1">
|
||||
<span className="text-[12px] font-bold text-[#1B9869] bg-[#E4FAE7]/60 px-2 py-0.5 rounded-full inline-flex items-center">
|
||||
{metric.trend}
|
||||
</span>
|
||||
|
||||
{/* Sparkline curve */}
|
||||
<div className="w-[60px] h-[26px] flex items-center justify-end">
|
||||
<svg
|
||||
width="60"
|
||||
height="26"
|
||||
viewBox="0 0 60 26"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="overflow-visible"
|
||||
>
|
||||
<path
|
||||
d="M 2 20 Q 12 22, 20 15 T 40 18 T 58 6"
|
||||
stroke="#1B9869"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatCard;
|
||||
@@ -1,10 +1,4 @@
|
||||
function HomePage() {
|
||||
return (
|
||||
<section className="page home-page">
|
||||
<h1>Home Page</h1>
|
||||
<p>Welcome to the home page. Use the navigation links to switch pages.</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default HomePage
|
||||
export { StatCard } from "./StatCard";
|
||||
export { ExposureBarChart } from "./ExposureBarChart";
|
||||
export { DisruptionMixChart } from "./DisruptionMixChart";
|
||||
export { RecentIncidentsTable } from "./RecentIncidentsTable";
|
||||
|
||||
+138
-372
@@ -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<DashboardMetric[]>([]);
|
||||
const [exposureData, setExposureData] = useState<ExposureChartItem[]>([]);
|
||||
const [disruptionMix, setDisruptionMix] = useState<DisruptionMixItem[]>([]);
|
||||
const [recentIncidents, setRecentIncidents] = useState<RecentIncident[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(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<typeof tableData[0]>[] = [
|
||||
{
|
||||
header: "Cohort Name",
|
||||
accessor: (row) => <span className="font-bold text-gray-900">{row.name}</span>,
|
||||
},
|
||||
{
|
||||
header: "Description",
|
||||
accessor: "description",
|
||||
},
|
||||
{
|
||||
header: "Status",
|
||||
accessor: (row) => <CustomStatus status={row.status} />,
|
||||
},
|
||||
{
|
||||
header: "Last Modified",
|
||||
accessor: "lastModified",
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
header: "Last Modified By",
|
||||
accessor: "lastModifiedBy",
|
||||
filterable: true,
|
||||
},
|
||||
{
|
||||
header: "Action",
|
||||
accessor: () => (
|
||||
<div className="flex justify-center w-full">
|
||||
<CustomActionMenu>
|
||||
<CustomActionItem variant="success" icon={<CheckIcon size={16} />}>Activate</CustomActionItem>
|
||||
<CustomActionItem icon={<XIcon size={16} />}>Deactivate</CustomActionItem>
|
||||
<CustomActionItem icon={<CopyIcon size={16} />}>Duplicate</CustomActionItem>
|
||||
<CustomActionItem icon={<PencilSimpleIcon size={16} />}>Edit</CustomActionItem>
|
||||
<CustomActionItem variant="danger" icon={<TrashIcon size={16} />}>Delete</CustomActionItem>
|
||||
</CustomActionMenu>
|
||||
setMetrics(metricsRes);
|
||||
setExposureData(exposureRes);
|
||||
setDisruptionMix(mixRes);
|
||||
setRecentIncidents(incidentsRes);
|
||||
} catch (error) {
|
||||
console.error("Error loading dashboard data:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadDashboardData();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div>
|
||||
{/* Top Stat Cards Skeleton */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="bg-white rounded-[14px] p-5 border border-gray-100/80 flex flex-col justify-between h-[100px]">
|
||||
<div className="space-y-2">
|
||||
<Skeleton width="60%" height={16} />
|
||||
<Skeleton width="40%" height={28} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<Skeleton width={45} height={18} />
|
||||
<Skeleton width={50} height={20} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
className: "text-center w-24",
|
||||
},
|
||||
];
|
||||
|
||||
const options = [
|
||||
{ label: "Draft", value: "draft" },
|
||||
{ label: "Active", value: "active" },
|
||||
{ label: "Inactive", value: "inactive" },
|
||||
];
|
||||
{/* Middle Section Skeleton */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 items-stretch">
|
||||
<div className="lg:col-span-8 bg-white rounded-[16px] border border-gray-100 p-6 flex flex-col justify-between h-[360px]">
|
||||
<div className="space-y-2">
|
||||
<Skeleton width="40%" height={22} />
|
||||
<Skeleton width="60%" height={14} />
|
||||
</div>
|
||||
<div className="flex items-end gap-6 h-[240px] pt-8">
|
||||
<Skeleton className="flex-1" height="60%" />
|
||||
<Skeleton className="flex-1" height="90%" />
|
||||
<Skeleton className="flex-1" height="40%" />
|
||||
<Skeleton className="flex-1" height="85%" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-4 bg-white rounded-[16px] border border-gray-100 p-6 flex flex-col justify-between h-[360px]">
|
||||
<div className="space-y-2">
|
||||
<Skeleton width="50%" height={22} />
|
||||
<Skeleton width="70%" height={14} />
|
||||
</div>
|
||||
<div className="flex items-center justify-center my-4">
|
||||
<Skeleton variant="circular" width={160} height={160} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} height={24} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Table Skeleton */}
|
||||
<div className="bg-white rounded-[16px] border border-gray-100 p-6 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Skeleton width="30%" height={22} />
|
||||
<Skeleton width="50%" height={14} />
|
||||
</div>
|
||||
<div className="space-y-3 pt-2">
|
||||
<Skeleton height={40} />
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} height={48} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-5xl mx-auto flex flex-col gap-8">
|
||||
<div className="p-4 md:p-6 lg:p-4 space-y-6">
|
||||
{/* 1. Top Stat Cards Row */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||
{metrics.map((metric) => (
|
||||
<StatCard key={metric.id} metric={metric} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 2. Middle Section: Refund Exposure + Disruption Mix */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 items-stretch">
|
||||
{/* Refund & Compensation Exposure (approx 7 cols) */}
|
||||
<div className="lg:col-span-8 flex flex-col">
|
||||
<ExposureBarChart data={exposureData} />
|
||||
</div>
|
||||
|
||||
{/* Disruption Mix (approx 4 cols) */}
|
||||
<div className="lg:col-span-4 flex flex-col">
|
||||
<DisruptionMixChart data={disruptionMix} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3. Bottom Section: Recent Recovery Incidents */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-2 text-gray-900">Dashboard (UI Test Page)</h1>
|
||||
<p className="text-gray-500">Test all button variants and the custom components here.</p>
|
||||
<RecentIncidentsTable incidents={recentIncidents} />
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-8 rounded-[20px] border border-gray-100 shadow-sm flex flex-col gap-6">
|
||||
<h2 className="text-[15px] font-bold text-gray-900 border-b border-gray-100 pb-3">Table Component</h2>
|
||||
|
||||
<CustomTable
|
||||
columns={tableColumns}
|
||||
data={tableData}
|
||||
searchValue={searchValue}
|
||||
onSearchChange={setSearchValue}
|
||||
searchPlaceholder="Search cohorts..."
|
||||
currentPage={currentPage}
|
||||
totalPages={3}
|
||||
totalItems={12}
|
||||
startIndex={1}
|
||||
endIndex={3}
|
||||
onPageChange={setCurrentPage}
|
||||
itemName="cohorts"
|
||||
rightHeaderActions={
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-40">
|
||||
<CustomDropdown
|
||||
options={options}
|
||||
value="active"
|
||||
placeholder="Choose Status"
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
<CustomButton variant="primary" size="md" leftIcon={<PlusIcon size={16} />}>
|
||||
Create Cohort
|
||||
</CustomButton>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-8 rounded-[20px] border border-gray-100 shadow-sm flex flex-col gap-6">
|
||||
<h2 className="text-[15px] font-bold text-gray-900 border-b border-gray-100 pb-3">Modal Components</h2>
|
||||
|
||||
<div className="flex flex-wrap gap-4 items-center">
|
||||
<CustomButton variant="primary" onClick={() => setIsModalOpen(true)}>
|
||||
Open Test Modal
|
||||
</CustomButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-8 rounded-[20px] border border-gray-100 shadow-sm flex flex-col gap-6">
|
||||
<h2 className="text-[15px] font-bold text-gray-900 border-b border-gray-100 pb-3">Status Pills & Toggles</h2>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-wrap gap-4 items-center">
|
||||
<span className="text-sm font-semibold w-24">Status Pills:</span>
|
||||
<CustomStatus status="active" />
|
||||
<CustomStatus status="inactive" />
|
||||
<CustomStatus status="pending" />
|
||||
<CustomStatus status="shipped" />
|
||||
<CustomStatus status="neutral" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-4 items-center">
|
||||
<span className="text-sm font-semibold w-24">Checkboxes:</span>
|
||||
<CustomCheckBox checked={isChecked} onChange={() => setIsChecked(!isChecked)} label="Checked State" />
|
||||
<CustomCheckBox checked={false} onChange={() => {}} label="Unchecked State" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-4 items-center">
|
||||
<span className="text-sm font-semibold w-24">Radios:</span>
|
||||
<CustomRadio checked={radioValue === "option1"} onChange={() => setRadioValue("option1")} label="Option 1" />
|
||||
<CustomRadio checked={radioValue === "option2"} onChange={() => setRadioValue("option2")} label="Option 2" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-4 items-center">
|
||||
<span className="text-sm font-semibold w-24">Switches:</span>
|
||||
<CustomSwitch checked={isSwitchOn} onChange={() => setIsSwitchOn(!isSwitchOn)} label="Toggle Feature" />
|
||||
<CustomSwitch checked={false} onChange={() => {}} label="Off Toggle" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-8 rounded-[20px] border border-gray-100 shadow-sm flex flex-col gap-6">
|
||||
<h2 className="text-[15px] font-bold text-gray-900 border-b border-gray-100 pb-3">Button Variants</h2>
|
||||
|
||||
<div className="flex flex-wrap gap-4 items-center">
|
||||
<CustomButton variant="primary">Primary</CustomButton>
|
||||
<CustomButton variant="secondary">Secondary</CustomButton>
|
||||
<CustomButton variant="outlined">Outlined</CustomButton>
|
||||
<CustomButton variant="text">Text Button</CustomButton>
|
||||
<CustomButton variant="link">Link Button</CustomButton>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-4 items-center mt-2">
|
||||
<CustomButton variant="primary" size="sm">Small</CustomButton>
|
||||
<CustomButton variant="primary" size="md">Medium</CustomButton>
|
||||
<CustomButton variant="primary" size="lg">Large</CustomButton>
|
||||
<CustomButton variant="primary" loading>Loading</CustomButton>
|
||||
<CustomButton variant="primary" disabled>Disabled</CustomButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-8 rounded-[20px] border border-gray-100 shadow-sm flex flex-col gap-6">
|
||||
<h2 className="text-[15px] font-bold text-gray-900 border-b border-gray-100 pb-3">Input & TextArea Variants</h2>
|
||||
|
||||
<div className="flex flex-wrap gap-6 items-end">
|
||||
<div className="w-48">
|
||||
<CustomInput
|
||||
label="Small (sm)"
|
||||
size="sm"
|
||||
placeholder="Enter text..."
|
||||
/>
|
||||
</div>
|
||||
<div className="w-56">
|
||||
<CustomInput
|
||||
label="Medium (md)"
|
||||
size="md"
|
||||
placeholder="Enter text..."
|
||||
/>
|
||||
</div>
|
||||
<div className="w-64">
|
||||
<CustomInput
|
||||
label="Large (lg)"
|
||||
size="lg"
|
||||
placeholder="Enter text..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-6 items-end mt-4">
|
||||
<div className="w-48">
|
||||
<CustomTextArea
|
||||
label="Small (sm)"
|
||||
size="sm"
|
||||
placeholder="Enter text..."
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-56">
|
||||
<CustomTextArea
|
||||
label="Medium (md)"
|
||||
size="md"
|
||||
placeholder="Enter text..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-64">
|
||||
<CustomTextArea
|
||||
label="Large (lg)"
|
||||
size="lg"
|
||||
placeholder="Enter text..."
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-8 rounded-[20px] border border-gray-100 shadow-sm flex flex-col gap-6">
|
||||
<h2 className="text-[15px] font-bold text-gray-900 border-b border-gray-100 pb-3">Standard Dropdown Variants</h2>
|
||||
|
||||
<div className="flex flex-wrap gap-6 items-end">
|
||||
<div className="w-48">
|
||||
<CustomDropdown
|
||||
label="Small (sm)"
|
||||
size="sm"
|
||||
options={options}
|
||||
value={dropdownValue}
|
||||
onChange={setDropdownValue}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-56">
|
||||
<CustomDropdown
|
||||
label="Medium (md)"
|
||||
size="md"
|
||||
options={options}
|
||||
value={dropdownValue}
|
||||
onChange={setDropdownValue}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-8 rounded-[20px] border border-gray-100 shadow-sm flex flex-col gap-6">
|
||||
<h2 className="text-[15px] font-bold text-gray-900 border-b border-gray-100 pb-3">Searchable Dropdown Variants</h2>
|
||||
|
||||
<div className="flex flex-wrap gap-6 items-end">
|
||||
<div className="w-48">
|
||||
<CustomSearchableDropdown
|
||||
label="Small (sm)"
|
||||
size="sm"
|
||||
options={options}
|
||||
value={dropdownValue}
|
||||
onChange={setDropdownValue}
|
||||
placeholder="Small size..."
|
||||
/>
|
||||
</div>
|
||||
<div className="w-56">
|
||||
<CustomSearchableDropdown
|
||||
label="Medium (md)"
|
||||
size="md"
|
||||
options={options}
|
||||
value={dropdownValue}
|
||||
onChange={setDropdownValue}
|
||||
placeholder="Medium size..."
|
||||
/>
|
||||
</div>
|
||||
<div className="w-64">
|
||||
<CustomSearchableDropdown
|
||||
label="Large (lg)"
|
||||
size="lg"
|
||||
options={options}
|
||||
value={dropdownValue}
|
||||
onChange={setDropdownValue}
|
||||
placeholder="Large size..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-8 rounded-[20px] border border-gray-100 shadow-sm flex flex-col gap-6 mb-32">
|
||||
<h2 className="text-[15px] font-bold text-gray-900 border-b border-gray-100 pb-3">Multi-Select Dropdown</h2>
|
||||
|
||||
<div className="w-[400px]">
|
||||
<CustomMultiSelect
|
||||
label="Select Multiple Statuses"
|
||||
options={options}
|
||||
value={multiSelectValue}
|
||||
onChange={setMultiSelectValue}
|
||||
placeholder="Select statuses..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CustomModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
title="New Dynamic Cohorts"
|
||||
description="Configure targeting criteria for high-precision recovery."
|
||||
icon={<FileTextIcon size={24} />}
|
||||
primaryAction={{
|
||||
label: "Next Step",
|
||||
onClick: () => setIsModalOpen(false),
|
||||
icon: <CaretRightIcon size={18} />
|
||||
}}
|
||||
secondaryAction={{
|
||||
label: "Cancel",
|
||||
onClick: () => setIsModalOpen(false)
|
||||
}}
|
||||
size="md"
|
||||
>
|
||||
<div className="flex flex-col gap-6 h-64">
|
||||
<div className="flex justify-between border-b border-gray-100 pb-2">
|
||||
<div>
|
||||
<span className="text-[10px] font-bold text-[#1B9869] tracking-wider uppercase">1 - Information</span>
|
||||
<h3 className="text-sm font-bold text-[#1B9869]">Cohort Identity</h3>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-[10px] font-bold text-gray-400 tracking-wider uppercase">2 - Targeting</span>
|
||||
<h3 className="text-sm font-bold text-gray-400">Targeting Criteria</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="w-full">
|
||||
<label className="text-xs font-bold text-gray-700 mb-1 block">Cohort Name</label>
|
||||
<input type="text" placeholder="Enter" className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-[#1B9869]" />
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<CustomDropdown
|
||||
label="Status"
|
||||
options={[{label: "Selected Option", value: "selected"}]}
|
||||
value="selected"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<label className="text-xs font-bold text-gray-700 mb-1 block">Description</label>
|
||||
<textarea placeholder="Enter" className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm h-24 focus:outline-none focus:border-[#1B9869]"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</CustomModal>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default HomePage
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { UsersIcon, BriefcaseIcon, BuildingsIcon } from "@phosphor-icons/react";
|
||||
import {
|
||||
AirplaneTiltIcon,
|
||||
ShieldCheckIcon,
|
||||
ArrowsClockwiseIcon,
|
||||
UsersFourIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
|
||||
const CustomAppLoader = () => {
|
||||
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 (
|
||||
<div className="flex flex-col items-center justify-center space-y-6 h-screen w-full ">
|
||||
<div className="flex flex-col items-center justify-center space-y-6 min-h-[400px] h-screen w-full bg-[#F8FAFC] font-sans">
|
||||
<div className="relative flex items-center justify-center">
|
||||
{/* Outer Glowing Pulsing Aura */}
|
||||
<div className="absolute w-24 h-24 bg-[#1B9869]/10 rounded-full animate-ping pointer-events-none" />
|
||||
|
||||
{/* Outer Spinning Ring */}
|
||||
<div className="w-20 h-20 border-4 border-gray-200 border-t-[#0B3B6A] rounded-full animate-spin"></div>
|
||||
<div className="w-20 h-20 border-4 border-gray-200/80 border-t-[#1B9869] border-r-[#1B9869]/40 rounded-full animate-spin" />
|
||||
|
||||
{/* Icon Container */}
|
||||
<div className="absolute flex items-center justify-center">
|
||||
<div className="absolute flex items-center justify-center w-12 h-12 bg-white rounded-full shadow-sm border border-gray-100">
|
||||
<ActiveIcon
|
||||
key={icons[currentIndex].key}
|
||||
size={32}
|
||||
className="text-[#0B3B6A] transition-all duration-500 opacity-100"
|
||||
strokeWidth={1.5}
|
||||
size={28}
|
||||
className="text-[#1B9869] transition-all duration-300 transform scale-100"
|
||||
weight="bold"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<h3 className="text-[#0B3B6A] font-semibold text-lg tracking-wide">
|
||||
HRM System
|
||||
<div className="flex flex-col items-center gap-1.5 text-center">
|
||||
<h3 className="text-gray-900 font-bold text-xl tracking-tight flex items-center gap-2">
|
||||
Aero<span className="text-[#1B9869]">Resolve</span>
|
||||
</h3>
|
||||
<span className="text-gray-500 text-sm animate-pulse">
|
||||
Loading resources...
|
||||
<span className="text-gray-500 text-xs font-semibold uppercase tracking-wider">
|
||||
Passenger Recovery & Resolution Platform
|
||||
</span>
|
||||
<span className="text-[13px] font-medium text-[#1B9869] animate-pulse mt-2">
|
||||
Loading {icons[currentIndex].label}...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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