Compare commits
110
Commits
younus
...
development
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6058d3cb26 | ||
|
|
0f026fe2dc | ||
|
|
73e233f129 | ||
|
|
3e01d757c7 | ||
|
|
3cbb18f4a5 | ||
|
|
c7f2760abd | ||
|
|
2bdfa72f91 | ||
|
|
1ea8ce5522 | ||
|
|
b9a267d958 | ||
|
|
38e060282b | ||
|
|
96bc0e0ba2 | ||
|
|
62980c9632 | ||
|
|
f1294724b7 | ||
|
|
1053285ae7 | ||
|
|
1cc16e72a9 | ||
|
|
32aa2cd5f7 | ||
|
|
f3b78faf5c | ||
|
|
878b6b2ebc | ||
|
|
1b52d7ede4 | ||
|
|
b024171f08 | ||
|
|
d232654b90 | ||
|
|
f72ef36629 | ||
|
|
0d2394e5ee | ||
|
|
d8db35b92c | ||
|
|
7f9d20309a | ||
|
|
cd5fcc3084 | ||
|
|
1192d3c178 | ||
|
|
39ee42c3eb | ||
|
|
43f9c08b1e | ||
|
|
d1b9a2b985 | ||
|
|
cdcddf65b2 | ||
|
|
8b4e96824c | ||
|
|
f0048b24ac | ||
|
|
62b1bbf4d6 | ||
|
|
5a1ad8e7ba | ||
|
|
4e8084da21 | ||
|
|
78a6fbcb6b | ||
|
|
47344f3005 | ||
|
|
1ebd68befd | ||
|
|
e23a2a2adf | ||
|
|
59c03cf43a | ||
|
|
1bedca9b86 | ||
|
|
229f897288 | ||
|
|
1c562adf29 | ||
|
|
0dc64824c9 | ||
|
|
f9fe81687a | ||
|
|
9adb397f9a | ||
|
|
13c043ba56 | ||
|
|
f3162e5c7d | ||
|
|
a1ea47c0f5 | ||
|
|
ee83c09c2d | ||
|
|
ded3390300 | ||
|
|
2bc8ba9bdc | ||
|
|
265ce1957f | ||
|
|
b25c949d28 | ||
|
|
2957a4de7a | ||
|
|
32cb1ab745 | ||
|
|
6270c7cfd3 | ||
|
|
a50fc12496 | ||
|
|
5747d6da68 | ||
|
|
4b6622f7d8 | ||
|
|
d9f94b3278 | ||
|
|
54c266c900 | ||
|
|
4f30456321 | ||
|
|
a368b93c90 | ||
|
|
15dce6a373 | ||
|
|
00c19ffc1f | ||
|
|
8f9b756b37 | ||
|
|
8ac4376a25 | ||
|
|
1b142d4cbf | ||
|
|
26a3f68b99 | ||
|
|
ea3ab37457 | ||
|
|
e1e4ad46e4 | ||
|
|
180567f11f | ||
|
|
f0bac1762c | ||
|
|
750286ddea | ||
|
|
8a34abcc97 | ||
|
|
72864c1027 | ||
|
|
2751487810 | ||
|
|
3e4f7781d8 | ||
|
|
35a6f81a90 | ||
|
|
df75a59117 | ||
|
|
ba2535d4c7 | ||
|
|
d5a3b05044 | ||
|
|
eff663fbc1 | ||
|
|
252bd40c10 | ||
|
|
568f55f00f | ||
|
|
3baff47c80 | ||
|
|
890870cdc7 | ||
|
|
95bfe33ddc | ||
|
|
95e0eff1ca | ||
|
|
fb848d181f | ||
|
|
795d33b636 | ||
|
|
bca69bc521 | ||
|
|
2fa3259244 | ||
|
|
7d9b2ada8f | ||
|
|
222271ed32 | ||
|
|
4de22c07e3 | ||
|
|
b3c5634f85 | ||
|
|
a5335f6f78 | ||
|
|
fcc7ded2a2 | ||
|
|
170930e0dc | ||
|
|
49bec66833 | ||
|
|
d5bb9e2438 | ||
|
|
cb842ca8bb | ||
|
|
bce1b088ea | ||
|
|
3048435468 | ||
|
|
d497b1a2b7 | ||
|
|
62613799e0 | ||
|
|
48dbbdfcbb |
@@ -1,4 +1,5 @@
|
||||
# Aero Resolve - Frontend
|
||||
test123
|
||||
|
||||
This is the frontend application built with React and Vite.
|
||||
|
||||
@@ -67,3 +68,6 @@ npm run preview:test
|
||||
# UAT
|
||||
npm run preview:uat
|
||||
```
|
||||
|
||||
|
||||
//this is dev
|
||||
Generated
+14
-10
@@ -8,10 +8,10 @@
|
||||
"name": "@aeroresolve/frontend",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"axios": "^1.18.1",
|
||||
"framer-motion": "^12.40.0",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-icons": "^5.6.0",
|
||||
@@ -576,6 +576,19 @@
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
}
|
||||
},
|
||||
"node_modules/@phosphor-icons/react": {
|
||||
"version": "2.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@phosphor-icons/react/-/react-2.1.10.tgz",
|
||||
"integrity": "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 16.8",
|
||||
"react-dom": ">= 16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.0.tgz",
|
||||
@@ -2828,15 +2841,6 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "1.23.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.23.0.tgz",
|
||||
"integrity": "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
|
||||
+2
-2
@@ -19,10 +19,10 @@
|
||||
"preview:uat": "vite preview --mode uat"
|
||||
},
|
||||
"dependencies": {
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"axios": "^1.18.1",
|
||||
"framer-motion": "^12.40.0",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-icons": "^5.6.0",
|
||||
@@ -47,4 +47,4 @@
|
||||
"typescript-eslint": "^8.59.2",
|
||||
"vite": "^8.0.12"
|
||||
}
|
||||
}
|
||||
}
|
||||
+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 |
+8
-5
@@ -1,12 +1,15 @@
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import AppRoutes from './AppRoutes'
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { AuthProvider } from './context/AuthContext';
|
||||
import AppRoutes from './AppRoutes';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AppRoutes />
|
||||
<AuthProvider>
|
||||
<AppRoutes />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default App
|
||||
export default App;
|
||||
|
||||
+151
-14
@@ -1,21 +1,158 @@
|
||||
import { Route, Routes } 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 { lazy, Suspense } from "react";
|
||||
import { Route, Routes, Navigate } from "react-router-dom";
|
||||
import Layout from "./layout/AppLayout";
|
||||
import ProtectedRoute from "./components/common/ProtectedRoute";
|
||||
|
||||
const LoginPage = lazy(() => import("./app/authentication"));
|
||||
const HomePage = lazy(() => import("./app/dashboard"));
|
||||
const SimulationPage = lazy(() => import("./app/simulation"));
|
||||
const CohortManage = lazy(() => import("./app/cohartManage"));
|
||||
const PolicyEngineList = lazy(() => import("./app/policyEngine/components/PolicyEngineList"));
|
||||
const AddPolicyEngine = lazy(() => import("./app/policyEngine/components/AddPolicyEngine"));
|
||||
const RecoveryIncidentsList = lazy(() => import("./app/recoveryIncidents/components/RecoveryIncidentsList"));
|
||||
const RecoveryIncidentTabs = lazy(() => import("./app/recoveryIncidents/tabs/index"));
|
||||
const ConfigurationPage = lazy(() => import("./app/configuration"));
|
||||
const AuditLogsList = lazy(() => import("./app/auditLogs/components/AuditLogsList"));
|
||||
const UsersPage = lazy(() => import("./app/users"));
|
||||
const RolesPage = lazy(() => import("./app/roles"));
|
||||
|
||||
function AppRoutes() {
|
||||
return (
|
||||
<Layout>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="min-h-screen flex items-center justify-center bg-[#F4F7F6]">
|
||||
<div className="w-10 h-10 border-4 border-slate-200 border-t-teal-600 rounded-full animate-spin" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/cohorts" element={<CohortManage />} />
|
||||
<Route path="/policy-engine" element={<PolicyEngineList />} />
|
||||
<Route path="/policy-engine/add" element={<AddPolicyEngine />} />
|
||||
{/* Public Login Route */}
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
||||
{/* Protected Application Routes */}
|
||||
<Route
|
||||
path="/*"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="min-h-[400px] flex items-center justify-center">
|
||||
<div className="w-8 h-8 border-3 border-slate-200 border-t-teal-600 rounded-full animate-spin" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute permission="dashboard:view">
|
||||
<HomePage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/simulation"
|
||||
element={
|
||||
<ProtectedRoute permission="simulation:view">
|
||||
<SimulationPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/cohorts"
|
||||
element={
|
||||
<ProtectedRoute permission="cohorts:view">
|
||||
<CohortManage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/policy-engine"
|
||||
element={
|
||||
<ProtectedRoute permission="policy_engine:view">
|
||||
<PolicyEngineList />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/policy-engine/add"
|
||||
element={
|
||||
<ProtectedRoute permission="policy_engine:create">
|
||||
<AddPolicyEngine />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/policy-engine/edit/:id"
|
||||
element={
|
||||
<ProtectedRoute permission="policy_engine:edit">
|
||||
<AddPolicyEngine />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/action-builder"
|
||||
element={<Navigate to="/config?tab=action-builder" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="/config"
|
||||
element={
|
||||
<ProtectedRoute permission="config:view">
|
||||
<ConfigurationPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/recovery"
|
||||
element={
|
||||
<ProtectedRoute permission="recovery:view">
|
||||
<RecoveryIncidentsList />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/recovery/:id"
|
||||
element={
|
||||
<ProtectedRoute permission="recovery:view">
|
||||
<RecoveryIncidentTabs />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/users"
|
||||
element={
|
||||
<ProtectedRoute permission="users:view">
|
||||
<UsersPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/roles"
|
||||
element={
|
||||
<ProtectedRoute permission="roles:view">
|
||||
<RolesPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/audit-logs"
|
||||
element={
|
||||
<ProtectedRoute permission="audit_logs:view">
|
||||
<AuditLogsList />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</Layout>
|
||||
)
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
export default AppRoutes
|
||||
export default AppRoutes;
|
||||
|
||||
+66
-15
@@ -1,23 +1,38 @@
|
||||
import axios from 'axios';
|
||||
import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
||||
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001/api';
|
||||
|
||||
// Create a configured Axios instance
|
||||
// Create a configured Axios instance with credentials enabled for secure HTTP-only cookies
|
||||
export const ApiClient = axios.create({
|
||||
baseURL: API_BASE,
|
||||
withCredentials: true,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Tenant-Id': 'demo-airline',
|
||||
},
|
||||
});
|
||||
|
||||
let isRefreshing = false;
|
||||
let failedQueue: Array<{
|
||||
resolve: (value?: any) => void;
|
||||
reject: (reason?: any) => void;
|
||||
}> = [];
|
||||
|
||||
const processQueue = (error: any, token: any = null) => {
|
||||
failedQueue.forEach((prom) => {
|
||||
if (error) {
|
||||
prom.reject(error);
|
||||
} else {
|
||||
prom.resolve(token);
|
||||
}
|
||||
});
|
||||
failedQueue = [];
|
||||
};
|
||||
|
||||
// Request Interceptor
|
||||
ApiClient.interceptors.request.use(
|
||||
(config) => {
|
||||
// You can attach authorization tokens here if needed
|
||||
// const token = localStorage.getItem('token');
|
||||
// if (token) {
|
||||
// config.headers.Authorization = `Bearer ${token}`;
|
||||
// }
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
// Cookies are automatically sent via withCredentials: true
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
@@ -25,17 +40,53 @@ ApiClient.interceptors.request.use(
|
||||
}
|
||||
);
|
||||
|
||||
// Response Interceptor
|
||||
// Response Interceptor with automatic silent refresh
|
||||
ApiClient.interceptors.response.use(
|
||||
(response) => {
|
||||
// Return just the data payload by default for convenience
|
||||
return response.data;
|
||||
},
|
||||
(error) => {
|
||||
// Handle global API errors here (e.g., redirect on 401 Unauthorized)
|
||||
if (error.response?.status === 401) {
|
||||
console.error('Unauthorized access - perhaps redirect to login?');
|
||||
async (error: AxiosError) => {
|
||||
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
|
||||
|
||||
const isAuthUrl =
|
||||
originalRequest?.url?.includes('/auth/login') ||
|
||||
originalRequest?.url?.includes('/auth/refresh') ||
|
||||
originalRequest?.url?.includes('/auth/me');
|
||||
|
||||
if (error.response?.status === 401 && !originalRequest?._retry && !isAuthUrl) {
|
||||
if (isRefreshing) {
|
||||
return new Promise((resolve, reject) => {
|
||||
failedQueue.push({ resolve, reject });
|
||||
})
|
||||
.then(() => ApiClient(originalRequest))
|
||||
.catch((err) => Promise.reject(err));
|
||||
}
|
||||
|
||||
originalRequest._retry = true;
|
||||
isRefreshing = true;
|
||||
|
||||
try {
|
||||
await axios.post(
|
||||
`${API_BASE}/auth/refresh`,
|
||||
{},
|
||||
{ withCredentials: true }
|
||||
);
|
||||
processQueue(null);
|
||||
return ApiClient(originalRequest);
|
||||
} catch (refreshError) {
|
||||
processQueue(refreshError, null);
|
||||
// Only redirect if not already on the login page
|
||||
if (!window.location.pathname.startsWith('/login')) {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(refreshError);
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default ApiClient;
|
||||
|
||||
@@ -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,226 @@
|
||||
import { useState, useEffect } from "react";
|
||||
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";
|
||||
import { getAllMasterData, type AllMasterData } from "../../configuration/masterData/MasterDataApi";
|
||||
|
||||
const MASTER_DATA_MAP: Record<string, keyof AllMasterData> = {
|
||||
regions: 'regions',
|
||||
cabinClasses: 'cabinClasses',
|
||||
tripPurposes: 'tripPurposes',
|
||||
loyaltyTiers: 'membershipTiers',
|
||||
membershipTiers: 'membershipTiers',
|
||||
customerValues: 'customerValues',
|
||||
passengerTypes: 'passengerTypes',
|
||||
ancillaryPurchases: 'ancillaryPurchases',
|
||||
revenueSegments: 'revenueSegments',
|
||||
};
|
||||
|
||||
function transformValue(key: string, value: any, masterData: AllMasterData | null) {
|
||||
if (!value) return value;
|
||||
|
||||
const mappedKey = MASTER_DATA_MAP[key];
|
||||
|
||||
// Array of objects with an ID
|
||||
if (Array.isArray(value) && value.length > 0 && typeof value[0] === 'object' && value[0] !== null && 'id' in value[0]) {
|
||||
if (mappedKey && masterData && masterData[mappedKey]) {
|
||||
const list = masterData[mappedKey];
|
||||
return value.map((item: any) => {
|
||||
const found = list.find((l) => l.id === item.id);
|
||||
return found ? found.label || item.id : item.id;
|
||||
});
|
||||
}
|
||||
return value.map((item: any) => item.id);
|
||||
}
|
||||
|
||||
// Single object with an ID
|
||||
if (typeof value === 'object' && !Array.isArray(value) && value !== null && 'id' in value) {
|
||||
if (mappedKey && masterData && masterData[mappedKey]) {
|
||||
const list = masterData[mappedKey];
|
||||
const found = list.find((l) => l.id === value.id);
|
||||
return found ? found.label || value.id : value.id;
|
||||
}
|
||||
return value.id;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
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, masterData }: { before?: Record<string, any>; after?: Record<string, any>; masterData: AllMasterData | null }) {
|
||||
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={transformValue(key, (before ?? {})[key], masterData)}
|
||||
after={transformValue(key, (after ?? {})[key], masterData)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AuditLogDetail({ log, onClose }: AuditLogDetailProps) {
|
||||
const [masterData, setMasterData] = useState<AllMasterData | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (log) {
|
||||
getAllMasterData().then(setMasterData).catch(console.error);
|
||||
}
|
||||
}, [log]);
|
||||
|
||||
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} masterData={masterData} />
|
||||
</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,367 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
MagnifyingGlassIcon,
|
||||
CalendarBlank,
|
||||
Export,
|
||||
CaretRight,
|
||||
} from "@phosphor-icons/react";
|
||||
import {
|
||||
CustomTable,
|
||||
CustomInput,
|
||||
CustomStatus,
|
||||
Skeleton,
|
||||
CustomButton,
|
||||
CustomDropdown,
|
||||
} 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";
|
||||
import Can from "../../../components/common/Can";
|
||||
|
||||
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,
|
||||
className = "",
|
||||
}: {
|
||||
text: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`text-[13px] font-semibold text-[#0F172B] leading-[18px] ${className}`}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModuleBadge({ module }: { module: string }) {
|
||||
let label = AUDIT_MODULE_LABELS[module] ?? module;
|
||||
|
||||
return (
|
||||
<div className="px-2.5 py-1 rounded-full text-[12px] font-bold w-max bg-[#E6F4EA] text-[#1E8E3E]">
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getLogDescription(row: AuditLog) {
|
||||
const entity = row.entityLabel || row.entityId || "record";
|
||||
const mod = AUDIT_MODULE_LABELS[row.module] || row.module;
|
||||
if (row.action === "CREATE")
|
||||
return `New record created in ${mod}: ${entity}.`;
|
||||
if (row.action === "UPDATE") return `${mod} record updated: ${entity}.`;
|
||||
if (row.action === "STATUS_CHANGE")
|
||||
return `Status changed for ${entity} in ${mod}.`;
|
||||
if (row.action === "DELETE") return `${mod} record deleted: ${entity}.`;
|
||||
return `Action ${row.action} performed on ${entity}.`;
|
||||
}
|
||||
|
||||
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 [daysFilter, setDaysFilter] = useState("7");
|
||||
|
||||
// ─── Fetch ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const fetchLogs = useCallback(
|
||||
async (page: number) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const filters: AuditLogFilters = {
|
||||
page,
|
||||
limit: PAGE_SIZE,
|
||||
};
|
||||
|
||||
if (daysFilter !== "all") {
|
||||
const now = new Date();
|
||||
const past = new Date();
|
||||
|
||||
if (daysFilter === "today") {
|
||||
past.setHours(0, 0, 0, 0);
|
||||
} else {
|
||||
past.setDate(now.getDate() - parseInt(daysFilter, 10));
|
||||
}
|
||||
|
||||
filters.dateFrom = past.toISOString();
|
||||
filters.dateTo = now.toISOString();
|
||||
}
|
||||
|
||||
const result = await getAuditLogs(filters);
|
||||
|
||||
setLogs(result.data || []);
|
||||
setTotalItems(result.total || 0);
|
||||
setTotalPages(result.totalPages || 1);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch audit logs", err);
|
||||
setLogs([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[daysFilter],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs(currentPage);
|
||||
}, [currentPage, fetchLogs]);
|
||||
|
||||
// Reset to page 1 when filters change
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [search, daysFilter]);
|
||||
|
||||
const handleExportCSV = () => {
|
||||
if (logs.length === 0) return;
|
||||
|
||||
const headers = [
|
||||
"Timestamp",
|
||||
"User",
|
||||
"Action",
|
||||
"Module",
|
||||
"Description",
|
||||
"Audit ID",
|
||||
];
|
||||
const rows = logs.map((row) => {
|
||||
const date = new Date(row.createdAt).toLocaleString();
|
||||
const user = row.performedBy ?? "System";
|
||||
const action = row.action;
|
||||
const module = AUDIT_MODULE_LABELS[row.module] ?? row.module;
|
||||
|
||||
const entity = row.entityLabel || row.entityId || "record";
|
||||
let desc = `Action ${action} performed on ${entity}.`;
|
||||
if (action === "CREATE")
|
||||
desc = `New record created in ${module}: ${entity}.`;
|
||||
if (action === "UPDATE") desc = `${module} record updated: ${entity}.`;
|
||||
if (action === "STATUS_CHANGE")
|
||||
desc = `Status changed for ${entity} in ${module}.`;
|
||||
if (action === "DELETE") desc = `${module} record deleted: ${entity}.`;
|
||||
|
||||
const auditId = row.id;
|
||||
|
||||
// Escape quotes and wrap in quotes to handle commas in values
|
||||
return [
|
||||
`"${date.replace(/"/g, '""')}"`,
|
||||
`"${user.replace(/"/g, '""')}"`,
|
||||
`"${action.replace(/"/g, '""')}"`,
|
||||
`"${module.replace(/"/g, '""')}"`,
|
||||
`"${desc.replace(/"/g, '""')}"`,
|
||||
`"${auditId.replace(/"/g, '""')}"`,
|
||||
].join(",");
|
||||
});
|
||||
|
||||
const csvContent = [headers.join(","), ...rows].join("\n");
|
||||
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.setAttribute("download", `audit_logs_${new Date().getTime()}.csv`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
// ─── Columns ────────────────────────────────────────────────────────────────
|
||||
|
||||
const columns: Column<AuditLog>[] = [
|
||||
{
|
||||
header: <HeaderLabel text="Timestamp" />,
|
||||
accessor: (row) => {
|
||||
const d = new Date(row.createdAt);
|
||||
const time = d
|
||||
.toLocaleTimeString(undefined, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: true,
|
||||
})
|
||||
.toLowerCase()
|
||||
.replace(" ", "");
|
||||
const date = d.toLocaleDateString(undefined, {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[13px] font-medium text-[#6C766D]">
|
||||
{time}
|
||||
</span>
|
||||
<span className="text-[13px] font-medium text-[#6C766D]">
|
||||
{date}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: <HeaderLabel text="User" />,
|
||||
accessor: (row) => {
|
||||
return <PrimaryText text={row.performedBy ?? "System"} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
header: <HeaderLabel text="Action" />,
|
||||
accessor: (row) => {
|
||||
const displayStatus =
|
||||
row.action === "STATUS_CHANGE" ? "Status Change" : row.action;
|
||||
return (
|
||||
<CustomStatus
|
||||
status={displayStatus}
|
||||
variant={AUDIT_ACTION_VARIANTS[row.action] ?? "neutral"}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: <HeaderLabel text="Module" />,
|
||||
accessor: (row) => <ModuleBadge module={row.module} />,
|
||||
},
|
||||
{
|
||||
header: <HeaderLabel text="des" />,
|
||||
accessor: (row) => (
|
||||
<PrimaryText text={getLogDescription(row)} className="!font-semibold" />
|
||||
),
|
||||
},
|
||||
{
|
||||
header: <HeaderLabel text="Audit ID" />,
|
||||
accessor: (row) => {
|
||||
const hash = row.id.split("-")[0].substring(0, 4).toUpperCase();
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-[13px] font-medium text-[#6C766D]">
|
||||
LOG-{hash}
|
||||
<CaretRight weight="bold" size={14} className="text-[#0F172B]" />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// ─── 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 displayedLogs = search
|
||||
? logs.filter(
|
||||
(l) =>
|
||||
l.entityLabel?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
l.entityId?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
l.module?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
l.performedBy?.toLowerCase().includes(search.toLowerCase()),
|
||||
)
|
||||
: logs;
|
||||
|
||||
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">
|
||||
<CustomTable<AuditLog>
|
||||
columns={columns}
|
||||
data={displayedLogs}
|
||||
leftHeaderActions={
|
||||
<div className="w-[360px]">
|
||||
<CustomInput
|
||||
placeholder="Search logs by keyword, user, or ID..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
leftIcon={
|
||||
<MagnifyingGlassIcon size={16} className="text-[#6C766D]" />
|
||||
}
|
||||
className="!bg-[#F8F9FA] !rounded-[8px] !h-[40px] !border-none !text-[13px]"
|
||||
containerClassName="!gap-0 border-none"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
rightHeaderActions={
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-[180px]">
|
||||
<CustomDropdown
|
||||
value={daysFilter}
|
||||
onChange={setDaysFilter}
|
||||
leftIcon={
|
||||
<CalendarBlank
|
||||
size={16}
|
||||
weight="bold"
|
||||
className="text-[#1E8E3E]"
|
||||
/>
|
||||
}
|
||||
searchable={false}
|
||||
options={[
|
||||
{ label: "Today", value: "today" },
|
||||
{ label: "Last 7 Days", value: "7" },
|
||||
{ label: "Last 30 Days", value: "30" },
|
||||
{ label: "Last 90 Days", value: "90" },
|
||||
{ label: "All Time", value: "all" },
|
||||
]}
|
||||
className="!border-[#1E8E3E] !bg-[#E6F4EA] !h-[40px] hover:!bg-[#E6F4EA]"
|
||||
/>
|
||||
</div>
|
||||
<Can permission="audit_logs:export">
|
||||
<CustomButton
|
||||
variant="outlined"
|
||||
onClick={handleExportCSV}
|
||||
className="flex items-center gap-2 h-[40px] px-4 rounded-[8px] border border-[#1E8E3E] text-[#1E8E3E] text-[13px] font-bold hover:bg-[#E6F4EA] transition-colors cursor-pointer"
|
||||
>
|
||||
<Export size={16} weight="bold" />
|
||||
Export CSV
|
||||
</CustomButton>
|
||||
</Can>
|
||||
</div>
|
||||
}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
totalItems={totalItems}
|
||||
startIndex={startIndex}
|
||||
endIndex={endIndex}
|
||||
onPageChange={setCurrentPage}
|
||||
itemName="log entries"
|
||||
onRowClick={(row) => setSelectedLog(row)}
|
||||
rowClassName={() =>
|
||||
"bg-white border-b border-gray-100 cursor-pointer hover:bg-[#F9FAFB] transition-colors"
|
||||
}
|
||||
/>
|
||||
|
||||
<AuditLogDetail log={selectedLog} onClose={() => setSelectedLog(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,232 @@
|
||||
function HomePage() {
|
||||
return (
|
||||
<section className="page home-page">
|
||||
<h1>Home Page</h1>
|
||||
<p>Welcome to the home page. Use the navigation links to switch pages.</p>
|
||||
</section>
|
||||
)
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../../../context/AuthContext';
|
||||
import {
|
||||
CustomInput,
|
||||
CustomButton,
|
||||
CustomCheckBox,
|
||||
CustomAlertBanner,
|
||||
} from '../../../components/custom';
|
||||
import {
|
||||
EnvelopeSimpleIcon,
|
||||
LockKeyIcon,
|
||||
ArrowRightIcon,
|
||||
SparkleIcon,
|
||||
ShieldCheckIcon,
|
||||
InfoIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
|
||||
interface SiginProps {
|
||||
onSuccess?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default HomePage
|
||||
export default function Sigin({ onSuccess, className = '' }: SiginProps) {
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [rememberMe, setRememberMe] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showForgotNotice, setShowForgotNotice] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!email.trim() || !password.trim()) {
|
||||
setError('Please provide both your enterprise email and password.');
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await login(email.trim(), password);
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
} else {
|
||||
const from = (location.state as any)?.from?.pathname || '/';
|
||||
navigate(from, { replace: true });
|
||||
}
|
||||
} catch (err: any) {
|
||||
const msg =
|
||||
err?.response?.data?.message ||
|
||||
err?.message ||
|
||||
'Failed to authenticate. Please check your credentials and network access.';
|
||||
setError(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleQuickFill = () => {
|
||||
setEmail('admin@aeroresolve.com');
|
||||
setPassword('Password@123');
|
||||
setError(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`w-full max-w-md mx-auto space-y-6 ${className}`}>
|
||||
{/* Brand Header */}
|
||||
<div className="space-y-3">
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[18px] font-extrabold text-[#0F172B] tracking-tight whitespace-nowrap">
|
||||
Aero Resolve
|
||||
</span>
|
||||
<span className="text-[10px] uppercase font-bold tracking-wider px-2 py-0.5 rounded-full bg-[#E8F3EF] text-[#1E7D5C] border border-emerald-100">
|
||||
Enterprise
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[12px] text-[#64748B]">Disruption Management & Recovery</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-2xl font-extrabold text-[#0F172B] tracking-tight">
|
||||
Sign In to Terminal
|
||||
</h2>
|
||||
<p className="text-sm text-[#64748B] mt-1">
|
||||
Enter your enterprise credentials to access operational control.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Demo Credentials Quick Fill Helper */}
|
||||
<div className="p-3.5 rounded-[12px] bg-[#E8F3EF]/60 border border-emerald-200/60 flex items-center justify-between gap-3 shadow-xs">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-7 h-7 rounded-lg bg-[#1E7D5C]/10 flex items-center justify-center text-[#1E7D5C] shrink-0">
|
||||
<SparkleIcon size={16} weight="fill" />
|
||||
</div>
|
||||
<div className="text-xs">
|
||||
<span className="font-semibold text-[#0F172B] block">Super Admin Demo</span>
|
||||
<span className="text-[#64748B] text-[11px] font-mono">admin@aeroresolve.com</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleQuickFill}
|
||||
className="px-3 py-1.5 text-xs font-bold text-[#1E7D5C] bg-white hover:bg-emerald-50 border border-emerald-200 rounded-[8px] transition-colors cursor-pointer shadow-xs"
|
||||
>
|
||||
Auto Fill
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error Alert */}
|
||||
{error && (
|
||||
<CustomAlertBanner
|
||||
message={error}
|
||||
type="error"
|
||||
onClose={() => setError(null)}
|
||||
autoClose={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Forgot Password Modal / Alert Notice */}
|
||||
{showForgotNotice && (
|
||||
<div className="p-3.5 rounded-[12px] bg-blue-50 border border-blue-200 text-blue-900 text-xs flex items-start justify-between gap-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<InfoIcon size={16} className="text-blue-600 mt-0.5 shrink-0" />
|
||||
<p>
|
||||
For security compliance, password resets require airline system administrator approval. Contact your IT operations lead at <span className="font-semibold font-mono">support@aeroresolve.com</span>.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowForgotNotice(false)}
|
||||
className="text-blue-500 hover:text-blue-800 font-bold px-1"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<CustomInput
|
||||
label="Email Address"
|
||||
required
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="e.g. name@aeroresolve.com"
|
||||
leftIcon={<EnvelopeSimpleIcon size={18} className="text-gray-400" />}
|
||||
className="!h-[44px] !rounded-[10px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<CustomInput
|
||||
label="Password"
|
||||
required
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••••••"
|
||||
leftIcon={<LockKeyIcon size={18} className="text-gray-400" />}
|
||||
className="!h-[44px] !rounded-[10px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Remember me & Forgot Password */}
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<CustomCheckBox
|
||||
label="Remember this workstation"
|
||||
checked={rememberMe}
|
||||
onChange={(e) => setRememberMe(e.target.checked)}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowForgotNotice(true)}
|
||||
className="text-xs font-semibold text-[#1E7D5C] hover:text-[#14704E] hover:underline transition-colors cursor-pointer"
|
||||
>
|
||||
Forgot Password?
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="pt-2">
|
||||
<CustomButton
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
loading={loading}
|
||||
disabled={loading || !email.trim() || !password.trim()}
|
||||
rightIcon={<ArrowRightIcon size={18} weight="bold" />}
|
||||
className="!w-full !h-[46px] !rounded-[12px] !bg-[#1B9869] hover:!bg-[#14704E] font-bold text-sm shadow-sm transition-all"
|
||||
>
|
||||
{loading ? 'Authenticating...' : 'Sign In to Terminal'}
|
||||
</CustomButton>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Security Footer */}
|
||||
<div className="pt-3 border-t border-gray-100 flex items-center justify-center gap-2 text-[12px] text-[#64748B]">
|
||||
<ShieldCheckIcon size={17} weight="fill" className="text-[#1E7D5C]" />
|
||||
<span>Secured via Role-Based Access Control (RBAC)</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,128 @@
|
||||
function AboutPage() {
|
||||
return (
|
||||
<section className="page about-page">
|
||||
<h1>About Page</h1>
|
||||
<p>This is the about page. Add your about content here.</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import Sigin from './components/Sigin';
|
||||
import {
|
||||
ShieldCheckIcon,
|
||||
AirplaneTiltIcon,
|
||||
CheckCircleIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
|
||||
export default AboutPage
|
||||
export default function LoginPage() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
// If already authenticated, redirect to destination or root
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
const from = (location.state as any)?.from?.pathname || '/';
|
||||
navigate(from, { replace: true });
|
||||
}
|
||||
}, [isAuthenticated, navigate, location]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen w-full flex flex-col lg:flex-row bg-[#F4F7F6] text-[#0F172B] font-sans">
|
||||
{/* Left / Hero Column */}
|
||||
<div className="relative hidden lg:flex lg:w-1/2 xl:w-7/12 flex-col justify-between p-12 overflow-hidden bg-gradient-to-br from-[#0F172A] via-[#111C33] to-[#0A101D] text-slate-100">
|
||||
{/* Background glow and subtle aviation radar circles */}
|
||||
<div className="absolute top-1/4 -left-20 w-96 h-96 bg-emerald-500/10 rounded-full blur-3xl pointer-events-none" />
|
||||
<div className="absolute bottom-10 right-10 w-96 h-96 bg-teal-600/10 rounded-full blur-3xl pointer-events-none" />
|
||||
<div className="absolute inset-0 bg-[radial-gradient(#334155_1px,transparent_1px)] [background-size:24px_24px] opacity-25 pointer-events-none" />
|
||||
|
||||
{/* Top Brand Logo */}
|
||||
<div className="relative z-10 flex items-center gap-3.5">
|
||||
<div className="w-[46px] h-[46px] bg-[#4B4B4B] rounded-[14px] flex flex-col items-center justify-center text-white shadow-lg shadow-black/20 shrink-0 border border-white/10">
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
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-[20px] h-[2px] bg-white rounded-full" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-xl font-extrabold tracking-tight text-white">
|
||||
AERO RESOLVE
|
||||
</h1>
|
||||
<span className="text-[10px] uppercase font-bold tracking-widest px-2 py-0.5 rounded-full bg-emerald-500/20 text-emerald-300 border border-emerald-500/30">
|
||||
Enterprise
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 font-medium">
|
||||
Aviation Passenger Recovery & Compensation Intelligence
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hero Central Content */}
|
||||
<div className="relative z-10 my-auto max-w-xl space-y-6">
|
||||
<div className="inline-flex items-center gap-2 px-3.5 py-1.5 rounded-full bg-white/5 border border-white/10 backdrop-blur-md">
|
||||
<ShieldCheckIcon size={18} weight="fill" className="text-emerald-400" />
|
||||
<span className="text-xs font-semibold text-slate-200 tracking-wide">
|
||||
Multi-Jurisdiction Regulatory Decision Framework
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h2 className="text-3xl xl:text-4xl font-black text-white leading-tight tracking-tight">
|
||||
Next-Generation Flight Disruption Orchestration
|
||||
</h2>
|
||||
|
||||
<p className="text-sm xl:text-base text-slate-300 leading-relaxed font-normal">
|
||||
Automate passenger cohort segmentation, statutory compensation (EU261, US DOT, APPR, UK261),
|
||||
goodwill policies, and live manifest recovery simulation in real time.
|
||||
</p>
|
||||
|
||||
{/* Operational Feature Badges */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-2">
|
||||
<div className="flex items-center gap-2.5 p-3 rounded-xl bg-white/[0.04] border border-white/10">
|
||||
<CheckCircleIcon size={20} weight="fill" className="text-emerald-400 shrink-0" />
|
||||
<span className="text-xs font-medium text-slate-200">
|
||||
Dynamic Policy Rule Engine
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5 p-3 rounded-xl bg-white/[0.04] border border-white/10">
|
||||
<AirplaneTiltIcon size={20} weight="fill" className="text-emerald-400 shrink-0" />
|
||||
<span className="text-xs font-medium text-slate-200">
|
||||
Real-Time Incident Recovery
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metrics Row */}
|
||||
<div className="grid grid-cols-2 gap-4 pt-4 border-t border-slate-800/80">
|
||||
<div className="p-4 rounded-xl bg-white/[0.03] border border-white/5">
|
||||
<span className="text-2xl font-extrabold text-white block">99.98%</span>
|
||||
<span className="text-xs text-slate-400 font-medium">Uptime Reliability</span>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-white/[0.03] border border-white/5">
|
||||
<span className="text-2xl font-extrabold text-emerald-400 block">< 150ms</span>
|
||||
<span className="text-xs text-slate-400 font-medium">Evaluation Latency</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Trust Footer */}
|
||||
<div className="relative z-10 flex items-center justify-between text-xs text-slate-500 pt-6 border-t border-slate-800/60 font-medium">
|
||||
<span>Aero Resolve Operating System v1.0</span>
|
||||
<span>Aerospace High-Security Protocol</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right / Login Form Column */}
|
||||
<div className="w-full lg:w-1/2 xl:w-5/12 flex flex-col justify-center items-center p-6 sm:p-10 lg:p-12 bg-white">
|
||||
<div className="w-full max-w-md">
|
||||
<Sigin />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { FileText, ChevronRight, ChevronLeft, User, CircleCheck, Globe } from 'lucide-react';
|
||||
import React, { useState, useEffect} from 'react';
|
||||
import { FileTextIcon, CaretRightIcon, CaretLeftIcon, UserListIcon, GlobeIcon, UserCircleCheckIcon } from '@phosphor-icons/react';
|
||||
import {
|
||||
CustomModal,
|
||||
CustomInput,
|
||||
@@ -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);
|
||||
@@ -344,13 +326,13 @@ export default function AddCohart({ isOpen, onClose, onSuccess, editData }: AddC
|
||||
Cancel
|
||||
</CustomButton>
|
||||
) : (
|
||||
<CustomButton variant="outlined" leftIcon={<ChevronLeft size={18} />} onClick={() => setStep(1)}>
|
||||
<CustomButton variant="outlined" leftIcon={<CaretLeftIcon size={18} />} onClick={() => setStep(1)}>
|
||||
Previous Step
|
||||
</CustomButton>
|
||||
)}
|
||||
<CustomButton
|
||||
variant="primary"
|
||||
rightIcon={step === 2 && isOnFinalSection ? undefined : <ChevronRight size={18} />}
|
||||
rightIcon={step === 2 && isOnFinalSection ? undefined : <CaretRightIcon size={18} />}
|
||||
onClick={
|
||||
step === 1
|
||||
? () => setStep(2)
|
||||
@@ -377,7 +359,7 @@ export default function AddCohart({ isOpen, onClose, onSuccess, editData }: AddC
|
||||
className="!max-w-[750px]"
|
||||
title={isEditMode ? 'Edit Cohort' : 'New Dynamic Cohorts'}
|
||||
description="Configure targeting criteria for high-precision recovery."
|
||||
icon={<FileText size={20} />}
|
||||
icon={<FileTextIcon size={20} />}
|
||||
headerExtra={stepper}
|
||||
footer={footer}
|
||||
allowBackdropClose={false}
|
||||
@@ -442,7 +424,7 @@ export default function AddCohart({ isOpen, onClose, onSuccess, editData }: AddC
|
||||
|
||||
{/* Passenger Attributes */}
|
||||
<CustomAccordionSection
|
||||
icon={<User size={15} className="text-gray-700" strokeWidth={2} />}
|
||||
icon={<UserListIcon size={15} className="text-gray-700" strokeWidth={2} />}
|
||||
title="Passenger Attributes"
|
||||
isOpen={openSection === 'passenger'}
|
||||
hasData={passengerHasData}
|
||||
@@ -478,7 +460,7 @@ export default function AddCohart({ isOpen, onClose, onSuccess, editData }: AddC
|
||||
|
||||
{/* Customer Value */}
|
||||
<CustomAccordionSection
|
||||
icon={<CircleCheck size={15} className="text-gray-700" strokeWidth={2} />}
|
||||
icon={<UserCircleCheckIcon size={15} className="text-gray-700" />}
|
||||
title="Customer Value"
|
||||
isOpen={openSection === 'customer'}
|
||||
hasData={customerHasData}
|
||||
@@ -523,7 +505,7 @@ export default function AddCohart({ isOpen, onClose, onSuccess, editData }: AddC
|
||||
|
||||
{/* Journey Context */}
|
||||
<CustomAccordionSection
|
||||
icon={<Globe size={15} className="text-gray-700" strokeWidth={2} />}
|
||||
icon={<GlobeIcon size={15} className="text-gray-700" strokeWidth={2} />}
|
||||
title="Journey Context"
|
||||
isOpen={openSection === 'journey'}
|
||||
hasData={journeyHasData}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import AddCohart from './AddCohart';
|
||||
import { Plus, Trash2, Search, Check, X, Pencil } from 'lucide-react';
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import AddCohart from "./AddCohart";
|
||||
import {
|
||||
PlusIcon,
|
||||
TrashIcon,
|
||||
MagnifyingGlassIcon,
|
||||
CheckIcon,
|
||||
XIcon,
|
||||
PencilSimpleIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import {
|
||||
CustomTable,
|
||||
CustomInput,
|
||||
@@ -12,38 +19,41 @@ import {
|
||||
CustomConfirmationModal,
|
||||
CustomAlertBanner,
|
||||
Skeleton,
|
||||
} from '../../../components/custom';
|
||||
import type { Column } from '../../../components/custom/CustomTable';
|
||||
} from "../../../components/custom";
|
||||
import type { Column } from "../../../components/custom/CustomTable";
|
||||
import {
|
||||
listCoharts,
|
||||
deleteCohart,
|
||||
updateCohartStatus,
|
||||
} from '../CohartManageApi';
|
||||
import type { CohartResponse } from '../CohartManageTypes';
|
||||
} from "../CohartManageApi";
|
||||
import type { CohartResponse } from "../CohartManageTypes";
|
||||
import Can from "../../../components/common/Can";
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ label: 'All Statuses', value: '' },
|
||||
{ label: 'Active', value: 'Active' },
|
||||
{ label: 'Inactive', value: 'Inactive' },
|
||||
{ label: 'Draft', value: 'Draft' },
|
||||
{ label: "All Statuses", value: "" },
|
||||
{ label: "Active", value: "Active" },
|
||||
{ label: "Inactive", value: "Inactive" },
|
||||
{ label: "Draft", value: "Draft" },
|
||||
];
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function HeaderLabel({ text }: { text: string }) {
|
||||
return (
|
||||
<span className="text-[14px] font-semibold text-[#6C766D] tracking-[0px]">{text}</span>
|
||||
<span className="text-[14px] font-semibold text-[#6C766D] tracking-[0px]">
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function CellText({ text }: { text: string | null | undefined }) {
|
||||
return (
|
||||
<span style={{ fontSize: '14px', color: '#676767', fontWeight: 500 }}>
|
||||
{text || '—'}
|
||||
<span style={{ fontSize: "14px", color: "#676767", fontWeight: 500 }}>
|
||||
{text || "—"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -54,6 +64,7 @@ export default function CohortList() {
|
||||
const [cohorts, setCohorts] = useState<CohartResponse[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
||||
|
||||
// Server-side pagination
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
@@ -61,12 +72,13 @@ export default function CohortList() {
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
|
||||
// Client-side filters (applied on top of server data)
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [search, setSearch] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
|
||||
// Modal state
|
||||
const [deleteTarget, setDeleteTarget] = useState<CohartResponse | null>(null);
|
||||
const [deactivateTarget, setDeactivateTarget] = useState<CohartResponse | null>(null);
|
||||
const [deactivateTarget, setDeactivateTarget] =
|
||||
useState<CohartResponse | null>(null);
|
||||
const [editTarget, setEditTarget] = useState<CohartResponse | null>(null);
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
const [statusLoading, setStatusLoading] = useState(false);
|
||||
@@ -83,7 +95,7 @@ export default function CohortList() {
|
||||
setTotalItems(res.total);
|
||||
setTotalPages(res.totalPages);
|
||||
} catch {
|
||||
setError('Failed to load cohorts. Please try again.');
|
||||
setError("Failed to load cohorts. Please try again.");
|
||||
setCohorts([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -113,12 +125,15 @@ export default function CohortList() {
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
setDeleteLoading(true);
|
||||
setError(null);
|
||||
setSuccessMsg(null);
|
||||
try {
|
||||
await deleteCohart(deleteTarget.id);
|
||||
setSuccessMsg(`Cohort "${deleteTarget.name}" deleted successfully.`);
|
||||
setDeleteTarget(null);
|
||||
fetchCohorts(currentPage);
|
||||
} catch {
|
||||
setError('Failed to delete cohort. Please try again.');
|
||||
setError("Failed to delete cohort. Please try again.");
|
||||
} finally {
|
||||
setDeleteLoading(false);
|
||||
}
|
||||
@@ -127,13 +142,17 @@ export default function CohortList() {
|
||||
const handleToggleStatus = async () => {
|
||||
if (!deactivateTarget) return;
|
||||
setStatusLoading(true);
|
||||
setError(null);
|
||||
setSuccessMsg(null);
|
||||
try {
|
||||
const newStatus = deactivateTarget.status === 'Active' ? 'Inactive' : 'Active';
|
||||
const newStatus =
|
||||
deactivateTarget.status === "Active" ? "Inactive" : "Active";
|
||||
await updateCohartStatus(deactivateTarget.id, { status: newStatus });
|
||||
setSuccessMsg(`Cohort "${deactivateTarget.name}" is now ${newStatus}.`);
|
||||
setDeactivateTarget(null);
|
||||
fetchCohorts(currentPage);
|
||||
} catch {
|
||||
setError('Failed to update cohort status. Please try again.');
|
||||
setError("Failed to update cohort status. Please try again.");
|
||||
} finally {
|
||||
setStatusLoading(false);
|
||||
}
|
||||
@@ -142,8 +161,8 @@ export default function CohortList() {
|
||||
// ─── Client-side filter on top of server data ──────────────────────────────
|
||||
|
||||
const displayedCohorts = cohorts
|
||||
.filter(c => c.name.toLowerCase().includes(search.toLowerCase()))
|
||||
.filter(c => (statusFilter ? c.status === statusFilter : true));
|
||||
.filter((c) => c.name.toLowerCase().includes(search.toLowerCase()))
|
||||
.filter((c) => (statusFilter ? c.status === statusFilter : true));
|
||||
|
||||
const startIndex = totalItems > 0 ? (currentPage - 1) * PAGE_SIZE + 1 : 0;
|
||||
const endIndex = Math.min(currentPage * PAGE_SIZE, totalItems);
|
||||
@@ -153,7 +172,7 @@ export default function CohortList() {
|
||||
const columns: Column<CohartResponse>[] = [
|
||||
{
|
||||
header: <HeaderLabel text="Cohort Name" />,
|
||||
accessor: row => (
|
||||
accessor: (row) => (
|
||||
<span className="text-[13px] font-semibold text-[#0F172B] leading-[18px] tracking-[0px]">
|
||||
{row.name}
|
||||
</span>
|
||||
@@ -161,47 +180,55 @@ export default function CohortList() {
|
||||
},
|
||||
{
|
||||
header: <HeaderLabel text="Description" />,
|
||||
accessor: row => <CellText text={row.description} />,
|
||||
accessor: (row) => <CellText text={row.description} />,
|
||||
},
|
||||
{
|
||||
header: <HeaderLabel text="Status" />,
|
||||
accessor: row => <CustomStatus status={row.status} />,
|
||||
accessor: (row) => <CustomStatus status={row.status} />,
|
||||
},
|
||||
{
|
||||
header: <HeaderLabel text="Action" />,
|
||||
className: 'text-right',
|
||||
accessor: row => (
|
||||
className: "text-right",
|
||||
accessor: (row) => (
|
||||
<CustomActionMenu>
|
||||
{row.status !== 'Active' && (
|
||||
<CustomActionItem
|
||||
icon={<Check size={15} />}
|
||||
variant="success"
|
||||
onClick={() => setDeactivateTarget(row)}
|
||||
>
|
||||
Activate
|
||||
</CustomActionItem>
|
||||
{row.status !== "Active" && (
|
||||
<Can permission="cohorts:edit">
|
||||
<CustomActionItem
|
||||
icon={<CheckIcon size={15} />}
|
||||
variant="success"
|
||||
onClick={() => setDeactivateTarget(row)}
|
||||
>
|
||||
Activate
|
||||
</CustomActionItem>
|
||||
</Can>
|
||||
)}
|
||||
{row.status === 'Active' && (
|
||||
<CustomActionItem
|
||||
icon={<X size={15} />}
|
||||
onClick={() => setDeactivateTarget(row)}
|
||||
>
|
||||
Deactivate
|
||||
</CustomActionItem>
|
||||
{row.status === "Active" && (
|
||||
<Can permission="cohorts:edit">
|
||||
<CustomActionItem
|
||||
icon={<XIcon size={15} />}
|
||||
onClick={() => setDeactivateTarget(row)}
|
||||
>
|
||||
Deactivate
|
||||
</CustomActionItem>
|
||||
</Can>
|
||||
)}
|
||||
<CustomActionItem
|
||||
icon={<Pencil size={15} />}
|
||||
onClick={() => setEditTarget(row)}
|
||||
>
|
||||
Edit
|
||||
</CustomActionItem>
|
||||
<CustomActionItem
|
||||
icon={<Trash2 size={15} />}
|
||||
variant="danger"
|
||||
onClick={() => setDeleteTarget(row)}
|
||||
>
|
||||
Delete
|
||||
</CustomActionItem>
|
||||
<Can permission="cohorts:edit">
|
||||
<CustomActionItem
|
||||
icon={<PencilSimpleIcon size={15} />}
|
||||
onClick={() => setEditTarget(row)}
|
||||
>
|
||||
Edit
|
||||
</CustomActionItem>
|
||||
</Can>
|
||||
<Can permission="cohorts:delete">
|
||||
<CustomActionItem
|
||||
icon={<TrashIcon size={15} />}
|
||||
variant="danger"
|
||||
onClick={() => setDeleteTarget(row)}
|
||||
>
|
||||
Delete
|
||||
</CustomActionItem>
|
||||
</Can>
|
||||
</CustomActionMenu>
|
||||
),
|
||||
},
|
||||
@@ -239,6 +266,13 @@ export default function CohortList() {
|
||||
onClose={() => setError(null)}
|
||||
/>
|
||||
)}
|
||||
{successMsg && (
|
||||
<CustomAlertBanner
|
||||
message={successMsg}
|
||||
type="success"
|
||||
onClose={() => setSuccessMsg(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CustomTable<CohartResponse>
|
||||
columns={columns}
|
||||
@@ -248,8 +282,8 @@ export default function CohortList() {
|
||||
<CustomInput
|
||||
placeholder="Search cohorts..."
|
||||
value={search}
|
||||
onChange={e => handleSearchChange(e.target.value)}
|
||||
leftIcon={<Search size={16} />}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
leftIcon={<MagnifyingGlassIcon size={16} />}
|
||||
className="!bg-[#F3F6F5] !rounded-[10px] !h-[38px] !border !border-[#E5E7EB]"
|
||||
containerClassName="!gap-0"
|
||||
/>
|
||||
@@ -266,15 +300,17 @@ export default function CohortList() {
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
<CustomButton
|
||||
variant="primary"
|
||||
size="md"
|
||||
leftIcon={<Plus size={16} />}
|
||||
className="!rounded-[10px] !gap-[10px] !h-[40px]"
|
||||
onClick={() => setIsAddOpen(true)}
|
||||
>
|
||||
Create Cohort
|
||||
</CustomButton>
|
||||
<Can permission="cohorts:create">
|
||||
<CustomButton
|
||||
variant="primary"
|
||||
size="md"
|
||||
leftIcon={<PlusIcon size={16} />}
|
||||
className="!rounded-[10px] !gap-[10px] !h-[40px]"
|
||||
onClick={() => setIsAddOpen(true)}
|
||||
>
|
||||
Create Cohort
|
||||
</CustomButton>
|
||||
</Can>
|
||||
</>
|
||||
}
|
||||
currentPage={currentPage}
|
||||
@@ -304,13 +340,19 @@ export default function CohortList() {
|
||||
isOpen={!!deactivateTarget}
|
||||
onClose={() => setDeactivateTarget(null)}
|
||||
onConfirm={handleToggleStatus}
|
||||
title={deactivateTarget?.status === 'Active' ? 'Deactivate Cohort' : 'Activate Cohort'}
|
||||
title={
|
||||
deactivateTarget?.status === "Active"
|
||||
? "Deactivate Cohort"
|
||||
: "Activate Cohort"
|
||||
}
|
||||
description={
|
||||
deactivateTarget?.status === 'Active'
|
||||
deactivateTarget?.status === "Active"
|
||||
? `"${deactivateTarget?.name}" will be deactivated and removed from active targeting.`
|
||||
: `"${deactivateTarget?.name}" will be reactivated and available for targeting.`
|
||||
}
|
||||
confirmText={deactivateTarget?.status === 'Active' ? 'Deactivate' : 'Activate'}
|
||||
confirmText={
|
||||
deactivateTarget?.status === "Active" ? "Deactivate" : "Activate"
|
||||
}
|
||||
cancelText="Cancel"
|
||||
variant="warning"
|
||||
isLoading={statusLoading}
|
||||
@@ -332,7 +374,10 @@ export default function CohortList() {
|
||||
<AddCohart
|
||||
isOpen={!!editTarget}
|
||||
onClose={() => setEditTarget(null)}
|
||||
onSuccess={() => { setEditTarget(null); fetchCohorts(currentPage); }}
|
||||
onSuccess={() => {
|
||||
setEditTarget(null);
|
||||
fetchCohorts(currentPage);
|
||||
}}
|
||||
editData={editTarget}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
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`)
|
||||
.then(res => Array.isArray(res) ? res.sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0)) : []);
|
||||
}
|
||||
|
||||
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,180 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import {
|
||||
CustomModal,
|
||||
CustomInput,
|
||||
CustomDropdown,
|
||||
CustomTextArea,
|
||||
CustomSwitch,
|
||||
CustomButton,
|
||||
CustomAlertBanner,
|
||||
} 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;
|
||||
setError?: (err: string | null) => void;
|
||||
onClose: () => void;
|
||||
onSubmit: (e: React.FormEvent) => void;
|
||||
}
|
||||
|
||||
export function ActionTypeFormModal({
|
||||
isOpen,
|
||||
editingType,
|
||||
categories,
|
||||
formData,
|
||||
setFormData,
|
||||
error,
|
||||
setError,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: ActionTypeFormModalProps) {
|
||||
useEffect(() => {
|
||||
if (!error || !setError) return;
|
||||
if (error === 'Please select a Category.' && formData.categoryId) {
|
||||
setError(null);
|
||||
} else if (error === 'Action Type Name is required.' && formData.name.trim()) {
|
||||
setError(null);
|
||||
} else if (error === 'Action Type Code is required.' && formData.code.trim()) {
|
||||
setError(null);
|
||||
}
|
||||
}, [formData.categoryId, formData.name, formData.code, error, setError]);
|
||||
|
||||
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 && (
|
||||
<CustomAlertBanner
|
||||
message={error}
|
||||
type="error"
|
||||
onClose={() => setError?.(null)}
|
||||
autoClose={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
<form onSubmit={onSubmit} noValidate 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 }));
|
||||
if (setError && val) {
|
||||
setError(null);
|
||||
}
|
||||
}}
|
||||
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, '_'),
|
||||
}));
|
||||
if (setError && nameVal.trim()) {
|
||||
setError(null);
|
||||
}
|
||||
}}
|
||||
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) => {
|
||||
const codeVal = e.target.value;
|
||||
setFormData((prev) => ({ ...prev, code: codeVal }));
|
||||
if (setError && codeVal.trim()) {
|
||||
setError(null);
|
||||
}
|
||||
}}
|
||||
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,161 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { PlusIcon, MagnifyingGlassIcon, PencilSimpleIcon, TrashIcon } from '@phosphor-icons/react';
|
||||
import { CustomInput, CustomButton } from '../../../../components/custom';
|
||||
import type { ActionType } from '../ActionBuilderTypes';
|
||||
import Can from '../../../../components/common/Can';
|
||||
|
||||
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>
|
||||
<Can permission="config:create">
|
||||
<CustomButton
|
||||
variant="outlined"
|
||||
size="sm"
|
||||
onClick={onOpenAdd}
|
||||
disabled={!selectedCategoryId}
|
||||
leftIcon={<PlusIcon size={15} weight="bold" />}
|
||||
>
|
||||
Add
|
||||
</CustomButton>
|
||||
</Can>
|
||||
</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 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`font-bold text-[14px] truncate ${isSelected ? 'text-white' : 'text-slate-800'}`}>
|
||||
{t.name}
|
||||
</span>
|
||||
<span
|
||||
title={t.isActive !== false ? 'Active' : 'Inactive'}
|
||||
className={`w-2 h-2 rounded-full flex-shrink-0 ${
|
||||
isSelected
|
||||
? t.isActive !== false
|
||||
? 'bg-emerald-300 ring-2 ring-white/30'
|
||||
: 'bg-rose-300 ring-2 ring-rose-400/40'
|
||||
: t.isActive !== false
|
||||
? 'bg-[#1E7D5C]'
|
||||
: 'bg-rose-500'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div className={`font-mono text-[11px] uppercase tracking-wider truncate ${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">
|
||||
<Can permission="config:edit">
|
||||
<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>
|
||||
</Can>
|
||||
<Can permission="config:delete">
|
||||
<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>
|
||||
</Can>
|
||||
</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,151 @@
|
||||
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';
|
||||
import Can from '../../../../components/common/Can';
|
||||
|
||||
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>
|
||||
<Can permission="config:create">
|
||||
<CustomButton
|
||||
variant="outlined"
|
||||
size="sm"
|
||||
onClick={onOpenAdd}
|
||||
leftIcon={<PlusIcon size={15} weight="bold" />}
|
||||
>
|
||||
Add
|
||||
</CustomButton>
|
||||
</Can>
|
||||
</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-1 min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`font-bold text-[14px] truncate ${isSelected ? 'text-white' : 'text-slate-800'}`}>
|
||||
{cat.name}
|
||||
</span>
|
||||
<span
|
||||
title={cat.isActive !== false ? 'Active' : 'Inactive'}
|
||||
className={`w-2 h-2 rounded-full flex-shrink-0 ${isSelected
|
||||
? cat.isActive !== false
|
||||
? 'bg-emerald-300 ring-2 ring-white/30'
|
||||
: 'bg-rose-300 ring-2 ring-rose-400/40'
|
||||
: cat.isActive !== false
|
||||
? 'bg-[#1E7D5C]'
|
||||
: 'bg-rose-500'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div className={`font-mono text-[11px] uppercase tracking-wider truncate ${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">
|
||||
<Can permission="config:edit">
|
||||
<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>
|
||||
</Can>
|
||||
<Can permission="config:delete">
|
||||
<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>
|
||||
</Can>
|
||||
</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,156 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import {
|
||||
CustomModal,
|
||||
CustomInput,
|
||||
CustomTextArea,
|
||||
CustomSwitch,
|
||||
CustomButton,
|
||||
CustomAlertBanner,
|
||||
} 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;
|
||||
setError?: (err: string | null) => void;
|
||||
onClose: () => void;
|
||||
onSubmit: (e: React.FormEvent) => void;
|
||||
}
|
||||
|
||||
export function CategoryFormModal({
|
||||
isOpen,
|
||||
editingCategory,
|
||||
formData,
|
||||
setFormData,
|
||||
error,
|
||||
setError,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: CategoryFormModalProps) {
|
||||
useEffect(() => {
|
||||
if (!error || !setError) return;
|
||||
if (error === 'Category Name is required.' && formData.name.trim()) {
|
||||
setError(null);
|
||||
} else if (error === 'Category Code is required.' && formData.code.trim()) {
|
||||
setError(null);
|
||||
}
|
||||
}, [formData.name, formData.code, error, setError]);
|
||||
|
||||
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 && (
|
||||
<CustomAlertBanner
|
||||
message={error}
|
||||
type="error"
|
||||
onClose={() => setError?.(null)}
|
||||
autoClose={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
<form onSubmit={onSubmit} noValidate 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, '-'),
|
||||
}));
|
||||
if (setError && nameVal.trim()) {
|
||||
setError(null);
|
||||
}
|
||||
}}
|
||||
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) => {
|
||||
const codeVal = e.target.value;
|
||||
setFormData((prev) => ({ ...prev, code: codeVal }));
|
||||
if (setError && codeVal.trim()) {
|
||||
setError(null);
|
||||
}
|
||||
}}
|
||||
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,284 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
PlusIcon,
|
||||
MagnifyingGlassIcon,
|
||||
PencilSimpleLineIcon,
|
||||
TrashIcon,
|
||||
DotsSixVerticalIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
import type { FieldDefinition } from '../ActionBuilderTypes';
|
||||
import Can from '../../../../components/common/Can';
|
||||
import { CustomButton, CustomInput, Skeleton } from '../../../../components/custom';
|
||||
|
||||
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>
|
||||
<Can permission="config:create">
|
||||
<CustomButton
|
||||
variant="outlined"
|
||||
size="sm"
|
||||
onClick={onOpenAdd}
|
||||
disabled={!selectedActionTypeId}
|
||||
leftIcon={<PlusIcon size={15} weight="bold" />}
|
||||
>
|
||||
Add
|
||||
</CustomButton>
|
||||
</Can>
|
||||
</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="flex flex-col gap-3">
|
||||
<Skeleton width="100%" height={100} className="rounded-[16px]" />
|
||||
<Skeleton width="100%" height={100} className="rounded-[16px]" />
|
||||
<Skeleton width="100%" height={100} className="rounded-[16px]" />
|
||||
</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 className="min-w-0 flex-1 pr-2">
|
||||
<div className="font-bold text-[15px] text-[#0F172B] leading-tight truncate">
|
||||
{f.fieldName}
|
||||
</div>
|
||||
<div className="font-mono text-[12px] text-[#8C9AA8] uppercase tracking-wider mt-0.5 truncate">
|
||||
{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 */}
|
||||
<Can permission="config:edit">
|
||||
<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>
|
||||
</Can>
|
||||
|
||||
<Can permission="config:delete">
|
||||
<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>
|
||||
</Can>
|
||||
</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,410 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import {
|
||||
CustomModal,
|
||||
CustomInput,
|
||||
CustomDropdown,
|
||||
CustomTextArea,
|
||||
CustomSwitch,
|
||||
CustomButton,
|
||||
CustomAlertBanner,
|
||||
} 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;
|
||||
setError?: (err: string | null) => void;
|
||||
onClose: () => void;
|
||||
onSubmit: (e: React.FormEvent) => void;
|
||||
}
|
||||
|
||||
const VALIDATION_RELEVANT_TYPES: FieldType[] = [
|
||||
'textbox',
|
||||
'textarea',
|
||||
'email',
|
||||
'phone',
|
||||
'url',
|
||||
'number',
|
||||
'decimal',
|
||||
'percentage',
|
||||
'currency',
|
||||
];
|
||||
|
||||
export function FieldDefinitionFormModal({
|
||||
isOpen,
|
||||
editingField,
|
||||
fields,
|
||||
masterLookupOptions,
|
||||
formData,
|
||||
setFormData,
|
||||
error,
|
||||
setError,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: FieldDefinitionFormModalProps) {
|
||||
useEffect(() => {
|
||||
if (!error || !setError) return;
|
||||
if (error === 'Field Name is required.' && formData.fieldName.trim()) {
|
||||
setError(null);
|
||||
} else if (error === 'Field Code is required.' && formData.fieldCode.trim()) {
|
||||
setError(null);
|
||||
} else if (
|
||||
error === 'Master Data Lookup Source is required for Dropdown or Multi-Select control types.' &&
|
||||
formData.lookupSource?.trim()
|
||||
) {
|
||||
setError(null);
|
||||
}
|
||||
}, [formData.fieldName, formData.fieldCode, formData.lookupSource, error, setError]);
|
||||
|
||||
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,
|
||||
})),
|
||||
];
|
||||
|
||||
const isNumericType = ['number', 'decimal', 'percentage', 'currency'].includes(formData.fieldType);
|
||||
const minLabel = isNumericType ? 'Min Value' : 'Min Length';
|
||||
const maxLabel = isNumericType ? 'Max Value' : 'Max Length';
|
||||
const minPlaceholder = isNumericType ? 'e.g. 0' : 'e.g. 3';
|
||||
const maxPlaceholder = isNumericType ? 'e.g. 1000' : 'e.g. 250';
|
||||
|
||||
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 && (
|
||||
<CustomAlertBanner
|
||||
message={error}
|
||||
type="error"
|
||||
onClose={() => setError?.(null)}
|
||||
autoClose={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
<form onSubmit={onSubmit} noValidate className="space-y-4 pt-1">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<CustomInput
|
||||
label="Field Label / Name"
|
||||
required
|
||||
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, '_'),
|
||||
}));
|
||||
if (setError && val.trim()) {
|
||||
setError(null);
|
||||
}
|
||||
}}
|
||||
placeholder="e.g. Field Name"
|
||||
/>
|
||||
|
||||
<CustomInput
|
||||
label="Field Code (JSON Key)"
|
||||
required
|
||||
value={formData.fieldCode}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setFormData((prev) => ({ ...prev, fieldCode: val }));
|
||||
if (setError && val.trim()) {
|
||||
setError(null);
|
||||
}
|
||||
}}
|
||||
placeholder="e.g. field_code"
|
||||
/>
|
||||
|
||||
<CustomDropdown
|
||||
label="Control Type"
|
||||
required
|
||||
options={FIELD_TYPE_OPTIONS}
|
||||
value={formData.fieldType}
|
||||
onChange={(val) => {
|
||||
const newType = val as FieldType;
|
||||
const isLookupType = newType === 'dropdown' || newType === 'multi_select';
|
||||
const isValRelevant = VALIDATION_RELEVANT_TYPES.includes(newType);
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
fieldType: newType,
|
||||
lookupSource: isLookupType ? prev.lookupSource : undefined,
|
||||
validationJson: isValRelevant ? prev.validationJson : undefined,
|
||||
}));
|
||||
if (setError && !isLookupType) {
|
||||
setError(null);
|
||||
}
|
||||
}}
|
||||
placeholder="Select Control Type..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`grid grid-cols-1 ${formData.fieldType === 'dropdown' || formData.fieldType === 'multi_select' || formData.fieldType === 'currency'
|
||||
? 'md:grid-cols-3'
|
||||
: 'md:grid-cols-2'
|
||||
} gap-4`}
|
||||
>
|
||||
{(formData.fieldType === 'dropdown' || formData.fieldType === 'multi_select' || formData.fieldType === 'currency') && (
|
||||
<CustomDropdown
|
||||
label="Master Data Lookup Source"
|
||||
required
|
||||
options={masterLookupOptions}
|
||||
value={formData.lookupSource || ''}
|
||||
onChange={(val) => {
|
||||
setFormData((prev) => ({ ...prev, lookupSource: val }));
|
||||
if (setError && val?.trim()) {
|
||||
setError(null);
|
||||
}
|
||||
}}
|
||||
placeholder="Select Lookup Source..."
|
||||
/>
|
||||
)}
|
||||
|
||||
<CustomDropdown
|
||||
label="Form Width Layout"
|
||||
options={WIDTH_OPTIONS}
|
||||
value={formData.width}
|
||||
onChange={(val) => setFormData((prev) => ({ ...prev, width: val as FieldWidth }))}
|
||||
/>
|
||||
|
||||
<CustomInput
|
||||
label="Section Group Name"
|
||||
value={formData.section || ''}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, section: e.target.value }))}
|
||||
placeholder="e.g. General Information"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<CustomInput
|
||||
label="Placeholder"
|
||||
value={formData.placeholder || ''}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, placeholder: e.target.value }))}
|
||||
placeholder="e.g. Enter value..."
|
||||
/>
|
||||
|
||||
<CustomInput
|
||||
label="Default Value"
|
||||
value={formData.defaultValue || ''}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, defaultValue: e.target.value }))}
|
||||
placeholder="e.g. Default"
|
||||
/>
|
||||
|
||||
<CustomInput
|
||||
label="Display Order"
|
||||
type="number"
|
||||
value={String(formData.displayOrder)}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, displayOrder: parseInt(e.target.value) || 1 }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CustomTextArea
|
||||
label="Help Text / Instructions"
|
||||
value={formData.helpText || ''}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, helpText: e.target.value }))}
|
||||
placeholder="e.g. Instructions for end users filling this field"
|
||||
rows={2}
|
||||
/>
|
||||
|
||||
{/* Validation JSON Rules - Only shown for relevant field types */}
|
||||
{VALIDATION_RELEVANT_TYPES.includes(formData.fieldType) && (
|
||||
<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">
|
||||
<CustomInput
|
||||
label={minLabel}
|
||||
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={minPlaceholder}
|
||||
/>
|
||||
|
||||
<CustomInput
|
||||
label={maxLabel}
|
||||
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={maxPlaceholder}
|
||||
/>
|
||||
|
||||
<CustomInput
|
||||
label="Regex Pattern"
|
||||
value={formData.validationJson?.regex || ''}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
validationJson: {
|
||||
...prev.validationJson,
|
||||
regex: e.target.value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
placeholder="e.g. ^[A-Z0-9]+$"
|
||||
/>
|
||||
</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">
|
||||
<CustomDropdown
|
||||
label="Depends on Field Code"
|
||||
options={dependentOptions}
|
||||
value={formData.visibilityConditionJson?.field || ''}
|
||||
onChange={(val) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
visibilityConditionJson: {
|
||||
...(prev.visibilityConditionJson || { operator: 'equals', value: '' }),
|
||||
field: val,
|
||||
},
|
||||
}))
|
||||
}
|
||||
placeholder="Select dependent field..."
|
||||
/>
|
||||
|
||||
<CustomDropdown
|
||||
label="Operator"
|
||||
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,
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
|
||||
<CustomInput
|
||||
label="Target Value"
|
||||
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 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,684 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { CheckCircleIcon } from '@phosphor-icons/react';
|
||||
import { CustomConfirmationModal, Skeleton } 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: '',
|
||||
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: '',
|
||||
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 || '',
|
||||
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;
|
||||
}
|
||||
if (
|
||||
(fieldFormData.fieldType === 'dropdown' || fieldFormData.fieldType === 'multi_select') &&
|
||||
!fieldFormData.lookupSource?.trim()
|
||||
) {
|
||||
setFieldError('Master Data Lookup Source is required for Dropdown or Multi-Select control types.');
|
||||
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="grid grid-cols-1 lg:grid-cols-10 gap-2 items-start">
|
||||
<div className="lg:col-span-3 bg-white rounded-[16px] border border-gray-100 p-4 h-[600px] flex flex-col gap-4">
|
||||
<Skeleton width={150} height={24} />
|
||||
<Skeleton width="100%" height={40} className="rounded-lg" />
|
||||
<Skeleton width="100%" height={60} className="rounded-xl" />
|
||||
<Skeleton width="100%" height={60} className="rounded-xl" />
|
||||
<Skeleton width="100%" height={60} className="rounded-xl" />
|
||||
</div>
|
||||
<div className="lg:col-span-3 bg-white rounded-[16px] border border-gray-100 p-4 h-[600px] flex flex-col gap-4">
|
||||
<Skeleton width={150} height={24} />
|
||||
<Skeleton width="100%" height={40} className="rounded-lg" />
|
||||
<Skeleton width="100%" height={60} className="rounded-xl" />
|
||||
<Skeleton width="100%" height={60} className="rounded-xl" />
|
||||
<Skeleton width="100%" height={60} className="rounded-xl" />
|
||||
</div>
|
||||
<div className="lg:col-span-4 bg-white rounded-[16px] border border-gray-100 p-4 h-[600px] flex flex-col gap-4">
|
||||
<Skeleton width={200} height={24} />
|
||||
<Skeleton width="100%" height={40} className="rounded-lg" />
|
||||
<Skeleton width="100%" height={80} className="rounded-xl" />
|
||||
<Skeleton width="100%" height={80} className="rounded-xl" />
|
||||
</div>
|
||||
</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}
|
||||
setError={setCategoryError}
|
||||
onClose={() => {
|
||||
setCategoryError(null);
|
||||
setIsCategoryModalOpen(false);
|
||||
}}
|
||||
onSubmit={handleSaveCategory}
|
||||
/>
|
||||
|
||||
<ActionTypeFormModal
|
||||
isOpen={isTypeModalOpen}
|
||||
editingType={editingType}
|
||||
categories={categories}
|
||||
formData={typeFormData}
|
||||
setFormData={setTypeFormData}
|
||||
error={typeError}
|
||||
setError={setTypeError}
|
||||
onClose={() => {
|
||||
setTypeError(null);
|
||||
setIsTypeModalOpen(false);
|
||||
}}
|
||||
onSubmit={handleSaveType}
|
||||
/>
|
||||
|
||||
<FieldDefinitionFormModal
|
||||
isOpen={isFieldModalOpen}
|
||||
editingField={editingField}
|
||||
fields={fields}
|
||||
masterLookupOptions={masterLookupOptions}
|
||||
formData={fieldFormData}
|
||||
setFormData={setFieldFormData}
|
||||
error={fieldError}
|
||||
setError={setFieldError}
|
||||
onClose={() => {
|
||||
setFieldError(null);
|
||||
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,107 @@
|
||||
import React from 'react';
|
||||
import { ListBulletsIcon, MagnifyingGlassIcon, DatabaseIcon } from '@phosphor-icons/react';
|
||||
import { CustomInput, Skeleton } 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-2 flex flex-col gap-2">
|
||||
{Array.from({ length: 12 }).map((_, i) => (
|
||||
<Skeleton key={i} width="100%" height={38} className="rounded-[10px]" />
|
||||
))}
|
||||
</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,155 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
CustomModal,
|
||||
CustomInput,
|
||||
CustomSwitch,
|
||||
CustomButton,
|
||||
CustomAlertBanner,
|
||||
} 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;
|
||||
setErrorMsg?: (err: string | null) => void;
|
||||
}
|
||||
|
||||
export const MasterItemFormModal: React.FC<MasterItemFormModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
selectedCategory,
|
||||
editingItem,
|
||||
formData,
|
||||
setFormData,
|
||||
onSubmit,
|
||||
errorMsg,
|
||||
setErrorMsg,
|
||||
}) => {
|
||||
React.useEffect(() => {
|
||||
if (!errorMsg || !setErrorMsg) return;
|
||||
if (errorMsg === 'Value / Name is required.' && formData.value.trim()) {
|
||||
setErrorMsg(null);
|
||||
}
|
||||
}, [formData.value, errorMsg, setErrorMsg]);
|
||||
|
||||
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 && (
|
||||
<CustomAlertBanner
|
||||
message={errorMsg}
|
||||
type="error"
|
||||
onClose={() => setErrorMsg?.(null)}
|
||||
autoClose={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
<form onSubmit={onSubmit} noValidate 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, '_'),
|
||||
}));
|
||||
if (setErrorMsg && val.trim()) {
|
||||
setErrorMsg(null);
|
||||
}
|
||||
}}
|
||||
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) => {
|
||||
const codeVal = e.target.value;
|
||||
setFormData((prev) => ({ ...prev, code: codeVal }));
|
||||
if (setErrorMsg && codeVal.trim()) {
|
||||
setErrorMsg(null);
|
||||
}
|
||||
}}
|
||||
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,235 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
PlusIcon,
|
||||
MagnifyingGlassIcon,
|
||||
PencilSimpleIcon,
|
||||
TrashIcon,
|
||||
CaretLeftIcon,
|
||||
CaretRightIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
import {
|
||||
CustomButton,
|
||||
CustomInput,
|
||||
Skeleton,
|
||||
CustomStatus,
|
||||
} from '../../../../components/custom';
|
||||
import type { MasterDataCategoryItem, MasterDataItem } from '../MasterDataTypes';
|
||||
import Can from '../../../../components/common/Can';
|
||||
|
||||
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>
|
||||
|
||||
<Can permission="config:create">
|
||||
<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>
|
||||
</Can>
|
||||
</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="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">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<tr key={i}>
|
||||
<td className="py-3 px-4"><Skeleton width={20} height={16} /></td>
|
||||
<td className="py-3 px-4"><Skeleton width={120} height={16} /></td>
|
||||
<td className="py-3 px-4"><Skeleton width={80} height={16} /></td>
|
||||
<td className="py-3 px-4"><Skeleton width={60} height={20} className="rounded-full" /></td>
|
||||
<td className="py-3 px-4 flex justify-end"><Skeleton width={40} height={24} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</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">
|
||||
<Can permission="config:edit">
|
||||
<button
|
||||
onClick={() => onOpenEdit(item)}
|
||||
title="Edit Item"
|
||||
className="p-1.5 rounded-lg text-slate-500 hover:text-[#1E7D5C] hover:bg-[#E8F3EF] transition-colors cursor-pointer"
|
||||
>
|
||||
<PencilSimpleIcon size={16} weight="bold" />
|
||||
</button>
|
||||
</Can>
|
||||
|
||||
<Can permission="config:delete">
|
||||
<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 cursor-pointer"
|
||||
>
|
||||
<TrashIcon size={16} weight="bold" />
|
||||
</button>
|
||||
</Can>
|
||||
</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,268 @@
|
||||
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}
|
||||
setErrorMsg={setErrorMsg}
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ApiClient } from '../api/ApiClient';
|
||||
import type {
|
||||
DashboardMetric,
|
||||
ExposureChartItem,
|
||||
DisruptionMixItem,
|
||||
RecentIncident,
|
||||
} from './DashboardTypes';
|
||||
|
||||
export function getDashboardMetrics(): Promise<DashboardMetric[]> {
|
||||
return ApiClient.get<any, DashboardMetric[]>('/dashboard/metrics');
|
||||
}
|
||||
|
||||
export function getExposureData(): Promise<ExposureChartItem[]> {
|
||||
return ApiClient.get<any, ExposureChartItem[]>('/dashboard/exposure');
|
||||
}
|
||||
|
||||
export function getDisruptionMixData(): Promise<DisruptionMixItem[]> {
|
||||
return ApiClient.get<any, DisruptionMixItem[]>('/dashboard/disruption-mix');
|
||||
}
|
||||
|
||||
export function getRecentIncidents(): Promise<RecentIncident[]> {
|
||||
return ApiClient.get<any, RecentIncident[]>('/dashboard/recent-incidents');
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
export interface DashboardMetric {
|
||||
id: string;
|
||||
title: string;
|
||||
value: string;
|
||||
trend: string;
|
||||
isPositive?: boolean;
|
||||
sparklineData: number[];
|
||||
}
|
||||
|
||||
export interface ExposureChartItem {
|
||||
category: string;
|
||||
amount: number;
|
||||
displayAmount: string;
|
||||
color: string;
|
||||
views?: string;
|
||||
date?: string;
|
||||
hasTooltip?: boolean;
|
||||
}
|
||||
|
||||
export interface DisruptionMixItem {
|
||||
label: string;
|
||||
percentage: number;
|
||||
value: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface RecentIncident {
|
||||
id: string;
|
||||
recoveryId: string;
|
||||
passengerName: string;
|
||||
pnr: string;
|
||||
flightNumber: string;
|
||||
category: string;
|
||||
status: string;
|
||||
value: string;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import React, { useState } from "react";
|
||||
import type { DisruptionMixItem } from "../DashboardTypes";
|
||||
|
||||
interface DisruptionMixChartProps {
|
||||
data: DisruptionMixItem[];
|
||||
}
|
||||
|
||||
export const DisruptionMixChart: React.FC<DisruptionMixChartProps> = ({ data }) => {
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
|
||||
// Calculate SVG donut stroke offsets
|
||||
const size = 180;
|
||||
const strokeWidth = 24;
|
||||
const radius = (size - strokeWidth) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
|
||||
let accumulatedPercent = 0;
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-[16px] border border-gray-100 p-6 shadow-[0_2px_4px_rgba(0,0,0,0.02)] flex flex-col justify-between h-full">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h2 className="text-[17px] font-bold text-gray-900 leading-snug">
|
||||
Disruption Mix
|
||||
</h2>
|
||||
<p className="text-[12px] text-gray-400 font-normal mt-0.5">
|
||||
Distribution of passenger recovery cases.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Donut Chart Visual */}
|
||||
<div className="flex flex-col items-center justify-center my-6 relative">
|
||||
<div className="relative w-[180px] h-[180px] flex items-center justify-center">
|
||||
<svg width={size} height={size} className="transform -rotate-90">
|
||||
{data.map((item, index) => {
|
||||
const strokeDasharray = `${(item.percentage / 100) * circumference} ${circumference}`;
|
||||
const strokeDashoffset = -((accumulatedPercent / 100) * circumference);
|
||||
accumulatedPercent += item.percentage;
|
||||
|
||||
const isHovered = hoveredIndex === index;
|
||||
|
||||
return (
|
||||
<circle
|
||||
key={item.label}
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="transparent"
|
||||
stroke={item.color}
|
||||
strokeWidth={isHovered ? strokeWidth + 4 : strokeWidth}
|
||||
strokeDasharray={strokeDasharray}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
className="transition-all duration-300 cursor-pointer"
|
||||
onMouseEnter={() => setHoveredIndex(index)}
|
||||
onMouseLeave={() => setHoveredIndex(null)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{/* Donut Center Info */}
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
|
||||
<span className="text-[22px] font-bold text-gray-900 leading-none">
|
||||
{hoveredIndex !== null ? `${data[hoveredIndex].percentage}%` : "100%"}
|
||||
</span>
|
||||
<span className="text-[11px] font-medium text-gray-400 mt-1">
|
||||
{hoveredIndex !== null ? data[hoveredIndex].label : "Total Cases"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend Grid */}
|
||||
<div className="grid grid-cols-2 gap-3 pt-2">
|
||||
{data.map((item, index) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className={`flex items-center justify-between p-2 rounded-lg transition-colors cursor-pointer ${
|
||||
hoveredIndex === index ? "bg-gray-50" : ""
|
||||
}`}
|
||||
onMouseEnter={() => setHoveredIndex(index)}
|
||||
onMouseLeave={() => setHoveredIndex(null)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
<span className="text-[12px] font-medium text-gray-700">
|
||||
{item.label}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[12px] font-bold text-gray-900">
|
||||
{item.percentage}%
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DisruptionMixChart;
|
||||
@@ -0,0 +1,107 @@
|
||||
import React, { useState } from "react";
|
||||
import type { ExposureChartItem } from "../DashboardTypes";
|
||||
|
||||
interface ExposureBarChartProps {
|
||||
data: ExposureChartItem[];
|
||||
}
|
||||
|
||||
export const ExposureBarChart: React.FC<ExposureBarChartProps> = ({ data }) => {
|
||||
const [activeBar, setActiveBar] = useState<string | null>("Baggage");
|
||||
|
||||
const maxAmount = 60;
|
||||
const yAxisTicks = ["$50", "$50", "$50", "$30", "$10"];
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-[16px] border border-gray-100 p-6 shadow-[0_2px_4px_rgba(0,0,0,0.02)] flex flex-col justify-between h-full">
|
||||
{/* Chart Title Header */}
|
||||
<div className="mb-6">
|
||||
<h2 className="text-[17px] font-bold text-gray-900 leading-snug">
|
||||
Refund & Compensation Exposure
|
||||
</h2>
|
||||
<p className="text-[12px] text-gray-400 font-normal mt-0.5">
|
||||
Real-time assessment log of refund and compensation cases.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Bar Chart Body */}
|
||||
<div className="relative w-full h-[280px] flex flex-col justify-end pt-8 pb-2">
|
||||
{/* Horizontal Background Grid Lines */}
|
||||
<div className="absolute inset-0 flex flex-col justify-between pointer-events-none pb-8 pt-8 pl-12 pr-4">
|
||||
{yAxisTicks.map((_, idx) => (
|
||||
<div key={idx} className="w-full border-b border-gray-100/80 h-0" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Chart Content Area */}
|
||||
<div className="flex w-full h-full relative z-10">
|
||||
{/* Y-Axis Labels */}
|
||||
<div className="flex flex-col justify-between pr-4 pb-8 text-[11px] font-medium text-gray-400 text-right w-12 select-none">
|
||||
{yAxisTicks.map((tick, idx) => (
|
||||
<span key={idx} className="leading-none">
|
||||
{tick}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Bars Grid */}
|
||||
<div className="flex-1 grid grid-cols-4 items-end gap-6 px-4 pb-8 h-full">
|
||||
{data.map((item) => {
|
||||
const heightPercent = Math.min(100, Math.max(10, (item.amount / maxAmount) * 100));
|
||||
const isSelected = activeBar === item.category || (item.hasTooltip && !activeBar);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.category}
|
||||
className="relative flex flex-col items-center justify-end h-full group cursor-pointer"
|
||||
onMouseEnter={() => setActiveBar(item.category)}
|
||||
onMouseLeave={() => setActiveBar("Baggage")}
|
||||
>
|
||||
{/* Tooltip Bubble (Visible for selected bar e.g. Baggage) */}
|
||||
{isSelected && (item.hasTooltip || activeBar === item.category) && (
|
||||
<div className="absolute -top-14 z-20 flex flex-col items-center animate-fadeIn">
|
||||
<div className="bg-white border border-gray-100 shadow-md rounded-[8px] px-3 py-1.5 text-center whitespace-nowrap min-w-[120px]">
|
||||
<p className="text-[11px] font-bold text-gray-800 leading-tight">
|
||||
{item.views || `${item.amount} cases`}
|
||||
</p>
|
||||
<p className="text-[10px] text-gray-400 font-medium leading-tight mt-0.5">
|
||||
{item.date || "Monday, April 22nd"}
|
||||
</p>
|
||||
</div>
|
||||
{/* Tooltip Caret */}
|
||||
<div className="w-2.5 h-2.5 bg-white border-r border-b border-gray-100 transform rotate-45 -mt-1.5 shadow-xs" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bar Element */}
|
||||
<div
|
||||
className="w-full max-w-[110px] rounded-t-[4px] transition-all duration-300 relative group-hover:brightness-105"
|
||||
style={{
|
||||
height: `${heightPercent}%`,
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* X-Axis Category Labels */}
|
||||
<div className="grid grid-cols-4 pl-12 pr-4 text-center">
|
||||
{data.map((item) => (
|
||||
<span
|
||||
key={item.category}
|
||||
className={`text-[12px] font-medium transition-colors ${
|
||||
activeBar === item.category ? "text-gray-900 font-semibold" : "text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{item.category}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExposureBarChart;
|
||||
@@ -0,0 +1,85 @@
|
||||
import React from "react";
|
||||
import type { RecentIncident } from "../DashboardTypes";
|
||||
import { CustomTable, CustomStatus } from "../../../components/custom";
|
||||
import type { Column } from "../../../components/custom/CustomTable";
|
||||
|
||||
interface RecentIncidentsTableProps {
|
||||
incidents: RecentIncident[];
|
||||
}
|
||||
|
||||
export const RecentIncidentsTable: React.FC<RecentIncidentsTableProps> = ({
|
||||
incidents,
|
||||
}) => {
|
||||
const columns: Column<RecentIncident>[] = [
|
||||
{
|
||||
header: "Recovery ID",
|
||||
accessor: (row) => (
|
||||
<span className="text-[13px] font-bold text-gray-900 font-mono">
|
||||
{row.recoveryId}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Passenger / PNR",
|
||||
accessor: (row) => (
|
||||
<span className="text-[13px] font-bold text-gray-900">
|
||||
{row.passengerName}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Flight",
|
||||
accessor: (row) => (
|
||||
<span className="text-[13px] font-bold text-gray-900">
|
||||
{row.flightNumber}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Category",
|
||||
accessor: (row) => (
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-[11px] font-semibold bg-[#EFEFEF] text-gray-600">
|
||||
{row.category}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Status",
|
||||
accessor: (row) => <CustomStatus status={row.status} />,
|
||||
},
|
||||
{
|
||||
header: "Value",
|
||||
accessor: (row) => (
|
||||
<span className="text-[13px] font-bold text-gray-900">{row.value}</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-[16px] border border-gray-100 p-6 shadow-[0_2px_4px_rgba(0,0,0,0.02)] flex flex-col gap-5">
|
||||
{/* Table Header Section */}
|
||||
<div>
|
||||
<h2 className="text-[17px] font-bold text-gray-900 leading-snug">
|
||||
Recent Recovery Incidents
|
||||
</h2>
|
||||
<p className="text-[12px] text-gray-400 font-normal mt-0.5">
|
||||
Real-time assessment log of refund and compensation cases.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Custom Table Component */}
|
||||
<CustomTable<RecentIncident>
|
||||
columns={columns}
|
||||
data={incidents}
|
||||
itemName="incidents"
|
||||
totalItems={incidents.length}
|
||||
startIndex={incidents.length > 0 ? 1 : 0}
|
||||
endIndex={incidents.length}
|
||||
totalPages={1}
|
||||
currentPage={1}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecentIncidentsTable;
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from "react";
|
||||
import type { DashboardMetric } from "../DashboardTypes";
|
||||
|
||||
export const StatCard: React.FC<{ metric: DashboardMetric }> = ({ metric }) => {
|
||||
return (
|
||||
<div className="bg-white rounded-[14px] p-4 px-5 border border-gray-100/80 shadow-[0_2px_4px_rgba(0,0,0,0.02)] flex flex-col justify-between flex-1 min-w-[170px] hover:shadow-md transition-all duration-200">
|
||||
<div>
|
||||
<span className="text-[13px] font-medium text-gray-600 block">
|
||||
{metric.title}
|
||||
</span>
|
||||
<span className="text-[26px] font-bold text-gray-900 leading-tight tracking-tight mt-1.5 block">
|
||||
{metric.value}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mt-3 pt-1">
|
||||
<span className="text-[12px] font-bold text-[#1B9869] bg-[#E4FAE7]/60 px-2 py-0.5 rounded-full inline-flex items-center">
|
||||
{metric.trend}
|
||||
</span>
|
||||
|
||||
{/* Sparkline curve */}
|
||||
<div className="w-[60px] h-[26px] flex items-center justify-end">
|
||||
<svg
|
||||
width="60"
|
||||
height="26"
|
||||
viewBox="0 0 60 26"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="overflow-visible"
|
||||
>
|
||||
<path
|
||||
d="M 2 20 Q 12 22, 20 15 T 40 18 T 58 6"
|
||||
stroke="#1B9869"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatCard;
|
||||
@@ -1,10 +1,4 @@
|
||||
function HomePage() {
|
||||
return (
|
||||
<section className="page home-page">
|
||||
<h1>Home Page</h1>
|
||||
<p>Welcome to the home page. Use the navigation links to switch pages.</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default HomePage
|
||||
export { StatCard } from "./StatCard";
|
||||
export { ExposureBarChart } from "./ExposureBarChart";
|
||||
export { DisruptionMixChart } from "./DisruptionMixChart";
|
||||
export { RecentIncidentsTable } from "./RecentIncidentsTable";
|
||||
|
||||
+142
-372
@@ -1,383 +1,153 @@
|
||||
import { useState } from "react";
|
||||
import CustomButton from "../../components/custom/CustomButton";
|
||||
import CustomSearchableDropdown from "../../components/custom/CustomSearchableDropdown";
|
||||
import CustomDropdown from "../../components/custom/CustomDropdown";
|
||||
import CustomMultiSelect from "../../components/custom/CustomMultiSelect";
|
||||
import CustomModal from "../../components/custom/CustomModal";
|
||||
import CustomInput from "../../components/custom/CustomInput";
|
||||
import CustomTextArea from "../../components/custom/CustomTextArea";
|
||||
import CustomStatus from "../../components/custom/CustomStatus";
|
||||
import CustomCheckBox from "../../components/custom/CustomCheckBox";
|
||||
import CustomRadio from "../../components/custom/CustomRadio";
|
||||
import CustomSwitch from "../../components/custom/CustomSwitch";
|
||||
import CustomTable, { type Column } from "../../components/custom/CustomTable";
|
||||
import CustomActionMenu, { CustomActionItem } from "../../components/custom/CustomActionMenu";
|
||||
import { FileText, ChevronRight, Plus, Check, X, Copy, Edit2, Trash2 } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Skeleton } from "../../components/custom";
|
||||
import {
|
||||
StatCard,
|
||||
ExposureBarChart,
|
||||
DisruptionMixChart,
|
||||
RecentIncidentsTable,
|
||||
} from "./components";
|
||||
import type {
|
||||
DashboardMetric,
|
||||
ExposureChartItem,
|
||||
DisruptionMixItem,
|
||||
RecentIncident,
|
||||
} from "./DashboardTypes";
|
||||
import {
|
||||
getDashboardMetrics,
|
||||
getExposureData,
|
||||
getDisruptionMixData,
|
||||
getRecentIncidents,
|
||||
} from "./DashboardApi";
|
||||
|
||||
function HomePage() {
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [dropdownValue, setDropdownValue] = useState("active");
|
||||
const [multiSelectValue, setMultiSelectValue] = useState<(string | number)[]>(["active"]);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isChecked, setIsChecked] = useState(true);
|
||||
const [radioValue, setRadioValue] = useState("option1");
|
||||
const [isSwitchOn, setIsSwitchOn] = useState(true);
|
||||
export default function HomePage() {
|
||||
const [metrics, setMetrics] = useState<DashboardMetric[]>([]);
|
||||
const [exposureData, setExposureData] = useState<ExposureChartItem[]>([]);
|
||||
const [disruptionMix, setDisruptionMix] = useState<DisruptionMixItem[]>([]);
|
||||
const [recentIncidents, setRecentIncidents] = useState<RecentIncident[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
|
||||
const tableData = [
|
||||
{
|
||||
name: "Strategic Accounts",
|
||||
description: "Key corporate account travelers.",
|
||||
status: "active",
|
||||
lastModified: "4 Jun 2026, 4:09pm",
|
||||
lastModifiedBy: "John Doe",
|
||||
},
|
||||
{
|
||||
name: "Families with Infants",
|
||||
description: "Passengers traveling with children < 2yrs.",
|
||||
status: "active",
|
||||
lastModified: "4 Jun 2026, 4:09pm",
|
||||
lastModifiedBy: "John Doe",
|
||||
},
|
||||
{
|
||||
name: "Inactive Users",
|
||||
description: "Users who haven't booked in 12 months.",
|
||||
status: "inactive",
|
||||
lastModified: "3 Jun 2026, 2:15pm",
|
||||
lastModifiedBy: "Jane Smith",
|
||||
},
|
||||
];
|
||||
useEffect(() => {
|
||||
async function loadDashboardData() {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [metricsRes, exposureRes, mixRes, incidentsRes] =
|
||||
await Promise.all([
|
||||
getDashboardMetrics(),
|
||||
getExposureData(),
|
||||
getDisruptionMixData(),
|
||||
getRecentIncidents(),
|
||||
]);
|
||||
|
||||
const tableColumns: Column<typeof tableData[0]>[] = [
|
||||
{
|
||||
header: "Cohort Name",
|
||||
accessor: (row) => <span className="font-bold text-gray-900">{row.name}</span>,
|
||||
},
|
||||
{
|
||||
header: "Description",
|
||||
accessor: "description",
|
||||
},
|
||||
{
|
||||
header: "Status",
|
||||
accessor: (row) => <CustomStatus status={row.status} />,
|
||||
},
|
||||
{
|
||||
header: "Last Modified",
|
||||
accessor: "lastModified",
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
header: "Last Modified By",
|
||||
accessor: "lastModifiedBy",
|
||||
filterable: true,
|
||||
},
|
||||
{
|
||||
header: "Action",
|
||||
accessor: () => (
|
||||
<div className="flex justify-center w-full">
|
||||
<CustomActionMenu>
|
||||
<CustomActionItem variant="success" icon={<Check size={16} />}>Activate</CustomActionItem>
|
||||
<CustomActionItem icon={<X size={16} />}>Deactivate</CustomActionItem>
|
||||
<CustomActionItem icon={<Copy size={16} />}>Duplicate</CustomActionItem>
|
||||
<CustomActionItem icon={<Edit2 size={16} />}>Edit</CustomActionItem>
|
||||
<CustomActionItem variant="danger" icon={<Trash2 size={16} />}>Delete</CustomActionItem>
|
||||
</CustomActionMenu>
|
||||
setMetrics(metricsRes);
|
||||
setExposureData(exposureRes);
|
||||
setDisruptionMix(mixRes);
|
||||
setRecentIncidents(incidentsRes);
|
||||
} catch (error) {
|
||||
console.error("Error loading dashboard data:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadDashboardData();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-2 md:p-2 lg:p-4 space-y-4">
|
||||
{/* Top Stat Cards Skeleton */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="bg-white rounded-[14px] py-4 px-5 border border-gray-100/80 shadow-[0_2px_4px_rgba(0,0,0,0.02)] flex flex-col justify-between h-[102px]"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Skeleton width="60%" height={16} />
|
||||
<Skeleton width="40%" height={28} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<Skeleton width={45} height={18} />
|
||||
<Skeleton width={50} height={20} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
className: "text-center w-24",
|
||||
},
|
||||
];
|
||||
|
||||
const options = [
|
||||
{ label: "Draft", value: "draft" },
|
||||
{ label: "Active", value: "active" },
|
||||
{ label: "Inactive", value: "inactive" },
|
||||
];
|
||||
{/* Middle Section Skeleton */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 items-stretch">
|
||||
<div className="lg:col-span-8 bg-white rounded-[16px] border border-gray-100 p-6 shadow-[0_2px_4px_rgba(0,0,0,0.02)] flex flex-col justify-between h-[360px]">
|
||||
<div className="space-y-2">
|
||||
<Skeleton width="40%" height={22} />
|
||||
<Skeleton width="60%" height={14} />
|
||||
</div>
|
||||
<div className="flex items-end gap-6 h-[240px] pt-8">
|
||||
<Skeleton className="flex-1" height="60%" />
|
||||
<Skeleton className="flex-1" height="90%" />
|
||||
<Skeleton className="flex-1" height="40%" />
|
||||
<Skeleton className="flex-1" height="85%" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-4 bg-white rounded-[16px] border border-gray-100 p-6 shadow-[0_2px_4px_rgba(0,0,0,0.02)] flex flex-col justify-between h-[360px]">
|
||||
<div className="space-y-2">
|
||||
<Skeleton width="50%" height={22} />
|
||||
<Skeleton width="70%" height={14} />
|
||||
</div>
|
||||
<div className="flex items-center justify-center my-4">
|
||||
<Skeleton variant="circular" width={160} height={160} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} height={24} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Table Skeleton */}
|
||||
<div className="bg-white rounded-[16px] border border-gray-100 p-6 shadow-[0_2px_4px_rgba(0,0,0,0.02)] flex flex-col gap-5">
|
||||
<div className="space-y-2">
|
||||
<Skeleton width="30%" height={22} />
|
||||
<Skeleton width="50%" height={14} />
|
||||
</div>
|
||||
<div className="space-y-3 pt-2">
|
||||
<Skeleton height={40} />
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} height={48} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-5xl mx-auto flex flex-col gap-8">
|
||||
<div className="p-2 md:p-2 lg:p-4 space-y-4">
|
||||
{/* 1. Top Stat Cards Row */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||
{metrics.map((metric) => (
|
||||
<StatCard key={metric.id} metric={metric} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 2. Middle Section: Refund Exposure + Disruption Mix */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 items-stretch">
|
||||
{/* Refund & Compensation Exposure (approx 7 cols) */}
|
||||
<div className="lg:col-span-8 flex flex-col">
|
||||
<ExposureBarChart data={exposureData} />
|
||||
</div>
|
||||
|
||||
{/* Disruption Mix (approx 4 cols) */}
|
||||
<div className="lg:col-span-4 flex flex-col">
|
||||
<DisruptionMixChart data={disruptionMix} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3. Bottom Section: Recent Recovery Incidents */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-2 text-gray-900">Dashboard (UI Test Page)</h1>
|
||||
<p className="text-gray-500">Test all button variants and the custom components here.</p>
|
||||
<RecentIncidentsTable incidents={recentIncidents} />
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-8 rounded-[20px] border border-gray-100 shadow-sm flex flex-col gap-6">
|
||||
<h2 className="text-[15px] font-bold text-gray-900 border-b border-gray-100 pb-3">Table Component</h2>
|
||||
|
||||
<CustomTable
|
||||
columns={tableColumns}
|
||||
data={tableData}
|
||||
searchValue={searchValue}
|
||||
onSearchChange={setSearchValue}
|
||||
searchPlaceholder="Search cohorts..."
|
||||
currentPage={currentPage}
|
||||
totalPages={3}
|
||||
totalItems={12}
|
||||
startIndex={1}
|
||||
endIndex={3}
|
||||
onPageChange={setCurrentPage}
|
||||
itemName="cohorts"
|
||||
rightHeaderActions={
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-40">
|
||||
<CustomDropdown
|
||||
options={options}
|
||||
value="active"
|
||||
placeholder="Choose Status"
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
<CustomButton variant="primary" size="md" leftIcon={<Plus size={16} />}>
|
||||
Create Cohort
|
||||
</CustomButton>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-8 rounded-[20px] border border-gray-100 shadow-sm flex flex-col gap-6">
|
||||
<h2 className="text-[15px] font-bold text-gray-900 border-b border-gray-100 pb-3">Modal Components</h2>
|
||||
|
||||
<div className="flex flex-wrap gap-4 items-center">
|
||||
<CustomButton variant="primary" onClick={() => setIsModalOpen(true)}>
|
||||
Open Test Modal
|
||||
</CustomButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-8 rounded-[20px] border border-gray-100 shadow-sm flex flex-col gap-6">
|
||||
<h2 className="text-[15px] font-bold text-gray-900 border-b border-gray-100 pb-3">Status Pills & Toggles</h2>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-wrap gap-4 items-center">
|
||||
<span className="text-sm font-semibold w-24">Status Pills:</span>
|
||||
<CustomStatus status="active" />
|
||||
<CustomStatus status="inactive" />
|
||||
<CustomStatus status="pending" />
|
||||
<CustomStatus status="shipped" />
|
||||
<CustomStatus status="neutral" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-4 items-center">
|
||||
<span className="text-sm font-semibold w-24">Checkboxes:</span>
|
||||
<CustomCheckBox checked={isChecked} onChange={() => setIsChecked(!isChecked)} label="Checked State" />
|
||||
<CustomCheckBox checked={false} onChange={() => {}} label="Unchecked State" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-4 items-center">
|
||||
<span className="text-sm font-semibold w-24">Radios:</span>
|
||||
<CustomRadio checked={radioValue === "option1"} onChange={() => setRadioValue("option1")} label="Option 1" />
|
||||
<CustomRadio checked={radioValue === "option2"} onChange={() => setRadioValue("option2")} label="Option 2" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-4 items-center">
|
||||
<span className="text-sm font-semibold w-24">Switches:</span>
|
||||
<CustomSwitch checked={isSwitchOn} onChange={() => setIsSwitchOn(!isSwitchOn)} label="Toggle Feature" />
|
||||
<CustomSwitch checked={false} onChange={() => {}} label="Off Toggle" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-8 rounded-[20px] border border-gray-100 shadow-sm flex flex-col gap-6">
|
||||
<h2 className="text-[15px] font-bold text-gray-900 border-b border-gray-100 pb-3">Button Variants</h2>
|
||||
|
||||
<div className="flex flex-wrap gap-4 items-center">
|
||||
<CustomButton variant="primary">Primary</CustomButton>
|
||||
<CustomButton variant="secondary">Secondary</CustomButton>
|
||||
<CustomButton variant="outlined">Outlined</CustomButton>
|
||||
<CustomButton variant="text">Text Button</CustomButton>
|
||||
<CustomButton variant="link">Link Button</CustomButton>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-4 items-center mt-2">
|
||||
<CustomButton variant="primary" size="sm">Small</CustomButton>
|
||||
<CustomButton variant="primary" size="md">Medium</CustomButton>
|
||||
<CustomButton variant="primary" size="lg">Large</CustomButton>
|
||||
<CustomButton variant="primary" loading>Loading</CustomButton>
|
||||
<CustomButton variant="primary" disabled>Disabled</CustomButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-8 rounded-[20px] border border-gray-100 shadow-sm flex flex-col gap-6">
|
||||
<h2 className="text-[15px] font-bold text-gray-900 border-b border-gray-100 pb-3">Input & TextArea Variants</h2>
|
||||
|
||||
<div className="flex flex-wrap gap-6 items-end">
|
||||
<div className="w-48">
|
||||
<CustomInput
|
||||
label="Small (sm)"
|
||||
size="sm"
|
||||
placeholder="Enter text..."
|
||||
/>
|
||||
</div>
|
||||
<div className="w-56">
|
||||
<CustomInput
|
||||
label="Medium (md)"
|
||||
size="md"
|
||||
placeholder="Enter text..."
|
||||
/>
|
||||
</div>
|
||||
<div className="w-64">
|
||||
<CustomInput
|
||||
label="Large (lg)"
|
||||
size="lg"
|
||||
placeholder="Enter text..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-6 items-end mt-4">
|
||||
<div className="w-48">
|
||||
<CustomTextArea
|
||||
label="Small (sm)"
|
||||
size="sm"
|
||||
placeholder="Enter text..."
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-56">
|
||||
<CustomTextArea
|
||||
label="Medium (md)"
|
||||
size="md"
|
||||
placeholder="Enter text..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-64">
|
||||
<CustomTextArea
|
||||
label="Large (lg)"
|
||||
size="lg"
|
||||
placeholder="Enter text..."
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-8 rounded-[20px] border border-gray-100 shadow-sm flex flex-col gap-6">
|
||||
<h2 className="text-[15px] font-bold text-gray-900 border-b border-gray-100 pb-3">Standard Dropdown Variants</h2>
|
||||
|
||||
<div className="flex flex-wrap gap-6 items-end">
|
||||
<div className="w-48">
|
||||
<CustomDropdown
|
||||
label="Small (sm)"
|
||||
size="sm"
|
||||
options={options}
|
||||
value={dropdownValue}
|
||||
onChange={setDropdownValue}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-56">
|
||||
<CustomDropdown
|
||||
label="Medium (md)"
|
||||
size="md"
|
||||
options={options}
|
||||
value={dropdownValue}
|
||||
onChange={setDropdownValue}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-8 rounded-[20px] border border-gray-100 shadow-sm flex flex-col gap-6">
|
||||
<h2 className="text-[15px] font-bold text-gray-900 border-b border-gray-100 pb-3">Searchable Dropdown Variants</h2>
|
||||
|
||||
<div className="flex flex-wrap gap-6 items-end">
|
||||
<div className="w-48">
|
||||
<CustomSearchableDropdown
|
||||
label="Small (sm)"
|
||||
size="sm"
|
||||
options={options}
|
||||
value={dropdownValue}
|
||||
onChange={setDropdownValue}
|
||||
placeholder="Small size..."
|
||||
/>
|
||||
</div>
|
||||
<div className="w-56">
|
||||
<CustomSearchableDropdown
|
||||
label="Medium (md)"
|
||||
size="md"
|
||||
options={options}
|
||||
value={dropdownValue}
|
||||
onChange={setDropdownValue}
|
||||
placeholder="Medium size..."
|
||||
/>
|
||||
</div>
|
||||
<div className="w-64">
|
||||
<CustomSearchableDropdown
|
||||
label="Large (lg)"
|
||||
size="lg"
|
||||
options={options}
|
||||
value={dropdownValue}
|
||||
onChange={setDropdownValue}
|
||||
placeholder="Large size..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-8 rounded-[20px] border border-gray-100 shadow-sm flex flex-col gap-6 mb-32">
|
||||
<h2 className="text-[15px] font-bold text-gray-900 border-b border-gray-100 pb-3">Multi-Select Dropdown</h2>
|
||||
|
||||
<div className="w-[400px]">
|
||||
<CustomMultiSelect
|
||||
label="Select Multiple Statuses"
|
||||
options={options}
|
||||
value={multiSelectValue}
|
||||
onChange={setMultiSelectValue}
|
||||
placeholder="Select statuses..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CustomModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
title="New Dynamic Cohorts"
|
||||
description="Configure targeting criteria for high-precision recovery."
|
||||
icon={<FileText size={24} />}
|
||||
primaryAction={{
|
||||
label: "Next Step",
|
||||
onClick: () => setIsModalOpen(false),
|
||||
icon: <ChevronRight size={18} />
|
||||
}}
|
||||
secondaryAction={{
|
||||
label: "Cancel",
|
||||
onClick: () => setIsModalOpen(false)
|
||||
}}
|
||||
size="md"
|
||||
>
|
||||
<div className="flex flex-col gap-6 h-64">
|
||||
<div className="flex justify-between border-b border-gray-100 pb-2">
|
||||
<div>
|
||||
<span className="text-[10px] font-bold text-[#1B9869] tracking-wider uppercase">1 - Information</span>
|
||||
<h3 className="text-sm font-bold text-[#1B9869]">Cohort Identity</h3>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-[10px] font-bold text-gray-400 tracking-wider uppercase">2 - Targeting</span>
|
||||
<h3 className="text-sm font-bold text-gray-400">Targeting Criteria</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="w-full">
|
||||
<label className="text-xs font-bold text-gray-700 mb-1 block">Cohort Name</label>
|
||||
<input type="text" placeholder="Enter" className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-[#1B9869]" />
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<CustomDropdown
|
||||
label="Status"
|
||||
options={[{label: "Selected Option", value: "selected"}]}
|
||||
value="selected"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<label className="text-xs font-bold text-gray-700 mb-1 block">Description</label>
|
||||
<textarea placeholder="Enter" className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm h-24 focus:outline-none focus:border-[#1B9869]"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</CustomModal>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default HomePage
|
||||
|
||||
@@ -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,343 @@
|
||||
import { ApiClient } from '../api/ApiClient';
|
||||
import type { PolicyEngineResponse, PaginatedPolicyEngineResponse } from './PolicyEngineTypes';
|
||||
|
||||
export interface OptionItem {
|
||||
label: string;
|
||||
value: string;
|
||||
id?: string;
|
||||
code?: string;
|
||||
groupName?: string;
|
||||
groupHeader?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
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;
|
||||
validationJson?: any;
|
||||
visibilityConditionJson?: {
|
||||
field: string;
|
||||
operator: 'equals' | 'not_equals' | 'contains';
|
||||
value: any;
|
||||
};
|
||||
}
|
||||
|
||||
// ─── 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).sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0)) : []))
|
||||
.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,
|
||||
})
|
||||
: '—';
|
||||
|
||||
let jName = 'GLOBAL';
|
||||
if (Array.isArray(p.jurisdictions) && p.jurisdictions.length > 0) {
|
||||
const names = p.jurisdictions
|
||||
.map((j: any) => j.jurisdiction?.label || j.jurisdiction?.name || j.jurisdiction?.code || j.jurisdictionId)
|
||||
.filter(Boolean);
|
||||
jName = names.length > 0 ? names.join(', ') : 'GLOBAL';
|
||||
} else if (typeof p.jurisdiction === 'object' && p.jurisdiction !== null) {
|
||||
jName = p.jurisdiction.label || p.jurisdiction.name || p.jurisdiction.code || 'GLOBAL';
|
||||
} else if (p.jurisdiction) {
|
||||
jName = p.jurisdiction;
|
||||
}
|
||||
|
||||
return {
|
||||
...p,
|
||||
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).sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0)) : []))
|
||||
.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,6 +1,13 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Plus, Trash2, Search, Check, X, Pencil, Copy } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
PlusIcon,
|
||||
TrashIcon,
|
||||
MagnifyingGlassIcon,
|
||||
XIcon,
|
||||
PencilSimpleIcon,
|
||||
ChecksIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import {
|
||||
CustomTable,
|
||||
CustomInput,
|
||||
@@ -9,61 +16,36 @@ import {
|
||||
CustomActionMenu,
|
||||
CustomActionItem,
|
||||
CustomConfirmationModal,
|
||||
CustomAlertBanner,
|
||||
Skeleton,
|
||||
} from '../../../components/custom';
|
||||
import type { Column } from '../../../components/custom/CustomTable';
|
||||
import type { PolicyEngineResponse } from '../PolicyEngineTypes';
|
||||
} from "../../../components/custom";
|
||||
import type { Column } from "../../../components/custom/CustomTable";
|
||||
import type { PolicyEngineResponse } from "../PolicyEngineTypes";
|
||||
import Can from "../../../components/common/Can";
|
||||
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 }) {
|
||||
return (
|
||||
<span className="text-[14px] font-semibold text-[#6C766D] tracking-[0px]">{text}</span>
|
||||
<span className="text-[14px] font-semibold text-[#6C766D] tracking-[0px]">
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function CellText({ text }: { text: string | null | undefined }) {
|
||||
return (
|
||||
<span style={{ fontSize: '14px', color: '#676767', fontWeight: 500 }}>
|
||||
{text || '—'}
|
||||
<span style={{ fontSize: "14px", color: "#676767", fontWeight: 500 }}>
|
||||
{text || "—"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -82,6 +64,16 @@ export default function PolicyEngineList() {
|
||||
const navigate = useNavigate();
|
||||
const [policies, setPolicies] = useState<PolicyEngineResponse[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
if (location.state?.successMsg) {
|
||||
setSuccessMsg(location.state.successMsg);
|
||||
window.history.replaceState({}, document.title);
|
||||
}
|
||||
}, [location]);
|
||||
|
||||
// Pagination
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
@@ -89,39 +81,39 @@ export default function PolicyEngineList() {
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
|
||||
// Filters
|
||||
const [search, setSearch] = useState('');
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
// Modal state
|
||||
const [deleteTarget, setDeleteTarget] = useState<PolicyEngineResponse | null>(null);
|
||||
const [deactivateTarget, setDeactivateTarget] = useState<PolicyEngineResponse | null>(null);
|
||||
|
||||
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);
|
||||
}, [search]);
|
||||
getPolicies(page, PAGE_SIZE)
|
||||
.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);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Trigger a new fetch when page or search changes
|
||||
fetchPolicies(currentPage);
|
||||
}, [currentPage, search, fetchPolicies]);
|
||||
}, [currentPage, fetchPolicies]);
|
||||
|
||||
// ─── Handlers ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -136,20 +128,48 @@ export default function PolicyEngineList() {
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
// Simulate delete
|
||||
console.log('Deleted policy:', deleteTarget.id);
|
||||
setDeleteTarget(null);
|
||||
fetchPolicies(currentPage);
|
||||
setError(null);
|
||||
setSuccessMsg(null);
|
||||
try {
|
||||
await deletePolicy(deleteTarget.id);
|
||||
setSuccessMsg(
|
||||
`Policy "${deleteTarget.policyName}" deleted successfully.`,
|
||||
);
|
||||
setDeleteTarget(null);
|
||||
fetchPolicies(currentPage);
|
||||
} catch (err) {
|
||||
console.error("Failed to delete policy:", err);
|
||||
setError("Failed to delete policy. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleStatus = async () => {
|
||||
if (!deactivateTarget) return;
|
||||
// Simulate toggle status
|
||||
console.log('Toggled status for policy:', deactivateTarget.id);
|
||||
setDeactivateTarget(null);
|
||||
fetchPolicies(currentPage);
|
||||
setError(null);
|
||||
setSuccessMsg(null);
|
||||
try {
|
||||
const nextStatus =
|
||||
deactivateTarget.status === "Active" ? "inactive" : "active";
|
||||
await updatePolicyStatus(deactivateTarget.id, nextStatus);
|
||||
setSuccessMsg(
|
||||
`Policy "${deactivateTarget.policyName}" is now ${nextStatus === "active" ? "Active" : "Inactive"}.`,
|
||||
);
|
||||
setDeactivateTarget(null);
|
||||
fetchPolicies(currentPage);
|
||||
} catch (err) {
|
||||
console.error("Failed to toggle policy status:", err);
|
||||
setError("Failed to update policy status. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
const displayedPolicies = search
|
||||
? policies.filter(
|
||||
(p) =>
|
||||
p.policyName?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
p.jurisdiction?.toLowerCase().includes(search.toLowerCase()),
|
||||
)
|
||||
: policies;
|
||||
|
||||
const startIndex = totalItems > 0 ? (currentPage - 1) * PAGE_SIZE + 1 : 0;
|
||||
const endIndex = Math.min(currentPage * PAGE_SIZE, totalItems);
|
||||
|
||||
@@ -158,7 +178,7 @@ export default function PolicyEngineList() {
|
||||
const columns: Column<PolicyEngineResponse>[] = [
|
||||
{
|
||||
header: <HeaderLabel text="Policy Name" />,
|
||||
accessor: row => (
|
||||
accessor: (row) => (
|
||||
<span className="text-[13px] font-semibold text-[#0F172B] leading-[18px] tracking-[0px]">
|
||||
{row.policyName}
|
||||
</span>
|
||||
@@ -166,57 +186,59 @@ export default function PolicyEngineList() {
|
||||
},
|
||||
{
|
||||
header: <HeaderLabel text="Jurisdiction" />,
|
||||
accessor: row => <BadgeLabel text={row.jurisdiction} />,
|
||||
accessor: (row) => <BadgeLabel text={row.jurisdiction} />,
|
||||
},
|
||||
{
|
||||
header: <HeaderLabel text="Status" />,
|
||||
accessor: row => <CustomStatus status={row.status} />,
|
||||
accessor: (row) => <CustomStatus status={row.status} />,
|
||||
},
|
||||
{
|
||||
header: <HeaderLabel text="Last Modified" />,
|
||||
accessor: row => <CellText text={row.lastModified} />,
|
||||
accessor: (row) => <CellText text={row.lastModified} />,
|
||||
},
|
||||
{
|
||||
header: <HeaderLabel text="Action" />,
|
||||
className: 'text-right',
|
||||
accessor: row => (
|
||||
className: "text-right",
|
||||
accessor: (row) => (
|
||||
<CustomActionMenu>
|
||||
{row.status !== 'Active' && (
|
||||
<CustomActionItem
|
||||
icon={<Check size={15} />}
|
||||
variant="success"
|
||||
onClick={() => setDeactivateTarget(row)}
|
||||
>
|
||||
Activate
|
||||
</CustomActionItem>
|
||||
{row.status?.toLowerCase() === "inactive" && (
|
||||
<Can permission="policy_engine:publish">
|
||||
<CustomActionItem
|
||||
icon={<ChecksIcon size={15} />}
|
||||
variant="success"
|
||||
onClick={() => setDeactivateTarget(row)}
|
||||
>
|
||||
Activate
|
||||
</CustomActionItem>
|
||||
</Can>
|
||||
)}
|
||||
{row.status === 'Active' && (
|
||||
<CustomActionItem
|
||||
icon={<X size={15} />}
|
||||
onClick={() => setDeactivateTarget(row)}
|
||||
>
|
||||
Deactivate
|
||||
</CustomActionItem>
|
||||
{row.status?.toLowerCase() === "active" && (
|
||||
<Can permission="policy_engine:publish">
|
||||
<CustomActionItem
|
||||
icon={<XIcon size={15} />}
|
||||
onClick={() => setDeactivateTarget(row)}
|
||||
>
|
||||
Deactivate
|
||||
</CustomActionItem>
|
||||
</Can>
|
||||
)}
|
||||
<CustomActionItem
|
||||
icon={<Copy size={15} />}
|
||||
onClick={() => console.log('Duplicate:', row.id)}
|
||||
>
|
||||
Duplicate
|
||||
</CustomActionItem>
|
||||
<CustomActionItem
|
||||
icon={<Pencil size={15} />}
|
||||
onClick={() => console.log('Edit:', row.id)}
|
||||
>
|
||||
Edit
|
||||
</CustomActionItem>
|
||||
<CustomActionItem
|
||||
icon={<Trash2 size={15} />}
|
||||
variant="danger"
|
||||
onClick={() => setDeleteTarget(row)}
|
||||
>
|
||||
Delete
|
||||
</CustomActionItem>
|
||||
<Can permission="policy_engine:edit">
|
||||
<CustomActionItem
|
||||
icon={<PencilSimpleIcon size={15} />}
|
||||
onClick={() => navigate(`/policy-engine/add?id=${row.id}`)}
|
||||
>
|
||||
Edit
|
||||
</CustomActionItem>
|
||||
</Can>
|
||||
<Can permission="policy_engine:delete">
|
||||
<CustomActionItem
|
||||
icon={<TrashIcon size={15} />}
|
||||
variant="danger"
|
||||
onClick={() => setDeleteTarget(row)}
|
||||
>
|
||||
Delete
|
||||
</CustomActionItem>
|
||||
</Can>
|
||||
</CustomActionMenu>
|
||||
),
|
||||
},
|
||||
@@ -246,16 +268,30 @@ export default function PolicyEngineList() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && (
|
||||
<CustomAlertBanner
|
||||
message={error}
|
||||
type="error"
|
||||
onClose={() => setError(null)}
|
||||
/>
|
||||
)}
|
||||
{successMsg && (
|
||||
<CustomAlertBanner
|
||||
message={successMsg}
|
||||
type="success"
|
||||
onClose={() => setSuccessMsg(null)}
|
||||
/>
|
||||
)}
|
||||
<CustomTable<PolicyEngineResponse>
|
||||
columns={columns}
|
||||
data={policies}
|
||||
data={displayedPolicies}
|
||||
leftHeaderActions={
|
||||
<div className="w-[380px]">
|
||||
<CustomInput
|
||||
placeholder="Search framework registry..."
|
||||
value={search}
|
||||
onChange={e => handleSearchChange(e.target.value)}
|
||||
leftIcon={<Search size={16} />}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
leftIcon={<MagnifyingGlassIcon size={16} />}
|
||||
className="!bg-[#F3F6F5] !rounded-[10px] !h-[38px] !border !border-[#E5E7EB]"
|
||||
containerClassName="!gap-0"
|
||||
/>
|
||||
@@ -263,15 +299,17 @@ export default function PolicyEngineList() {
|
||||
}
|
||||
rightHeaderActions={
|
||||
<>
|
||||
<CustomButton
|
||||
variant="primary"
|
||||
size="md"
|
||||
leftIcon={<Plus size={16} />}
|
||||
className="!rounded-[10px] !gap-[10px] !h-[40px] !bg-[#1E7D5C] hover:!bg-[#17664B]"
|
||||
onClick={() => navigate('/policy-engine/add')}
|
||||
>
|
||||
Deploy New Policy
|
||||
</CustomButton>
|
||||
<Can permission="policy_engine:create">
|
||||
<CustomButton
|
||||
variant="primary"
|
||||
size="md"
|
||||
leftIcon={<PlusIcon size={16} />}
|
||||
className="!rounded-[10px] !gap-[10px] !h-[40px] !bg-[#1E7D5C] hover:!bg-[#17664B]"
|
||||
onClick={() => navigate("/policy-engine/add")}
|
||||
>
|
||||
Deploy New Policy
|
||||
</CustomButton>
|
||||
</Can>
|
||||
</>
|
||||
}
|
||||
currentPage={currentPage}
|
||||
@@ -300,13 +338,19 @@ export default function PolicyEngineList() {
|
||||
isOpen={!!deactivateTarget}
|
||||
onClose={() => setDeactivateTarget(null)}
|
||||
onConfirm={handleToggleStatus}
|
||||
title={deactivateTarget?.status === 'Active' ? 'Deactivate Policy' : 'Activate Policy'}
|
||||
title={
|
||||
deactivateTarget?.status === "Active"
|
||||
? "Deactivate Policy"
|
||||
: "Activate Policy"
|
||||
}
|
||||
description={
|
||||
deactivateTarget?.status === 'Active'
|
||||
deactivateTarget?.status === "Active"
|
||||
? `"${deactivateTarget?.policyName}" will be deactivated.`
|
||||
: `"${deactivateTarget?.policyName}" will be reactivated.`
|
||||
}
|
||||
confirmText={deactivateTarget?.status === 'Active' ? 'Deactivate' : 'Activate'}
|
||||
confirmText={
|
||||
deactivateTarget?.status === "Active" ? "Deactivate" : "Activate"
|
||||
}
|
||||
cancelText="Cancel"
|
||||
variant="warning"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { ApiClient } from '../api/ApiClient';
|
||||
import type { RecoveryIncident, MetricCardData } from './RecoveryIncidentsTypes';
|
||||
|
||||
export interface AuditTrailStepDto {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
timestamp: string;
|
||||
status: 'completed' | 'current' | 'pending' | 'rejected';
|
||||
}
|
||||
|
||||
export function getRecoveryIncidents(): Promise<RecoveryIncident[]> {
|
||||
return ApiClient.get<any, RecoveryIncident[]>('/recovery-incidents');
|
||||
}
|
||||
|
||||
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 getIncidentAuditTrail(id: string): Promise<AuditTrailStepDto[]> {
|
||||
return ApiClient.get<any, AuditTrailStepDto[]>(`/recovery-incidents/${id}/audit-trail`);
|
||||
}
|
||||
|
||||
export function createRecoveryIncident(data: Omit<RecoveryIncident, 'id' | 'createdAt' | 'updatedAt'>): 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 reRunPolicyEngine(id: string): Promise<RecoveryIncident> {
|
||||
return ApiClient.post<any, RecoveryIncident>(`/recovery-incidents/${id}/evaluate`, {});
|
||||
}
|
||||
|
||||
export function updateIncidentStatus(id: string, status: string): Promise<RecoveryIncident> {
|
||||
return ApiClient.patch<any, RecoveryIncident>(`/recovery-incidents/${id}/status`, { status });
|
||||
}
|
||||
|
||||
export function deleteRecoveryIncident(id: string): Promise<void> {
|
||||
return ApiClient.delete<any, void>(`/recovery-incidents/${id}`);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
export interface IncidentStatus {
|
||||
text: string;
|
||||
variant: "success" | "error" | "warning" | "info" | "neutral" | "brand";
|
||||
}
|
||||
|
||||
export interface IncidentEvaluationAction {
|
||||
id: string;
|
||||
evaluationId?: string;
|
||||
actionTypeCode?: string;
|
||||
title: string;
|
||||
category: string; // 'Financial Refund' | 'Compensation & Perks' | 'Passenger Care'
|
||||
amount?: number;
|
||||
currency?: string;
|
||||
status: string; // 'Pending Approval' | 'Automated' | 'Issued'
|
||||
sequence: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface IncidentEvaluation {
|
||||
updatedAt: unknown;
|
||||
createdAt: any;
|
||||
id: string;
|
||||
incidentId?: string;
|
||||
policyId?: string;
|
||||
policyName: string;
|
||||
recoveryScore: number;
|
||||
matchedCohortName?: string;
|
||||
status: string;
|
||||
aiAssessment?: string;
|
||||
actions: IncidentEvaluationAction[];
|
||||
}
|
||||
|
||||
export interface RecoveryIncident {
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
id: string;
|
||||
recoveryCode: string;
|
||||
date: string;
|
||||
passengerName?: string;
|
||||
pnr?: string;
|
||||
loyaltyTier?: string;
|
||||
passengerType?: string;
|
||||
nationality?: string;
|
||||
specialAssistance?: string;
|
||||
cabinClass?: string;
|
||||
originalCabin?: string;
|
||||
actualCabin?: string;
|
||||
flightNumber: string;
|
||||
flightRoute: string;
|
||||
origin?: string;
|
||||
destination?: string;
|
||||
category?: string;
|
||||
scenario?: string;
|
||||
jurisdiction?: string;
|
||||
delayDuration?: number;
|
||||
statuses?: IncidentStatus[];
|
||||
status?: string;
|
||||
value: string;
|
||||
isPerksClaimed?: boolean;
|
||||
recoverySource?: string;
|
||||
isGroupHeader?: boolean;
|
||||
evaluation?: IncidentEvaluation;
|
||||
}
|
||||
|
||||
export interface MetricCardData {
|
||||
id?: string;
|
||||
title: string;
|
||||
value?: string;
|
||||
trendText: string;
|
||||
trendValue: string;
|
||||
trendType: "positive" | "negative" | "neutral";
|
||||
sparklineColor: "green" | "red";
|
||||
}
|
||||
@@ -0,0 +1,616 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { FileText, User, Airplane, WarningCircle, CaretRight, Lightning } from '@phosphor-icons/react';
|
||||
import {
|
||||
CustomModal,
|
||||
CustomInput,
|
||||
CustomDropdown,
|
||||
CustomCheckBox,
|
||||
} from "../../../components/custom";
|
||||
import { createRecoveryIncident, updateRecoveryIncident, getRecoveryIncidents } from '../RecoveryIncidentsApi';
|
||||
import { getMembershipTiers, getCategoryValues } from '../../configuration/masterData/MasterDataApi';
|
||||
import {
|
||||
getMockFlightNumbers,
|
||||
searchDisruptionOrPassenger,
|
||||
type MockPassenger,
|
||||
} from '../disruptionMockService';
|
||||
import type { RecoveryIncident } from '../RecoveryIncidentsTypes';
|
||||
|
||||
interface AddRecoveryIncidentsProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess?: (createdCount: number, flightNumber: string, passengerName: string, isEdit?: boolean) => 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";
|
||||
|
||||
const DEFAULT_CATEGORY_OPTIONS = [
|
||||
{ label: "Flight Ops", value: "flight_ops" },
|
||||
{ label: "Travel Exp", value: "travel_exp" },
|
||||
{ label: "Weather", value: "weather" },
|
||||
{ label: "Technical Fault", value: "technical_fault" },
|
||||
];
|
||||
|
||||
const DEFAULT_SCENARIO_OPTIONS = [
|
||||
{ label: "Delayed Flight", value: "delayed_flight" },
|
||||
{ label: "Cancelled Flight", value: "cancelled_flight" },
|
||||
{ label: "Missed Connection", value: "missed_connection" },
|
||||
];
|
||||
|
||||
const DEFAULT_JURISDICTION_OPTIONS = [
|
||||
{ label: "EU261 (European Union)", value: "EU261" },
|
||||
{ label: "US DOT (United States)", value: "US_DOT" },
|
||||
{ label: "UK261 (United Kingdom)", value: "UK261" },
|
||||
{ label: "CAA SG (Singapore)", value: "CAA_SG" },
|
||||
];
|
||||
|
||||
export default function AddRecoveryIncidents({ isOpen, onClose, onSuccess, incident }: AddRecoveryIncidentsProps) {
|
||||
const [, setLoyaltyTierOptions] = useState<{ label: string; value: string }[]>([]);
|
||||
const [jurisdictionOptions, setJurisdictionOptions] = useState<{ label: string; value: string }[]>(DEFAULT_JURISDICTION_OPTIONS);
|
||||
const [scenarioOptions, setScenarioOptions] = useState<{ label: string; value: string }[]>(DEFAULT_SCENARIO_OPTIONS);
|
||||
|
||||
const [query, setQuery] = useState<string>('');
|
||||
const [passengersList, setPassengersList] = useState<MockPassenger[]>([]);
|
||||
const [selectedPassengerIds, setSelectedPassengerIds] = useState<string[]>([]);
|
||||
const [autoFilledNotice, setAutoFilledNotice] = useState<string | null>(null);
|
||||
const [perksClaimedMap, setPerksClaimedMap] = useState<Record<string, boolean>>({});
|
||||
const [existingIncidents, setExistingIncidents] = useState<RecoveryIncident[]>([]);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
passengerName: "",
|
||||
pnr: "",
|
||||
loyaltyTier: "",
|
||||
flightNumber: "",
|
||||
date: "",
|
||||
origin: "",
|
||||
destination: "",
|
||||
category: "",
|
||||
scenario: "",
|
||||
jurisdiction: "",
|
||||
delayDuration: "",
|
||||
isPerksClaimed: false,
|
||||
});
|
||||
|
||||
const flightNumberOptions = getMockFlightNumbers();
|
||||
|
||||
// Flexible option matcher to bridge master data codes/labels with mock response codes
|
||||
const matchOption = (val: string | undefined, options: { label: string; value: string }[]) => {
|
||||
if (!val) return '';
|
||||
const lowerVal = val.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
const found = options.find((opt) => {
|
||||
const lowerValOpt = opt.value.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
const lowerLabelOpt = opt.label.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
return lowerValOpt === lowerVal || lowerLabelOpt === lowerVal || lowerValOpt.includes(lowerVal) || lowerVal.includes(lowerValOpt);
|
||||
});
|
||||
return found ? found.value : val;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
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(() => { });
|
||||
|
||||
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(() => { });
|
||||
|
||||
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: (incident as any).scenario || "",
|
||||
jurisdiction: (incident as any).jurisdiction || "",
|
||||
delayDuration: (incident as any).delayDuration ? String((incident as any).delayDuration) : "",
|
||||
isPerksClaimed: incident.isPerksClaimed || false,
|
||||
});
|
||||
const initialQuery = incident.flightNumber || incident.pnr || "";
|
||||
setQuery(initialQuery);
|
||||
|
||||
const res = searchDisruptionOrPassenger(initialQuery);
|
||||
if (res.disruption) {
|
||||
const passengers = res.disruption.passengers || [];
|
||||
setPassengersList(passengers);
|
||||
|
||||
getRecoveryIncidents().then(all => {
|
||||
const flightIncidents = all.filter(i => i.flightNumber === res.disruption!.flightNumber);
|
||||
setExistingIncidents(flightIncidents);
|
||||
|
||||
const newPerks: Record<string, boolean> = {};
|
||||
const newSelected: string[] = [];
|
||||
|
||||
flightIncidents.forEach(fi => {
|
||||
const p = passengers.find(pass => pass.pnr === fi.pnr && pass.passengerName === fi.passengerName);
|
||||
if (p) {
|
||||
newSelected.push(p.id);
|
||||
if (fi.isPerksClaimed) newPerks[p.id] = true;
|
||||
}
|
||||
});
|
||||
|
||||
const p = passengers.find(pass => pass.pnr === incident.pnr && pass.passengerName === incident.passengerName);
|
||||
if (p) {
|
||||
if (!newSelected.includes(p.id)) newSelected.push(p.id);
|
||||
if (incident.isPerksClaimed) newPerks[p.id] = true;
|
||||
}
|
||||
|
||||
setSelectedPassengerIds(newSelected.length > 0 ? newSelected : passengers.map(p => p.id));
|
||||
setPerksClaimedMap(newPerks);
|
||||
}).catch(() => {
|
||||
setSelectedPassengerIds(passengers.map(p => p.id));
|
||||
});
|
||||
}
|
||||
setAutoFilledNotice(null);
|
||||
} else if (isOpen) {
|
||||
setFormData({
|
||||
passengerName: "",
|
||||
pnr: "",
|
||||
loyaltyTier: "",
|
||||
flightNumber: "",
|
||||
date: "",
|
||||
origin: "",
|
||||
destination: "",
|
||||
category: "",
|
||||
scenario: "",
|
||||
jurisdiction: "",
|
||||
delayDuration: "",
|
||||
isPerksClaimed: false,
|
||||
});
|
||||
setQuery('');
|
||||
setPassengersList([]);
|
||||
setSelectedPassengerIds([]);
|
||||
setAutoFilledNotice(null);
|
||||
setPerksClaimedMap({});
|
||||
setExistingIncidents([]);
|
||||
}
|
||||
}, [incident, isOpen]);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleInputChange = (field: string, value: string) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
|
||||
// Helper when user types or selects flight number or PNR
|
||||
const handleQueryChange = (val: string) => {
|
||||
setQuery(val);
|
||||
const res = searchDisruptionOrPassenger(val);
|
||||
if (res.disruption) {
|
||||
const d = res.disruption;
|
||||
const passengers = d.passengers || [];
|
||||
setPassengersList(passengers);
|
||||
|
||||
if (res.matchedPassengerId) {
|
||||
setSelectedPassengerIds([res.matchedPassengerId]);
|
||||
} else {
|
||||
// Select all passengers by default when loading a flight manifest
|
||||
setSelectedPassengerIds(passengers.map((p) => p.id));
|
||||
}
|
||||
|
||||
const matchedCategory = matchOption(d.category, DEFAULT_CATEGORY_OPTIONS);
|
||||
const matchedScenario = matchOption(d.scenario, scenarioOptions);
|
||||
const matchedJurisdiction = matchOption(d.jurisdiction, jurisdictionOptions);
|
||||
|
||||
const firstP = passengers[0];
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
flightNumber: d.flightNumber,
|
||||
date: d.date,
|
||||
origin: d.origin,
|
||||
destination: d.destination,
|
||||
category: matchedCategory,
|
||||
scenario: matchedScenario,
|
||||
jurisdiction: matchedJurisdiction,
|
||||
delayDuration: String(d.delayDuration),
|
||||
passengerName: firstP ? firstP.passengerName : prev.passengerName,
|
||||
pnr: firstP ? firstP.pnr : prev.pnr,
|
||||
loyaltyTier: firstP ? firstP.loyaltyTier : prev.loyaltyTier,
|
||||
}));
|
||||
|
||||
const categoryLabel = DEFAULT_CATEGORY_OPTIONS.find((c) => c.value === matchedCategory)?.label || matchedCategory;
|
||||
const scenarioLabel = scenarioOptions.find((s) => s.value === matchedScenario)?.label || matchedScenario;
|
||||
const jurisdictionLabel = jurisdictionOptions.find((j) => j.value === matchedJurisdiction)?.label || matchedJurisdiction;
|
||||
|
||||
setAutoFilledNotice(
|
||||
`Disruption Mock Synced • Flight ${d.flightNumber} (${d.origin} → ${d.destination}) • Jurisdiction: ${jurisdictionLabel} • Category: ${categoryLabel} • Scenario: ${scenarioLabel} • Delay: ${d.delayDuration} mins • ${passengers.length} passenger(s) on manifest.`
|
||||
);
|
||||
} else {
|
||||
setPassengersList([]);
|
||||
setSelectedPassengerIds([]);
|
||||
setAutoFilledNotice(null);
|
||||
setFormData((prev) => ({ ...prev, flightNumber: val }));
|
||||
}
|
||||
};
|
||||
|
||||
// Select all / Deselect all passengers
|
||||
const handleToggleSelectAll = () => {
|
||||
if (selectedPassengerIds.length === passengersList.length) {
|
||||
setSelectedPassengerIds([]);
|
||||
} else {
|
||||
setSelectedPassengerIds(passengersList.map((p) => p.id));
|
||||
}
|
||||
};
|
||||
|
||||
const handleTogglePerksClaimed = (passengerId: string, checked: boolean) => {
|
||||
setPerksClaimedMap(prev => ({ ...prev, [passengerId]: checked }));
|
||||
};
|
||||
|
||||
// Toggle individual passenger row selection
|
||||
const handleTogglePassengerRow = (passengerId: string) => {
|
||||
setSelectedPassengerIds((prev) => {
|
||||
const exists = prev.includes(passengerId);
|
||||
let updated: string[];
|
||||
if (exists) {
|
||||
updated = prev.filter((id) => id !== passengerId);
|
||||
} else {
|
||||
updated = [...prev, passengerId];
|
||||
}
|
||||
|
||||
const primaryP = passengersList.find((p) => p.id === (updated[0] || passengerId));
|
||||
if (primaryP) {
|
||||
setFormData((f) => ({
|
||||
...f,
|
||||
passengerName: updated.length > 1 ? `${primaryP.passengerName} (+${updated.length - 1} more)` : primaryP.passengerName,
|
||||
pnr: primaryP.pnr,
|
||||
loyaltyTier: primaryP.loyaltyTier,
|
||||
}));
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const selectedPassengers = passengersList.filter((p) => selectedPassengerIds.includes(p.id));
|
||||
|
||||
if (selectedPassengers.length > 0) {
|
||||
const promises = selectedPassengers.map((p) => {
|
||||
const existingInc = existingIncidents.find(fi => fi.pnr === p.pnr && fi.passengerName === p.passengerName)
|
||||
|| (incident && incident.pnr === p.pnr && incident.passengerName === p.passengerName ? incident : null);
|
||||
|
||||
const payload = {
|
||||
recoveryCode: existingInc ? existingInc.recoveryCode : "REC-" + Math.floor(Math.random() * 100000),
|
||||
passengerName: p.passengerName,
|
||||
pnr: p.pnr,
|
||||
loyaltyTier: p.loyaltyTier,
|
||||
passengerType: p.passengerType,
|
||||
nationality: p.nationality,
|
||||
specialAssistance: p.specialAssistance,
|
||||
cabinClass: p.cabinClass,
|
||||
originalCabin: p.originalCabin,
|
||||
actualCabin: p.actualCabin,
|
||||
flightNumber: formData.flightNumber || "TBD",
|
||||
flightRoute: `${formData.origin || 'UNK'} → ${formData.destination || 'UNK'}`,
|
||||
origin: formData.origin || undefined,
|
||||
destination: formData.destination || undefined,
|
||||
date: formData.date ? new Date(formData.date).toISOString() : new Date().toISOString(),
|
||||
category: formData.category || "General",
|
||||
status: existingInc ? (existingInc.status || "Pending") : "Pending",
|
||||
value: existingInc ? existingInc.value : "$0",
|
||||
isPerksClaimed: perksClaimedMap[p.id] || false,
|
||||
jurisdiction: formData.jurisdiction || undefined,
|
||||
delayDuration: formData.delayDuration ? Number(formData.delayDuration) : undefined,
|
||||
scenario: formData.scenario || undefined,
|
||||
recoverySource: existingInc ? (existingInc.recoverySource || 'Manual Disruption Entry') : 'Manual Disruption Entry',
|
||||
};
|
||||
|
||||
if (existingInc) {
|
||||
return updateRecoveryIncident(existingInc.id, payload);
|
||||
} else {
|
||||
return createRecoveryIncident(payload);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
if (onSuccess) {
|
||||
onSuccess(
|
||||
selectedPassengers.length,
|
||||
formData.flightNumber || "TBD",
|
||||
selectedPassengers[0]?.passengerName || "Unknown",
|
||||
!!incident
|
||||
);
|
||||
}
|
||||
}
|
||||
onClose();
|
||||
} catch (error: any) {
|
||||
console.error("Error saving incident(s):", error.response?.data || error.message || error);
|
||||
alert(`Failed to save incident(s): ${error.response?.data?.message || 'Unknown error'}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isAllSelected = passengersList.length > 0 && selectedPassengerIds.length === passengersList.length;
|
||||
|
||||
return (
|
||||
<CustomModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={incident ? "Edit Recovery Incident" : "New Recovery Incident"}
|
||||
description="Log disruption cases and assess against policy frameworks"
|
||||
icon={<FileText className="text-[#1B9869]" />}
|
||||
size="xl"
|
||||
primaryAction={{
|
||||
label: loading
|
||||
? "Saving..."
|
||||
: incident
|
||||
? "Save Changes"
|
||||
: selectedPassengerIds.length > 1
|
||||
? `Assess & Log (${selectedPassengerIds.length}) Incidents`
|
||||
: "Assess & Log Incident",
|
||||
onClick: handleSubmit,
|
||||
icon: <CaretRight size={16} />,
|
||||
}}
|
||||
secondaryAction={{
|
||||
label: "Discard",
|
||||
onClick: onClose,
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* SEARCH FLIGHT NUMBER / PNR CARD */}
|
||||
<div className={SECTION_CONTAINER_CLASS}>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-[#4A5568] mb-1.5">
|
||||
Flight Number or PNR Reference
|
||||
</label>
|
||||
<CustomDropdown
|
||||
placeholder="e.g. FT7687T9I or B7687YT"
|
||||
value={query}
|
||||
onChange={(val) => handleQueryChange(val as string)}
|
||||
options={flightNumberOptions}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<CustomInput
|
||||
type="date"
|
||||
label="Date"
|
||||
placeholder="Selected Option"
|
||||
value={formData.date}
|
||||
onChange={(e) => handleInputChange("date", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AUTO FILLED NOTICE BANNER */}
|
||||
{autoFilledNotice && (
|
||||
<div className="flex items-center gap-3 p-3 bg-emerald-50 border border-emerald-200 text-emerald-800 rounded-xl text-xs font-medium">
|
||||
<Lightning size={18} weight="fill" className="text-emerald-600 shrink-0" />
|
||||
<span className="flex-1">{autoFilledNotice}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PASSENGERS TABLE (SUPPORTING SELECT ALL & MULTI-SELECT) */}
|
||||
{passengersList.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden shadow-xs">
|
||||
<div className="px-4 py-3 bg-[#F9FAFB] border-b border-gray-200 flex items-center justify-between">
|
||||
<span className="text-xs font-bold text-gray-700 tracking-wider uppercase flex items-center gap-2">
|
||||
<User size={16} className="text-[#1B9869]" />
|
||||
Passenger Manifest Details (Selected: {selectedPassengerIds.length} of {passengersList.length})
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggleSelectAll}
|
||||
className="text-xs text-[#1B9869] hover:underline font-semibold cursor-pointer"
|
||||
>
|
||||
{isAllSelected ? "Deselect All" : "Select All Passengers"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-[#F8FAFC] border-b border-gray-200 text-[#718096] font-semibold">
|
||||
<th className="py-3 px-4 w-10 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isAllSelected}
|
||||
onChange={handleToggleSelectAll}
|
||||
className="w-4 h-4 text-[#1B9869] rounded border-gray-300 focus:ring-[#1B9869] cursor-pointer"
|
||||
title="Select All Passengers"
|
||||
/>
|
||||
</th>
|
||||
<th className="py-3 px-4">PNR Number</th>
|
||||
<th className="py-3 px-4">Passanger name</th>
|
||||
<th className="py-3 px-4">Passanger type</th>
|
||||
<th className="py-3 px-4">Booked Cabin</th>
|
||||
<th className="py-3 px-4">Assigned Cabin</th>
|
||||
<th className="py-3 px-4">Nationality</th>
|
||||
<th className="py-3 px-4">loyality type</th>
|
||||
<th className="py-3 px-4">Special assisstance</th>
|
||||
<th className="py-3 px-4 text-center">Perks Claimed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{passengersList.map((p) => {
|
||||
const isSelected = selectedPassengerIds.includes(p.id);
|
||||
const isDowngraded = Boolean(p.originalCabin && p.actualCabin && p.originalCabin !== p.actualCabin);
|
||||
return (
|
||||
<tr
|
||||
key={p.id}
|
||||
onClick={() => handleTogglePassengerRow(p.id)}
|
||||
className={`cursor-pointer transition-colors ${isSelected
|
||||
? 'bg-emerald-50/70 border-l-4 border-l-[#1B9869]'
|
||||
: 'hover:bg-gray-50/80'
|
||||
}`}
|
||||
>
|
||||
<td className="py-3 px-4 text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => handleTogglePassengerRow(p.id)}
|
||||
className="w-4 h-4 text-[#1B9869] rounded border-gray-300 focus:ring-[#1B9869] cursor-pointer"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-3 px-4 font-mono font-medium text-gray-900">{p.pnr}</td>
|
||||
<td className="py-3 px-4 font-semibold text-gray-800">{p.passengerName}</td>
|
||||
<td className="py-3 px-4 text-gray-600">{p.passengerType}</td>
|
||||
<td className="py-3 px-4 text-gray-700 font-medium">{p.originalCabin || p.cabinClass || 'Economy'}</td>
|
||||
<td className="py-3 px-4">
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded text-[11px] font-semibold ${isDowngraded ? 'bg-amber-100 text-amber-900 border border-amber-300' : 'text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{p.actualCabin || p.cabinClass || 'Economy'}
|
||||
{isDowngraded && <span className="ml-1 text-[10px] text-amber-700 font-bold">(Downgraded)</span>}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-gray-600">{p.nationality}</td>
|
||||
<td className="py-3 px-4">
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[11px] font-semibold ${p.loyaltyTier === 'Platinum'
|
||||
? 'bg-purple-100 text-purple-800'
|
||||
: p.loyaltyTier === 'Gold'
|
||||
? 'bg-amber-100 text-amber-800'
|
||||
: p.loyaltyTier === 'Silver'
|
||||
? 'bg-slate-100 text-slate-700'
|
||||
: 'bg-orange-100 text-orange-800'
|
||||
}`}
|
||||
>
|
||||
{p.loyaltyTier}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-gray-600">{p.specialAssistance}</td>
|
||||
<td className="py-3 px-4 text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<CustomCheckBox
|
||||
checked={perksClaimedMap[p.id] || false}
|
||||
onChange={(e) => handleTogglePerksClaimed(p.id, e.target.checked)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* FLIGHT CONTEXT DETAILS */}
|
||||
<div className={SECTION_CONTAINER_CLASS}>
|
||||
<div className={SECTION_TITLE_CLASS}>
|
||||
<Airplane size={16} />
|
||||
<span>Flight Context & Route</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<CustomDropdown
|
||||
label="Origin (IATA)"
|
||||
placeholder="Selected Option"
|
||||
value={formData.origin}
|
||||
onChange={(val) => handleInputChange("origin", val as string)}
|
||||
options={[
|
||||
{ label: "FRA - Frankfurt", value: "FRA" },
|
||||
{ label: "LHR - London Heathrow", value: "LHR" },
|
||||
{ label: "SFO - San Francisco", value: "SFO" },
|
||||
{ label: "JFK - New York JFK", value: "JFK" },
|
||||
{ label: "CDG - Paris Charles de Gaulle", value: "CDG" },
|
||||
{ label: "NRT - Tokyo Narita", value: "NRT" },
|
||||
{ label: "DXB - Dubai International", value: "DXB" },
|
||||
]}
|
||||
/>
|
||||
<CustomDropdown
|
||||
label="Destination (IATA)"
|
||||
placeholder="Selected Option"
|
||||
value={formData.destination}
|
||||
onChange={(val) => handleInputChange("destination", val as string)}
|
||||
options={[
|
||||
{ label: "JFK - New York JFK", value: "JFK" },
|
||||
{ label: "CDG - Paris Charles de Gaulle", value: "CDG" },
|
||||
{ label: "NRT - Tokyo Narita", value: "NRT" },
|
||||
{ label: "FRA - Frankfurt", value: "FRA" },
|
||||
{ label: "LHR - London Heathrow", value: "LHR" },
|
||||
{ label: "SFO - San Francisco", value: "SFO" },
|
||||
{ label: "DXB - Dubai International", value: "DXB" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</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={DEFAULT_CATEGORY_OPTIONS}
|
||||
/>
|
||||
<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>
|
||||
</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,715 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { useNavigate, useLocation } 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,
|
||||
CustomAlertBanner,
|
||||
CustomSuccessModal,
|
||||
} from "../../../components/custom";
|
||||
import Can from "../../../components/common/Can";
|
||||
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 location = useLocation();
|
||||
const [incidents, setIncidents] = useState<RecoveryIncident[]>([]);
|
||||
const [metrics, setMetrics] = useState<MetricCardData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (location.state?.successMsg) {
|
||||
setSuccessMsg(location.state.successMsg);
|
||||
window.history.replaceState({}, document.title);
|
||||
}
|
||||
}, [location]);
|
||||
|
||||
// Pagination
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
// Filters
|
||||
const [search, setSearch] = useState("");
|
||||
const [isGrouped, setIsGrouped] = useState(() => {
|
||||
return sessionStorage.getItem("recovery_isGrouped") === "true";
|
||||
});
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(() => {
|
||||
const saved = sessionStorage.getItem("recovery_collapsedGroups");
|
||||
return saved ? new Set(JSON.parse(saved)) : new Set();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
sessionStorage.setItem("recovery_isGrouped", String(isGrouped));
|
||||
}, [isGrouped]);
|
||||
|
||||
useEffect(() => {
|
||||
sessionStorage.setItem(
|
||||
"recovery_collapsedGroups",
|
||||
JSON.stringify(Array.from(collapsedGroups)),
|
||||
);
|
||||
}, [collapsedGroups]);
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingIncident, setEditingIncident] =
|
||||
useState<RecoveryIncident | null>(null);
|
||||
|
||||
// Selection
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// Success Modal
|
||||
const [successModalData, setSuccessModalData] = useState<{
|
||||
isOpen: boolean;
|
||||
count: number;
|
||||
flightNumber: string;
|
||||
passengerName: string;
|
||||
isEdit?: boolean;
|
||||
}>({ isOpen: false, count: 0, flightNumber: "", passengerName: "", isEdit: false });
|
||||
|
||||
// ─── Fetch data ─────────────────────────────────────────────
|
||||
|
||||
const fetchIncidents = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getRecoveryIncidents();
|
||||
setIncidents(data);
|
||||
getRecoveryMetrics()
|
||||
.then((m) => setMetrics(m))
|
||||
.catch((err) =>
|
||||
console.error("Failed to fetch recovery metrics:", err),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch recovery incidents", error);
|
||||
setIncidents([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchIncidents();
|
||||
}, [fetchIncidents]);
|
||||
|
||||
useEffect(() => {
|
||||
if (incidents.length > 0) {
|
||||
setCollapsedGroups((prev) => {
|
||||
const hasSaved = sessionStorage.getItem("recovery_hasSavedGroups");
|
||||
if (hasSaved) return prev;
|
||||
|
||||
sessionStorage.setItem("recovery_hasSavedGroups", "true");
|
||||
return new Set(incidents.map((i) => i.flightNumber || "Other"));
|
||||
});
|
||||
}
|
||||
}, [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 {
|
||||
setError(null);
|
||||
setSuccessMsg(null);
|
||||
const { updateIncidentStatus } = await import("../RecoveryIncidentsApi");
|
||||
await updateIncidentStatus(incident.id, text);
|
||||
fetchIncidents();
|
||||
setSuccessMsg(`Incident status updated to ${text} successfully.`);
|
||||
} catch (error) {
|
||||
console.error("Failed to update status", error);
|
||||
setError("Failed to update status. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
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} App`, 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,
|
||||
} as any);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return result.map((r, i) => ({ ...r, globalIndex: i }));
|
||||
}, [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 className={row.isGroupHeader ? "-ml-[40px]" : ""}>
|
||||
<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.isGroupHeader ? null : 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) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`px-2.5 py-1 rounded-full text-[11px] font-semibold leading-[16.5px] ${status.variant === "warning" ? "bg-[#FFFBE6] text-[#D48806]" : "bg-[#E4FAE7] text-[#1B9869]"}`}
|
||||
>
|
||||
{status.text}
|
||||
</div>
|
||||
))}
|
||||
</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" />,
|
||||
accessor: (row) => {
|
||||
const key =
|
||||
(row as any).groupKey || row.flightNumber || row.recoveryCode;
|
||||
return (
|
||||
<div className="flex items-center pl-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>
|
||||
<Can permission="recovery:edit">
|
||||
<CustomActionItem
|
||||
onClick={() => {
|
||||
setEditingIncident(row);
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
icon={
|
||||
<PencilSimpleIcon size={16} className="text-yellow-500" />
|
||||
}
|
||||
>
|
||||
Edit Incident
|
||||
</CustomActionItem>
|
||||
</Can>
|
||||
<Can permission="recovery:override">
|
||||
<CustomActionItem
|
||||
icon={
|
||||
<CheckCircleIcon size={16} className="text-green-500" />
|
||||
}
|
||||
onClick={() => handleStatusChange(row, "Approved")}
|
||||
>
|
||||
Approve
|
||||
</CustomActionItem>
|
||||
</Can>
|
||||
<Can permission="recovery:override">
|
||||
<CustomActionItem
|
||||
variant="danger"
|
||||
icon={<XCircleIcon size={16} className="text-red-500" />}
|
||||
onClick={() => handleStatusChange(row, "Rejected")}
|
||||
>
|
||||
Reject
|
||||
</CustomActionItem>
|
||||
</Can>
|
||||
<Can permission="recovery:edit">
|
||||
<CustomActionItem
|
||||
icon={<ClockIcon size={16} className="text-yellow-600" />}
|
||||
onClick={() => handleStatusChange(row, "Under Review")}
|
||||
>
|
||||
Mark for Review
|
||||
</CustomActionItem>
|
||||
</Can>
|
||||
</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">
|
||||
{error && (
|
||||
<CustomAlertBanner
|
||||
message={error}
|
||||
type="error"
|
||||
onClose={() => setError(null)}
|
||||
/>
|
||||
)}
|
||||
{successMsg && (
|
||||
<CustomAlertBanner
|
||||
message={successMsg}
|
||||
type="success"
|
||||
onClose={() => setSuccessMsg(null)}
|
||||
/>
|
||||
)}
|
||||
{/* 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>
|
||||
<Can permission="recovery:create">
|
||||
<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>
|
||||
</Can>
|
||||
</>
|
||||
}
|
||||
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: any) => {
|
||||
if (row.isGroupHeader) {
|
||||
return "bg-white border-b border-gray-100 cursor-pointer hover:bg-gray-50/60";
|
||||
}
|
||||
return "bg-[#F9FAFB] border-transparent cursor-pointer hover:bg-[#F3F4F6]";
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<AddRecoveryIncidents
|
||||
isOpen={isModalOpen}
|
||||
incident={editingIncident}
|
||||
onClose={() => {
|
||||
setIsModalOpen(false);
|
||||
setEditingIncident(null);
|
||||
fetchIncidents(); // Refresh list
|
||||
}}
|
||||
onSuccess={(createdCount, flightNumber, passengerName, isEdit) => {
|
||||
setSuccessModalData({
|
||||
isOpen: true,
|
||||
count: createdCount,
|
||||
flightNumber,
|
||||
passengerName,
|
||||
isEdit,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Success Modal */}
|
||||
<CustomSuccessModal
|
||||
isOpen={successModalData.isOpen}
|
||||
onClose={() =>
|
||||
setSuccessModalData((prev) => ({ ...prev, isOpen: false }))
|
||||
}
|
||||
title={
|
||||
successModalData.isEdit
|
||||
? (successModalData.count > 1 ? "Recovery Incidents Updated." : "Recovery Incident Updated.")
|
||||
: (successModalData.count > 1 ? "Recovery Incidents Created." : "Recovery Incident Created.")
|
||||
}
|
||||
label="FLIGHT & PASSENGER DETAILS"
|
||||
cohortName={`Flight ${successModalData.flightNumber}`}
|
||||
cohortDescription={
|
||||
successModalData.isEdit
|
||||
? (successModalData.count > 1
|
||||
? `Successfully updated incidents for ${successModalData.count} passengers, including ${successModalData.passengerName}.`
|
||||
: `Successfully updated incident for ${successModalData.passengerName}.`)
|
||||
: (successModalData.count > 1
|
||||
? `Successfully logged incidents for ${successModalData.count} passengers, including ${successModalData.passengerName}.`
|
||||
: `Successfully logged incident for ${successModalData.passengerName}.`)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
export interface MockPassenger {
|
||||
id: string;
|
||||
passengerName: string;
|
||||
pnr: string;
|
||||
passengerType: string; // e.g. "VIP Adult", "Adult", "High Value", "Child", "Infant"
|
||||
nationality: string; // e.g. "German", "British", "American", "Japanese", "French"
|
||||
loyaltyTier: string; // e.g. "Platinum", "Gold", "Silver", "Bronze", "Regular"
|
||||
specialAssistance: string; // e.g. "Wheelchair (WCHR)", "None", "Dietary Meal", "Medical"
|
||||
originalCabin: string; // e.g. "First Class", "Business Class", "Premium Economy", "Economy"
|
||||
actualCabin: string; // e.g. "Business Class", "Economy", "First Class" (assigned cabin after disruption)
|
||||
cabinClass?: string; // Legacy fallback/alias
|
||||
isHighValue?: boolean;
|
||||
seatNumber?: string;
|
||||
ticketNumber?: string;
|
||||
}
|
||||
|
||||
export interface MockDisruption {
|
||||
flightNumber: string;
|
||||
date: string; // YYYY-MM-DD
|
||||
origin: string; // IATA code
|
||||
destination: string; // IATA code
|
||||
category: string; // e.g. "flight_ops", "weather", "travel_exp"
|
||||
scenario: string; // e.g. "delayed_flight", "cancelled_flight", "missed_connection"
|
||||
jurisdiction: string; // e.g. "EU261", "US_DOT", "UK261"
|
||||
delayDuration: number; // in minutes
|
||||
status: string; // e.g. "Delayed", "Cancelled", "Diverted"
|
||||
description?: string;
|
||||
passengers: MockPassenger[];
|
||||
}
|
||||
|
||||
export const MOCK_DISRUPTIONS: Record<string, MockDisruption> = {
|
||||
B7687YT: {
|
||||
flightNumber: 'B7687YT',
|
||||
date: '2026-08-14',
|
||||
origin: 'FRA',
|
||||
destination: 'JFK',
|
||||
category: 'flight_ops',
|
||||
scenario: 'delayed_flight',
|
||||
jurisdiction: 'EU261',
|
||||
delayDuration: 240,
|
||||
status: 'Delayed',
|
||||
description: 'Technical engine maintenance delay at Frankfurt Airport.',
|
||||
passengers: [
|
||||
{
|
||||
id: 'p-101',
|
||||
passengerName: 'John Doe',
|
||||
pnr: 'FT7687T9I',
|
||||
passengerType: 'VIP Adult',
|
||||
nationality: 'German',
|
||||
loyaltyTier: 'Platinum',
|
||||
specialAssistance: 'Wheelchair (WCHR)',
|
||||
originalCabin: 'First Class',
|
||||
actualCabin: 'Business Class', // Downgraded during disruption
|
||||
cabinClass: 'First Class',
|
||||
isHighValue: true,
|
||||
seatNumber: '4A',
|
||||
ticketNumber: '0162394810239',
|
||||
},
|
||||
{
|
||||
id: 'p-102',
|
||||
passengerName: 'Sarah Jenkins',
|
||||
pnr: 'SJ98231FA',
|
||||
passengerType: 'High Value Adult',
|
||||
nationality: 'British',
|
||||
loyaltyTier: 'Gold',
|
||||
specialAssistance: 'None',
|
||||
originalCabin: 'First Class',
|
||||
actualCabin: 'First Class',
|
||||
cabinClass: 'First Class',
|
||||
isHighValue: true,
|
||||
seatNumber: '2F',
|
||||
ticketNumber: '0162394810240',
|
||||
},
|
||||
{
|
||||
id: 'p-103',
|
||||
passengerName: 'Michael Chen',
|
||||
pnr: 'MC441298X',
|
||||
passengerType: 'Adult',
|
||||
nationality: 'American',
|
||||
loyaltyTier: 'Silver',
|
||||
specialAssistance: 'Dietary Meal (VGML)',
|
||||
originalCabin: 'Business Class',
|
||||
actualCabin: 'Economy', // Downgraded
|
||||
cabinClass: 'Business Class',
|
||||
isHighValue: false,
|
||||
seatNumber: '28C',
|
||||
ticketNumber: '0162394810241',
|
||||
},
|
||||
{
|
||||
id: 'p-104',
|
||||
passengerName: 'Amanda Lewis',
|
||||
pnr: 'AL551029P',
|
||||
passengerType: 'Adult',
|
||||
nationality: 'Canadian',
|
||||
loyaltyTier: 'Bronze',
|
||||
specialAssistance: 'None',
|
||||
originalCabin: 'Economy',
|
||||
actualCabin: 'Economy',
|
||||
cabinClass: 'Economy',
|
||||
isHighValue: false,
|
||||
seatNumber: '31D',
|
||||
ticketNumber: '0162394810242',
|
||||
},
|
||||
],
|
||||
},
|
||||
Q23SXD: {
|
||||
flightNumber: 'Q23SXD',
|
||||
date: '2026-08-13',
|
||||
origin: 'LHR',
|
||||
destination: 'CDG',
|
||||
category: 'weather',
|
||||
scenario: 'cancelled_flight',
|
||||
jurisdiction: 'UK261',
|
||||
delayDuration: 360,
|
||||
status: 'Cancelled',
|
||||
description: 'Severe storm and heavy fog over London Heathrow.',
|
||||
passengers: [
|
||||
{
|
||||
id: 'p-201',
|
||||
passengerName: 'Robert Vance',
|
||||
pnr: 'RV992104K',
|
||||
passengerType: 'VIP Adult',
|
||||
nationality: 'British',
|
||||
loyaltyTier: 'Platinum',
|
||||
specialAssistance: 'None',
|
||||
originalCabin: 'First Class',
|
||||
actualCabin: 'Business Class',
|
||||
cabinClass: 'First Class',
|
||||
isHighValue: true,
|
||||
seatNumber: '1B',
|
||||
ticketNumber: '1259920194812',
|
||||
},
|
||||
{
|
||||
id: 'p-202',
|
||||
passengerName: 'Emma Watson',
|
||||
pnr: 'EW771029M',
|
||||
passengerType: 'High Value Adult',
|
||||
nationality: 'French',
|
||||
loyaltyTier: 'Gold',
|
||||
specialAssistance: 'Dietary Meal',
|
||||
originalCabin: 'Business Class',
|
||||
actualCabin: 'Business Class',
|
||||
cabinClass: 'Business Class',
|
||||
isHighValue: true,
|
||||
seatNumber: '6D',
|
||||
ticketNumber: '1259920194813',
|
||||
},
|
||||
{
|
||||
id: 'p-203',
|
||||
passengerName: 'David Miller',
|
||||
pnr: 'DM334190Q',
|
||||
passengerType: 'Adult',
|
||||
nationality: 'Australian',
|
||||
loyaltyTier: 'Regular',
|
||||
specialAssistance: 'Wheelchair (WCHR)',
|
||||
originalCabin: 'Economy',
|
||||
actualCabin: 'Economy',
|
||||
cabinClass: 'Economy',
|
||||
isHighValue: false,
|
||||
seatNumber: '19A',
|
||||
ticketNumber: '1259920194814',
|
||||
},
|
||||
],
|
||||
},
|
||||
AZ404: {
|
||||
flightNumber: 'AZ404',
|
||||
date: '2026-08-13',
|
||||
origin: 'SFO',
|
||||
destination: 'NRT',
|
||||
category: 'travel_exp',
|
||||
scenario: 'missed_connection',
|
||||
jurisdiction: 'US_DOT',
|
||||
delayDuration: 180,
|
||||
status: 'Delayed',
|
||||
description: 'Late arrival of incoming aircraft causing connection breakdown.',
|
||||
passengers: [
|
||||
{
|
||||
id: 'p-301',
|
||||
passengerName: 'Kaito Tanaka',
|
||||
pnr: 'KT883192Z',
|
||||
passengerType: 'High Value Adult',
|
||||
nationality: 'Japanese',
|
||||
loyaltyTier: 'Platinum',
|
||||
specialAssistance: 'None',
|
||||
originalCabin: 'Business Class',
|
||||
actualCabin: 'Business Class',
|
||||
cabinClass: 'Business Class',
|
||||
isHighValue: true,
|
||||
seatNumber: '11K',
|
||||
ticketNumber: '0571120938491',
|
||||
},
|
||||
{
|
||||
id: 'p-302',
|
||||
passengerName: 'Lisa Ray',
|
||||
pnr: 'LR110293Y',
|
||||
passengerType: 'Adult',
|
||||
nationality: 'American',
|
||||
loyaltyTier: 'Silver',
|
||||
specialAssistance: 'Unaccompanied Minor',
|
||||
originalCabin: 'Premium Economy',
|
||||
actualCabin: 'Economy',
|
||||
cabinClass: 'Premium Economy',
|
||||
isHighValue: false,
|
||||
seatNumber: '16C',
|
||||
ticketNumber: '0571120938492',
|
||||
},
|
||||
{
|
||||
id: 'p-303',
|
||||
passengerName: 'Carlos Gomez',
|
||||
pnr: 'CG559102X',
|
||||
passengerType: 'VIP Adult',
|
||||
nationality: 'Mexican',
|
||||
loyaltyTier: 'Gold',
|
||||
specialAssistance: 'Medical Assistance',
|
||||
originalCabin: 'Business Class',
|
||||
actualCabin: 'Business Class',
|
||||
cabinClass: 'Business Class',
|
||||
isHighValue: true,
|
||||
seatNumber: '8A',
|
||||
ticketNumber: '0571120938493',
|
||||
},
|
||||
],
|
||||
},
|
||||
BA178: {
|
||||
flightNumber: 'BA178',
|
||||
date: '2026-08-15',
|
||||
origin: 'JFK',
|
||||
destination: 'LHR',
|
||||
category: 'flight_ops',
|
||||
scenario: 'delayed_flight',
|
||||
jurisdiction: 'UK261',
|
||||
delayDuration: 300,
|
||||
status: 'Delayed',
|
||||
description: 'Air traffic control delay on transatlantic sector.',
|
||||
passengers: [
|
||||
{
|
||||
id: 'p-401',
|
||||
passengerName: 'Harrison Ford',
|
||||
pnr: 'HF908123A',
|
||||
passengerType: 'VIP Adult',
|
||||
nationality: 'American',
|
||||
loyaltyTier: 'Platinum',
|
||||
specialAssistance: 'None',
|
||||
originalCabin: 'First Class',
|
||||
actualCabin: 'First Class',
|
||||
cabinClass: 'First Class',
|
||||
isHighValue: true,
|
||||
seatNumber: '2A',
|
||||
ticketNumber: '1250912837192',
|
||||
},
|
||||
{
|
||||
id: 'p-402',
|
||||
passengerName: 'Clara Oswald',
|
||||
pnr: 'CO449182B',
|
||||
passengerType: 'High Value Adult',
|
||||
nationality: 'British',
|
||||
loyaltyTier: 'Gold',
|
||||
specialAssistance: 'None',
|
||||
originalCabin: 'Business Class',
|
||||
actualCabin: 'Premium Economy',
|
||||
cabinClass: 'Business Class',
|
||||
isHighValue: true,
|
||||
seatNumber: '12E',
|
||||
ticketNumber: '1250912837193',
|
||||
},
|
||||
],
|
||||
},
|
||||
EK202: {
|
||||
flightNumber: 'EK202',
|
||||
date: '2026-08-14',
|
||||
origin: 'JFK',
|
||||
destination: 'DXB',
|
||||
category: 'weather',
|
||||
scenario: 'delayed_flight',
|
||||
jurisdiction: 'US_DOT',
|
||||
delayDuration: 210,
|
||||
status: 'Delayed',
|
||||
description: 'Severe thunderstorm delay prior to pushback.',
|
||||
passengers: [
|
||||
{
|
||||
id: 'p-501',
|
||||
passengerName: 'Tariq Al-Mansoor',
|
||||
pnr: 'TM771920K',
|
||||
passengerType: 'VIP Adult',
|
||||
nationality: 'Emirati',
|
||||
loyaltyTier: 'Platinum',
|
||||
specialAssistance: 'None',
|
||||
originalCabin: 'First Class',
|
||||
actualCabin: 'First Class',
|
||||
cabinClass: 'First Class',
|
||||
isHighValue: true,
|
||||
seatNumber: '1A',
|
||||
ticketNumber: '1769910293841',
|
||||
},
|
||||
{
|
||||
id: 'p-502',
|
||||
passengerName: 'Fatima Al-Sayed',
|
||||
pnr: 'FA882019L',
|
||||
passengerType: 'High Value Adult',
|
||||
nationality: 'Emirati',
|
||||
loyaltyTier: 'Gold',
|
||||
specialAssistance: 'Dietary Meal',
|
||||
originalCabin: 'Business Class',
|
||||
actualCabin: 'Business Class',
|
||||
cabinClass: 'Business Class',
|
||||
isHighValue: true,
|
||||
seatNumber: '7K',
|
||||
ticketNumber: '1769910293842',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Get options list of flight numbers for dropdown selection
|
||||
*/
|
||||
export function getMockFlightNumbers(): { label: string; value: string; description: string }[] {
|
||||
return Object.values(MOCK_DISRUPTIONS).map((item) => ({
|
||||
label: `${item.flightNumber} (${item.origin} → ${item.destination} • ${item.delayDuration}m delay)`,
|
||||
value: item.flightNumber,
|
||||
description: item.description || '',
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get disruption detail for a given flight number or PNR reference
|
||||
*/
|
||||
export function getMockDisruptionByFlightNumber(query: string): MockDisruption | undefined {
|
||||
if (!query) return undefined;
|
||||
const upper = query.trim().toUpperCase();
|
||||
|
||||
// 1. Direct flight number match
|
||||
const flightKey = Object.keys(MOCK_DISRUPTIONS).find((k) => k.toUpperCase() === upper);
|
||||
if (flightKey) return MOCK_DISRUPTIONS[flightKey];
|
||||
|
||||
// 2. Direct PNR match search across flights
|
||||
for (const item of Object.values(MOCK_DISRUPTIONS)) {
|
||||
const match = item.passengers.some((p) => p.pnr.toUpperCase() === upper);
|
||||
if (match) return item;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of passengers for a flight
|
||||
*/
|
||||
export function getPassengersForFlight(flightNumber: string): MockPassenger[] {
|
||||
const disruption = getMockDisruptionByFlightNumber(flightNumber);
|
||||
return disruption ? disruption.passengers : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get specific passenger details by flight number and PNR
|
||||
*/
|
||||
export function getMockPassengerByPnr(flightNumber: string, pnr: string): MockPassenger | undefined {
|
||||
const passengers = getPassengersForFlight(flightNumber);
|
||||
if (!pnr) return undefined;
|
||||
const upperPnr = pnr.trim().toUpperCase();
|
||||
return passengers.find((p) => p.pnr.toUpperCase() === upperPnr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search across flights and PNRs, returning disruption and optionally matched passenger ID
|
||||
*/
|
||||
export function searchDisruptionOrPassenger(query: string): { disruption?: MockDisruption; matchedPassengerId?: string } {
|
||||
if (!query) return {};
|
||||
const upper = query.trim().toUpperCase();
|
||||
|
||||
// Search flight number
|
||||
const flightKey = Object.keys(MOCK_DISRUPTIONS).find((k) => k.toUpperCase() === upper);
|
||||
if (flightKey) {
|
||||
return { disruption: MOCK_DISRUPTIONS[flightKey] };
|
||||
}
|
||||
|
||||
// Search PNR
|
||||
for (const item of Object.values(MOCK_DISRUPTIONS)) {
|
||||
const passenger = item.passengers.find((p) => p.pnr.toUpperCase() === upper);
|
||||
if (passenger) {
|
||||
return { disruption: item, matchedPassengerId: passenger.id };
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all mock disruptions
|
||||
*/
|
||||
export function getAllMockDisruptions(): MockDisruption[] {
|
||||
return Object.values(MOCK_DISRUPTIONS);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { ClipboardTextIcon, CheckCircleIcon } from '@phosphor-icons/react';
|
||||
import { formatDate } from '../../../utils/formatDate';
|
||||
import { getIncidentAuditTrail, type AuditTrailStepDto } from '../RecoveryIncidentsApi';
|
||||
import type { RecoveryIncident } from '../RecoveryIncidentsTypes';
|
||||
|
||||
interface AuditTrailTabProps {
|
||||
incident?: RecoveryIncident | null;
|
||||
}
|
||||
|
||||
export default function AuditTrailTab({ incident }: AuditTrailTabProps) {
|
||||
const [apiSteps, setApiSteps] = useState<AuditTrailStepDto[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const code = incident?.recoveryCode || 'REC-INCIDENT';
|
||||
const passenger = incident?.passengerName || 'Passenger';
|
||||
const flight = incident?.flightNumber || 'Flight';
|
||||
const route = incident?.flightRoute || 'Route';
|
||||
const status = incident?.status || 'Pending';
|
||||
const evaluation = incident?.evaluation;
|
||||
const policyName = evaluation?.policyName || 'Standard Policy';
|
||||
const cohortName = evaluation?.matchedCohortName || 'General Audience';
|
||||
const recoveryScore = evaluation?.recoveryScore || 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!incident?.id) return;
|
||||
setLoading(true);
|
||||
getIncidentAuditTrail(incident.id)
|
||||
.then((data) => {
|
||||
if (Array.isArray(data)) {
|
||||
setApiSteps(data);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to load backend audit trail:', err);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [incident?.id, incident?.status, incident?.evaluation?.updatedAt]);
|
||||
|
||||
const checkIcon = <CheckCircleIcon size={24} weight="fill" className="text-[#1B9869]" />;
|
||||
|
||||
const isApproved = status.toLowerCase().includes('appr');
|
||||
const isRejected = status.toLowerCase().includes('reject');
|
||||
|
||||
// Initial default steps if API response is loading
|
||||
const defaultInitialSteps: AuditTrailStepDto[] = [
|
||||
{
|
||||
id: 'step-1',
|
||||
title: 'Flight Disruption Recorded',
|
||||
description: `Disruption identified for ${passenger} on flight ${flight} (${route}).`,
|
||||
timestamp: incident?.createdAt || incident?.date || new Date().toISOString(),
|
||||
status: 'completed',
|
||||
},
|
||||
{
|
||||
id: 'step-2',
|
||||
title: 'Target Audience Cohort Evaluated',
|
||||
description: `Automated audience eligibility assessment performed against active frameworks. Matched cohort: "${cohortName}".`,
|
||||
timestamp: incident?.createdAt || new Date().toISOString(),
|
||||
status: 'completed',
|
||||
},
|
||||
{
|
||||
id: 'step-3',
|
||||
title: 'Policy Evaluated',
|
||||
description: `Evaluated against active rules under "${policyName}". Recovery Score: ${recoveryScore}/100.`,
|
||||
timestamp: evaluation?.createdAt || incident?.createdAt || new Date().toISOString(),
|
||||
status: 'completed',
|
||||
},
|
||||
{
|
||||
id: 'step-4',
|
||||
title: `Status: ${status}`,
|
||||
description: `Case officer assigned. Case status updated to "${status}".`,
|
||||
timestamp: incident?.updatedAt || new Date().toISOString(),
|
||||
status: 'completed',
|
||||
},
|
||||
{
|
||||
id: 'step-5',
|
||||
title: 'Recovery Resolution',
|
||||
description: isApproved
|
||||
? 'Recovery incident approved. Automated settlement and customer notification dispatched.'
|
||||
: isRejected
|
||||
? 'Recovery incident rejected by case officer.'
|
||||
: 'Refund and compensation settlement will initiate upon final approval.',
|
||||
timestamp: incident?.updatedAt || new Date().toISOString(),
|
||||
status: isApproved ? 'completed' : isRejected ? 'rejected' : 'pending',
|
||||
},
|
||||
];
|
||||
|
||||
const displaySteps = apiSteps.length > 0 ? apiSteps : defaultInitialSteps;
|
||||
|
||||
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 justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardTextIcon size={20} weight="bold" className="text-[#143d30]" />
|
||||
<h3 className="text-[13px] font-bold text-[#143d30] tracking-wider uppercase">
|
||||
Lifecycle Timeline & Audit Trail ({code})
|
||||
</h3>
|
||||
</div>
|
||||
{loading && (
|
||||
<span className="text-xs text-gray-400 animate-pulse">Loading updates...</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col mt-4 pl-2">
|
||||
{displaySteps.map((step, idx) => {
|
||||
const isLast = idx === displaySteps.length - 1;
|
||||
const rawTime = (step as any).createdAt || step.timestamp;
|
||||
|
||||
return (
|
||||
<div key={step.id || idx} className={`relative pl-10 ${isLast ? '' : 'pb-8'}`}>
|
||||
{/* Connecting Line */}
|
||||
{!isLast && (
|
||||
<div
|
||||
className="absolute left-[7px] top-7 bottom-0 w-[2px] bg-[#1B9869]"
|
||||
></div>
|
||||
)}
|
||||
|
||||
{/* Node Icon Indicator */}
|
||||
<div className="absolute left-[-4px] top-0.5 bg-white flex items-center justify-center">
|
||||
<div className="text-[#1B9869]">{checkIcon}</div>
|
||||
</div>
|
||||
|
||||
{/* Step Details */}
|
||||
<div className="flex justify-between items-start gap-4">
|
||||
<div className="flex flex-col gap-1 max-w-xl">
|
||||
<h4 className="text-sm font-semibold text-gray-900">{step.title}</h4>
|
||||
<p className="text-[13px] text-gray-500 leading-relaxed">
|
||||
{step.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<span className="text-[11px] font-medium text-gray-400 tracking-wider shrink-0">
|
||||
{formatDate(rawTime)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
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;
|
||||
|
||||
const isDowngraded = Boolean(
|
||||
incident.originalCabin &&
|
||||
incident.actualCabin &&
|
||||
incident.originalCabin !== incident.actualCabin
|
||||
);
|
||||
|
||||
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]">{incident.loyaltyTier || 'Regular'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-[11px] font-bold text-gray-400 tracking-wider mb-1">PASSENGER TYPE</span>
|
||||
<span className="block text-[15px] font-semibold text-gray-900">{incident.passengerType || 'Adult'}</span>
|
||||
</div>
|
||||
{incident.nationality && (
|
||||
<div>
|
||||
<span className="block text-[11px] font-bold text-gray-400 tracking-wider mb-1">NATIONALITY</span>
|
||||
<span className="block text-[15px] font-semibold text-gray-900">{incident.nationality}</span>
|
||||
</div>
|
||||
)}
|
||||
{incident.specialAssistance && (
|
||||
<div>
|
||||
<span className="block text-[11px] font-bold text-gray-400 tracking-wider mb-1">SPECIAL ASSISTANCE</span>
|
||||
<span className="block text-[15px] font-semibold text-gray-900">{incident.specialAssistance}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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]">
|
||||
{incident.actualCabin || incident.cabinClass || 'Economy'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-[11px] font-bold text-gray-400 tracking-wider mb-1">DELAY (ARRIVAL)</span>
|
||||
<span className="block text-[15px] font-semibold text-gray-900">
|
||||
{incident.delayDuration ? `${incident.delayDuration} mins` : '--'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-[11px] font-bold text-gray-400 tracking-wider mb-1">ORIGINAL CABIN</span>
|
||||
<span className="block text-[15px] font-semibold text-gray-900">
|
||||
{incident.originalCabin || incident.cabinClass || 'Economy'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-[11px] font-bold text-gray-400 tracking-wider mb-1">ACTUAL CABIN</span>
|
||||
<span className="block text-[15px] font-semibold text-gray-900 flex items-center gap-1.5">
|
||||
{incident.actualCabin || incident.cabinClass || 'Economy'}
|
||||
{isDowngraded && (
|
||||
<span className="text-[10px] bg-amber-100 text-amber-800 font-bold px-2 py-0.5 rounded border border-amber-300">
|
||||
Downgraded
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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">{incident.scenario || 'Delayed Flight'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-[11px] font-bold text-gray-400 tracking-wider mb-1">JURISDICTION</span>
|
||||
<span className="block text-[15px] font-semibold text-[#1B9869]">{incident.jurisdiction || 'EU261'}</span>
|
||||
</div>
|
||||
<div className="col-span-3">
|
||||
<span className="block text-[11px] font-bold text-gray-400 tracking-wider mb-1">ROOT CAUSE ANALYSIS</span>
|
||||
<span className="block text-[15px] font-semibold text-gray-900">
|
||||
{incident.category === 'weather'
|
||||
? 'Severe weather disruption impacting airport operations and flight scheduling.'
|
||||
: incident.category === 'flight_ops' || incident.category === 'technical_fault'
|
||||
? 'Technical flight operations delay requiring maintenance clearance prior to departure.'
|
||||
: 'Operational issues resulting in service disruption. Analysis verified by automated engine.'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { CheckCircleIcon, InfoIcon } from '@phosphor-icons/react';
|
||||
import type { RecoveryIncident, IncidentEvaluationAction } from '../RecoveryIncidentsTypes';
|
||||
|
||||
interface RecoveryPlanTabProps {
|
||||
incident?: RecoveryIncident | null;
|
||||
}
|
||||
|
||||
export default function RecoveryPlanTab({ incident }: RecoveryPlanTabProps) {
|
||||
const actions: IncidentEvaluationAction[] = incident?.evaluation?.actions || [];
|
||||
|
||||
// Dynamically group evaluated actions by master ActionCategory name defined in DB
|
||||
const categoriesMap = new Map<string, IncidentEvaluationAction[]>();
|
||||
|
||||
actions.forEach((act) => {
|
||||
const catName = (act.category || 'General Actions').trim();
|
||||
if (!categoriesMap.has(catName)) {
|
||||
categoriesMap.set(catName, []);
|
||||
}
|
||||
categoriesMap.get(catName)!.push(act);
|
||||
});
|
||||
|
||||
if (!incident?.evaluation || actions.length === 0) {
|
||||
return (
|
||||
<div className="bg-white rounded-2xl p-12 border border-gray-100 shadow-sm flex flex-col items-center justify-center text-center gap-4">
|
||||
<div className="w-14 h-14 rounded-full bg-emerald-50 text-[#1B9869] flex items-center justify-center">
|
||||
<InfoIcon size={28} />
|
||||
</div>
|
||||
<div className="max-w-md flex flex-col gap-1">
|
||||
<h3 className="text-lg font-bold text-gray-900">No Policy Actions Evaluated</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{incident?.evaluation?.aiAssessment ||
|
||||
'No active policy rules in the Policy Engine matched the flight disruption parameters for this incident.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{Array.from(categoriesMap.entries()).map(([categoryName, categoryActions]) => (
|
||||
<div
|
||||
key={categoryName}
|
||||
className="bg-white rounded-2xl p-6 border border-gray-100 shadow-sm flex flex-col gap-6"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircleIcon size={20} weight="bold" className="text-[#143d30]" />
|
||||
<h3 className="text-[13px] font-bold text-[#143d30] tracking-wider uppercase">
|
||||
{categoryName}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 divide-y divide-gray-100">
|
||||
{categoryActions.map((action, idx) => {
|
||||
// Strip any legacy brackets from description sentence
|
||||
const cleanDescription = (action.description || '')
|
||||
.replace(/\[(Inputs|Configured Inputs):\s*.*?\]/, '')
|
||||
.trim();
|
||||
|
||||
return (
|
||||
<div key={action.id || idx} className="pt-4 first:pt-0 flex items-start justify-between gap-4">
|
||||
<div className="flex flex-col gap-1.5 max-w-2xl">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-[15px] font-bold text-gray-900 leading-snug">
|
||||
{action.title}
|
||||
</span>
|
||||
{action.actionTypeCode && (
|
||||
<span className="px-2 py-0.5 rounded text-[11px] font-semibold bg-emerald-50 text-[#1B9869] border border-emerald-200 uppercase tracking-wide">
|
||||
{action.actionTypeCode.replace(/_/g, ' ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-600 leading-relaxed font-medium">
|
||||
{cleanDescription || 'Action executed per policy configuration.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-1.5 shrink-0">
|
||||
<span className="text-[15px] font-bold text-[#1B9869]">
|
||||
{action.amount
|
||||
? `${action.currency || ''} ${action.amount.toLocaleString()}`.trim()
|
||||
: 'Included / Configured'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { SparkleIcon, UserIcon, ArrowRightIcon } from "@phosphor-icons/react";
|
||||
import { CustomButton } from "../../../components/custom";
|
||||
import type { RecoveryIncident } from "../RecoveryIncidentsTypes";
|
||||
|
||||
interface SummaryTabProps {
|
||||
incident?: RecoveryIncident | null;
|
||||
}
|
||||
|
||||
export default function SummaryTab({ incident }: SummaryTabProps) {
|
||||
const policyName = incident?.evaluation?.policyName || 'No Policy Matched';
|
||||
const recoveryScore = incident?.evaluation?.recoveryScore || 0;
|
||||
const aiAssessmentText =
|
||||
incident?.evaluation?.aiAssessment ||
|
||||
`No active policy rules matched flight ${incident?.flightNumber || 'N/A'} for passenger ${incident?.passengerName || 'N/A'}. Click "RE-RUN ENGINE" to evaluate policy rules.`;
|
||||
|
||||
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">
|
||||
"{aiAssessmentText}"
|
||||
</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">
|
||||
{incident?.recoverySource || 'Policy Evaluation 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">
|
||||
{policyName}
|
||||
</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">
|
||||
{incident?.jurisdiction || 'N/A'}
|
||||
</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">
|
||||
{recoveryScore}
|
||||
</span>
|
||||
<span className="text-xl text-gray-400 font-semibold">/ 100</span>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-600 leading-relaxed">
|
||||
Automated evaluation score derived from configuration rules.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { User, Checks, X, ClockCounterClockwiseIcon, ArrowLeftIcon, ArrowsClockwiseIcon, AirplaneTiltIcon, SparkleIcon } from '@phosphor-icons/react';
|
||||
import { CustomButton, CustomTabs, CustomBackButton, CustomStatus, Skeleton, CustomAlertBanner } from '../../../components/custom';
|
||||
import SummaryTab from './SummaryTab';
|
||||
import CaseDetailsTab from './CaseDetailsTab';
|
||||
import RecoveryPlanTab from './RecoveryPlanTab';
|
||||
import AuditTrailTab from './AuditTrailTab';
|
||||
import { getRecoveryIncident, updateIncidentStatus, reRunPolicyEngine } 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 navigate = useNavigate();
|
||||
const [activeTab, setActiveTab] = useState('Summary');
|
||||
const [incident, setIncident] = useState<RecoveryIncident | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [updating, setUpdating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
||||
|
||||
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);
|
||||
setError(null);
|
||||
try {
|
||||
const updated = await updateIncidentStatus(id, newStatus);
|
||||
setIncident(updated);
|
||||
const code = updated?.recoveryCode || incident?.recoveryCode || id;
|
||||
navigate('/recovery', {
|
||||
state: {
|
||||
successMsg: `Incident ${code} status updated to ${newStatus} successfully.`
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to update incident status:", err);
|
||||
setError("Failed to update incident status. Please try again.");
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReRunEngine = async () => {
|
||||
if (!id || updating) return;
|
||||
setUpdating(true);
|
||||
setError(null);
|
||||
setSuccessMsg(null);
|
||||
try {
|
||||
const updated = await reRunPolicyEngine(id);
|
||||
setIncident(updated);
|
||||
setSuccessMsg("Policy engine re-evaluated successfully.");
|
||||
} catch (err) {
|
||||
console.error("Failed to re-run policy engine:", err);
|
||||
setError("Failed to re-run policy engine. Please try again.");
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const tabItems = [
|
||||
{
|
||||
id: 'Summary',
|
||||
label: 'Summary',
|
||||
content: <SummaryTab incident={incident} />
|
||||
},
|
||||
{
|
||||
id: 'Case Details',
|
||||
label: 'Case Details',
|
||||
content: <CaseDetailsTab incident={incident} />
|
||||
},
|
||||
{
|
||||
id: 'Recovery Plan',
|
||||
label: 'Recovery Plan',
|
||||
content: <RecoveryPlanTab incident={incident} />
|
||||
},
|
||||
{
|
||||
id: 'Audit Trail',
|
||||
label: 'Audit Trail',
|
||||
content: <AuditTrailTab incident={incident} />
|
||||
}
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="w-full flex flex-col h-full relative space-y-6">
|
||||
{/* Header Skeleton */}
|
||||
<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">
|
||||
<Skeleton width={32} height={32} variant="circular" />
|
||||
<Skeleton width={150} height={28} />
|
||||
<Skeleton width={80} height={24} className="rounded-full" />
|
||||
</div>
|
||||
<div className="flex items-center gap-3 ml-8">
|
||||
<Skeleton width={120} height={16} />
|
||||
<Skeleton width={180} height={16} />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton width={160} height={42} className="rounded-lg" />
|
||||
</div>
|
||||
|
||||
{/* Tabs Row Skeleton */}
|
||||
<div className="flex items-center justify-between py-6">
|
||||
<div className="flex-1 flex gap-6">
|
||||
<Skeleton width={80} height={24} />
|
||||
<Skeleton width={100} height={24} />
|
||||
<Skeleton width={120} height={24} />
|
||||
<Skeleton width={90} height={24} />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton width={140} height={42} className="rounded-lg" />
|
||||
<Skeleton width={160} height={42} className="rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content Area Skeleton */}
|
||||
<div className="flex gap-6 items-start pb-24">
|
||||
<div className="flex-1 bg-white rounded-[24px] p-6 border border-gray-100 h-[400px]">
|
||||
<div className="space-y-4">
|
||||
<Skeleton width="40%" height={24} />
|
||||
<Skeleton width="100%" height={100} />
|
||||
<Skeleton width="100%" height={100} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-[360px] flex-shrink-0 bg-white rounded-[16px] border border-gray-100 p-6 h-[400px]">
|
||||
<div className="space-y-6">
|
||||
<Skeleton width={180} height={20} />
|
||||
<Skeleton width="100%" height={60} />
|
||||
<Skeleton width="100%" height={60} />
|
||||
<Skeleton width="100%" height={40} className="mt-8" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const currentStatus = incident?.status || "Pending";
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col h-full relative">
|
||||
{error && (
|
||||
<CustomAlertBanner
|
||||
message={error}
|
||||
type="error"
|
||||
onClose={() => setError(null)}
|
||||
/>
|
||||
)}
|
||||
{successMsg && (
|
||||
<CustomAlertBanner
|
||||
message={successMsg}
|
||||
type="success"
|
||||
onClose={() => setSuccessMsg(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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
|
||||
disabled={updating}
|
||||
onClick={handleReRunEngine}
|
||||
leftIcon={<ArrowsClockwiseIcon size={18} weight="bold" className={updating ? "animate-spin" : ""} />}
|
||||
className="!bg-[#1B9869] hover:!bg-[#14704E] !text-white !font-semibold !rounded-lg !px-5 !py-2.5 disabled:opacity-50"
|
||||
>
|
||||
{updating ? "EVALUATING..." : "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} weight="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]">{incident?.evaluation?.recoveryScore || 84}%</span>
|
||||
</div>
|
||||
<div className="h-2 bg-white rounded-full overflow-hidden border border-gray-100">
|
||||
<div className="h-full bg-[#1B9869] w-[84%] rounded-full opacity-60"></div>
|
||||
</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 automated recovery under "{incident?.evaluation?.policyName || 'Standard Policy'}".
|
||||
</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-[-16px] -mx-4 px-4 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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { ApiClient } from '../api/ApiClient';
|
||||
|
||||
export interface PermissionItem {
|
||||
id: string;
|
||||
module: string;
|
||||
action: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
groupName: string;
|
||||
}
|
||||
|
||||
export interface RoleItem {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string;
|
||||
isSystem: boolean;
|
||||
permissionCount: number;
|
||||
userCount: number;
|
||||
permissions?: PermissionItem[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface PermissionsResponse {
|
||||
all: PermissionItem[];
|
||||
grouped: Record<string, PermissionItem[]>;
|
||||
}
|
||||
|
||||
export interface CreateRolePayload {
|
||||
name: string;
|
||||
description?: string;
|
||||
permissionIds: string[];
|
||||
}
|
||||
|
||||
export interface UpdateRolePayload {
|
||||
name?: string;
|
||||
description?: string;
|
||||
permissionIds?: string[];
|
||||
}
|
||||
|
||||
export const RolesApi = {
|
||||
getRoles: async (): Promise<RoleItem[]> => {
|
||||
return ApiClient.get<any, RoleItem[]>('/roles');
|
||||
},
|
||||
|
||||
getRoleById: async (id: string): Promise<RoleItem> => {
|
||||
return ApiClient.get<any, RoleItem>(`/roles/${id}`);
|
||||
},
|
||||
|
||||
getPermissions: async (): Promise<PermissionsResponse> => {
|
||||
return ApiClient.get<any, PermissionsResponse>('/permissions');
|
||||
},
|
||||
|
||||
createRole: async (payload: CreateRolePayload): Promise<RoleItem> => {
|
||||
return ApiClient.post<any, RoleItem>('/roles', payload);
|
||||
},
|
||||
|
||||
updateRole: async (id: string, payload: UpdateRolePayload): Promise<RoleItem> => {
|
||||
return ApiClient.patch<any, RoleItem>(`/roles/${id}`, payload);
|
||||
},
|
||||
|
||||
deleteRole: async (id: string): Promise<{ success: boolean; message: string }> => {
|
||||
return ApiClient.delete<any, { success: boolean; message: string }>(`/roles/${id}`);
|
||||
},
|
||||
};
|
||||
|
||||
export default RolesApi;
|
||||
@@ -0,0 +1,456 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { RolesApi, type PermissionItem, type RoleItem } from '../RolesApi';
|
||||
import {
|
||||
CustomInput,
|
||||
CustomButton,
|
||||
CustomCheckBox,
|
||||
CustomTextArea,
|
||||
CustomAlertBanner,
|
||||
CustomSuccessModal,
|
||||
Skeleton,
|
||||
} from '../../../components/custom';
|
||||
import {
|
||||
ShieldCheckIcon,
|
||||
ArrowLeftIcon,
|
||||
LockKeyIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
|
||||
interface AddRolesProps {
|
||||
roleToEdit?: RoleItem | null;
|
||||
onBack: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
export default function AddRoles({ roleToEdit, onBack, onSaved }: AddRolesProps) {
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [selectedPermissionIds, setSelectedPermissionIds] = useState<Set<string>>(new Set());
|
||||
const [groupedPermissions, setGroupedPermissions] = useState<Record<string, PermissionItem[]>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [fetching, setFetching] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showSuccessModal, setShowSuccessModal] = useState(false);
|
||||
const [successTitle, setSuccessTitle] = useState('');
|
||||
|
||||
const isSystemRole = roleToEdit?.isSystem ?? false;
|
||||
|
||||
useEffect(() => {
|
||||
loadPermissions();
|
||||
}, [roleToEdit]);
|
||||
|
||||
const loadPermissions = async () => {
|
||||
setFetching(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await RolesApi.getPermissions();
|
||||
setGroupedPermissions(response.grouped || {});
|
||||
|
||||
if (roleToEdit) {
|
||||
setName(roleToEdit.name);
|
||||
setDescription(roleToEdit.description || '');
|
||||
|
||||
// Fetch full role details to get its permissions
|
||||
const detailedRole = await RolesApi.getRoleById(roleToEdit.id);
|
||||
const existingIds = (detailedRole.permissions || []).map((p) => p.id);
|
||||
setSelectedPermissionIds(new Set(existingIds));
|
||||
} else {
|
||||
setName('');
|
||||
setDescription('');
|
||||
setSelectedPermissionIds(new Set());
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.message || 'Failed to load permissions list.');
|
||||
} finally {
|
||||
setFetching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const togglePermission = (id: string) => {
|
||||
if (isSystemRole) return;
|
||||
setSelectedPermissionIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleGroup = (_groupName: string, perms: PermissionItem[]) => {
|
||||
if (isSystemRole) return;
|
||||
const groupIds = perms.map((p) => p.id);
|
||||
const allSelected = groupIds.every((id) => selectedPermissionIds.has(id));
|
||||
|
||||
setSelectedPermissionIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (allSelected) {
|
||||
groupIds.forEach((id) => next.delete(id));
|
||||
} else {
|
||||
groupIds.forEach((id) => next.add(id));
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const selectAll = () => {
|
||||
if (isSystemRole) return;
|
||||
const allIds = Object.values(groupedPermissions).flatMap((perms) => perms.map((p) => p.id));
|
||||
setSelectedPermissionIds(new Set(allIds));
|
||||
};
|
||||
|
||||
const deselectAll = () => {
|
||||
if (isSystemRole) return;
|
||||
setSelectedPermissionIds(new Set());
|
||||
};
|
||||
|
||||
const handleSubmit = async (e?: React.FormEvent) => {
|
||||
if (e) e.preventDefault();
|
||||
if (!name.trim()) {
|
||||
setError('Role name is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedPermissionIds.size === 0) {
|
||||
setError('Please select at least one permission for this role.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const permissionIds = Array.from(selectedPermissionIds);
|
||||
if (roleToEdit) {
|
||||
await RolesApi.updateRole(roleToEdit.id, {
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
permissionIds,
|
||||
});
|
||||
setSuccessTitle('Role Updated Successfully.');
|
||||
} else {
|
||||
await RolesApi.createRole({
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
permissionIds,
|
||||
});
|
||||
setSuccessTitle('Role Created Successfully.');
|
||||
}
|
||||
setShowSuccessModal(true);
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.message || 'Failed to save role.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const CardHeader = ({ icon: Icon, title }: { icon: any; title: string }) => (
|
||||
<div className="flex items-center gap-2 mb-5">
|
||||
<div className="w-8 h-8 rounded-lg bg-[#E8F3EF] flex items-center justify-center text-[#1E7D5C]">
|
||||
<Icon size={18} />
|
||||
</div>
|
||||
<h2 className="text-base font-bold text-[#0F172B]">{title}</h2>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (fetching) {
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-white min-h-screen">
|
||||
{/* ─── Header Skeleton ────────────────────────────────────────────── */}
|
||||
<div className="flex items-center justify-between py-4 bg-white border-b border-gray-200">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="mt-1 p-1">
|
||||
<Skeleton variant="circular" className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Skeleton className="w-48 h-7" />
|
||||
<Skeleton className="w-32 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── Body Content Skeleton ──────────────────────────────────────── */}
|
||||
<div className="flex-1 overflow-y-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden py-6 pb-32">
|
||||
<div className="w-full flex flex-col gap-6">
|
||||
{/* Role Information Card Skeleton */}
|
||||
<div className="bg-[#FAFAFA] rounded-[16px] p-6 border border-gray-100">
|
||||
<div className="flex items-center gap-2 mb-5">
|
||||
<Skeleton className="w-8 h-8 rounded-lg" />
|
||||
<Skeleton className="w-40 h-5" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="w-24 h-4" />
|
||||
<Skeleton className="w-full h-11" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="w-24 h-4" />
|
||||
<Skeleton className="w-full h-11" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="w-24 h-4" />
|
||||
<Skeleton className="w-full h-24" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Permissions Matrix Card Skeleton */}
|
||||
<div className="bg-[#FAFAFA] rounded-[16px] p-6 border border-gray-100">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="w-8 h-8 rounded-lg" />
|
||||
<Skeleton className="w-48 h-5" />
|
||||
</div>
|
||||
<Skeleton className="w-32 h-9 rounded-lg" />
|
||||
</div>
|
||||
<div className="border border-gray-100 rounded-[12px] p-6 bg-white space-y-4">
|
||||
<Skeleton className="w-48 h-6" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<Skeleton className="w-full h-20 rounded-lg" />
|
||||
<Skeleton className="w-full h-20 rounded-lg" />
|
||||
<Skeleton className="w-full h-20 rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-white min-h-screen">
|
||||
{/* ─── Header ────────────────────────────────────────────────────── */}
|
||||
<div className="flex items-center justify-between py-4 bg-white border-b border-gray-200">
|
||||
<div className="flex items-start gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="mt-1 p-1 hover:bg-gray-100 rounded-full transition-colors text-gray-500"
|
||||
>
|
||||
<ArrowLeftIcon size={20} />
|
||||
</button>
|
||||
<div className="flex flex-col">
|
||||
<h1 className="text-xl font-bold text-[#0F172B]">
|
||||
{roleToEdit ? (isSystemRole ? 'View System Role' : 'Edit Role') : 'Create Custom Role'}
|
||||
</h1>
|
||||
<span className="text-[13px] text-gray-500">Global Framework Registry</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── Body Content ──────────────────────────────────────────────── */}
|
||||
<div className="flex-1 overflow-y-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden py-6 pb-32">
|
||||
<div className="w-full flex flex-col gap-6">
|
||||
{/* Error Alert */}
|
||||
{error && (
|
||||
<CustomAlertBanner
|
||||
message={error}
|
||||
type="error"
|
||||
onClose={() => setError(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Role Information Card */}
|
||||
<div className="bg-[#FAFAFA] rounded-[16px] p-6 border border-gray-100">
|
||||
<CardHeader icon={LockKeyIcon} title="Role Information" />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<CustomInput
|
||||
label="Role Name"
|
||||
required
|
||||
disabled={isSystemRole}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Flight Disruption Lead"
|
||||
className="!h-11"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-[13px] font-semibold text-gray-700">Role Type</label>
|
||||
<div className="flex items-center gap-6 h-11">
|
||||
<span
|
||||
className={`inline-flex items-center px-3 py-1.5 rounded-full text-xs font-semibold ${
|
||||
isSystemRole
|
||||
? 'bg-amber-50 text-amber-700 border border-amber-200'
|
||||
: 'bg-emerald-50 text-emerald-700 border border-emerald-200'
|
||||
}`}
|
||||
>
|
||||
{isSystemRole ? 'System Role (Immutable)' : 'Custom Role'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<CustomTextArea
|
||||
label="Description"
|
||||
disabled={isSystemRole}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="e.g. Manages passenger goodwill offers and compensation evaluations"
|
||||
className="!h-24 resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Granular Permissions Matrix Card */}
|
||||
<div className="bg-[#FAFAFA] rounded-[16px] p-6 border border-gray-100">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<CardHeader icon={ShieldCheckIcon} title="Granular Permissions Matrix" />
|
||||
<span className="text-xs font-semibold text-[#1E7D5C] bg-[#E8F3EF] px-2.5 py-1 rounded-full border border-emerald-100 mb-5">
|
||||
{selectedPermissionIds.size} Selected
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!isSystemRole && (
|
||||
<div className="flex items-center gap-3 mb-5">
|
||||
<CustomButton
|
||||
variant="outlined"
|
||||
size="sm"
|
||||
onClick={selectAll}
|
||||
className="!border-[#1E7D5C] !text-[#1E7D5C] hover:!bg-emerald-50/50 font-semibold !h-9 text-xs"
|
||||
>
|
||||
Select All
|
||||
</CustomButton>
|
||||
<CustomButton
|
||||
variant="outlined"
|
||||
size="sm"
|
||||
onClick={deselectAll}
|
||||
className="!border-gray-300 !text-gray-600 hover:!bg-gray-50 font-semibold !h-9 text-xs"
|
||||
>
|
||||
Deselect All
|
||||
</CustomButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
{Object.entries(groupedPermissions).map(([groupName, perms]) => {
|
||||
const groupIds = perms.map((p) => p.id);
|
||||
const allGroupSelected = groupIds.length > 0 && groupIds.every((id) => selectedPermissionIds.has(id));
|
||||
const selectedInGroupCount = perms.filter((p) => selectedPermissionIds.has(p.id)).length;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={groupName}
|
||||
className="border border-gray-100 rounded-[12px] p-6 bg-white relative transition-all shadow-sm"
|
||||
>
|
||||
{/* Module Header */}
|
||||
<div className="flex items-center justify-between pb-4 mb-4 border-b border-gray-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<CustomCheckBox
|
||||
disabled={isSystemRole}
|
||||
checked={allGroupSelected}
|
||||
onChange={() => toggleGroup(groupName, perms)}
|
||||
/>
|
||||
<h3 className="text-base font-bold text-[#0F172B]">{groupName}</h3>
|
||||
</div>
|
||||
<span className="text-[12px] font-semibold text-[#1E7D5C] bg-[#E8F3EF] px-3 py-1 rounded-full border border-emerald-100">
|
||||
{selectedInGroupCount} / {perms.length} selected
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Permission Cards Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{perms.map((perm) => {
|
||||
const isSelected = selectedPermissionIds.has(perm.id);
|
||||
return (
|
||||
<div
|
||||
key={perm.id}
|
||||
onClick={() => togglePermission(perm.id)}
|
||||
className={`p-3.5 rounded-[10px] border transition-all cursor-pointer select-none flex items-start gap-3 ${
|
||||
isSelected
|
||||
? 'bg-emerald-50/80 border-emerald-300 shadow-sm'
|
||||
: 'bg-white border-gray-200 hover:border-gray-300 hover:bg-gray-50/50'
|
||||
} ${isSystemRole ? 'opacity-80 cursor-default' : ''}`}
|
||||
>
|
||||
<div className="mt-0.5">
|
||||
<CustomCheckBox
|
||||
checked={isSelected}
|
||||
disabled={isSystemRole}
|
||||
onChange={() => togglePermission(perm.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 overflow-hidden flex-1">
|
||||
<span className="text-xs font-bold text-[#0F172B] block truncate">
|
||||
{perm.name}
|
||||
</span>
|
||||
<span className="text-[11px] font-mono text-[#6C766D] bg-gray-100 px-1.5 py-0.5 rounded inline-block truncate max-w-full">
|
||||
{perm.code}
|
||||
</span>
|
||||
{perm.description && (
|
||||
<p className="text-[11px] text-[#6C766D] line-clamp-2 mt-1 leading-tight">
|
||||
{perm.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── Sticky Footer ─────────────────────────────────────────────── */}
|
||||
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 px-8 py-4 flex items-center justify-between z-10 shadow-[0_-4px_10px_rgba(0,0,0,0.02)]">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[14px] font-semibold text-gray-600">Permissions Selected:</span>
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-[#E8F3EF] text-[#1E7D5C]">
|
||||
{selectedPermissionIds.size} Active
|
||||
</span>
|
||||
{isSystemRole && (
|
||||
<span className="text-xs text-amber-600 bg-amber-50 px-2 py-0.5 rounded border border-amber-200 font-medium">
|
||||
System Role (Read-only)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<CustomButton
|
||||
variant="outlined"
|
||||
className="!border-[#1E7D5C] !text-[#1E7D5C] hover:!bg-gray-50 font-semibold px-6"
|
||||
onClick={onBack}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</CustomButton>
|
||||
{!isSystemRole && (
|
||||
<CustomButton
|
||||
variant="primary"
|
||||
className="!bg-[#1E7D5C] hover:!bg-[#17664B] font-semibold px-6 shadow-sm disabled:opacity-50"
|
||||
onClick={() => handleSubmit()}
|
||||
disabled={loading || !name.trim() || selectedPermissionIds.size === 0}
|
||||
loading={loading}
|
||||
>
|
||||
{loading ? 'Saving...' : roleToEdit ? 'Save Changes' : 'Create Role'}
|
||||
</CustomButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Success Modal */}
|
||||
<CustomSuccessModal
|
||||
isOpen={showSuccessModal}
|
||||
onClose={() => {
|
||||
setShowSuccessModal(false);
|
||||
onSaved();
|
||||
}}
|
||||
title={successTitle}
|
||||
label="ROLE NAME"
|
||||
cohortName={name}
|
||||
cohortDescription={description}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
import { useEffect, useState, useMemo, useCallback } from 'react';
|
||||
import { RolesApi, type RoleItem } from '../RolesApi';
|
||||
import {
|
||||
CustomTable,
|
||||
CustomInput,
|
||||
CustomButton,
|
||||
CustomAlertBanner,
|
||||
CustomConfirmationModal,
|
||||
CustomActionMenu,
|
||||
CustomActionItem,
|
||||
CustomCheckBox,
|
||||
Skeleton,
|
||||
} from '../../../components/custom';
|
||||
import type { Column } from '../../../components/custom/CustomTable';
|
||||
import {
|
||||
ShieldCheckIcon,
|
||||
PlusIcon,
|
||||
LockKeyIcon,
|
||||
UsersIcon,
|
||||
KeyIcon,
|
||||
PencilSimpleIcon,
|
||||
TrashIcon,
|
||||
MagnifyingGlassIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
import Can from '../../../components/common/Can';
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
interface RolesListProps {
|
||||
onAddRole: () => void;
|
||||
onEditRole: (role: RoleItem) => void;
|
||||
}
|
||||
|
||||
export default function RolesList({ onAddRole, onEditRole }: RolesListProps) {
|
||||
const [roles, setRoles] = useState<RoleItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
||||
|
||||
// Pagination & Search
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
// Row selection
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// Delete modal state
|
||||
const [deleteConfirmRole, setDeleteConfirmRole] = useState<RoleItem | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const loadRoles = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await RolesApi.getRoles();
|
||||
setRoles(data);
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.message || 'Failed to load roles list.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadRoles();
|
||||
}, [loadRoles]);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteConfirmRole) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await RolesApi.deleteRole(deleteConfirmRole.id);
|
||||
setSuccessMsg(`Role "${deleteConfirmRole.name}" was deleted successfully.`);
|
||||
setDeleteConfirmRole(null);
|
||||
loadRoles();
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.message || 'Failed to delete role.');
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Filter & Pagination
|
||||
const filteredRoles = useMemo(() => {
|
||||
const q = search.toLowerCase().trim();
|
||||
if (!q) return roles;
|
||||
return roles.filter(
|
||||
(r) =>
|
||||
r.name.toLowerCase().includes(q) ||
|
||||
r.slug.toLowerCase().includes(q) ||
|
||||
(r.description || '').toLowerCase().includes(q)
|
||||
);
|
||||
}, [roles, search]);
|
||||
|
||||
const totalItems = filteredRoles.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 paginatedRoles = useMemo(() => {
|
||||
const start = (currentPage - 1) * PAGE_SIZE;
|
||||
return filteredRoles.slice(start, start + PAGE_SIZE);
|
||||
}, [filteredRoles, currentPage]);
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
};
|
||||
|
||||
const handleSearchChange = (val: string) => {
|
||||
setSearch(val);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
// Row selection handlers
|
||||
const toggleSelectAll = () => {
|
||||
if (paginatedRoles.length > 0 && selectedIds.size === paginatedRoles.length) {
|
||||
setSelectedIds(new Set());
|
||||
} else {
|
||||
setSelectedIds(new Set(paginatedRoles.map((r) => r.id)));
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSelectOne = (id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Table columns definition
|
||||
const columns: Column<RoleItem>[] = [
|
||||
{
|
||||
header: (
|
||||
<CustomCheckBox
|
||||
checked={paginatedRoles.length > 0 && selectedIds.size === paginatedRoles.length}
|
||||
onChange={toggleSelectAll}
|
||||
/>
|
||||
),
|
||||
className: 'w-[40px] pr-0',
|
||||
accessor: (row) => (
|
||||
<CustomCheckBox
|
||||
checked={selectedIds.has(row.id)}
|
||||
onChange={() => toggleSelectOne(row.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: <HeaderLabel text="Role Name" />,
|
||||
accessor: (row) => (
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`w-9 h-9 rounded-[10px] flex items-center justify-center font-bold text-sm shrink-0 ${
|
||||
row.isSystem
|
||||
? 'bg-amber-100 text-amber-800'
|
||||
: 'bg-emerald-100 text-[#1B9869]'
|
||||
}`}
|
||||
>
|
||||
<ShieldCheckIcon size={20} weight="bold" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] font-semibold text-[#0F172B] leading-[18px]">
|
||||
{row.name}
|
||||
</span>
|
||||
{row.isSystem && (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-semibold px-2 py-0.5 rounded-full bg-amber-50 text-amber-700 border border-amber-200 shrink-0">
|
||||
<LockKeyIcon size={11} weight="bold" />
|
||||
System Default
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[11px] font-mono text-[#6C766D] block mt-0.5">
|
||||
{row.slug}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: <HeaderLabel text="Description" />,
|
||||
accessor: (row) => (
|
||||
<p className="text-[12px] font-medium text-[#6C766D] line-clamp-2 max-w-sm leading-relaxed">
|
||||
{row.description || 'No description configured for this operational role.'}
|
||||
</p>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: <HeaderLabel text="Permissions" />,
|
||||
accessor: (row) => (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-[#E8F3EF] text-[#1E7D5C] border border-emerald-100">
|
||||
<KeyIcon size={13} weight="bold" />
|
||||
{row.permissionCount} rules
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: <HeaderLabel text="Assigned Users" />,
|
||||
accessor: (row) => (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-blue-50 text-blue-700 border border-blue-100">
|
||||
<UsersIcon size={13} weight="bold" />
|
||||
{row.userCount} users
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: <HeaderLabel text="Created At" />,
|
||||
accessor: (row) => (
|
||||
<span className="text-[12px] font-medium text-[#6C766D]">
|
||||
{row.createdAt ? formatDate(row.createdAt) : '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: <HeaderLabel text="Action" />,
|
||||
accessor: (row) => (
|
||||
<div className="flex items-center pl-2">
|
||||
<CustomActionMenu>
|
||||
<Can permission="roles:view">
|
||||
<CustomActionItem
|
||||
onClick={() => onEditRole(row)}
|
||||
icon={<PencilSimpleIcon size={16} className="text-yellow-500" />}
|
||||
>
|
||||
{row.isSystem ? 'View Permissions' : 'Edit Role'}
|
||||
</CustomActionItem>
|
||||
</Can>
|
||||
|
||||
{!row.isSystem && (
|
||||
<Can permission="roles:delete">
|
||||
<CustomActionItem
|
||||
variant="danger"
|
||||
icon={<TrashIcon size={16} className="text-red-500" />}
|
||||
onClick={() => setDeleteConfirmRole(row)}
|
||||
>
|
||||
Delete Role
|
||||
</CustomActionItem>
|
||||
</Can>
|
||||
)}
|
||||
</CustomActionMenu>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col gap-6">
|
||||
{error && (
|
||||
<CustomAlertBanner
|
||||
message={error}
|
||||
type="error"
|
||||
onClose={() => setError(null)}
|
||||
/>
|
||||
)}
|
||||
{successMsg && (
|
||||
<CustomAlertBanner
|
||||
message={successMsg}
|
||||
type="success"
|
||||
onClose={() => setSuccessMsg(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Standardized Table Section */}
|
||||
<CustomTable<RoleItem>
|
||||
columns={columns}
|
||||
data={paginatedRoles}
|
||||
leftHeaderActions={
|
||||
<div className="w-[320px]">
|
||||
<CustomInput
|
||||
placeholder="Search roles by name, slug..."
|
||||
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={
|
||||
<Can permission="roles:create">
|
||||
<CustomButton
|
||||
variant="primary"
|
||||
size="md"
|
||||
leftIcon={<PlusIcon size={16} />}
|
||||
className="!rounded-[10px] !gap-[8px] !h-[40px] !bg-[#1B9869] hover:!bg-[#14704E]"
|
||||
onClick={onAddRole}
|
||||
>
|
||||
Create Role
|
||||
</CustomButton>
|
||||
</Can>
|
||||
}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
totalItems={totalItems}
|
||||
startIndex={startIndex}
|
||||
endIndex={endIndex}
|
||||
onPageChange={handlePageChange}
|
||||
itemName="Roles"
|
||||
rowClassName={() => 'bg-white border-b border-gray-100 hover:bg-gray-50/60'}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
<CustomConfirmationModal
|
||||
isOpen={!!deleteConfirmRole}
|
||||
onClose={() => setDeleteConfirmRole(null)}
|
||||
onConfirm={handleDelete}
|
||||
title="Delete Role"
|
||||
description={`Are you sure you want to permanently delete the role "${deleteConfirmRole?.name}"? ${
|
||||
deleteConfirmRole && deleteConfirmRole.userCount > 0
|
||||
? `Warning: ${deleteConfirmRole.userCount} user(s) are currently assigned to this role.`
|
||||
: ''
|
||||
}`}
|
||||
confirmText={deleting ? 'Deleting...' : 'Confirm Delete'}
|
||||
variant="danger"
|
||||
isLoading={deleting}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useState } from 'react';
|
||||
import RolesList from './components/RolesList';
|
||||
import AddRoles from './components/AddRoles';
|
||||
import type { RoleItem } from './RolesApi';
|
||||
|
||||
export default function RolesManagementPage() {
|
||||
const [view, setView] = useState<'list' | 'add' | 'edit'>('list');
|
||||
const [selectedRole, setSelectedRole] = useState<RoleItem | null>(null);
|
||||
|
||||
const handleAddRole = () => {
|
||||
setSelectedRole(null);
|
||||
setView('add');
|
||||
};
|
||||
|
||||
const handleEditRole = (role: RoleItem) => {
|
||||
setSelectedRole(role);
|
||||
setView('edit');
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setSelectedRole(null);
|
||||
setView('list');
|
||||
};
|
||||
|
||||
const handleSaved = () => {
|
||||
setSelectedRole(null);
|
||||
setView('list');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-6">
|
||||
{view === 'list' && (
|
||||
<RolesList onAddRole={handleAddRole} onEditRole={handleEditRole} />
|
||||
)}
|
||||
{(view === 'add' || view === 'edit') && (
|
||||
<AddRoles
|
||||
roleToEdit={selectedRole}
|
||||
onBack={handleBack}
|
||||
onSaved={handleSaved}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ApiClient } from '../api/ApiClient';
|
||||
|
||||
// ─── Evaluation Engine & Simulation API ──────────────────────────────────────────
|
||||
|
||||
export function evaluateIncidentPolicy(input: any): Promise<any> {
|
||||
return ApiClient.post<any, any>('/policy-engine/evaluate', input);
|
||||
}
|
||||
|
||||
export function evaluateBatchSimulation(inputs: any[]): Promise<any[]> {
|
||||
return ApiClient.post<any, any[]>('/policy-engine/evaluate-batch', inputs);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
export interface SimulatedPassengerInput {
|
||||
id: string;
|
||||
name: string;
|
||||
pnr: string;
|
||||
tier: string;
|
||||
cabinClass?: string;
|
||||
originalCabin?: string;
|
||||
actualCabin?: string;
|
||||
passengerType?: string;
|
||||
nationality?: string;
|
||||
specialAssistance?: string;
|
||||
bookingRef?: string;
|
||||
ancillaryItem?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface SimulatedPassenger extends SimulatedPassengerInput {
|
||||
eligibility: 'ELIGIBLE' | 'INELIGIBLE' | 'REVIEW';
|
||||
refund: string;
|
||||
comp: string;
|
||||
perks: string[];
|
||||
statusBadges?: string[];
|
||||
}
|
||||
|
||||
export interface SimulationFormState {
|
||||
category: 'Flight' | 'Travel' | 'Ancillary';
|
||||
scenario: string;
|
||||
scenarioSubType: string;
|
||||
jurisdiction: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
@@ -0,0 +1,820 @@
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import {
|
||||
MagnifyingGlassIcon,
|
||||
FloppyDiskIcon,
|
||||
CaretLeftIcon,
|
||||
CaretRightIcon,
|
||||
CheckCircleIcon,
|
||||
AirplaneTiltIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
CustomInput,
|
||||
CustomDropdown,
|
||||
CustomButton,
|
||||
CustomTabs,
|
||||
CustomSwitch,
|
||||
} from '../../../components/custom';
|
||||
import type { Option } from '../../../components/custom/CustomDropdown';
|
||||
import type { TabItem } from '../../../components/custom/CustomTabs';
|
||||
import type { SimulatedPassenger, SimulationFormState } from '../SimulationTypes';
|
||||
import { createRecoveryIncident } from '../../recoveryIncidents/RecoveryIncidentsApi';
|
||||
import { getCategoryValues } from '../../configuration/masterData/MasterDataApi';
|
||||
import { evaluateBatchSimulation } from '../SimulationApi';
|
||||
import Can from '../../../components/common/Can';
|
||||
import {
|
||||
CATEGORY_SIMULATION_CONFIGS,
|
||||
type CategorySimulationConfig,
|
||||
type FormFieldConfig,
|
||||
} from '../simulationConfig';
|
||||
|
||||
const DEFAULT_JURISDICTIONS: Option[] = [
|
||||
{ label: 'Global Policy', value: 'Global Policy' },
|
||||
{ label: 'European Union (EU261)', value: 'European Union' },
|
||||
{ label: 'United States (DOT)', value: 'United States' },
|
||||
{ label: 'United Kingdom (UK261)', value: 'United Kingdom' },
|
||||
{ label: 'India (DGCA)', value: 'India' },
|
||||
{ label: 'United Arab Emirates', value: 'United Arab Emirates' },
|
||||
{ label: 'Asia Pacific', value: 'Asia Pacific' },
|
||||
];
|
||||
|
||||
export default function SimulationTerminal() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Initial Form State (no default selections)
|
||||
const [formData, setFormData] = useState<SimulationFormState>({
|
||||
category: 'Flight',
|
||||
jurisdiction: '',
|
||||
...CATEGORY_SIMULATION_CONFIGS.Flight.defaultFormState,
|
||||
});
|
||||
|
||||
const [jurisdictionOptions, setJurisdictionOptions] = useState<Option[]>(DEFAULT_JURISDICTIONS);
|
||||
const [passengers, setPassengers] = useState<SimulatedPassenger[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isSimulating, setIsSimulating] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [savedSuccessCount, setSavedSuccessCount] = useState<number | null>(null);
|
||||
|
||||
// Category Tabs with Icons
|
||||
const categoryTabs: TabItem[] = useMemo(() => [
|
||||
{
|
||||
id: 'Flight',
|
||||
label: 'FLIGHT',
|
||||
content: null,
|
||||
},
|
||||
{
|
||||
id: 'Travel',
|
||||
label: 'TRAVEL',
|
||||
content: null,
|
||||
},
|
||||
{
|
||||
id: 'Ancillary',
|
||||
label: 'ANCILLARY',
|
||||
content: null,
|
||||
},
|
||||
], []);
|
||||
|
||||
// Active Category Config
|
||||
const activeCategory = (formData.category as 'Flight' | 'Travel' | 'Ancillary') || 'Flight';
|
||||
const categoryConfig: CategorySimulationConfig =
|
||||
CATEGORY_SIMULATION_CONFIGS[activeCategory] || CATEGORY_SIMULATION_CONFIGS.Flight;
|
||||
|
||||
// Dynamically derived sub-type options based on selected scenario
|
||||
const subTypeOptions: Option[] = useMemo(() => {
|
||||
return categoryConfig.scenarioSubTypes[formData.scenario] || [];
|
||||
}, [categoryConfig, formData.scenario]);
|
||||
|
||||
// Load Master Data Jurisdictions
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
getCategoryValues('jurisdiction')
|
||||
.then((items) => {
|
||||
if (!isMounted) return;
|
||||
if (Array.isArray(items) && items.length > 0) {
|
||||
const activeOptions: Option[] = items
|
||||
.filter((m) => m.isActive !== false)
|
||||
.map((m) => {
|
||||
let label = String(m.label || m.name || m.value || '');
|
||||
if (label.toLowerCase() === 'global' || label.toLowerCase() === 'global policy') {
|
||||
label = 'Global Policy';
|
||||
}
|
||||
return {
|
||||
label,
|
||||
value: label,
|
||||
};
|
||||
});
|
||||
if (activeOptions.length > 0) {
|
||||
const merged = [...activeOptions];
|
||||
if (!merged.some((o) => o.value === 'Global Policy')) {
|
||||
merged.unshift({ label: 'Global Policy', value: 'Global Policy' });
|
||||
}
|
||||
const unique = merged.filter(
|
||||
(opt, idx, arr) => arr.findIndex((x) => x.value === opt.value) === idx
|
||||
);
|
||||
setJurisdictionOptions(unique);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to load master data jurisdictions:', err);
|
||||
});
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Pagination State
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const pageSize = 4;
|
||||
|
||||
const handleInputChange = (field: string, value: any) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
// Handler for changing Scenario (do not set any default sub-type)
|
||||
const handleScenarioChange = (newScenario: string) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
scenario: newScenario,
|
||||
scenarioSubType: '',
|
||||
}));
|
||||
};
|
||||
|
||||
// Switch Strategic Category Tab (do not set any default scenario or fields)
|
||||
const handleCategoryChange = (newCategory: 'Flight' | 'Travel' | 'Ancillary') => {
|
||||
const newConfig = CATEGORY_SIMULATION_CONFIGS[newCategory] || CATEGORY_SIMULATION_CONFIGS.Flight;
|
||||
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
category: newCategory,
|
||||
jurisdiction: prev.jurisdiction || '',
|
||||
...newConfig.defaultFormState,
|
||||
scenario: '',
|
||||
scenarioSubType: '',
|
||||
}));
|
||||
setPassengers([]);
|
||||
setSearchQuery('');
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
// Run Simulation Handler with Pure Policy-Engine Evaluation
|
||||
const handleRunSimulation = async () => {
|
||||
setIsSimulating(true);
|
||||
try {
|
||||
const passengerPool = categoryConfig.passengerPool;
|
||||
const delay = parseInt(formData.arrDelay || formData.deprivationHours || '240', 10) || 240;
|
||||
|
||||
// Build payload for each simulated passenger in the category pool
|
||||
const evaluationPayloads = passengerPool.map((p) => ({
|
||||
recoveryCode: `SIM-${p.id}`,
|
||||
passengerName: p.name,
|
||||
pnr: p.pnr,
|
||||
loyaltyTier: p.tier,
|
||||
passengerType: p.passengerType,
|
||||
nationality: p.nationality,
|
||||
cabinClass: p.cabinClass,
|
||||
originalCabin: p.originalCabin,
|
||||
actualCabin: p.actualCabin,
|
||||
flightNumber: formData.flightNumber || formData.bookingRef || formData.receiptId || 'SIM-101',
|
||||
flightRoute:
|
||||
formData.origin && formData.destination
|
||||
? `${formData.origin} → ${formData.destination}`
|
||||
: formData.location || 'Terminal Hub',
|
||||
origin: formData.origin || formData.location || 'FRA',
|
||||
destination: formData.destination || 'JFK',
|
||||
date: new Date().toISOString(),
|
||||
category: formData.category,
|
||||
scenario: formData.scenarioSubType || formData.scenario,
|
||||
scenarioType: formData.scenario,
|
||||
scenarioSubType: formData.scenarioSubType,
|
||||
jurisdiction: formData.jurisdiction || 'Global Policy',
|
||||
delayDuration: delay,
|
||||
ancillaryType: formData.scenario || p.ancillaryItem,
|
||||
ancillaryPurchased: formData.scenario || p.ancillaryItem,
|
||||
ancillaryDelivered: formData.delivered === true || formData.delivered === 'true',
|
||||
serviceValue: Number(formData.serviceCost || 25),
|
||||
serviceCost: Number(formData.serviceCost || 25),
|
||||
}));
|
||||
|
||||
// Call Policy & Cohort Engine backend
|
||||
const results = await evaluateBatchSimulation(evaluationPayloads);
|
||||
|
||||
const simulatedPassengers: SimulatedPassenger[] = passengerPool.map((p, index): SimulatedPassenger => {
|
||||
const res = Array.isArray(results) ? results[index] : null;
|
||||
|
||||
let computedComp = '--';
|
||||
let computedRefund = '--';
|
||||
const perksList: string[] = [];
|
||||
let eligibility: 'ELIGIBLE' | 'INELIGIBLE' = 'INELIGIBLE';
|
||||
|
||||
if (res) {
|
||||
const isMatched = res.decision === 'MATCHED';
|
||||
eligibility = isMatched ? 'ELIGIBLE' : 'INELIGIBLE';
|
||||
|
||||
const rawActions = Array.isArray(res.actions) ? res.actions : [];
|
||||
const normalizedActions = Array.isArray(res.normalizedActions) ? res.normalizedActions : [];
|
||||
|
||||
// Refund Action resolved by Policy Engine
|
||||
const refundAction =
|
||||
rawActions.find(
|
||||
(a: any) =>
|
||||
(a.category && /refund/i.test(a.category)) ||
|
||||
(a.actionTypeCode && /REFUND/i.test(a.actionTypeCode)) ||
|
||||
(a.actionName && /refund/i.test(a.actionName))
|
||||
) ||
|
||||
normalizedActions.find(
|
||||
(a: any) =>
|
||||
(a.category && /refund/i.test(a.category)) ||
|
||||
(a.actionType && /refund/i.test(a.actionType))
|
||||
);
|
||||
|
||||
if (refundAction) {
|
||||
const refundAmt =
|
||||
refundAction.amount ||
|
||||
refundAction.configuration?.['Refund Amount'] ||
|
||||
refundAction.configuration?.['Amount'] ||
|
||||
'';
|
||||
const refundCurr = refundAction.currency || refundAction.configuration?.['Currency'] || 'USD';
|
||||
computedRefund = refundAmt ? `${refundAmt} ${refundCurr}`.trim() : 'Applied';
|
||||
}
|
||||
|
||||
// Compensation Action resolved by Policy Engine
|
||||
const compAction =
|
||||
rawActions.find(
|
||||
(a: any) =>
|
||||
(a.category && /compensation|payout/i.test(a.category)) ||
|
||||
(a.actionTypeCode && /COMP|PAYOUT/i.test(a.actionTypeCode)) ||
|
||||
(a.actionName && /comp|cash|payout/i.test(a.actionName))
|
||||
) ||
|
||||
normalizedActions.find(
|
||||
(a: any) =>
|
||||
(a.category && /compensation|payout/i.test(a.category)) ||
|
||||
(a.actionType && /comp|cash|payout/i.test(a.actionType))
|
||||
);
|
||||
|
||||
if (compAction) {
|
||||
const amt =
|
||||
compAction.amount ||
|
||||
compAction.configuration?.['Compensation Amount'] ||
|
||||
compAction.configuration?.['Amount'] ||
|
||||
'';
|
||||
const curr = compAction.currency || compAction.configuration?.['Currency'] || 'EUR';
|
||||
computedComp = amt ? `${amt} ${curr}`.trim() : 'Applied';
|
||||
}
|
||||
|
||||
// Perks and actions resolved by Policy Engine
|
||||
rawActions.forEach((act: any) => {
|
||||
let actionName = act.actionName || act.normalizedAction?.title;
|
||||
if (!actionName && act.actionTypeCode) {
|
||||
actionName = act.actionTypeCode
|
||||
.replace(/_/g, ' ')
|
||||
.toLowerCase()
|
||||
.replace(/\b\w/g, (c: string) => c.toUpperCase());
|
||||
}
|
||||
if (actionName && !perksList.includes(actionName)) {
|
||||
perksList.push(actionName);
|
||||
}
|
||||
});
|
||||
|
||||
if (perksList.length === 0) {
|
||||
normalizedActions.forEach((act: any) => {
|
||||
const actionTitle = act.title || act.actionType || act.category;
|
||||
if (actionTitle && !perksList.includes(actionTitle)) {
|
||||
perksList.push(actionTitle);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (isMatched && perksList.length === 0) {
|
||||
if (computedComp !== '--') perksList.push(`Compensation (${computedComp})`);
|
||||
if (computedRefund !== '--') perksList.push(`Refund (${computedRefund})`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...p,
|
||||
refund: computedRefund,
|
||||
comp: computedComp,
|
||||
perks: eligibility === 'ELIGIBLE' ? perksList : [],
|
||||
statusBadges: perksList,
|
||||
eligibility,
|
||||
};
|
||||
});
|
||||
|
||||
setPassengers(simulatedPassengers);
|
||||
setCurrentPage(1);
|
||||
} catch (err) {
|
||||
console.error('Failed to run live policy evaluation:', err);
|
||||
const simulatedPassengers: SimulatedPassenger[] = categoryConfig.passengerPool.map((p): SimulatedPassenger => ({
|
||||
...p,
|
||||
refund: '--',
|
||||
comp: '--',
|
||||
perks: [],
|
||||
statusBadges: [],
|
||||
eligibility: 'INELIGIBLE',
|
||||
}));
|
||||
setPassengers(simulatedPassengers);
|
||||
setCurrentPage(1);
|
||||
} finally {
|
||||
setIsSimulating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Save to Recovery Database
|
||||
const handleSaveAsRecovery = async () => {
|
||||
try {
|
||||
setIsSaving(true);
|
||||
const promises = passengers.map((p) => {
|
||||
const payload = {
|
||||
recoveryCode: `REC-${Math.floor(100000 + Math.random() * 900000)}`,
|
||||
passengerName: p.name,
|
||||
pnr: p.pnr,
|
||||
loyaltyTier: p.tier,
|
||||
passengerType: p.passengerType,
|
||||
nationality: p.nationality,
|
||||
cabinClass: p.cabinClass,
|
||||
originalCabin: p.originalCabin,
|
||||
actualCabin: p.actualCabin,
|
||||
flightNumber: formData.flightNumber || formData.bookingRef || formData.receiptId || 'REC-101',
|
||||
flightRoute:
|
||||
formData.origin && formData.destination
|
||||
? `${formData.origin} → ${formData.destination}`
|
||||
: formData.location || 'Terminal Hub',
|
||||
origin: formData.origin || formData.location || 'FRA',
|
||||
destination: formData.destination || 'JFK',
|
||||
date: new Date().toISOString(),
|
||||
category: formData.category === 'Flight' ? 'Flight Ops' : formData.category,
|
||||
scenario: formData.scenarioSubType || formData.scenario,
|
||||
jurisdiction: formData.jurisdiction || 'Global Policy',
|
||||
delayDuration: parseInt(formData.arrDelay || formData.deprivationHours || '240', 10) || 240,
|
||||
status: 'Pending',
|
||||
value: p.comp !== '--' ? p.comp : p.refund !== '--' ? p.refund : `${formData.serviceCost || 25} USD`,
|
||||
isPerksClaimed: false,
|
||||
recoverySource: 'Simulation Engine',
|
||||
};
|
||||
return createRecoveryIncident(payload);
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
setSavedSuccessCount(passengers.length);
|
||||
} catch (err) {
|
||||
console.error('Failed to save simulated cases:', err);
|
||||
alert('Unable to save simulation manifest. Please try again.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Filter passengers based on search
|
||||
const filteredPassengers = useMemo(() => {
|
||||
if (!searchQuery.trim()) return passengers;
|
||||
const q = searchQuery.toLowerCase();
|
||||
return passengers.filter(
|
||||
(p) =>
|
||||
p.name.toLowerCase().includes(q) ||
|
||||
p.pnr.toLowerCase().includes(q) ||
|
||||
p.tier.toLowerCase().includes(q) ||
|
||||
(p.bookingRef && p.bookingRef.toLowerCase().includes(q)) ||
|
||||
(p.ancillaryItem && p.ancillaryItem.toLowerCase().includes(q))
|
||||
);
|
||||
}, [passengers, searchQuery]);
|
||||
|
||||
const totalPages = Math.ceil(filteredPassengers.length / pageSize) || 1;
|
||||
const paginatedPassengers = filteredPassengers.slice(
|
||||
(currentPage - 1) * pageSize,
|
||||
currentPage * pageSize
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col gap-6 pb-4">
|
||||
{/* 2-Column Grid Layout */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 items-start">
|
||||
{/* Left Column: Assessment Setup */}
|
||||
<div className="lg:col-span-5 xl:col-span-5 bg-white rounded-2xl p-6 border border-gray-100/90 shadow-[0_2px_10px_-4px_rgba(0,0,0,0.04)] flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="text-[17px] font-bold text-[#111827] tracking-tight">
|
||||
Assessment Setup
|
||||
</h2>
|
||||
<p className="text-xs text-gray-400 font-medium">
|
||||
Configure parameters for <span className="text-[#143D30] font-semibold">{categoryConfig.label}</span>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Strategic Category Tabs */}
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-700 uppercase tracking-wider mb-1.5">
|
||||
STRATEGIC CATEGORY
|
||||
</label>
|
||||
<CustomTabs
|
||||
tabs={categoryTabs}
|
||||
value={formData.category}
|
||||
onChange={(tabId) => handleCategoryChange(tabId as 'Flight' | 'Travel' | 'Ancillary')}
|
||||
tabListClassName="w-full !bg-[#F4F7F6] !p-1 !rounded-xl !border !border-gray-200/40 !shadow-none !gap-0"
|
||||
tabClassName="!flex-1 !py-2 !text-xs !font-semibold !rounded-lg !px-2 !shadow-none"
|
||||
activeTabClassName="!bg-[#143D30] !text-white !shadow-sm"
|
||||
contentClassName="!hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Form Fields using Custom Components */}
|
||||
<div className="flex flex-col gap-3.5">
|
||||
{/* Jurisdiction Dropdown */}
|
||||
<div>
|
||||
|
||||
<CustomDropdown
|
||||
label="JURISDICTION"
|
||||
options={jurisdictionOptions}
|
||||
value={formData.jurisdiction}
|
||||
onChange={(val) => handleInputChange('jurisdiction', val)}
|
||||
placeholder="Select Jurisdiction"
|
||||
searchable={false}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Scenario Dropdown */}
|
||||
<div>
|
||||
|
||||
<CustomDropdown
|
||||
label="SCENARIO"
|
||||
options={categoryConfig.scenarios}
|
||||
value={formData.scenario}
|
||||
onChange={(val) => handleScenarioChange(val)}
|
||||
placeholder="Select Scenario"
|
||||
searchable={false}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Dynamic Scenario Sub-Type Dropdown */}
|
||||
<div>
|
||||
|
||||
<CustomDropdown
|
||||
label="Scenario Sub-Type"
|
||||
options={subTypeOptions}
|
||||
value={formData.scenarioSubType}
|
||||
onChange={(val) => handleInputChange('scenarioSubType', val)}
|
||||
placeholder="Select Scenario Sub-Type"
|
||||
searchable={false}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Dynamic Category Fields */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{categoryConfig.fields.map((field: FormFieldConfig) => {
|
||||
const isFullWidth = field.gridSpan === 2;
|
||||
return (
|
||||
<div
|
||||
key={field.id}
|
||||
className={isFullWidth ? 'col-span-2' : 'col-span-1'}
|
||||
>
|
||||
{field.type === 'switch' ? (
|
||||
<div className="flex items-center justify-between px-3.5 py-2.5 bg-gray-50/80 border border-gray-200/90 rounded-xl">
|
||||
<span className="text-[12px] font-bold text-gray-800 tracking-wide uppercase">
|
||||
{field.label}
|
||||
</span>
|
||||
<CustomSwitch
|
||||
checked={Boolean(formData[field.id])}
|
||||
onChange={(e) => handleInputChange(field.id, e.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
) : field.type === 'select' ? (
|
||||
<CustomDropdown
|
||||
label={field.label}
|
||||
options={field.options || []}
|
||||
value={formData[field.id] !== undefined ? formData[field.id] : field.defaultValue}
|
||||
onChange={(val) => handleInputChange(field.id, val)}
|
||||
placeholder={field.placeholder || `Select ${field.label}`}
|
||||
searchable={false}
|
||||
size="sm"
|
||||
/>
|
||||
) : (
|
||||
<CustomInput
|
||||
label={field.label}
|
||||
type={field.type}
|
||||
value={formData[field.id] !== undefined ? formData[field.id] : field.defaultValue}
|
||||
onChange={(e) => handleInputChange(field.id, e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Button using CustomButton */}
|
||||
<div className="w-full pt-1">
|
||||
<Can permission="simulation:execute" fallback={
|
||||
<div className="text-center p-2 text-xs text-slate-400 italic">
|
||||
Execution permission (simulation:execute) required to run simulations.
|
||||
</div>
|
||||
}>
|
||||
<CustomButton
|
||||
variant="primary"
|
||||
loading={isSimulating}
|
||||
onClick={handleRunSimulation}
|
||||
className="w-full !rounded-xl !py-3.5 !text-[13px] !font-bold tracking-wide shadow-sm"
|
||||
>
|
||||
Run Manifest Simulation
|
||||
</CustomButton>
|
||||
</Can>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Assessment Manifest */}
|
||||
<div className="lg:col-span-7 xl:col-span-7 bg-white rounded-2xl p-6 border border-gray-100/90 shadow-[0_2px_10px_-4px_rgba(0,0,0,0.04)] flex flex-col justify-between min-h-[580px]">
|
||||
<div className="flex flex-col gap-5 flex-1">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h2 className="text-[17px] font-bold text-[#111827] tracking-tight">
|
||||
Assessment Manifest
|
||||
</h2>
|
||||
<p className="text-[12px] font-medium text-gray-400 mt-0.5">
|
||||
Real-time recovery outcomes for <span className="font-semibold text-gray-600">{formData.category}</span> simulation.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{passengers.length === 0 ? (
|
||||
/* Clean Empty Placeholder */
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-center p-8 border border-dashed border-gray-200 rounded-2xl bg-gray-50/40 my-auto min-h-[380px]">
|
||||
<div className="w-16 h-16 rounded-2xl bg-[#1B9869]/10 text-[#1B9869] flex items-center justify-center mb-4 shadow-xs">
|
||||
<AirplaneTiltIcon size={32} weight="duotone" />
|
||||
</div>
|
||||
<h3 className="text-[15px] font-bold text-gray-800 tracking-tight">
|
||||
No {formData.category} Simulation Manifest Generated
|
||||
</h3>
|
||||
<p className="text-[13px] text-gray-500 max-w-md mt-1.5 leading-relaxed">
|
||||
Configure the parameters under <span className="font-semibold text-gray-700">Assessment Setup</span> and click <span className="font-semibold text-[#1B9869]">"Run Manifest Simulation"</span> to evaluate recovery entitlements and policy rules.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Action Bar */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1">
|
||||
<CustomInput
|
||||
placeholder="Search Manifest..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
leftIcon={<MagnifyingGlassIcon size={16} />}
|
||||
size="sm"
|
||||
containerClassName="!gap-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Can permission="recovery:create">
|
||||
<CustomButton
|
||||
variant="outlined"
|
||||
size="sm"
|
||||
loading={isSaving}
|
||||
disabled={filteredPassengers.length === 0}
|
||||
leftIcon={<FloppyDiskIcon size={16} weight="bold" />}
|
||||
onClick={handleSaveAsRecovery}
|
||||
className="!border-[#1B9869] !text-[#1B9869] hover:!bg-[#1B9869]/5 !rounded-xl !px-4 !py-2 shrink-0 font-semibold text-xs"
|
||||
>
|
||||
Save as Recovery
|
||||
</CustomButton>
|
||||
</Can>
|
||||
</div>
|
||||
|
||||
{/* Dynamic Manifest Table */}
|
||||
<div className="w-full overflow-x-auto mt-1">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-100">
|
||||
{categoryConfig.manifestColumns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className="pb-3 text-[11px] font-bold text-gray-500 uppercase tracking-wider pr-3"
|
||||
>
|
||||
{col.label}
|
||||
</th>
|
||||
))}
|
||||
<th className="pb-3 text-[11px] font-bold text-gray-500 uppercase tracking-wider">
|
||||
ELIGIBILITY
|
||||
</th>
|
||||
<th className="pb-3 text-[11px] font-bold text-gray-500 uppercase tracking-wider">
|
||||
PERKS
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100/70">
|
||||
{paginatedPassengers.length > 0 ? (
|
||||
paginatedPassengers.map((passenger) => (
|
||||
<tr key={passenger.id} className="group hover:bg-gray-50/50 transition-colors">
|
||||
{/* Dynamic Columns based on Category */}
|
||||
{categoryConfig.manifestColumns.map((col) => {
|
||||
if (col.key === 'name') {
|
||||
return (
|
||||
<td key={col.key} className="py-4 pr-3">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-bold text-[13px] text-[#111827] leading-snug">
|
||||
{passenger.name}
|
||||
</span>
|
||||
<span className="text-[11px] text-gray-400 font-medium mt-0.5">
|
||||
{passenger.nationality || 'Verified Customer'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
if (col.key === 'tier') {
|
||||
return (
|
||||
<td key={col.key} className="py-4 pr-3 align-middle text-[12px] font-semibold text-gray-700">
|
||||
{passenger.tier} <span className="text-gray-400 text-[11px] font-normal">({passenger.passengerType || 'Adult'})</span>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
if (col.key === 'cabin') {
|
||||
return (
|
||||
<td key={col.key} className="py-4 pr-3 align-middle text-[12px] font-medium text-gray-600">
|
||||
{passenger.cabinClass || 'Economy'}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
if (col.key === 'pnr') {
|
||||
return (
|
||||
<td key={col.key} className="py-4 pr-3 align-middle text-[12px] font-mono text-gray-500">
|
||||
{passenger.pnr}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
if (col.key === 'bookingRef') {
|
||||
return (
|
||||
<td key={col.key} className="py-4 pr-3 align-middle text-[12px] font-mono text-gray-600">
|
||||
{passenger.bookingRef || '--'}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
if (col.key === 'ancillaryItem') {
|
||||
return (
|
||||
<td key={col.key} className="py-4 pr-3 align-middle text-[12px] font-medium text-gray-700 max-w-[160px] truncate">
|
||||
{passenger.ancillaryItem || '--'}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
if (col.key === 'refund') {
|
||||
return (
|
||||
<td key={col.key} className="py-4 pr-3 align-middle text-[12px] font-semibold text-gray-500">
|
||||
{passenger.refund}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
if (col.key === 'comp') {
|
||||
return (
|
||||
<td key={col.key} className="py-4 pr-3 align-middle font-bold text-[12px] text-[#111827]">
|
||||
{passenger.comp}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<td key={col.key} className="py-4 pr-3 align-middle text-[12px] text-gray-600">
|
||||
{passenger[col.key] || '--'}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Eligibility Status */}
|
||||
<td className="py-4 pr-3 align-middle">
|
||||
<span
|
||||
className={`inline-block px-2.5 py-0.5 text-[10.5px] font-bold rounded-full tracking-wide ${passenger.eligibility === 'ELIGIBLE'
|
||||
? 'bg-[#E8F8F0] text-[#1B9869] border border-[#BDEFD7]'
|
||||
: 'bg-gray-100 text-gray-500 border border-gray-200'
|
||||
}`}
|
||||
>
|
||||
{passenger.eligibility}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Applied Perks */}
|
||||
<td className="py-4 align-middle">
|
||||
{passenger.perks && passenger.perks.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5 max-w-[280px]">
|
||||
{passenger.perks.map((perk, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="bg-[#F3F4F6] text-gray-700 border border-gray-200/80 text-[11px] font-medium px-2 py-0.5 rounded-md whitespace-nowrap"
|
||||
>
|
||||
{perk}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-400 text-[12px] font-medium">--</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={categoryConfig.manifestColumns.length + 2}
|
||||
className="py-10 text-center text-xs text-gray-400"
|
||||
>
|
||||
No matching passengers found in this manifest simulation.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Footer & Pagination */}
|
||||
<div className="flex items-center justify-between pt-4 border-t border-gray-100 mt-4">
|
||||
<span className="text-[12px] font-medium text-gray-400">
|
||||
Showing {filteredPassengers.length > 0 ? (currentPage - 1) * pageSize + 1 : 0} to{' '}
|
||||
{Math.min(currentPage * pageSize, filteredPassengers.length)} of{' '}
|
||||
{filteredPassengers.length} entries
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={currentPage <= 1 || filteredPassengers.length === 0}
|
||||
aria-label="Previous page"
|
||||
className="p-1 rounded-lg text-gray-400 hover:text-gray-700 hover:bg-gray-100 disabled:opacity-30 disabled:hover:bg-transparent transition-colors cursor-pointer"
|
||||
>
|
||||
<CaretLeftIcon size={14} weight="bold" />
|
||||
</button>
|
||||
|
||||
<span className="text-[12px] font-semibold text-gray-600 px-1">
|
||||
{filteredPassengers.length > 0 ? `${currentPage}/${totalPages}` : '0/0'}
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage >= totalPages || filteredPassengers.length === 0}
|
||||
aria-label="Next page"
|
||||
className="p-1 rounded-lg text-gray-400 hover:text-gray-700 hover:bg-gray-100 disabled:opacity-30 disabled:hover:bg-transparent transition-colors cursor-pointer"
|
||||
>
|
||||
<CaretRightIcon size={14} weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Success Modal after Saving Incidents */}
|
||||
{savedSuccessCount !== null && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-xs p-4 animate-in fade-in duration-200">
|
||||
<div className="bg-white rounded-2xl p-6 max-w-md w-full shadow-2xl border border-gray-100 flex flex-col items-center text-center gap-4 animate-in zoom-in-95 duration-200">
|
||||
<div className="w-12 h-12 rounded-full bg-emerald-50 text-[#1B9869] flex items-center justify-center">
|
||||
<CheckCircleIcon size={30} weight="fill" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-lg font-bold text-gray-900">
|
||||
Manifest Saved to Recovery Database
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500">
|
||||
Successfully created {savedSuccessCount} recovery incident case(s) for{' '}
|
||||
<span className="font-semibold text-gray-800">{formData.category}</span> simulation with
|
||||
active entitlement rules.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 w-full mt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSavedSuccessCount(null)}
|
||||
className="flex-1 py-2.5 border border-gray-200 text-gray-700 hover:bg-gray-50 font-semibold text-xs rounded-xl transition-colors cursor-pointer"
|
||||
>
|
||||
Continue Simulating
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSavedSuccessCount(null);
|
||||
navigate('/recovery');
|
||||
}}
|
||||
className="flex-1 py-2.5 bg-[#1B9869] hover:bg-[#158057] text-white font-semibold text-xs rounded-xl transition-colors cursor-pointer"
|
||||
>
|
||||
View Recovery Cases
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import SimulationTerminal from './components/SimulationTerminal';
|
||||
|
||||
export default function SimulationPage() {
|
||||
return <SimulationTerminal />;
|
||||
}
|
||||
@@ -0,0 +1,557 @@
|
||||
import type { Option } from '../../components/custom/CustomDropdown';
|
||||
import type { TabItem } from '../../components/custom/CustomTabs';
|
||||
import type { SimulatedPassengerInput } from './SimulationTypes';
|
||||
|
||||
export interface FormFieldConfig {
|
||||
id: string;
|
||||
label: string;
|
||||
type: 'text' | 'number' | 'select' | 'switch';
|
||||
options?: Option[];
|
||||
placeholder?: string;
|
||||
defaultValue: any;
|
||||
gridSpan?: 1 | 2; // 1 = half width (grid-cols-2), 2 = full width (grid-cols-1)
|
||||
}
|
||||
|
||||
export interface ManifestColumnConfig {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface CategorySimulationConfig {
|
||||
id: 'Flight' | 'Travel' | 'Ancillary';
|
||||
label: string;
|
||||
description: string;
|
||||
scenarios: Option[];
|
||||
scenarioSubTypes: Record<string, Option[]>;
|
||||
fields: FormFieldConfig[];
|
||||
passengerPool: SimulatedPassengerInput[];
|
||||
manifestColumns: ManifestColumnConfig[];
|
||||
defaultFormState: {
|
||||
scenario: string;
|
||||
scenarioSubType: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
}
|
||||
|
||||
export const CATEGORY_TABS: TabItem[] = [
|
||||
{ id: 'Flight', label: 'FLIGHT', content: null },
|
||||
{ id: 'Travel', label: 'TRAVEL', content: null },
|
||||
{ id: 'Ancillary', label: 'ANCILLARY', content: null },
|
||||
];
|
||||
|
||||
export const CATEGORY_SIMULATION_CONFIGS: Record<
|
||||
'Flight' | 'Travel' | 'Ancillary',
|
||||
CategorySimulationConfig
|
||||
> = {
|
||||
Flight: {
|
||||
id: 'Flight',
|
||||
label: 'Flight Operations Disruption',
|
||||
description: 'Simulate flight delays, cancellations, denied boarding, and missed connections.',
|
||||
scenarios: [
|
||||
{ label: 'Flight Delay Disruption', value: 'Flight Delay Disruption' },
|
||||
{ label: 'Flight Cancellation', value: 'Flight Cancellation' },
|
||||
{ label: 'Denied Boarding / Involuntary Bumping', value: 'Denied Boarding' },
|
||||
{ label: 'Missed Connection Delay', value: 'Missed Connection' },
|
||||
],
|
||||
scenarioSubTypes: {
|
||||
'Flight Delay Disruption': [
|
||||
{ label: 'Short Delay (< 2 Hours)', value: 'Short Delay' },
|
||||
{ label: 'Long Delay (3-4 Hours)', value: 'Long Delay' },
|
||||
{ label: 'Extreme Delay (> 4 Hours)', value: 'Extreme Delay' },
|
||||
{ label: 'Overnight Delay', value: 'Overnight Delay' },
|
||||
],
|
||||
'Flight Cancellation': [
|
||||
{ label: 'Same-Day Cancellation', value: 'Same-Day Cancellation' },
|
||||
{ label: 'Short Notice Cancellation (< 14 Days)', value: 'Short Notice Cancellation' },
|
||||
{ label: 'Advance Cancellation (> 14 Days)', value: 'Advance Cancellation' },
|
||||
{ label: 'Weather Extraordinary Cancellation', value: 'Weather Cancellation' },
|
||||
],
|
||||
'Denied Boarding': [
|
||||
{ label: 'Involuntary Denied Boarding', value: 'Involuntary Denied Boarding' },
|
||||
{ label: 'Voluntary Denied Boarding', value: 'Voluntary Denied Boarding' },
|
||||
{ label: 'Involuntary Cabin Downgrade', value: 'Cabin Downgrade' },
|
||||
{ label: 'Oversale Flight Capacity', value: 'Oversale' },
|
||||
],
|
||||
'Missed Connection': [
|
||||
{ label: 'Short Connection Miss (< 2 Hours)', value: 'Short Connection Miss' },
|
||||
{ label: 'Long Connection Miss (> 3 Hours)', value: 'Long Connection Miss' },
|
||||
{ label: 'Overnight Misconnection', value: 'Overnight Misconnection' },
|
||||
{ label: 'Final Leg Connection Miss', value: 'Final Connection Miss' },
|
||||
],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
id: 'flightNumber',
|
||||
label: 'Flight No',
|
||||
type: 'text',
|
||||
placeholder: 'LH450',
|
||||
defaultValue: '',
|
||||
gridSpan: 1,
|
||||
},
|
||||
{
|
||||
id: 'airline',
|
||||
label: 'Airline',
|
||||
type: 'text',
|
||||
placeholder: 'Lufthansa',
|
||||
defaultValue: '',
|
||||
gridSpan: 1,
|
||||
},
|
||||
{
|
||||
id: 'origin',
|
||||
label: 'Origin',
|
||||
type: 'text',
|
||||
placeholder: 'FRA',
|
||||
defaultValue: '',
|
||||
gridSpan: 1,
|
||||
},
|
||||
{
|
||||
id: 'destination',
|
||||
label: 'Dest',
|
||||
type: 'text',
|
||||
placeholder: 'JFK',
|
||||
defaultValue: '',
|
||||
gridSpan: 1,
|
||||
},
|
||||
{
|
||||
id: 'flightDistance',
|
||||
label: 'Flight Distance (KM)',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: '1500', value: '1500' },
|
||||
{ label: '3500', value: '3500' },
|
||||
{ label: '6200', value: '6200' },
|
||||
{ label: '8500', value: '8500' },
|
||||
{ label: '11200', value: '11200' },
|
||||
],
|
||||
defaultValue: '',
|
||||
gridSpan: 2,
|
||||
},
|
||||
{
|
||||
id: 'arrDelay',
|
||||
label: 'Arr Delay (Mins)',
|
||||
type: 'number',
|
||||
placeholder: '240',
|
||||
defaultValue: '',
|
||||
gridSpan: 1,
|
||||
},
|
||||
{
|
||||
id: 'depDelay',
|
||||
label: 'Dep Delay (Mins)',
|
||||
type: 'number',
|
||||
placeholder: '180',
|
||||
defaultValue: '',
|
||||
gridSpan: 1,
|
||||
},
|
||||
],
|
||||
defaultFormState: {
|
||||
scenario: '',
|
||||
scenarioSubType: '',
|
||||
flightNumber: '',
|
||||
airline: '',
|
||||
origin: '',
|
||||
destination: '',
|
||||
flightDistance: '',
|
||||
arrDelay: '',
|
||||
depDelay: '',
|
||||
},
|
||||
passengerPool: [
|
||||
{
|
||||
id: 'p-1',
|
||||
name: 'Alexander Wright',
|
||||
pnr: 'PNR-A1',
|
||||
tier: 'Platinum',
|
||||
cabinClass: 'First Class',
|
||||
originalCabin: 'First Class',
|
||||
actualCabin: 'First Class',
|
||||
passengerType: 'VIP Adult',
|
||||
nationality: 'British',
|
||||
},
|
||||
{
|
||||
id: 'p-2',
|
||||
name: 'Sarah Jenkins',
|
||||
pnr: 'PNR-A2',
|
||||
tier: 'Platinum',
|
||||
cabinClass: 'First Class',
|
||||
originalCabin: 'First Class',
|
||||
actualCabin: 'First Class',
|
||||
passengerType: 'High Value Adult',
|
||||
nationality: 'British',
|
||||
},
|
||||
{
|
||||
id: 'p-3',
|
||||
name: 'The Miller Family',
|
||||
pnr: 'PNR-B3',
|
||||
tier: 'Gold',
|
||||
cabinClass: 'Business Class',
|
||||
originalCabin: 'Business Class',
|
||||
actualCabin: 'Business Class',
|
||||
passengerType: 'Family Group',
|
||||
nationality: 'German',
|
||||
},
|
||||
{
|
||||
id: 'p-4',
|
||||
name: 'Marcus Chen',
|
||||
pnr: 'PNR-C4',
|
||||
tier: 'Silver',
|
||||
cabinClass: 'Business Class',
|
||||
originalCabin: 'Business Class',
|
||||
actualCabin: 'Business Class',
|
||||
passengerType: 'Adult',
|
||||
nationality: 'American',
|
||||
},
|
||||
],
|
||||
manifestColumns: [
|
||||
{ key: 'name', label: 'Passenger' },
|
||||
{ key: 'tier', label: 'Tier & Type' },
|
||||
{ key: 'cabin', label: 'Cabin Class' },
|
||||
{ key: 'pnr', label: 'PNR' },
|
||||
{ key: 'comp', label: 'Compensation' },
|
||||
{ key: 'refund', label: 'Refund' },
|
||||
],
|
||||
},
|
||||
Travel: {
|
||||
id: 'Travel',
|
||||
label: 'Travel & Accommodation Disruption',
|
||||
description: 'Simulate hotel overbookings, missed ground transfers, and package itinerary disruptions.',
|
||||
scenarios: [
|
||||
{ label: 'Hotel Overbooking / Relocation', value: 'Hotel Overbooking' },
|
||||
{ label: 'Missed Ground Transit / Transfer', value: 'Missed Ground Transit' },
|
||||
{ label: 'Package Itinerary Interruption', value: 'Itinerary Interruption' },
|
||||
{ label: 'VIP Accommodation Downgrade', value: 'Accommodation Downgrade' },
|
||||
],
|
||||
scenarioSubTypes: {
|
||||
'Hotel Overbooking': [
|
||||
{ label: 'Relocation to Lower Star Hotel', value: 'Relocation to Lower Star' },
|
||||
{ label: 'Relocation Outside Airport Zone', value: 'Relocation Outside Airport Zone' },
|
||||
{ label: 'Same Star Rating Relocation', value: 'Same Star Relocation' },
|
||||
{ label: 'Unscheduled Hotel Night Incurred', value: 'Unscheduled Hotel Night' },
|
||||
],
|
||||
'Missed Ground Transit': [
|
||||
{ label: 'Ground Transfer Delay (> 4 Hours)', value: 'Ground Transfer Delay' },
|
||||
{ label: 'Chauffeur Transit Breakdown', value: 'Chauffeur Transit Breakdown' },
|
||||
{ label: 'Shuttle Service Cancellation', value: 'Shuttle Service Cancellation' },
|
||||
{ label: 'Missed Connecting Rail/Bus Transit', value: 'Missed Rail/Bus Transit' },
|
||||
],
|
||||
'Itinerary Interruption': [
|
||||
{ label: 'Package Tour Cancellation', value: 'Package Tour Cancellation' },
|
||||
{ label: 'Excursion Schedule Disruption', value: 'Excursion Disruption' },
|
||||
{ label: 'Prepaid Activity Ticket Loss', value: 'Activity Ticket Loss' },
|
||||
{ label: 'Cruiseline Departure Missed', value: 'Cruiseline Missed Departure' },
|
||||
],
|
||||
'Accommodation Downgrade': [
|
||||
{ label: 'Room Category Downgrade', value: 'Room Category Downgrade' },
|
||||
{ label: 'Promised Amenities Non-Availability', value: 'Amenities Non-Availability' },
|
||||
{ label: 'Shared Facility Relocation', value: 'Shared Facility Downgrade' },
|
||||
{ label: 'Executive Lounge Access Loss', value: 'Executive Lounge Loss' },
|
||||
],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
id: 'bookingRef',
|
||||
label: 'Booking Ref',
|
||||
type: 'text',
|
||||
placeholder: 'TRV-8829',
|
||||
defaultValue: '',
|
||||
gridSpan: 1,
|
||||
},
|
||||
{
|
||||
id: 'travelProvider',
|
||||
label: 'Provider / Partner',
|
||||
type: 'text',
|
||||
placeholder: 'Marriott Bonvoy',
|
||||
defaultValue: '',
|
||||
gridSpan: 1,
|
||||
},
|
||||
{
|
||||
id: 'location',
|
||||
label: 'Location / City',
|
||||
type: 'text',
|
||||
placeholder: 'Frankfurt Airport City',
|
||||
defaultValue: '',
|
||||
gridSpan: 1,
|
||||
},
|
||||
{
|
||||
id: 'accommodationClass',
|
||||
label: 'Accommodation Rating',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: '5-Star Luxury', value: '5-Star Luxury' },
|
||||
{ label: '4-Star Executive', value: '4-Star Executive' },
|
||||
{ label: '3-Star Standard', value: '3-Star Standard' },
|
||||
{ label: 'Transit Lodge', value: 'Transit Lodge' },
|
||||
],
|
||||
defaultValue: '',
|
||||
gridSpan: 1,
|
||||
},
|
||||
{
|
||||
id: 'expenseIncurred',
|
||||
label: 'Expense Incurred ($)',
|
||||
type: 'number',
|
||||
placeholder: '350',
|
||||
defaultValue: '',
|
||||
gridSpan: 1,
|
||||
},
|
||||
{
|
||||
id: 'disruptionDuration',
|
||||
label: 'Duration (Nights/Hrs)',
|
||||
type: 'text',
|
||||
placeholder: '1 Night',
|
||||
defaultValue: '',
|
||||
gridSpan: 1,
|
||||
},
|
||||
],
|
||||
defaultFormState: {
|
||||
scenario: '',
|
||||
scenarioSubType: '',
|
||||
bookingRef: '',
|
||||
travelProvider: '',
|
||||
location: '',
|
||||
accommodationClass: '',
|
||||
expenseIncurred: '',
|
||||
disruptionDuration: '',
|
||||
},
|
||||
passengerPool: [
|
||||
{
|
||||
id: 'p-1',
|
||||
name: 'Alexander Wright',
|
||||
pnr: 'TRV-PNR1',
|
||||
tier: 'Platinum',
|
||||
passengerType: 'VIP Traveler',
|
||||
nationality: 'British',
|
||||
bookingRef: 'TRV-8829',
|
||||
},
|
||||
{
|
||||
id: 'p-2',
|
||||
name: 'Sarah Jenkins',
|
||||
pnr: 'TRV-PNR2',
|
||||
tier: 'Platinum',
|
||||
passengerType: 'Corporate Traveler',
|
||||
nationality: 'British',
|
||||
bookingRef: 'TRV-8830',
|
||||
},
|
||||
{
|
||||
id: 'p-3',
|
||||
name: 'The Miller Family',
|
||||
pnr: 'TRV-PNR3',
|
||||
tier: 'Gold',
|
||||
passengerType: 'Family Group',
|
||||
nationality: 'German',
|
||||
bookingRef: 'TRV-8831',
|
||||
},
|
||||
{
|
||||
id: 'p-4',
|
||||
name: 'Marcus Chen',
|
||||
pnr: 'TRV-PNR4',
|
||||
tier: 'Silver',
|
||||
passengerType: 'Solo Traveler',
|
||||
nationality: 'American',
|
||||
bookingRef: 'TRV-8832',
|
||||
},
|
||||
],
|
||||
manifestColumns: [
|
||||
{ key: 'name', label: 'Traveler' },
|
||||
{ key: 'tier', label: 'Tier & Type' },
|
||||
{ key: 'bookingRef', label: 'Booking Ref' },
|
||||
{ key: 'comp', label: 'Compensation' },
|
||||
{ key: 'refund', label: 'Refund' },
|
||||
],
|
||||
},
|
||||
Ancillary: {
|
||||
id: 'Ancillary',
|
||||
label: 'Ancillary Services & Perks Disruption',
|
||||
description: 'Simulate ancillary failures, non-delivery, seat defects, lounge denials, and baggage issues.',
|
||||
scenarios: [
|
||||
{ label: 'Preferred Seat', value: 'Preferred Seat' },
|
||||
{ label: 'Wi-Fi', value: 'Wi-Fi' },
|
||||
{ label: 'Lounge Access', value: 'Lounge Access' },
|
||||
{ label: 'Priority Boarding', value: 'Priority Boarding' },
|
||||
{ label: 'Fast Track Security', value: 'Fast Track Security' },
|
||||
{ label: 'Special Meal', value: 'Special Meal' },
|
||||
{ label: 'Paid Meal', value: 'Paid Meal' },
|
||||
{ label: 'Extra Baggage', value: 'Extra Baggage' },
|
||||
{ label: 'Sports Equipment', value: 'Sports Equipment' },
|
||||
{ label: 'Musical Instrument', value: 'Musical Instrument' },
|
||||
{ label: 'Upgrade Purchase', value: 'Upgrade Purchase' },
|
||||
{ label: 'In-flight Entertainment', value: 'In-flight Entertainment' },
|
||||
{ label: 'Airport Transfer', value: 'Airport Transfer' },
|
||||
{ label: 'Chauffeur Service', value: 'Chauffeur Service' },
|
||||
{ label: 'Power Outlet', value: 'Power Outlet' },
|
||||
{ label: 'Extra Legroom', value: 'Extra Legroom' },
|
||||
{ label: 'Carbon Offset', value: 'Carbon Offset' },
|
||||
],
|
||||
scenarioSubTypes: {
|
||||
'Wi-Fi': [
|
||||
{ label: 'Purchased but Unavailable', value: 'Purchased but Unavailable' },
|
||||
{ label: 'Fee Paid Service Non-delivery', value: 'Fee Paid Service Non-delivery' },
|
||||
{ label: 'System Outage / Inoperative', value: 'System Outage' },
|
||||
{ label: 'Slow Speed / Unusable', value: 'Slow Speed' },
|
||||
{ label: 'Partial Flight Unavailable', value: 'Partial Flight Unavailable' },
|
||||
],
|
||||
'Preferred Seat': [
|
||||
{ label: 'Purchased but Unavailable', value: 'Purchased but Unavailable' },
|
||||
{ label: 'Seat Hardware Defect', value: 'Seat Hardware Defect' },
|
||||
{ label: 'Involuntary Seat Reassignment', value: 'Involuntary Seat Reassignment' },
|
||||
{ label: 'Non-Reclining Exit Row Seat', value: 'Non-Reclining Exit Row Seat' },
|
||||
{ label: 'Extra Legroom Feature Defect', value: 'Extra Legroom Feature Defect' },
|
||||
],
|
||||
'Lounge Access': [
|
||||
{ label: 'Purchased but Unavailable', value: 'Purchased but Unavailable' },
|
||||
{ label: 'Overcapacity Entry Refusal', value: 'Overcapacity Entry Refusal' },
|
||||
{ label: 'Operating Hours Premature Closure', value: 'Operating Hours Premature Closure' },
|
||||
{ label: 'Partner Airline Lounge Access Denial', value: 'Partner Lounge Denial' },
|
||||
{ label: 'Pass Registration System Error', value: 'Pass Registration System Error' },
|
||||
],
|
||||
'Priority Boarding': [
|
||||
{ label: 'Purchased but Unavailable', value: 'Purchased but Unavailable' },
|
||||
{ label: 'Gate Priority Not Honored', value: 'Gate Priority Not Honored' },
|
||||
{ label: 'Boarding Zone Error', value: 'Boarding Zone Error' },
|
||||
{ label: 'Late Jet Bridge Call', value: 'Late Jet Bridge Call' },
|
||||
],
|
||||
'Fast Track Security': [
|
||||
{ label: 'Purchased but Unavailable', value: 'Purchased but Unavailable' },
|
||||
{ label: 'Fast Track Lane Closed', value: 'Fast Track Lane Closed' },
|
||||
{ label: 'Security Voucher Rejected', value: 'Security Voucher Rejected' },
|
||||
{ label: 'Terminal Access Error', value: 'Terminal Access Error' },
|
||||
],
|
||||
'Special Meal': [
|
||||
{ label: 'Purchased but Unavailable', value: 'Purchased but Unavailable' },
|
||||
{ label: 'Meal Not Loaded', value: 'Meal Not Loaded' },
|
||||
{ label: 'Incorrect Dietary Meal', value: 'Incorrect Dietary Meal' },
|
||||
{ label: 'Contaminated / Spoiled', value: 'Contaminated Meal' },
|
||||
],
|
||||
'Paid Meal': [
|
||||
{ label: 'Purchased but Unavailable', value: 'Purchased but Unavailable' },
|
||||
{ label: 'Paid Meal Out of Stock', value: 'Paid Meal Out of Stock' },
|
||||
{ label: 'Incorrect Meal Delivered', value: 'Incorrect Meal Delivered' },
|
||||
{ label: 'Quality Substandard', value: 'Quality Substandard' },
|
||||
],
|
||||
'Extra Baggage': [
|
||||
{ label: 'Purchased but Unavailable', value: 'Purchased but Unavailable' },
|
||||
{ label: 'Baggage Left Behind', value: 'Baggage Left Behind' },
|
||||
{ label: 'Overcharge Dispute', value: 'Overcharge Dispute' },
|
||||
{ label: 'Priority Baggage Delayed', value: 'Priority Baggage Delayed' },
|
||||
],
|
||||
'Sports Equipment': [
|
||||
{ label: 'Purchased but Unavailable', value: 'Purchased but Unavailable' },
|
||||
{ label: 'Equipment Refused at Check-in', value: 'Equipment Refused at Check-in' },
|
||||
{ label: 'Equipment Damaged', value: 'Equipment Damaged' },
|
||||
{ label: 'Delayed Delivery', value: 'Delayed Delivery' },
|
||||
],
|
||||
'Musical Instrument': [
|
||||
{ label: 'Purchased but Unavailable', value: 'Purchased but Unavailable' },
|
||||
{ label: 'Cabin Stowage Refused', value: 'Cabin Stowage Refused' },
|
||||
{ label: 'Involuntary Gate Check', value: 'Involuntary Gate Check' },
|
||||
{ label: 'Transit Damage', value: 'Transit Damage' },
|
||||
],
|
||||
'Upgrade Purchase': [
|
||||
{ label: 'Purchased but Unavailable', value: 'Purchased but Unavailable' },
|
||||
{ label: 'Involuntary Cabin Downgrade', value: 'Involuntary Cabin Downgrade' },
|
||||
{ label: 'Seat Feature Inoperative', value: 'Seat Feature Inoperative' },
|
||||
{ label: 'Overbooked Premium Cabin', value: 'Overbooked Premium Cabin' },
|
||||
],
|
||||
'In-flight Entertainment': [
|
||||
{ label: 'Purchased but Unavailable', value: 'Purchased but Unavailable' },
|
||||
{ label: 'In-Flight Screen Hardware Fault', value: 'Screen Hardware Fault' },
|
||||
{ label: 'Audio / Headset Jack Fault', value: 'Audio Headset Fault' },
|
||||
{ label: 'Content Library Inaccessible', value: 'Content Library Inaccessible' },
|
||||
],
|
||||
'Airport Transfer': [
|
||||
{ label: 'Purchased but Unavailable', value: 'Purchased but Unavailable' },
|
||||
{ label: 'Driver No-Show', value: 'Driver No-Show' },
|
||||
{ label: 'Transfer Vehicle Delay (> 2 Hours)', value: 'Transfer Vehicle Delay' },
|
||||
{ label: 'Vehicle Class Downgrade', value: 'Vehicle Class Downgrade' },
|
||||
],
|
||||
'Chauffeur Service': [
|
||||
{ label: 'Purchased but Unavailable', value: 'Purchased but Unavailable' },
|
||||
{ label: 'Chauffeur Missed Pickup', value: 'Chauffeur Missed Pickup' },
|
||||
{ label: 'Vehicle Breakdown', value: 'Vehicle Breakdown' },
|
||||
{ label: 'Unscheduled Cancellation', value: 'Unscheduled Cancellation' },
|
||||
],
|
||||
'Power Outlet': [
|
||||
{ label: 'Purchased but Unavailable', value: 'Purchased but Unavailable' },
|
||||
{ label: 'In-Seat Power Outage', value: 'Power Outage' },
|
||||
{ label: 'Low Voltage / Non-Functional', value: 'Low Voltage' },
|
||||
{ label: 'Physical Port Broken', value: 'Physical Port Broken' },
|
||||
],
|
||||
'Extra Legroom': [
|
||||
{ label: 'Purchased but Unavailable', value: 'Purchased but Unavailable' },
|
||||
{ label: 'Involuntary Seat Reassignment', value: 'Involuntary Seat Reassignment' },
|
||||
{ label: 'Obstructed Legroom', value: 'Obstructed Legroom' },
|
||||
{ label: 'Aircraft Swap Downgrade', value: 'Aircraft Swap Downgrade' },
|
||||
],
|
||||
'Carbon Offset': [
|
||||
{ label: 'Purchased but Unavailable', value: 'Purchased but Unavailable' },
|
||||
{ label: 'Offset Certificate Failed', value: 'Offset Certificate Failed' },
|
||||
{ label: 'Billing Mismatch', value: 'Billing Mismatch' },
|
||||
],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
id: 'serviceCost',
|
||||
label: 'SERVICE COST',
|
||||
type: 'number',
|
||||
placeholder: '25',
|
||||
defaultValue: '',
|
||||
gridSpan: 2,
|
||||
},
|
||||
{
|
||||
id: 'delivered',
|
||||
label: 'DELIVERED',
|
||||
type: 'switch',
|
||||
defaultValue: false,
|
||||
gridSpan: 2,
|
||||
},
|
||||
],
|
||||
defaultFormState: {
|
||||
scenario: '',
|
||||
scenarioSubType: '',
|
||||
serviceCost: '',
|
||||
delivered: false,
|
||||
},
|
||||
passengerPool: [
|
||||
{
|
||||
id: 'p-1',
|
||||
name: 'Alexander Wright',
|
||||
pnr: 'ANC-PNR1',
|
||||
tier: 'Platinum',
|
||||
passengerType: 'VIP Guest',
|
||||
nationality: 'British',
|
||||
ancillaryItem: 'Wi-Fi',
|
||||
},
|
||||
{
|
||||
id: 'p-2',
|
||||
name: 'Sarah Jenkins',
|
||||
pnr: 'ANC-PNR2',
|
||||
tier: 'Platinum',
|
||||
passengerType: 'Frequent Flyer',
|
||||
nationality: 'British',
|
||||
ancillaryItem: 'Wi-Fi',
|
||||
},
|
||||
{
|
||||
id: 'p-3',
|
||||
name: 'The Miller Family',
|
||||
pnr: 'ANC-PNR3',
|
||||
tier: 'Gold',
|
||||
passengerType: 'Family Group',
|
||||
nationality: 'German',
|
||||
ancillaryItem: 'Wi-Fi',
|
||||
},
|
||||
{
|
||||
id: 'p-4',
|
||||
name: 'Marcus Chen',
|
||||
pnr: 'ANC-PNR4',
|
||||
tier: 'Silver',
|
||||
passengerType: 'Solo Traveler',
|
||||
nationality: 'American',
|
||||
ancillaryItem: 'Wi-Fi',
|
||||
},
|
||||
],
|
||||
manifestColumns: [
|
||||
{ key: 'name', label: 'Passenger' },
|
||||
{ key: 'tier', label: 'Tier & Type' },
|
||||
{ key: 'ancillaryItem', label: 'Service / Item' },
|
||||
{ key: 'comp', label: 'Compensation' },
|
||||
{ key: 'refund', label: 'Refund' },
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import { ApiClient } from '../api/ApiClient';
|
||||
import type { UserRole } from '../../context/AuthContext';
|
||||
|
||||
export interface UserItem {
|
||||
id: string;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName?: string;
|
||||
fullName: string;
|
||||
roleId: string;
|
||||
role: UserRole;
|
||||
tenantId?: string | null;
|
||||
isActive: boolean;
|
||||
isSystem: boolean;
|
||||
permissions: string[];
|
||||
lastLoginAt?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface CreateUserPayload {
|
||||
email: string;
|
||||
password: string;
|
||||
firstName: string;
|
||||
lastName?: string;
|
||||
roleId: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateUserPayload {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
roleId?: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export const UsersApi = {
|
||||
getUsers: async (search?: string, roleId?: string): Promise<UserItem[]> => {
|
||||
return ApiClient.get<any, UserItem[]>('/users', {
|
||||
params: { search, roleId },
|
||||
});
|
||||
},
|
||||
|
||||
getUserById: async (id: string): Promise<UserItem> => {
|
||||
return ApiClient.get<any, UserItem>(`/users/${id}`);
|
||||
},
|
||||
|
||||
createUser: async (payload: CreateUserPayload): Promise<UserItem> => {
|
||||
return ApiClient.post<any, UserItem>('/users', payload);
|
||||
},
|
||||
|
||||
updateUser: async (id: string, payload: UpdateUserPayload): Promise<UserItem> => {
|
||||
return ApiClient.patch<any, UserItem>(`/users/${id}`, payload);
|
||||
},
|
||||
|
||||
resetPassword: async (id: string, newPassword: string): Promise<{ success: boolean; message: string }> => {
|
||||
return ApiClient.post<any, { success: boolean; message: string }>(`/users/${id}/reset-password`, {
|
||||
newPassword,
|
||||
});
|
||||
},
|
||||
|
||||
deleteUser: async (id: string): Promise<{ success: boolean; message: string }> => {
|
||||
return ApiClient.delete<any, { success: boolean; message: string }>(`/users/${id}`);
|
||||
},
|
||||
};
|
||||
|
||||
export default UsersApi;
|
||||
@@ -0,0 +1,110 @@
|
||||
import React, { useState } from 'react';
|
||||
import { UsersApi, type UserItem } from '../UsersApi';
|
||||
import {
|
||||
CustomModal,
|
||||
CustomInput,
|
||||
CustomButton,
|
||||
CustomAlertBanner,
|
||||
} from '../../../components/custom';
|
||||
import { LockKeyIcon, KeyIcon } from '@phosphor-icons/react';
|
||||
|
||||
interface ResetPasswordModalProps {
|
||||
user: UserItem | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: (email: string) => void;
|
||||
}
|
||||
|
||||
export default function ResetPasswordModal({
|
||||
user,
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: ResetPasswordModalProps) {
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
if (!isOpen || !user) return null;
|
||||
|
||||
const handleSubmit = async (e?: React.FormEvent) => {
|
||||
if (e) e.preventDefault();
|
||||
if (!newPassword || newPassword.length < 8) {
|
||||
setError('New password must be at least 8 characters long.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await UsersApi.resetPassword(user.id, newPassword);
|
||||
onSuccess(user.email);
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.message || 'Failed to reset password.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CustomModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Reset User Credentials"
|
||||
description={`Set a new secure access password for ${user.email}.`}
|
||||
size="sm"
|
||||
footer={
|
||||
<div className="flex items-center justify-end gap-3 w-full">
|
||||
<CustomButton
|
||||
variant="outlined"
|
||||
size="md"
|
||||
onClick={onClose}
|
||||
className="!rounded-[10px] !h-[40px]"
|
||||
>
|
||||
Cancel
|
||||
</CustomButton>
|
||||
<CustomButton
|
||||
variant="primary"
|
||||
size="md"
|
||||
leftIcon={<KeyIcon size={16} />}
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
onClick={handleSubmit}
|
||||
className="!rounded-[10px] !h-[40px] !bg-[#1B9869] hover:!bg-[#14704E]"
|
||||
>
|
||||
Update Password
|
||||
</CustomButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4 py-1">
|
||||
{error && (
|
||||
<CustomAlertBanner
|
||||
message={error}
|
||||
type="error"
|
||||
onClose={() => setError(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="p-3.5 bg-amber-50/80 border border-amber-200/80 rounded-[12px] text-amber-800 text-[12px] leading-relaxed font-medium">
|
||||
Setting a new password will immediately revoke active tokens and require the user to sign in with the new credentials.
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<CustomInput
|
||||
label="New Password"
|
||||
required
|
||||
type="password"
|
||||
placeholder="Enter at least 8 characters"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
leftIcon={<LockKeyIcon size={18} />}
|
||||
className="!h-[42px] !rounded-[10px]"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</CustomModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { UsersApi, type UserItem } from '../UsersApi';
|
||||
import { RolesApi, type RoleItem } from '../../roles/RolesApi';
|
||||
import {
|
||||
CustomModal,
|
||||
CustomInput,
|
||||
CustomDropdown,
|
||||
CustomSwitch,
|
||||
CustomButton,
|
||||
CustomAlertBanner,
|
||||
} from '../../../components/custom';
|
||||
import {
|
||||
UserIcon,
|
||||
EnvelopeSimpleIcon,
|
||||
LockKeyIcon,
|
||||
ShieldCheckIcon,
|
||||
FloppyDiskIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
|
||||
interface UserModalProps {
|
||||
userToEdit?: UserItem | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
export default function UserModal({
|
||||
userToEdit,
|
||||
isOpen,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: UserModalProps) {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [firstName, setFirstName] = useState('');
|
||||
const [lastName, setLastName] = useState('');
|
||||
const [roleId, setRoleId] = useState('');
|
||||
const [isActive, setIsActive] = useState(true);
|
||||
|
||||
const [roles, setRoles] = useState<RoleItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadRoles();
|
||||
if (userToEdit) {
|
||||
setEmail(userToEdit.email);
|
||||
setFirstName(userToEdit.firstName);
|
||||
setLastName(userToEdit.lastName || '');
|
||||
setRoleId(userToEdit.roleId);
|
||||
setIsActive(userToEdit.isActive);
|
||||
setPassword('');
|
||||
} else {
|
||||
setEmail('');
|
||||
setPassword('');
|
||||
setFirstName('');
|
||||
setLastName('');
|
||||
setRoleId('');
|
||||
setIsActive(true);
|
||||
}
|
||||
setError(null);
|
||||
}
|
||||
}, [isOpen, userToEdit]);
|
||||
|
||||
const loadRoles = async () => {
|
||||
try {
|
||||
const data = await RolesApi.getRoles();
|
||||
setRoles(data);
|
||||
if (!userToEdit && data.length > 0 && !roleId) {
|
||||
setRoleId(data[0].id);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load roles in user modal:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const isSystemAdmin = userToEdit?.isSystem ?? false;
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (!firstName.trim()) {
|
||||
setError('First name is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!userToEdit && !email.trim()) {
|
||||
setError('Email address is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!userToEdit && (!password || password.length < 8)) {
|
||||
setError('Password must be at least 8 characters long.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!roleId) {
|
||||
setError('Please assign an operational role.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
if (userToEdit) {
|
||||
await UsersApi.updateUser(userToEdit.id, {
|
||||
firstName: firstName.trim(),
|
||||
lastName: lastName.trim() || undefined,
|
||||
roleId,
|
||||
isActive,
|
||||
});
|
||||
} else {
|
||||
await UsersApi.createUser({
|
||||
email: email.trim().toLowerCase(),
|
||||
password,
|
||||
firstName: firstName.trim(),
|
||||
lastName: lastName.trim() || undefined,
|
||||
roleId,
|
||||
isActive,
|
||||
});
|
||||
}
|
||||
onSaved();
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.message || 'Failed to save user account.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const roleOptions = roles.map((r) => ({
|
||||
label: `${r.name}${r.isSystem ? ' (System Default)' : ''}`,
|
||||
value: r.id,
|
||||
}));
|
||||
|
||||
return (
|
||||
<CustomModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={userToEdit ? 'Edit User Profile' : 'Provision New User'}
|
||||
description={
|
||||
userToEdit
|
||||
? 'Update account information and assigned operational clearance.'
|
||||
: 'Provision a new team member with specific role credentials.'
|
||||
}
|
||||
size="md"
|
||||
footer={
|
||||
<div className="flex items-center justify-end gap-3 w-full">
|
||||
<CustomButton
|
||||
variant="outlined"
|
||||
size="md"
|
||||
onClick={onClose}
|
||||
className="!rounded-[10px] !h-[40px]"
|
||||
>
|
||||
Cancel
|
||||
</CustomButton>
|
||||
<CustomButton
|
||||
variant="primary"
|
||||
size="md"
|
||||
leftIcon={<FloppyDiskIcon size={16} />}
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
onClick={handleSubmit}
|
||||
className="!rounded-[10px] !h-[40px] !bg-[#1B9869] hover:!bg-[#14704E]"
|
||||
>
|
||||
{userToEdit ? 'Save Changes' : 'Provision User'}
|
||||
</CustomButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4 py-1">
|
||||
{error && (
|
||||
<CustomAlertBanner
|
||||
message={error}
|
||||
type="error"
|
||||
onClose={() => setError(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<CustomInput
|
||||
label="First Name"
|
||||
required
|
||||
placeholder="e.g. John"
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
leftIcon={<UserIcon size={18} />}
|
||||
className="!h-[42px] !rounded-[10px]"
|
||||
/>
|
||||
|
||||
<CustomInput
|
||||
label="Last Name"
|
||||
placeholder="e.g. Doe"
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
leftIcon={<UserIcon size={18} />}
|
||||
className="!h-[42px] !rounded-[10px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CustomInput
|
||||
label="Email Address"
|
||||
required
|
||||
type="email"
|
||||
disabled={!!userToEdit}
|
||||
placeholder="e.g. john.doe@aeroresolve.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
leftIcon={<EnvelopeSimpleIcon size={18} />}
|
||||
className="!h-[42px] !rounded-[10px]"
|
||||
/>
|
||||
|
||||
{!userToEdit && (
|
||||
<CustomInput
|
||||
label="Temporary Password"
|
||||
required
|
||||
type="password"
|
||||
placeholder="At least 8 characters"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
leftIcon={<LockKeyIcon size={18} />}
|
||||
className="!h-[42px] !rounded-[10px]"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<CustomDropdown
|
||||
label="Operational Role"
|
||||
required
|
||||
disabled={isSystemAdmin}
|
||||
options={roleOptions}
|
||||
value={roleId}
|
||||
onChange={(val) => setRoleId(val)}
|
||||
placeholder="Select Operational Role..."
|
||||
leftIcon={<ShieldCheckIcon size={18} />}
|
||||
size="md"
|
||||
className="!h-[42px] !rounded-[10px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3.5 bg-gray-50/70 border border-gray-100 rounded-[12px] mt-2">
|
||||
<div>
|
||||
<span className="text-[13px] font-semibold text-gray-900 block">
|
||||
Account Status
|
||||
</span>
|
||||
<span className="text-[12px] text-gray-500 font-medium">
|
||||
{isActive
|
||||
? 'User can authenticate and access authorized workflows.'
|
||||
: 'Account is deactivated and prohibited from signing in.'}
|
||||
</span>
|
||||
</div>
|
||||
<CustomSwitch
|
||||
disabled={isSystemAdmin}
|
||||
checked={isActive}
|
||||
onChange={(e) => setIsActive(e.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</CustomModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { UsersApi, type UserItem } from '../UsersApi';
|
||||
import {
|
||||
CustomTable,
|
||||
CustomInput,
|
||||
CustomButton,
|
||||
CustomStatus,
|
||||
CustomCheckBox,
|
||||
Skeleton,
|
||||
CustomActionMenu,
|
||||
CustomActionItem,
|
||||
CustomAlertBanner,
|
||||
CustomConfirmationModal,
|
||||
} from '../../../components/custom';
|
||||
import type { Column } from '../../../components/custom/CustomTable';
|
||||
import {
|
||||
PencilSimpleIcon,
|
||||
TrashIcon,
|
||||
KeyIcon,
|
||||
PlusIcon,
|
||||
MagnifyingGlassIcon,
|
||||
LockKeyIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
import UserModal from './UserModal';
|
||||
import ResetPasswordModal from './ResetPasswordModal';
|
||||
import Can from '../../../components/common/Can';
|
||||
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-700 rounded-full text-xs font-semibold">
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function UsersList() {
|
||||
const [users, setUsers] = useState<UserItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
||||
|
||||
// Pagination & Search
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
// Selection
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// Modals
|
||||
const [userModalOpen, setUserModalOpen] = useState(false);
|
||||
const [userToEdit, setUserToEdit] = useState<UserItem | null>(null);
|
||||
|
||||
const [resetModalOpen, setResetModalOpen] = useState(false);
|
||||
const [userForReset, setUserForReset] = useState<UserItem | null>(null);
|
||||
|
||||
const [deleteConfirmUser, setDeleteConfirmUser] = useState<UserItem | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await UsersApi.getUsers();
|
||||
setUsers(data);
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load users:', err);
|
||||
setError(err?.response?.data?.message || 'Failed to load user accounts.');
|
||||
setUsers([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, [fetchUsers]);
|
||||
|
||||
// Handlers
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
};
|
||||
|
||||
const handleSearchChange = (val: string) => {
|
||||
setSearch(val);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedIds.size === users.length && users.length > 0) {
|
||||
setSelectedIds(new Set());
|
||||
} else {
|
||||
setSelectedIds(new Set(users.map((u) => u.id)));
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSelectOne = (id: string) => {
|
||||
const next = new Set(selectedIds);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
setSelectedIds(next);
|
||||
};
|
||||
|
||||
const handleCreateUser = () => {
|
||||
setUserToEdit(null);
|
||||
setUserModalOpen(true);
|
||||
};
|
||||
|
||||
const handleEditUser = (user: UserItem) => {
|
||||
setUserToEdit(user);
|
||||
setUserModalOpen(true);
|
||||
};
|
||||
|
||||
const handleResetPassword = (user: UserItem) => {
|
||||
setUserForReset(user);
|
||||
setResetModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteConfirmUser) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await UsersApi.deleteUser(deleteConfirmUser.id);
|
||||
setSuccessMsg(`User "${deleteConfirmUser.email}" was removed successfully.`);
|
||||
setDeleteConfirmUser(null);
|
||||
fetchUsers();
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.message || 'Failed to delete user.');
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaved = () => {
|
||||
setUserModalOpen(false);
|
||||
setSuccessMsg('User account updated successfully.');
|
||||
fetchUsers();
|
||||
};
|
||||
|
||||
const handleResetSuccess = (email: string) => {
|
||||
setSuccessMsg(`Password for ${email} was reset successfully.`);
|
||||
};
|
||||
|
||||
// Filtered & Paginated Data
|
||||
const filteredUsers = useMemo(() => {
|
||||
const q = search.toLowerCase().trim();
|
||||
if (!q) return users;
|
||||
return users.filter(
|
||||
(u) =>
|
||||
u.fullName.toLowerCase().includes(q) ||
|
||||
u.email.toLowerCase().includes(q) ||
|
||||
(u.role?.name || '').toLowerCase().includes(q)
|
||||
);
|
||||
}, [users, search]);
|
||||
|
||||
const totalItems = filteredUsers.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 paginatedUsers = useMemo(() => {
|
||||
const start = (currentPage - 1) * PAGE_SIZE;
|
||||
return filteredUsers.slice(start, start + PAGE_SIZE);
|
||||
}, [filteredUsers, currentPage]);
|
||||
|
||||
// Columns definition
|
||||
const columns: Column<UserItem>[] = [
|
||||
{
|
||||
header: (
|
||||
<CustomCheckBox
|
||||
checked={users.length > 0 && selectedIds.size === users.length}
|
||||
onChange={toggleSelectAll}
|
||||
/>
|
||||
),
|
||||
className: 'w-[40px] pr-0',
|
||||
accessor: (row) => (
|
||||
<CustomCheckBox
|
||||
checked={selectedIds.has(row.id)}
|
||||
onChange={() => toggleSelectOne(row.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: <HeaderLabel text="User / Account" />,
|
||||
accessor: (row) => (
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<PrimaryText text={row.fullName || row.email} />
|
||||
{row.isSystem && (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-semibold px-2 py-0.5 rounded-full bg-amber-50 text-amber-700 border border-amber-200">
|
||||
<LockKeyIcon size={11} weight="bold" />
|
||||
System Admin
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<SecondaryText text={row.email} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: <HeaderLabel text="Operational Role" />,
|
||||
accessor: (row) => <BadgeLabel text={row.role?.name || 'Unassigned'} />,
|
||||
},
|
||||
{
|
||||
header: <HeaderLabel text="Status" />,
|
||||
accessor: (row) => (
|
||||
<CustomStatus
|
||||
status={row.isActive ? 'Active' : 'Deactivated'}
|
||||
variant={row.isActive ? 'success' : 'neutral'}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: <HeaderLabel text="Last Login" />,
|
||||
accessor: (row) => (
|
||||
<SecondaryText
|
||||
text={row.lastLoginAt ? formatDate(row.lastLoginAt) : 'Never logged in'}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: <HeaderLabel text="Created Date" />,
|
||||
accessor: (row) => <SecondaryText text={formatDate(row.createdAt)} />,
|
||||
},
|
||||
{
|
||||
header: <HeaderLabel text="Action" />,
|
||||
accessor: (row) => (
|
||||
<div className="flex items-center pl-2">
|
||||
<CustomActionMenu>
|
||||
<Can permission="users:edit">
|
||||
<CustomActionItem
|
||||
onClick={() => handleEditUser(row)}
|
||||
icon={<PencilSimpleIcon size={16} className="text-yellow-500" />}
|
||||
>
|
||||
Edit User
|
||||
</CustomActionItem>
|
||||
</Can>
|
||||
|
||||
<Can permission="users:reset_password">
|
||||
<CustomActionItem
|
||||
onClick={() => handleResetPassword(row)}
|
||||
icon={<KeyIcon size={16} className="text-blue-500" />}
|
||||
>
|
||||
Reset Password
|
||||
</CustomActionItem>
|
||||
</Can>
|
||||
|
||||
{!row.isSystem && (
|
||||
<Can permission="users:delete">
|
||||
<CustomActionItem
|
||||
variant="danger"
|
||||
icon={<TrashIcon size={16} className="text-red-500" />}
|
||||
onClick={() => setDeleteConfirmUser(row)}
|
||||
>
|
||||
Delete User
|
||||
</CustomActionItem>
|
||||
</Can>
|
||||
)}
|
||||
</CustomActionMenu>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col gap-6">
|
||||
{error && (
|
||||
<CustomAlertBanner
|
||||
message={error}
|
||||
type="error"
|
||||
onClose={() => setError(null)}
|
||||
/>
|
||||
)}
|
||||
{successMsg && (
|
||||
<CustomAlertBanner
|
||||
message={successMsg}
|
||||
type="success"
|
||||
onClose={() => setSuccessMsg(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Table Section */}
|
||||
<CustomTable<UserItem>
|
||||
columns={columns}
|
||||
data={paginatedUsers}
|
||||
leftHeaderActions={
|
||||
<div className="w-[320px]">
|
||||
<CustomInput
|
||||
placeholder="Search users by name, email..."
|
||||
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={
|
||||
<Can permission="users:create">
|
||||
<CustomButton
|
||||
variant="primary"
|
||||
size="md"
|
||||
leftIcon={<PlusIcon size={16} />}
|
||||
className="!rounded-[10px] !gap-[8px] !h-[40px] !bg-[#1B9869] hover:!bg-[#14704E]"
|
||||
onClick={handleCreateUser}
|
||||
>
|
||||
Provision User
|
||||
</CustomButton>
|
||||
</Can>
|
||||
}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
totalItems={totalItems}
|
||||
startIndex={startIndex}
|
||||
endIndex={endIndex}
|
||||
onPageChange={handlePageChange}
|
||||
itemName="Users"
|
||||
rowClassName={() => 'bg-white border-b border-gray-100 hover:bg-gray-50/60'}
|
||||
/>
|
||||
|
||||
{/* Create / Edit User Modal */}
|
||||
<UserModal
|
||||
isOpen={userModalOpen}
|
||||
userToEdit={userToEdit}
|
||||
onClose={() => setUserModalOpen(false)}
|
||||
onSaved={handleSaved}
|
||||
/>
|
||||
|
||||
{/* Reset Password Modal */}
|
||||
<ResetPasswordModal
|
||||
isOpen={resetModalOpen}
|
||||
user={userForReset}
|
||||
onClose={() => setResetModalOpen(false)}
|
||||
onSuccess={handleResetSuccess}
|
||||
/>
|
||||
|
||||
{/* Confirmation Modal */}
|
||||
<CustomConfirmationModal
|
||||
isOpen={!!deleteConfirmUser}
|
||||
onClose={() => setDeleteConfirmUser(null)}
|
||||
onConfirm={handleDelete}
|
||||
title="Delete User Account"
|
||||
description={`Are you sure you want to permanently remove user "${deleteConfirmUser?.email}"? All active sessions will be invalidated.`}
|
||||
confirmText={deleting ? 'Deleting...' : 'Confirm Delete'}
|
||||
variant="danger"
|
||||
isLoading={deleting}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import UsersList from './components/UsersList';
|
||||
|
||||
export default function UsersManagementPage() {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<UsersList />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
|
||||
interface CanProps {
|
||||
permission?: string;
|
||||
permissions?: string[];
|
||||
matchAny?: boolean;
|
||||
fallback?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Can: React.FC<CanProps> = ({
|
||||
permission,
|
||||
permissions,
|
||||
matchAny = true,
|
||||
fallback = null,
|
||||
children,
|
||||
}) => {
|
||||
const { hasPermission, hasAnyPermission, hasAllPermissions } = useAuth();
|
||||
|
||||
let isAllowed = false;
|
||||
|
||||
if (permission) {
|
||||
isAllowed = hasPermission(permission);
|
||||
} else if (permissions && permissions.length > 0) {
|
||||
isAllowed = matchAny
|
||||
? hasAnyPermission(permissions)
|
||||
: hasAllPermissions(permissions);
|
||||
} else {
|
||||
isAllowed = true;
|
||||
}
|
||||
|
||||
if (!isAllowed) {
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default Can;
|
||||
@@ -0,0 +1,70 @@
|
||||
import React from 'react';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { ShieldWarningIcon, ShieldCheckIcon } from '@phosphor-icons/react';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
permission?: string;
|
||||
permissions?: string[];
|
||||
matchAny?: boolean;
|
||||
}
|
||||
|
||||
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
|
||||
children,
|
||||
permission,
|
||||
permissions,
|
||||
matchAny = true,
|
||||
}) => {
|
||||
const { isAuthenticated, isLoading, hasPermission, hasAnyPermission, hasAllPermissions } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen w-full flex flex-col items-center justify-center bg-[#F4F7F6]">
|
||||
<div className="relative flex items-center justify-center">
|
||||
<div className="w-14 h-14 border-4 border-slate-200 border-t-primary rounded-full animate-spin"></div>
|
||||
<div className="absolute w-7 h-7 bg-primary/10 rounded-full flex items-center justify-center">
|
||||
<ShieldCheckIcon className="w-4 h-4 text-primary animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-4 text-xs font-semibold uppercase tracking-widest text-slate-500 animate-pulse">
|
||||
Authenticating Aero Resolve Session...
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
// Check required permissions
|
||||
let isAuthorized = true;
|
||||
if (permission) {
|
||||
isAuthorized = hasPermission(permission);
|
||||
} else if (permissions && permissions.length > 0) {
|
||||
isAuthorized = matchAny ? hasAnyPermission(permissions) : hasAllPermissions(permissions);
|
||||
}
|
||||
|
||||
if (!isAuthorized) {
|
||||
return (
|
||||
<div className="min-h-[60vh] flex flex-col items-center justify-center p-6 text-center">
|
||||
<div className="w-16 h-16 bg-red-50 text-red-500 rounded-2xl flex items-center justify-center mb-4 shadow-sm">
|
||||
<ShieldWarningIcon size={32} />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-slate-800 tracking-tight">Access Restricted</h2>
|
||||
<p className="text-sm text-slate-500 mt-1.5 max-w-md">
|
||||
Your current role does not have the required security clearance to access this operational module.
|
||||
</p>
|
||||
<div className="mt-4 text-xs font-mono bg-slate-100 text-slate-600 px-3 py-1.5 rounded-lg border border-slate-200">
|
||||
Required Clearance: {permission || permissions?.join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default ProtectedRoute;
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { CircleCheck } from "lucide-react";
|
||||
import { CheckCircleIcon, CaretDownIcon, CaretUpIcon } from "@phosphor-icons/react";
|
||||
|
||||
interface CustomAccordionSectionProps {
|
||||
icon: React.ReactNode;
|
||||
@@ -27,7 +27,12 @@ const CustomAccordionSection: React.FC<CustomAccordionSectionProps> = ({
|
||||
>
|
||||
{icon}
|
||||
<span className="text-[11px] font-bold text-gray-700 uppercase tracking-wider flex-1">{title}</span>
|
||||
{!isOpen && hasData && <CircleCheck size={18} className="text-[#1B9869]" strokeWidth={2} />}
|
||||
{!isOpen && hasData && <CheckCircleIcon size={18} className="text-[#1B9869]" weight="fill" />}
|
||||
{isOpen ? (
|
||||
<CaretUpIcon size={16} className="text-gray-500" />
|
||||
) : (
|
||||
<CaretDownIcon size={16} className="text-gray-500" />
|
||||
)}
|
||||
</button>
|
||||
{isOpen && <div className="px-4 pb-4 pt-3">{children}</div>}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useEffect, useRef, useState, useLayoutEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { MoreVertical } from "lucide-react";
|
||||
import { DotsThreeVerticalIcon } from "@phosphor-icons/react";
|
||||
|
||||
interface CustomActionMenuProps {
|
||||
children: React.ReactNode;
|
||||
@@ -86,7 +86,7 @@ export const CustomActionMenu: React.FC<CustomActionMenuProps> = ({
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full transition-colors focus:outline-none ${isOpen ? 'bg-gray-100 text-black' : 'text-black hover:bg-gray-100'}`}
|
||||
aria-label="Actions"
|
||||
>
|
||||
<MoreVertical size={18} />
|
||||
<DotsThreeVerticalIcon size={18} />
|
||||
</button>
|
||||
|
||||
{isOpen &&
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type FC, useEffect } from "react";
|
||||
import { AlertTriangle, CircleCheck, Info, XCircle, X } from 'lucide-react';
|
||||
import { WarningIcon, CheckCircleIcon, InfoIcon, XCircleIcon, XIcon } from '@phosphor-icons/react';
|
||||
|
||||
type AlertType = 'error' | 'success' | 'warning' | 'info';
|
||||
|
||||
@@ -13,19 +13,19 @@ interface AlertBannerProps {
|
||||
|
||||
const alertConfig = {
|
||||
error: {
|
||||
icon: <XCircle className="h-5 w-5" />,
|
||||
icon: <XCircleIcon className="h-5 w-5" />,
|
||||
containerClasses: 'bg-red-50 border-red-500 text-red-800',
|
||||
},
|
||||
success: {
|
||||
icon: <CircleCheck className="h-5 w-5" />,
|
||||
icon: <CheckCircleIcon className="h-5 w-5" />,
|
||||
containerClasses: 'bg-green-50 border-green-500 text-green-800',
|
||||
},
|
||||
warning: {
|
||||
icon: <AlertTriangle className="h-5 w-5" />,
|
||||
icon: <WarningIcon className="h-5 w-5" />,
|
||||
containerClasses: 'bg-yellow-50 border-yellow-500 text-yellow-800',
|
||||
},
|
||||
info: {
|
||||
icon: <Info className="h-5 w-5" />,
|
||||
icon: <InfoIcon className="h-5 w-5" />,
|
||||
containerClasses: 'bg-blue-50 border-blue-500 text-blue-800',
|
||||
},
|
||||
};
|
||||
@@ -75,7 +75,7 @@ const AlertBanner: FC<AlertBannerProps> = ({
|
||||
className="ml-4 p-1 rounded-full hover:bg-black/5 transition-colors focus:outline-none"
|
||||
aria-label="Close alert"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Users, Briefcase, Building2 } from "lucide-react";
|
||||
import {
|
||||
AirplaneTiltIcon,
|
||||
ShieldCheckIcon,
|
||||
ArrowsClockwiseIcon,
|
||||
UsersFourIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
|
||||
const CustomAppLoader = () => {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
|
||||
// HRM related icons
|
||||
// AeroResolve domain icons
|
||||
const icons = [
|
||||
{ component: Users, key: "users" },
|
||||
{ component: Briefcase, key: "briefcase" },
|
||||
{ component: Building2, key: "building" },
|
||||
{ component: AirplaneTiltIcon, key: "flight", label: "Flight Operations" },
|
||||
{ component: ArrowsClockwiseIcon, key: "recovery", label: "Recovery Incidents" },
|
||||
{ component: ShieldCheckIcon, key: "policy", label: "Policy Engine" },
|
||||
{ component: UsersFourIcon, key: "cohorts", label: "Passenger Cohorts" },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setCurrentIndex((prev) => (prev + 1) % icons.length);
|
||||
}, 800); // Slower, smoother transition
|
||||
}, 700);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [icons.length]);
|
||||
@@ -22,28 +28,34 @@ const CustomAppLoader = () => {
|
||||
const ActiveIcon = icons[currentIndex].component;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center space-y-6 h-screen w-full ">
|
||||
<div className="flex flex-col items-center justify-center space-y-6 min-h-[400px] h-screen w-full bg-[#F8FAFC] font-sans">
|
||||
<div className="relative flex items-center justify-center">
|
||||
{/* Outer Glowing Pulsing Aura */}
|
||||
<div className="absolute w-24 h-24 bg-[#1B9869]/10 rounded-full animate-ping pointer-events-none" />
|
||||
|
||||
{/* Outer Spinning Ring */}
|
||||
<div className="w-20 h-20 border-4 border-gray-200 border-t-[#0B3B6A] rounded-full animate-spin"></div>
|
||||
<div className="w-20 h-20 border-4 border-gray-200/80 border-t-[#1B9869] border-r-[#1B9869]/40 rounded-full animate-spin" />
|
||||
|
||||
{/* Icon Container */}
|
||||
<div className="absolute flex items-center justify-center">
|
||||
<div className="absolute flex items-center justify-center w-12 h-12 bg-white rounded-full shadow-sm border border-gray-100">
|
||||
<ActiveIcon
|
||||
key={icons[currentIndex].key}
|
||||
size={32}
|
||||
className="text-[#0B3B6A] transition-all duration-500 opacity-100"
|
||||
strokeWidth={1.5}
|
||||
size={28}
|
||||
className="text-[#1B9869] transition-all duration-300 transform scale-100"
|
||||
weight="bold"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<h3 className="text-[#0B3B6A] font-semibold text-lg tracking-wide">
|
||||
HRM System
|
||||
<div className="flex flex-col items-center gap-1.5 text-center">
|
||||
<h3 className="text-gray-900 font-bold text-xl tracking-tight flex items-center gap-2">
|
||||
Aero<span className="text-[#1B9869]">Resolve</span>
|
||||
</h3>
|
||||
<span className="text-gray-500 text-sm animate-pulse">
|
||||
Loading resources...
|
||||
<span className="text-gray-500 text-xs font-semibold uppercase tracking-wider">
|
||||
Passenger Recovery & Resolution Platform
|
||||
</span>
|
||||
<span className="text-[13px] font-medium text-[#1B9869] animate-pulse mt-2">
|
||||
Loading {icons[currentIndex].label}...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { MoveLeft } from "lucide-react";
|
||||
import { ArrowLeftIcon } from "@phosphor-icons/react";
|
||||
|
||||
interface CustomBackButtonProps {
|
||||
to?: string;
|
||||
@@ -29,7 +29,7 @@ const CustomBackButton: React.FC<CustomBackButtonProps> = ({
|
||||
className={`group relative inline-flex items-center gap-2 text-gray-500 hover:text-gray-900 transition-colors cursor-pointer ${className}`}
|
||||
>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gray-100 hover:bg-gray-200 transition-colors">
|
||||
<MoveLeft className="h-4 w-4" />
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
</div>
|
||||
{label && <span className="text-sm font-medium">{label}</span>}
|
||||
|
||||
|
||||
@@ -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, { forwardRef } from "react";
|
||||
import { Check } from "lucide-react";
|
||||
import { CheckIcon } from "@phosphor-icons/react";
|
||||
|
||||
interface CustomCheckBoxProps
|
||||
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type"> {
|
||||
@@ -37,7 +37,7 @@ const CustomCheckBox = forwardRef<HTMLInputElement, CustomCheckBoxProps>(
|
||||
peer-disabled:bg-gray-100 peer-disabled:border-gray-200
|
||||
`}
|
||||
>
|
||||
<Check
|
||||
<CheckIcon
|
||||
size={14}
|
||||
className="text-white opacity-0 transition-opacity duration-200"
|
||||
strokeWidth={3}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import CustomModal from "./CustomModal";
|
||||
import CustomButton from "./CustomButton";
|
||||
import { AlertTriangle, CheckCircle } from "lucide-react";
|
||||
import { WarningIcon, CheckCircleIcon } from "@phosphor-icons/react";
|
||||
|
||||
interface ConfirmationModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -40,7 +40,7 @@ const ConfirmationModal: React.FC<ConfirmationModalProps> = ({
|
||||
variant === 'success' ? 'bg-emerald-50 text-emerald-600' :
|
||||
'bg-blue-50 text-blue-600'
|
||||
}`}>
|
||||
{variant === 'success' ? <CheckCircle size={32} /> : <AlertTriangle size={32} />}
|
||||
{variant === 'success' ? <CheckCircleIcon size={32} /> : <WarningIcon size={32} />}
|
||||
</div>
|
||||
|
||||
<p className="text-gray-500 mb-6 font-medium">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { forwardRef } from "react";
|
||||
import { Calendar } from "lucide-react";
|
||||
import { CalendarBlankIcon } from "@phosphor-icons/react";
|
||||
|
||||
interface CustomDatePickerProps
|
||||
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type"> {
|
||||
@@ -42,7 +42,7 @@ const CustomDatePicker = forwardRef<HTMLInputElement, CustomDatePickerProps>(
|
||||
{...props}
|
||||
/>
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none">
|
||||
<Calendar size={18} />
|
||||
<CalendarBlankIcon size={18} />
|
||||
</span>
|
||||
</div>
|
||||
{error && <p className="text-xs text-red-500 mt-0.5">{error}</p>}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { forwardRef } from "react";
|
||||
import { CalendarClock } from "lucide-react";
|
||||
import { CalendarDotsIcon } from "@phosphor-icons/react";
|
||||
|
||||
interface CustomDateTimePickerProps
|
||||
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type"> {
|
||||
@@ -44,7 +44,7 @@ const CustomDateTimePicker = forwardRef<
|
||||
{...props}
|
||||
/>
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none">
|
||||
<CalendarClock size={18} />
|
||||
<CalendarDotsIcon size={18} />
|
||||
</span>
|
||||
</div>
|
||||
{error && <p className="text-xs text-red-500 mt-0.5">{error}</p>}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { ChevronDown, Check } from "lucide-react";
|
||||
import { CaretDownIcon, CheckIcon, MagnifyingGlassIcon } from "@phosphor-icons/react";
|
||||
import DropdownPortal from "./DropdownPortal";
|
||||
|
||||
interface Option {
|
||||
export interface Option {
|
||||
label: string;
|
||||
value: string | number;
|
||||
disabled?: boolean;
|
||||
groupHeader?: boolean;
|
||||
groupName?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface CustomDropdownProps {
|
||||
@@ -20,6 +23,8 @@ interface CustomDropdownProps {
|
||||
required?: boolean;
|
||||
className?: string;
|
||||
error?: string;
|
||||
searchable?: boolean;
|
||||
searchPlaceholder?: string;
|
||||
}
|
||||
|
||||
const CustomDropdown = React.forwardRef<HTMLDivElement, CustomDropdownProps>(
|
||||
@@ -36,12 +41,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 +67,7 @@ const CustomDropdown = React.forwardRef<HTMLDivElement, CustomDropdownProps>(
|
||||
!(panelRef.current && panelRef.current.contains(target))
|
||||
) {
|
||||
setIsOpen(false);
|
||||
setSearchQuery("");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -65,15 +75,44 @@ 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;
|
||||
if (option.disabled || option.groupHeader) return;
|
||||
onChange?.(String(option.value));
|
||||
setIsOpen(false);
|
||||
setSearchQuery("");
|
||||
};
|
||||
|
||||
const selectedOption = value !== '' && value !== null && value !== undefined
|
||||
? options.find((opt) => String(opt.value) === String(value))
|
||||
: undefined;
|
||||
const selectedOption =
|
||||
value !== "" && value !== null && value !== undefined
|
||||
? options.find((opt) => !opt.groupHeader && String(opt.value) === String(value))
|
||||
: undefined;
|
||||
|
||||
const filteredOptions =
|
||||
searchable && searchQuery.trim()
|
||||
? options.filter((opt, idx) => {
|
||||
const lowerQuery = searchQuery.toLowerCase().trim();
|
||||
if (opt.groupHeader) {
|
||||
if (opt.label.toLowerCase().includes(lowerQuery)) return true;
|
||||
for (let i = idx + 1; i < options.length; i++) {
|
||||
if (options[i].groupHeader) break;
|
||||
if (options[i].label.toLowerCase().includes(lowerQuery)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return opt.label.toLowerCase().includes(lowerQuery);
|
||||
})
|
||||
: options;
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col gap-1.5" ref={ref}>
|
||||
@@ -90,14 +129,14 @@ const CustomDropdown = React.forwardRef<HTMLDivElement, CustomDropdownProps>(
|
||||
className={`
|
||||
w-full rounded-lg
|
||||
bg-white
|
||||
border ${error ? 'border-red-500' : 'border-gray-300'}
|
||||
border ${error ? "border-red-500" : "border-gray-300"}
|
||||
${sizeClasses[size]}
|
||||
px-3
|
||||
outline-none
|
||||
transition-all duration-200
|
||||
flex items-center
|
||||
${!disabled ? 'cursor-pointer hover:border-primary' : 'cursor-not-allowed bg-gray-50 text-gray-500'}
|
||||
${isOpen ? 'border-primary ring-2 ring-primary/20' : ''}
|
||||
${!disabled ? "cursor-pointer hover:border-primary" : "cursor-not-allowed bg-gray-50 text-gray-500"}
|
||||
${isOpen ? "border-primary ring-2 ring-primary/20" : ""}
|
||||
${leftIcon ? "pl-10" : ""}
|
||||
pr-10
|
||||
${className}
|
||||
@@ -111,14 +150,18 @@ const CustomDropdown = React.forwardRef<HTMLDivElement, CustomDropdownProps>(
|
||||
|
||||
<div className="flex-1 truncate text-left text-[14px] font-medium tracking-[0.25px] leading-[15px]">
|
||||
{selectedOption ? (
|
||||
<span style={{ color: '#6C766D' }}>{selectedOption.label}</span>
|
||||
<span style={{ color: "#032D20" }}>
|
||||
{selectedOption.groupName
|
||||
? `${selectedOption.groupName} › ${selectedOption.label}`
|
||||
: selectedOption.label}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ color: '#6C766D' }}>{placeholder}</span>
|
||||
<span style={{ color: "#6C766D" }}>{placeholder}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 pointer-events-none flex items-center">
|
||||
<ChevronDown
|
||||
<CaretDownIcon
|
||||
size={16}
|
||||
className={`transition-transform duration-200 ${isOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
@@ -130,47 +173,82 @@ 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-[320px]"
|
||||
>
|
||||
{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 && <Check 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-[260px]">
|
||||
{filteredOptions.length === 0 ? (
|
||||
<div className="px-4 py-3 text-sm text-gray-500 text-center">
|
||||
{searchQuery ? "No matching options" : "No options available"}
|
||||
</div>
|
||||
) : (
|
||||
filteredOptions.map((option, index) => {
|
||||
if (option.groupHeader) {
|
||||
return (
|
||||
<div
|
||||
key={option.value || `group_${index}`}
|
||||
className="px-3 py-1.5 text-[11px] font-bold tracking-wider text-gray-500 uppercase bg-gray-50/90 border-y border-gray-100 sticky top-0 z-[5] select-none"
|
||||
>
|
||||
{option.label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isSelected = String(option.value) === String(value);
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
disabled={option.disabled}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSelect(option);
|
||||
}}
|
||||
className={`
|
||||
w-full text-left px-3 min-h-[38px] text-[13px] leading-none
|
||||
transition-colors duration-150 flex items-center gap-2
|
||||
${
|
||||
isSelected
|
||||
? "bg-[#EEF9EF]"
|
||||
: option.disabled
|
||||
? "text-gray-400 cursor-not-allowed"
|
||||
: "text-gray-700 hover:bg-gray-50"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{isSelected && <CheckIcon size={16} className="text-primary shrink-0" strokeWidth={2.5} />}
|
||||
<span
|
||||
className={
|
||||
isSelected
|
||||
? "ml-1 font-medium bg-gradient-to-b from-[#1B9869] to-[#14704E] bg-clip-text text-transparent"
|
||||
: option.groupName
|
||||
? "ml-4 text-gray-800 font-normal"
|
||||
: "ml-6 text-gray-800 font-normal"
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</DropdownPortal>
|
||||
</div>
|
||||
{error && <p className="text-xs text-red-500 mt-1">{error}</p>}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useRef, useState, useEffect } from "react";
|
||||
import { Upload, X, File } from "lucide-react";
|
||||
import { UploadSimpleIcon, XIcon, FileIcon } from "@phosphor-icons/react";
|
||||
|
||||
interface CustomFileUploaderProps {
|
||||
label?: string;
|
||||
@@ -141,7 +141,7 @@ const CustomFileUploader: React.FC<CustomFileUploaderProps> = ({
|
||||
/>
|
||||
|
||||
<div className="flex flex-col items-center gap-2 text-gray-500">
|
||||
<Upload size={24} />
|
||||
<UploadSimpleIcon size={24} />
|
||||
<span className="text-sm font-medium">
|
||||
{isDragging ? "Drop files here" : "Click or drag to upload"}
|
||||
</span>
|
||||
@@ -185,7 +185,7 @@ const CustomFileUploader: React.FC<CustomFileUploaderProps> = ({
|
||||
}}
|
||||
className="p-1 text-gray-400 hover:text-red-500 transition-colors rounded-full hover:bg-gray-100"
|
||||
>
|
||||
<X size={18} />
|
||||
<XIcon size={18} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -206,7 +206,7 @@ const CustomFileUploader: React.FC<CustomFileUploaderProps> = ({
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<File size={20} className="text-gray-500" />
|
||||
<FileIcon size={20} className="text-gray-500" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col overflow-hidden">
|
||||
@@ -223,7 +223,7 @@ const CustomFileUploader: React.FC<CustomFileUploaderProps> = ({
|
||||
onClick={(e) => removeFile(e, index)}
|
||||
className="p-1 text-gray-400 hover:text-[#0B3B6A] transition-colors rounded-full hover:bg-gray-100"
|
||||
>
|
||||
<X size={18} />
|
||||
<XIcon size={18} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { XIcon } from "@phosphor-icons/react";
|
||||
|
||||
interface FullModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -61,7 +61,7 @@ const FullModal: React.FC<FullModalProps> = ({
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 z-50 p-2 bg-black/10 hover:bg-black/20 rounded-full transition-colors md:top-6 md:right-6"
|
||||
>
|
||||
<X size={24} className="text-gray-700" />
|
||||
<XIcon size={24} className="text-gray-700" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import React, { useState, forwardRef } from "react";
|
||||
import { Phone, Eye, EyeOff } from "lucide-react";
|
||||
import React, { useState, forwardRef, useEffect } 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";
|
||||
|
||||
export interface ValidationJsonRule {
|
||||
min?: number;
|
||||
max?: number;
|
||||
regex?: string;
|
||||
}
|
||||
|
||||
interface CustomInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "size"> {
|
||||
label?: string;
|
||||
@@ -13,6 +19,7 @@ interface CustomInputProps extends Omit<React.InputHTMLAttributes<HTMLInputEleme
|
||||
error?: string;
|
||||
containerClassName?: string;
|
||||
size?: "sm" | "md" | "lg";
|
||||
validationJson?: ValidationJsonRule | string;
|
||||
}
|
||||
|
||||
const CustomInput = forwardRef<HTMLInputElement, CustomInputProps>(
|
||||
@@ -22,14 +29,16 @@ const CustomInput = forwardRef<HTMLInputElement, CustomInputProps>(
|
||||
type = "text",
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
maxLength,
|
||||
maxLength = 100,
|
||||
phonePrefix,
|
||||
disabled,
|
||||
className = "",
|
||||
containerClassName = "",
|
||||
error,
|
||||
onInput,
|
||||
onChange,
|
||||
size = "md",
|
||||
validationJson,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
@@ -37,6 +46,7 @@ const CustomInput = forwardRef<HTMLInputElement, CustomInputProps>(
|
||||
const isPassword = type === "password";
|
||||
const isPhone = phonePrefix !== undefined;
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [validationError, setValidationError] = useState<string>("");
|
||||
|
||||
const inputType = isPassword && showPassword ? "text" : type;
|
||||
|
||||
@@ -46,16 +56,87 @@ const CustomInput = forwardRef<HTMLInputElement, CustomInputProps>(
|
||||
lg: "py-3 text-base h-[48px]",
|
||||
};
|
||||
|
||||
const handleInput = (e: React.FormEvent<HTMLInputElement>) => {
|
||||
if (maxLength && (type === "number" || type === "tel")) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
if (target.value.length > maxLength) {
|
||||
target.value = target.value.slice(0, maxLength);
|
||||
const parsedValJson: ValidationJsonRule | null = (() => {
|
||||
if (!validationJson) return null;
|
||||
if (typeof validationJson === "string") {
|
||||
try {
|
||||
return JSON.parse(validationJson);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return validationJson;
|
||||
})();
|
||||
|
||||
const effectiveMax = parsedValJson?.max !== undefined ? Number(parsedValJson.max) : maxLength;
|
||||
const effectiveMin = parsedValJson?.min !== undefined ? Number(parsedValJson.min) : undefined;
|
||||
const effectiveRegex = parsedValJson?.regex || undefined;
|
||||
|
||||
const validateInput = (val: string) => {
|
||||
const fieldLabel = label ? `"${label}"` : "This field";
|
||||
|
||||
// 1. Max length check (character count)
|
||||
if (effectiveMax !== undefined && effectiveMax > 0) {
|
||||
if (val.length > effectiveMax) {
|
||||
setValidationError(`${fieldLabel} cannot exceed ${effectiveMax} characters`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Min length check (character count)
|
||||
if (effectiveMin !== undefined && effectiveMin > 0) {
|
||||
if (val.length > 0 && val.length < effectiveMin) {
|
||||
setValidationError(`${fieldLabel} must be at least ${effectiveMin} characters`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Regex check
|
||||
if (effectiveRegex && val.length > 0) {
|
||||
try {
|
||||
const reg = new RegExp(effectiveRegex, "u");
|
||||
if (!reg.test(val)) {
|
||||
setValidationError(`Invalid format for ${fieldLabel}`);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// ignore invalid regex string syntax
|
||||
}
|
||||
}
|
||||
|
||||
setValidationError("");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (props.value !== undefined && props.value !== null) {
|
||||
validateInput(String(props.value));
|
||||
}
|
||||
}, [props.value, effectiveMax, effectiveMin, effectiveRegex, label]);
|
||||
|
||||
const handleInput = (e: React.FormEvent<HTMLInputElement>) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
if (effectiveMax && (type === "number" || type === "tel")) {
|
||||
if (target.value.length > effectiveMax) {
|
||||
target.value = target.value.slice(0, effectiveMax);
|
||||
}
|
||||
}
|
||||
validateInput(target.value);
|
||||
onInput?.(e as any);
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
if (effectiveMax && (type === "number" || type === "tel")) {
|
||||
if (target.value.length > effectiveMax) {
|
||||
target.value = target.value.slice(0, effectiveMax);
|
||||
}
|
||||
}
|
||||
validateInput(target.value);
|
||||
onChange?.(e);
|
||||
};
|
||||
|
||||
const displayError = error || validationError;
|
||||
|
||||
return (
|
||||
<div className={`w-full flex flex-col gap-1.5 ${containerClassName}`}>
|
||||
{label && (
|
||||
@@ -73,7 +154,7 @@ const CustomInput = forwardRef<HTMLInputElement, CustomInputProps>(
|
||||
>
|
||||
{isPhone && (
|
||||
<div className="flex items-center gap-2 border-r border-gray-300 bg-gray-50 px-3 text-sm font-semibold text-gray-900">
|
||||
<Phone size={16} className="text-gray-500" />
|
||||
<PhoneIcon size={16} className="text-gray-500" />
|
||||
{phonePrefix}
|
||||
</div>
|
||||
)}
|
||||
@@ -88,9 +169,10 @@ const CustomInput = forwardRef<HTMLInputElement, CustomInputProps>(
|
||||
ref={ref}
|
||||
type={inputType}
|
||||
maxLength={
|
||||
type === "number" || type === "tel" ? undefined : maxLength
|
||||
type === "number" || type === "tel" ? undefined : effectiveMax
|
||||
}
|
||||
onInput={handleInput}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
className={`
|
||||
${isPhone
|
||||
@@ -98,7 +180,7 @@ const CustomInput = forwardRef<HTMLInputElement, CustomInputProps>(
|
||||
: `
|
||||
w-full rounded-lg
|
||||
bg-white text-gray-900
|
||||
border ${error ? "border-red-500" : "border-gray-300"}
|
||||
border ${displayError ? "border-red-500" : "border-gray-300"}
|
||||
px-3 ${sizeClasses[size]}
|
||||
outline-none
|
||||
transition-all duration-200
|
||||
@@ -120,7 +202,7 @@ const CustomInput = forwardRef<HTMLInputElement, CustomInputProps>(
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 cursor-pointer text-slate-400 select-none hover:text-primary"
|
||||
>
|
||||
{showPassword ? <Eye size={20} /> : <EyeOff size={20} />}
|
||||
{showPassword ? <EyeIcon size={20} /> : <EyeSlashIcon size={20} />}
|
||||
</span>
|
||||
) : (
|
||||
!isPhone &&
|
||||
@@ -131,7 +213,7 @@ const CustomInput = forwardRef<HTMLInputElement, CustomInputProps>(
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{error && <p className="text-xs text-red-500 mt-1">{error}</p>}
|
||||
{displayError && <p className="text-xs text-red-500 mt-1">{displayError}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { XCircle } from "lucide-react";
|
||||
import { XCircleIcon } from "@phosphor-icons/react";
|
||||
import CustomButton from "./CustomButton";
|
||||
|
||||
type ModalSize = "sm" | "md" | "lg" | "xl" | "full";
|
||||
@@ -140,7 +140,7 @@ const CustomModal: React.FC<CustomModalProps> = ({
|
||||
className="absolute right-4 top-4 flex h-8 w-8 items-center justify-center rounded-full
|
||||
text-slate-400 transition hover:text-slate-600 hover:bg-slate-100"
|
||||
>
|
||||
<XCircle size={20} strokeWidth={2} />
|
||||
<XCircleIcon size={20} strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { ChevronDown, Check } from "lucide-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,20 +121,31 @@ 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>
|
||||
|
||||
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 pointer-events-none flex items-center">
|
||||
<ChevronDown
|
||||
<CaretDownIcon
|
||||
size={16}
|
||||
className={`transition-transform duration-200 ${isOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
@@ -166,7 +184,7 @@ const CustomMultiSelect = React.forwardRef<HTMLDivElement, CustomMultiSelectProp
|
||||
}
|
||||
`}
|
||||
>
|
||||
{isSelected && <Check size={16} className="text-[#1B9869] shrink-0" strokeWidth={2.5} />}
|
||||
{isSelected && <CheckIcon size={16} className="text-[#1B9869] shrink-0" strokeWidth={2.5} />}
|
||||
<span className={isSelected ? "ml-1" : "ml-6"}>{option.label}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { ChevronDown, X, Check } from "lucide-react";
|
||||
import { CaretDownIcon, XIcon, CheckIcon } from "@phosphor-icons/react";
|
||||
import DropdownPortal from "./DropdownPortal";
|
||||
|
||||
interface Option {
|
||||
@@ -205,7 +205,7 @@ const CustomSearchableDropdown = React.forwardRef<
|
||||
disabled={disabled}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<ChevronDown
|
||||
<CaretDownIcon
|
||||
size={16}
|
||||
className={`transition-transform duration-200 ${isOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
@@ -214,7 +214,7 @@ const CustomSearchableDropdown = React.forwardRef<
|
||||
|
||||
{searchTerm && !disabled && (
|
||||
<button type="button" onClick={handleClear} className="text-gray-500 hover:text-gray-900 p-0.5">
|
||||
<X size={16} />
|
||||
<XIcon size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -248,7 +248,7 @@ const CustomSearchableDropdown = React.forwardRef<
|
||||
}
|
||||
`}
|
||||
>
|
||||
{isSelected && <Check size={16} className="text-primary shrink-0" strokeWidth={2.5} />}
|
||||
{isSelected && <CheckIcon size={16} className="text-primary shrink-0" strokeWidth={2.5} />}
|
||||
<span
|
||||
className={
|
||||
isSelected
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X } from "lucide-react";
|
||||
import { XIcon } from "@phosphor-icons/react";
|
||||
import CustomStatus from "./CustomStatus";
|
||||
import SuccessTick from "../../assets/icons/SuccessTick.png";
|
||||
|
||||
@@ -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,
|
||||
@@ -58,7 +60,7 @@ const CustomSuccessModal: React.FC<CustomSuccessModalProps> = ({
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 text-gray-500 hover:text-gray-700 focus:outline-none"
|
||||
>
|
||||
<X size={24} strokeWidth={2.5} className="text-[#152A3C]" />
|
||||
<XIcon size={24} weight="bold" className="text-[#152A3C]" />
|
||||
</button>
|
||||
|
||||
{/* Animated Icon Background */}
|
||||
@@ -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}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import React from "react";
|
||||
import { Search, ChevronLeft, ChevronRight, Filter, ArrowDownUp } from "lucide-react";
|
||||
import {
|
||||
MagnifyingGlassIcon,
|
||||
CaretLeftIcon,
|
||||
CaretRightIcon,
|
||||
FunnelSimpleIcon,
|
||||
ArrowsDownUpIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import CustomInput from "./CustomInput";
|
||||
|
||||
export interface Column<T> {
|
||||
@@ -13,14 +19,14 @@ export interface Column<T> {
|
||||
interface CustomTableProps<T> {
|
||||
columns: Column<T>[];
|
||||
data: T[];
|
||||
|
||||
|
||||
// Header Props
|
||||
searchPlaceholder?: string;
|
||||
searchValue?: string;
|
||||
onSearchChange?: (val: string) => void;
|
||||
leftHeaderActions?: React.ReactNode;
|
||||
rightHeaderActions?: React.ReactNode;
|
||||
|
||||
|
||||
// Pagination Props
|
||||
currentPage?: number;
|
||||
totalPages?: number;
|
||||
@@ -29,9 +35,10 @@ interface CustomTableProps<T> {
|
||||
endIndex?: number;
|
||||
onPageChange?: (page: number) => void;
|
||||
itemName?: string;
|
||||
|
||||
|
||||
// Table Props
|
||||
onRowClick?: (row: T) => void;
|
||||
rowClassName?: (row: T) => string;
|
||||
}
|
||||
|
||||
export function CustomTable<T>({
|
||||
@@ -50,8 +57,8 @@ export function CustomTable<T>({
|
||||
onPageChange,
|
||||
itemName = "items",
|
||||
onRowClick,
|
||||
rowClassName,
|
||||
}: CustomTableProps<T>) {
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
if (newPage >= 1 && newPage <= totalPages && onPageChange) {
|
||||
onPageChange(newPage);
|
||||
@@ -59,26 +66,53 @@ export function CustomTable<T>({
|
||||
};
|
||||
|
||||
const getPageNumbers = () => {
|
||||
const pages = [];
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
pages.push(i);
|
||||
const pages: (number | string)[] = [];
|
||||
|
||||
if (totalPages <= 7) {
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
} else {
|
||||
if (currentPage <= 4) {
|
||||
pages.push(1, 2, 3, 4, 5, "...", totalPages);
|
||||
} else if (currentPage >= totalPages - 3) {
|
||||
pages.push(
|
||||
1,
|
||||
"...",
|
||||
totalPages - 4,
|
||||
totalPages - 3,
|
||||
totalPages - 2,
|
||||
totalPages - 1,
|
||||
totalPages,
|
||||
);
|
||||
} else {
|
||||
pages.push(
|
||||
1,
|
||||
"...",
|
||||
currentPage - 1,
|
||||
currentPage,
|
||||
currentPage + 1,
|
||||
"...",
|
||||
totalPages,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return pages;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col bg-white rounded-[14px] shadow-sm border border-gray-100 overflow-hidden">
|
||||
|
||||
{/* Top Header Section */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-100">
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
{onSearchChange !== undefined && (
|
||||
<div className="w-80">
|
||||
<CustomInput
|
||||
<CustomInput
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchValue}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
leftIcon={<Search size={18} />}
|
||||
leftIcon={<MagnifyingGlassIcon size={18} />}
|
||||
size="sm"
|
||||
containerClassName="!gap-0"
|
||||
/>
|
||||
@@ -86,10 +120,8 @@ export function CustomTable<T>({
|
||||
)}
|
||||
{leftHeaderActions}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{rightHeaderActions}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">{rightHeaderActions}</div>
|
||||
</div>
|
||||
|
||||
{/* Table Section */}
|
||||
@@ -98,14 +130,24 @@ export function CustomTable<T>({
|
||||
<thead>
|
||||
<tr className="bg-[#F3F6F5] border-b border-[#F9FAFB]">
|
||||
{columns.map((col, index) => (
|
||||
<th
|
||||
key={index}
|
||||
<th
|
||||
key={index}
|
||||
className={`py-4 px-6 text-[13px] font-semibold text-gray-500 whitespace-nowrap ${col.className || ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{col.header}
|
||||
{col.sortable && <ArrowDownUp size={14} className="cursor-pointer hover:text-gray-900" />}
|
||||
{col.filterable && <Filter size={14} className="cursor-pointer hover:text-gray-900" />}
|
||||
{col.sortable && (
|
||||
<ArrowsDownUpIcon
|
||||
size={14}
|
||||
className="cursor-pointer hover:text-gray-900"
|
||||
/>
|
||||
)}
|
||||
{col.filterable && (
|
||||
<FunnelSimpleIcon
|
||||
size={14}
|
||||
className="cursor-pointer hover:text-gray-900"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
@@ -114,13 +156,16 @@ export function CustomTable<T>({
|
||||
<tbody>
|
||||
{data.length > 0 ? (
|
||||
data.map((row, rowIndex) => (
|
||||
<tr
|
||||
key={rowIndex}
|
||||
<tr
|
||||
key={rowIndex}
|
||||
onClick={() => onRowClick?.(row)}
|
||||
className={`border-b border-gray-50 hover:bg-gray-50/50 transition-colors ${onRowClick ? "cursor-pointer" : ""}`}
|
||||
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 || ''}`}>
|
||||
<td
|
||||
key={colIndex}
|
||||
className={`py-5 px-6 ${col.className || ""}`}
|
||||
>
|
||||
{typeof col.accessor === "function"
|
||||
? col.accessor(row)
|
||||
: (row[col.accessor] as React.ReactNode)}
|
||||
@@ -130,7 +175,10 @@ export function CustomTable<T>({
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="py-12 text-center text-gray-500 text-sm">
|
||||
<td
|
||||
colSpan={columns.length}
|
||||
className="py-12 text-center text-gray-500 text-sm"
|
||||
>
|
||||
No {itemName} found.
|
||||
</td>
|
||||
</tr>
|
||||
@@ -142,41 +190,49 @@ export function CustomTable<T>({
|
||||
{/* Pagination Footer */}
|
||||
<div className="flex items-center justify-between py-4 px-6 bg-[#F3F6F5] border-t border-[#E4E9F2]">
|
||||
<div className="text-[13px] font-medium text-gray-500">
|
||||
Showing {totalItems > 0 ? startIndex : 0} to {endIndex} of {totalItems} {itemName}
|
||||
Showing {totalItems > 0 ? startIndex : 0} to {endIndex} of{" "}
|
||||
{totalItems} {itemName}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
<button
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-lg border border-[#9FACA1] bg-white text-[#9FACA1] hover:bg-gray-50 hover:text-gray-900 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
<CaretLeftIcon size={16} />
|
||||
</button>
|
||||
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{getPageNumbers().map(page => (
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => handlePageChange(page)}
|
||||
className={`w-8 h-8 flex items-center justify-center rounded-lg text-sm font-semibold transition-colors
|
||||
${currentPage === page
|
||||
? "bg-gradient-to-b from-primary to-primary-dark text-white"
|
||||
: "bg-white text-[#9FACA1] border border-[#9FACA1] hover:bg-gray-50 hover:text-gray-900"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
))}
|
||||
{getPageNumbers().map((page, index) =>
|
||||
page === "..." ? (
|
||||
<span key={`ellipsis-${index}`} className="px-2 text-gray-500">
|
||||
...
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => handlePageChange(page as number)}
|
||||
className={`w-8 h-8 flex items-center justify-center rounded-lg text-sm font-semibold transition-colors
|
||||
${
|
||||
currentPage === page
|
||||
? "bg-[#1E7D5C] text-white border-none"
|
||||
: "bg-white text-[#9FACA1] border border-[#9FACA1] hover:bg-gray-50 hover:text-gray-900"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
<button
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-lg border border-[#9FACA1] bg-white text-[#9FACA1] hover:bg-gray-50 hover:text-gray-900 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronRight size={16} />
|
||||
<CaretRightIcon size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import React, { forwardRef } from "react";
|
||||
import React, { useState, forwardRef, useEffect } from "react";
|
||||
|
||||
export interface ValidationJsonRule {
|
||||
min?: number;
|
||||
max?: number;
|
||||
regex?: string;
|
||||
}
|
||||
|
||||
interface CustomTextAreaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
@@ -8,6 +14,7 @@ interface CustomTextAreaProps
|
||||
maxLength?: number;
|
||||
error?: string;
|
||||
size?: "sm" | "md" | "lg";
|
||||
validationJson?: ValidationJsonRule | string;
|
||||
}
|
||||
|
||||
const CustomTextArea = forwardRef<HTMLTextAreaElement, CustomTextAreaProps>(
|
||||
@@ -16,21 +23,95 @@ const CustomTextArea = forwardRef<HTMLTextAreaElement, CustomTextAreaProps>(
|
||||
label,
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
maxLength,
|
||||
maxLength = 500,
|
||||
disabled,
|
||||
className = "",
|
||||
rows = 4,
|
||||
size = "md",
|
||||
error,
|
||||
onInput,
|
||||
onChange,
|
||||
validationJson,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [validationError, setValidationError] = useState<string>("");
|
||||
|
||||
const sizeClasses = {
|
||||
sm: "py-2 text-sm",
|
||||
md: "py-2.5 text-sm",
|
||||
lg: "py-3 text-base",
|
||||
};
|
||||
|
||||
const parsedValJson: ValidationJsonRule | null = (() => {
|
||||
if (!validationJson) return null;
|
||||
if (typeof validationJson === "string") {
|
||||
try {
|
||||
return JSON.parse(validationJson);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return validationJson;
|
||||
})();
|
||||
|
||||
const effectiveMax = parsedValJson?.max !== undefined ? Number(parsedValJson.max) : maxLength;
|
||||
const effectiveMin = parsedValJson?.min !== undefined ? Number(parsedValJson.min) : undefined;
|
||||
const effectiveRegex = parsedValJson?.regex || undefined;
|
||||
|
||||
const validateInput = (val: string) => {
|
||||
const fieldLabel = label ? `"${label}"` : "This field";
|
||||
|
||||
if (effectiveMax !== undefined && effectiveMax > 0) {
|
||||
if (val.length > effectiveMax) {
|
||||
setValidationError(`${fieldLabel} cannot exceed ${effectiveMax} characters`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (effectiveMin !== undefined && effectiveMin > 0) {
|
||||
if (val.length > 0 && val.length < effectiveMin) {
|
||||
setValidationError(`${fieldLabel} must be at least ${effectiveMin} characters`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (effectiveRegex && val.length > 0) {
|
||||
try {
|
||||
const reg = new RegExp(effectiveRegex, "u");
|
||||
if (!reg.test(val)) {
|
||||
setValidationError(`Invalid format for ${fieldLabel}`);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// ignore invalid regex string syntax
|
||||
}
|
||||
}
|
||||
|
||||
setValidationError("");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (props.value !== undefined && props.value !== null) {
|
||||
validateInput(String(props.value));
|
||||
}
|
||||
}, [props.value, effectiveMax, effectiveMin, effectiveRegex, label]);
|
||||
|
||||
const handleInput = (e: React.FormEvent<HTMLTextAreaElement>) => {
|
||||
const target = e.target as HTMLTextAreaElement;
|
||||
validateInput(target.value);
|
||||
onInput?.(e as any);
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const target = e.target as HTMLTextAreaElement;
|
||||
validateInput(target.value);
|
||||
onChange?.(e);
|
||||
};
|
||||
|
||||
const displayError = error || validationError;
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col gap-1.5">
|
||||
{label && (
|
||||
@@ -50,12 +131,14 @@ const CustomTextArea = forwardRef<HTMLTextAreaElement, CustomTextAreaProps>(
|
||||
<textarea
|
||||
ref={ref}
|
||||
rows={rows}
|
||||
maxLength={maxLength}
|
||||
maxLength={effectiveMax}
|
||||
onInput={handleInput}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
className={`
|
||||
w-full rounded-lg
|
||||
bg-white text-gray-900
|
||||
border ${props.error ? 'border-red-500' : 'border-gray-300'}
|
||||
border ${displayError ? 'border-red-500' : 'border-gray-300'}
|
||||
px-3 ${sizeClasses[size]}
|
||||
outline-none
|
||||
transition-all duration-200
|
||||
@@ -77,7 +160,7 @@ const CustomTextArea = forwardRef<HTMLTextAreaElement, CustomTextAreaProps>(
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{props.error && <p className="text-xs text-red-500 mt-1">{props.error}</p>}
|
||||
{displayError && <p className="text-xs text-red-500 mt-1">{displayError}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { forwardRef } from "react";
|
||||
import { Clock } from "lucide-react";
|
||||
import { ClockIcon } from "@phosphor-icons/react";
|
||||
|
||||
interface CustomTimePickerProps
|
||||
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type"> {
|
||||
@@ -42,7 +42,7 @@ const CustomTimePicker = forwardRef<HTMLInputElement, CustomTimePickerProps>(
|
||||
{...props}
|
||||
/>
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none">
|
||||
<Clock size={18} />
|
||||
<ClockIcon size={18} />
|
||||
</span>
|
||||
</div>
|
||||
{error && <p className="text-xs text-red-500 mt-0.5">{error}</p>}
|
||||
|
||||
@@ -23,7 +23,8 @@ import CustomAlertBanner from "./CustomAlertBanner";
|
||||
import Skeleton from "./CustomSkeleton";
|
||||
import CustomTimePicker from "./CustomTimePicker";
|
||||
import CustomAccordionSection from "./CustomAccordionSection";
|
||||
|
||||
import CustomFullModal from "./CustomFullModal";
|
||||
import CustomSuccessModal from "./CustomSuccessModal";
|
||||
export {
|
||||
CustomInput,
|
||||
CustomTextArea,
|
||||
@@ -37,6 +38,7 @@ export {
|
||||
CustomRadio,
|
||||
CustomSwitch,
|
||||
CustomModal,
|
||||
CustomFullModal,
|
||||
CustomConfirmationModal,
|
||||
CustomDatePicker,
|
||||
CustomDateTimePicker,
|
||||
@@ -49,5 +51,6 @@ export {
|
||||
CustomAlertBanner,
|
||||
Skeleton,
|
||||
CustomTimePicker,
|
||||
CustomAccordionSection
|
||||
CustomAccordionSection,
|
||||
CustomSuccessModal
|
||||
};
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
|
||||
import { ApiClient } from '../app/api/ApiClient';
|
||||
|
||||
export interface UserRole {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string;
|
||||
isSystem: boolean;
|
||||
}
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName?: string;
|
||||
fullName: string;
|
||||
roleId: string;
|
||||
role: UserRole;
|
||||
tenantId?: string | null;
|
||||
isActive: boolean;
|
||||
isSystem: boolean;
|
||||
permissions: string[];
|
||||
lastLoginAt?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
user: AuthUser | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
login: (email: string, password: string) => Promise<AuthUser>;
|
||||
logout: () => Promise<void>;
|
||||
refreshProfile: () => Promise<void>;
|
||||
hasPermission: (permissionCode: string) => boolean;
|
||||
hasAnyPermission: (permissionCodes: string[]) => boolean;
|
||||
hasAllPermissions: (permissionCodes: string[]) => boolean;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
|
||||
// Verify active session on app boot
|
||||
const refreshProfile = useCallback(async () => {
|
||||
try {
|
||||
const profile = await ApiClient.get<any, AuthUser>('/auth/me');
|
||||
setUser(profile);
|
||||
} catch {
|
||||
setUser(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshProfile();
|
||||
}, [refreshProfile]);
|
||||
|
||||
const login = async (email: string, password: string): Promise<AuthUser> => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await ApiClient.post<any, { accessToken: string; user: AuthUser }>(
|
||||
'/auth/login',
|
||||
{ email, password }
|
||||
);
|
||||
setUser(response.user);
|
||||
return response.user;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
await ApiClient.post('/auth/logout');
|
||||
} catch (e) {
|
||||
console.warn('Logout API error:', e);
|
||||
} finally {
|
||||
setUser(null);
|
||||
window.location.href = '/login';
|
||||
}
|
||||
};
|
||||
|
||||
const hasPermission = useCallback(
|
||||
(permissionCode: string): boolean => {
|
||||
if (!user) return false;
|
||||
// Super admin has all permissions
|
||||
if (user.role?.slug === 'super_admin' || user.isSystem) return true;
|
||||
return user.permissions?.includes(permissionCode) ?? false;
|
||||
},
|
||||
[user]
|
||||
);
|
||||
|
||||
const hasAnyPermission = useCallback(
|
||||
(permissionCodes: string[]): boolean => {
|
||||
if (!user) return false;
|
||||
if (user.role?.slug === 'super_admin' || user.isSystem) return true;
|
||||
return permissionCodes.some((code) => user.permissions?.includes(code));
|
||||
},
|
||||
[user]
|
||||
);
|
||||
|
||||
const hasAllPermissions = useCallback(
|
||||
(permissionCodes: string[]): boolean => {
|
||||
if (!user) return false;
|
||||
if (user.role?.slug === 'super_admin' || user.isSystem) return true;
|
||||
return permissionCodes.every((code) => user.permissions?.includes(code));
|
||||
},
|
||||
[user]
|
||||
);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
isAuthenticated: !!user,
|
||||
isLoading,
|
||||
login,
|
||||
logout,
|
||||
refreshProfile,
|
||||
hasPermission,
|
||||
hasAnyPermission,
|
||||
hasAllPermissions,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export default AuthContext;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,9 +1,18 @@
|
||||
import { Bell, Menu } from 'lucide-react';
|
||||
import { BellIcon, ListIcon } from '@phosphor-icons/react';
|
||||
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.' },
|
||||
'/simulation': { title: 'Simulation Terminal', subtitle: 'Integrated manifest-level assessment engine.' },
|
||||
'/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.' },
|
||||
'/users': { title: 'User Management', subtitle: 'Provision accounts, assign role permissions, and control tenant access.' },
|
||||
'/roles': { title: 'Roles & Permissions', subtitle: 'Configure role-based access control and granular permission matrices.' },
|
||||
'/audit-logs': { title: 'Audit Trail & Compliance', subtitle: 'Immutable chronological event ledger for operational compliance.' },
|
||||
};
|
||||
|
||||
interface AppHeaderProps {
|
||||
@@ -21,7 +30,7 @@ export default function AppHeader({ onMenuClick }: AppHeaderProps) {
|
||||
onClick={onMenuClick}
|
||||
className="lg:hidden p-2 text-slate-500 hover:bg-slate-100 rounded-lg transition-colors"
|
||||
>
|
||||
<Menu size={24} strokeWidth={2} />
|
||||
<ListIcon size={24} weight="bold" />
|
||||
</button>
|
||||
<div className="flex flex-col justify-center">
|
||||
<h1 className="text-lg md:text-[18px] font-bold text-[#111827] tracking-tight">{meta.title}</h1>
|
||||
@@ -30,7 +39,7 @@ export default function AppHeader({ onMenuClick }: AppHeaderProps) {
|
||||
</div>
|
||||
<div className="flex items-center gap-2 md:gap-4">
|
||||
<button className="relative p-2 text-slate-500 hover:bg-slate-100 rounded-full transition-colors">
|
||||
<Bell size={20} strokeWidth={2} />
|
||||
<BellIcon size={20} weight="bold" />
|
||||
<span className="absolute top-2 right-2 w-2 h-2 bg-red-500 rounded-full border-[1.5px] border-white"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user