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:
+4
-1
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
@@ -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>
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ export const ApiClient = axios.create({
|
||||
baseURL: API_BASE,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Tenant-Id': 'demo-airline',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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(() => []);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -15,7 +15,7 @@ interface CustomButtonProps
|
||||
|
||||
const CustomButton: React.FC<CustomButtonProps> = ({
|
||||
variant = "primary",
|
||||
size = "md",
|
||||
size = "sm",
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
loading = false,
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 || ''}`}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user