Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6058d3cb26 | ||
|
|
0f026fe2dc | ||
|
|
73e233f129 | ||
|
|
3e01d757c7 |
Generated
-10
@@ -12,7 +12,6 @@
|
||||
"@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",
|
||||
@@ -2842,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",
|
||||
|
||||
+1
-2
@@ -23,7 +23,6 @@
|
||||
"@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",
|
||||
@@ -48,4 +47,4 @@
|
||||
"typescript-eslint": "^8.59.2",
|
||||
"vite": "^8.0.12"
|
||||
}
|
||||
}
|
||||
}
|
||||
+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;
|
||||
|
||||
+147
-32
@@ -1,42 +1,157 @@
|
||||
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 HomePage = lazy(() => import('./app/dashboard'))
|
||||
const CohortManage = lazy(() => import('./app/cohartManage'))
|
||||
const PolicyEngineList = lazy(() => import('./app/policyEngine/components/PolicyEngineList'))
|
||||
const AddPolicyEngine = lazy(() => import('./app/policyEngine/components/AddPolicyEngine'))
|
||||
const RecoveryIncidentsList = lazy(() => import('./app/recoveryIncidents/components/RecoveryIncidentsList'))
|
||||
const RecoveryIncidentTabs = lazy(() => import('./app/recoveryIncidents/tabs/index'))
|
||||
const AuditLogsList = lazy(() => import('./app/auditLogs/components/AuditLogsList'))
|
||||
const ConfigurationPage = lazy(() => import('./app/configuration'))
|
||||
const SimulationPage = lazy(() => import('./app/simulation'))
|
||||
const ChatbotPage = lazy(() => import('./app/chatbot'))
|
||||
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={null}>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/chatbot" element={<ChatbotPage />} />
|
||||
<Route path="/simulation" element={<SimulationPage />} />
|
||||
<Route path="/cohorts" element={<CohortManage />} />
|
||||
<Route path="/policy-engine" element={<PolicyEngineList />} />
|
||||
<Route path="/policy-engine/add" element={<AddPolicyEngine />} />
|
||||
<Route path="/policy-engine/edit/:id" element={<AddPolicyEngine />} />
|
||||
<Route
|
||||
path="/action-builder"
|
||||
element={<Navigate to="/config?tab=action-builder" replace />}
|
||||
/>
|
||||
<Route path="/config" element={<ConfigurationPage />} />
|
||||
<Route path="/recovery" element={<RecoveryIncidentsList />} />
|
||||
<Route path="/recovery/:id" element={<RecoveryIncidentTabs />} />
|
||||
<Route path="/audit-logs" element={<AuditLogsList />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</Layout>
|
||||
<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>
|
||||
{/* 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>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+65
-15
@@ -1,24 +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) => {
|
||||
@@ -26,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;
|
||||
|
||||
@@ -18,6 +18,7 @@ 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;
|
||||
|
||||
@@ -335,14 +336,16 @@ export default function AuditLogsList() {
|
||||
className="!border-[#1E8E3E] !bg-[#E6F4EA] !h-[40px] hover:!bg-[#E6F4EA]"
|
||||
/>
|
||||
</div>
|
||||
<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 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}
|
||||
|
||||
@@ -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,172 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { ConversationList } from './components/ConversationList';
|
||||
import { ChatWindow } from './components/ChatWindow';
|
||||
import {
|
||||
getConversations,
|
||||
createConversation,
|
||||
getConversationMessages,
|
||||
sendMessage as sendChatMessage,
|
||||
deleteConversation as deleteChatConversation,
|
||||
} from './services/chatApi';
|
||||
import type { Conversation, ChatMessage } from './types/ChatTypes';
|
||||
|
||||
export default function ChatbotPage() {
|
||||
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||
const [activeConversationId, setActiveConversationId] = useState<string | null>(null);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [loadingConversations, setLoadingConversations] = useState(true);
|
||||
const [loadingMessages, setLoadingMessages] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Fetch initial user conversations
|
||||
const loadConversations = useCallback(async (autoSelectFirst = true) => {
|
||||
setLoadingConversations(true);
|
||||
try {
|
||||
const list = await getConversations();
|
||||
setConversations(list);
|
||||
if (autoSelectFirst && list.length > 0 && !activeConversationId) {
|
||||
setActiveConversationId(list[0].id);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load conversations:', err);
|
||||
setError('Unable to load conversations. Please try again.');
|
||||
} finally {
|
||||
setLoadingConversations(false);
|
||||
}
|
||||
}, [activeConversationId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadConversations();
|
||||
}, [loadConversations]);
|
||||
|
||||
// Fetch messages when active conversation changes
|
||||
useEffect(() => {
|
||||
if (!activeConversationId) {
|
||||
setMessages([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingMessages(true);
|
||||
getConversationMessages(activeConversationId)
|
||||
.then((msgs) => setMessages(msgs))
|
||||
.catch((err) => {
|
||||
console.error('Failed to load messages:', err);
|
||||
setError('Failed to retrieve message history.');
|
||||
})
|
||||
.finally(() => setLoadingMessages(false));
|
||||
}, [activeConversationId]);
|
||||
|
||||
// Handle "+ New Conversation"
|
||||
const handleNewConversation = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
const newConv = await createConversation('New Conversation');
|
||||
setConversations((prev) => [newConv, ...prev]);
|
||||
setActiveConversationId(newConv.id);
|
||||
setMessages([]);
|
||||
} catch (err) {
|
||||
console.error('Failed to create conversation:', err);
|
||||
setError('Failed to create new conversation.');
|
||||
}
|
||||
};
|
||||
|
||||
// Handle sending a message
|
||||
const handleSendMessage = async (text: string) => {
|
||||
setError(null);
|
||||
let convId = activeConversationId;
|
||||
|
||||
// Auto-create conversation if none selected
|
||||
if (!convId) {
|
||||
try {
|
||||
const newConv = await createConversation(text.length > 30 ? text.substring(0, 30) + '...' : text);
|
||||
setConversations((prev) => [newConv, ...prev]);
|
||||
convId = newConv.id;
|
||||
setActiveConversationId(convId);
|
||||
} catch (err) {
|
||||
console.error('Failed to create initial conversation:', err);
|
||||
setError('Failed to initiate conversation.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Optimistically add user message to UI
|
||||
const tempUserMsg: ChatMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
conversationId: convId,
|
||||
role: 'user',
|
||||
content: text,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
setMessages((prev) => [...prev, tempUserMsg]);
|
||||
setSending(true);
|
||||
|
||||
try {
|
||||
const response = await sendChatMessage({
|
||||
conversationId: convId,
|
||||
message: text,
|
||||
});
|
||||
|
||||
const assistantMsg: ChatMessage = {
|
||||
id: response.messageId,
|
||||
conversationId: response.conversationId,
|
||||
role: response.role,
|
||||
content: response.content,
|
||||
createdAt: response.createdAt,
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, assistantMsg]);
|
||||
// Refresh list to update conversation title/timestamps
|
||||
loadConversations(false);
|
||||
} catch (err: any) {
|
||||
console.error('Failed to send message:', err);
|
||||
setError(
|
||||
err.response?.data?.message ||
|
||||
'Unable to connect to the chatbot service. Please try again.',
|
||||
);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle deleting a conversation
|
||||
const handleDeleteConversation = async (id: string) => {
|
||||
try {
|
||||
await deleteChatConversation(id);
|
||||
const updated = conversations.filter((c) => c.id !== id);
|
||||
setConversations(updated);
|
||||
if (activeConversationId === id) {
|
||||
setActiveConversationId(updated.length > 0 ? updated[0].id : null);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete conversation:', err);
|
||||
setError('Failed to delete conversation.');
|
||||
}
|
||||
};
|
||||
|
||||
const activeConversation =
|
||||
conversations.find((c) => c.id === activeConversationId) || null;
|
||||
|
||||
return (
|
||||
<div className="w-full h-[calc(100vh-80px)] flex bg-white rounded-2xl border border-slate-200/80 shadow-sm overflow-hidden">
|
||||
<ConversationList
|
||||
conversations={conversations}
|
||||
activeId={activeConversationId}
|
||||
loading={loadingConversations}
|
||||
onSelect={setActiveConversationId}
|
||||
onNew={handleNewConversation}
|
||||
onDelete={handleDeleteConversation}
|
||||
/>
|
||||
|
||||
<ChatWindow
|
||||
activeConversation={activeConversation}
|
||||
messages={messages}
|
||||
loadingMessages={loadingMessages}
|
||||
sending={sending}
|
||||
error={error}
|
||||
onClearError={() => setError(null)}
|
||||
onSendMessage={handleSendMessage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import { useState, useRef, type KeyboardEvent } from 'react';
|
||||
import { PaperPlaneRightIcon } from '@phosphor-icons/react';
|
||||
import { CustomButton } from '../../../components/custom';
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (message: string) => void;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function ChatInput({
|
||||
onSend,
|
||||
disabled = false,
|
||||
placeholder = 'Ask Aero Resolve AI anything about IROPS, policies, or cohorts...',
|
||||
}: ChatInputProps) {
|
||||
const [text, setText] = useState('');
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const handleSend = () => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed || disabled) return;
|
||||
onSend(trimmed);
|
||||
setText('');
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
const handleTextChange = (val: string) => {
|
||||
setText(val);
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 140)}px`;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-slate-200/80 rounded-2xl p-2 shadow-md transition-all focus-within:border-[#1B9869] focus-within:ring-2 focus-within:ring-[#1B9869]/10">
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={text}
|
||||
disabled={disabled}
|
||||
onChange={(e) => handleTextChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
rows={1}
|
||||
className="flex-1 resize-none bg-transparent px-3 py-2 text-[14px] text-slate-800 placeholder-slate-400 focus:outline-none max-h-[140px] scrollbar-thin"
|
||||
/>
|
||||
|
||||
<CustomButton
|
||||
variant="primary"
|
||||
onClick={handleSend}
|
||||
disabled={disabled || !text.trim()}
|
||||
className="!bg-[#1B9869] hover:!bg-[#14704E] !rounded-xl !p-2.5 !h-[42px] !w-[42px] flex items-center justify-center shrink-0 disabled:opacity-40 transition-all"
|
||||
>
|
||||
<PaperPlaneRightIcon size={18} weight="bold" />
|
||||
</CustomButton>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between px-3 pt-1.5 pb-0.5 text-[11px] text-slate-400">
|
||||
<span>Press <kbd className="font-semibold text-slate-500">Enter</kbd> to send, <kbd className="font-semibold text-slate-500">Shift + Enter</kbd> for new line</span>
|
||||
<span>{text.length} / 4000</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { UserIcon, CopyIcon, CheckIcon } from '@phosphor-icons/react';
|
||||
import { SparkleIcon } from 'lucide-react';
|
||||
import type { ChatMessage } from '../types/ChatTypes';
|
||||
import { formatDate } from '../../../utils/formatDate';
|
||||
|
||||
interface ChatMessageItemProps {
|
||||
message: ChatMessage;
|
||||
}
|
||||
|
||||
export function ChatMessageItem({ message }: ChatMessageItemProps) {
|
||||
const isUser = message.role === 'user';
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(message.content);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const formattedTime = (() => {
|
||||
try {
|
||||
return formatDate(message.createdAt);
|
||||
} catch {
|
||||
return new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
})();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex gap-3 my-4 group ${
|
||||
isUser ? 'flex-row-reverse' : 'flex-row'
|
||||
}`}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div
|
||||
className={`w-9 h-9 rounded-xl flex items-center justify-center shrink-0 shadow-sm ${
|
||||
isUser
|
||||
? 'bg-[#1B9869] text-white'
|
||||
: 'bg-emerald-50 text-[#14704E] border border-emerald-200/60'
|
||||
}`}
|
||||
>
|
||||
{isUser ? <UserIcon size={18} weight="bold" /> : <SparkleIcon size={18} />}
|
||||
</div>
|
||||
|
||||
{/* Message Body & Actions */}
|
||||
<div
|
||||
className={`flex flex-col max-w-[78%] ${
|
||||
isUser ? 'items-end' : 'items-start'
|
||||
}`}
|
||||
>
|
||||
{/* Name and Time Header */}
|
||||
<div className="flex items-center gap-2 mb-1 px-1">
|
||||
<span className="text-[11px] font-bold text-slate-500 tracking-wide uppercase">
|
||||
{isUser ? 'You' : 'Aero Resolve AI'}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400 font-medium">
|
||||
{formattedTime}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Message Bubble */}
|
||||
<div
|
||||
className={`relative px-4 py-3 rounded-2xl text-[14px] leading-relaxed shadow-sm transition-all ${
|
||||
isUser
|
||||
? 'bg-[#1B9869] text-white rounded-tr-none'
|
||||
: 'bg-white text-slate-800 border border-slate-100 rounded-tl-none font-normal'
|
||||
}`}
|
||||
>
|
||||
<div className="whitespace-pre-wrap break-words">{message.content}</div>
|
||||
|
||||
{/* Copy Action Button for Assistant */}
|
||||
{!isUser && (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
title="Copy response"
|
||||
className="absolute right-2 top-2 opacity-0 group-hover:opacity-100 transition-opacity p-1 text-slate-400 hover:text-slate-700 hover:bg-slate-100 rounded-lg"
|
||||
>
|
||||
{copied ? (
|
||||
<CheckIcon size={14} className="text-emerald-600" />
|
||||
) : (
|
||||
<CopyIcon size={14} />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { SparkleIcon } from 'lucide-react';
|
||||
import { ChatMessageItem } from './ChatMessageItem';
|
||||
import { ChatInput } from './ChatInput';
|
||||
import { CustomAlertBanner, Skeleton } from '../../../components/custom';
|
||||
import type { ChatMessage, Conversation } from '../types/ChatTypes';
|
||||
|
||||
interface ChatWindowProps {
|
||||
activeConversation: Conversation | null;
|
||||
messages: ChatMessage[];
|
||||
loadingMessages: boolean;
|
||||
sending: boolean;
|
||||
error: string | null;
|
||||
onClearError: () => void;
|
||||
onSendMessage: (text: string) => void;
|
||||
}
|
||||
|
||||
|
||||
export function ChatWindow({
|
||||
activeConversation,
|
||||
messages,
|
||||
loadingMessages,
|
||||
sending,
|
||||
error,
|
||||
onClearError,
|
||||
onSendMessage,
|
||||
}: ChatWindowProps) {
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages, sending]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full bg-white relative overflow-hidden font-sans">
|
||||
{/* Top Header */}
|
||||
<div className="px-6 py-4 border-b border-slate-100 flex items-center justify-between bg-white z-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-tr from-[#1B9869] to-[#14704E] text-white flex items-center justify-center shadow-sm">
|
||||
<SparkleIcon size={20} />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<h2 className="text-[15px] font-bold text-slate-900 leading-tight">
|
||||
{activeConversation?.title || 'Aero Resolve Assistant'}
|
||||
</h2>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span>
|
||||
<span className="text-[11px] font-medium text-slate-500">
|
||||
AI Active & Ready
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alert Error Banner */}
|
||||
{error && (
|
||||
<div className="p-4">
|
||||
<CustomAlertBanner
|
||||
message={error}
|
||||
type="error"
|
||||
onClose={onClearError}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Messages Stream Container */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-2 scrollbar-thin">
|
||||
{loadingMessages ? (
|
||||
<div className="space-y-4 max-w-xl py-6">
|
||||
<Skeleton height={60} className="rounded-2xl" />
|
||||
<Skeleton height={80} className="rounded-2xl w-3/4 ml-auto" />
|
||||
<Skeleton height={70} className="rounded-2xl" />
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
/* Empty State Suggestions */
|
||||
<div className="h-full flex flex-col items-center justify-center max-w-xl mx-auto text-center px-4 py-8">
|
||||
<div className="w-16 h-16 rounded-2xl bg-emerald-50 text-[#1B9869] flex items-center justify-center mb-4 border border-emerald-100 shadow-sm">
|
||||
<SparkleIcon size={32} />
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl font-bold text-slate-900 mb-2">
|
||||
How can I help with Aero Resolve today?
|
||||
</h3>
|
||||
</div>
|
||||
) : (
|
||||
/* Messages List */
|
||||
messages.map((m) => <ChatMessageItem key={m.id} message={m} />)
|
||||
)}
|
||||
|
||||
{/* Typing Indicator */}
|
||||
{sending && (
|
||||
<div className="flex gap-3 my-4 items-start">
|
||||
<div className="w-9 h-9 rounded-xl bg-emerald-50 text-[#14704E] border border-emerald-200/60 flex items-center justify-center shrink-0">
|
||||
<SparkleIcon size={18} />
|
||||
</div>
|
||||
<div className="bg-slate-100 text-slate-500 px-4 py-3 rounded-2xl rounded-tl-none flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full bg-slate-400 animate-bounce"></span>
|
||||
<span className="w-2 h-2 rounded-full bg-slate-400 animate-bounce [animation-delay:0.2s]"></span>
|
||||
<span className="w-2 h-2 rounded-full bg-slate-400 animate-bounce [animation-delay:0.4s]"></span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
{/* Input Container */}
|
||||
<div className="p-4 border-t border-slate-100 bg-white">
|
||||
<ChatInput onSend={onSendMessage} disabled={sending} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
PlusIcon,
|
||||
ChatTeardropTextIcon,
|
||||
TrashIcon,
|
||||
MagnifyingGlassIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
import { CustomButton, CustomInput, Skeleton } from '../../../components/custom';
|
||||
import type { Conversation } from '../types/ChatTypes';
|
||||
import { formatDate } from '../../../utils/formatDate';
|
||||
|
||||
interface ConversationListProps {
|
||||
conversations: Conversation[];
|
||||
activeId: string | null;
|
||||
loading: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
onNew: () => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
export function ConversationList({
|
||||
conversations,
|
||||
activeId,
|
||||
loading,
|
||||
onSelect,
|
||||
onNew,
|
||||
onDelete,
|
||||
}: ConversationListProps) {
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const filteredConversations = conversations.filter((c) =>
|
||||
(c.title || '').toLowerCase().includes(search.toLowerCase()),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-[300px] flex-shrink-0 bg-[#F8FAFC] border-r border-slate-200/80 h-full flex flex-col p-4 font-sans">
|
||||
{/* Action Header */}
|
||||
<div className="flex flex-col gap-3 mb-4">
|
||||
<CustomButton
|
||||
variant="primary"
|
||||
onClick={onNew}
|
||||
leftIcon={<PlusIcon size={16} weight="bold" />}
|
||||
className="w-full !bg-[#1B9869] hover:!bg-[#14704E] !text-white !font-semibold !rounded-xl !py-2.5 shadow-sm"
|
||||
>
|
||||
New Conversation
|
||||
</CustomButton>
|
||||
|
||||
{/* Search Bar */}
|
||||
<CustomInput
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search chats..."
|
||||
leftIcon={<MagnifyingGlassIcon size={16} className="text-slate-400" />}
|
||||
className="!bg-white !rounded-xl !h-[38px] !border-slate-200 !text-[13px]"
|
||||
containerClassName="!gap-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Conversations List */}
|
||||
<div className="flex-1 overflow-y-auto space-y-1.5 pr-1 scrollbar-thin">
|
||||
{loading ? (
|
||||
<div className="space-y-2 pt-2">
|
||||
<Skeleton height={48} className="rounded-xl" />
|
||||
<Skeleton height={48} className="rounded-xl" />
|
||||
<Skeleton height={48} className="rounded-xl" />
|
||||
</div>
|
||||
) : filteredConversations.length === 0 ? (
|
||||
<div className="py-12 text-center text-slate-400 text-xs flex flex-col items-center gap-2">
|
||||
<ChatTeardropTextIcon size={24} className="text-slate-300" />
|
||||
<span>No conversations found</span>
|
||||
</div>
|
||||
) : (
|
||||
filteredConversations.map((c) => {
|
||||
const isActive = c.id === activeId;
|
||||
const displayTime = (() => {
|
||||
try {
|
||||
return formatDate(c.updatedAt);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
})();
|
||||
|
||||
return (
|
||||
<div
|
||||
key={c.id}
|
||||
onClick={() => onSelect(c.id)}
|
||||
className={`group flex items-center justify-between p-3 rounded-xl cursor-pointer transition-all border ${
|
||||
isActive
|
||||
? 'bg-white border-[#1B9869]/30 text-slate-900 shadow-sm font-semibold'
|
||||
: 'bg-transparent border-transparent text-slate-600 hover:bg-slate-200/50 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2.5 min-w-0 flex-1">
|
||||
<ChatTeardropTextIcon
|
||||
size={18}
|
||||
className={isActive ? 'text-[#1B9869]' : 'text-slate-400'}
|
||||
weight={isActive ? 'bold' : 'regular'}
|
||||
/>
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<span className="text-[13px] truncate leading-tight">
|
||||
{c.title || 'New Conversation'}
|
||||
</span>
|
||||
{displayTime && (
|
||||
<span className="text-[10px] text-slate-400 font-normal mt-0.5">
|
||||
{displayTime}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(c.id);
|
||||
}}
|
||||
title="Delete conversation"
|
||||
className="opacity-0 group-hover:opacity-100 p-1 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-all"
|
||||
>
|
||||
<TrashIcon size={14} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,427 +0,0 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ChatTeardropTextIcon,
|
||||
XIcon,
|
||||
PlusCircleIcon,
|
||||
ArrowSquareOutIcon,
|
||||
PaperPlaneRightIcon,
|
||||
ArrowsClockwiseIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
import { SparkleIcon } from 'lucide-react';
|
||||
import { ChatMessageItem } from './ChatMessageItem';
|
||||
import { CustomAlertBanner, Skeleton } from '../../../components/custom';
|
||||
import {
|
||||
getConversations,
|
||||
createConversation,
|
||||
getConversationMessages,
|
||||
sendMessage as sendChatMessage,
|
||||
} from '../services/chatApi';
|
||||
import type { Conversation, ChatMessage } from '../types/ChatTypes';
|
||||
|
||||
const SUGGESTIONS = [
|
||||
{
|
||||
title: 'What is IROPS?',
|
||||
desc: 'Understand Irregular Operations & flight disruption policies.',
|
||||
},
|
||||
{
|
||||
title: 'Policy Engine Overview',
|
||||
desc: 'How business rules, compensation, and gates evaluate.',
|
||||
},
|
||||
{
|
||||
title: 'Cohort Targeting Rules',
|
||||
desc: 'Passenger segmentation by tier, cabin class, & route.',
|
||||
},
|
||||
];
|
||||
|
||||
export function FloatingChatWidget() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||
const [activeConversationId, setActiveConversationId] = useState<string | null>(null);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [loadingConversations, setLoadingConversations] = useState(false);
|
||||
const [loadingMessages, setLoadingMessages] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [inputText, setInputText] = useState('');
|
||||
const [hasUnread, setHasUnread] = useState(false);
|
||||
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// If we're on the dedicated /chatbot page, hide the floating widget to avoid duplication
|
||||
const isChatbotPage = location.pathname === '/chatbot';
|
||||
|
||||
// Load conversations
|
||||
const loadConversations = useCallback(async (autoSelectFirst = true) => {
|
||||
setLoadingConversations(true);
|
||||
try {
|
||||
const list = await getConversations();
|
||||
setConversations(list);
|
||||
if (autoSelectFirst && list.length > 0 && !activeConversationId) {
|
||||
setActiveConversationId(list[0].id);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load conversations in widget:', err);
|
||||
} finally {
|
||||
setLoadingConversations(false);
|
||||
}
|
||||
}, [activeConversationId]);
|
||||
|
||||
// When widget opens for the first time, load conversations
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setHasUnread(false);
|
||||
loadConversations();
|
||||
}
|
||||
}, [isOpen, loadConversations]);
|
||||
|
||||
// Fetch messages when active conversation changes
|
||||
useEffect(() => {
|
||||
if (!isOpen || !activeConversationId) {
|
||||
setMessages([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingMessages(true);
|
||||
getConversationMessages(activeConversationId)
|
||||
.then((msgs) => setMessages(msgs))
|
||||
.catch((err) => {
|
||||
console.error('Failed to load messages in widget:', err);
|
||||
setError('Failed to retrieve message history.');
|
||||
})
|
||||
.finally(() => setLoadingMessages(false));
|
||||
}, [isOpen, activeConversationId]);
|
||||
|
||||
// Auto-scroll to bottom on new messages or sending
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}, [messages, sending, isOpen]);
|
||||
|
||||
// Handle "+ New Conversation"
|
||||
const handleNewConversation = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
const newConv = await createConversation('New Conversation');
|
||||
setConversations((prev) => [newConv, ...prev]);
|
||||
setActiveConversationId(newConv.id);
|
||||
setMessages([]);
|
||||
} catch (err) {
|
||||
console.error('Failed to create new conversation:', err);
|
||||
setError('Failed to create new conversation.');
|
||||
}
|
||||
};
|
||||
|
||||
// Handle sending message
|
||||
const handleSendMessage = async (text: string) => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed || sending) return;
|
||||
|
||||
setError(null);
|
||||
let convId = activeConversationId;
|
||||
|
||||
// Auto-create conversation if none selected
|
||||
if (!convId) {
|
||||
try {
|
||||
const title = trimmed.length > 30 ? trimmed.substring(0, 30) + '...' : trimmed;
|
||||
const newConv = await createConversation(title);
|
||||
setConversations((prev) => [newConv, ...prev]);
|
||||
convId = newConv.id;
|
||||
setActiveConversationId(convId);
|
||||
} catch (err) {
|
||||
console.error('Failed to create conversation:', err);
|
||||
setError('Failed to start conversation.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Optimistically add user message
|
||||
const tempUserMsg: ChatMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
conversationId: convId,
|
||||
role: 'user',
|
||||
content: trimmed,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
setMessages((prev) => [...prev, tempUserMsg]);
|
||||
setInputText('');
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
}
|
||||
setSending(true);
|
||||
|
||||
try {
|
||||
const response = await sendChatMessage({
|
||||
conversationId: convId,
|
||||
message: trimmed,
|
||||
});
|
||||
|
||||
const assistantMsg: ChatMessage = {
|
||||
id: response.messageId,
|
||||
conversationId: response.conversationId,
|
||||
role: response.role,
|
||||
content: response.content,
|
||||
createdAt: response.createdAt,
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, assistantMsg]);
|
||||
loadConversations(false);
|
||||
} catch (err: any) {
|
||||
console.error('Failed to send message in widget:', err);
|
||||
setError(
|
||||
err.response?.data?.message ||
|
||||
'Unable to connect to the chatbot service. Please try again.',
|
||||
);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSendMessage(inputText);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTextChange = (val: string) => {
|
||||
setInputText(val);
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 120)}px`;
|
||||
}
|
||||
};
|
||||
|
||||
const activeConversation = conversations.find((c) => c.id === activeConversationId);
|
||||
|
||||
if (isChatbotPage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Floating Chat Modal Window */}
|
||||
{isOpen && (
|
||||
<div className="fixed bottom-24 right-6 z-50 w-[420px] max-w-[calc(100vw-32px)] h-[620px] max-h-[calc(100vh-120px)] bg-white rounded-[24px] shadow-[0_20px_60px_-15px_rgba(0,0,0,0.25)] border border-slate-200/90 flex flex-col overflow-hidden animate-in fade-in zoom-in-95 duration-200 font-sans">
|
||||
{/* Header */}
|
||||
<div className="px-5 py-3.5 bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 text-white flex items-center justify-between shadow-sm shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-tr from-[#1B9869] to-[#14704E] text-white flex items-center justify-center shadow-sm shrink-0">
|
||||
<SparkleIcon size={18} />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-[14px] font-bold text-white leading-tight">
|
||||
Aero Resolve Assistant
|
||||
</h3>
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-semibold bg-emerald-500/20 text-emerald-400 border border-emerald-500/30">
|
||||
AI
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
||||
<span className="text-[11px] text-slate-300 font-medium">
|
||||
Online & Ready
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Header Actions */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNewConversation}
|
||||
title="New Chat"
|
||||
className="p-2 text-slate-300 hover:text-white hover:bg-white/10 rounded-xl transition-colors"
|
||||
>
|
||||
<PlusCircleIcon size={18} weight="bold" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
navigate('/chatbot');
|
||||
}}
|
||||
title="Open in Full Page"
|
||||
className="p-2 text-slate-300 hover:text-white hover:bg-white/10 rounded-xl transition-colors"
|
||||
>
|
||||
<ArrowSquareOutIcon size={18} weight="bold" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(false)}
|
||||
title="Close Window"
|
||||
className="p-2 text-slate-300 hover:text-white hover:bg-white/10 rounded-xl transition-colors"
|
||||
>
|
||||
<XIcon size={18} weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Conversation Bar (if multiple conversations exist) */}
|
||||
{conversations.length > 1 && (
|
||||
<div className="px-4 py-2 bg-slate-50 border-b border-slate-100 flex items-center justify-between text-xs text-slate-600">
|
||||
<span className="truncate font-medium max-w-[220px]">
|
||||
{activeConversation?.title || 'Active Conversation'}
|
||||
</span>
|
||||
<select
|
||||
value={activeConversationId || ''}
|
||||
onChange={(e) => setActiveConversationId(e.target.value)}
|
||||
className="text-[11px] bg-white border border-slate-200 rounded-lg px-2 py-1 text-slate-700 outline-none focus:border-[#1B9869]"
|
||||
>
|
||||
{conversations.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.title.length > 25 ? c.title.substring(0, 25) + '...' : c.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Banner */}
|
||||
{error && (
|
||||
<div className="px-4 pt-3 pb-1 shrink-0">
|
||||
<CustomAlertBanner
|
||||
message={error}
|
||||
type="error"
|
||||
onClose={() => setError(null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Messages Stream Container */}
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-2 bg-[#FAFBFB] scrollbar-thin">
|
||||
{loadingMessages ? (
|
||||
<div className="space-y-3 max-w-sm py-4">
|
||||
<Skeleton height={50} className="rounded-2xl" />
|
||||
<Skeleton height={65} className="rounded-2xl w-3/4 ml-auto" />
|
||||
<Skeleton height={55} className="rounded-2xl" />
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
/* Empty State Suggestions */
|
||||
<div className="h-full flex flex-col items-center justify-center text-center px-2 py-4">
|
||||
<div className="w-12 h-12 rounded-2xl bg-emerald-50 text-[#1B9869] flex items-center justify-center mb-3 border border-emerald-100 shadow-sm">
|
||||
<SparkleIcon size={24} />
|
||||
</div>
|
||||
<h4 className="text-[15px] font-bold text-slate-800 mb-1">
|
||||
How can I assist you?
|
||||
</h4>
|
||||
<p className="text-xs text-slate-500 mb-4 max-w-[280px]">
|
||||
Ask questions about IROPS, flight policies, compensation rules, or cohorts.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
{SUGGESTIONS.map((s, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => handleSendMessage(s.title)}
|
||||
className="text-left p-2.5 rounded-xl border border-slate-200/80 bg-white hover:border-[#1B9869] hover:bg-emerald-50/30 transition-all group shadow-2xs"
|
||||
>
|
||||
<div className="text-xs font-bold text-slate-800 group-hover:text-[#1B9869]">
|
||||
{s.title}
|
||||
</div>
|
||||
<div className="text-[11px] text-slate-500 leading-tight mt-0.5">
|
||||
{s.desc}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Message List */
|
||||
messages.map((m) => <ChatMessageItem key={m.id} message={m} />)
|
||||
)}
|
||||
|
||||
{/* Typing Indicator */}
|
||||
{sending && (
|
||||
<div className="flex gap-2.5 my-3 items-start">
|
||||
<div className="w-8 h-8 rounded-xl bg-emerald-50 text-[#14704E] border border-emerald-200/60 flex items-center justify-center shrink-0">
|
||||
<SparkleIcon size={15} />
|
||||
</div>
|
||||
<div className="bg-white text-slate-500 px-3.5 py-2.5 rounded-2xl rounded-tl-none border border-slate-100 flex items-center gap-1.5 shadow-2xs">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-slate-400 animate-bounce" />
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-slate-400 animate-bounce [animation-delay:0.2s]" />
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-slate-400 animate-bounce [animation-delay:0.4s]" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
{/* Footer Input Area */}
|
||||
<div className="p-3 bg-white border-t border-slate-100 shrink-0">
|
||||
<div className="bg-slate-50 border border-slate-200/80 rounded-2xl p-1.5 transition-all focus-within:border-[#1B9869] focus-within:bg-white focus-within:ring-2 focus-within:ring-[#1B9869]/10 shadow-2xs">
|
||||
<div className="flex items-end gap-1.5">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={inputText}
|
||||
disabled={sending}
|
||||
onChange={(e) => handleTextChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Ask Aero AI anything..."
|
||||
rows={1}
|
||||
className="flex-1 resize-none bg-transparent px-2.5 py-1.5 text-[13px] text-slate-800 placeholder-slate-400 focus:outline-none max-h-[120px] scrollbar-thin"
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSendMessage(inputText)}
|
||||
disabled={sending || !inputText.trim()}
|
||||
className="bg-[#1B9869] hover:bg-[#14704E] text-white rounded-xl w-9 h-9 flex items-center justify-center shrink-0 disabled:opacity-30 disabled:hover:bg-[#1B9869] transition-all shadow-sm active:scale-95"
|
||||
title="Send message"
|
||||
>
|
||||
<PaperPlaneRightIcon size={16} weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between px-2 pt-1 text-[10px] text-slate-400">
|
||||
<span>Press <kbd className="font-semibold text-slate-500">Enter</kbd> to send</span>
|
||||
<span>Aero Resolve AI</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Floating Action Button (FAB) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen((prev) => !prev)}
|
||||
aria-label={isOpen ? 'Close chat assistant' : 'Open chat assistant'}
|
||||
className={`fixed bottom-6 right-6 z-50 group flex items-center justify-center w-14 h-14 rounded-full bg-gradient-to-tr from-[#1B9869] to-[#14704E] text-white shadow-[0_8px_25px_rgba(27,152,105,0.45)] hover:shadow-[0_12px_30px_rgba(27,152,105,0.6)] hover:scale-105 active:scale-95 transition-all duration-300 focus:outline-none focus:ring-4 focus:ring-[#1B9869]/30 ${
|
||||
isOpen ? 'rotate-90' : ''
|
||||
}`}
|
||||
>
|
||||
{isOpen ? (
|
||||
<XIcon size={24} weight="bold" className="transition-transform duration-200" />
|
||||
) : (
|
||||
<div className="relative flex items-center justify-center">
|
||||
<ChatTeardropTextIcon size={26} weight="fill" />
|
||||
{/* Sparkle badge */}
|
||||
<span className="absolute -top-1.5 -right-1.5 w-4 h-4 bg-amber-400 text-slate-900 rounded-full flex items-center justify-center shadow-sm">
|
||||
<SparkleIcon size={10} className="fill-slate-900" />
|
||||
</span>
|
||||
{/* Active pulse dot */}
|
||||
<span className="absolute -bottom-1 -left-1 w-2.5 h-2.5 rounded-full bg-emerald-300 border-2 border-[#14704E] animate-pulse" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hover Tooltip (when closed) */}
|
||||
{!isOpen && (
|
||||
<span className="absolute right-16 px-3 py-1.5 bg-slate-900 text-white text-xs font-semibold rounded-xl shadow-lg opacity-0 pointer-events-none group-hover:opacity-100 transition-opacity duration-200 whitespace-nowrap">
|
||||
Ask Aero AI
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default FloatingChatWidget;
|
||||
@@ -1,5 +0,0 @@
|
||||
import ChatbotPage from './ChatbotPage';
|
||||
export { FloatingChatWidget } from './components/FloatingChatWidget';
|
||||
|
||||
export default ChatbotPage;
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { ApiClient } from '../../api/ApiClient';
|
||||
import type {
|
||||
Conversation,
|
||||
ChatMessage,
|
||||
SendMessagePayload,
|
||||
SendMessageResponse,
|
||||
} from '../types/ChatTypes';
|
||||
|
||||
export async function createConversation(title?: string): Promise<Conversation> {
|
||||
return ApiClient.post<any, Conversation>('/chat/conversations', { title });
|
||||
}
|
||||
|
||||
export async function getConversations(): Promise<Conversation[]> {
|
||||
const res = await ApiClient.get<any, { conversations: Conversation[] }>(
|
||||
'/chat/conversations',
|
||||
);
|
||||
return res.conversations || [];
|
||||
}
|
||||
|
||||
export async function getConversationMessages(
|
||||
conversationId: string,
|
||||
): Promise<ChatMessage[]> {
|
||||
const res = await ApiClient.get<
|
||||
any,
|
||||
{ conversationId: string; messages: ChatMessage[] }
|
||||
>(`/chat/conversations/${conversationId}/messages`);
|
||||
return res.messages || [];
|
||||
}
|
||||
|
||||
export async function sendMessage(
|
||||
payload: SendMessagePayload,
|
||||
): Promise<SendMessageResponse> {
|
||||
return ApiClient.post<any, SendMessageResponse>('/chat/messages', payload);
|
||||
}
|
||||
|
||||
export async function deleteConversation(
|
||||
conversationId: string,
|
||||
): Promise<{ success: boolean }> {
|
||||
return ApiClient.delete<any, { success: boolean }>(
|
||||
`/chat/conversations/${conversationId}`,
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
conversationId: string;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Conversation {
|
||||
id: string;
|
||||
userId?: string;
|
||||
title: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface SendMessagePayload {
|
||||
conversationId: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface SendMessageResponse {
|
||||
messageId: string;
|
||||
conversationId: string;
|
||||
role: 'assistant';
|
||||
content: string;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
updateCohartStatus,
|
||||
} from "../CohartManageApi";
|
||||
import type { CohartResponse } from "../CohartManageTypes";
|
||||
import Can from "../../../components/common/Can";
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -191,35 +192,43 @@ export default function CohortList() {
|
||||
accessor: (row) => (
|
||||
<CustomActionMenu>
|
||||
{row.status !== "Active" && (
|
||||
<CustomActionItem
|
||||
icon={<CheckIcon size={15} />}
|
||||
variant="success"
|
||||
onClick={() => setDeactivateTarget(row)}
|
||||
>
|
||||
Activate
|
||||
</CustomActionItem>
|
||||
<Can permission="cohorts:edit">
|
||||
<CustomActionItem
|
||||
icon={<CheckIcon size={15} />}
|
||||
variant="success"
|
||||
onClick={() => setDeactivateTarget(row)}
|
||||
>
|
||||
Activate
|
||||
</CustomActionItem>
|
||||
</Can>
|
||||
)}
|
||||
{row.status === "Active" && (
|
||||
<CustomActionItem
|
||||
icon={<XIcon size={15} />}
|
||||
onClick={() => setDeactivateTarget(row)}
|
||||
>
|
||||
Deactivate
|
||||
</CustomActionItem>
|
||||
<Can permission="cohorts:edit">
|
||||
<CustomActionItem
|
||||
icon={<XIcon size={15} />}
|
||||
onClick={() => setDeactivateTarget(row)}
|
||||
>
|
||||
Deactivate
|
||||
</CustomActionItem>
|
||||
</Can>
|
||||
)}
|
||||
<CustomActionItem
|
||||
icon={<PencilSimpleIcon size={15} />}
|
||||
onClick={() => setEditTarget(row)}
|
||||
>
|
||||
Edit
|
||||
</CustomActionItem>
|
||||
<CustomActionItem
|
||||
icon={<TrashIcon 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>
|
||||
),
|
||||
},
|
||||
@@ -291,15 +300,17 @@ export default function CohortList() {
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
<CustomButton
|
||||
variant="primary"
|
||||
size="md"
|
||||
leftIcon={<PlusIcon 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}
|
||||
|
||||
@@ -3,6 +3,7 @@ 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[];
|
||||
@@ -40,15 +41,17 @@ export function ActionTypesColumn({
|
||||
{/* Column Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-[16px] font-bold text-[#0F172B]">Action Types</h3>
|
||||
<CustomButton
|
||||
variant="outlined"
|
||||
size="sm"
|
||||
onClick={onOpenAdd}
|
||||
disabled={!selectedCategoryId}
|
||||
leftIcon={<PlusIcon size={15} weight="bold" />}
|
||||
>
|
||||
Add
|
||||
</CustomButton>
|
||||
<Can permission="config:create">
|
||||
<CustomButton
|
||||
variant="outlined"
|
||||
size="sm"
|
||||
onClick={onOpenAdd}
|
||||
disabled={!selectedCategoryId}
|
||||
leftIcon={<PlusIcon size={15} weight="bold" />}
|
||||
>
|
||||
Add
|
||||
</CustomButton>
|
||||
</Can>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -114,24 +117,28 @@ export function ActionTypesColumn({
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{/* Hover Actions */}
|
||||
<div className="hidden group-hover:flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => onOpenEdit(t, e)}
|
||||
title="Edit Action Type"
|
||||
className={`p-1.5 rounded-lg transition-colors cursor-pointer ${isSelected ? 'text-white hover:bg-white/20' : 'text-slate-400 hover:text-[#1E7D5C] hover:bg-white'
|
||||
}`}
|
||||
>
|
||||
<PencilSimpleIcon size={15} weight="bold" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => onDelete(t, e)}
|
||||
title="Delete Action Type"
|
||||
className={`p-1.5 rounded-lg transition-colors cursor-pointer ${isSelected ? 'text-white hover:bg-white/20' : 'text-slate-400 hover:text-red-600 hover:bg-white'
|
||||
}`}
|
||||
>
|
||||
<TrashIcon size={15} weight="bold" />
|
||||
</button>
|
||||
<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 */}
|
||||
|
||||
@@ -3,6 +3,7 @@ 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[];
|
||||
@@ -36,14 +37,16 @@ export function CategoriesColumn({
|
||||
{/* Column Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-[16px] font-bold text-[#0F172B]">Categories</h3>
|
||||
<CustomButton
|
||||
variant="outlined"
|
||||
size="sm"
|
||||
onClick={onOpenAdd}
|
||||
leftIcon={<PlusIcon size={15} weight="bold" />}
|
||||
>
|
||||
Add
|
||||
</CustomButton>
|
||||
<Can permission="config:create">
|
||||
<CustomButton
|
||||
variant="outlined"
|
||||
size="sm"
|
||||
onClick={onOpenAdd}
|
||||
leftIcon={<PlusIcon size={15} weight="bold" />}
|
||||
>
|
||||
Add
|
||||
</CustomButton>
|
||||
</Can>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -104,24 +107,28 @@ export function CategoriesColumn({
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{/* Hover Actions */}
|
||||
<div className="hidden group-hover:flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => onOpenEdit(cat, e)}
|
||||
title="Edit Category"
|
||||
className={`p-1.5 rounded-lg transition-colors cursor-pointer ${isSelected ? 'text-white hover:bg-white/20' : 'text-slate-400 hover:text-[#1E7D5C] hover:bg-white'
|
||||
}`}
|
||||
>
|
||||
<PencilSimpleIcon size={15} weight="bold" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => onDelete(cat, e)}
|
||||
title="Delete Category"
|
||||
className={`p-1.5 rounded-lg transition-colors cursor-pointer ${isSelected ? 'text-white hover:bg-white/20' : 'text-slate-400 hover:text-red-600 hover:bg-white'
|
||||
}`}
|
||||
>
|
||||
<TrashIcon size={15} weight="bold" />
|
||||
</button>
|
||||
<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 */}
|
||||
|
||||
@@ -7,8 +7,9 @@ import {
|
||||
TrashIcon,
|
||||
DotsSixVerticalIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
import { CustomInput, CustomButton, Skeleton } from '../../../../components/custom';
|
||||
import type { FieldDefinition } from '../ActionBuilderTypes';
|
||||
import Can from '../../../../components/common/Can';
|
||||
import { CustomButton, CustomInput, Skeleton } from '../../../../components/custom';
|
||||
|
||||
interface ConfigurationFieldsColumnProps {
|
||||
fields: FieldDefinition[];
|
||||
@@ -47,15 +48,17 @@ export function ConfigurationFieldsColumn({
|
||||
{/* Column Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-[16px] font-bold text-[#0F172B]">Configuration Field</h3>
|
||||
<CustomButton
|
||||
variant="outlined"
|
||||
size="sm"
|
||||
onClick={onOpenAdd}
|
||||
disabled={!selectedActionTypeId}
|
||||
leftIcon={<PlusIcon size={15} weight="bold" />}
|
||||
>
|
||||
Add
|
||||
</CustomButton>
|
||||
<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 */}
|
||||
@@ -184,22 +187,27 @@ export function ConfigurationFieldsColumn({
|
||||
<span className="text-slate-300 font-light mx-0.5">|</span>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenEdit(f)}
|
||||
title="Edit Field"
|
||||
className="p-1 text-slate-800 hover:text-[#1E7D5C] transition-colors cursor-pointer"
|
||||
>
|
||||
<PencilSimpleLineIcon size={18} weight="bold" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(f)}
|
||||
title="Delete Field"
|
||||
className="p-1 text-[#D9383A] hover:text-red-700 transition-colors cursor-pointer"
|
||||
>
|
||||
<TrashIcon size={18} weight="bold" />
|
||||
</button>
|
||||
<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>
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
CustomStatus,
|
||||
} from '../../../../components/custom';
|
||||
import type { MasterDataCategoryItem, MasterDataItem } from '../MasterDataTypes';
|
||||
import Can from '../../../../components/common/Can';
|
||||
|
||||
interface MasterItemTableProps {
|
||||
selectedCategory: MasterDataCategoryItem | null;
|
||||
@@ -63,15 +64,17 @@ export const MasterItemTable: React.FC<MasterItemTableProps> = ({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CustomButton
|
||||
variant="primary"
|
||||
onClick={onOpenCreate}
|
||||
disabled={!selectedCategory}
|
||||
leftIcon={<PlusIcon size={18} />}
|
||||
className="!bg-[#1E7D5C] hover:!bg-[#17664B] !rounded-[12px] !gap-[8px] !h-[40px] font-semibold text-[14px] shrink-0"
|
||||
>
|
||||
Add Master Value
|
||||
</CustomButton>
|
||||
<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 */}
|
||||
@@ -155,21 +158,25 @@ export const MasterItemTable: React.FC<MasterItemTableProps> = ({
|
||||
|
||||
<td className="py-3 px-4 text-right">
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<button
|
||||
onClick={() => onOpenEdit(item)}
|
||||
title="Edit Item"
|
||||
className="p-1.5 rounded-lg text-slate-500 hover:text-[#1E7D5C] hover:bg-[#E8F3EF] transition-colors"
|
||||
>
|
||||
<PencilSimpleIcon size={16} weight="bold" />
|
||||
</button>
|
||||
<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>
|
||||
|
||||
<button
|
||||
onClick={() => onOpenDelete(item)}
|
||||
title="Delete Item"
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:text-red-600 hover:bg-red-50 transition-colors"
|
||||
>
|
||||
<TrashIcon size={16} weight="bold" />
|
||||
</button>
|
||||
<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>
|
||||
|
||||
@@ -30,12 +30,13 @@ export default function HomePage() {
|
||||
async function loadDashboardData() {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [metricsRes, exposureRes, mixRes, incidentsRes] = await Promise.all([
|
||||
getDashboardMetrics(),
|
||||
getExposureData(),
|
||||
getDisruptionMixData(),
|
||||
getRecentIncidents(),
|
||||
]);
|
||||
const [metricsRes, exposureRes, mixRes, incidentsRes] =
|
||||
await Promise.all([
|
||||
getDashboardMetrics(),
|
||||
getExposureData(),
|
||||
getDisruptionMixData(),
|
||||
getRecentIncidents(),
|
||||
]);
|
||||
|
||||
setMetrics(metricsRes);
|
||||
setExposureData(exposureRes);
|
||||
@@ -57,7 +58,10 @@ export default function HomePage() {
|
||||
{/* 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
|
||||
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} />
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
Skeleton,
|
||||
} from '../../../components/custom';
|
||||
import CustomSuccessModal from '../../../components/custom/CustomSuccessModal';
|
||||
import Can from '../../../components/common/Can';
|
||||
import {
|
||||
getJurisdictionOptions,
|
||||
getCohortOptions,
|
||||
@@ -1648,14 +1649,16 @@ export default function AddPolicyEngine() {
|
||||
<CustomStatus status={status} />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<CustomButton
|
||||
variant="secondary"
|
||||
className="!text-[#1E7D5C] !bg-[#E8F3EF] hover:!bg-[#d9ece4] !border-none font-semibold px-6 disabled:opacity-50"
|
||||
onClick={() => handleSavePolicy(false)}
|
||||
disabled={isSaving || !isDraftValid}
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save Draft'}
|
||||
</CustomButton>
|
||||
<Can permissions={['policy_engine:create', 'policy_engine:edit']}>
|
||||
<CustomButton
|
||||
variant="secondary"
|
||||
className="!text-[#1E7D5C] !bg-[#E8F3EF] hover:!bg-[#d9ece4] !border-none font-semibold px-6 disabled:opacity-50"
|
||||
onClick={() => handleSavePolicy(false)}
|
||||
disabled={isSaving || !isDraftValid}
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save Draft'}
|
||||
</CustomButton>
|
||||
</Can>
|
||||
<CustomButton
|
||||
variant="outlined"
|
||||
className="!border-[#1E7D5C] !text-[#1E7D5C] hover:!bg-gray-50 font-semibold px-6"
|
||||
@@ -1664,14 +1667,16 @@ export default function AddPolicyEngine() {
|
||||
>
|
||||
Cancel Policy
|
||||
</CustomButton>
|
||||
<CustomButton
|
||||
variant="primary"
|
||||
className="!bg-[#1E7D5C] hover:!bg-[#17664B] font-semibold px-6 shadow-sm disabled:opacity-50"
|
||||
onClick={() => handleSavePolicy(true)}
|
||||
disabled={isSaving || !isFormValid}
|
||||
>
|
||||
{isSaving ? 'Deploying...' : isEditMode ? 'Update & Deploy Policy' : 'Deploy Policy'}
|
||||
</CustomButton>
|
||||
<Can permission="policy_engine:publish">
|
||||
<CustomButton
|
||||
variant="primary"
|
||||
className="!bg-[#1E7D5C] hover:!bg-[#17664B] font-semibold px-6 shadow-sm disabled:opacity-50"
|
||||
onClick={() => handleSavePolicy(true)}
|
||||
disabled={isSaving || !isFormValid}
|
||||
>
|
||||
{isSaving ? 'Deploying...' : isEditMode ? 'Update & Deploy Policy' : 'Deploy Policy'}
|
||||
</CustomButton>
|
||||
</Can>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from "../../../components/custom";
|
||||
import type { Column } from "../../../components/custom/CustomTable";
|
||||
import type { PolicyEngineResponse } from "../PolicyEngineTypes";
|
||||
import Can from "../../../components/common/Can";
|
||||
import {
|
||||
getPolicies,
|
||||
deletePolicy,
|
||||
@@ -201,35 +202,43 @@ export default function PolicyEngineList() {
|
||||
accessor: (row) => (
|
||||
<CustomActionMenu>
|
||||
{row.status?.toLowerCase() === "inactive" && (
|
||||
<CustomActionItem
|
||||
icon={<ChecksIcon size={15} />}
|
||||
variant="success"
|
||||
onClick={() => setDeactivateTarget(row)}
|
||||
>
|
||||
Activate
|
||||
</CustomActionItem>
|
||||
<Can permission="policy_engine:publish">
|
||||
<CustomActionItem
|
||||
icon={<ChecksIcon size={15} />}
|
||||
variant="success"
|
||||
onClick={() => setDeactivateTarget(row)}
|
||||
>
|
||||
Activate
|
||||
</CustomActionItem>
|
||||
</Can>
|
||||
)}
|
||||
{row.status?.toLowerCase() === "active" && (
|
||||
<CustomActionItem
|
||||
icon={<XIcon size={15} />}
|
||||
onClick={() => setDeactivateTarget(row)}
|
||||
>
|
||||
Deactivate
|
||||
</CustomActionItem>
|
||||
<Can permission="policy_engine:publish">
|
||||
<CustomActionItem
|
||||
icon={<XIcon size={15} />}
|
||||
onClick={() => setDeactivateTarget(row)}
|
||||
>
|
||||
Deactivate
|
||||
</CustomActionItem>
|
||||
</Can>
|
||||
)}
|
||||
<CustomActionItem
|
||||
icon={<PencilSimpleIcon size={15} />}
|
||||
onClick={() => navigate(`/policy-engine/add?id=${row.id}`)}
|
||||
>
|
||||
Edit
|
||||
</CustomActionItem>
|
||||
<CustomActionItem
|
||||
icon={<TrashIcon 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>
|
||||
),
|
||||
},
|
||||
@@ -290,15 +299,17 @@ export default function PolicyEngineList() {
|
||||
}
|
||||
rightHeaderActions={
|
||||
<>
|
||||
<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 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}
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
CustomAlertBanner,
|
||||
CustomSuccessModal,
|
||||
} from "../../../components/custom";
|
||||
import Can from "../../../components/common/Can";
|
||||
import type { Column } from "../../../components/custom/CustomTable";
|
||||
import type {
|
||||
RecoveryIncident,
|
||||
@@ -479,38 +480,46 @@ export default function RecoveryIncidentsList() {
|
||||
>
|
||||
View Details
|
||||
</CustomActionItem>
|
||||
<CustomActionItem
|
||||
onClick={() => {
|
||||
setEditingIncident(row);
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
icon={
|
||||
<PencilSimpleIcon size={16} className="text-yellow-500" />
|
||||
}
|
||||
>
|
||||
Edit Incident
|
||||
</CustomActionItem>
|
||||
<CustomActionItem
|
||||
icon={
|
||||
<CheckCircleIcon size={16} className="text-green-500" />
|
||||
}
|
||||
onClick={() => handleStatusChange(row, "Approved")}
|
||||
>
|
||||
Approve
|
||||
</CustomActionItem>
|
||||
<CustomActionItem
|
||||
variant="danger"
|
||||
icon={<XCircleIcon size={16} className="text-red-500" />}
|
||||
onClick={() => handleStatusChange(row, "Rejected")}
|
||||
>
|
||||
Reject
|
||||
</CustomActionItem>
|
||||
<CustomActionItem
|
||||
icon={<ClockIcon size={16} className="text-yellow-600" />}
|
||||
onClick={() => handleStatusChange(row, "Under Review")}
|
||||
>
|
||||
Mark for Review
|
||||
</CustomActionItem>
|
||||
<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>
|
||||
@@ -618,18 +627,20 @@ export default function RecoveryIncidentsList() {
|
||||
>
|
||||
{isGrouped ? "Ungroup" : "Group by Flight"}
|
||||
</CustomButton>
|
||||
<CustomButton
|
||||
variant="primary"
|
||||
size="md"
|
||||
leftIcon={<PlusIcon size={16} />}
|
||||
className="!rounded-[10px] !gap-[8px] !h-[40px] !bg-[#1B9869] hover:!bg-[#14704E]"
|
||||
onClick={() => {
|
||||
setEditingIncident(null);
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
>
|
||||
New Incident
|
||||
</CustomButton>
|
||||
<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}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { User, Checks, X, ClockCounterClockwiseIcon, ArrowLeftIcon, ArrowsClockwiseIcon, AirplaneTiltIcon } from '@phosphor-icons/react';
|
||||
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 { SparkleIcon } from 'lucide-react';
|
||||
import { getRecoveryIncident, updateIncidentStatus, reRunPolicyEngine } from '../RecoveryIncidentsApi';
|
||||
import type { RecoveryIncident } from '../RecoveryIncidentsTypes';
|
||||
|
||||
@@ -254,7 +253,7 @@ export default function RecoveryIncidentTabs() {
|
||||
{/* Sidebar Content */}
|
||||
<div className="p-6 select-none pointer-events-none">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<span className="text-[#1B9869]"><SparkleIcon size={20} height="fill" /></span>
|
||||
<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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ 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,
|
||||
@@ -402,8 +403,8 @@ export default function SimulationTerminal() {
|
||||
|
||||
{/* Strategic Category Tabs */}
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-700 tracking-wider mb-1.5">
|
||||
Strategic Category
|
||||
<label className="block text-xs font-bold text-gray-700 uppercase tracking-wider mb-1.5">
|
||||
STRATEGIC CATEGORY
|
||||
</label>
|
||||
<CustomTabs
|
||||
tabs={categoryTabs}
|
||||
@@ -422,7 +423,7 @@ export default function SimulationTerminal() {
|
||||
<div>
|
||||
|
||||
<CustomDropdown
|
||||
label="Jurisdiction"
|
||||
label="JURISDICTION"
|
||||
options={jurisdictionOptions}
|
||||
value={formData.jurisdiction}
|
||||
onChange={(val) => handleInputChange('jurisdiction', val)}
|
||||
@@ -436,7 +437,7 @@ export default function SimulationTerminal() {
|
||||
<div>
|
||||
|
||||
<CustomDropdown
|
||||
label="Scenario"
|
||||
label="SCENARIO"
|
||||
options={categoryConfig.scenarios}
|
||||
value={formData.scenario}
|
||||
onChange={(val) => handleScenarioChange(val)}
|
||||
@@ -507,14 +508,20 @@ export default function SimulationTerminal() {
|
||||
|
||||
{/* Action Button using CustomButton */}
|
||||
<div className="w-full pt-1">
|
||||
<CustomButton
|
||||
variant="primary"
|
||||
loading={isSimulating}
|
||||
onClick={handleRunSimulation}
|
||||
className="w-full !rounded-xl !py-3.5 !text-[13px] !font-bold tracking-wide shadow-sm"
|
||||
>
|
||||
Run Manifest Simulation
|
||||
</CustomButton>
|
||||
<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>
|
||||
|
||||
@@ -562,17 +569,19 @@ export default function SimulationTerminal() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CustomButton
|
||||
variant="outlined"
|
||||
size="sm"
|
||||
loading={isSaving}
|
||||
disabled={filteredPassengers.length === 0}
|
||||
leftIcon={<FloppyDiskIcon size={16} weight="bold" />}
|
||||
onClick={handleSaveAsRecovery}
|
||||
className="!border-[#1B9869] !text-[#1B9869] hover:!bg-[#1B9869]/5 !rounded-xl !px-4 !py-2 shrink-0 font-semibold text-xs"
|
||||
>
|
||||
Save as Recovery
|
||||
</CustomButton>
|
||||
<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 */}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -10,6 +10,9 @@ const PAGE_META: Record<string, { title: string; subtitle: string }> = {
|
||||
'/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 {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import AppSidebar from './AppSidebar';
|
||||
import AppHeader from './AppHeader';
|
||||
import { FloatingChatWidget } from '../app/chatbot';
|
||||
|
||||
interface LayoutProps {
|
||||
children: ReactNode;
|
||||
@@ -22,9 +21,6 @@ function Layout({ children }: LayoutProps) {
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Global Floating Chatbot FAB & Modal Window */}
|
||||
<FloatingChatWidget />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+153
-58
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import {
|
||||
SquaresFourIcon,
|
||||
@@ -11,19 +11,29 @@ import {
|
||||
GearIcon,
|
||||
ClockCounterClockwiseIcon,
|
||||
CaretDoubleRightIcon,
|
||||
ChatTeardropTextIcon,
|
||||
ShieldCheckIcon,
|
||||
LockKeyIcon,
|
||||
CaretUpIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { ShieldCheckIcon } from "lucide-react";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ label: "Dashboard", path: "/", icon: SquaresFourIcon },
|
||||
{ label: "AI Assistant", path: "/chatbot", icon: ChatTeardropTextIcon },
|
||||
{ label: "Simulation Engine", path: "/simulation", icon: FadersIcon },
|
||||
{ label: "Recovery Incidents", path: "/recovery", icon: ArrowsClockwiseIcon },
|
||||
{ label: "Cohort Management", path: "/cohorts", icon: UsersFourIcon },
|
||||
{ label: "Policy Engine", path: "/policy-engine", icon: ShieldCheckIcon },
|
||||
{ label: "Configuration", path: "/config", icon: GearIcon },
|
||||
{ label: "Audit Logs", path: "/audit-logs", icon: ClockCounterClockwiseIcon },
|
||||
interface NavItem {
|
||||
label: string;
|
||||
path: string;
|
||||
icon: React.ElementType;
|
||||
permission?: string;
|
||||
}
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ label: "Dashboard", path: "/", icon: SquaresFourIcon, permission: "dashboard:view" },
|
||||
{ label: "Simulation Engine", path: "/simulation", icon: FadersIcon, permission: "simulation:view" },
|
||||
{ label: "Recovery Incidents", path: "/recovery", icon: ArrowsClockwiseIcon, permission: "recovery:view" },
|
||||
{ label: "Cohort Management", path: "/cohorts", icon: UsersFourIcon, permission: "cohorts:view" },
|
||||
{ label: "Policy Engine", path: "/policy-engine", icon: ShieldCheckIcon, permission: "policy_engine:view" },
|
||||
{ label: "Configuration", path: "/config", icon: GearIcon, permission: "config:view" },
|
||||
{ label: "User Management", path: "/users", icon: UsersFourIcon, permission: "users:view" },
|
||||
{ label: "Roles & Permissions", path: "/roles", icon: LockKeyIcon, permission: "roles:view" },
|
||||
{ label: "Audit Logs", path: "/audit-logs", icon: ClockCounterClockwiseIcon, permission: "audit_logs:view" },
|
||||
];
|
||||
|
||||
interface AppSidebarProps {
|
||||
@@ -34,6 +44,45 @@ interface AppSidebarProps {
|
||||
export default function AppSidebar({ isOpen, onClose }: AppSidebarProps) {
|
||||
const location = useLocation();
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const { user, logout, hasPermission } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setIsMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
setIsMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isMenuOpen) {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [isMenuOpen]);
|
||||
|
||||
const visibleNavItems = NAV_ITEMS.filter((item) => {
|
||||
if (!item.permission) return true;
|
||||
return hasPermission(item.permission);
|
||||
});
|
||||
|
||||
const initials = user
|
||||
? `${user.firstName?.[0] || ''}${user.lastName?.[0] || ''}`.toUpperCase() || user.email?.[0]?.toUpperCase() || 'AD'
|
||||
: 'AD';
|
||||
|
||||
const userDisplayName = user?.fullName || `${user?.firstName || ''} ${user?.lastName || ''}`.trim() || user?.email || 'Admin Demo';
|
||||
const roleDisplayName = user?.role?.name || (user?.isSystem ? 'System Administrator' : 'User');
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -82,7 +131,10 @@ export default function AppSidebar({ isOpen, onClose }: AppSidebarProps) {
|
||||
<CaretDoubleLeftIcon size={20} weight="bold" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
onClick={() => {
|
||||
setIsMenuOpen(false);
|
||||
setIsCollapsed(!isCollapsed);
|
||||
}}
|
||||
className={`hidden lg:block text-slate-400 hover:text-slate-600 transition-colors ${isCollapsed ? "" : "ml-auto"}`}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
@@ -97,7 +149,7 @@ export default function AppSidebar({ isOpen, onClose }: AppSidebarProps) {
|
||||
<nav
|
||||
className={`flex-1 py-3 space-y-1 overflow-y-auto overflow-x-hidden ${isCollapsed ? "px-3" : "px-4"}`}
|
||||
>
|
||||
{NAV_ITEMS.map((item) => {
|
||||
{visibleNavItems.map((item) => {
|
||||
const isActive =
|
||||
location.pathname === item.path ||
|
||||
(item.path !== "/" && location.pathname.startsWith(item.path));
|
||||
@@ -109,6 +161,7 @@ export default function AppSidebar({ isOpen, onClose }: AppSidebarProps) {
|
||||
to={item.path}
|
||||
title={isCollapsed ? item.label : undefined}
|
||||
onClick={() => {
|
||||
setIsMenuOpen(false);
|
||||
if (window.innerWidth < 1024) onClose();
|
||||
}}
|
||||
className={`group flex items-center ${isCollapsed ? "justify-center px-0 w-12 mx-auto" : "gap-3 px-3.5"} py-[10px] rounded-[12px] text-[13px] transition-all duration-200 relative ${isActive
|
||||
@@ -130,59 +183,101 @@ export default function AppSidebar({ isOpen, onClose }: AppSidebarProps) {
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Bottom Section Card */}
|
||||
{/* Bottom Section Card Container with Dropup Menu */}
|
||||
<div
|
||||
className={`mb-2 mt-2 bg-gradient-to-br from-[#FAFCFB] to-[#E3EFE9] border border-white rounded-[24px] shadow-[0_4px_12px_-4px_rgba(0,0,0,0.05)] relative overflow-hidden transition-all duration-300 ${isCollapsed ? "mx-2 p-2 flex flex-col items-center gap-3" : "mx-4 p-3"}`}
|
||||
ref={menuRef}
|
||||
className={`relative mb-2 mt-2 transition-all duration-300 ${
|
||||
isCollapsed ? "mx-2" : "mx-4"
|
||||
}`}
|
||||
>
|
||||
{/* Soft decorative glow */}
|
||||
{!isCollapsed && (
|
||||
<div className="absolute -top-10 -right-10 w-32 h-32 bg-white/60 rounded-full blur-2xl pointer-events-none" />
|
||||
)}
|
||||
|
||||
{/* Dropup Menu (Opens Upwards with transition) */}
|
||||
<div
|
||||
className={`relative z-10 flex flex-col ${isCollapsed ? "gap-2 w-full" : "gap-0.5"}`}
|
||||
className={`absolute bottom-full mb-2 ${
|
||||
isCollapsed ? "left-0 w-48" : "left-0 right-0"
|
||||
} z-50 bg-gradient-to-br from-[#FAFCFB] to-[#E3EFE9] border border-white rounded-[20px] shadow-[0_12px_28px_-4px_rgba(0,0,0,0.12),0_4px_10px_-2px_rgba(0,0,0,0.06)] p-2 transition-all duration-200 ease-out origin-bottom transform ${
|
||||
isMenuOpen
|
||||
? "opacity-100 translate-y-0 scale-100 pointer-events-auto"
|
||||
: "opacity-0 translate-y-2 scale-95 pointer-events-none"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
title={isCollapsed ? "Sign Out" : undefined}
|
||||
className={`flex items-center ${isCollapsed ? "justify-center px-0 h-10 w-full" : "gap-3 px-3 py-2"} text-[13px] font-semibold text-[#E02424] hover:bg-red-50/50 rounded-[10px] transition-colors`}
|
||||
>
|
||||
<SignOutIcon
|
||||
size={17}
|
||||
className="text-[#E02424] shrink-0"
|
||||
weight="bold"
|
||||
/>
|
||||
{!isCollapsed && (
|
||||
<span className="whitespace-nowrap">Sign Out</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
title={isCollapsed ? "Help Center" : undefined}
|
||||
className={`flex items-center ${isCollapsed ? "justify-center px-0 h-10 w-full" : "gap-3 px-3 py-2"} text-[13px] font-medium text-slate-700 hover:bg-white/40 rounded-[10px] transition-colors`}
|
||||
>
|
||||
<QuestionIcon
|
||||
size={17}
|
||||
className="text-slate-700 shrink-0"
|
||||
weight="regular"
|
||||
/>
|
||||
{!isCollapsed && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
title="Help Center"
|
||||
className="flex items-center gap-3 px-3 py-2 text-[13px] font-medium text-slate-700 hover:bg-white/60 rounded-[12px] transition-colors w-full cursor-pointer text-left"
|
||||
>
|
||||
<QuestionIcon
|
||||
size={17}
|
||||
className="text-slate-700 shrink-0"
|
||||
weight="regular"
|
||||
/>
|
||||
<span className="whitespace-nowrap">Help center</span>
|
||||
)}
|
||||
</button>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsMenuOpen(false);
|
||||
logout();
|
||||
}}
|
||||
title="Sign Out"
|
||||
className="flex items-center gap-3 px-3 py-2 text-[13px] font-semibold text-[#E02424] hover:bg-red-50/70 rounded-[12px] transition-colors w-full cursor-pointer text-left"
|
||||
>
|
||||
<SignOutIcon
|
||||
size={17}
|
||||
className="text-[#E02424] shrink-0"
|
||||
weight="bold"
|
||||
/>
|
||||
<span className="whitespace-nowrap">Sign Out</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User Profile Card (Clickable Trigger) */}
|
||||
<div
|
||||
onClick={() => setIsMenuOpen((prev) => !prev)}
|
||||
title={isCollapsed ? userDisplayName : undefined}
|
||||
className={`bg-gradient-to-br from-[#FAFCFB] to-[#E3EFE9] border border-white rounded-[24px] shadow-[0_4px_12px_-4px_rgba(0,0,0,0.05)] hover:shadow-md relative overflow-hidden transition-all duration-300 cursor-pointer select-none ${
|
||||
isCollapsed
|
||||
? "p-2 flex items-center justify-center h-12"
|
||||
: "p-3 flex items-center justify-between"
|
||||
} ${isMenuOpen ? "ring-2 ring-primary/20 shadow-md" : ""}`}
|
||||
>
|
||||
{/* Soft decorative glow */}
|
||||
{!isCollapsed && (
|
||||
<div className="absolute -top-10 -right-10 w-32 h-32 bg-white/60 rounded-full blur-2xl pointer-events-none" />
|
||||
)}
|
||||
|
||||
<div
|
||||
title={isCollapsed ? "Admin Demo" : undefined}
|
||||
className={`p-2 bg-white rounded-[16px] shadow-[0_2px_8px_-4px_rgba(0,0,0,0.08)] border border-white flex items-center cursor-pointer hover:shadow-md transition-shadow ${isCollapsed ? "justify-center mt-1 w-full h-12" : "gap-2.5 mt-2"}`}
|
||||
className={`relative z-10 flex items-center ${
|
||||
isCollapsed ? "justify-center w-full" : "justify-between w-full gap-2.5"
|
||||
}`}
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-[#C2D1E0] flex-shrink-0" />
|
||||
{!isCollapsed && (
|
||||
<div className="flex flex-col justify-center overflow-hidden">
|
||||
<span className="text-[13px] font-bold text-[#111827] leading-tight truncate">
|
||||
Admin Demo
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-500 font-medium mt-0.5 leading-tight truncate">
|
||||
System Administrator
|
||||
</span>
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="w-8 h-8 rounded-full bg-[#C2D1E0] flex items-center justify-center text-slate-700 font-bold text-xs shrink-0 shadow-sm">
|
||||
{initials}
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<div className="flex flex-col justify-center overflow-hidden min-w-0">
|
||||
<span className="text-[13px] font-bold text-[#111827] leading-tight truncate">
|
||||
{userDisplayName}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-500 font-medium mt-0.5 leading-tight truncate">
|
||||
{roleDisplayName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isCollapsed && (
|
||||
<CaretUpIcon
|
||||
size={14}
|
||||
weight="bold"
|
||||
className={`text-slate-400 shrink-0 transition-transform duration-200 ${
|
||||
isMenuOpen ? "rotate-0 text-slate-700" : "rotate-180"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user