Merge pull request 'development' (#34) from development into test

Reviewed-on: https://gitea.maskantech.in/gitea_admin/aeroresolve_frontend/pulls/34
This commit is contained in:
Syed Waseem khadri Rafai
2026-08-12 11:11:18 +00:00
55 changed files with 6894 additions and 589 deletions
+4 -1
View File
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 608 B

+11 -2
View File
@@ -1,10 +1,13 @@
import { Route, Routes } from 'react-router-dom'
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'
function AppRoutes() {
return (
<Layout>
@@ -13,6 +16,12 @@ function AppRoutes() {
<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>
</Layout>
)
+1
View File
@@ -7,6 +7,7 @@ export const ApiClient = axios.create({
baseURL: API_BASE,
headers: {
'Content-Type': 'application/json',
'X-Tenant-Id': 'demo-airline',
},
});
+19
View File
@@ -0,0 +1,19 @@
import { ApiClient } from '../api/ApiClient';
import type { AuditLog, AuditLogListResponse, AuditLogFilters } from './AuditLogsTypes';
export function getAuditLogs(filters: AuditLogFilters = {}): Promise<AuditLogListResponse> {
const params = new URLSearchParams();
if (filters.page) params.set('page', String(filters.page));
if (filters.limit) params.set('limit', String(filters.limit));
if (filters.module) params.set('module', filters.module);
if (filters.action) params.set('action', filters.action);
if (filters.entityId) params.set('entityId', filters.entityId);
if (filters.dateFrom) params.set('dateFrom', filters.dateFrom);
if (filters.dateTo) params.set('dateTo', filters.dateTo);
return ApiClient.get<any, AuditLogListResponse>(`/audit-logs?${params.toString()}`);
}
export function getAuditLog(id: string): Promise<AuditLog> {
return ApiClient.get<any, AuditLog>(`/audit-logs/${id}`);
}
+46
View File
@@ -0,0 +1,46 @@
export interface AuditLog {
id: string;
tenantId: string;
module: string; // 'policy-engine' | 'recovery-incident' | 'cohort' | 'master-data'
action: string; // 'CREATE' | 'UPDATE' | 'DELETE' | 'STATUS_CHANGE'
entityId?: string;
entityLabel?: string;
before?: Record<string, any>;
after?: Record<string, any>;
performedBy?: string;
ipAddress?: string;
userAgent?: string;
createdAt: string;
}
export interface AuditLogListResponse {
data: AuditLog[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface AuditLogFilters {
page?: number;
limit?: number;
module?: string;
action?: string;
entityId?: string;
dateFrom?: string;
dateTo?: string;
}
export const AUDIT_MODULE_LABELS: Record<string, string> = {
'policy-engine': 'Policy Engine',
'recovery-incident': 'Recovery Incident',
'cohort': 'Cohort',
'master-data': 'Master Data',
};
export const AUDIT_ACTION_VARIANTS: Record<string, 'success' | 'error' | 'warning' | 'info' | 'neutral'> = {
CREATE: 'success',
UPDATE: 'info',
DELETE: 'error',
STATUS_CHANGE: 'warning',
};
@@ -0,0 +1,173 @@
import { X, ClockCounterClockwise } from "@phosphor-icons/react";
import type { AuditLog } from "../AuditLogsTypes";
import { AUDIT_MODULE_LABELS, AUDIT_ACTION_VARIANTS } from "../AuditLogsTypes";
import { CustomStatus } from "../../../components/custom";
interface AuditLogDetailProps {
log: AuditLog | null;
onClose: () => void;
}
function JsonDiffRow({
label,
before,
after,
}: {
label: string;
before: any;
after: any;
}) {
const changed =
JSON.stringify(before) !== JSON.stringify(after);
return (
<tr className={changed ? "bg-amber-50" : ""}>
<td className="py-2 px-3 text-[12px] font-semibold text-gray-500 w-[140px] align-top border-b border-gray-100">
{label}
</td>
<td className="py-2 px-3 text-[12px] text-red-500 align-top border-b border-gray-100 w-1/2 font-mono break-all">
{before !== undefined ? JSON.stringify(before) : <span className="text-gray-300 italic"></span>}
</td>
<td className="py-2 px-3 text-[12px] text-[#1B9869] align-top border-b border-gray-100 w-1/2 font-mono break-all">
{after !== undefined ? JSON.stringify(after) : <span className="text-gray-300 italic"></span>}
</td>
</tr>
);
}
function DiffTable({ before, after }: { before?: Record<string, any>; after?: Record<string, any> }) {
const allKeys = Array.from(
new Set([
...Object.keys(before ?? {}),
...Object.keys(after ?? {}),
])
).filter((k) => !["tenantId", "createdAt", "updatedAt"].includes(k));
if (allKeys.length === 0) {
return <p className="text-[13px] text-gray-400 italic mt-2">No data to display.</p>;
}
return (
<table className="w-full border-collapse mt-3">
<thead>
<tr className="bg-gray-50">
<th className="py-2 px-3 text-left text-[11px] font-bold text-gray-400 uppercase tracking-wider w-[140px]">Field</th>
<th className="py-2 px-3 text-left text-[11px] font-bold text-red-400 uppercase tracking-wider w-1/2">Before</th>
<th className="py-2 px-3 text-left text-[11px] font-bold text-[#1B9869] uppercase tracking-wider w-1/2">After</th>
</tr>
</thead>
<tbody>
{allKeys.map((key) => (
<JsonDiffRow
key={key}
label={key}
before={(before ?? {})[key]}
after={(after ?? {})[key]}
/>
))}
</tbody>
</table>
);
}
export default function AuditLogDetail({ log, onClose }: AuditLogDetailProps) {
if (!log) return null;
const actionVariant = AUDIT_ACTION_VARIANTS[log.action] ?? "neutral";
const moduleLabel = AUDIT_MODULE_LABELS[log.module] ?? log.module;
return (
<div className="fixed inset-0 z-50 flex justify-end">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/20 backdrop-blur-sm"
onClick={onClose}
/>
{/* Panel */}
<div className="relative w-[640px] max-w-full h-full bg-white shadow-2xl flex flex-col overflow-hidden animate-[slideInRight_0.2s_ease-out]">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-[8px] bg-[#F0FDF4] flex items-center justify-center">
<ClockCounterClockwise size={16} className="text-[#1B9869]" />
</div>
<div>
<h2 className="text-[15px] font-bold text-gray-900">Audit Entry</h2>
<p className="text-[12px] text-gray-400">{log.id}</p>
</div>
</div>
<button
onClick={onClose}
className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-gray-100 transition-colors text-gray-400 hover:text-gray-700"
>
<X size={16} />
</button>
</div>
{/* Scrollable body */}
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
{/* Meta grid */}
<div className="bg-[#F9FAFB] rounded-[12px] p-4 grid grid-cols-2 gap-4">
<div>
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider mb-1">Module</p>
<p className="text-[13px] font-semibold text-gray-800">{moduleLabel}</p>
</div>
<div>
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider mb-1">Action</p>
<CustomStatus status={log.action} variant={actionVariant} />
</div>
<div>
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider mb-1">Entity</p>
<p className="text-[13px] font-semibold text-gray-800">
{log.entityLabel ?? "—"}
</p>
{log.entityId && (
<p className="text-[11px] text-gray-400 font-mono mt-0.5">{log.entityId}</p>
)}
</div>
<div>
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider mb-1">Timestamp</p>
<p className="text-[13px] font-semibold text-gray-800">
{new Date(log.createdAt).toLocaleString()}
</p>
</div>
<div>
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider mb-1">Performed By</p>
<p className="text-[13px] font-medium text-gray-700">{log.performedBy ?? "—"}</p>
</div>
<div>
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider mb-1">IP Address</p>
<p className="text-[13px] font-medium text-gray-700 font-mono">{log.ipAddress ?? "—"}</p>
</div>
</div>
{/* Before / After diff */}
<div>
<h3 className="text-[13px] font-bold text-gray-700 mb-1 flex items-center gap-2">
Change Diff
{log.action === "CREATE" && (
<span className="text-[11px] font-medium text-gray-400">(no before record was created)</span>
)}
{log.action === "DELETE" && (
<span className="text-[11px] font-medium text-gray-400">(no after record was deleted)</span>
)}
</h3>
<div className="rounded-[12px] border border-gray-100 overflow-hidden">
<DiffTable before={log.before} after={log.after} />
</div>
</div>
{/* User agent */}
{log.userAgent && (
<div>
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider mb-1">User Agent</p>
<p className="text-[11px] text-gray-500 font-mono break-all">{log.userAgent}</p>
</div>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,318 @@
import { useState, useEffect, useCallback } from "react";
import {
MagnifyingGlassIcon
} from "@phosphor-icons/react";
import {
CustomTable,
CustomInput,
CustomDropdown,
CustomStatus,
Skeleton,
} from "../../../components/custom";
import type { Column } from "../../../components/custom/CustomTable";
import type { AuditLog, AuditLogFilters } from "../AuditLogsTypes";
import {
AUDIT_MODULE_LABELS,
AUDIT_ACTION_VARIANTS,
} from "../AuditLogsTypes";
import { getAuditLogs } from "../AuditLogsApi";
import AuditLogDetail from "./AuditLogDetail";
const PAGE_SIZE = 20;
function HeaderLabel({ text }: { text: string }) {
return (
<span className="text-[13px] font-semibold text-[#6C766D] tracking-[0px]">
{text}
</span>
);
}
function PrimaryText({ text }: { text: string }) {
return (
<div className="text-[13px] font-semibold text-[#0F172B] leading-[18px]">
{text}
</div>
);
}
function SecondaryText({ text }: { text: string }) {
return (
<div className="text-[12px] font-medium text-[#6C766D] leading-[16px] mt-0.5">
{text}
</div>
);
}
const MODULE_OPTIONS = [
{ label: "All Modules", value: "" },
{ label: "Policy Engine", value: "policy-engine" },
{ label: "Recovery Incident", value: "recovery-incident" },
{ label: "Cohort", value: "cohort" },
{ label: "Master Data", value: "master-data" },
];
const ACTION_OPTIONS = [
{ label: "All Actions", value: "" },
{ label: "Create", value: "CREATE" },
{ label: "Update", value: "UPDATE" },
{ label: "Delete", value: "DELETE" },
{ label: "Status Change", value: "STATUS_CHANGE" },
];
export default function AuditLogsList() {
const [logs, setLogs] = useState<AuditLog[]>([]);
const [loading, setLoading] = useState(true);
const [selectedLog, setSelectedLog] = useState<AuditLog | null>(null);
// Pagination
const [currentPage, setCurrentPage] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const [totalPages, setTotalPages] = useState(1);
// Filters
const [search, setSearch] = useState("");
const [moduleFilter, setModuleFilter] = useState("");
const [actionFilter, setActionFilter] = useState("");
// ─── Fetch ─────────────────────────────────────────────────────────────────
const fetchLogs = useCallback(
async (page: number) => {
setLoading(true);
try {
const filters: AuditLogFilters = {
page,
limit: PAGE_SIZE,
module: moduleFilter || undefined,
action: actionFilter || undefined,
};
const result = await getAuditLogs(filters);
// Client-side search on entityLabel / entityId
const filtered = search
? result.data.filter(
(l) =>
l.entityLabel?.toLowerCase().includes(search.toLowerCase()) ||
l.entityId?.toLowerCase().includes(search.toLowerCase()) ||
l.module?.toLowerCase().includes(search.toLowerCase())
)
: result.data;
setLogs(filtered);
setTotalItems(result.total);
setTotalPages(result.totalPages);
} catch (err) {
console.error("Failed to fetch audit logs", err);
setLogs([]);
} finally {
setLoading(false);
}
},
[moduleFilter, actionFilter, search]
);
useEffect(() => {
fetchLogs(currentPage);
}, [currentPage, moduleFilter, actionFilter, fetchLogs]);
// Reset to page 1 when filters change
useEffect(() => {
setCurrentPage(1);
}, [moduleFilter, actionFilter, search]);
// ─── Columns ────────────────────────────────────────────────────────────────
const columns: Column<AuditLog>[] = [
{
header: <HeaderLabel text="Timestamp" />,
accessor: (row) => (
<div>
<PrimaryText
text={new Date(row.createdAt).toLocaleDateString(undefined, {
day: "2-digit",
month: "short",
year: "numeric",
})}
/>
<SecondaryText
text={new Date(row.createdAt).toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
})}
/>
</div>
),
},
{
header: <HeaderLabel text="Module" />,
accessor: (row) => (
<PrimaryText text={AUDIT_MODULE_LABELS[row.module] ?? row.module} />
),
},
{
header: <HeaderLabel text="Action" />,
accessor: (row) => (
<CustomStatus
status={row.action}
variant={AUDIT_ACTION_VARIANTS[row.action] ?? "neutral"}
/>
),
},
{
header: <HeaderLabel text="Entity" />,
accessor: (row) => (
<div>
<PrimaryText text={row.entityLabel ?? "—"} />
{row.entityId && (
<SecondaryText text={row.entityId.slice(0, 8) + "…"} />
)}
</div>
),
},
{
header: <HeaderLabel text="Performed By" />,
accessor: (row) => (
<PrimaryText text={row.performedBy ?? "—"} />
),
},
{
header: <HeaderLabel text="IP" />,
accessor: (row) => (
<span className="text-[12px] font-mono text-gray-500">
{row.ipAddress ?? "—"}
</span>
),
},
{
header: <HeaderLabel text="Changes" />,
accessor: (row) => {
if (!row.before && !row.after) return null;
const changedFields = Object.keys({
...row.before,
...row.after,
}).filter(
(k) =>
JSON.stringify((row.before ?? {})[k]) !==
JSON.stringify((row.after ?? {})[k])
).length;
return (
<span className="text-[12px] font-semibold text-gray-500">
{row.action === "CREATE"
? "New record"
: row.action === "DELETE"
? "Deleted"
: `${changedFields} field${changedFields !== 1 ? "s" : ""}`}
</span>
);
},
},
];
// ─── Loading skeleton ───────────────────────────────────────────────────────
if (loading) {
return (
<div className="w-full flex flex-col bg-white rounded-[20px] shadow-sm border border-gray-100 overflow-hidden">
<div className="flex items-center justify-between p-4 border-b border-gray-100">
<Skeleton width={320} height={36} />
<div className="flex items-center gap-3">
<Skeleton width={160} height={36} />
<Skeleton width={160} height={36} />
</div>
</div>
<div className="p-6 flex flex-col gap-4">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} height={52} />
))}
</div>
</div>
);
}
// ─── Render ─────────────────────────────────────────────────────────────────
const startIndex = totalItems > 0 ? (currentPage - 1) * PAGE_SIZE + 1 : 0;
const endIndex = Math.min(currentPage * PAGE_SIZE, totalItems);
return (
<div className="w-full flex flex-col gap-6">
{/* Summary strip */}
<div className="flex gap-4">
{[
{ label: "Total Events", value: totalItems.toLocaleString(), color: "text-gray-900" },
{ label: "Creates", value: logs.filter((l) => l.action === "CREATE").length, color: "text-[#1B9869]" },
{ label: "Updates", value: logs.filter((l) => l.action === "UPDATE").length, color: "text-blue-500" },
{ label: "Deletes", value: logs.filter((l) => l.action === "DELETE").length, color: "text-red-500" },
].map((s) => (
<div
key={s.label}
className="bg-[#F8F9FA] rounded-[16px] p-5 flex-1 flex flex-col gap-1"
>
<span className="text-[13px] font-semibold text-gray-500">{s.label}</span>
<span className={`text-[28px] font-bold leading-none ${s.color}`}>
{s.value}
</span>
</div>
))}
</div>
{/* Table */}
<CustomTable<AuditLog>
columns={columns}
data={logs}
leftHeaderActions={
<div className="w-[300px]">
<CustomInput
placeholder="Search entity, module…"
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<MagnifyingGlassIcon size={16} />}
className="!bg-[#F3F6F5] !rounded-[10px] !h-[40px] !border !border-[#E5E7EB]"
containerClassName="!gap-0"
/>
</div>
}
rightHeaderActions={
<>
<div className="w-[180px]">
<CustomDropdown
placeholder="All Modules"
value={moduleFilter}
onChange={(v) => setModuleFilter(v as string)}
options={MODULE_OPTIONS}
/>
</div>
<div className="w-[160px]">
<CustomDropdown
placeholder="All Actions"
value={actionFilter}
onChange={(v) => setActionFilter(v as string)}
options={ACTION_OPTIONS}
/>
</div>
</>
}
currentPage={currentPage}
totalPages={totalPages}
totalItems={totalItems}
startIndex={startIndex}
endIndex={endIndex}
onPageChange={setCurrentPage}
itemName="Events"
onRowClick={(row) => setSelectedLog(row)}
rowClassName={() =>
"bg-[#F9FAFB] border-transparent cursor-pointer hover:bg-[#F3F4F6]"
}
/>
{/* Detail side panel */}
<AuditLogDetail
log={selectedLog}
onClose={() => setSelectedLog(null)}
/>
</div>
);
}
+3 -21
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useMemo, useRef } from 'react';
import React, { useState, useEffect} from 'react';
import { FileTextIcon, CaretRightIcon, CaretLeftIcon, UserListIcon, GlobeIcon, UserCircleCheckIcon } from '@phosphor-icons/react';
import {
CustomModal,
@@ -22,8 +22,8 @@ import {
getRevenueSegments,
getRegions,
getTripPurposes,
} from '../../masterData/MasterDataApi';
import type { MasterDataItem } from '../../masterData/MasterDataTypes';
} from '../../configuration/masterData/MasterDataApi';
import type { MasterDataItem } from '../../configuration/masterData/MasterDataTypes';
import type {
CohartResponse,
CreateCohartPayload,
@@ -267,25 +267,7 @@ export default function AddCohart({ isOpen, onClose, onSuccess, editData }: AddC
stepTwo.originAirports.length > 0 ||
stepTwo.destinationAirports.length > 0;
// Auto-advance to the next section the moment the active one gains its
// first value. Reopening a prior section later never re-triggers this,
// since it only fires on the false -> true transition while that section
// is the one currently open.
const sectionHasData: Partial<Record<Section, boolean>> = useMemo(
() => ({ passenger: passengerHasData, customer: customerHasData }),
[passengerHasData, customerHasData]
);
const prevSectionHasData = useRef(sectionHasData);
useEffect(() => {
const current = openSection as Section;
const currentIndex = SECTION_ORDER.indexOf(current);
const justFilled = !prevSectionHasData.current[current] && sectionHasData[current];
if (justFilled && currentIndex >= 0 && currentIndex < SECTION_ORDER.length - 1) {
setOpenSection(SECTION_ORDER[currentIndex + 1]);
}
prevSectionHasData.current = sectionHasData;
}, [sectionHasData, openSection]);
const advanceSection = () => {
const currentIndex = SECTION_ORDER.indexOf(openSection as Section);
@@ -0,0 +1,118 @@
import { ApiClient } from '../../api/ApiClient';
import type {
ActionCategory,
ActionType,
ActionCategoryFormData,
ActionTypeFormData,
FieldDefinition,
FieldDefinitionFormData,
ActionSubmissionPayload,
} from './ActionBuilderTypes';
// ─── Action Category Endpoints ───────────────────────────────────────────────
export function getActionCategories(page?: number, limit?: number): Promise<ActionCategory[]> {
const query = page !== undefined && limit !== undefined ? `?page=${page}&limit=${limit}` : '';
return ApiClient.get<any, ActionCategory[]>(`/master-data/action-categories${query}`);
}
export function getActionCategory(id: string): Promise<ActionCategory> {
return ApiClient.get<any, ActionCategory>(`/master-data/action-categories/${id}`);
}
export function createActionCategory(payload: ActionCategoryFormData): Promise<ActionCategory> {
return ApiClient.post<any, ActionCategory>('/master-data/action-categories', payload);
}
export function updateActionCategory(id: string, payload: Partial<ActionCategoryFormData>): Promise<ActionCategory> {
return ApiClient.put<any, ActionCategory>(`/master-data/action-categories/${id}`, payload);
}
export function deleteActionCategory(id: string): Promise<void> {
return ApiClient.delete<any, void>(`/master-data/action-categories/${id}`);
}
// ─── Action Type Endpoints ───────────────────────────────────────────────────
export function getActionTypes(page?: number, limit?: number): Promise<ActionType[]> {
const query = page !== undefined && limit !== undefined ? `?page=${page}&limit=${limit}` : '';
return ApiClient.get<any, ActionType[]>(`/master-data/action-types${query}`);
}
export function getActionTypesByCategory(categoryId: string, page?: number, limit?: number): Promise<ActionType[]> {
const query = page !== undefined && limit !== undefined ? `?page=${page}&limit=${limit}` : '';
return ApiClient.get<any, ActionType[]>(`/master-data/action-types/category-id/${categoryId}${query}`);
}
export function getActionTypesByCategoryCode(code: string, page?: number, limit?: number): Promise<ActionType[]> {
const query = page !== undefined && limit !== undefined ? `?page=${page}&limit=${limit}` : '';
return ApiClient.get<any, ActionType[]>(`/master-data/action-types/category/${code}${query}`);
}
export function getActionType(id: string): Promise<ActionType> {
return ApiClient.get<any, ActionType>(`/master-data/action-types/${id}`);
}
export function createActionType(payload: ActionTypeFormData): Promise<ActionType> {
return ApiClient.post<any, ActionType>('/master-data/action-types', payload);
}
export function updateActionType(id: string, payload: Partial<ActionTypeFormData>): Promise<ActionType> {
return ApiClient.put<any, ActionType>(`/master-data/action-types/${id}`, payload);
}
export function deleteActionType(id: string): Promise<void> {
return ApiClient.delete<any, void>(`/master-data/action-types/${id}`);
}
// ─── Field Definition Endpoints ──────────────────────────────────────────────
export function getFieldDefinitions(actionTypeId: string): Promise<FieldDefinition[]> {
return ApiClient.get<any, FieldDefinition[]>(`/master-data/action-types/${actionTypeId}/fields`);
}
export function createFieldDefinition(actionTypeId: string, payload: FieldDefinitionFormData): Promise<FieldDefinition> {
return ApiClient.post<any, FieldDefinition>(`/master-data/action-types/${actionTypeId}/fields`, {
...payload,
actionTypeId,
});
}
export function updateFieldDefinition(fieldId: string, payload: Partial<FieldDefinitionFormData>): Promise<FieldDefinition> {
return ApiClient.patch<any, FieldDefinition>(`/master-data/fields/${fieldId}`, payload);
}
export function deleteFieldDefinition(fieldId: string): Promise<void> {
return ApiClient.delete<any, void>(`/master-data/fields/${fieldId}`);
}
export function reorderFieldDefinitions(actionTypeId: string, fieldIds: string[]): Promise<FieldDefinition[]> {
return ApiClient.post<any, FieldDefinition[]>(`/master-data/action-types/${actionTypeId}/fields/reorder`, { fieldIds });
}
// ─── Action Payload Submission ───────────────────────────────────────────────
export function submitActionPayload(payload: ActionSubmissionPayload): Promise<any> {
return ApiClient.post<any, any>('/master-data/actions', payload);
}
// ─── Master Data Options Loader ──────────────────────────────────────────────
export function getMasterDataOptions(categoryCode: string): Promise<{ label: string; value: string; id?: string }[]> {
return ApiClient.get<any, any[]>(`/master-data/${categoryCode}`)
.then((res) => {
if (Array.isArray(res)) {
return res.map((item) => {
const val = item.id || item.value || item.code || item.label || item.name || '';
const lbl = item.label || item.name || item.value || item.code || '';
return {
label: lbl,
value: val,
id: item.id || val,
};
});
}
return [];
})
.catch(() => []);
}
@@ -0,0 +1,134 @@
export interface ActionCategory {
id: string;
code: string;
name: string;
description: string;
displayOrder: number;
isActive: boolean;
createdAt?: string;
updatedAt?: string;
}
export interface ActionType {
id: string;
categoryId: string;
categoryCode?: string;
categoryName?: string;
code: string;
name: string;
description: string;
icon?: string;
displayOrder: number;
isActive: boolean;
createdAt?: string;
updatedAt?: string;
}
export interface ActionCategoryFormData {
code: string;
name: string;
description: string;
displayOrder: number;
isActive: boolean;
}
export interface ActionTypeFormData {
categoryId: string;
code: string;
name: string;
description: string;
icon?: string;
displayOrder: number;
isActive: boolean;
}
export type FieldType =
| 'textbox'
| 'email'
| 'phone'
| 'url'
| 'textarea'
| 'number'
| 'decimal'
| 'percentage'
| 'currency'
| 'dropdown'
| 'radio'
| 'multi_select'
| 'checkbox'
| 'switch'
| 'date'
| 'datetime'
| 'time'
| 'color'
| 'formula';
export type FieldWidth = 'full' | 'half' | 'third' | 'two_thirds';
export interface ValidationJson {
min?: number;
max?: number;
regex?: string;
}
export interface VisibilityConditionJson {
field: string;
operator: 'equals' | 'not_equals' | 'contains';
value: any;
}
export interface FieldDefinition {
id: string;
actionTypeId: string;
fieldCode: string;
fieldName: string;
fieldType: FieldType;
lookupSource?: string;
isRequired: boolean;
defaultValue?: string;
placeholder?: string;
helpText?: string;
width: FieldWidth;
section?: string;
displayOrder: number;
isActive: boolean;
validationJson?: ValidationJson;
visibilityConditionJson?: VisibilityConditionJson;
createdAt?: string;
updatedAt?: string;
}
export interface FieldDefinitionFormData {
fieldCode: string;
fieldName: string;
fieldType: FieldType;
lookupSource?: string;
isRequired: boolean;
defaultValue?: string;
placeholder?: string;
helpText?: string;
width: FieldWidth;
section?: string;
displayOrder: number;
isActive: boolean;
validationJson?: ValidationJson;
visibilityConditionJson?: VisibilityConditionJson;
}
export interface ActionSubmissionPayload {
category_id: string;
action_type_id: string;
values: ActionSubmissionValue[];
}
export interface ActionSubmissionValue {
field_definition_id: string;
value_index?: number;
selected_value_id?: string;
text_value?: string;
number_value?: number;
boolean_value?: boolean;
date_value?: string;
time_value?: string;
timestamp_value?: string;
}
@@ -0,0 +1,150 @@
import { XCircleIcon } from '@phosphor-icons/react';
import {
CustomModal,
CustomInput,
CustomDropdown,
CustomTextArea,
CustomSwitch,
CustomButton,
} from '../../../../components/custom';
import type { ActionCategory, ActionType, ActionTypeFormData } from '../ActionBuilderTypes';
interface ActionTypeFormModalProps {
isOpen: boolean;
editingType: ActionType | null;
categories: ActionCategory[];
formData: ActionTypeFormData;
setFormData: React.Dispatch<React.SetStateAction<ActionTypeFormData>>;
error: string | null;
onClose: () => void;
onSubmit: (e: React.FormEvent) => void;
}
export function ActionTypeFormModal({
isOpen,
editingType,
categories,
formData,
setFormData,
error,
onClose,
onSubmit,
}: ActionTypeFormModalProps) {
return (
<CustomModal
isOpen={isOpen}
onClose={onClose}
title={editingType ? `Edit Action Type: ${editingType.name}` : 'Create New Action Type'}
description="Select category, configure type code, name, description, and status."
size="lg"
>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 text-[#D40000] rounded-[10px] text-[13px] font-medium flex items-center gap-2">
<XCircleIcon size={18} weight="fill" />
{error}
</div>
)}
<form onSubmit={onSubmit} className="space-y-5 pt-1">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
Category <span className="text-red-500">*</span>
</label>
<CustomDropdown
options={categories.map((c) => ({ label: `${c.name} (${c.code})`, value: c.id }))}
value={formData.categoryId}
onChange={(val) => setFormData((prev) => ({ ...prev, categoryId: val }))}
placeholder="Select Category"
/>
</div>
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
Action Type Name <span className="text-red-500">*</span>
</label>
<CustomInput
value={formData.name}
onChange={(e) => {
const nameVal = e.target.value;
setFormData((prev) => ({
...prev,
name: nameVal,
code: editingType ? prev.code : nameVal.toLowerCase().replace(/\s+/g, '_'),
}));
}}
placeholder="e.g. Award Miles"
/>
</div>
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
Action Type Code <span className="text-red-500">*</span>
</label>
<CustomInput
value={formData.code}
onChange={(e) => setFormData((prev) => ({ ...prev, code: e.target.value }))}
placeholder="e.g. award_miles"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="md:col-span-3">
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
Description
</label>
<CustomTextArea
value={formData.description}
onChange={(e) => setFormData((prev) => ({ ...prev, description: e.target.value }))}
placeholder="Provide details about how this action type is executed..."
rows={3}
/>
</div>
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
Display Order
</label>
<CustomInput
type="number"
value={String(formData.displayOrder)}
onChange={(e) => setFormData((prev) => ({ ...prev, displayOrder: parseInt(e.target.value) || 0 }))}
placeholder="1"
/>
</div>
</div>
<div className="flex items-center justify-between pt-4 border-t border-gray-100 mt-6">
<div className="flex items-center gap-3">
<span className="text-[13px] font-semibold text-slate-700">Active Status</span>
<CustomSwitch
checked={formData.isActive}
onChange={(e) => setFormData((prev) => ({ ...prev, isActive: e.target.checked }))}
/>
</div>
<div className="flex items-center gap-3">
<CustomButton
variant="outlined"
type="button"
onClick={onClose}
className="!border-gray-300 !text-gray-600 font-semibold px-5"
>
Cancel
</CustomButton>
<CustomButton
variant="primary"
type="submit"
className="!bg-[#1E7D5C] hover:!bg-[#17664B] font-semibold px-6 shadow-sm"
>
{editingType ? 'Save Changes' : 'Create Action Type'}
</CustomButton>
</div>
</div>
</form>
</CustomModal>
);
}
@@ -0,0 +1,140 @@
import { useState } from 'react';
import { PlusIcon, MagnifyingGlassIcon, PencilSimpleIcon, TrashIcon } from '@phosphor-icons/react';
import { CustomInput, CustomButton } from '../../../../components/custom';
import type { ActionType } from '../ActionBuilderTypes';
interface ActionTypesColumnProps {
actionTypes: ActionType[];
selectedCategoryId: string;
selectedActionTypeId: string;
onSelectActionType: (id: string) => void;
fieldCountMap: Record<string, number>;
onOpenAdd: () => void;
onOpenEdit: (type: ActionType, e: React.MouseEvent) => void;
onDelete: (type: ActionType, e: React.MouseEvent) => void;
}
export function ActionTypesColumn({
actionTypes,
selectedCategoryId,
selectedActionTypeId,
onSelectActionType,
fieldCountMap,
onOpenAdd,
onOpenEdit,
onDelete,
}: ActionTypesColumnProps) {
const [search, setSearch] = useState('');
const categoryActionTypes = actionTypes.filter((t) => t.categoryId === selectedCategoryId);
const filteredActionTypes = categoryActionTypes.filter(
(t) =>
t.name.toLowerCase().includes(search.toLowerCase()) ||
t.code.toLowerCase().includes(search.toLowerCase())
);
return (
<div className="bg-white p-4 rounded-[20px] border border-gray-200/70 shadow-sm space-y-4 flex flex-col min-h-[600px]">
{/* Column Header */}
<div className="flex items-center justify-between">
<h3 className="text-[16px] font-bold text-[#0F172B]">Action Types</h3>
<CustomButton
variant="outlined"
size="sm"
onClick={onOpenAdd}
disabled={!selectedCategoryId}
leftIcon={<PlusIcon size={15} weight="bold" />}
>
Add
</CustomButton>
</div>
{/* Search Input */}
<div className="relative">
<CustomInput
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search Type by name or code..."
leftIcon={<MagnifyingGlassIcon size={16} className="text-slate-400" />}
className="!bg-[#F3F6F5] !rounded-[12px] !h-[40px] !border-none !text-[13px]"
containerClassName="!gap-0"
/>
</div>
{/* Action Types Cards List */}
<div className="space-y-2.5 flex-1 overflow-y-auto max-h-[580px] pr-0.5">
{!selectedCategoryId ? (
<div className="py-12 text-center text-slate-400 text-[13px]">
Select a category to view action types.
</div>
) : filteredActionTypes.length === 0 ? (
<div className="py-12 text-center text-slate-400 text-[13px]">
No action types in this category. Click "+ Add" to create one.
</div>
) : (
filteredActionTypes.map((t) => {
const isSelected = t.id === selectedActionTypeId;
const fieldCount = fieldCountMap[t.id] ?? 0;
return (
<div
key={t.id}
onClick={() => onSelectActionType(t.id)}
className={`group relative p-3.5 rounded-[14px] flex items-center justify-between cursor-pointer transition-all duration-150 ${isSelected
? 'bg-[#1E7D5C] text-white shadow-sm'
: 'bg-slate-50/80 hover:bg-slate-100/70 border border-gray-100 text-slate-800'
}`}
>
<div className="pr-3 space-y-0.5 min-w-0">
<div className={`font-bold text-[14px] truncate ${isSelected ? 'text-white' : 'text-slate-800'}`}>
{t.name}
</div>
<div className={`font-mono text-[11px] uppercase tracking-wider ${isSelected ? 'text-white/80' : 'text-slate-400'}`}>
{t.code}
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{/* Hover Actions */}
<div className="hidden group-hover:flex items-center gap-1">
<button
type="button"
onClick={(e) => onOpenEdit(t, e)}
title="Edit Action Type"
className={`p-1.5 rounded-lg transition-colors cursor-pointer ${isSelected ? 'text-white hover:bg-white/20' : 'text-slate-400 hover:text-[#1E7D5C] hover:bg-white'
}`}
>
<PencilSimpleIcon size={15} weight="bold" />
</button>
<button
type="button"
onClick={(e) => onDelete(t, e)}
title="Delete Action Type"
className={`p-1.5 rounded-lg transition-colors cursor-pointer ${isSelected ? 'text-white hover:bg-white/20' : 'text-slate-400 hover:text-red-600 hover:bg-white'
}`}
>
<TrashIcon size={15} weight="bold" />
</button>
</div>
{/* Count Badge */}
<div
className={`w-7 h-7 rounded-full font-bold text-[12px] flex items-center justify-center ${isSelected
? 'bg-white/20 text-white'
: 'bg-[#E8F3EF] text-[#1E7D5C]'
}`}
>
{fieldCount}
</div>
</div>
</div>
);
})
)}
</div>
</div>
);
}
@@ -0,0 +1,131 @@
import { useState } from 'react';
import { PlusIcon, MagnifyingGlassIcon, PencilSimpleIcon, TrashIcon } from '@phosphor-icons/react';
import { CustomInput, CustomButton } from '../../../../components/custom';
import type { ActionCategory, ActionType } from '../ActionBuilderTypes';
interface CategoriesColumnProps {
categories: ActionCategory[];
selectedCategoryId: string;
onSelectCategory: (id: string) => void;
actionTypes: ActionType[];
onOpenAdd: () => void;
onOpenEdit: (cat: ActionCategory, e: React.MouseEvent) => void;
onDelete: (cat: ActionCategory, e: React.MouseEvent) => void;
}
export function CategoriesColumn({
categories,
selectedCategoryId,
onSelectCategory,
actionTypes,
onOpenAdd,
onOpenEdit,
onDelete,
}: CategoriesColumnProps) {
const [search, setSearch] = useState('');
const filteredCategories = categories.filter(
(c) =>
c.name.toLowerCase().includes(search.toLowerCase()) ||
c.code.toLowerCase().includes(search.toLowerCase())
);
return (
<div className="bg-white p-4 rounded-[20px] border border-gray-200/70 shadow-sm space-y-4 flex flex-col min-h-[600px]">
{/* Column Header */}
<div className="flex items-center justify-between">
<h3 className="text-[16px] font-bold text-[#0F172B]">Categories</h3>
<CustomButton
variant="outlined"
size="sm"
onClick={onOpenAdd}
leftIcon={<PlusIcon size={15} weight="bold" />}
>
Add
</CustomButton>
</div>
{/* Search Input */}
<div className="relative">
<CustomInput
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search Category by name or code..."
leftIcon={<MagnifyingGlassIcon size={16} className="text-slate-400" />}
className="!bg-[#F3F6F5] !rounded-[12px] !h-[40px] !border-none !text-[13px]"
containerClassName="!gap-0"
/>
</div>
{/* Category Cards List */}
<div className="space-y-2.5 flex-1 overflow-y-auto max-h-[580px] pr-0.5">
{filteredCategories.length === 0 ? (
<div className="py-12 text-center text-slate-400 text-[13px]">
No categories found.
</div>
) : (
filteredCategories.map((cat) => {
const isSelected = cat.id === selectedCategoryId;
const childCount = actionTypes.filter((t) => t.categoryId === cat.id).length;
return (
<div
key={cat.id}
onClick={() => onSelectCategory(cat.id)}
className={`group relative p-3.5 rounded-[14px] flex items-center justify-between cursor-pointer transition-all duration-150 ${isSelected
? 'bg-[#1E7D5C] text-white shadow-sm'
: 'bg-slate-50/80 hover:bg-slate-100/70 border border-gray-100 text-slate-800'
}`}
>
<div className="pr-3 space-y-0.5 min-w-0">
<div className={`font-bold text-[14px] truncate ${isSelected ? 'text-white' : 'text-slate-800'}`}>
{cat.name}
</div>
<div className={`font-mono text-[11px] uppercase tracking-wider ${isSelected ? 'text-white/80' : 'text-slate-400'}`}>
{cat.code}
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{/* Hover Actions */}
<div className="hidden group-hover:flex items-center gap-1">
<button
type="button"
onClick={(e) => onOpenEdit(cat, e)}
title="Edit Category"
className={`p-1.5 rounded-lg transition-colors cursor-pointer ${isSelected ? 'text-white hover:bg-white/20' : 'text-slate-400 hover:text-[#1E7D5C] hover:bg-white'
}`}
>
<PencilSimpleIcon size={15} weight="bold" />
</button>
<button
type="button"
onClick={(e) => onDelete(cat, e)}
title="Delete Category"
className={`p-1.5 rounded-lg transition-colors cursor-pointer ${isSelected ? 'text-white hover:bg-white/20' : 'text-slate-400 hover:text-red-600 hover:bg-white'
}`}
>
<TrashIcon size={15} weight="bold" />
</button>
</div>
{/* Count Badge */}
<div
className={`w-7 h-7 rounded-full font-bold text-[12px] flex items-center justify-center ${isSelected
? 'bg-white/20 text-white'
: 'bg-[#E8F3EF] text-[#1E7D5C]'
}`}
>
{childCount}
</div>
</div>
</div>
);
})
)}
</div>
</div>
);
}
@@ -0,0 +1,133 @@
import { XCircleIcon } from '@phosphor-icons/react';
import {
CustomModal,
CustomInput,
CustomTextArea,
CustomSwitch,
CustomButton,
} from '../../../../components/custom';
import type { ActionCategory, ActionCategoryFormData } from '../ActionBuilderTypes';
interface CategoryFormModalProps {
isOpen: boolean;
editingCategory: ActionCategory | null;
formData: ActionCategoryFormData;
setFormData: React.Dispatch<React.SetStateAction<ActionCategoryFormData>>;
error: string | null;
onClose: () => void;
onSubmit: (e: React.FormEvent) => void;
}
export function CategoryFormModal({
isOpen,
editingCategory,
formData,
setFormData,
error,
onClose,
onSubmit,
}: CategoryFormModalProps) {
return (
<CustomModal
isOpen={isOpen}
onClose={onClose}
title={editingCategory ? `Edit Category: ${editingCategory.name}` : 'Create New Action Category'}
description="Provide category code, name, description, and display configuration."
size="lg"
>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 text-[#D40000] rounded-[10px] text-[13px] font-medium flex items-center gap-2">
<XCircleIcon size={18} weight="fill" />
{error}
</div>
)}
<form onSubmit={onSubmit} className="space-y-4 pt-1">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
Category Name <span className="text-red-500">*</span>
</label>
<CustomInput
value={formData.name}
onChange={(e) => {
const nameVal = e.target.value;
setFormData((prev) => ({
...prev,
name: nameVal,
code: editingCategory ? prev.code : nameVal.toLowerCase().replace(/\s+/g, '-'),
}));
}}
placeholder="e.g. Refunds"
/>
</div>
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
Category Code <span className="text-red-500">*</span>
</label>
<CustomInput
value={formData.code}
onChange={(e) => setFormData((prev) => ({ ...prev, code: e.target.value }))}
placeholder="e.g. refunds"
/>
</div>
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
Display Order
</label>
<CustomInput
type="number"
value={String(formData.displayOrder)}
onChange={(e) => setFormData((prev) => ({ ...prev, displayOrder: parseInt(e.target.value) || 0 }))}
placeholder="1"
/>
</div>
</div>
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
Description
</label>
<CustomTextArea
value={formData.description}
onChange={(e) => setFormData((prev) => ({ ...prev, description: e.target.value }))}
placeholder="Briefly describe what actions belong in this category..."
rows={3}
/>
</div>
<div className="flex items-center justify-between pt-4 border-t border-gray-100 mt-6">
<div className="flex items-center gap-3">
<span className="text-[13px] font-semibold text-slate-700">Active Status</span>
<CustomSwitch
checked={formData.isActive}
onChange={(e) => setFormData((prev) => ({ ...prev, isActive: e.target.checked }))}
/>
</div>
<div className="flex items-center gap-3">
<CustomButton
variant="outlined"
type="button"
onClick={onClose}
className="!border-gray-300 !text-gray-600 font-semibold px-5"
>
Cancel
</CustomButton>
<CustomButton
variant="primary"
type="submit"
className="!bg-[#1E7D5C] hover:!bg-[#17664B] font-semibold px-6 shadow-sm"
>
{editingCategory ? 'Save Changes' : 'Create Category'}
</CustomButton>
</div>
</div>
</form>
</CustomModal>
);
}
@@ -0,0 +1,274 @@
import { useState } from 'react';
import {
PlusIcon,
MagnifyingGlassIcon,
PencilSimpleLineIcon,
TrashIcon,
DotsSixVerticalIcon,
} from '@phosphor-icons/react';
import { CustomInput, CustomLoader, CustomButton } from '../../../../components/custom';
import type { FieldDefinition } from '../ActionBuilderTypes';
interface ConfigurationFieldsColumnProps {
fields: FieldDefinition[];
selectedActionTypeId: string;
loading: boolean;
onOpenAdd: () => void;
onOpenEdit: (field: FieldDefinition) => void;
onDelete: (field: FieldDefinition) => void;
onReorderFields?: (fromIndex: number, toIndex: number) => void;
}
export function ConfigurationFieldsColumn({
fields,
selectedActionTypeId,
loading,
onOpenAdd,
onOpenEdit,
onDelete,
onReorderFields,
}: ConfigurationFieldsColumnProps) {
const [search, setSearch] = useState('');
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const filteredFields = fields.filter(
(f) =>
f.fieldName.toLowerCase().includes(search.toLowerCase()) ||
f.fieldCode.toLowerCase().includes(search.toLowerCase()) ||
(f.section || '').toLowerCase().includes(search.toLowerCase())
);
const fieldNameByCode = new Map(fields.map((f) => [f.fieldCode, f.fieldName]));
return (
<div className="bg-[#FFFFFF] p-4 rounded-[20px] border border-gray-200/70 shadow-sm space-y-4 flex flex-col min-h-[600px]">
{/* Column Header */}
<div className="flex items-center justify-between">
<h3 className="text-[16px] font-bold text-[#0F172B]">Configuration Field</h3>
<CustomButton
variant="outlined"
size="sm"
onClick={onOpenAdd}
disabled={!selectedActionTypeId}
leftIcon={<PlusIcon size={15} weight="bold" />}
>
Add
</CustomButton>
</div>
{/* Search Input */}
<div className="relative">
<CustomInput
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search by name or code..."
leftIcon={<MagnifyingGlassIcon size={16} className="text-slate-400" />}
className="!bg-[#F3F6F5] !rounded-[12px] !h-[40px] !border-none !text-[13px]"
containerClassName="!gap-0"
/>
</div>
{/* Field Cards List */}
<div className="space-y-3 flex-1 overflow-y-auto max-h-[580px] pr-0.5">
{loading ? (
<div className="py-16 flex justify-center">
<CustomLoader label="Loading fields..." />
</div>
) : !selectedActionTypeId ? (
<div className="py-12 text-center text-slate-400 text-[13px]">
Select an Action Type to view configuration fields.
</div>
) : filteredFields.length === 0 ? (
<div className="py-12 text-center text-slate-400 text-[13px]">
No fields configured for this action type yet. Click "+ Add" above to create one.
</div>
) : (
filteredFields.map((f, idx) => {
const hasVisibility = f.visibilityConditionJson?.field && f.visibilityConditionJson?.value;
const depFieldName = hasVisibility
? fieldNameByCode.get(f.visibilityConditionJson!.field) || f.visibilityConditionJson!.field
: 'Field Name';
const minVal = f.validationJson?.min;
const maxVal = f.validationJson?.max;
const hasMinMax = minVal !== undefined || maxVal !== undefined;
const isDragging = draggedIndex === idx;
const isDragOver = dragOverIndex === idx;
return (
<div
key={f.id}
draggable
onDragStart={(e) => {
setDraggedIndex(idx);
e.dataTransfer.setData('text/plain', String(idx));
e.dataTransfer.effectAllowed = 'move';
}}
onDragOver={(e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (dragOverIndex !== idx) {
setDragOverIndex(idx);
}
}}
onDragLeave={() => {
if (dragOverIndex === idx) {
setDragOverIndex(null);
}
}}
onDrop={(e) => {
e.preventDefault();
if (draggedIndex !== null && draggedIndex !== idx) {
const fromOriginalIndex = fields.findIndex((item) => item.id === filteredFields[draggedIndex].id);
const toOriginalIndex = fields.findIndex((item) => item.id === f.id);
if (fromOriginalIndex !== -1 && toOriginalIndex !== -1) {
onReorderFields?.(fromOriginalIndex, toOriginalIndex);
}
}
setDraggedIndex(null);
setDragOverIndex(null);
}}
onDragEnd={() => {
setDraggedIndex(null);
setDragOverIndex(null);
}}
className={`bg-[#EFF3F7] rounded-[16px] border border-[#E2E8F0] overflow-hidden flex items-stretch transition-all duration-150 ${isDragOver ? 'ring-2 ring-[#1E7D5C] scale-[1.01]' : 'hover:border-slate-300'
} ${isDragging ? 'opacity-40' : ''}`}
>
{/* Left Drag Handle Bar */}
<div
className="w-8 flex items-center justify-center text-slate-400 hover:text-slate-700 cursor-grab active:cursor-grabbing flex-shrink-0 select-none"
title="Drag to reorder"
>
<DotsSixVerticalIcon size={18} weight="bold" />
</div>
{/* Inner White Card Content */}
<div className="bg-white flex-1 p-4 rounded-r-[15px] border-l border-[#E2E8F0] space-y-3 min-w-0">
{/* Top Header Row */}
<div className="flex items-start justify-between gap-2">
<div>
<div className="font-bold text-[15px] text-[#0F172B] leading-tight">
{f.fieldName}
</div>
<div className="font-mono text-[12px] text-[#8C9AA8] uppercase tracking-wider mt-0.5">
{f.fieldCode}
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{/* Required Badge */}
{f.isRequired && (
<span className="bg-[#FEECEB] text-[#D9383A] text-[12px] font-medium px-3 py-1 rounded-full">
Required
</span>
)}
{/* Active Badge */}
<span
className={`text-[12px] font-medium px-3 py-1 rounded-full ${f.isActive !== false
? 'bg-[#E6F7ED] text-[#1E7D5C]'
: 'bg-slate-100 text-slate-500'
}`}
>
{f.isActive !== false ? 'Active' : 'Inactive'}
</span>
{/* Divider */}
<span className="text-slate-300 font-light mx-0.5">|</span>
{/* Action Buttons */}
<button
type="button"
onClick={() => onOpenEdit(f)}
title="Edit Field"
className="p-1 text-slate-800 hover:text-[#1E7D5C] transition-colors cursor-pointer"
>
<PencilSimpleLineIcon size={18} weight="bold" />
</button>
<button
type="button"
onClick={() => onDelete(f)}
title="Delete Field"
className="p-1 text-[#D9383A] hover:text-red-700 transition-colors cursor-pointer"
>
<TrashIcon size={18} weight="bold" />
</button>
</div>
</div>
<div className="border-b border-slate-100 my-2" />
{/* Details Grid (2 columns x 2 rows) */}
<div className="grid grid-cols-2 gap-y-3 gap-x-4">
<div>
<div className="text-[11px] font-bold text-[#8C9AA8] uppercase tracking-wider">
CONTROL TYPE
</div>
<div className="text-[13px] font-bold text-[#0F172B] mt-0.5">
{f.fieldType}
</div>
</div>
<div>
<div className="text-[11px] font-bold text-[#8C9AA8] uppercase tracking-wider">
WIDTH
</div>
<div className="text-[13px] font-bold text-[#0F172B] mt-0.5">
{f.width ? `${f.width} width` : 'full width'}
</div>
</div>
<div>
<div className="text-[11px] font-bold text-[#8C9AA8] uppercase tracking-wider">
MASTER LOOKUP
</div>
<div className="text-[13px] font-bold text-[#0F172B] mt-0.5">
{f.lookupSource || '--'}
</div>
</div>
<div>
<div className="text-[11px] font-bold text-[#8C9AA8] uppercase tracking-wider">
SECTION
</div>
<div className="text-[13px] font-medium text-[#5A6E85] mt-0.5">
{f.section || 'General Information'}
</div>
</div>
</div>
{/* Visibility Condition Banner & Min/Max Footer */}
<div className="pt-2 flex items-end justify-between gap-3">
{hasVisibility ? (
<div className="bg-[#FFF8E6] border border-[#FDE6B8] text-[#B45309] rounded-[10px] px-3.5 py-2 text-[12px] space-y-0.5 max-w-[70%]">
<div className="font-medium text-[#B45309]">Visible if {depFieldName} =</div>
<div className="font-bold text-[#B45309] uppercase break-all">
{String(f.visibilityConditionJson?.value)}
</div>
</div>
) : (
<div />
)}
{hasMinMax && (
<div className="text-right text-[12px] font-medium text-[#5A6E85] ml-auto">
Min: {minVal ?? '—'} | Max: {maxVal ?? '—'}
</div>
)}
</div>
</div>
</div>
);
})
)}
</div>
</div>
);
}
@@ -0,0 +1,376 @@
import { XCircleIcon } from '@phosphor-icons/react';
import {
CustomModal,
CustomInput,
CustomDropdown,
CustomTextArea,
CustomSwitch,
CustomButton,
} from '../../../../components/custom';
import type {
FieldDefinition,
FieldDefinitionFormData,
FieldType,
FieldWidth,
} from '../ActionBuilderTypes';
const FIELD_TYPE_OPTIONS: { label: string; value: FieldType }[] = [
{ label: 'Text Box (Single Line)', value: 'textbox' },
{ label: 'Text Area (Multi Line)', value: 'textarea' },
{ label: 'Email Address', value: 'email' },
{ label: 'Phone Number', value: 'phone' },
{ label: 'URL / Link', value: 'url' },
{ label: 'Number (Integer)', value: 'number' },
{ label: 'Decimal / Price', value: 'decimal' },
{ label: 'Percentage (%)', value: 'percentage' },
{ label: 'Currency (Amount + Currency)', value: 'currency' },
{ label: 'Dropdown Selection', value: 'dropdown' },
{ label: 'Radio Selection', value: 'radio' },
{ label: 'Multi-Select Checkboxes', value: 'multi_select' },
{ label: 'Single Checkbox', value: 'checkbox' },
{ label: 'Toggle Switch', value: 'switch' },
{ label: 'Date Picker', value: 'date' },
{ label: 'Date & Time Picker', value: 'datetime' },
{ label: 'Time Picker', value: 'time' },
{ label: 'Color Picker', value: 'color' },
{ label: 'Formula (Read-only Computed)', value: 'formula' },
];
const WIDTH_OPTIONS: { label: string; value: FieldWidth }[] = [
{ label: 'Full Width (12/12)', value: 'full' },
{ label: 'Half Width (6/12)', value: 'half' },
{ label: 'One-Third Width (4/12)', value: 'third' },
{ label: 'Two-Thirds Width (8/12)', value: 'two_thirds' },
];
interface FieldDefinitionFormModalProps {
isOpen: boolean;
editingField: FieldDefinition | null;
fields: FieldDefinition[];
masterLookupOptions: { label: string; value: string }[];
formData: FieldDefinitionFormData;
setFormData: React.Dispatch<React.SetStateAction<FieldDefinitionFormData>>;
error: string | null;
onClose: () => void;
onSubmit: (e: React.FormEvent) => void;
}
export function FieldDefinitionFormModal({
isOpen,
editingField,
fields,
masterLookupOptions,
formData,
setFormData,
error,
onClose,
onSubmit,
}: FieldDefinitionFormModalProps) {
const dependentOptions = [
{ label: 'None (No Dependency)', value: '' },
...fields
.filter((f) => !editingField || f.id !== editingField.id)
.map((f) => ({
label: `${f.fieldName} (${f.fieldCode})`,
value: f.fieldCode,
})),
];
return (
<CustomModal
isOpen={isOpen}
onClose={onClose}
title={editingField ? `Edit Field: ${editingField.fieldName}` : 'Create Field Definition'}
description="Configure field code, label, control type, section layout, validation rules, and visibility."
size="lg"
>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 text-[#D40000] rounded-[10px] text-[13px] font-medium flex items-center gap-2">
<XCircleIcon size={18} weight="fill" />
{error}
</div>
)}
<form onSubmit={onSubmit} className="space-y-4 pt-1">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
Field Label / Name <span className="text-red-500">*</span>
</label>
<CustomInput
value={formData.fieldName}
onChange={(e) => {
const val = e.target.value;
setFormData((prev) => ({
...prev,
fieldName: val,
fieldCode: editingField ? prev.fieldCode : val.toLowerCase().replace(/[^a-z0-9_]+/g, '_'),
}));
}}
placeholder="e.g. Field Name"
/>
</div>
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
Field Code (JSON Key) <span className="text-red-500">*</span>
</label>
<CustomInput
value={formData.fieldCode}
onChange={(e) => setFormData((prev) => ({ ...prev, fieldCode: e.target.value }))}
placeholder="e.g. field_code"
/>
</div>
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
Control Type <span className="text-red-500">*</span>
</label>
<CustomDropdown
options={FIELD_TYPE_OPTIONS}
value={formData.fieldType}
onChange={(val) => setFormData((prev) => ({ ...prev, fieldType: val as FieldType }))}
placeholder="Select Control Type..."
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
Master Data Lookup Source
</label>
<CustomDropdown
options={masterLookupOptions}
value={formData.lookupSource || ''}
onChange={(val) => setFormData((prev) => ({ ...prev, lookupSource: val }))}
placeholder="Select Lookup Source..."
/>
</div>
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
Form Width Layout
</label>
<CustomDropdown
options={WIDTH_OPTIONS}
value={formData.width}
onChange={(val) => setFormData((prev) => ({ ...prev, width: val as FieldWidth }))}
/>
</div>
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
Section Group Name
</label>
<CustomInput
value={formData.section || ''}
onChange={(e) => setFormData((prev) => ({ ...prev, section: e.target.value }))}
placeholder="e.g. General Information"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">Placeholder</label>
<CustomInput
value={formData.placeholder || ''}
onChange={(e) => setFormData((prev) => ({ ...prev, placeholder: e.target.value }))}
placeholder="e.g. Enter value..."
/>
</div>
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">Default Value</label>
<CustomInput
value={formData.defaultValue || ''}
onChange={(e) => setFormData((prev) => ({ ...prev, defaultValue: e.target.value }))}
placeholder="e.g. Default"
/>
</div>
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">Display Order</label>
<CustomInput
type="number"
value={String(formData.displayOrder)}
onChange={(e) => setFormData((prev) => ({ ...prev, displayOrder: parseInt(e.target.value) || 1 }))}
/>
</div>
</div>
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">Help Text / Instructions</label>
<CustomTextArea
value={formData.helpText || ''}
onChange={(e) => setFormData((prev) => ({ ...prev, helpText: e.target.value }))}
placeholder="e.g. Instructions for end users filling this field"
rows={2}
/>
</div>
{/* Validation JSON Rules */}
<div className="p-4 bg-slate-50 border border-slate-200 rounded-[14px] space-y-3">
<h5 className="text-[13px] font-bold text-slate-800">Validation Rules (validation_json)</h5>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-[12px] font-semibold text-slate-600 mb-1">Min Value / Length</label>
<CustomInput
type="number"
value={formData.validationJson?.min !== undefined ? String(formData.validationJson.min) : ''}
onChange={(e) =>
setFormData((prev) => ({
...prev,
validationJson: {
...prev.validationJson,
min: e.target.value !== '' ? Number(e.target.value) : undefined,
},
}))
}
placeholder="e.g. 312"
/>
</div>
<div>
<label className="block text-[12px] font-semibold text-slate-600 mb-1">Max Value / Length</label>
<CustomInput
type="number"
value={formData.validationJson?.max !== undefined ? String(formData.validationJson.max) : ''}
onChange={(e) =>
setFormData((prev) => ({
...prev,
validationJson: {
...prev.validationJson,
max: e.target.value !== '' ? Number(e.target.value) : undefined,
},
}))
}
placeholder="e.g. 245"
/>
</div>
<div>
<label className="block text-[12px] font-semibold text-slate-600 mb-1">Regex Pattern</label>
<CustomInput
value={formData.validationJson?.regex || ''}
onChange={(e) =>
setFormData((prev) => ({
...prev,
validationJson: {
...prev.validationJson,
regex: e.target.value,
},
}))
}
placeholder="e.g. ^[A-Z0-9]+$"
/>
</div>
</div>
</div>
{/* Conditional Visibility */}
<div className="p-4 bg-amber-50/60 border border-amber-200 rounded-[14px] space-y-3">
<h5 className="text-[13px] font-bold text-amber-900">Conditional Visibility (visibility_condition_json)</h5>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-[12px] font-semibold text-slate-600 mb-1">Depends on Field Code</label>
<CustomDropdown
options={dependentOptions}
value={formData.visibilityConditionJson?.field || ''}
onChange={(val) =>
setFormData((prev) => ({
...prev,
visibilityConditionJson: {
...(prev.visibilityConditionJson || { operator: 'equals', value: '' }),
field: val,
},
}))
}
placeholder="Select dependent field..."
/>
</div>
<div>
<label className="block text-[12px] font-semibold text-slate-600 mb-1">Operator</label>
<CustomDropdown
options={[
{ label: 'Equals', value: 'equals' },
{ label: 'Not Equals', value: 'not_equals' },
{ label: 'Contains', value: 'contains' },
]}
value={formData.visibilityConditionJson?.operator || 'equals'}
onChange={(val) =>
setFormData((prev) => ({
...prev,
visibilityConditionJson: {
...(prev.visibilityConditionJson || { field: '', value: '' }),
operator: val as any,
},
}))
}
/>
</div>
<div>
<label className="block text-[12px] font-semibold text-slate-600 mb-1">Target Value</label>
<CustomInput
value={formData.visibilityConditionJson?.value || ''}
onChange={(e) =>
setFormData((prev) => ({
...prev,
visibilityConditionJson: {
...(prev.visibilityConditionJson || { field: '', operator: 'equals' }),
value: e.target.value,
},
}))
}
placeholder="e.g. DGYRHTFUJKYHUSDERTYUIK"
/>
</div>
</div>
</div>
<div className="flex items-center justify-between pt-4 border-t border-gray-100 mt-6">
<div className="flex items-center gap-6">
<div className="flex items-center gap-2">
<span className="text-[13px] font-semibold text-slate-700">Required Field</span>
<CustomSwitch
checked={formData.isRequired}
onChange={(e) => setFormData((prev) => ({ ...prev, isRequired: e.target.checked }))}
/>
</div>
<div className="flex items-center gap-2">
<span className="text-[13px] font-semibold text-slate-700">Active Status</span>
<CustomSwitch
checked={formData.isActive}
onChange={(e) => setFormData((prev) => ({ ...prev, isActive: e.target.checked }))}
/>
</div>
</div>
<div className="flex items-center gap-3">
<CustomButton
variant="outlined"
type="button"
onClick={onClose}
className="!border-gray-300 !text-gray-600 font-semibold px-5"
>
Cancel
</CustomButton>
<CustomButton
variant="primary"
type="submit"
className="!bg-[#1E7D5C] hover:!bg-[#17664B] font-semibold px-6 shadow-sm"
>
{editingField ? 'Save Changes' : 'Create Field Definition'}
</CustomButton>
</div>
</div>
</form>
</CustomModal>
);
}
@@ -0,0 +1,6 @@
export { CategoriesColumn } from './CategoriesColumn';
export { ActionTypesColumn } from './ActionTypesColumn';
export { ConfigurationFieldsColumn } from './ConfigurationFieldsColumn';
export { CategoryFormModal } from './CategoryFormModal';
export { ActionTypeFormModal } from './ActionTypeFormModal';
export { FieldDefinitionFormModal } from './FieldDefinitionFormModal';
@@ -0,0 +1,646 @@
import { useState, useEffect } from 'react';
import { CheckCircleIcon } from '@phosphor-icons/react';
import { CustomConfirmationModal, CustomLoader } from '../../../components/custom';
import {
CategoriesColumn,
ActionTypesColumn,
ConfigurationFieldsColumn,
CategoryFormModal,
ActionTypeFormModal,
FieldDefinitionFormModal,
} from './components';
import {
getActionCategories,
getActionTypes,
createActionCategory,
updateActionCategory,
deleteActionCategory,
createActionType,
updateActionType,
deleteActionType,
getFieldDefinitions,
createFieldDefinition,
updateFieldDefinition,
deleteFieldDefinition,
reorderFieldDefinitions,
} from './ActionBuilderApi';
import { getMasterCategories } from '../masterData/MasterDataApi';
import type {
ActionCategory,
ActionType,
FieldDefinition,
ActionCategoryFormData,
ActionTypeFormData,
FieldDefinitionFormData,
FieldType,
} from './ActionBuilderTypes';
export default function ActionBuilderPage() {
// Data States
const [categories, setCategories] = useState<ActionCategory[]>([]);
const [selectedCategoryId, setSelectedCategoryId] = useState<string>('');
const [actionTypes, setActionTypes] = useState<ActionType[]>([]);
const [selectedActionTypeId, setSelectedActionTypeId] = useState<string>('');
const [fields, setFields] = useState<FieldDefinition[]>([]);
const [fieldCountMap, setFieldCountMap] = useState<Record<string, number>>({});
const [masterLookupOptions, setMasterLookupOptions] = useState<{ label: string; value: string }[]>([
{ label: 'None (Manual / Free-text)', value: '' },
]);
// Loading & Notification States
const [loadingCategories, setLoadingCategories] = useState<boolean>(true);
const [loadingFields, setLoadingFields] = useState<boolean>(false);
const [successMsg, setSuccessMsg] = useState<string | null>(null);
// ─── Modal States ──────────────────────────────────────────────────────────
// Category Modal
const [isCategoryModalOpen, setIsCategoryModalOpen] = useState(false);
const [editingCategory, setEditingCategory] = useState<ActionCategory | null>(null);
const [categoryFormData, setCategoryFormData] = useState<ActionCategoryFormData>({
code: '',
name: '',
description: '',
displayOrder: 1,
isActive: true,
});
const [categoryError, setCategoryError] = useState<string | null>(null);
// Action Type Modal
const [isTypeModalOpen, setIsTypeModalOpen] = useState(false);
const [editingType, setEditingType] = useState<ActionType | null>(null);
const [typeFormData, setTypeFormData] = useState<ActionTypeFormData>({
categoryId: '',
code: '',
name: '',
description: '',
displayOrder: 1,
isActive: true,
});
const [typeError, setTypeError] = useState<string | null>(null);
// Field Definition Modal
const [isFieldModalOpen, setIsFieldModalOpen] = useState(false);
const [editingField, setEditingField] = useState<FieldDefinition | null>(null);
const [fieldFormData, setFieldFormData] = useState<FieldDefinitionFormData>({
fieldCode: '',
fieldName: '',
fieldType: 'textbox' as FieldType,
lookupSource: '',
isRequired: false,
defaultValue: '',
placeholder: '',
helpText: '',
width: 'full',
section: 'General Information',
displayOrder: 1,
isActive: true,
validationJson: { min: undefined, max: undefined, regex: '' },
visibilityConditionJson: { field: '', operator: 'equals', value: '' },
});
const [fieldError, setFieldError] = useState<string | null>(null);
// Delete Confirmation Modals
const [deleteCategoryTarget, setDeleteCategoryTarget] = useState<ActionCategory | null>(null);
const [deleteCategoryError, setDeleteCategoryError] = useState<string | null>(null);
const [deleteTypeTarget, setDeleteTypeTarget] = useState<ActionType | null>(null);
const [deleteFieldTarget, setDeleteFieldTarget] = useState<FieldDefinition | null>(null);
// ─── Data Fetching ────────────────────────────────────────────────────────
const fetchCategoriesAndTypes = async () => {
setLoadingCategories(true);
try {
const [catsRes, typesRes] = await Promise.all([
getActionCategories().catch(() => []),
getActionTypes().catch(() => []),
]);
const catsList = Array.isArray(catsRes) ? catsRes : [];
const typesList = Array.isArray(typesRes) ? typesRes : [];
setCategories(catsList);
setActionTypes(typesList);
// Default selection if none selected
if (catsList.length > 0 && !selectedCategoryId) {
setSelectedCategoryId(catsList[0].id);
}
// Pre-fetch field counts for each type
const counts: Record<string, number> = {};
await Promise.all(
typesList.map(async (t) => {
try {
const fList = await getFieldDefinitions(t.id);
counts[t.id] = fList ? fList.length : 0;
} catch {
counts[t.id] = 0;
}
})
);
setFieldCountMap(counts);
} catch (e) {
console.error('Error fetching categories/types data', e);
} finally {
setLoadingCategories(false);
}
};
useEffect(() => {
fetchCategoriesAndTypes();
getMasterCategories()
.then((masterCats) => {
if (Array.isArray(masterCats) && masterCats.length > 0) {
const opts = masterCats.map((c) => ({
label: `${c.name} (${c.code})`,
value: c.code,
}));
setMasterLookupOptions([
{ label: 'None (Manual / Free-text)', value: '' },
...opts,
]);
}
})
.catch(() => { });
}, []);
// When selected category changes, auto-select first action type in that category
useEffect(() => {
if (!selectedCategoryId) {
setSelectedActionTypeId('');
setFields([]);
return;
}
const catTypes = actionTypes.filter((t) => t.categoryId === selectedCategoryId);
if (catTypes.length > 0) {
if (!catTypes.some((t) => t.id === selectedActionTypeId)) {
setSelectedActionTypeId(catTypes[0].id);
}
} else {
setSelectedActionTypeId('');
setFields([]);
}
}, [selectedCategoryId, actionTypes]);
// When selected action type changes, fetch fields
const fetchFieldsForType = async (atId: string) => {
if (!atId) {
setFields([]);
return;
}
setLoadingFields(true);
try {
const res = await getFieldDefinitions(atId);
const fieldList = res || [];
setFields(fieldList);
setFieldCountMap((prev) => ({ ...prev, [atId]: fieldList.length }));
} catch (e) {
console.error('Failed loading field definitions', e);
setFields([]);
} finally {
setLoadingFields(false);
}
};
useEffect(() => {
if (selectedActionTypeId) {
fetchFieldsForType(selectedActionTypeId);
} else {
setFields([]);
}
}, [selectedActionTypeId]);
const showSuccess = (msg: string) => {
setSuccessMsg(msg);
setTimeout(() => setSuccessMsg(null), 3000);
};
// ─── Category CRUD Handlers ───────────────────────────────────────────────
const handleOpenAddCategory = () => {
setEditingCategory(null);
setCategoryFormData({
code: '',
name: '',
description: '',
displayOrder: categories.length + 1,
isActive: true,
});
setCategoryError(null);
setIsCategoryModalOpen(true);
};
const handleOpenEditCategory = (cat: ActionCategory, e?: React.MouseEvent) => {
if (e) e.stopPropagation();
setEditingCategory(cat);
setCategoryFormData({
code: cat.code,
name: cat.name,
description: cat.description || '',
displayOrder: cat.displayOrder || 1,
isActive: cat.isActive !== false,
});
setCategoryError(null);
setIsCategoryModalOpen(true);
};
const handleSaveCategory = async (e: React.FormEvent) => {
e.preventDefault();
setCategoryError(null);
if (!categoryFormData.name.trim()) {
setCategoryError('Category Name is required.');
return;
}
if (!categoryFormData.code.trim()) {
setCategoryError('Category Code is required.');
return;
}
try {
if (editingCategory) {
await updateActionCategory(editingCategory.id, categoryFormData);
showSuccess(`Category "${categoryFormData.name}" updated successfully.`);
} else {
const created = await createActionCategory(categoryFormData);
showSuccess(`Category "${categoryFormData.name}" created successfully.`);
if (created?.id) setSelectedCategoryId(created.id);
}
setIsCategoryModalOpen(false);
await fetchCategoriesAndTypes();
} catch (err: any) {
setCategoryError(err.message || 'Failed to save category.');
}
};
const handleConfirmDeleteCategory = async () => {
if (!deleteCategoryTarget) return;
setDeleteCategoryError(null);
const hasChildTypes = actionTypes.some((t) => t.categoryId === deleteCategoryTarget.id);
if (hasChildTypes) {
setDeleteCategoryError('Cannot delete category because it has associated Action Types.');
return;
}
try {
await deleteActionCategory(deleteCategoryTarget.id);
showSuccess(`Category deleted successfully.`);
setDeleteCategoryTarget(null);
await fetchCategoriesAndTypes();
} catch (err: any) {
setDeleteCategoryError(err.message || 'Failed to delete category.');
}
};
// ─── Action Type CRUD Handlers ────────────────────────────────────────────
const handleOpenAddType = () => {
setEditingType(null);
setTypeFormData({
categoryId: selectedCategoryId || (categories[0]?.id ?? ''),
code: '',
name: '',
description: '',
displayOrder: actionTypes.length + 1,
isActive: true,
});
setTypeError(null);
setIsTypeModalOpen(true);
};
const handleOpenEditType = (t: ActionType, e?: React.MouseEvent) => {
if (e) e.stopPropagation();
setEditingType(t);
setTypeFormData({
categoryId: t.categoryId,
code: t.code,
name: t.name,
description: t.description || '',
displayOrder: t.displayOrder || 1,
isActive: t.isActive !== false,
});
setTypeError(null);
setIsTypeModalOpen(true);
};
const handleSaveType = async (e: React.FormEvent) => {
e.preventDefault();
setTypeError(null);
if (!typeFormData.categoryId) {
setTypeError('Please select a Category.');
return;
}
if (!typeFormData.name.trim()) {
setTypeError('Action Type Name is required.');
return;
}
if (!typeFormData.code.trim()) {
setTypeError('Action Type Code is required.');
return;
}
try {
if (editingType) {
await updateActionType(editingType.id, typeFormData);
showSuccess(`Action Type "${typeFormData.name}" updated successfully.`);
} else {
const created = await createActionType(typeFormData);
showSuccess(`Action Type "${typeFormData.name}" created successfully.`);
if (created?.id) setSelectedActionTypeId(created.id);
}
setIsTypeModalOpen(false);
await fetchCategoriesAndTypes();
} catch (err: any) {
setTypeError(err.message || 'Failed to save action type.');
}
};
const handleConfirmDeleteType = async () => {
if (!deleteTypeTarget) return;
try {
await deleteActionType(deleteTypeTarget.id);
showSuccess('Action Type deleted successfully.');
setDeleteTypeTarget(null);
await fetchCategoriesAndTypes();
} catch (err: any) {
alert(err.message || 'Failed to delete action type.');
}
};
// ─── Field Definition CRUD Handlers ───────────────────────────────────────
const handleOpenAddField = () => {
if (!selectedActionTypeId) {
alert('Please select an Action Type first.');
return;
}
setEditingField(null);
setFieldFormData({
fieldCode: '',
fieldName: '',
fieldType: 'textbox' as FieldType,
lookupSource: '',
isRequired: false,
defaultValue: '',
placeholder: '',
helpText: '',
width: 'full',
section: 'General Information',
displayOrder: fields.length + 1,
isActive: true,
validationJson: { min: undefined, max: undefined, regex: '' },
visibilityConditionJson: { field: '', operator: 'equals', value: '' },
});
setFieldError(null);
setIsFieldModalOpen(true);
};
const handleOpenEditField = (f: FieldDefinition) => {
setEditingField(f);
setFieldFormData({
fieldCode: f.fieldCode,
fieldName: f.fieldName,
fieldType: f.fieldType,
lookupSource: f.lookupSource || '',
isRequired: f.isRequired,
defaultValue: f.defaultValue || '',
placeholder: f.placeholder || '',
helpText: f.helpText || '',
width: f.width || 'full',
section: f.section || 'General Information',
displayOrder: f.displayOrder || 1,
isActive: f.isActive !== false,
validationJson: f.validationJson || { min: undefined, max: undefined, regex: '' },
visibilityConditionJson: f.visibilityConditionJson || { field: '', operator: 'equals', value: '' },
});
setFieldError(null);
setIsFieldModalOpen(true);
};
const handleSaveField = async (e: React.FormEvent) => {
e.preventDefault();
setFieldError(null);
if (!fieldFormData.fieldName.trim()) {
setFieldError('Field Name is required.');
return;
}
if (!fieldFormData.fieldCode.trim()) {
setFieldError('Field Code is required.');
return;
}
const payloadData: FieldDefinitionFormData = {
...fieldFormData,
fieldCode: fieldFormData.fieldCode.trim().toLowerCase().replace(/[^a-z0-9_]+/g, '_'),
validationJson:
fieldFormData.validationJson?.min !== undefined ||
fieldFormData.validationJson?.max !== undefined ||
fieldFormData.validationJson?.regex
? fieldFormData.validationJson
: undefined,
visibilityConditionJson:
fieldFormData.visibilityConditionJson?.field && fieldFormData.visibilityConditionJson?.value
? fieldFormData.visibilityConditionJson
: undefined,
};
try {
if (editingField) {
await updateFieldDefinition(editingField.id, payloadData);
showSuccess(`Field "${fieldFormData.fieldName}" updated successfully.`);
} else {
await createFieldDefinition(selectedActionTypeId, payloadData);
showSuccess(`Field "${fieldFormData.fieldName}" created successfully.`);
}
setIsFieldModalOpen(false);
await fetchFieldsForType(selectedActionTypeId);
} catch (err: any) {
setFieldError(err.message || 'Failed to save field definition.');
}
};
const handleConfirmDeleteField = async () => {
if (!deleteFieldTarget) return;
try {
await deleteFieldDefinition(deleteFieldTarget.id);
showSuccess('Field definition deleted successfully.');
setDeleteFieldTarget(null);
await fetchFieldsForType(selectedActionTypeId);
} catch (err: any) {
alert(err.message || 'Failed to delete field definition.');
}
};
const handleReorderFields = async (fromIndex: number, toIndex: number) => {
if (fromIndex < 0 || toIndex < 0 || fromIndex >= fields.length || toIndex >= fields.length) return;
const newFields = [...fields];
const [movedItem] = newFields.splice(fromIndex, 1);
newFields.splice(toIndex, 0, movedItem);
const updatedFields = newFields.map((f, idx) => ({ ...f, displayOrder: idx + 1 }));
setFields(updatedFields);
try {
await reorderFieldDefinitions(
selectedActionTypeId,
updatedFields.map((f) => f.id)
);
showSuccess('Fields reordered successfully.');
} catch (err: any) {
console.error('Failed to persist field order:', err);
fetchFieldsForType(selectedActionTypeId);
}
};
return (
<div className="w-full space-y-5 font-sans">
{/* Top Banner / Notification */}
{successMsg && (
<div className="p-3.5 bg-[#EAFAF5] border border-[#1E7D5C]/30 text-[#14704E] rounded-[12px] text-[14px] font-medium flex items-center gap-2 animate-fadeIn">
<CheckCircleIcon size={20} weight="fill" />
{successMsg}
</div>
)}
{/* 3-Column Drilldown Layout */}
{loadingCategories && categories.length === 0 ? (
<div className="py-20 flex justify-center bg-white rounded-[20px] border border-gray-100 shadow-sm">
<CustomLoader label="Loading Action Builder Configurations..." />
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-10 gap-2 items-start">
{/* COLUMN 1: CATEGORIES */}
<div className="lg:col-span-3">
<CategoriesColumn
categories={categories}
selectedCategoryId={selectedCategoryId}
onSelectCategory={setSelectedCategoryId}
actionTypes={actionTypes}
onOpenAdd={handleOpenAddCategory}
onOpenEdit={handleOpenEditCategory}
onDelete={(cat, e) => {
e.stopPropagation();
setDeleteCategoryTarget(cat);
}}
/>
</div>
{/* COLUMN 2: ACTION TYPES */}
<div className="lg:col-span-3">
<ActionTypesColumn
actionTypes={actionTypes}
selectedCategoryId={selectedCategoryId}
selectedActionTypeId={selectedActionTypeId}
onSelectActionType={setSelectedActionTypeId}
fieldCountMap={fieldCountMap}
onOpenAdd={handleOpenAddType}
onOpenEdit={handleOpenEditType}
onDelete={(type, e) => {
e.stopPropagation();
setDeleteTypeTarget(type);
}}
/>
</div>
{/* COLUMN 3: CONFIGURATION FIELDS */}
<div className="lg:col-span-4">
<ConfigurationFieldsColumn
fields={fields}
selectedActionTypeId={selectedActionTypeId}
loading={loadingFields}
onOpenAdd={handleOpenAddField}
onOpenEdit={handleOpenEditField}
onDelete={setDeleteFieldTarget}
onReorderFields={handleReorderFields}
/>
</div>
</div>
)}
{/* ─── MODALS ───────────────────────────────────────────────────────────── */}
<CategoryFormModal
isOpen={isCategoryModalOpen}
editingCategory={editingCategory}
formData={categoryFormData}
setFormData={setCategoryFormData}
error={categoryError}
onClose={() => setIsCategoryModalOpen(false)}
onSubmit={handleSaveCategory}
/>
<ActionTypeFormModal
isOpen={isTypeModalOpen}
editingType={editingType}
categories={categories}
formData={typeFormData}
setFormData={setTypeFormData}
error={typeError}
onClose={() => setIsTypeModalOpen(false)}
onSubmit={handleSaveType}
/>
<FieldDefinitionFormModal
isOpen={isFieldModalOpen}
editingField={editingField}
fields={fields}
masterLookupOptions={masterLookupOptions}
formData={fieldFormData}
setFormData={setFieldFormData}
error={fieldError}
onClose={() => setIsFieldModalOpen(false)}
onSubmit={handleSaveField}
/>
{/* ─── CONFIRMATION DELETE MODALS ──────────────────────────────────────── */}
{deleteCategoryTarget && (
<CustomConfirmationModal
isOpen={!!deleteCategoryTarget}
title={`Delete Category: ${deleteCategoryTarget.name}`}
description={
deleteCategoryError
? deleteCategoryError
: `Are you sure you want to delete "${deleteCategoryTarget.name}"? This action cannot be undone.`
}
onConfirm={handleConfirmDeleteCategory}
onClose={() => {
setDeleteCategoryTarget(null);
setDeleteCategoryError(null);
}}
confirmText="Delete Category"
cancelText="Cancel"
variant="danger"
/>
)}
{deleteTypeTarget && (
<CustomConfirmationModal
isOpen={!!deleteTypeTarget}
title={`Delete Action Type: ${deleteTypeTarget.name}`}
description={`Are you sure you want to delete "${deleteTypeTarget.name}"? This action cannot be undone.`}
onConfirm={handleConfirmDeleteType}
onClose={() => setDeleteTypeTarget(null)}
confirmText="Delete Action Type"
cancelText="Cancel"
variant="danger"
/>
)}
{deleteFieldTarget && (
<CustomConfirmationModal
isOpen={!!deleteFieldTarget}
title={`Delete Field: ${deleteFieldTarget.fieldName}`}
description={`Are you sure you want to delete "${deleteFieldTarget.fieldName}"? This action cannot be undone.`}
onConfirm={handleConfirmDeleteField}
onClose={() => setDeleteFieldTarget(null)}
confirmText="Delete Field Definition"
cancelText="Cancel"
variant="danger"
/>
)}
</div>
);
}
+38
View File
@@ -0,0 +1,38 @@
import { useSearchParams } from 'react-router-dom';
import ActionBuilderPage from './actionBuilder';
import MasterDataManagement from './masterData';
import { CustomTabs } from '../../components/custom';
export default function ConfigurationPage() {
const [searchParams, setSearchParams] = useSearchParams();
const currentTabParam = searchParams.get('tab');
const activeTab = currentTabParam === 'master-data' ? 'master-data' : 'action-builder';
const handleTabChange = (tabId: string) => {
setSearchParams({ tab: tabId });
};
const tabs = [
{
id: 'action-builder',
label: 'Action Builder',
content: <ActionBuilderPage />,
},
{
id: 'master-data',
label: 'Master Data Configurations',
content: <MasterDataManagement />,
},
];
return (
<div className="w-full space-y-6 font-sans">
<CustomTabs
tabs={tabs}
value={activeTab}
onChange={handleTabChange}
/>
</div>
);
}
@@ -0,0 +1,161 @@
import { ApiClient } from '../../api/ApiClient';
import type {
MasterDataCategoryItem,
MasterDataItem,
MasterDataValueFormData,
} from './MasterDataTypes';
// Helper to normalize items so label and value are guaranteed
function normalizeItems(items: any[]): MasterDataItem[] {
if (!Array.isArray(items)) return [];
return items.map((item) => {
const val = item.value || item.label || item.name || item.code || '';
const lbl = item.label || item.value || item.name || item.code || '';
return {
...item,
id: item.id || val,
value: val,
label: lbl,
isActive: item.isActive !== false,
};
});
}
// ─── Lookup Tables & Categories List Endpoint ─────────────────────────────────
export function getLookupTables(): Promise<MasterDataCategoryItem[]> {
return ApiClient.get<any, MasterDataCategoryItem[]>('/master-data/lookup-tables')
.then((res) => (Array.isArray(res) ? res : []))
.catch(() => []);
}
export function getMasterCategories(): Promise<MasterDataCategoryItem[]> {
return getLookupTables()
.then((tables) => {
if (Array.isArray(tables) && tables.length > 0) return tables;
return ApiClient.get<any, MasterDataCategoryItem[]>('/master-data/categories')
.then((res) => (Array.isArray(res) ? res : []))
.catch(() => []);
})
.catch(() => []);
}
// ─── Category Values Endpoint ───────────────────────────────────────────────
export function getCategoryValues(categoryCode: string): Promise<MasterDataItem[]> {
if (!categoryCode) return Promise.resolve([]);
return ApiClient.get<any, any[]>(`/master-data/${categoryCode}`)
.then((res) => normalizeItems(res))
.catch(() => []);
}
// ─── Generic Master Data Item CRUD ──────────────────────────────────────────
export function getMasterDataByCategory(category: string): Promise<MasterDataItem[]> {
return getCategoryValues(category);
}
export function createCategoryValue(
categoryCode: string,
payload: MasterDataValueFormData,
): Promise<MasterDataItem> {
return ApiClient.post<any, MasterDataItem>(`/master-data/${categoryCode}`, {
...payload,
label: payload.value,
});
}
export function updateCategoryValue(
categoryCode: string,
id: string,
payload: Partial<MasterDataValueFormData>,
): Promise<MasterDataItem> {
return ApiClient.put<any, MasterDataItem>(`/master-data/${categoryCode}/${id}`, {
...payload,
label: payload.value || payload.label,
});
}
export function deleteCategoryValue(categoryCode: string, id: string): Promise<void> {
return ApiClient.delete<any, void>(`/master-data/${categoryCode}/${id}`);
}
// ─── Individual Category Helpers for Cohort & Policy Modules ─────────────────
export function getMembershipTiers(): Promise<MasterDataItem[]> {
return getCategoryValues('membership-tier');
}
export function getCustomerValues(): Promise<MasterDataItem[]> {
return getCategoryValues('customer-value');
}
export function getRegions(): Promise<MasterDataItem[]> {
return getCategoryValues('region');
}
export function getTripPurposes(): Promise<MasterDataItem[]> {
return getCategoryValues('trip-purpose');
}
export function getCabinClasses(): Promise<MasterDataItem[]> {
return getCategoryValues('cabin-class');
}
export function getPassengerTypes(): Promise<MasterDataItem[]> {
return getCategoryValues('passenger-type');
}
export function getAncillaryPurchases(): Promise<MasterDataItem[]> {
return getCategoryValues('ancillary-purchase');
}
export function getRevenueSegments(): Promise<MasterDataItem[]> {
return getCategoryValues('revenue-segment');
}
// ─── Fetch All Master Data at Once ──────────────────────────────────────────
export interface AllMasterData {
membershipTiers: MasterDataItem[];
customerValues: MasterDataItem[];
regions: MasterDataItem[];
tripPurposes: MasterDataItem[];
cabinClasses: MasterDataItem[];
passengerTypes: MasterDataItem[];
ancillaryPurchases: MasterDataItem[];
revenueSegments: MasterDataItem[];
}
export async function getAllMasterData(): Promise<AllMasterData> {
const [
membershipTiers,
customerValues,
regions,
tripPurposes,
cabinClasses,
passengerTypes,
ancillaryPurchases,
revenueSegments,
] = await Promise.all([
getMembershipTiers(),
getCustomerValues(),
getRegions(),
getTripPurposes(),
getCabinClasses(),
getPassengerTypes(),
getAncillaryPurchases(),
getRevenueSegments(),
]);
return {
membershipTiers,
customerValues,
regions,
tripPurposes,
cabinClasses,
passengerTypes,
ancillaryPurchases,
revenueSegments,
};
}
@@ -0,0 +1,37 @@
// ─── Master Data Category Definition ────────────────────────────────────────
export interface MasterDataCategoryItem {
id: string;
code: string;
name: string;
tableName?: string;
description?: string;
displayOrder?: number;
isActive?: boolean;
createdAt?: string;
updatedAt?: string;
}
// ─── Master Data Item Value ──────────────────────────────────────────────────
export interface MasterDataItem {
id: string;
label: string;
value: string;
code?: string;
name?: string;
displayOrder?: number;
isActive: boolean;
createdAt?: string;
updatedAt?: string;
}
// ─── Form Data for Add / Edit Item ───────────────────────────────────────────
export interface MasterDataValueFormData {
code?: string;
value: string;
label?: string;
displayOrder?: number;
isActive: boolean;
}
@@ -0,0 +1,105 @@
import React from 'react';
import { ListBulletsIcon, MagnifyingGlassIcon, DatabaseIcon } from '@phosphor-icons/react';
import { CustomInput, CustomLoader } from '../../../../components/custom';
import type { MasterDataCategoryItem } from '../MasterDataTypes';
interface CategorySidebarProps {
filteredCategories: MasterDataCategoryItem[];
selectedCategory: MasterDataCategoryItem | null;
onSelectCategory: (category: MasterDataCategoryItem) => void;
loading: boolean;
searchQuery: string;
onSearchChange: (query: string) => void;
}
export const CategorySidebar: React.FC<CategorySidebarProps> = ({
filteredCategories,
selectedCategory,
onSelectCategory,
loading,
searchQuery,
onSearchChange,
}) => {
return (
<div className="lg:col-span-4 bg-white rounded-[20px] border border-gray-200 shadow-sm p-4 space-y-4 h-fit">
<div className="flex items-center justify-between border-b border-gray-100 pb-3">
<h2 className="text-[16px] font-extrabold text-[#0F172B] flex items-center gap-2">
<ListBulletsIcon size={20} className="text-[#1E7D5C]" />
Master Categories
</h2>
<span className="text-[12px] font-bold text-slate-400 bg-slate-100 px-2 py-0.5 rounded-full">
{filteredCategories.length}
</span>
</div>
{/* Search Input */}
<CustomInput
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
placeholder="Search category name or code..."
leftIcon={<MagnifyingGlassIcon size={16} />}
className="!bg-[#F8FAFC] !rounded-[12px] !h-[38px] !border-[#E2E8F0] text-[13px]"
/>
{/* Categories Selector List */}
{loading ? (
<div className="py-8 flex justify-center">
<CustomLoader label="Loading Categories..." />
</div>
) : filteredCategories.length === 0 ? (
<div className="py-8 text-center text-slate-400 text-[13px]">
No matching master categories found.
</div>
) : (
<div className="space-y-1.5 max-h-[520px] overflow-y-auto pr-1">
{filteredCategories.map((category) => {
const isSelected = selectedCategory?.code === category.code;
return (
<button
key={category.code}
onClick={() => onSelectCategory(category)}
className={`w-full text-left p-3 rounded-[14px] transition-all duration-150 flex items-center justify-between group ${
isSelected
? 'bg-[#1E7D5C] text-white shadow-md shadow-[#1E7D5C]/20'
: 'hover:bg-slate-50 text-slate-700 border border-transparent hover:border-slate-100'
}`}
>
<div className="flex items-center gap-2.5 min-w-0">
<div
className={`w-8 h-8 rounded-[10px] flex items-center justify-center shrink-0 ${
isSelected ? 'bg-white/20 text-white' : 'bg-slate-100 text-slate-500 group-hover:bg-[#E8F3EF] group-hover:text-[#1E7D5C]'
}`}
>
<DatabaseIcon size={16} weight={isSelected ? 'bold' : 'regular'} />
</div>
<div className="min-w-0">
<h3
className={`text-[13px] font-bold truncate leading-tight ${
isSelected ? 'text-white' : 'text-[#0F172B]'
}`}
>
{category.name}
</h3>
<p
className={`text-[11px] font-mono truncate mt-0.5 ${
isSelected ? 'text-white/80' : 'text-slate-400'
}`}
>
{category.code}
</p>
</div>
</div>
<div
className={`w-2 h-2 rounded-full shrink-0 ${
isSelected ? 'bg-white' : 'bg-slate-200 group-hover:bg-[#1E7D5C]'
}`}
/>
</button>
);
})}
</div>
)}
</div>
);
};
@@ -0,0 +1,135 @@
import React from 'react';
import { XCircleIcon } from '@phosphor-icons/react';
import {
CustomModal,
CustomInput,
CustomSwitch,
CustomButton,
} from '../../../../components/custom';
import type {
MasterDataCategoryItem,
MasterDataItem,
MasterDataValueFormData,
} from '../MasterDataTypes';
interface MasterItemFormModalProps {
isOpen: boolean;
onClose: () => void;
selectedCategory: MasterDataCategoryItem | null;
editingItem: MasterDataItem | null;
formData: MasterDataValueFormData;
setFormData: React.Dispatch<React.SetStateAction<MasterDataValueFormData>>;
onSubmit: (e: React.FormEvent) => void;
errorMsg: string | null;
}
export const MasterItemFormModal: React.FC<MasterItemFormModalProps> = ({
isOpen,
onClose,
selectedCategory,
editingItem,
formData,
setFormData,
onSubmit,
errorMsg,
}) => {
return (
<CustomModal
isOpen={isOpen}
onClose={onClose}
title={
editingItem
? `Edit Master Data Item: ${editingItem.value || editingItem.label}`
: `Add New Item to ${selectedCategory?.name || 'Category'}`
}
description="Configure master lookup code, value label, display sequence, and active status."
size="md"
>
{errorMsg && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 text-[#D40000] rounded-[10px] text-[13px] font-medium flex items-center gap-2">
<XCircleIcon size={18} weight="fill" />
{errorMsg}
</div>
)}
<form onSubmit={onSubmit} className="space-y-4 pt-1">
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
Item Value / Name <span className="text-red-500">*</span>
</label>
<CustomInput
value={formData.value}
onChange={(e) => {
const val = e.target.value;
setFormData((prev) => ({
...prev,
value: val,
code: editingItem ? prev.code : val.toLowerCase().trim().replace(/[^a-z0-9_]+/g, '_'),
}));
}}
placeholder="e.g. Platinum Tier / USD"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
Code Identifier
</label>
<CustomInput
value={formData.code || ''}
onChange={(e) => setFormData((prev) => ({ ...prev, code: e.target.value }))}
placeholder="e.g. platinum_tier"
/>
</div>
<div>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
Display Sequence Order
</label>
<CustomInput
type="number"
value={String(formData.displayOrder || 1)}
onChange={(e) =>
setFormData((prev) => ({
...prev,
displayOrder: parseInt(e.target.value) || 1,
}))
}
placeholder="1"
/>
</div>
</div>
<div className="flex items-center justify-between pt-4 border-t border-gray-100 mt-6">
<div className="flex items-center gap-3">
<span className="text-[13px] font-semibold text-slate-700">Active Status</span>
<CustomSwitch
checked={formData.isActive}
onChange={(e) => setFormData((prev) => ({ ...prev, isActive: e.target.checked }))}
/>
</div>
<div className="flex items-center gap-3">
<CustomButton
variant="outlined"
type="button"
onClick={onClose}
className="!border-gray-300 !text-gray-600 font-semibold px-5"
>
Cancel
</CustomButton>
<CustomButton
variant="primary"
type="submit"
className="!bg-[#1E7D5C] hover:!bg-[#17664B] font-semibold px-6 shadow-sm"
>
{editingItem ? 'Save Changes' : 'Create Item'}
</CustomButton>
</div>
</div>
</form>
</CustomModal>
);
};
@@ -0,0 +1,207 @@
import React from 'react';
import {
PlusIcon,
MagnifyingGlassIcon,
PencilSimpleIcon,
TrashIcon,
CaretLeftIcon,
CaretRightIcon,
} from '@phosphor-icons/react';
import {
CustomButton,
CustomInput,
CustomLoader,
CustomStatus,
} from '../../../../components/custom';
import type { MasterDataCategoryItem, MasterDataItem } from '../MasterDataTypes';
interface MasterItemTableProps {
selectedCategory: MasterDataCategoryItem | null;
filteredItems: MasterDataItem[];
paginatedItems: MasterDataItem[];
loading: boolean;
searchQuery: string;
onSearchChange: (query: string) => void;
currentPage: number;
totalPages: number;
pageSize: number;
onPageChange: (page: number) => void;
onOpenCreate: () => void;
onOpenEdit: (item: MasterDataItem) => void;
onOpenDelete: (item: MasterDataItem) => void;
}
export const MasterItemTable: React.FC<MasterItemTableProps> = ({
selectedCategory,
filteredItems,
paginatedItems,
loading,
searchQuery,
onSearchChange,
currentPage,
totalPages,
pageSize,
onPageChange,
onOpenCreate,
onOpenEdit,
onOpenDelete,
}) => {
const startIndex = filteredItems.length > 0 ? (currentPage - 1) * pageSize + 1 : 0;
const endIndex = Math.min(currentPage * pageSize, filteredItems.length);
return (
<div className="lg:col-span-8 bg-white rounded-[20px] border border-gray-200 shadow-sm p-6 space-y-5 flex flex-col justify-between">
<div className="space-y-4">
{/* Panel Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-gray-100 pb-4">
<div>
<h2 className="text-lg font-extrabold text-[#0F172B]">
{selectedCategory ? `${selectedCategory.name} Values` : 'Category Items'}
</h2>
<p className="text-[13px] text-slate-500 mt-0.5">
{selectedCategory?.description || 'View and manage configured entries for this category.'}
</p>
</div>
<CustomButton
variant="primary"
onClick={onOpenCreate}
disabled={!selectedCategory}
leftIcon={<PlusIcon size={18} />}
className="!bg-[#1E7D5C] hover:!bg-[#17664B] !rounded-[12px] !gap-[8px] !h-[40px] font-semibold text-[14px] shrink-0"
>
Add Master Value
</CustomButton>
</div>
{/* Search Bar */}
<div className="w-full sm:w-80">
<CustomInput
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
placeholder="Search items by value or code..."
leftIcon={<MagnifyingGlassIcon size={16} />}
className="!bg-[#F8FAFC] !rounded-[12px] !h-[38px] !border-[#E2E8F0] text-[13px]"
/>
</div>
{/* Items Table */}
{loading ? (
<div className="py-16 flex justify-center">
<CustomLoader label="Loading Category Values..." />
</div>
) : !selectedCategory ? (
<div className="py-16 text-center text-slate-400 text-[14px]">
Select a master category from the left sidebar to view items.
</div>
) : (
<div className="overflow-x-auto border border-gray-100 rounded-[16px]">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-slate-50/80 border-b border-gray-100 text-[12px] font-bold text-slate-600 uppercase tracking-wider">
<th className="py-3 px-4 w-16">Seq</th>
<th className="py-3 px-4">Value / Label</th>
<th className="py-3 px-4">Code</th>
<th className="py-3 px-4">Status</th>
<th className="py-3 px-4 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 text-[13px]">
{paginatedItems.length === 0 ? (
<tr>
<td colSpan={5} className="py-12 text-center text-slate-400">
No items found in {selectedCategory.name}. Click "Add Master Value" to create one.
</td>
</tr>
) : (
paginatedItems.map((item, idx) => (
<tr key={item.id || idx} className="hover:bg-slate-50/60 transition-colors">
<td className="py-3 px-4 font-mono text-slate-400 font-medium">
#{item.displayOrder || idx + 1}
</td>
<td className="py-3 px-4 font-bold text-[#0F172B]">
{item.label || item.value}
</td>
<td className="py-3 px-4 font-mono text-slate-500 text-[12px]">
{item.value || '—'}
</td>
<td className="py-3 px-4">
<CustomStatus status={item.isActive !== false ? 'Active' : 'Inactive'} />
</td>
<td className="py-3 px-4 text-right">
<div className="flex items-center justify-end gap-1.5">
<button
onClick={() => onOpenEdit(item)}
title="Edit Item"
className="p-1.5 rounded-lg text-slate-500 hover:text-[#1E7D5C] hover:bg-[#E8F3EF] transition-colors"
>
<PencilSimpleIcon size={16} weight="bold" />
</button>
<button
onClick={() => onOpenDelete(item)}
title="Delete Item"
className="p-1.5 rounded-lg text-slate-400 hover:text-red-600 hover:bg-red-50 transition-colors"
>
<TrashIcon size={16} weight="bold" />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
)}
</div>
{/* Pagination Footer Bar */}
{selectedCategory && (
<div className="flex flex-col sm:flex-row items-center justify-between pt-4 border-t border-gray-100 text-[13px] text-slate-500 gap-3">
<div>
Showing <strong className="text-slate-800">{filteredItems.length > 0 ? startIndex : 0}</strong> to{' '}
<strong className="text-slate-800">{endIndex}</strong> of <strong className="text-slate-800">{filteredItems.length}</strong> items
</div>
{totalPages > 1 && (
<div className="flex items-center gap-1.5">
<button
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
className="p-1.5 rounded-lg border border-gray-200 bg-white text-slate-600 hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<CaretLeftIcon size={16} />
</button>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
<button
key={page}
onClick={() => onPageChange(page)}
className={`min-w-[32px] h-8 px-2 rounded-lg text-[13px] font-semibold transition-colors ${currentPage === page
? 'bg-[#1E7D5C] text-white shadow-sm font-bold'
: 'bg-white text-slate-600 border border-gray-200 hover:bg-slate-50'
}`}
>
{page}
</button>
))}
<button
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
className="p-1.5 rounded-lg border border-gray-200 bg-white text-slate-600 hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<CaretRightIcon size={16} />
</button>
</div>
)}
</div>
)}
</div>
);
};
@@ -0,0 +1,4 @@
export { CategorySidebar } from './CategorySidebar';
export { MasterItemTable } from './MasterItemTable';
export { MasterItemFormModal } from './MasterItemFormModal';
+267
View File
@@ -0,0 +1,267 @@
import React, { useState, useEffect } from 'react';
import { CheckCircleIcon } from '@phosphor-icons/react';
import { CustomConfirmationModal } from '../../../components/custom';
import {
getMasterCategories,
getCategoryValues,
createCategoryValue,
updateCategoryValue,
deleteCategoryValue,
} from './MasterDataApi';
import type {
MasterDataCategoryItem,
MasterDataItem,
MasterDataValueFormData,
} from './MasterDataTypes';
import {
CategorySidebar,
MasterItemTable,
MasterItemFormModal,
} from './components';
const PAGE_SIZE = 10;
export default function MasterDataManagement() {
const [categories, setCategories] = useState<MasterDataCategoryItem[]>([]);
const [loadingCategories, setLoadingCategories] = useState<boolean>(true);
const [categorySearch, setCategorySearch] = useState<string>('');
const [selectedCategory, setSelectedCategory] = useState<MasterDataCategoryItem | null>(null);
// Items State for Selected Category
const [items, setItems] = useState<MasterDataItem[]>([]);
const [loadingItems, setLoadingItems] = useState<boolean>(false);
const [itemSearch, setItemSearch] = useState<string>('');
const [currentPage, setCurrentPage] = useState<number>(1);
// Form Modal State
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingItem, setEditingItem] = useState<MasterDataItem | null>(null);
const [formData, setFormData] = useState<MasterDataValueFormData>({
code: '',
value: '',
displayOrder: 1,
isActive: true,
});
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const [successMsg, setSuccessMsg] = useState<string | null>(null);
// Delete Modal State
const [deleteTarget, setDeleteTarget] = useState<MasterDataItem | null>(null);
// Load Categories on Mount
useEffect(() => {
setLoadingCategories(true);
getMasterCategories()
.then((cats) => {
const activeList = Array.isArray(cats) ? cats : [];
setCategories(activeList);
if (activeList.length > 0) {
setSelectedCategory(activeList[0]);
}
})
.catch((e) => {
console.error('Failed to load master categories', e);
})
.finally(() => {
setLoadingCategories(false);
});
}, []);
// Load Items when Selected Category Changes
const fetchCategoryItems = async (catCode: string) => {
if (!catCode) return;
setLoadingItems(true);
setCurrentPage(1);
try {
const res = await getCategoryValues(catCode);
setItems(Array.isArray(res) ? res : []);
} catch (e) {
console.error('Failed to load category items', e);
setItems([]);
} finally {
setLoadingItems(false);
}
};
useEffect(() => {
if (selectedCategory?.code) {
fetchCategoryItems(selectedCategory.code);
}
}, [selectedCategory]);
// Filter Categories
const filteredCategories = categories.filter(
(c) =>
c.name.toLowerCase().includes(categorySearch.toLowerCase()) ||
c.code.toLowerCase().includes(categorySearch.toLowerCase()),
);
// Filter Items for Selected Category
const filteredItems = items.filter((item) => {
const term = itemSearch.toLowerCase();
const valText = (item.value || item.label || item.name || '').toLowerCase();
const codeText = (item.code || '').toLowerCase();
return valText.includes(term) || codeText.includes(term);
});
// Pagination Math
const totalPages = Math.ceil(filteredItems.length / PAGE_SIZE) || 1;
const paginatedItems = filteredItems.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE);
// Modal Handlers
const resetForm = () => {
setFormData({
code: '',
value: '',
displayOrder: items.length + 1,
isActive: true,
});
setEditingItem(null);
setErrorMsg(null);
setIsModalOpen(false);
};
const handleOpenCreate = () => {
if (!selectedCategory) return;
setEditingItem(null);
setFormData({
code: '',
value: '',
displayOrder: items.length + 1,
isActive: true,
});
setErrorMsg(null);
setIsModalOpen(true);
};
const handleOpenEdit = (item: MasterDataItem) => {
setEditingItem(item);
setFormData({
code: item.code || '',
value: item.value || item.label || item.name || '',
displayOrder: item.displayOrder || 1,
isActive: item.isActive !== false,
});
setErrorMsg(null);
setIsModalOpen(true);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedCategory) return;
setErrorMsg(null);
setSuccessMsg(null);
if (!formData.value.trim()) {
setErrorMsg('Value / Name is required.');
return;
}
const payload: MasterDataValueFormData = {
...formData,
value: formData.value.trim(),
code: formData.code?.trim() || formData.value.trim().toLowerCase().replace(/[^a-z0-9_]+/g, '_'),
};
try {
if (editingItem) {
await updateCategoryValue(selectedCategory.code, editingItem.id, payload);
setSuccessMsg(`Item "${payload.value}" updated successfully!`);
} else {
await createCategoryValue(selectedCategory.code, payload);
setSuccessMsg(`Item "${payload.value}" created successfully!`);
}
await fetchCategoryItems(selectedCategory.code);
resetForm();
setTimeout(() => setSuccessMsg(null), 3000);
} catch (err: any) {
setErrorMsg(err.message || err.response?.data?.message || 'Failed to save master data item.');
}
};
const handleConfirmDelete = async () => {
if (!deleteTarget || !selectedCategory) return;
try {
await deleteCategoryValue(selectedCategory.code, deleteTarget.id);
setDeleteTarget(null);
setSuccessMsg('Master data item deleted successfully.');
await fetchCategoryItems(selectedCategory.code);
setTimeout(() => setSuccessMsg(null), 3000);
} catch (err: any) {
setErrorMsg(err.message || 'Failed to delete master data item.');
}
};
return (
<div className="w-full space-y-6 font-sans">
{/* Success Notification Banner */}
{successMsg && (
<div className="p-4 bg-[#EAFAF5] border border-[#1E7D5C]/30 text-[#14704E] rounded-[14px] text-[14px] font-medium flex items-center gap-2 animate-fadeIn">
<CheckCircleIcon size={20} weight="fill" />
{successMsg}
</div>
)}
{/* Main Split Layout */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
{/* Left Panel: Categories Selector */}
<CategorySidebar
filteredCategories={filteredCategories}
selectedCategory={selectedCategory}
onSelectCategory={setSelectedCategory}
loading={loadingCategories}
searchQuery={categorySearch}
onSearchChange={setCategorySearch}
/>
{/* Right Panel: Category Items Table */}
<MasterItemTable
selectedCategory={selectedCategory}
filteredItems={filteredItems}
paginatedItems={paginatedItems}
loading={loadingItems}
searchQuery={itemSearch}
onSearchChange={(query) => {
setItemSearch(query);
setCurrentPage(1);
}}
currentPage={currentPage}
totalPages={totalPages}
pageSize={PAGE_SIZE}
onPageChange={setCurrentPage}
onOpenCreate={handleOpenCreate}
onOpenEdit={handleOpenEdit}
onOpenDelete={setDeleteTarget}
/>
</div>
{/* Add / Edit Master Data Item Modal */}
<MasterItemFormModal
isOpen={isModalOpen}
onClose={resetForm}
selectedCategory={selectedCategory}
editingItem={editingItem}
formData={formData}
setFormData={setFormData}
onSubmit={handleSubmit}
errorMsg={errorMsg}
/>
{/* Delete Confirmation Modal */}
{deleteTarget && (
<CustomConfirmationModal
isOpen={!!deleteTarget}
title={`Delete Master Item: ${deleteTarget.value || deleteTarget.label}`}
description={`Are you sure you want to delete "${deleteTarget.value || deleteTarget.label}" from ${selectedCategory?.name}?`}
onConfirm={handleConfirmDelete}
onClose={() => setDeleteTarget(null)}
confirmText="Delete Item"
cancelText="Cancel"
variant="danger"
/>
)}
</div>
);
}
-88
View File
@@ -1,88 +0,0 @@
import { ApiClient } from '../api/ApiClient';
import type { MasterDataItem, MasterDataCategory } from './MasterDataTypes';
// ─── Generic fetch for any category ─────────────────────────────────────────
export function getMasterDataByCategory(category: MasterDataCategory): Promise<MasterDataItem[]> {
return ApiClient.get<any, MasterDataItem[]>(`/master-data/${category}`);
}
// ─── Individual category helpers ─────────────────────────────────────────────
export function getMembershipTiers(): Promise<MasterDataItem[]> {
return getMasterDataByCategory('MEMBERSHIP_TIER');
}
export function getCustomerValues(): Promise<MasterDataItem[]> {
return getMasterDataByCategory('CUSTOMER_VALUE');
}
export function getRegions(): Promise<MasterDataItem[]> {
return getMasterDataByCategory('REGION');
}
export function getTripPurposes(): Promise<MasterDataItem[]> {
return getMasterDataByCategory('TRIP_PURPOSE');
}
export function getCabinClasses(): Promise<MasterDataItem[]> {
return getMasterDataByCategory('CABIN_CLASS');
}
export function getPassengerTypes(): Promise<MasterDataItem[]> {
return getMasterDataByCategory('PASSENGER_TYPE');
}
export function getAncillaryPurchases(): Promise<MasterDataItem[]> {
return getMasterDataByCategory('ANCILLARY_PURCHASE');
}
export function getRevenueSegments(): Promise<MasterDataItem[]> {
return getMasterDataByCategory('REVENUE_SEGMENT');
}
// ─── Fetch all 8 categories at once ─────────────────────────────────────────
export interface AllMasterData {
membershipTiers: MasterDataItem[];
customerValues: MasterDataItem[];
regions: MasterDataItem[];
tripPurposes: MasterDataItem[];
cabinClasses: MasterDataItem[];
passengerTypes: MasterDataItem[];
ancillaryPurchases: MasterDataItem[];
revenueSegments: MasterDataItem[];
}
export async function getAllMasterData(): Promise<AllMasterData> {
const [
membershipTiers,
customerValues,
regions,
tripPurposes,
cabinClasses,
passengerTypes,
ancillaryPurchases,
revenueSegments,
] = await Promise.all([
getMembershipTiers(),
getCustomerValues(),
getRegions(),
getTripPurposes(),
getCabinClasses(),
getPassengerTypes(),
getAncillaryPurchases(),
getRevenueSegments(),
]);
return {
membershipTiers,
customerValues,
regions,
tripPurposes,
cabinClasses,
passengerTypes,
ancillaryPurchases,
revenueSegments,
};
}
-22
View File
@@ -1,22 +0,0 @@
// ─── Master Data Item ───────────────────────────────────────────────────────
export interface MasterDataItem {
id: string;
label: string;
value: string;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
// ─── Category Keys ──────────────────────────────────────────────────────────
export type MasterDataCategory =
| 'MEMBERSHIP_TIER'
| 'CUSTOMER_VALUE'
| 'REGION'
| 'TRIP_PURPOSE'
| 'CABIN_CLASS'
| 'PASSENGER_TYPE'
| 'ANCILLARY_PURCHASE'
| 'REVENUE_SEGMENT';
+326
View File
@@ -0,0 +1,326 @@
import { ApiClient } from '../api/ApiClient';
import type { PolicyEngineResponse, PaginatedPolicyEngineResponse } from './PolicyEngineTypes';
export interface OptionItem {
label: string;
value: string;
id?: string;
code?: string;
}
export interface ActionTypeField {
id: string;
actionTypeId: string;
fieldCode: string;
fieldName: string;
fieldType: string;
lookupSource?: string;
isRequired: boolean;
defaultValue?: string;
placeholder?: string;
helpText?: string;
width?: string;
section?: string;
displayOrder?: number;
isActive: boolean;
}
// ─── Local Direct Api Helpers for Self-Containment ───────────────────────────
function normalizeItems(items: any[]): any[] {
if (!Array.isArray(items)) return [];
return items.map((item) => {
const val = item.code || item.id || item.value || item.label || item.name || '';
const lbl = item.label || item.name || item.value || item.code || '';
return {
...item,
id: item.id || val,
value: val,
label: lbl,
code: item.code || val,
isActive: item.isActive !== false,
};
});
}
function fetchCategoryValues(categoryCodeOrId: string): Promise<any[]> {
if (!categoryCodeOrId) return Promise.resolve([]);
return ApiClient.get<any, any[]>(`/master-data/${categoryCodeOrId}`)
.then((res) => normalizeItems(res))
.catch(() => []);
}
function fetchMastersCategories(): Promise<any[]> {
return ApiClient.get<any, any[]>('/master-data/categories')
.then((res) => (Array.isArray(res) ? res : []))
.catch(() => []);
}
function fetchActionCategories(): Promise<any[]> {
return ApiClient.get<any, any[]>('/master-data/action-categories')
.then((res) => (Array.isArray(res) ? res : []))
.catch(() => []);
}
function fetchActionTypesByCategory(categoryId: string): Promise<any[]> {
if (!categoryId) return Promise.resolve([]);
return ApiClient.get<any, any[]>(`/master-data/action-types/category-id/${categoryId}`)
.then((res) => (Array.isArray(res) ? res : []))
.catch(() => []);
}
function fetchCohorts(): Promise<any[]> {
return ApiClient.get<any, any>('/cohorts?limit=1000')
.then((res) => (Array.isArray(res?.data) ? res.data : Array.isArray(res) ? res : []))
.catch(() => []);
}
// ─── Master Data & Dynamic Options for Policy Engine ────────────────────────
export function getJurisdictionOptions(): Promise<OptionItem[]> {
return fetchCategoryValues('jurisdiction')
.then((items) =>
(items || [])
.filter((i) => i.isActive !== false)
.map((i) => ({
label: i.label || i.name || i.value || '',
value: i.id || i.code || i.value || '',
id: i.id,
code: i.code,
})),
)
.catch(() => []);
}
export function getCohortOptions(): Promise<OptionItem[]> {
return fetchCohorts()
.then((list) =>
(list || [])
.filter((c) => c.status === 'Active')
.map((c) => ({
label: c.name,
value: c.id || c.code || c.name,
id: c.id,
code: c.code || c.name,
})),
)
.catch(() => []);
}
export function getRuleCategoryOptions(): Promise<OptionItem[]> {
return fetchMastersCategories()
.then((cats) =>
(cats || [])
.filter((c) => c.isActive !== false)
.map((c) => ({
label: c.name,
value: c.code || c.id || c.name,
id: c.id,
code: c.code || c.name,
})),
)
.catch(() => []);
}
/**
* Action Categories for Strategic Action Builder dropdown
*/
export function getActionCategoryOptions(): Promise<OptionItem[]> {
return fetchActionCategories()
.then((cats) =>
(cats || [])
.filter((c) => c.isActive !== false)
.map((c) => ({
label: c.name,
value: c.id,
id: c.id,
code: c.code,
})),
)
.catch(() => []);
}
/**
* Action Types filtered by selected Action Category
*/
export function getActionTypesByCategoryOptions(categoryId: string): Promise<OptionItem[]> {
return fetchActionTypesByCategory(categoryId)
.then((types) =>
(types || [])
.filter((t) => t.isActive !== false)
.map((t) => ({
label: t.name,
value: t.id,
id: t.id,
code: t.code,
})),
)
.catch(() => []);
}
/**
* Fetches condition options dynamically based on the selected Rule Category code or ID.
*/
export function getConditionOptions(categoryCodeOrId?: string): Promise<OptionItem[]> {
if (!categoryCodeOrId || !categoryCodeOrId.trim()) {
return Promise.resolve([]);
}
const code = categoryCodeOrId.trim();
return fetchCategoryValues(code)
.then((items) =>
(items || [])
.filter((i) => i.isActive !== false)
.map((i) => ({
label: i.label || i.name || i.value || '',
value: i.id || i.code || i.value || '',
id: i.id,
code: i.code,
})),
)
.catch(() => []);
}
export function getOperatorOptions(): Promise<OptionItem[]> {
return ApiClient.get<any, any[]>('/master-data/operators')
.then((res) => {
const items = Array.isArray(res) ? res : [];
return items
.filter((i) => i.isActive !== false)
.map((i) => ({
label: i.name || i.symbol || i.label || i.code || '',
value: i.id || i.code || i.value || '',
id: i.id,
code: i.code,
}));
})
.catch(() => []);
}
export function getActionTypeFields(actionTypeId: string): Promise<ActionTypeField[]> {
if (!actionTypeId) return Promise.resolve([]);
return ApiClient.get<any, ActionTypeField[]>(`/master-data/action-types/${actionTypeId}/fields`)
.then((res) => (Array.isArray(res) ? res.filter((f) => f.isActive !== false) : []))
.catch(() => []);
}
export function getLookupOptions(lookupSource: string): Promise<OptionItem[]> {
if (!lookupSource) return Promise.resolve([]);
return fetchCategoryValues(lookupSource.toLowerCase())
.then((items) =>
(items || [])
.filter((i) => i.isActive !== false)
.map((i) => ({
label: i.label || i.name || i.value || '',
value: i.code || i.id || i.value || '',
id: i.id,
code: i.code,
})),
)
.catch(() => []);
}
// ─── Policy CRUD Endpoints ───────────────────────────────────────────────────
export function getPolicies(page = 1, limit = 10, search = ''): Promise<PaginatedPolicyEngineResponse> {
let url = `/policy-engine?page=${page}&limit=${limit}`;
if (search) {
url += `&search=${encodeURIComponent(search)}`;
}
return ApiClient.get<any, any>(url).then((res) => {
const rawData = Array.isArray(res?.data) ? res.data : Array.isArray(res) ? res : [];
const formattedData = rawData.map((p: any) => {
const statusMap: Record<string, 'Active' | 'Inactive' | 'Draft'> = {
active: 'Active',
inactive: 'Inactive',
draft: 'Draft',
archived: 'Inactive',
};
const formattedStatus = statusMap[String(p.status).toLowerCase()] || 'Active';
const formattedDate = p.updatedAt || p.createdAt
? new Date(p.updatedAt || p.createdAt).toLocaleString('en-US', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true,
})
: '—';
const jName = typeof p.jurisdiction === 'object' && p.jurisdiction !== null
? p.jurisdiction.label || p.jurisdiction.name || p.jurisdiction.code
: p.jurisdiction || 'GLOBAL';
return {
...p,
id: p.id,
policyName: p.policyName || p.name || 'Untitled Policy',
jurisdiction: jName || 'GLOBAL',
status: formattedStatus,
lastModified: formattedDate,
};
});
return {
data: formattedData,
total: res?.total ?? formattedData.length,
totalPages: res?.totalPages ?? 1,
page: res?.page ?? page,
};
});
}
export function getPolicy(id: string): Promise<PolicyEngineResponse> {
return ApiClient.get<any, PolicyEngineResponse>(`/policy-engine/${id}`);
}
export function createPolicy(payload: any): Promise<PolicyEngineResponse> {
return ApiClient.post<any, PolicyEngineResponse>('/policy-engine', payload);
}
export function updatePolicy(id: string, payload: any): Promise<PolicyEngineResponse> {
return ApiClient.put<any, PolicyEngineResponse>(`/policy-engine/${id}`, payload);
}
export function updatePolicyStatus(id: string, status: string): Promise<PolicyEngineResponse> {
return ApiClient.patch<any, PolicyEngineResponse>(`/policy-engine/${id}/status`, { status: status.toLowerCase() });
}
export function deletePolicy(id: string): Promise<void> {
return ApiClient.delete<any, void>(`/policy-engine/${id}`);
}
// ─── 3-Level Metadata API Functions ──────────────────────────────────────────
export function getConditionGroupsForCategory(categoryCodeOrId: string): Promise<any[]> {
if (!categoryCodeOrId) return Promise.resolve([]);
return ApiClient.get<any, any[]>(`/master-data/rule-categories/${categoryCodeOrId}/condition-groups`)
.then((res) => (Array.isArray(res) ? res.filter((g) => g.isActive !== false) : []))
.catch(() => []);
}
export function getConditionFieldsForGroup(groupIdOrCode: string): Promise<any[]> {
if (!groupIdOrCode) return Promise.resolve([]);
return ApiClient.get<any, any[]>(`/master-data/condition-groups/${groupIdOrCode}/fields`)
.then((res) => (Array.isArray(res) ? res.filter((f) => f.isActive !== false) : []))
.catch(() => []);
}
export function getFieldLookupOptions(fieldIdOrCode: string): Promise<OptionItem[]> {
if (!fieldIdOrCode) return Promise.resolve([]);
return ApiClient.get<any, any[]>(`/master-data/condition-fields/${fieldIdOrCode}/lookup-values`)
.then((res) =>
(res || [])
.filter((i) => i.isActive !== false)
.map((i) => ({
label: i.label || i.name || i.value || '',
value: i.id || i.code || i.value || '',
id: i.id,
code: i.code,
})),
)
.catch(() => []);
}
+28
View File
@@ -12,3 +12,31 @@ export interface PaginatedPolicyEngineResponse {
totalPages: number;
page: number;
}
export interface ConditionGroupItem {
id: string;
code: string;
name: string;
displayOrder?: number;
isActive?: boolean;
}
export interface ConditionFieldItem {
id: string;
groupId: string;
code: string;
name: string;
lookupTable?: string;
dataType: 'STRING' | 'NUMBER' | 'BOOLEAN' | 'ENUM' | 'DATE';
operatorType: 'COMPARISON' | 'TEXT' | 'SET' | 'BOOLEAN';
displayOrder?: number;
isActive?: boolean;
}
export interface LookupOptionItem {
id: string;
code: string;
label: string;
value: string;
displayOrder?: number;
}
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react';
import { PlusIcon, TrashIcon, MagnifyingGlassIcon, XIcon, PencilSimpleIcon, CopyIcon, ChecksIcon } from '@phosphor-icons/react';
import { PlusIcon, TrashIcon, MagnifyingGlassIcon, XIcon, PencilSimpleIcon, ChecksIcon } from '@phosphor-icons/react';
import { useNavigate } from 'react-router-dom';
import {
CustomTable,
@@ -13,45 +13,12 @@ import {
} from '../../../components/custom';
import type { Column } from '../../../components/custom/CustomTable';
import type { PolicyEngineResponse } from '../PolicyEngineTypes';
import { getPolicies, deletePolicy, updatePolicyStatus } from '../PolicyEngineApi';
// ─── Constants ───────────────────────────────────────────────────────────────
const PAGE_SIZE = 10;
// ─── Mock Data ───────────────────────────────────────────────────────────────
const MOCK_POLICIES: PolicyEngineResponse[] = [
{
id: '1',
policyName: 'New Recovery Strategy',
jurisdiction: 'GLOBAL',
status: 'Active',
lastModified: '4 Jun 2026, 4:09pm',
},
{
id: '2',
policyName: 'EU261 Standard Recovery',
jurisdiction: 'GLOBAL',
status: 'Active',
lastModified: '4 Jun 2026, 4:09pm',
},
{
id: '3',
policyName: 'US DOT Consumer Protection',
jurisdiction: 'GLOBAL',
status: 'Active',
lastModified: '4 Jun 2026, 4:09pm',
},
// Add some more mock data to demonstrate pagination if needed
...Array.from({ length: 9 }).map((_, i) => ({
id: `mock-${i + 4}`,
policyName: `Sample Policy ${i + 4}`,
jurisdiction: 'GLOBAL',
status: i % 2 === 0 ? 'Draft' : 'Inactive' as 'Active' | 'Inactive' | 'Draft',
lastModified: '5 Jun 2026, 10:00am',
}))
];
// ─── Helpers ─────────────────────────────────────────────────────────────────
function HeaderLabel({ text }: { text: string }) {
@@ -94,32 +61,29 @@ export default function PolicyEngineList() {
// Modal state
const [deleteTarget, setDeleteTarget] = useState<PolicyEngineResponse | null>(null);
const [deactivateTarget, setDeactivateTarget] = useState<PolicyEngineResponse | null>(null);
// ─── Fetch data ─────────────────────────────────────────────
const fetchPolicies = useCallback((page: number) => {
setLoading(true);
// Simulate API call with timeout
setTimeout(() => {
const filteredData = MOCK_POLICIES.filter(p =>
p.policyName.toLowerCase().includes(search.toLowerCase())
);
const total = filteredData.length;
const pages = Math.ceil(total / PAGE_SIZE);
const start = (page - 1) * PAGE_SIZE;
const paginatedData = filteredData.slice(start, start + PAGE_SIZE);
setPolicies(paginatedData);
setTotalItems(total);
setTotalPages(pages || 1);
setLoading(false);
}, 500);
getPolicies(page, PAGE_SIZE, search)
.then((res) => {
setPolicies(res.data || []);
setTotalItems(res.total || 0);
setTotalPages(res.totalPages || 1);
})
.catch((err) => {
console.error('Failed to fetch policies:', err);
setPolicies([]);
setTotalItems(0);
setTotalPages(1);
})
.finally(() => {
setLoading(false);
});
}, [search]);
useEffect(() => {
// Trigger a new fetch when page or search changes
fetchPolicies(currentPage);
}, [currentPage, search, fetchPolicies]);
@@ -136,18 +100,25 @@ export default function PolicyEngineList() {
const handleDelete = async () => {
if (!deleteTarget) return;
// Simulate delete
console.log('Deleted policy:', deleteTarget.id);
setDeleteTarget(null);
fetchPolicies(currentPage);
try {
await deletePolicy(deleteTarget.id);
setDeleteTarget(null);
fetchPolicies(currentPage);
} catch (err) {
console.error('Failed to delete policy:', err);
}
};
const handleToggleStatus = async () => {
if (!deactivateTarget) return;
// Simulate toggle status
console.log('Toggled status for policy:', deactivateTarget.id);
setDeactivateTarget(null);
fetchPolicies(currentPage);
try {
const nextStatus = deactivateTarget.status === 'Active' ? 'inactive' : 'active';
await updatePolicyStatus(deactivateTarget.id, nextStatus);
setDeactivateTarget(null);
fetchPolicies(currentPage);
} catch (err) {
console.error('Failed to toggle policy status:', err);
}
};
const startIndex = totalItems > 0 ? (currentPage - 1) * PAGE_SIZE + 1 : 0;
@@ -198,15 +169,9 @@ export default function PolicyEngineList() {
Deactivate
</CustomActionItem>
)}
<CustomActionItem
icon={<CopyIcon size={15} />}
onClick={() => console.log('Duplicate:', row.id)}
>
Duplicate
</CustomActionItem>
<CustomActionItem
icon={<PencilSimpleIcon size={15} />}
onClick={() => console.log('Edit:', row.id)}
onClick={() => navigate(`/policy-engine/add?id=${row.id}`)}
>
Edit
</CustomActionItem>
@@ -0,0 +1,70 @@
import { ApiClient } from '../api/ApiClient';
import type { RecoveryIncident, MetricCardData } from './RecoveryIncidentsTypes';
export function getRecoveryIncidents(): Promise<RecoveryIncident[]> {
return ApiClient.get<any, RecoveryIncident[]>('/recovery-incidents');
}
export function getRecoveryMetrics(): Promise<MetricCardData[]> {
return ApiClient.get<any, MetricCardData[]>('/recovery-incidents/metrics')
.catch(() => [
{
id: 'total-recoveries',
title: "Total Recoveries",
value: "1,284",
trendValue: "40%",
trendText: "since last week",
trendType: "positive",
sparklineColor: "green",
},
{
id: 'pending-approval',
title: "Pending Approval",
value: "274",
trendValue: "High Priority",
trendText: "since last week",
trendType: "positive",
sparklineColor: "green",
},
{
id: 'refund-value',
title: "Refund Value",
value: "$412k",
trendValue: "MTD",
trendText: "since last week",
trendType: "negative",
sparklineColor: "red",
},
{
id: 'customer-satisfaction',
title: "Customer Satisfaction",
value: "94%",
trendValue: "+2.1%",
trendText: "since last week",
trendType: "positive",
sparklineColor: "green",
},
]);
}
export function getRecoveryIncident(id: string): Promise<RecoveryIncident> {
return ApiClient.get<any, RecoveryIncident>(`/recovery-incidents/${id}`);
}
export function createRecoveryIncident(data: Omit<RecoveryIncident, 'id'>): Promise<RecoveryIncident> {
return ApiClient.post<any, RecoveryIncident>('/recovery-incidents', data);
}
export function updateRecoveryIncident(id: string, data: Partial<RecoveryIncident>): Promise<RecoveryIncident> {
return ApiClient.patch<any, RecoveryIncident>(`/recovery-incidents/${id}`, data);
}
export function updateIncidentStatus(id: string, status: string): Promise<RecoveryIncident> {
return ApiClient.patch<any, RecoveryIncident>(`/recovery-incidents/${id}/status`, { status });
}
export function deleteRecoveryIncident(id: string): Promise<void> {
return ApiClient.delete<any, void>(`/recovery-incidents/${id}`);
}
@@ -0,0 +1,31 @@
export interface IncidentStatus {
text: string;
variant: "success" | "error" | "warning" | "info" | "neutral" | "brand";
}
export interface RecoveryIncident {
id: string;
recoveryCode: string;
date: string;
passengerName?: string;
pnr?: string;
flightNumber: string;
flightRoute: string;
category?: string;
statuses?: IncidentStatus[];
status?: string;
value: string;
isPerksClaimed?: boolean;
isGroupHeader?: boolean;
}
export interface MetricCardData {
id?: string;
title: string;
value?: string;
trendText: string;
trendValue: string;
trendType: "positive" | "negative" | "neutral";
sparklineColor: "green" | "red";
}
@@ -0,0 +1,323 @@
import { useEffect, useState } from 'react';
import { FileText, User, Airplane, WarningCircle, CaretRight } from '@phosphor-icons/react';
import {
CustomModal,
CustomInput,
CustomDropdown,
CustomCheckBox,
} from "../../../components/custom";
import { createRecoveryIncident, updateRecoveryIncident } from '../RecoveryIncidentsApi';
import { getMembershipTiers, getCategoryValues } from '../../configuration/masterData/MasterDataApi';
import type { RecoveryIncident } from '../RecoveryIncidentsTypes';
interface AddRecoveryIncidentsProps {
isOpen: boolean;
onClose: () => void;
incident?: RecoveryIncident | null;
}
const SECTION_TITLE_CLASS = "flex items-center gap-2 mb-4 text-[#4A5568] font-bold text-xs tracking-wider uppercase";
const SECTION_CONTAINER_CLASS = "bg-[#F9FAFB] rounded-[14px] p-5 border border-gray-100";
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 [formData, setFormData] = useState({
passengerName: "",
pnr: "",
loyaltyTier: "",
flightNumber: "",
date: "",
origin: "",
destination: "",
category: "",
scenario: "",
jurisdiction: "",
delayDuration: "",
isPerksClaimed: false,
});
useEffect(() => {
if (isOpen) {
// 1. Fetch Loyalty Tier Master Data
getMembershipTiers()
.then((items) => {
if (Array.isArray(items) && items.length > 0) {
const activeOptions = items
.filter((m) => m.isActive !== false)
.map((m) => ({
label: m.label || m.value,
value: m.value || m.id || m.label,
}));
if (activeOptions.length > 0) {
setLoyaltyTierOptions(activeOptions);
}
}
})
.catch((err) => {
console.error("Failed to fetch loyalty tier master data:", err);
});
// 2. Fetch Jurisdiction Master Data
getCategoryValues('jurisdiction')
.then((items) => {
if (Array.isArray(items) && items.length > 0) {
const activeOptions = items
.filter((m) => m.isActive !== false)
.map((m) => ({
label: m.label || m.name || m.value,
value: m.value || m.code || m.id || m.label,
}));
if (activeOptions.length > 0) {
setJurisdictionOptions(activeOptions);
}
}
})
.catch(() => { });
// 3. Fetch Scenario Master Data
getCategoryValues('flight-disruption-type')
.then((items) => {
if (Array.isArray(items) && items.length > 0) {
const activeOptions = items
.filter((m) => m.isActive !== false)
.map((m) => ({
label: m.label || m.name || m.value,
value: m.value || m.code || m.id || m.label,
}));
if (activeOptions.length > 0) {
setScenarioOptions(activeOptions);
}
}
})
.catch(() => { });
}
}, [isOpen]);
useEffect(() => {
if (incident && isOpen) {
const [origin, destination] = incident.flightRoute ? incident.flightRoute.split(' → ') : ["", ""];
setFormData({
passengerName: incident.passengerName || "",
pnr: incident.pnr || "",
loyaltyTier: (incident as any).loyaltyTier || "",
flightNumber: incident.flightNumber || "",
date: incident.date ? incident.date.split('T')[0] : "",
origin: origin?.trim() || "",
destination: destination?.trim() || "",
category: incident.category || "",
scenario: "",
jurisdiction: "",
delayDuration: "",
isPerksClaimed: incident.isPerksClaimed || false,
});
} else if (isOpen) {
setFormData({
passengerName: "",
pnr: "",
loyaltyTier: "",
flightNumber: "",
date: "",
origin: "",
destination: "",
category: "",
scenario: "",
jurisdiction: "",
delayDuration: "",
isPerksClaimed: false,
});
}
}, [incident, isOpen]);
const [loading, setLoading] = useState(false);
const handleInputChange = (field: string, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const handleCheckboxChange = (field: string, checked: boolean) => {
setFormData((prev) => ({ ...prev, [field]: checked }));
};
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,
};
console.log("Submitting payload:", payload);
if (incident) {
await updateRecoveryIncident(incident.id, payload);
} else {
await createRecoveryIncident(payload);
}
onClose(); // Will trigger refresh in parent list
} 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'}`);
} finally {
setLoading(false);
}
};
return (
<CustomModal
isOpen={isOpen}
onClose={onClose}
title={incident ? "Edit Recovery Incident" : "New Recovery Incident"}
description="Log a disruption case and assess against policy frameworks"
icon={<FileText className="text-[#1B9869]" />}
size="lg"
primaryAction={{
label: loading ? "Saving..." : (incident ? "Save Changes" : "Assess & Log Incident"),
onClick: handleSubmit,
icon: <CaretRight size={16} />
}}
secondaryAction={{
label: "Discard",
onClick: onClose,
}}
>
<div className="flex flex-col gap-5">
{/* PASSENGER IDENTITY */}
<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>
</div>
{/* FLIGHT CONTEXT */}
<div className={SECTION_CONTAINER_CLASS}>
<div className={SECTION_TITLE_CLASS}>
<Airplane size={16} />
<span>Flight Context</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" },
]}
/>
<CustomDropdown
label="Destination (IATA)"
placeholder="Selected Option"
value={formData.destination}
onChange={(val) => handleInputChange("destination", val as string)}
options={[
{ label: "JFK", value: "JFK" },
{ label: "CDG", value: "CDG" },
{ label: "NRT", value: "NRT" },
]}
/>
</div>
</div>
{/* DISRUPTION & JURISDICTION */}
<div className={SECTION_CONTAINER_CLASS}>
<div className={SECTION_TITLE_CLASS}>
<WarningCircle size={16} />
<span>Disruption & Jurisdiction</span>
</div>
<div className="grid grid-cols-2 gap-4">
<CustomDropdown
label="Category"
placeholder="Selected Option"
value={formData.category}
onChange={(val) => handleInputChange("category", val as string)}
options={[
{ label: "Flight Ops", value: "flight_ops" },
{ label: "Travel Exp", value: "travel_exp" },
{ label: "Weather", value: "weather" },
]}
/>
<CustomDropdown
label="Scenario"
placeholder="Selected Option"
value={formData.scenario}
onChange={(val) => handleInputChange("scenario", val as string)}
options={scenarioOptions}
/>
<CustomDropdown
label="Jurisdiction"
placeholder="Selected Option"
value={formData.jurisdiction}
onChange={(val) => handleInputChange("jurisdiction", val as string)}
options={jurisdictionOptions}
/>
<CustomInput
type="number"
label="Delay Duration (Mins)"
placeholder="0"
value={formData.delayDuration}
onChange={(e) => handleInputChange("delayDuration", e.target.value)}
/>
</div>
<div className="mt-4">
<CustomCheckBox
label="Passenger claimed the perks"
checked={formData.isPerksClaimed}
onChange={(e) => handleCheckboxChange("isPerksClaimed", e.target.checked)}
/>
</div>
</div>
</div>
</CustomModal>
);
}
@@ -0,0 +1,89 @@
export interface MetricCardProps {
title: string;
value?: string;
trendText: string;
trendValue: string;
trendType: "positive" | "negative" | "neutral";
sparklineColor: "green" | "red";
}
export function MetricCard({
title,
value,
trendText,
trendValue,
trendType,
sparklineColor,
}: MetricCardProps) {
return (
<div className="bg-[#F8F9FA] rounded-[16px] p-5 flex flex-col gap-4 flex-1 min-w-[220px]">
<div className="flex justify-between items-center">
<span className="text-[14px] font-semibold text-gray-700">{title}</span>
</div>
<div className="flex items-end justify-between">
<div className="flex flex-col gap-1">
{value && (
<span className="text-[28px] font-bold text-gray-900 leading-none">
{value}
</span>
)}
<div className="flex items-center gap-1 mt-1">
<span
className={`text-[12px] font-bold ${
trendType === "positive"
? "text-[#1B9869]"
: trendType === "negative"
? "text-red-500"
: "text-gray-500"
}`}
>
{trendValue}
</span>
<span className="text-[12px] font-medium text-gray-500">
{trendText}
</span>
</div>
</div>
{/* Simple SVG Sparkline placeholder based on color */}
<div className="w-[60px] h-[30px] flex items-center justify-end">
{sparklineColor === "green" ? (
<svg
width="60"
height="24"
viewBox="0 0 60 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2 20C10 20 12 12 20 12C28 12 32 18 40 18C48 18 52 4 58 4"
stroke="#1B9869"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
) : (
<svg
width="60"
height="24"
viewBox="0 0 60 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2 4C10 4 12 12 20 12C28 12 32 6 40 6C48 6 52 20 58 20"
stroke="#EF4444"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</div>
</div>
</div>
);
}
export default MetricCard;
@@ -0,0 +1,579 @@
import { useState, useEffect, useCallback, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import {
EyeIcon,
PencilSimpleIcon,
CheckCircleIcon,
XCircleIcon,
ClockIcon,
FunnelSimpleIcon,
SquaresFourIcon,
PlusIcon,
CaretDownIcon,
CaretUpIcon,
MagnifyingGlassIcon,
} from "@phosphor-icons/react";
import {
CustomTable,
CustomInput,
CustomButton,
CustomStatus,
CustomCheckBox,
Skeleton,
CustomActionMenu,
CustomActionItem,
} from "../../../components/custom";
import type { Column } from "../../../components/custom/CustomTable";
import type { RecoveryIncident, MetricCardData } from "../RecoveryIncidentsTypes";
import AddRecoveryIncidents from "./AddRecoveryIncidents";
import { MetricCard } from "./MetricCard";
import { getRecoveryIncidents, getRecoveryMetrics } from "../RecoveryIncidentsApi";
import { formatDate } from "../../../utils/formatDate";
const PAGE_SIZE = 10;
function HeaderLabel({
text,
rightIcon,
}: {
text: string;
rightIcon?: React.ReactNode;
}) {
return (
<div className="flex items-center gap-1">
<span className="text-[13px] font-semibold text-[#6C766D] tracking-[0px]">
{text}
</span>
{rightIcon && <span className="text-[#6C766D]">{rightIcon}</span>}
</div>
);
}
function PrimaryText({ text }: { text: string }) {
return (
<div className="text-[13px] font-semibold text-[#0F172B] leading-[18px] tracking-[0px]">
{text}
</div>
);
}
function SecondaryText({ text }: { text: string }) {
return (
<div className="text-[12px] font-medium text-[#6C766D] leading-[16px] tracking-[0px] mt-0.5">
{text}
</div>
);
}
function BadgeLabel({ text }: { text: string }) {
return (
<span className="px-3 py-1 bg-gray-100 text-gray-500 rounded-full text-xs font-semibold">
{text}
</span>
);
}
function getStatusVariant(status?: string): "success" | "error" | "warning" | "info" | "neutral" | "brand" {
if (!status) return "neutral";
const s = status.toLowerCase();
if (s.includes("appr") || s.includes("active") || s.includes("success")) return "success";
if (s.includes("reject") || s.includes("denied") || s.includes("error")) return "error";
if (s.includes("pend") || s.includes("review") || s.includes("warn")) return "warning";
if (s.includes("new") || s.includes("info")) return "info";
return "neutral";
}
// ─── Component ───────────────────────────────────────────────────────────────
export default function RecoveryIncidentsList() {
const navigate = useNavigate();
const [incidents, setIncidents] = useState<RecoveryIncident[]>([]);
const [metrics, setMetrics] = useState<MetricCardData[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
getRecoveryMetrics()
.then((data) => setMetrics(data))
.catch((err) => console.error("Failed to fetch recovery metrics:", err));
}, []);
// Pagination
const [currentPage, setCurrentPage] = useState(1);
// Filters
const [search, setSearch] = useState("");
const [isGrouped, setIsGrouped] = useState(true);
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingIncident, setEditingIncident] = useState<RecoveryIncident | null>(null);
// Selection
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
// ─── Fetch data ─────────────────────────────────────────────
const fetchIncidents = useCallback(async () => {
setLoading(true);
try {
const data = await getRecoveryIncidents();
setIncidents(data);
} catch (error) {
console.error("Failed to fetch recovery incidents", error);
setIncidents([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchIncidents();
}, [fetchIncidents]);
useEffect(() => {
if (incidents.length > 0) {
const keys = new Set(incidents.map((i) => i.flightNumber || "Other"));
setCollapsedGroups(keys);
}
}, [incidents]);
// ─── Handlers ──────────────────────────────────────────────────────────────
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
const handleSearchChange = (val: string) => {
setSearch(val);
setCurrentPage(1);
};
const toggleSelectAll = () => {
if (selectedIds.size === incidents.length && incidents.length > 0) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(incidents.map((i) => i.id)));
}
};
const toggleSelectOne = (id: string) => {
const newSelected = new Set(selectedIds);
if (newSelected.has(id)) {
newSelected.delete(id);
} else {
newSelected.add(id);
}
setSelectedIds(newSelected);
};
const toggleGroup = (groupKey: string, e?: React.MouseEvent) => {
if (e) e.stopPropagation();
setCollapsedGroups((prev) => {
const next = new Set(prev);
if (next.has(groupKey)) {
next.delete(groupKey);
} else {
next.add(groupKey);
}
return next;
});
};
const handleStatusChange = async (incident: RecoveryIncident, text: string) => {
try {
const { updateIncidentStatus, getRecoveryMetrics } = await import('../RecoveryIncidentsApi');
await updateIncidentStatus(incident.id, text);
fetchIncidents();
getRecoveryMetrics().then((data) => setMetrics(data)).catch(() => {});
} catch (error) {
console.error("Failed to update status", error);
}
};
const filteredIncidents = useMemo(() => {
return incidents.filter((p) => {
const q = search.toLowerCase();
return (
(p.recoveryCode || "").toLowerCase().includes(q) ||
(p.flightNumber || "").toLowerCase().includes(q) ||
(p.passengerName || "").toLowerCase().includes(q) ||
(p.pnr || "").toLowerCase().includes(q)
);
});
}, [incidents, search]);
const displayData = useMemo(() => {
if (!isGrouped) {
return filteredIncidents;
}
// Group items by flight number
const groupMap = new Map<string, RecoveryIncident[]>();
filteredIncidents.forEach((item) => {
const key = item.flightNumber || "Other";
if (!groupMap.has(key)) {
groupMap.set(key, []);
}
groupMap.get(key)!.push(item);
});
const result: (RecoveryIncident & { groupKey?: string })[] = [];
groupMap.forEach((groupItems, flightNo) => {
const firstItem = groupItems[0];
// Calculate status summary for group header
const pendingCount = groupItems.filter(i => {
const s = (i.status || "").toLowerCase();
return s.includes("pending") || s.includes("review") || s.includes("new");
}).length;
const approvedCount = groupItems.filter(i => {
const s = (i.status || "").toLowerCase();
return s.includes("approved") || s.includes("active");
}).length;
const headerStatuses = [
{ text: `${pendingCount} Pending`, variant: "warning" as const },
{ text: `${approvedCount} Approved`, variant: "success" as const },
];
// Calculate sum of values
const sumVal = groupItems.reduce((acc, curr) => {
const num = parseFloat((curr.value || "").replace(/[^0-9.]/g, "")) || 0;
return acc + num;
}, 0);
const groupKey = flightNo;
// Group Header Row
result.push({
id: `header-${groupKey}`,
recoveryCode: firstItem.recoveryCode,
date: firstItem.date,
flightNumber: flightNo,
flightRoute: firstItem.flightRoute,
category: firstItem.category,
statuses: headerStatuses,
value: `$${sumVal}`,
isGroupHeader: true,
groupKey: groupKey,
});
// Child Rows (if group is expanded)
if (!collapsedGroups.has(groupKey)) {
groupItems.forEach((child) => {
result.push({
...child,
groupKey: groupKey,
});
});
}
});
return result;
}, [filteredIncidents, isGrouped, collapsedGroups]);
const totalItems = displayData.length;
const totalPages = Math.ceil(totalItems / PAGE_SIZE) || 1;
const startIndex = totalItems > 0 ? (currentPage - 1) * PAGE_SIZE + 1 : 0;
const endIndex = Math.min(currentPage * PAGE_SIZE, totalItems);
const paginatedDisplayData = useMemo(() => {
const start = (currentPage - 1) * PAGE_SIZE;
return displayData.slice(start, start + PAGE_SIZE);
}, [displayData, currentPage]);
// ─── Table columns ─────────────────────────────────────────────────────────
const columns: Column<RecoveryIncident>[] = [
{
header: (
<CustomCheckBox
checked={
incidents.length > 0 && selectedIds.size === incidents.length
}
onChange={toggleSelectAll}
/>
),
className: "w-[40px] pr-0",
accessor: (row) => row.isGroupHeader ? null : (
<CustomCheckBox
checked={selectedIds.has(row.id)}
onChange={() => toggleSelectOne(row.id)}
onClick={(e) => e.stopPropagation()}
/>
),
},
{
header: <HeaderLabel text="Recovery ID" />,
accessor: (row) => (
<div>
<PrimaryText text={row.recoveryCode} />
<SecondaryText text={formatDate(row.date)} />
</div>
),
},
{
header: (
<HeaderLabel
text="Passenger / PNR"
rightIcon={<FunnelSimpleIcon size={14} weight="bold" />}
/>
),
accessor: (row) =>
row.passengerName ? (
<div>
<PrimaryText text={row.passengerName} />
<SecondaryText text={row.pnr || ""} />
</div>
) : null,
},
{
header: (
<HeaderLabel
text="Flight"
rightIcon={<FunnelSimpleIcon size={14} weight="bold" />}
/>
),
accessor: (row) => (
<div>
<PrimaryText text={row.flightNumber} />
<SecondaryText text={row.flightRoute} />
</div>
),
},
{
header: <HeaderLabel text="Category" />,
accessor: (row) =>
row.category ? <BadgeLabel text={row.category} /> : null,
},
{
header: <HeaderLabel text="Status" />,
accessor: (row) => {
if (row.isGroupHeader) {
return (
<div className="flex items-center gap-2">
{(row.statuses || []).map((status, idx) => (
<CustomStatus
key={idx}
status={status.text}
variant={status.variant}
/>
))}
</div>
);
}
const statusText = row.status || "Pending";
return (
<CustomStatus
status={statusText}
variant={getStatusVariant(statusText)}
/>
);
},
},
{
header: (
<HeaderLabel
text="Value"
rightIcon={<FunnelSimpleIcon size={14} weight="bold" />}
/>
),
accessor: (row) => <PrimaryText text={row.value} />,
},
{
header: <HeaderLabel text="Perks Claimed" />,
accessor: (row) =>
row.isGroupHeader ? null : (
<BadgeLabel text={row.isPerksClaimed ? "Yes" : "No"} />
),
},
{
header: <HeaderLabel text="Action" />,
className: "text-right",
accessor: (row) => {
const key = (row as any).groupKey || row.flightNumber || row.recoveryCode;
return (
<div className="flex justify-end pr-2">
{row.isGroupHeader ? (
<div
className="cursor-pointer text-gray-500 hover:text-gray-900 transition-colors p-1"
onClick={(e) => toggleGroup(key, e)}
>
{collapsedGroups.has(key) ? (
<CaretDownIcon size={20} />
) : (
<CaretUpIcon size={20} />
)}
</div>
) : (
<CustomActionMenu>
<CustomActionItem
onClick={() => navigate(`/recovery/${row.id}`)}
icon={<EyeIcon size={16} className="text-blue-500" />}
>
View Details
</CustomActionItem>
<CustomActionItem
onClick={() => {
setEditingIncident(row);
setIsModalOpen(true);
}}
icon={<PencilSimpleIcon size={16} className="text-yellow-500" />}
>
Edit Incident
</CustomActionItem>
<CustomActionItem
icon={<CheckCircleIcon size={16} className="text-green-500" />}
onClick={() => handleStatusChange(row, "Approved")}
>
Approve
</CustomActionItem>
<CustomActionItem
variant="danger"
icon={<XCircleIcon size={16} className="text-red-500" />}
onClick={() => handleStatusChange(row, "Rejected")}
>
Reject
</CustomActionItem>
<CustomActionItem
icon={<ClockIcon size={16} className="text-yellow-600" />}
onClick={() => handleStatusChange(row, "Under Review")}
>
Mark for Review
</CustomActionItem>
</CustomActionMenu>
)}
</div>
);
},
},
];
const isAnyGroupExpanded = useMemo(() => {
if (!isGrouped) return true;
const allGroupKeys = Array.from(
new Set(incidents.map((i) => i.flightNumber || "Other"))
);
return allGroupKeys.some((key) => !collapsedGroups.has(key));
}, [isGrouped, incidents, collapsedGroups]);
const activeColumns = useMemo(() => {
if (!isGrouped || isAnyGroupExpanded) {
return columns;
}
return columns.filter((col) => {
const headerText =
typeof col.header === "object" && col.header !== null && "props" in col.header
? (col.header as any).props.text
: "";
return (
headerText !== "Passenger / PNR" &&
headerText !== "Category" &&
headerText !== "Perks Claimed"
);
});
}, [columns, isGrouped, isAnyGroupExpanded]);
// ─── Loading skeleton ──────────────────────────────────────────────────────
if (loading) {
return (
<div className="w-full flex flex-col bg-white rounded-[20px] shadow-sm border border-gray-100 overflow-hidden">
<div className="flex items-center justify-between p-4 border-b border-gray-100">
<Skeleton width={320} height={36} />
<div className="flex items-center gap-3">
<Skeleton width={148} height={36} />
</div>
</div>
<div className="p-6 flex flex-col gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} height={52} />
))}
</div>
</div>
);
}
// ─── Render ────────────────────────────────────────────────────────────────
return (
<div className="w-full flex flex-col gap-6">
{/* Metrics Row */}
<div className="flex gap-4 w-full">
{metrics.map((metric) => (
<MetricCard key={metric.id || metric.title} {...metric} />
))}
</div>
{/* Table Section */}
<CustomTable<RecoveryIncident>
columns={activeColumns}
data={paginatedDisplayData}
leftHeaderActions={
<div className="w-[320px]">
<CustomInput
placeholder="Search framework registry..."
value={search}
onChange={(e) => handleSearchChange(e.target.value)}
leftIcon={<MagnifyingGlassIcon size={16} />}
className="!bg-[#F3F6F5] !rounded-[10px] !h-[40px] !border !border-[#E5E7EB]"
containerClassName="!gap-0"
/>
</div>
}
rightHeaderActions={
<>
<CustomButton
variant="outlined"
size="md"
leftIcon={<SquaresFourIcon size={16} />}
className="!rounded-[10px] !gap-[8px] !h-[40px] !border-primary !text-primary hover:!bg-primary/5"
onClick={() => setIsGrouped(!isGrouped)}
>
{isGrouped ? "Ungroup" : "Group by Flight"}
</CustomButton>
<CustomButton
variant="primary"
size="md"
leftIcon={<PlusIcon size={16} />}
className="!rounded-[10px] !gap-[8px] !h-[40px] !bg-[#1B9869] hover:!bg-[#14704E]"
onClick={() => {
setEditingIncident(null);
setIsModalOpen(true);
}}
>
New Incident
</CustomButton>
</>
}
currentPage={currentPage}
totalPages={totalPages}
totalItems={totalItems}
startIndex={startIndex}
endIndex={endIndex}
onPageChange={handlePageChange}
itemName="Recovery Incidents"
onRowClick={(row) => {
if (row.isGroupHeader) {
const key = (row as any).groupKey || row.flightNumber || row.recoveryCode;
toggleGroup(key);
} else {
navigate(`/recovery/${row.id}`);
}
}}
rowClassName={(row) => row.isGroupHeader ? "bg-white border-b border-gray-100 hover:bg-gray-50/60" : "bg-[#F9FAFB] border-transparent cursor-pointer hover:bg-[#F3F4F6]"}
/>
{/* Modal */}
<AddRecoveryIncidents
isOpen={isModalOpen}
incident={editingIncident}
onClose={() => {
setIsModalOpen(false);
setEditingIncident(null);
fetchIncidents(); // Refresh list
}}
/>
</div>
);
}
@@ -0,0 +1,102 @@
import { ClipboardTextIcon, CheckCircleIcon } from '@phosphor-icons/react';
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>
<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>
{/* 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>
{/* 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>
{/* 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 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>
</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>
);
}
@@ -0,0 +1,105 @@
import { IdentificationCardIcon, AirplaneTiltIcon, WarningCircleIcon } from '@phosphor-icons/react';
import type { RecoveryIncident } from '../RecoveryIncidentsTypes';
interface CaseDetailsTabProps {
incident?: RecoveryIncident | null;
}
export default function CaseDetailsTab({ incident }: CaseDetailsTabProps) {
if (!incident) return null;
return (
<div className="flex flex-col gap-4">
{/* PASSENGER INFORMATION */}
<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">
<IdentificationCardIcon size={20} weight="bold" className="text-gray-700" />
<h3 className="text-[13px] font-bold text-gray-800 tracking-wider">PASSENGER INFORMATION</h3>
</div>
<div className="grid grid-cols-4 gap-6">
<div>
<span className="block text-[11px] font-bold text-gray-400 tracking-wider mb-1">FULL NAME</span>
<span className="block text-[15px] font-semibold text-gray-900">{incident.passengerName || 'Unknown'}</span>
</div>
<div>
<span className="block text-[11px] font-bold text-gray-400 tracking-wider mb-1">PNR / REFERENCE</span>
<span className="block text-[15px] font-semibold text-gray-900">{incident.pnr || 'N/A'}</span>
</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>
</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>
</div>
</div>
</div>
{/* FLIGHT JOURNEY */}
<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">
<AirplaneTiltIcon size={20} weight="bold" className="text-gray-700" />
<h3 className="text-[13px] font-bold text-gray-800 tracking-wider">FLIGHT JOURNEY</h3>
</div>
<div className="grid grid-cols-4 gap-y-6 gap-x-6">
<div>
<span className="block text-[11px] font-bold text-gray-400 tracking-wider mb-1">FLIGHT NUMBER</span>
<span className="block text-[15px] font-semibold text-gray-900">{incident.flightNumber || 'N/A'}</span>
</div>
<div>
<span className="block text-[11px] font-bold text-gray-400 tracking-wider mb-1">SECTOR</span>
<span className="block text-[15px] font-semibold text-gray-900">{incident.flightRoute || 'N/A'}</span>
</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>
</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>
</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>
</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>
</div>
</div>
</div>
{/* DISRUPTION DETAILS */}
<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">
<WarningCircleIcon size={20} weight="bold" className="text-gray-700" />
<h3 className="text-[13px] font-bold text-gray-800 tracking-wider">DISRUPTION DETAILS</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">DISRUPTION CATEGORY</span>
<span className="block text-[15px] font-semibold text-gray-900">{incident.category || 'N/A'}</span>
</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>
</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>
</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.
</span>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,76 @@
import { CreditCardIcon, GiftIcon, HandHeartIcon } from '@phosphor-icons/react';
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>
<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="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>
{/* 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>
<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>
</div>
</div>
</div>
);
}
@@ -0,0 +1,118 @@
import { SparkleIcon, UserIcon, ArrowRightIcon } from "@phosphor-icons/react";
import { CustomButton } from "../../../components/custom";
export default function SummaryTab() {
return (
<div className="flex flex-col gap-6">
{/* AI STRATEGIC ASSESSMENT */}
<div className="bg-white rounded-2xl p-6 border border-gray-100 shadow-sm relative overflow-hidden">
{/* Subtle decorative glow */}
<div className="absolute top-0 right-0 w-32 h-32 bg-[#1B9869]/5 rounded-full blur-2xl -mr-16 -mt-16 pointer-events-none"></div>
<div className="flex items-center gap-2 mb-4">
<span className="text-[#1B9869]">
<SparkleIcon size={20} weight="fill" />
</span>
<h3 className="text-[13px] font-bold text-gray-800 tracking-wider">
AI STRATEGIC ASSESSMENT
</h3>
</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."
</p>
</div>
{/* Row of 3 Cards */}
<div className="grid grid-cols-3 gap-6">
{/* INCIDENT ROOT */}
<div className="bg-white rounded-2xl p-6 border border-gray-100 shadow-sm flex flex-col gap-5">
<h3 className="text-[13px] font-bold text-[#1B9869] tracking-wider">
INCIDENT ROOT
</h3>
<div>
<span className="block text-[11px] font-bold text-[#1B9869] tracking-wider mb-1">
Recovery Source
</span>
<span className="block text-[15px] font-semibold text-gray-900">
Simulation Engine
</span>
</div>
<div>
<span className="block text-[11px] font-bold text-[#1B9869] tracking-wider mb-1">
Policy Applied
</span>
<span className="block text-[15px] font-semibold text-gray-900">
Standard Policy
</span>
</div>
<div>
<span className="block text-[11px] font-bold text-[#1B9869] tracking-wider mb-1">
Jurisdiction
</span>
<span className="block text-[15px] font-semibold text-gray-900">
EU261
</span>
</div>
</div>
{/* ASSIGNMENT */}
<div className="bg-white rounded-2xl p-6 border border-gray-100 shadow-sm flex flex-col gap-5">
<h3 className="text-[13px] font-bold text-[#1B9869] tracking-wider">
ASSIGNMENT
</h3>
<div className="relative">
<div className="absolute inset-0 bg-white/40 backdrop-blur-[2px] z-10 flex items-center justify-center">
<h4 className="text-xl font-bold text-gray-900 italic">
"Coming soon"
</h4>
</div>
<div className="opacity-30 pointer-events-none">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center text-gray-400">
<UserIcon size={20} />
</div>
<div>
<div className="font-semibold text-gray-900">Emma Watson</div>
<div className="text-xs text-gray-500">Case Officer</div>
</div>
</div>
<CustomButton
variant="outlined"
leftIcon={<ArrowRightIcon size={16} />}
className="w-full !border-gray-200 !text-gray-600 !font-semibold hover:!bg-gray-50"
>
Change Assignee
</CustomButton>
</div>
</div>
</div>
{/* RECOVERY SCORE */}
<div className="bg-white rounded-2xl p-6 border border-gray-100 shadow-sm flex flex-col gap-5">
<h3 className="text-[13px] font-bold text-[#1B9869] tracking-wider">
RECOVERY SCORE
</h3>
<div className="flex items-baseline gap-1 mt-2 mb-2">
<span className="text-[48px] font-bold text-gray-900 leading-none">
75
</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.
</p>
</div>
</div>
</div>
);
}
+239
View File
@@ -0,0 +1,239 @@
import { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { User, Checks, X, ClockCounterClockwiseIcon, ArrowLeftIcon, ArrowsClockwiseIcon, AirplaneTiltIcon } from '@phosphor-icons/react';
import { CustomButton, CustomTabs, CustomBackButton, CustomStatus } from '../../../components/custom';
import SummaryTab from './SummaryTab';
import CaseDetailsTab from './CaseDetailsTab';
import RecoveryPlanTab from './RecoveryPlanTab';
import AuditTrailTab from './AuditTrailTab';
import { SparkleIcon } from 'lucide-react';
import { getRecoveryIncident, updateIncidentStatus } from '../RecoveryIncidentsApi';
import type { RecoveryIncident } from '../RecoveryIncidentsTypes';
function getStatusVariant(status?: string): "success" | "error" | "warning" | "info" | "neutral" {
if (!status) return "neutral";
const s = status.toLowerCase();
if (s.includes("appr") || s.includes("active") || s.includes("success")) return "success";
if (s.includes("reject") || s.includes("denied") || s.includes("error")) return "error";
if (s.includes("pend") || s.includes("review") || s.includes("warn")) return "warning";
if (s.includes("new") || s.includes("info")) return "info";
return "neutral";
}
export default function RecoveryIncidentTabs() {
const { id } = useParams();
const [activeTab, setActiveTab] = useState('Summary');
const [incident, setIncident] = useState<RecoveryIncident | null>(null);
const [loading, setLoading] = useState(true);
const [updating, setUpdating] = useState(false);
const fetchIncident = async () => {
if (!id) return;
setLoading(true);
try {
const data = await getRecoveryIncident(id);
setIncident(data);
} catch (err) {
console.error("Failed to load incident:", err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchIncident();
}, [id]);
const handleStatusChange = async (newStatus: string) => {
if (!id || updating) return;
setUpdating(true);
try {
const updated = await updateIncidentStatus(id, newStatus);
setIncident(updated);
} catch (err) {
console.error("Failed to update incident status:", err);
} finally {
setUpdating(false);
}
};
const tabItems = [
{
id: 'Summary',
label: 'Summary',
content: <SummaryTab />
},
{
id: 'Case Details',
label: 'Case Details',
content: <CaseDetailsTab incident={incident} />
},
{
id: 'Recovery Plan',
label: 'Recovery Plan',
content: <RecoveryPlanTab />
},
{
id: 'Audit Trail',
label: 'Audit Trail',
content: <AuditTrailTab />
}
];
if (loading) {
return <div className="p-8 text-center text-gray-500">Loading incident...</div>;
}
const currentStatus = incident?.status || "Pending";
return (
<div className="w-full flex flex-col h-full relative">
{/* Header */}
<div className="flex items-start justify-between pb-6 border-b border-gray-100">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-3">
<CustomBackButton />
<h1 className="text-xl font-bold text-gray-900">{incident?.recoveryCode || id}</h1>
<CustomStatus
status={currentStatus}
variant={getStatusVariant(currentStatus)}
className="uppercase !text-[11px] !px-2.5 !py-1 !tracking-wide"
/>
</div>
<div className="flex items-center gap-3 text-sm text-gray-500 ml-8">
<div className="flex items-center gap-1.5"><User size={16} /> {incident?.passengerName || 'Unknown'}</div>
<span></span>
<div className="flex items-center gap-1.5"><AirplaneTiltIcon size={16} /> {incident?.flightNumber} ({incident?.flightRoute})</div>
</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"
>
RE-RUN ENGINE
</CustomButton>
</div>
{/* Tabs Row */}
<div className="flex items-center justify-between py-6">
<div className="flex-1">
<CustomTabs
tabs={tabItems}
value={activeTab}
onChange={setActiveTab}
contentClassName="!hidden"
/>
</div>
<div className="flex items-center gap-3">
<CustomButton variant="outlined" className="!border-[#1B9869] !text-[#1B9869] hover:!bg-green-50 !font-semibold !rounded-lg">
Share with Finance
</CustomButton>
<CustomButton variant="outlined" className="!border-[#1B9869] !text-[#1B9869] hover:!bg-green-50 !font-semibold !rounded-lg">
Export Report (PDF)
</CustomButton>
</div>
</div>
{/* Main Content Area */}
<div className="flex gap-6 items-start pb-24">
{/* Left Column - Tab Content */}
<div className="flex-1 bg-[#F8F9FA] rounded-[24px] p-3 border border-gray-100">
<CustomTabs
tabs={tabItems}
value={activeTab}
onChange={setActiveTab}
tabListClassName="!hidden"
contentClassName="!mt-0"
/>
</div>
{/* 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="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>
</div>
<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>
</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>
</div>
</div>
<div className="mb-8">
<div className="flex justify-between items-end mb-2">
<span className="text-xs font-bold text-gray-400 tracking-wider">ESCALATION RISK</span>
<span className="text-sm font-bold text-blue-500">12%</span>
</div>
<div className="h-2 bg-white rounded-full overflow-hidden border border-gray-100">
<div className="h-full bg-blue-500 w-[12%] rounded-full opacity-60"></div>
</div>
</div>
<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>
</div>
<CustomButton
disabled
rightIcon={<ArrowLeftIcon size={16} weight="bold" className="rotate-180" />}
className="w-full !py-3 !bg-[#1B9869]/50 !text-white !font-semibold !rounded-lg"
>
EXECUTE RECOMMENDATION
</CustomButton>
</div>
</div>
</div>
</div>
{/* 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 className="flex gap-4">
<CustomButton
variant="text"
disabled={updating}
onClick={() => handleStatusChange("Rejected")}
leftIcon={<X size={18} weight="bold" />}
className="!bg-[#FDE8E8] !text-[#E02424] !font-bold !rounded-lg !border !border-[#E02424] hover:!bg-red-100 disabled:opacity-50"
>
REJECT RECOVERY
</CustomButton>
<CustomButton
variant="text"
disabled={updating}
onClick={() => handleStatusChange("Under Review")}
leftIcon={<ClockCounterClockwiseIcon size={18} weight="bold" />}
className="!bg-[#FEF3C7] !text-[#B45309] !font-bold !rounded-lg !border !border-[#B45309] hover:!bg-[#FDE68A] disabled:opacity-50"
>
MARK FOR REVIEW
</CustomButton>
<CustomButton
variant="primary"
disabled={updating}
onClick={() => handleStatusChange("Approved")}
leftIcon={<Checks size={18} weight="bold" />}
className="!bg-[#1B9869] !bg-none !text-white !font-bold !rounded-lg !border !border-[#1B9869] hover:!bg-[#14704E] disabled:opacity-50"
>
APPROVE RECOVERY
</CustomButton>
</div>
</div>
</div>
);
}
@@ -1,5 +1,5 @@
import React from "react";
import { CheckCircleIcon } from "@phosphor-icons/react";
import { CheckCircleIcon, CaretDownIcon, CaretUpIcon } from "@phosphor-icons/react";
interface CustomAccordionSectionProps {
icon: React.ReactNode;
@@ -28,6 +28,11 @@ const CustomAccordionSection: React.FC<CustomAccordionSectionProps> = ({
{icon}
<span className="text-[11px] font-bold text-gray-700 uppercase tracking-wider flex-1">{title}</span>
{!isOpen && hasData && <CheckCircleIcon size={18} className="text-[#1B9869]" weight="fill" />}
{isOpen ? (
<CaretUpIcon size={16} className="text-gray-500" />
) : (
<CaretDownIcon size={16} className="text-gray-500" />
)}
</button>
{isOpen && <div className="px-4 pb-4 pt-3">{children}</div>}
</div>
+1 -1
View File
@@ -15,7 +15,7 @@ interface CustomButtonProps
const CustomButton: React.FC<CustomButtonProps> = ({
variant = "primary",
size = "md",
size = "sm",
leftIcon,
rightIcon,
loading = false,
+86 -40
View File
@@ -1,5 +1,5 @@
import React, { useState, useRef, useEffect } from "react";
import { CaretDownIcon, CheckIcon } from "@phosphor-icons/react";
import { CaretDownIcon, CheckIcon, MagnifyingGlassIcon } from "@phosphor-icons/react";
import DropdownPortal from "./DropdownPortal";
interface Option {
@@ -20,6 +20,8 @@ interface CustomDropdownProps {
required?: boolean;
className?: string;
error?: string;
searchable?: boolean;
searchPlaceholder?: string;
}
const CustomDropdown = React.forwardRef<HTMLDivElement, CustomDropdownProps>(
@@ -36,12 +38,16 @@ const CustomDropdown = React.forwardRef<HTMLDivElement, CustomDropdownProps>(
leftIcon,
error,
size = "md",
searchable = true,
searchPlaceholder = "Search...",
},
ref
) => {
const [isOpen, setIsOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const dropdownRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const sizeClasses = {
sm: "py-1.5 text-sm h-[36px]",
@@ -58,6 +64,7 @@ const CustomDropdown = React.forwardRef<HTMLDivElement, CustomDropdownProps>(
!(panelRef.current && panelRef.current.contains(target))
) {
setIsOpen(false);
setSearchQuery("");
}
};
@@ -65,16 +72,34 @@ const CustomDropdown = React.forwardRef<HTMLDivElement, CustomDropdownProps>(
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
useEffect(() => {
if (isOpen && searchable) {
const timer = setTimeout(() => {
searchInputRef.current?.focus();
}, 50);
return () => clearTimeout(timer);
} else if (!isOpen) {
setSearchQuery("");
}
}, [isOpen, searchable]);
const handleSelect = (option: Option) => {
if (option.disabled) 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 filteredOptions = searchable && searchQuery.trim()
? options.filter((opt) =>
opt.label.toLowerCase().includes(searchQuery.toLowerCase().trim())
)
: options;
return (
<div className="w-full flex flex-col gap-1.5" ref={ref}>
{label && (
@@ -130,47 +155,68 @@ 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-y-auto flex flex-col"
className="bg-white border border-gray-200 rounded-lg shadow-lg overflow-hidden flex flex-col max-h-[300px]"
>
{options.length === 0 ? (
<div className="px-4 py-3 text-sm text-gray-500 text-center">No options available</div>
) : (
options.map((option) => {
const isSelected = String(option.value) === String(value);
return (
<button
key={option.value}
type="button"
disabled={option.disabled}
onClick={(e) => {
e.stopPropagation();
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
? "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.label}
</span>
</button>
);
})
{searchable && (
<div className="p-2 border-b border-gray-100 bg-white sticky top-0 z-10">
<div className="relative flex items-center">
<MagnifyingGlassIcon size={16} className="absolute left-2.5 text-gray-400 pointer-events-none" />
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={searchPlaceholder}
className="w-full text-xs py-1.5 pl-8 pr-3 border border-gray-200 rounded-md focus:outline-none focus:border-primary text-gray-800 bg-gray-50/50"
onClick={(e) => e.stopPropagation()}
/>
</div>
</div>
)}
<div className="overflow-y-auto flex-1 max-h-[240px]">
{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) => {
const isSelected = String(option.value) === String(value);
return (
<button
key={option.value}
type="button"
disabled={option.disabled}
onClick={(e) => {
e.stopPropagation();
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
? "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.label}
</span>
</button>
);
})
)}
</div>
</DropdownPortal>
</div>
{error && <p className="text-xs text-red-500 mt-1">{error}</p>}
+1 -1
View File
@@ -1,7 +1,7 @@
import React, { useState, forwardRef } from "react";
import { PhoneIcon, EyeIcon, EyeSlashIcon } from "@phosphor-icons/react";
type InputType = "text" | "password" | "number" | "email" | "tel" | "date" | "month" | "datetime-local";
type InputType = "text" | "password" | "number" | "email" | "tel" | "date" | "month" | "datetime-local" | "time";
interface CustomInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "size"> {
label?: string;
+29 -11
View File
@@ -1,5 +1,5 @@
import React, { useState, useRef, useEffect } from "react";
import { CaretDownIcon, CheckIcon } from "@phosphor-icons/react";
import { CaretDownIcon, CheckIcon, XCircleIcon } from "@phosphor-icons/react";
import DropdownPortal from "./DropdownPortal";
interface Option {
@@ -44,9 +44,9 @@ const CustomMultiSelect = React.forwardRef<HTMLDivElement, CustomMultiSelectProp
const panelRef = useRef<HTMLDivElement>(null);
const sizeClasses = {
sm: "py-1.5 text-sm min-h-[36px]",
md: "py-2.5 text-sm min-h-[42px]",
lg: "py-3 text-base min-h-[48px]",
sm: "h-[36px] text-sm",
md: "h-[44px] text-sm",
lg: "h-[48px] text-base",
};
useEffect(() => {
@@ -78,6 +78,13 @@ const CustomMultiSelect = React.forwardRef<HTMLDivElement, CustomMultiSelectProp
onChange?.(newValue);
};
const handleRemove = (e: React.MouseEvent, optionValue: string | number) => {
e.stopPropagation();
const optionValueStr = String(optionValue);
const newValue = value.filter(v => String(v) !== optionValueStr);
onChange?.(newValue);
};
const selectedOptions = options.filter(opt => value.some(v => String(v) === String(opt.value)));
return (
@@ -100,11 +107,11 @@ const CustomMultiSelect = React.forwardRef<HTMLDivElement, CustomMultiSelectProp
px-3
outline-none
transition-all duration-200
flex items-center flex-wrap gap-1
flex items-center justify-between
${!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
pr-9
${className}
`}
>
@@ -114,15 +121,26 @@ const CustomMultiSelect = React.forwardRef<HTMLDivElement, CustomMultiSelectProp
</span>
)}
<div className="flex-1 text-left text-[14px] font-medium tracking-[0.25px] leading-[15px] flex flex-wrap gap-1 items-center">
<div className="flex-1 text-left font-medium tracking-[0.25px] flex items-center gap-1.5 overflow-x-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden py-0.5">
{selectedOptions.length > 0 ? (
selectedOptions.map(opt => (
<span key={opt.value} className="bg-gray-100 text-[#6C766D] px-2 py-0.5 rounded text-xs border border-gray-200">
{opt.label}
</span>
<span
key={opt.value}
className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full bg-[#F2F9F6] border border-[#D5E8DF] text-[#032D20] text-xs font-medium shrink-0 transition-all"
>
<span>{opt.label}</span>
<button
type="button"
onClick={(e) => handleRemove(e, opt.value)}
className="inline-flex items-center justify-center text-[#7A998C] hover:text-red-500 transition-colors cursor-pointer shrink-0"
title={`Remove ${opt.label}`}
>
<XCircleIcon size={14} weight="fill" />
</button>
</span>
))
) : (
<span style={{ color: '#6C766D' }}>{placeholder}</span>
<span className="text-[#6C766D] truncate">{placeholder}</span>
)}
</div>
+3 -1
View File
@@ -8,6 +8,7 @@ interface CustomSuccessModalProps {
isOpen: boolean;
onClose: () => void;
title?: string;
label?: string;
cohortName?: string;
cohortStatus?: string;
cohortDescription?: string;
@@ -17,6 +18,7 @@ const CustomSuccessModal: React.FC<CustomSuccessModalProps> = ({
isOpen,
onClose,
title = "Cohort Created Successfully.",
label = "COHORT NAME",
cohortName,
cohortStatus,
cohortDescription,
@@ -76,7 +78,7 @@ const CustomSuccessModal: React.FC<CustomSuccessModalProps> = ({
<div className="flex justify-between items-start mb-4">
<div>
<p className="text-[10px] font-bold tracking-wider text-[#1B9869] uppercase mb-1">
COHORT NAME
{label}
</p>
<p className="text-base font-bold text-[#152A3C]">
{cohortName}
+3 -1
View File
@@ -32,6 +32,7 @@ interface CustomTableProps<T> {
// Table Props
onRowClick?: (row: T) => void;
rowClassName?: (row: T) => string;
}
export function CustomTable<T>({
@@ -50,6 +51,7 @@ export function CustomTable<T>({
onPageChange,
itemName = "items",
onRowClick,
rowClassName,
}: CustomTableProps<T>) {
const handlePageChange = (newPage: number) => {
@@ -117,7 +119,7 @@ export function CustomTable<T>({
<tr
key={rowIndex}
onClick={() => onRowClick?.(row)}
className={`border-b border-gray-50 hover:bg-gray-50/50 transition-colors ${onRowClick ? "cursor-pointer" : ""}`}
className={`border-b border-gray-50 hover:bg-gray-50/50 transition-colors ${onRowClick ? "cursor-pointer" : ""} ${rowClassName ? rowClassName(row) : ""}`}
>
{columns.map((col, colIndex) => (
<td key={colIndex} className={`py-5 px-6 ${col.className || ''}`}>
+2 -2
View File
@@ -90,7 +90,7 @@ const CustomTabs: React.FC<CustomTabsProps> = ({
className={`
relative inline-flex items-center justify-center gap-2 px-5 py-2 text-sm font-medium rounded-xl transition-all duration-200
${isActive
? "bg-[#0B3B6A] text-white shadow-sm"
? "bg-gradient-to-b from-primary to-primary-dark text-white shadow-md shadow-primary/20 font-semibold text-white shadow-sm"
: "text-slate-500 hover:text-slate-800 hover:bg-slate-50"
}
${tab.disabled ? "cursor-not-allowed opacity-60" : "cursor-pointer"}
@@ -105,7 +105,7 @@ const CustomTabs: React.FC<CustomTabsProps> = ({
className={`rounded-md px-1.5 py-0.5 text-[11px] font-bold leading-none flex items-center justify-center min-w-[22px] h-[22px] ${
isActive
? "bg-white/20 text-white"
: "bg-[#0B3B6A] text-white"
: "bg-[#1B9869] text-white"
}`}
>
{tab.badge}
+2
View File
@@ -23,6 +23,7 @@ import CustomAlertBanner from "./CustomAlertBanner";
import Skeleton from "./CustomSkeleton";
import CustomTimePicker from "./CustomTimePicker";
import CustomAccordionSection from "./CustomAccordionSection";
import CustomFullModal from "./CustomFullModal";
export {
CustomInput,
@@ -37,6 +38,7 @@ export {
CustomRadio,
CustomSwitch,
CustomModal,
CustomFullModal,
CustomConfirmationModal,
CustomDatePicker,
CustomDateTimePicker,
+19
View File
@@ -14,4 +14,23 @@
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
}
/* Custom scrollbars */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: rgba(230, 233, 241, 0.6);
}
::-webkit-scrollbar-thumb {
background: rgba(220, 220, 220, 0.8);
border-radius: 9999px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(27, 152, 105, 0.8);
}
+5
View File
@@ -4,6 +4,11 @@ import { useLocation } from 'react-router-dom';
const PAGE_META: Record<string, { title: string; subtitle: string }> = {
'/': { title: 'Dashboard', subtitle: 'Overview of system status and active incidents.' },
'/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/add': { title: 'Deploy New Policy', subtitle: 'Configure policy framework details, targeting rules, and action payloads.' },
'/action-builder': { title: 'Configuration - Action Builder', subtitle: 'Configure dynamic categories, action types, metadata fields, and dynamic user form workflows.' },
'/config': { title: 'Configuration', subtitle: 'Manage dynamic action workflows, categories, metadata fields, and system master data.' },
'/recovery': { title: 'Recovery Incidents', subtitle: 'Operational workspace for managing passenger disruption cases.' },
};
interface AppHeaderProps {
+112 -63
View File
@@ -1,27 +1,27 @@
import { useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { useState } from "react";
import { Link, useLocation } from "react-router-dom";
import {
ShieldCheckIcon,
GearIcon,
ClockCounterClockwiseIcon,
CaretDoubleRightIcon,
SquaresFourIcon,
FadersIcon,
FadersIcon,
ArrowsClockwiseIcon,
UsersFourIcon,
CaretDoubleLeftIcon,
QuestionIcon,
SignOutIcon,
QuestionIcon
} from '@phosphor-icons/react';
GearIcon,
ClockCounterClockwiseIcon,
CaretDoubleRightIcon,
} from "@phosphor-icons/react";
import { ShieldCheckIcon } from "lucide-react";
const NAV_ITEMS = [
{ label: 'Dashboard', path: '/', icon: SquaresFourIcon },
{ label: 'Simulation Engine', path: '/simulation', icon: FadersIcon },
{ label: 'Recovery Incidents', path: '/recovery', icon: ArrowsClockwiseIcon , dot: true },
{ label: 'Cohort Management', path: '/cohorts', icon: UsersFourIcon },
{ label: 'Policy Engine', path: '/policy-engine', icon: ShieldCheckIcon },
{ label: 'Configuration', path: '/config', icon: GearIcon },
{ label: 'Audit Logs', path: '/audit', icon: ClockCounterClockwiseIcon },
{ label: "Dashboard", path: "/", icon: SquaresFourIcon },
{ label: "Simulation Engine", path: "/simulation", icon: FadersIcon },
{ label: "Recovery Incidents", path: "/recovery", icon: ArrowsClockwiseIcon },
{ label: "Cohort Management", path: "/cohorts", icon: UsersFourIcon },
{ label: "Policy Engine", path: "/policy-engine", icon: ShieldCheckIcon },
{ label: "Configuration", path: "/config", icon: GearIcon },
{ label: "Audit Logs", path: "/audit-logs", icon: ClockCounterClockwiseIcon },
];
interface AppSidebarProps {
@@ -37,47 +37,70 @@ export default function AppSidebar({ isOpen, onClose }: AppSidebarProps) {
<>
{/* Mobile overlay */}
{isOpen && (
<div
<div
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
onClick={onClose}
/>
)}
<div
className={`fixed inset-y-0 left-0 transform ${isOpen ? 'translate-x-0' : '-translate-x-full'} lg:relative lg:translate-x-0 z-50 ${isCollapsed ? 'w-[88px]' : 'w-[260px]'} h-screen flex flex-col bg-[#F4F7F6] font-sans transition-all duration-300 ease-in-out`}
<div
className={`fixed inset-y-0 left-0 transform ${isOpen ? "translate-x-0" : "-translate-x-full"} lg:relative lg:translate-x-0 z-50 ${isCollapsed ? "w-[88px]" : "w-[260px]"} h-screen flex flex-col bg-[#F4F7F6] font-sans transition-all duration-300 ease-in-out`}
>
{/* Logo Area */}
<div className={`pt-6 pb-4 flex items-center ${isCollapsed ? 'px-0 justify-center flex-col gap-4' : 'px-5 justify-between'}`}>
<div
className={`pt-6 pb-4 flex items-center ${isCollapsed ? "px-0 justify-center flex-col gap-4" : "px-5 justify-between"}`}
>
<div className="flex items-center gap-3">
<div className="w-[42px] h-[42px] bg-[#4B4B4B] rounded-[14px] flex flex-col items-center justify-center text-white shadow-sm shrink-0">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="mb-0.5">
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
className="mb-0.5"
>
<path d="M17.8 19.2 16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.2-1.1.7l-1.2 3.3c-.2.5.1 1.1.6 1.2l6.9 1.7-2.9 2.9-3.6-.9c-.5-.1-.9.2-1.1.7l-1.3 3.5c-.2.5.1 1.1.6 1.2l12.4 3.1c.5.1.9-.2 1.1-.7l.8-2.3c.1-.5-.2-1.1-.7-1.2z" />
</svg>
<div className="w-[18px] h-[2px] bg-white rounded-full"></div>
</div>
{!isCollapsed && (
<span className="text-[17px] font-extrabold text-[#111827] tracking-tight whitespace-nowrap">Aero Resolve</span>
<span className="text-[17px] font-extrabold text-[#111827] tracking-tight whitespace-nowrap">
Aero Resolve
</span>
)}
</div>
<button onClick={onClose} className="text-slate-400 hover:text-slate-600 transition-colors lg:hidden">
<CaretDoubleLeftIcon size={20} weight="bold" />
</button>
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className="text-slate-400 hover:text-slate-600 transition-colors hidden lg:block"
<button
onClick={onClose}
className="text-slate-400 hover:text-slate-600 transition-colors lg:hidden"
>
{isCollapsed ? <CaretDoubleRightIcon size={20} weight="bold" /> : <CaretDoubleLeftIcon size={20} weight="bold" />}
<CaretDoubleLeftIcon size={20} weight="bold" />
</button>
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className={`hidden lg:block text-slate-400 hover:text-slate-600 transition-colors ${isCollapsed ? "" : "ml-auto"}`}
>
{isCollapsed ? (
<CaretDoubleRightIcon size={20} weight="bold" />
) : (
<CaretDoubleLeftIcon size={20} weight="bold" />
)}
</button>
</div>
{/* Navigation */}
<nav className={`flex-1 py-3 space-y-1 overflow-y-auto overflow-x-hidden ${isCollapsed ? 'px-3' : 'px-4'}`}>
<nav
className={`flex-1 py-3 space-y-1 overflow-y-auto overflow-x-hidden ${isCollapsed ? "px-3" : "px-4"}`}
>
{NAV_ITEMS.map((item) => {
const isActive = location.pathname === item.path || (item.path !== '/' && location.pathname.startsWith(item.path));
const isActive =
location.pathname === item.path ||
(item.path !== "/" && location.pathname.startsWith(item.path));
const Icon = item.icon;
return (
<Link
key={item.path}
@@ -86,51 +109,77 @@ export default function AppSidebar({ isOpen, onClose }: AppSidebarProps) {
onClick={() => {
if (window.innerWidth < 1024) onClose();
}}
className={`group flex items-center ${isCollapsed ? 'justify-center px-0 w-12 mx-auto' : 'gap-3 px-3.5'} py-[10px] rounded-[12px] text-[13px] transition-all duration-200 relative ${
isActive
? 'bg-gradient-to-b from-primary to-primary-dark text-white shadow-md shadow-primary/20 font-semibold'
: 'text-[#475569] font-medium hover:bg-slate-200/40 hover:text-slate-900'
}`}
className={`group flex items-center ${isCollapsed ? "justify-center px-0 w-12 mx-auto" : "gap-3 px-3.5"} py-[10px] rounded-[12px] text-[13px] transition-all duration-200 relative ${isActive
? "bg-gradient-to-b from-primary to-primary-dark text-white shadow-md shadow-primary/20 font-semibold"
: "text-[#475569] font-medium hover:bg-slate-200/40 hover:text-slate-900"
}`}
>
<Icon
size={18}
className={`${isActive ? 'text-white' : 'text-slate-600 group-hover:text-slate-800'} shrink-0`}
strokeWidth={isActive ? 2 : 1.5}
<Icon
size={18}
className={`${isActive ? "text-white" : "text-slate-600 group-hover:text-slate-800"} shrink-0`}
strokeWidth={isActive ? 2 : 1.5}
/>
{!isCollapsed && (
<span className="whitespace-nowrap">{item.label}</span>
)}
{item.dot && (
<div className={`${isCollapsed ? 'absolute top-2 right-2' : 'ml-auto'} w-1.5 h-1.5 rounded-full ${isActive ? 'bg-white' : 'bg-primary'}`} />
)}
</Link>
);
})}
</nav>
{/* Bottom Section Card */}
<div className={`mb-2 mt-2 bg-gradient-to-br from-[#FAFCFB] to-[#E3EFE9] border border-white rounded-[24px] shadow-[0_4px_12px_-4px_rgba(0,0,0,0.05)] relative overflow-hidden transition-all duration-300 ${isCollapsed ? 'mx-2 p-2 flex flex-col items-center gap-3' : 'mx-4 p-3'}`}>
<div
className={`mb-2 mt-2 bg-gradient-to-br from-[#FAFCFB] to-[#E3EFE9] border border-white rounded-[24px] shadow-[0_4px_12px_-4px_rgba(0,0,0,0.05)] relative overflow-hidden transition-all duration-300 ${isCollapsed ? "mx-2 p-2 flex flex-col items-center gap-3" : "mx-4 p-3"}`}
>
{/* Soft decorative glow */}
{!isCollapsed && <div className="absolute -top-10 -right-10 w-32 h-32 bg-white/60 rounded-full blur-2xl pointer-events-none" />}
<div className={`relative z-10 flex flex-col ${isCollapsed ? 'gap-2 w-full' : 'gap-0.5'}`}>
<button title={isCollapsed ? "Sign Out" : undefined} className={`flex items-center ${isCollapsed ? 'justify-center px-0 h-10 w-full' : 'gap-3 px-3 py-2'} text-[13px] font-semibold text-[#E02424] hover:bg-red-50/50 rounded-[10px] transition-colors`}>
<SignOutIcon size={17} className="text-[#E02424] shrink-0" weight="bold" />
{!isCollapsed && <span className="whitespace-nowrap">Sign Out</span>}
{!isCollapsed && (
<div className="absolute -top-10 -right-10 w-32 h-32 bg-white/60 rounded-full blur-2xl pointer-events-none" />
)}
<div
className={`relative z-10 flex flex-col ${isCollapsed ? "gap-2 w-full" : "gap-0.5"}`}
>
<button
title={isCollapsed ? "Sign Out" : undefined}
className={`flex items-center ${isCollapsed ? "justify-center px-0 h-10 w-full" : "gap-3 px-3 py-2"} text-[13px] font-semibold text-[#E02424] hover:bg-red-50/50 rounded-[10px] transition-colors`}
>
<SignOutIcon
size={17}
className="text-[#E02424] shrink-0"
weight="bold"
/>
{!isCollapsed && (
<span className="whitespace-nowrap">Sign Out</span>
)}
</button>
<button title={isCollapsed ? "Help Center" : undefined} className={`flex items-center ${isCollapsed ? 'justify-center px-0 h-10 w-full' : 'gap-3 px-3 py-2'} text-[13px] font-medium text-slate-700 hover:bg-white/40 rounded-[10px] transition-colors`}>
<QuestionIcon size={17} className="text-slate-700 shrink-0" weight="regular" />
{!isCollapsed && <span className="whitespace-nowrap">Help center</span>}
<button
title={isCollapsed ? "Help Center" : undefined}
className={`flex items-center ${isCollapsed ? "justify-center px-0 h-10 w-full" : "gap-3 px-3 py-2"} text-[13px] font-medium text-slate-700 hover:bg-white/40 rounded-[10px] transition-colors`}
>
<QuestionIcon
size={17}
className="text-slate-700 shrink-0"
weight="regular"
/>
{!isCollapsed && (
<span className="whitespace-nowrap">Help center</span>
)}
</button>
<div title={isCollapsed ? "Admin Demo" : undefined} className={`p-2 bg-white rounded-[16px] shadow-[0_2px_8px_-4px_rgba(0,0,0,0.08)] border border-white flex items-center cursor-pointer hover:shadow-md transition-shadow ${isCollapsed ? 'justify-center mt-1 w-full h-12' : 'gap-2.5 mt-2'}`}>
<div
title={isCollapsed ? "Admin Demo" : undefined}
className={`p-2 bg-white rounded-[16px] shadow-[0_2px_8px_-4px_rgba(0,0,0,0.08)] border border-white flex items-center cursor-pointer hover:shadow-md transition-shadow ${isCollapsed ? "justify-center mt-1 w-full h-12" : "gap-2.5 mt-2"}`}
>
<div className="w-8 h-8 rounded-full bg-[#C2D1E0] flex-shrink-0" />
{!isCollapsed && (
<div className="flex flex-col justify-center overflow-hidden">
<span className="text-[13px] font-bold text-[#111827] leading-tight truncate">Admin Demo</span>
<span className="text-[10px] text-slate-500 font-medium mt-0.5 leading-tight truncate">System Administrator</span>
<span className="text-[13px] font-bold text-[#111827] leading-tight truncate">
Admin Demo
</span>
<span className="text-[10px] text-slate-500 font-medium mt-0.5 leading-tight truncate">
System Administrator
</span>
</div>
)}
</div>
+26
View File
@@ -0,0 +1,26 @@
export function formatDate(dateString?: string | Date): string {
if (!dateString) return '';
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();
const monthNames = [
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
];
const month = monthNames[monthIdx];
let hours = isISO ? date.getUTCHours() : date.getHours();
const minutes = (isISO ? date.getUTCMinutes() : date.getMinutes()).toString().padStart(2, '0');
const ampm = hours >= 12 ? 'pm' : 'am';
hours = hours % 12;
hours = hours ? hours : 12;
return `${day} ${month} ${year}, ${hours}:${minutes}${ampm}`;
}
export default formatDate;