feat: implement recovery incident management and simulation modules with supporting types and UI components
This commit is contained in:
+11
-18
@@ -2,24 +2,16 @@ import { lazy, Suspense } from "react";
|
|||||||
import { Route, Routes, Navigate } from "react-router-dom";
|
import { Route, Routes, Navigate } from "react-router-dom";
|
||||||
import Layout from "./layout/AppLayout";
|
import Layout from "./layout/AppLayout";
|
||||||
|
|
||||||
const HomePage = lazy(() => import("./app/dashboard"));
|
|
||||||
const CohortManage = lazy(() => import("./app/cohartManage"));
|
const HomePage = lazy(() => import('./app/dashboard'))
|
||||||
const PolicyEngineList = lazy(
|
const CohortManage = lazy(() => import('./app/cohartManage'))
|
||||||
() => import("./app/policyEngine/components/PolicyEngineList"),
|
const PolicyEngineList = lazy(() => import('./app/policyEngine/components/PolicyEngineList'))
|
||||||
);
|
const AddPolicyEngine = lazy(() => import('./app/policyEngine/components/AddPolicyEngine'))
|
||||||
const AddPolicyEngine = lazy(
|
const RecoveryIncidentsList = lazy(() => import('./app/recoveryIncidents/components/RecoveryIncidentsList'))
|
||||||
() => import("./app/policyEngine/components/AddPolicyEngine"),
|
const RecoveryIncidentTabs = lazy(() => import('./app/recoveryIncidents/tabs/index'))
|
||||||
);
|
const AuditLogsList = lazy(() => import('./app/auditLogs/components/AuditLogsList'))
|
||||||
const RecoveryIncidentsList = lazy(
|
const ConfigurationPage = lazy(() => import('./app/configuration'))
|
||||||
() => import("./app/recoveryIncidents/components/RecoveryIncidentsList"),
|
const SimulationPage = lazy(() => import('./app/simulation'))
|
||||||
);
|
|
||||||
const RecoveryIncidentTabs = lazy(
|
|
||||||
() => import("./app/recoveryIncidents/tabs/index"),
|
|
||||||
);
|
|
||||||
const AuditLogsList = lazy(
|
|
||||||
() => import("./app/auditLogs/components/AuditLogsList"),
|
|
||||||
);
|
|
||||||
const ConfigurationPage = lazy(() => import("./app/configuration"));
|
|
||||||
|
|
||||||
function AppRoutes() {
|
function AppRoutes() {
|
||||||
return (
|
return (
|
||||||
@@ -27,6 +19,7 @@ function AppRoutes() {
|
|||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<HomePage />} />
|
<Route path="/" element={<HomePage />} />
|
||||||
|
<Route path="/simulation" element={<SimulationPage />} />
|
||||||
<Route path="/cohorts" element={<CohortManage />} />
|
<Route path="/cohorts" element={<CohortManage />} />
|
||||||
<Route path="/policy-engine" element={<PolicyEngineList />} />
|
<Route path="/policy-engine" element={<PolicyEngineList />} />
|
||||||
<Route path="/policy-engine/add" element={<AddPolicyEngine />} />
|
<Route path="/policy-engine/add" element={<AddPolicyEngine />} />
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export interface RecoveryIncident {
|
|||||||
status?: string;
|
status?: string;
|
||||||
value: string;
|
value: string;
|
||||||
isPerksClaimed?: boolean;
|
isPerksClaimed?: boolean;
|
||||||
|
recoverySource?: string;
|
||||||
isGroupHeader?: boolean;
|
isGroupHeader?: boolean;
|
||||||
evaluation?: IncidentEvaluation;
|
evaluation?: IncidentEvaluation;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -336,6 +336,7 @@ export default function AddRecoveryIncidents({ isOpen, onClose, incident }: AddR
|
|||||||
jurisdiction: formData.jurisdiction || undefined,
|
jurisdiction: formData.jurisdiction || undefined,
|
||||||
delayDuration: formData.delayDuration ? Number(formData.delayDuration) : undefined,
|
delayDuration: formData.delayDuration ? Number(formData.delayDuration) : undefined,
|
||||||
scenario: formData.scenario || undefined,
|
scenario: formData.scenario || undefined,
|
||||||
|
recoverySource: existingInc ? (existingInc.recoverySource || 'Manual Disruption Entry') : 'Manual Disruption Entry',
|
||||||
};
|
};
|
||||||
|
|
||||||
if (existingInc) {
|
if (existingInc) {
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export default function RecoveryIncidentsList() {
|
|||||||
|
|
||||||
// Filters
|
// Filters
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [isGrouped, setIsGrouped] = useState(true);
|
const [isGrouped, setIsGrouped] = useState(false);
|
||||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
|
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [editingIncident, setEditingIncident] = useState<RecoveryIncident | null>(null);
|
const [editingIncident, setEditingIncident] = useState<RecoveryIncident | null>(null);
|
||||||
|
|||||||
@@ -328,7 +328,7 @@ export function getMockFlightNumbers(): { label: string; value: string; descript
|
|||||||
export function getMockDisruptionByFlightNumber(query: string): MockDisruption | undefined {
|
export function getMockDisruptionByFlightNumber(query: string): MockDisruption | undefined {
|
||||||
if (!query) return undefined;
|
if (!query) return undefined;
|
||||||
const upper = query.trim().toUpperCase();
|
const upper = query.trim().toUpperCase();
|
||||||
|
|
||||||
// 1. Direct flight number match
|
// 1. Direct flight number match
|
||||||
const flightKey = Object.keys(MOCK_DISRUPTIONS).find((k) => k.toUpperCase() === upper);
|
const flightKey = Object.keys(MOCK_DISRUPTIONS).find((k) => k.toUpperCase() === upper);
|
||||||
if (flightKey) return MOCK_DISRUPTIONS[flightKey];
|
if (flightKey) return MOCK_DISRUPTIONS[flightKey];
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export default function SummaryTab({ incident }: SummaryTabProps) {
|
|||||||
Recovery Source
|
Recovery Source
|
||||||
</span>
|
</span>
|
||||||
<span className="block text-[15px] font-semibold text-gray-900">
|
<span className="block text-[15px] font-semibold text-gray-900">
|
||||||
Policy Evaluation Engine
|
{incident?.recoverySource || 'Policy Evaluation Engine'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { ApiClient } from '../api/ApiClient';
|
||||||
|
|
||||||
|
// ─── Evaluation Engine & Simulation API ──────────────────────────────────────────
|
||||||
|
|
||||||
|
export function evaluateIncidentPolicy(input: any): Promise<any> {
|
||||||
|
return ApiClient.post<any, any>('/policy-engine/evaluate', input);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function evaluateBatchSimulation(inputs: any[]): Promise<any[]> {
|
||||||
|
return ApiClient.post<any, any[]>('/policy-engine/evaluate-batch', inputs);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
export interface SimulatedPassenger {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
pnr: string;
|
||||||
|
tier: string;
|
||||||
|
eligibility: 'ELIGIBLE' | 'INELIGIBLE' | 'REVIEW';
|
||||||
|
refund: string;
|
||||||
|
comp: string;
|
||||||
|
statusBadges: string[];
|
||||||
|
cabinClass?: string;
|
||||||
|
originalCabin?: string;
|
||||||
|
actualCabin?: string;
|
||||||
|
passengerType?: string;
|
||||||
|
nationality?: string;
|
||||||
|
specialAssistance?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SimulationFormState {
|
||||||
|
category: 'Flight' | 'Travel' | 'Ancillary';
|
||||||
|
scenario: string;
|
||||||
|
scenarioSubType: string;
|
||||||
|
flightNumber: string;
|
||||||
|
airline: string;
|
||||||
|
origin: string;
|
||||||
|
destination: string;
|
||||||
|
flightDistance: string;
|
||||||
|
arrDelay: string;
|
||||||
|
depDelay: string;
|
||||||
|
jurisdiction: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,730 @@
|
|||||||
|
import { useState, useMemo, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
MagnifyingGlassIcon,
|
||||||
|
FloppyDiskIcon,
|
||||||
|
CaretLeftIcon,
|
||||||
|
CaretRightIcon,
|
||||||
|
CheckCircleIcon,
|
||||||
|
AirplaneTiltIcon,
|
||||||
|
} from '@phosphor-icons/react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
CustomInput,
|
||||||
|
CustomDropdown,
|
||||||
|
CustomButton,
|
||||||
|
CustomTabs,
|
||||||
|
} from '../../../components/custom';
|
||||||
|
import type { TabItem } from '../../../components/custom/CustomTabs';
|
||||||
|
import type { Option } from '../../../components/custom/CustomDropdown';
|
||||||
|
import type { SimulatedPassenger, SimulationFormState } from '../SimulationTypes';
|
||||||
|
import { createRecoveryIncident } from '../../recoveryIncidents/RecoveryIncidentsApi';
|
||||||
|
import { getCategoryValues } from '../../configuration/masterData/MasterDataApi';
|
||||||
|
import { evaluateBatchSimulation } from '../SimulationApi';
|
||||||
|
|
||||||
|
const CATEGORY_TABS: TabItem[] = [
|
||||||
|
{ id: 'Flight', label: 'Flight', content: null },
|
||||||
|
{ id: 'Travel', label: 'Travel', content: null },
|
||||||
|
{ id: 'Ancillary', label: 'Ancillary', content: null },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SIMULATION_PASSENGER_POOL: Array<Omit<SimulatedPassenger, 'refund' | 'comp' | 'statusBadges' | 'eligibility'>> = [
|
||||||
|
{
|
||||||
|
id: 'p-1',
|
||||||
|
name: 'Alexander Wright',
|
||||||
|
pnr: 'PNR-A1',
|
||||||
|
tier: 'Platinum',
|
||||||
|
cabinClass: 'First Class',
|
||||||
|
originalCabin: 'First Class',
|
||||||
|
actualCabin: 'First Class',
|
||||||
|
passengerType: 'VIP Adult',
|
||||||
|
nationality: 'British',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'p-2',
|
||||||
|
name: 'Sarah Jenkins',
|
||||||
|
pnr: 'PNR-A2',
|
||||||
|
tier: 'Platinum',
|
||||||
|
cabinClass: 'First Class',
|
||||||
|
originalCabin: 'First Class',
|
||||||
|
actualCabin: 'First Class',
|
||||||
|
passengerType: 'High Value Adult',
|
||||||
|
nationality: 'British',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'p-3',
|
||||||
|
name: 'The Miller Family',
|
||||||
|
pnr: 'PNR-B3',
|
||||||
|
tier: 'Gold',
|
||||||
|
cabinClass: 'Business Class',
|
||||||
|
originalCabin: 'Business Class',
|
||||||
|
actualCabin: 'Business Class',
|
||||||
|
passengerType: 'Family Group',
|
||||||
|
nationality: 'German',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'p-4',
|
||||||
|
name: 'Marcus Chen',
|
||||||
|
pnr: 'PNR-C4',
|
||||||
|
tier: 'Silver',
|
||||||
|
cabinClass: 'Business Class',
|
||||||
|
originalCabin: 'Business Class',
|
||||||
|
actualCabin: 'Business Class',
|
||||||
|
passengerType: 'Adult',
|
||||||
|
nationality: 'American',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const SCENARIO_OPTIONS: Option[] = [
|
||||||
|
{ label: 'e.g. Passenger Compensation', value: 'e.g. Passenger Compensation' },
|
||||||
|
{ label: 'Flight Delay Disruption', value: 'Flight Delay Disruption' },
|
||||||
|
{ label: 'Flight Cancellation', value: 'Flight Cancellation' },
|
||||||
|
{ label: 'Denied Boarding / Involuntary Bumping', value: 'Denied Boarding' },
|
||||||
|
{ label: 'Missed Connection Delay', value: 'Missed Connection' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SCENARIO_SUBTYPE_OPTIONS: Option[] = [
|
||||||
|
{ label: 'e.g. Passenger Compensation', value: 'e.g. Passenger Compensation' },
|
||||||
|
{ label: 'Delay > 3 Hours (Long Haul)', value: 'Delay > 3 Hours' },
|
||||||
|
{ label: 'Delay > 4 Hours (Standard)', value: 'Delay > 4 Hours' },
|
||||||
|
{ label: 'Short Notice Cancellation (< 14 Days)', value: 'Short Notice Cancellation' },
|
||||||
|
{ label: 'Involuntary Cabin Downgrade', value: 'Involuntary Downgrade' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DISTANCE_OPTIONS: Option[] = [
|
||||||
|
{ label: '1500', value: '1500' },
|
||||||
|
{ label: '3500', value: '3500' },
|
||||||
|
{ label: '6200', value: '6200' },
|
||||||
|
{ label: '8500', value: '8500' },
|
||||||
|
{ label: '11200', value: '11200' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function SimulationTerminal() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// Form State
|
||||||
|
const [formData, setFormData] = useState<SimulationFormState>({
|
||||||
|
category: 'Flight',
|
||||||
|
scenario: 'e.g. Passenger Compensation',
|
||||||
|
scenarioSubType: 'e.g. Passenger Compensation',
|
||||||
|
flightNumber: 'LH450',
|
||||||
|
airline: 'Lufthansa',
|
||||||
|
origin: 'FRA',
|
||||||
|
destination: 'JFK',
|
||||||
|
flightDistance: '6200',
|
||||||
|
arrDelay: '240',
|
||||||
|
depDelay: '180',
|
||||||
|
jurisdiction: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [jurisdictionOptions, setJurisdictionOptions] = useState<Option[]>([]);
|
||||||
|
const [passengers, setPassengers] = useState<SimulatedPassenger[]>([]);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [isSimulating, setIsSimulating] = useState(false);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [savedSuccessCount, setSavedSuccessCount] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// Load Master Data Jurisdictions
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
getCategoryValues('jurisdiction')
|
||||||
|
.then((items) => {
|
||||||
|
if (!isMounted) return;
|
||||||
|
if (Array.isArray(items) && items.length > 0) {
|
||||||
|
const activeOptions: Option[] = items
|
||||||
|
.filter((m) => m.isActive !== false)
|
||||||
|
.map((m) => ({
|
||||||
|
label: m.label || m.name || m.value,
|
||||||
|
value: m.label || m.value || m.name,
|
||||||
|
}));
|
||||||
|
if (activeOptions.length > 0) {
|
||||||
|
setJurisdictionOptions(activeOptions);
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
jurisdiction: prev.jurisdiction || String(activeOptions[0].value),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('Failed to load master data jurisdictions:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Pagination State
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const pageSize = 4;
|
||||||
|
|
||||||
|
const handleInputChange = (field: keyof SimulationFormState, value: string) => {
|
||||||
|
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Run Simulation Handler with real Cohort & Policy Engine evaluation
|
||||||
|
const handleRunSimulation = async () => {
|
||||||
|
setIsSimulating(true);
|
||||||
|
try {
|
||||||
|
const delay = parseInt(formData.arrDelay, 10) || 240;
|
||||||
|
|
||||||
|
// Build payload for each simulated passenger in the pool
|
||||||
|
const evaluationPayloads = SIMULATION_PASSENGER_POOL.map((p) => ({
|
||||||
|
recoveryCode: `SIM-${p.id}`,
|
||||||
|
passengerName: p.name,
|
||||||
|
pnr: p.pnr,
|
||||||
|
loyaltyTier: p.tier,
|
||||||
|
passengerType: p.passengerType,
|
||||||
|
nationality: p.nationality,
|
||||||
|
cabinClass: p.cabinClass,
|
||||||
|
originalCabin: p.originalCabin,
|
||||||
|
actualCabin: p.actualCabin,
|
||||||
|
flightNumber: formData.flightNumber || 'LH450',
|
||||||
|
flightRoute: `${formData.origin || 'FRA'} → ${formData.destination || 'JFK'}`,
|
||||||
|
origin: formData.origin || 'FRA',
|
||||||
|
destination: formData.destination || 'JFK',
|
||||||
|
date: new Date().toISOString(),
|
||||||
|
category: formData.category,
|
||||||
|
scenario: formData.scenarioSubType || formData.scenario,
|
||||||
|
jurisdiction: formData.jurisdiction,
|
||||||
|
delayDuration: delay,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Call the live Policy & Cohort Engine backend
|
||||||
|
const results = await evaluateBatchSimulation(evaluationPayloads);
|
||||||
|
|
||||||
|
const simulatedPassengers: SimulatedPassenger[] = SIMULATION_PASSENGER_POOL.map((p, index) => {
|
||||||
|
const res = Array.isArray(results) ? results[index] : null;
|
||||||
|
|
||||||
|
let computedComp = '--';
|
||||||
|
let computedRefund = '--';
|
||||||
|
const badges: string[] = [];
|
||||||
|
let eligibility: 'ELIGIBLE' | 'INELIGIBLE' = 'INELIGIBLE';
|
||||||
|
|
||||||
|
if (res) {
|
||||||
|
const isMatched = res.decision === 'MATCHED';
|
||||||
|
eligibility = isMatched ? 'ELIGIBLE' : 'INELIGIBLE';
|
||||||
|
|
||||||
|
// Extract actions resolved by Policy Engine
|
||||||
|
const rawActions = Array.isArray(res.actions) ? res.actions : [];
|
||||||
|
const normalizedActions = Array.isArray(res.normalizedActions) ? res.normalizedActions : [];
|
||||||
|
|
||||||
|
// Find refund action if any
|
||||||
|
const refundAction =
|
||||||
|
rawActions.find(
|
||||||
|
(a: any) =>
|
||||||
|
(a.category && /refund/i.test(a.category)) ||
|
||||||
|
(a.actionTypeCode && /REFUND/i.test(a.actionTypeCode)) ||
|
||||||
|
(a.actionName && /refund/i.test(a.actionName))
|
||||||
|
) ||
|
||||||
|
normalizedActions.find(
|
||||||
|
(a: any) =>
|
||||||
|
(a.category && /refund/i.test(a.category)) ||
|
||||||
|
(a.actionType && /refund/i.test(a.actionType))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (refundAction) {
|
||||||
|
const refundAmt =
|
||||||
|
refundAction.amount ||
|
||||||
|
refundAction.configuration?.['Refund Amount'] ||
|
||||||
|
refundAction.configuration?.['Amount'] ||
|
||||||
|
'';
|
||||||
|
const refundCurr = refundAction.currency || refundAction.configuration?.['Currency'] || 'USD';
|
||||||
|
computedRefund = refundAmt ? `${refundAmt} ${refundCurr}`.trim() : 'Applied';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find compensation action if any
|
||||||
|
const compAction =
|
||||||
|
rawActions.find(
|
||||||
|
(a: any) =>
|
||||||
|
(a.category && /compensation|payout/i.test(a.category)) ||
|
||||||
|
(a.actionTypeCode && /COMP|PAYOUT/i.test(a.actionTypeCode)) ||
|
||||||
|
(a.actionName && /comp|cash|payout/i.test(a.actionName))
|
||||||
|
) ||
|
||||||
|
normalizedActions.find(
|
||||||
|
(a: any) =>
|
||||||
|
(a.category && /compensation|payout/i.test(a.category)) ||
|
||||||
|
(a.actionType && /comp|cash|payout/i.test(a.actionType))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (compAction) {
|
||||||
|
const amt =
|
||||||
|
compAction.amount ||
|
||||||
|
compAction.configuration?.['Compensation Amount'] ||
|
||||||
|
compAction.configuration?.['Amount'] ||
|
||||||
|
'';
|
||||||
|
const curr = compAction.currency || compAction.configuration?.['Currency'] || 'EUR';
|
||||||
|
computedComp = amt ? `${amt} ${curr}`.trim() : 'Applied';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract ONLY applied action types for STATUS badges (strictly from policy engine, no hardcoded fallbacks)
|
||||||
|
rawActions.forEach((act: any) => {
|
||||||
|
const actionName = act.actionName || act.actionTypeCode || act.category;
|
||||||
|
if (actionName && !badges.includes(actionName)) {
|
||||||
|
badges.push(actionName);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (badges.length === 0) {
|
||||||
|
normalizedActions.forEach((act: any) => {
|
||||||
|
const actionTitle = act.title || act.actionType || act.category;
|
||||||
|
if (actionTitle && !badges.includes(actionTitle)) {
|
||||||
|
badges.push(actionTitle);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
refund: computedRefund,
|
||||||
|
comp: computedComp,
|
||||||
|
statusBadges: badges,
|
||||||
|
eligibility,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
setPassengers(simulatedPassengers);
|
||||||
|
setCurrentPage(1);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to run live policy evaluation:', err);
|
||||||
|
const simulatedPassengers: SimulatedPassenger[] = SIMULATION_PASSENGER_POOL.map((p) => ({
|
||||||
|
...p,
|
||||||
|
refund: '--',
|
||||||
|
comp: '--',
|
||||||
|
statusBadges: [],
|
||||||
|
eligibility: 'INELIGIBLE',
|
||||||
|
}));
|
||||||
|
setPassengers(simulatedPassengers);
|
||||||
|
setCurrentPage(1);
|
||||||
|
} finally {
|
||||||
|
setIsSimulating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save to Recovery Database
|
||||||
|
const handleSaveAsRecovery = async () => {
|
||||||
|
try {
|
||||||
|
setIsSaving(true);
|
||||||
|
const promises = passengers.map((p) => {
|
||||||
|
const payload = {
|
||||||
|
recoveryCode: `REC-${Math.floor(100000 + Math.random() * 900000)}`,
|
||||||
|
passengerName: p.name,
|
||||||
|
pnr: p.pnr,
|
||||||
|
loyaltyTier: p.tier,
|
||||||
|
passengerType: p.passengerType,
|
||||||
|
nationality: p.nationality,
|
||||||
|
cabinClass: p.cabinClass,
|
||||||
|
originalCabin: p.originalCabin,
|
||||||
|
actualCabin: p.actualCabin,
|
||||||
|
flightNumber: formData.flightNumber || 'LH450',
|
||||||
|
flightRoute: `${formData.origin || 'FRA'} → ${formData.destination || 'JFK'}`,
|
||||||
|
origin: formData.origin || 'FRA',
|
||||||
|
destination: formData.destination || 'JFK',
|
||||||
|
date: new Date().toISOString(),
|
||||||
|
category: formData.scenario.includes('Compensation') ? 'Passenger Care' : 'Flight Ops',
|
||||||
|
scenario: formData.scenarioSubType || 'Flight Delay',
|
||||||
|
jurisdiction: formData.jurisdiction,
|
||||||
|
delayDuration: parseInt(formData.arrDelay, 10) || 240,
|
||||||
|
status: 'Pending',
|
||||||
|
value: p.comp,
|
||||||
|
isPerksClaimed: false,
|
||||||
|
recoverySource: 'Simulation Engine',
|
||||||
|
};
|
||||||
|
return createRecoveryIncident(payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(promises);
|
||||||
|
setSavedSuccessCount(passengers.length);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save simulated cases:', err);
|
||||||
|
alert('Unable to save simulation manifest. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter passengers based on search
|
||||||
|
const filteredPassengers = useMemo(() => {
|
||||||
|
if (!searchQuery.trim()) return passengers;
|
||||||
|
const q = searchQuery.toLowerCase();
|
||||||
|
return passengers.filter(
|
||||||
|
(p) =>
|
||||||
|
p.name.toLowerCase().includes(q) ||
|
||||||
|
p.pnr.toLowerCase().includes(q) ||
|
||||||
|
p.tier.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}, [passengers, searchQuery]);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(filteredPassengers.length / pageSize) || 1;
|
||||||
|
const paginatedPassengers = filteredPassengers.slice(
|
||||||
|
(currentPage - 1) * pageSize,
|
||||||
|
currentPage * pageSize
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full flex flex-col gap-6 pb-4">
|
||||||
|
{/* 2-Column Grid Layout */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 items-start">
|
||||||
|
{/* Left Column: Assessment Setup */}
|
||||||
|
<div className="lg:col-span-5 xl:col-span-5 bg-white rounded-2xl p-6 border border-gray-100/90 shadow-[0_2px_10px_-4px_rgba(0,0,0,0.04)] flex flex-col gap-5">
|
||||||
|
<h2 className="text-[17px] font-bold text-[#111827] tracking-tight">
|
||||||
|
Assessment Setup
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Segmented Category Tabs */}
|
||||||
|
<CustomTabs
|
||||||
|
tabs={CATEGORY_TABS}
|
||||||
|
value={formData.category}
|
||||||
|
onChange={(tabId) => handleInputChange('category', tabId as SimulationFormState['category'])}
|
||||||
|
tabListClassName="w-full !bg-[#F4F7F6] !p-1 !rounded-xl !border !border-gray-200/40 !shadow-none !gap-0"
|
||||||
|
tabClassName="!flex-1 !py-2 !text-xs !font-semibold !rounded-lg !px-2 !shadow-none"
|
||||||
|
activeTabClassName="!bg-[#143D30] !text-white !shadow-sm"
|
||||||
|
contentClassName="!hidden"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Form Fields using Custom Components */}
|
||||||
|
<div className="flex flex-col gap-3.5">
|
||||||
|
{/* Scenario */}
|
||||||
|
<CustomDropdown
|
||||||
|
label="Scenario"
|
||||||
|
options={SCENARIO_OPTIONS}
|
||||||
|
value={formData.scenario}
|
||||||
|
onChange={(val) => handleInputChange('scenario', val)}
|
||||||
|
searchable={false}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Scenario Sub-Type */}
|
||||||
|
<CustomDropdown
|
||||||
|
label="Scenario Sub-Type"
|
||||||
|
options={SCENARIO_SUBTYPE_OPTIONS}
|
||||||
|
value={formData.scenarioSubType}
|
||||||
|
onChange={(val) => handleInputChange('scenarioSubType', val)}
|
||||||
|
searchable={false}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Flight No & Airline */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<CustomInput
|
||||||
|
label="Flight No"
|
||||||
|
value={formData.flightNumber}
|
||||||
|
onChange={(e) => handleInputChange('flightNumber', e.target.value)}
|
||||||
|
placeholder="LH450"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<CustomInput
|
||||||
|
label="Airline"
|
||||||
|
value={formData.airline}
|
||||||
|
onChange={(e) => handleInputChange('airline', e.target.value)}
|
||||||
|
placeholder="Lufthansa"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Origin & Dest */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<CustomInput
|
||||||
|
label="Origin"
|
||||||
|
value={formData.origin}
|
||||||
|
onChange={(e) => handleInputChange('origin', e.target.value)}
|
||||||
|
placeholder="FRA"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<CustomInput
|
||||||
|
label="Dest"
|
||||||
|
value={formData.destination}
|
||||||
|
onChange={(e) => handleInputChange('destination', e.target.value)}
|
||||||
|
placeholder="JFK"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Flight Distance (KM) */}
|
||||||
|
<CustomDropdown
|
||||||
|
label="Flight Distance (KM)"
|
||||||
|
options={DISTANCE_OPTIONS}
|
||||||
|
value={formData.flightDistance}
|
||||||
|
onChange={(val) => handleInputChange('flightDistance', val)}
|
||||||
|
searchable={false}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Arr Delay (Mins) & Dep Delay */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<CustomInput
|
||||||
|
label="Arr Delay (Mins)"
|
||||||
|
type="number"
|
||||||
|
value={formData.arrDelay}
|
||||||
|
onChange={(e) => handleInputChange('arrDelay', e.target.value)}
|
||||||
|
placeholder="240"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<CustomInput
|
||||||
|
label="Dep Delay"
|
||||||
|
type="number"
|
||||||
|
value={formData.depDelay}
|
||||||
|
onChange={(e) => handleInputChange('depDelay', e.target.value)}
|
||||||
|
placeholder="180"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Jurisdiction */}
|
||||||
|
<CustomDropdown
|
||||||
|
label="Jurisdiction"
|
||||||
|
options={jurisdictionOptions}
|
||||||
|
value={formData.jurisdiction}
|
||||||
|
onChange={(val) => handleInputChange('jurisdiction', val)}
|
||||||
|
searchable={false}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Button using CustomButton */}
|
||||||
|
<div className="w-full pt-1">
|
||||||
|
<CustomButton
|
||||||
|
variant="primary"
|
||||||
|
loading={isSimulating}
|
||||||
|
onClick={handleRunSimulation}
|
||||||
|
className="w-full !rounded-xl !py-3.5 !text-[13px] !font-bold tracking-wide shadow-sm"
|
||||||
|
>
|
||||||
|
Run Manifest Simulation
|
||||||
|
</CustomButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column: Assessment Manifest */}
|
||||||
|
<div className="lg:col-span-7 xl:col-span-7 bg-white rounded-2xl p-6 border border-gray-100/90 shadow-[0_2px_10px_-4px_rgba(0,0,0,0.04)] flex flex-col justify-between min-h-[580px]">
|
||||||
|
<div className="flex flex-col gap-5 flex-1">
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-[17px] font-bold text-[#111827] tracking-tight">
|
||||||
|
Assessment Manifest
|
||||||
|
</h2>
|
||||||
|
<p className="text-[12px] font-medium text-gray-400 mt-0.5">
|
||||||
|
Real-time recovery outcomes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{passengers.length === 0 ? (
|
||||||
|
/* Clean Empty Placeholder (No Search bar, No Save as Recovery button, No Table headers) */
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center text-center p-8 border border-dashed border-gray-200 rounded-2xl bg-gray-50/40 my-auto min-h-[380px]">
|
||||||
|
<div className="w-16 h-16 rounded-2xl bg-[#1B9869]/10 text-[#1B9869] flex items-center justify-center mb-4 shadow-xs">
|
||||||
|
<AirplaneTiltIcon size={32} weight="duotone" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-[15px] font-bold text-gray-800 tracking-tight">
|
||||||
|
No Simulation Manifest Generated
|
||||||
|
</h3>
|
||||||
|
<p className="text-[13px] text-gray-500 max-w-md mt-1.5 leading-relaxed">
|
||||||
|
Configure the flight disruption parameters under <span className="font-semibold text-gray-700">Assessment Setup</span> and click <span className="font-semibold text-[#1B9869]">"Run Manifest Simulation"</span> to evaluate passenger recovery entitlements and policy rules.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Action Bar (Search & Save as Recovery) */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<CustomInput
|
||||||
|
placeholder="Search by Passenger Name..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearchQuery(e.target.value);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
|
leftIcon={<MagnifyingGlassIcon size={16} />}
|
||||||
|
size="sm"
|
||||||
|
containerClassName="!gap-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CustomButton
|
||||||
|
variant="outlined"
|
||||||
|
size="sm"
|
||||||
|
loading={isSaving}
|
||||||
|
disabled={filteredPassengers.length === 0}
|
||||||
|
leftIcon={<FloppyDiskIcon size={16} weight="bold" />}
|
||||||
|
onClick={handleSaveAsRecovery}
|
||||||
|
className="!border-[#1B9869] !text-[#1B9869] hover:!bg-[#1B9869]/5 !rounded-xl !px-4 !py-2 shrink-0 font-semibold text-xs"
|
||||||
|
>
|
||||||
|
Save as Recovery
|
||||||
|
</CustomButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Manifest Table */}
|
||||||
|
<div className="w-full overflow-x-auto mt-1">
|
||||||
|
<table className="w-full text-left border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-100">
|
||||||
|
<th className="pb-3 text-[11px] font-bold text-gray-500 uppercase tracking-wider w-[36%]">
|
||||||
|
PASSENGER
|
||||||
|
</th>
|
||||||
|
<th className="pb-3 text-[11px] font-bold text-gray-500 uppercase tracking-wider w-[18%]">
|
||||||
|
ELIGIBILITY
|
||||||
|
</th>
|
||||||
|
<th className="pb-3 text-[11px] font-bold text-gray-500 uppercase tracking-wider w-[12%]">
|
||||||
|
REFUND
|
||||||
|
</th>
|
||||||
|
<th className="pb-3 text-[11px] font-bold text-gray-500 uppercase tracking-wider w-[14%]">
|
||||||
|
COMP
|
||||||
|
</th>
|
||||||
|
<th className="pb-3 text-[11px] font-bold text-gray-500 uppercase tracking-wider">
|
||||||
|
STATUS
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100/70">
|
||||||
|
{paginatedPassengers.length > 0 ? (
|
||||||
|
paginatedPassengers.map((passenger) => (
|
||||||
|
<tr key={passenger.id} className="group hover:bg-gray-50/50 transition-colors">
|
||||||
|
{/* Passenger */}
|
||||||
|
<td className="py-4 pr-2">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-bold text-[13px] text-[#111827] leading-snug">
|
||||||
|
{passenger.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-gray-400 font-medium mt-0.5">
|
||||||
|
{passenger.pnr} • {passenger.tier}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Eligibility */}
|
||||||
|
<td className="py-4 pr-2 align-middle">
|
||||||
|
<span
|
||||||
|
className={`inline-block px-2.5 py-0.5 text-[10.5px] font-bold rounded-full tracking-wide ${passenger.eligibility === 'ELIGIBLE'
|
||||||
|
? 'bg-[#E8F8F0] text-[#1B9869] border border-[#BDEFD7]'
|
||||||
|
: 'bg-gray-100 text-gray-500 border border-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{passenger.eligibility}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Refund */}
|
||||||
|
<td className="py-4 pr-2 align-middle text-[12px] font-semibold text-gray-400">
|
||||||
|
{passenger.refund}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Comp */}
|
||||||
|
<td className="py-4 pr-2 align-middle font-bold text-[12px] text-[#111827]">
|
||||||
|
{passenger.comp}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Status Badges */}
|
||||||
|
<td className="py-4 align-middle">
|
||||||
|
{passenger.statusBadges && passenger.statusBadges.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
{passenger.statusBadges.map((badge, idx) => (
|
||||||
|
<span
|
||||||
|
key={idx}
|
||||||
|
className="bg-[#F3F4F6] text-gray-600 border border-gray-200/80 text-[11px] font-medium px-2 py-0.5 rounded-md whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{badge}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400 text-[12px] font-medium">--</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="py-10 text-center text-xs text-gray-400">
|
||||||
|
No matching passengers found in this manifest simulation.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer & Pagination */}
|
||||||
|
<div className="flex items-center justify-between pt-4 border-t border-gray-100 mt-4">
|
||||||
|
<span className="text-[12px] font-medium text-gray-400">
|
||||||
|
Showing {filteredPassengers.length > 0 ? (currentPage - 1) * pageSize + 1 : 0} to{' '}
|
||||||
|
{Math.min(currentPage * pageSize, filteredPassengers.length)} of{' '}
|
||||||
|
{filteredPassengers.length} entries
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={currentPage <= 1 || filteredPassengers.length === 0}
|
||||||
|
aria-label="Previous page"
|
||||||
|
className="p-1 rounded-lg text-gray-400 hover:text-gray-700 hover:bg-gray-100 disabled:opacity-30 disabled:hover:bg-transparent transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<CaretLeftIcon size={14} weight="bold" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className="text-[12px] font-semibold text-gray-600 px-1">
|
||||||
|
{filteredPassengers.length > 0 ? `${currentPage}/${totalPages}` : '0/0'}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={currentPage >= totalPages || filteredPassengers.length === 0}
|
||||||
|
aria-label="Next page"
|
||||||
|
className="p-1 rounded-lg text-gray-400 hover:text-gray-700 hover:bg-gray-100 disabled:opacity-30 disabled:hover:bg-transparent transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<CaretRightIcon size={14} weight="bold" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Success Modal after Saving Incidents */}
|
||||||
|
{savedSuccessCount !== null && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-xs p-4 animate-in fade-in duration-200">
|
||||||
|
<div className="bg-white rounded-2xl p-6 max-w-md w-full shadow-2xl border border-gray-100 flex flex-col items-center text-center gap-4 animate-in zoom-in-95 duration-200">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-emerald-50 text-[#1B9869] flex items-center justify-center">
|
||||||
|
<CheckCircleIcon size={30} weight="fill" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h3 className="text-lg font-bold text-gray-900">
|
||||||
|
Manifest Saved to Recovery Database
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Successfully created {savedSuccessCount} recovery incident case(s) for flight{' '}
|
||||||
|
<span className="font-semibold text-gray-800">{formData.flightNumber}</span> with
|
||||||
|
active entitlement rules.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 w-full mt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSavedSuccessCount(null)}
|
||||||
|
className="flex-1 py-2.5 border border-gray-200 text-gray-700 hover:bg-gray-50 font-semibold text-xs rounded-xl transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Continue Simulating
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSavedSuccessCount(null);
|
||||||
|
navigate('/recovery');
|
||||||
|
}}
|
||||||
|
className="flex-1 py-2.5 bg-[#1B9869] hover:bg-[#158057] text-white font-semibold text-xs rounded-xl transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
View Recovery Cases
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import SimulationTerminal from './components/SimulationTerminal';
|
||||||
|
|
||||||
|
export default function SimulationPage() {
|
||||||
|
return <SimulationTerminal />;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { useLocation } from 'react-router-dom';
|
|||||||
|
|
||||||
const PAGE_META: Record<string, { title: string; subtitle: string }> = {
|
const PAGE_META: Record<string, { title: string; subtitle: string }> = {
|
||||||
'/': { title: 'Dashboard', subtitle: 'Overview of system status and active incidents.' },
|
'/': { title: 'Dashboard', subtitle: 'Overview of system status and active incidents.' },
|
||||||
|
'/simulation': { title: 'Simulation Terminal', subtitle: 'Integrated manifest-level assessment engine.' },
|
||||||
'/cohorts': { title: 'Cohort Management', subtitle: 'Dynamic passenger segmentation for targeted recovery and recovery intelligence.' },
|
'/cohorts': { title: 'Cohort Management', subtitle: 'Dynamic passenger segmentation for targeted recovery and recovery intelligence.' },
|
||||||
'/policy-engine': { title: 'Policy Engine Framework Registry', subtitle: 'Manage framework policies, conditions, and automated actions.' },
|
'/policy-engine': { title: 'Policy Engine Framework Registry', subtitle: 'Manage framework policies, conditions, and automated actions.' },
|
||||||
'/policy-engine/add': { title: 'Deploy New Policy', subtitle: 'Configure policy framework details, targeting rules, and action payloads.' },
|
'/policy-engine/add': { title: 'Deploy New Policy', subtitle: 'Configure policy framework details, targeting rules, and action payloads.' },
|
||||||
|
|||||||
Reference in New Issue
Block a user