refactor: optimize notification socket lifecycle, update dashboard services, and refine tenant UI permissions and actions

This commit is contained in:
Yashwin 2026-04-06 18:05:13 +05:30
parent c2e6d779d4
commit 26566f6620
17 changed files with 263 additions and 191 deletions

View File

@ -155,14 +155,14 @@ export const Header = ({ breadcrumbs, currentPage, onMenuClick }: HeaderProps):
{/* Right Side */} {/* Right Side */}
<div className="flex items-center gap-2 md:gap-3"> <div className="flex items-center gap-2 md:gap-3">
<Button {/* <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="w-8 h-8 md:w-8 md:h-8 rounded-full border border-[rgba(0,0,0,0.08)] min-h-[44px] min-w-[44px]" className="w-8 h-8 md:w-8 md:h-8 rounded-full border border-[rgba(0,0,0,0.08)] min-h-[44px] min-w-[44px]"
aria-label="Search" aria-label="Search"
> >
<Search className="w-4 h-4" /> <Search className="w-4 h-4" />
</Button> </Button> */}
<NotificationBell /> <NotificationBell />
{/* Desktop User Dropdown */} {/* Desktop User Dropdown */}

View File

@ -133,7 +133,6 @@ const tenantAdminSystemMenu: MenuItem[] = [
icon: FileText, icon: FileText,
label: "Audit Logs", label: "Audit Logs",
path: "/tenant/audit-logs", path: "/tenant/audit-logs",
requiredPermission: { resource: "audit_logs" },
}, },
{ {
icon: Settings, icon: Settings,

View File

@ -60,6 +60,13 @@ export const NotificationBell = () => {
}; };
}, [isOpen]); }, [isOpen]);
// Fetch notifications when opened
useEffect(() => {
if (isOpen) {
dispatch(fetchNotifications({ limit: 20 }));
}
}, [isOpen, dispatch]);
const handleMarkAllRead = () => { const handleMarkAllRead = () => {
dispatch(readAllAsync()); dispatch(readAllAsync());
}; };
@ -77,14 +84,17 @@ export const NotificationBell = () => {
} }
// Special handling for system as requested - redirect to Dashboard // Special handling for system as requested - redirect to Dashboard
if (['system'].includes(notification.category || '')) { // if (['system'].includes(notification.category || '')) {
navigate('/tenant'); // navigate('/tenant');
setIsOpen(false); // setIsOpen(false);
return; // return;
} // }
if (notification.action_url) { if (notification.action_url) {
navigate(notification.action_url); const targetUrl = notification.action_url.startsWith('/tenant')
? notification.action_url
: `/tenant${notification.action_url.startsWith('/') ? '' : '/'}${notification.action_url}`;
navigate(targetUrl);
} }
setIsOpen(false); setIsOpen(false);
}; };

View File

@ -1,7 +1,7 @@
import { useEffect, useCallback, createContext, useContext, useRef, type ReactNode } from 'react'; import { useEffect, useCallback, createContext, useContext, useRef, type ReactNode } from 'react';
import { io, Socket } from 'socket.io-client'; import { io, Socket } from 'socket.io-client';
import { useAppDispatch, useAppSelector } from '@/hooks/redux-hooks'; import { useAppDispatch, useAppSelector } from '@/hooks/redux-hooks';
import { addNotification, fetchNotifications, fetchUnreadCount, setUnreadCount, markReadLocal } from '@/store/notificationSlice'; import { addNotification, fetchUnreadCount, setUnreadCount, markReadLocal } from '@/store/notificationSlice';
import { showToast } from '@/utils/toast'; import { showToast } from '@/utils/toast';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
@ -66,6 +66,23 @@ export const NotificationProvider = ({ children }: NotificationProviderProps) =>
} }
}, [dispatch]); }, [dispatch]);
// Tracker for initial fetch to avoid duplicate calls on re-renders
const hasInitialized = useRef(false);
// Initial fetch of unread count only (notifications list fetched on-demand)
useEffect(() => {
if (isAuthenticated && accessToken && !hasInitialized.current) {
console.log('NotificationProvider: Initial unread count fetch');
dispatch(fetchUnreadCount());
hasInitialized.current = true;
}
// Reset initialization tracker on logout
if (!isAuthenticated) {
hasInitialized.current = false;
}
}, [isAuthenticated, accessToken, dispatch]);
useEffect(() => { useEffect(() => {
if (!isAuthenticated || !accessToken) { if (!isAuthenticated || !accessToken) {
if (socketRef.current) { if (socketRef.current) {
@ -78,8 +95,8 @@ export const NotificationProvider = ({ children }: NotificationProviderProps) =>
if (socketRef.current?.connected) return; if (socketRef.current?.connected) return;
console.log('WebSocket: Connecting to', SOCKET_URL);
// Connect to WebSocket // Connect to WebSocket
console.log('WebSocket: Connecting to', SOCKET_URL);
const socket = io(SOCKET_URL, { const socket = io(SOCKET_URL, {
auth: { auth: {
token: accessToken token: accessToken
@ -87,11 +104,8 @@ export const NotificationProvider = ({ children }: NotificationProviderProps) =>
transports: ["websocket", "polling"], transports: ["websocket", "polling"],
reconnection: true, reconnection: true,
reconnectionAttempts: 5, reconnectionAttempts: 5,
reconnectionDelay: 1000 reconnectionDelay: 1000,
}); autoConnect: true
socket.on('connected', (data) => {
console.log('WebSocket: Authenticated for user', data.userId);
}); });
socket.on('notification', handleNotification); socket.on('notification', handleNotification);
@ -100,31 +114,16 @@ export const NotificationProvider = ({ children }: NotificationProviderProps) =>
socket.on('connect', () => { socket.on('connect', () => {
console.log('WebSocket: Connected'); console.log('WebSocket: Connected');
// Re-fetch on reconnect to avoid missing updates // If we've already initialized, re-fetch count on reconnect to be safe
dispatch(fetchNotifications({ limit: 20 })); if (hasInitialized.current) {
dispatch(fetchUnreadCount()); dispatch(fetchUnreadCount());
}); }
socket.on('disconnect', (reason) => {
console.log('WebSocket: Disconnected', reason);
});
socket.on('connect_error', (err) => {
console.error('WebSocket: Connection error', err.message);
}); });
socketRef.current = socket; socketRef.current = socket;
// Initial fetch
dispatch(fetchNotifications({ limit: 20 }));
dispatch(fetchUnreadCount());
return () => { return () => {
if (socketRef.current) { // We don't want to disconnect on every route change, so we persist the socketRef
console.log('WebSocket: Cleaning up connection');
socketRef.current.disconnect();
socketRef.current = null;
}
}; };
}, [isAuthenticated, accessToken, dispatch, handleNotification, handleUnreadCount, handleNotificationRead]); }, [isAuthenticated, accessToken, dispatch, handleNotification, handleUnreadCount, handleNotificationRead]);

View File

@ -649,7 +649,7 @@ export const WorkflowDefinitionModal = ({
</div> </div>
<FormField <FormField
label="Entity Type" label="Entity Type"
placeholder="e.g. Document, CAPA, Supplier, Training" placeholder="e.g. document, capa, supplier, training"
required required
{...register("entity_type")} {...register("entity_type")}
error={errors.entity_type?.message as any} error={errors.entity_type?.message as any}

View File

@ -5,39 +5,34 @@ import type { QuickAction } from '@/types/dashboard';
export const QuickActions = () => { export const QuickActions = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { roles } = useAppSelector((state) => state.auth); const { roles, permissions } = useAppSelector((state) => state.auth);
// Simple roles array parsing // Helper to check permission
let rolesArray: string[] = []; const hasPermission = (resource: string, action: string) => {
if (Array.isArray(roles)) { if (roles.includes('super_admin') || roles.includes('tenant_admin')) return true;
rolesArray = roles; return permissions.some(p => p.resource === resource && p.action === action);
} else if (typeof roles === "string") { };
try {
rolesArray = JSON.parse(roles);
} catch {
rolesArray = [];
}
}
const isSuperAdmin = rolesArray.includes('super_admin'); const isSuperAdmin = roles.includes('super_admin');
// Define actions based on role // Define actions based on role
const superAdminActions: QuickAction[] = [ const superAdminActions: QuickAction[] = [
{ icon: Plus, label: 'New Tenant', onClick: () => navigate('/tenants/create-wizard') }, { icon: Plus, label: 'New Tenant', onClick: () => navigate('/tenants/create-wizard') },
{ icon: UserPlus, label: 'New User', onClick: () => console.log('New User') }, { icon: UserPlus, label: 'Module', onClick: () => navigate('/modules') },
{ icon: Shield, label: 'New Role', onClick: () => console.log('New Role') }, { icon: Shield, label: 'Notification', onClick: () => navigate('/notifications') },
{ icon: Settings, label: 'New Config', onClick: () => console.log('Config') }, { icon: Settings, label: 'Audit Logs', onClick: () => navigate('/audit-logs') },
]; ];
const tenantAdminActions: QuickAction[] = [ const tenantAdminActions: QuickAction[] = [
{ icon: UserPlus, label: 'New User', onClick: () => navigate('/tenant/users') }, hasPermission('users', 'create') && { icon: UserPlus, label: 'New User', onClick: () => navigate('/tenant/users') },
{ icon: Shield, label: 'New Role', onClick: () => navigate('/tenant/roles') }, hasPermission('roles', 'create') && { icon: Shield, label: 'New Role', onClick: () => navigate('/tenant/roles') },
{ icon: Building2, label: 'New Dept', onClick: () => navigate('/tenant/departments') }, hasPermission('departments', 'create') && { icon: Building2, label: 'New Dept', onClick: () => navigate('/tenant/departments') },
{ icon: BadgeCheck, label: 'New Desig', onClick: () => navigate('/tenant/designations') }, hasPermission('designations', 'create') && { icon: BadgeCheck, label: 'New Desig', onClick: () => navigate('/tenant/designations') },
]; ].filter(Boolean) as QuickAction[];
const actions = isSuperAdmin ? superAdminActions : tenantAdminActions; const actions = isSuperAdmin ? superAdminActions : tenantAdminActions;
return ( return (
<div className="bg-white rounded-xl p-6 h-full flex flex-col shadow-sm border border-[#e5e7eb]"> <div className="bg-white rounded-xl p-6 h-full flex flex-col shadow-sm border border-[#e5e7eb]">
<div className="flex justify-between items-center mb-6"> <div className="flex justify-between items-center mb-6">

View File

@ -1,5 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { ChevronDown, Filter, Loader2, User } from 'lucide-react'; import { Loader2, User, ArrowRight } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { auditLogService } from '@/services/audit-log-service'; import { auditLogService } from '@/services/audit-log-service';
import type { AuditLog } from '@/types/audit-log'; import type { AuditLog } from '@/types/audit-log';
import { useAppSelector } from '@/hooks/redux-hooks'; import { useAppSelector } from '@/hooks/redux-hooks';
@ -8,16 +9,7 @@ import { Card, CardHeader, CardContent } from '@/components/ui/card';
import { StatusBadge } from '@/components/shared'; import { StatusBadge } from '@/components/shared';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
// Helper functions (kept from original) // Helper functions
const getActionVariant = (action: string): 'success' | 'failure' | 'info' | 'process' => {
const lowerAction = action.toLowerCase();
if (lowerAction.includes('create') || lowerAction.includes('register')) return 'success';
if (lowerAction.includes('update') || lowerAction.includes('version_update') || lowerAction.includes('login')) return 'info';
if (lowerAction.includes('delete') || lowerAction.includes('deregister')) return 'failure';
if (lowerAction.includes('read') || lowerAction.includes('get') || lowerAction.includes('status_change')) return 'process';
return 'info';
};
const formatRelativeTime = (dateString: string): string => { const formatRelativeTime = (dateString: string): string => {
const date = new Date(dateString); const date = new Date(dateString);
const now = new Date(); const now = new Date();
@ -32,6 +24,23 @@ const formatRelativeTime = (dateString: string): string => {
return date.toLocaleDateString(); return date.toLocaleDateString();
}; };
const getStatusColor = (status: number | null): string => {
if (!status) return 'text-[#6b7280]';
if (status >= 200 && status < 300) return 'text-[#10b981]';
if (status >= 400) return 'text-[#ef4444]';
return 'text-[#f59e0b]';
};
const getMethodVariant = (method: string | null): 'success' | 'failure' | 'info' | 'process' => {
if (!method) return 'info';
const upperMethod = method.toUpperCase();
if (upperMethod === 'GET') return 'success';
if (upperMethod === 'POST') return 'info';
if (upperMethod === 'PUT' || upperMethod === 'PATCH') return 'process';
if (upperMethod === 'DELETE') return 'failure';
return 'info';
};
export interface RecentActivityProps { export interface RecentActivityProps {
variant?: 'list' | 'table'; variant?: 'list' | 'table';
} }
@ -40,6 +49,7 @@ export const RecentActivity = ({ variant }: RecentActivityProps) => {
const [auditLogs, setAuditLogs] = useState<AuditLog[]>([]); const [auditLogs, setAuditLogs] = useState<AuditLog[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true); const [isLoading, setIsLoading] = useState<boolean>(true);
const { tenantId } = useAppSelector((state) => state.auth); const { tenantId } = useAppSelector((state) => state.auth);
const navigate = useNavigate();
// Default to table variant for a more professional look // Default to table variant for a more professional look
const activeVariant = variant || 'table'; const activeVariant = variant || 'table';
@ -48,14 +58,12 @@ export const RecentActivity = ({ variant }: RecentActivityProps) => {
const fetchRecentActivity = async (): Promise<void> => { const fetchRecentActivity = async (): Promise<void> => {
try { try {
setIsLoading(true); setIsLoading(true);
// Pass tenantId if we're on a tenant route to get tenant-specific logs const response = await auditLogService.getMyLogs(1, 5);
const filterTenantId = window.location.pathname.startsWith('/tenant') ? tenantId : null;
const response = await auditLogService.getAll(1, 5, { tenant_id: filterTenantId }, ['created_at', 'desc']);
if (response.success) { if (response.success) {
setAuditLogs(response.data); setAuditLogs(response.data);
} }
} catch (err: any) { } catch (err: any) {
// Fallback or log if needed console.error('Failed to fetch recent activity:', err);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@ -69,7 +77,14 @@ export const RecentActivity = ({ variant }: RecentActivityProps) => {
<div className="bg-white rounded-xl flex flex-col h-full border border-[#e5e7eb] shadow-sm overflow-hidden"> <div className="bg-white rounded-xl flex flex-col h-full border border-[#e5e7eb] shadow-sm overflow-hidden">
<div className="px-6 py-5 border-b border-[#f1f5f9] flex justify-between items-center"> <div className="px-6 py-5 border-b border-[#f1f5f9] flex justify-between items-center">
<h2 className="text-[16px] font-bold text-[#111827] tracking-tight">Recent Activity</h2> <h2 className="text-[16px] font-bold text-[#111827] tracking-tight">Recent Activity</h2>
<span className="text-[11px] font-bold text-gray-400">Last 24 hours</span> <Button
variant="ghost"
size="sm"
className="text-[11px] font-bold text-[#084cc8] hover:bg-[#084cc8]/5 gap-1 h-7"
onClick={() => navigate('/tenant/audit-logs')}
>
View All <ArrowRight className="w-3 h-3" />
</Button>
</div> </div>
<div className="flex-1"> <div className="flex-1">
@ -89,8 +104,7 @@ export const RecentActivity = ({ variant }: RecentActivityProps) => {
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap"> <div className="flex items-center gap-1.5 flex-wrap">
<span className="text-[12px] font-bold text-[#111827]">{log.user?.email?.split('@')[0] || 'System User'}</span> <span className="text-[12px] font-bold text-[#111827]">{log.action}</span>
<span className="text-[12px] font-medium text-gray-500 whitespace-nowrap">{log.action.toLowerCase().includes('create') ? 'created' : 'performed'}</span>
<span className="text-[12px] font-bold text-[#084cc8] hover:underline cursor-pointer truncate">{log.resource_type}</span> <span className="text-[12px] font-bold text-[#084cc8] hover:underline cursor-pointer truncate">{log.resource_type}</span>
</div> </div>
</div> </div>
@ -103,22 +117,19 @@ export const RecentActivity = ({ variant }: RecentActivityProps) => {
); );
} }
// TABLE VARIANT (Original design for superadmin dashboard) // TABLE VARIANT
return ( return (
<Card className="flex-1 border-[rgba(0,0,0,0.12)] w-full"> <Card className="flex-1 border-[rgba(0,0,0,0.12)] w-full">
<CardHeader className="border-b border-[rgba(0,0,0,0.08)] pb-3 md:pb-4 pt-3 md:pt-4 px-4 md:px-5 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 sm:gap-2"> <CardHeader className="border-b border-[rgba(0,0,0,0.08)] pb-3 md:pb-4 pt-3 md:pt-4 px-4 md:px-5 flex flex-row items-center justify-between gap-2">
<h2 className="text-sm md:text-[15px] font-semibold text-[#0f1724]">Recent Activity</h2> <h2 className="text-sm md:text-[15px] font-semibold text-[#0f1724]">Recent Activity</h2>
<div className="flex items-center gap-2 w-full sm:w-auto"> <Button
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded px-2.5 py-1.5 flex items-center gap-1.5 flex-1 sm:flex-initial"> variant="outline"
<span className="text-[11px] font-medium text-[#0f1724]">Action</span> size="sm"
<span className="text-[11px] font-normal text-[#6b7280]">All</span> className="h-8 text-[11px] gap-1.5 border-[rgba(0,0,0,0.08)] hover:bg-gray-50"
<ChevronDown className="w-3 h-3" /> onClick={() => navigate('/tenant/audit-logs')}
</div> >
<Button variant="ghost" size="sm" className="gap-1 px-1 min-h-[44px]"> View All <ArrowRight className="w-3 h-3" />
<Filter className="w-3 h-3" /> </Button>
<span className="text-[11px] font-normal text-[#6b7280] hidden md:inline">Filters</span>
</Button>
</div>
</CardHeader> </CardHeader>
<CardContent className="p-0"> <CardContent className="p-0">
{isLoading ? ( {isLoading ? (
@ -129,40 +140,43 @@ export const RecentActivity = ({ variant }: RecentActivityProps) => {
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full border-collapse"> <table className="w-full border-collapse">
<thead> <thead>
<tr className="bg-white border-b border-[rgba(0,0,0,0.08)]"> <tr className="bg-gray-50/50 border-b border-[rgba(0,0,0,0.08)]">
<th className="px-5 py-4 text-left text-xs font-bold text-[#374151] uppercase tracking-wider">User Profile</th> <th className="px-5 py-3 text-left text-[11px] font-bold text-[#6b7280] uppercase tracking-wider">Timestamp</th>
<th className="px-5 py-4 text-left text-xs font-bold text-[#374151] uppercase tracking-wider">Timestamp</th> <th className="px-5 py-3 text-left text-[11px] font-bold text-[#6b7280] uppercase tracking-wider">Resource Type</th>
<th className="px-5 py-4 text-left text-xs font-bold text-[#374151] uppercase tracking-wider">Resource Type</th> <th className="px-5 py-3 text-left text-[11px] font-bold text-[#6b7280] uppercase tracking-wider">Method</th>
<th className="px-5 py-4 text-left text-xs font-bold text-[#374151] uppercase tracking-wider">Action</th> <th className="px-5 py-3 text-left text-[11px] font-bold text-[#6b7280] uppercase tracking-wider">Status</th>
<th className="px-5 py-3 text-left text-[11px] font-bold text-[#6b7280] uppercase tracking-wider">IP Address</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-[rgba(0,0,0,0.04)]"> <tbody className="divide-y divide-[rgba(0,0,0,0.04)]">
{auditLogs.map((log) => ( {auditLogs.map((log) => (
<tr key={log.id} className="hover:bg-gray-50/50 transition-colors"> <tr key={log.id} className="hover:bg-gray-50/30 transition-colors">
<td className="px-5 py-4 whitespace-nowrap">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-gray-100 flex items-center justify-center shrink-0 border border-gray-200">
<User className="w-4 h-4 text-gray-400" />
</div>
<div className="flex flex-col">
<span className="text-[13px] font-bold text-[#111827]">
{log.user ? `${log.user.first_name || ''} ${log.user.last_name || ''}`.trim() || log.user.email.split('@')[0] : 'System User'}
</span>
{log.user && <span className="text-[10px] text-gray-400 font-medium">{log.user.email}</span>}
</div>
</div>
</td>
<td className="px-5 py-4 whitespace-nowrap"> <td className="px-5 py-4 whitespace-nowrap">
<span className="text-[12px] font-medium text-gray-500">{formatRelativeTime(log.created_at)}</span> <span className="text-[12px] font-medium text-gray-500">{formatRelativeTime(log.created_at)}</span>
</td> </td>
<td className="px-5 py-4 whitespace-nowrap"> <td className="px-5 py-4 whitespace-nowrap">
<span className="text-[12px] font-bold text-[#084cc8]">{log.resource_type}</span> <span className="text-[12px] font-bold text-[#084cc8] truncate max-w-[150px] inline-block">{log.resource_type}</span>
</td> </td>
<td className="px-5 py-4 whitespace-nowrap"> <td className="px-5 py-4 whitespace-nowrap">
<StatusBadge variant={getActionVariant(log.action)}>{log.action}</StatusBadge> <StatusBadge variant={getMethodVariant(log.request_method)}>
{log.request_method || 'N/A'}
</StatusBadge>
</td>
<td className="px-5 py-4 whitespace-nowrap">
<span className={cn("text-[12px] font-bold", getStatusColor(log.response_status))}>
{log.response_status || '---'}
</span>
</td>
<td className="px-5 py-4 whitespace-nowrap">
<span className="text-[12px] font-mono text-gray-500">{log.ip_address || '---'}</span>
</td> </td>
</tr> </tr>
))} ))}
{auditLogs.length === 0 && (
<tr>
<td colSpan={5} className="px-5 py-10 text-center text-xs text-gray-400">No recent activity recorded</td>
</tr>
)}
</tbody> </tbody>
</table> </table>
</div> </div>

View File

@ -3,7 +3,7 @@ import { Layout } from '@/components/layout/Layout';
import { StatsGrid } from '@/features/dashboard/components/StatsGrid'; import { StatsGrid } from '@/features/dashboard/components/StatsGrid';
import { RecentActivity } from '@/features/dashboard/components/RecentActivity'; import { RecentActivity } from '@/features/dashboard/components/RecentActivity';
import { QuickActions } from '@/features/dashboard/components/QuickActions'; import { QuickActions } from '@/features/dashboard/components/QuickActions';
import { SystemHealth } from '@/features/dashboard/components/SystemHealth'; // import { SystemHealth } from '@/features/dashboard/components/SystemHealth';
const Dashboard = (): ReactElement => { const Dashboard = (): ReactElement => {
return ( return (
@ -22,7 +22,7 @@ const Dashboard = (): ReactElement => {
<RecentActivity /> <RecentActivity />
<div className="flex flex-col gap-4 md:gap-6 w-full lg:w-[300px] lg:shrink-0"> <div className="flex flex-col gap-4 md:gap-6 w-full lg:w-[300px] lg:shrink-0">
<QuickActions /> <QuickActions />
<SystemHealth /> {/* <SystemHealth /> */}
</div> </div>
</div> </div>
</Layout> </Layout>

View File

@ -329,13 +329,13 @@ const Modules = (): ReactElement => {
{/* Actions */} {/* Actions */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* Export Button */} {/* Export Button */}
<button {/* <button
type="button" type="button"
className="flex items-center gap-1.5 px-3 py-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0f1724] hover:bg-gray-50 transition-colors cursor-pointer" className="flex items-center gap-1.5 px-3 py-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0f1724] hover:bg-gray-50 transition-colors cursor-pointer"
> >
<Download className="w-3.5 h-3.5" /> <Download className="w-3.5 h-3.5" />
<span>Export</span> <span>Export</span>
</button> </button> */}
{/* New Module Button */} {/* New Module Button */}
<PrimaryButton <PrimaryButton

View File

@ -372,13 +372,13 @@ const Tenants = (): ReactElement => {
{/* Actions */} {/* Actions */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* Export Button */} {/* Export Button */}
<button {/* <button
type="button" type="button"
className="flex items-center gap-1.5 px-3 py-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0f1724] hover:bg-gray-50 transition-colors cursor-pointer" className="flex items-center gap-1.5 px-3 py-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0f1724] hover:bg-gray-50 transition-colors cursor-pointer"
> >
<Download className="w-3.5 h-3.5" /> <Download className="w-3.5 h-3.5" />
<span>Export</span> <span>Export</span>
</button> </button> */}
{/* New Tenant Button (Old) - Commented out, using wizard instead */} {/* New Tenant Button (Old) - Commented out, using wizard instead */}
{/* <PrimaryButton {/* <PrimaryButton

View File

@ -1,28 +1,19 @@
import { Layout } from "@/components/layout/Layout"; import { Layout } from "@/components/layout/Layout";
import type { ReactElement } from "react"; import type { ReactElement } from "react";
import { import {
Info,
FileCheck, FileCheck,
Briefcase, Briefcase,
FileText, FileText,
GraduationCap, GraduationCap,
Users,
Bell,
} from "lucide-react"; } from "lucide-react";
import { QuickActions } from "@/features/dashboard/components/QuickActions"; import { QuickActions } from "@/features/dashboard/components/QuickActions";
import { RecentActivity } from "@/features/dashboard/components/RecentActivity"; import { RecentActivity } from "@/features/dashboard/components/RecentActivity";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import {
ResponsiveContainer,
ComposedChart,
CartesianGrid,
XAxis,
YAxis,
Tooltip,
Legend,
Bar,
Line
} from 'recharts';
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { workflowService } from "@/services/workflow-service"; import { workflowService } from "@/services/workflow-service";
import { dashboardService, type TenantDashboardStats } from "@/services/dashboard-service";
import type { WorkflowTask } from "@/types/workflow"; import type { WorkflowTask } from "@/types/workflow";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
@ -117,7 +108,7 @@ const TaskCard = ({ task }: { task: WorkflowTask }) => {
task.is_overdue ? "bg-[#ef4444]" : "bg-[#10b981]" task.is_overdue ? "bg-[#ef4444]" : "bg-[#10b981]"
)} /> )} />
<span className="text-[11px] text-[#6b7280] font-medium leading-none"> <span className="text-[11px] text-[#6b7280] font-medium leading-none">
{task.step.name} {task.assignment.assigned_role} {task.step.name} {task.assignment?.assigned_role || task.assignment?.assigned_to_name || 'Unassigned'}
</span> </span>
</div> </div>
@ -137,6 +128,7 @@ const TaskCard = ({ task }: { task: WorkflowTask }) => {
); );
}; };
/*
const CAPASummaryChart = () => { const CAPASummaryChart = () => {
const data = [ const data = [
{ name: 'Jan', open: 35, inProgress: 48, closed: 25, trend: 15 }, { name: 'Jan', open: 35, inProgress: 48, closed: 25, trend: 15 },
@ -196,68 +188,98 @@ const CAPASummaryChart = () => {
</div> </div>
); );
}; };
*/
const Dashboard = (): ReactElement => { const Dashboard = (): ReactElement => {
const navigate = useNavigate(); const navigate = useNavigate();
const [tasks, setTasks] = useState<WorkflowTask[]>([]); const [tasks, setTasks] = useState<WorkflowTask[]>([]);
const [stats, setStats] = useState<TenantDashboardStats | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [tasksLoading, setTasksLoading] = useState(true);
useEffect(() => { useEffect(() => {
fetchTasks(); fetchDashboardData();
}, []); }, []);
const fetchTasks = async () => { const fetchDashboardData = async () => {
setLoading(true);
setTasksLoading(true);
try { try {
setLoading(true); // Fetch tasks independently to avoid one failing the other
const response = await workflowService.listTasks({ limit: 3 }); workflowService.listTasks({ limit: 3 })
if (response.success) { .then(response => {
setTasks(response.data); console.log("[Dashboard] Tasks response:", response);
} if (response.success && Array.isArray(response.data)) {
setTasks(response.data);
}
})
.catch(error => {
console.error("Error fetching tasks:", error);
})
.finally(() => {
setTasksLoading(false);
});
// Fetch statistics independently
dashboardService.getTenantStatistics()
.then(response => {
if (response.success) {
setStats(response.data);
}
})
.catch(error => {
console.error("Error fetching dashboard statistics:", error);
})
.finally(() => {
setLoading(false);
});
} catch (error) { } catch (error) {
console.error("Error fetching tasks:", error); console.error("Critical error in fetchDashboardData:", error);
} finally {
setLoading(false); setLoading(false);
setTasksLoading(false);
} }
}; };
const statCards: StatCardProps[] = [ const statCards: StatCardProps[] = [
{ stats?.documentsCount !== undefined && {
icon: Info,
value: 18,
label: "Open CAPAs",
badge: { text: "2 New This Week", variant: "success" }
},
{
icon: FileCheck,
value: 7,
label: "Pending Approvals",
badge: { text: "Awaiting Manager Review", variant: "warning" }
},
{
icon: Briefcase,
value: 9,
label: "Active Projects",
badge: { text: "3 At Risk Of Delay", variant: "error" }
},
{
icon: Info,
value: 3,
label: "Overdue Tasks",
badge: { text: "Action Needed", variant: "error" }
},
{
icon: FileText, icon: FileText,
value: 14, value: stats.documentsCount,
label: "Docs Pending Review", label: "Total Documents",
badge: { text: "Due Within 7 Days", variant: "info" } badge: { text: "Controlled", variant: "info" }
}, },
{ stats?.pendingTasks !== undefined && {
icon: GraduationCap, icon: FileCheck,
value: "94%", value: stats.pendingTasks,
label: "Training Compliance", label: "My Tasks",
badge: { text: "Target Met", variant: "success" } badge: { text: "Action Needed", variant: "warning" }
}, },
]; stats?.usersCount !== undefined && {
icon: Users,
value: stats.usersCount,
label: "Total Users",
badge: { text: "Team Members", variant: "success" }
},
stats?.unreadNotificationsCount !== undefined && {
icon: Bell,
value: stats.unreadNotificationsCount,
label: "Notifications",
badge: { text: "Unread", variant: "error" }
},
stats?.activeModulesCount !== undefined && {
icon: Briefcase,
value: stats.activeModulesCount,
label: "Running Modules",
badge: { text: "Operational", variant: "success" }
},
// Training Compliance is still static/placeholder for now
// {
// icon: GraduationCap,
// value: "94%",
// label: "Compliance",
// badge: { text: "Target Met", variant: "success" }
// },
].filter(Boolean) as StatCardProps[];
return ( return (
<Layout <Layout
@ -273,13 +295,19 @@ const Dashboard = (): ReactElement => {
<div className="lg:col-span-8 xl:col-span-9 flex flex-col gap-6"> <div className="lg:col-span-8 xl:col-span-9 flex flex-col gap-6">
{/* Stats Grid */} {/* Stats Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
{statCards.map((card, index) => ( {loading ? (
<StatCard key={index} {...card} /> <div className="col-span-full text-center py-8 text-gray-400 text-sm">Loading statistics...</div>
))} ) : statCards.length > 0 ? (
statCards.map((card, index) => (
<StatCard key={index} {...card} />
))
) : (
<div className="col-span-full text-center py-8 text-gray-400 text-sm">No statistics available</div>
)}
</div> </div>
{/* CAPA Summary Card */} {/* CAPA Summary Card (Commented out for now) */}
<div className="bg-white border border-[#e5e7eb] rounded-xl p-6 shadow-sm"> {/* <div className="bg-white border border-[#e5e7eb] rounded-xl p-6 shadow-sm">
<div className="flex justify-between items-center mb-8"> <div className="flex justify-between items-center mb-8">
<h2 className="text-[16px] font-bold text-[#111827] tracking-tight"> <h2 className="text-[16px] font-bold text-[#111827] tracking-tight">
CAPA Summary CAPA Summary
@ -295,7 +323,7 @@ const Dashboard = (): ReactElement => {
</div> </div>
<CAPASummaryChart /> <CAPASummaryChart />
</div> </div> */}
{/* Recent Activity Card */} {/* Recent Activity Card */}
<RecentActivity /> <RecentActivity />
@ -316,7 +344,7 @@ const Dashboard = (): ReactElement => {
</div> </div>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{loading ? ( {tasksLoading ? (
<div className="text-center py-4 text-gray-400 text-sm">Loading tasks...</div> <div className="text-center py-4 text-gray-400 text-sm">Loading tasks...</div>
) : tasks.length > 0 ? ( ) : tasks.length > 0 ? (
tasks.map((task) => ( tasks.map((task) => (

View File

@ -11,7 +11,7 @@ import {
import { documentService } from "@/services/document-service"; import { documentService } from "@/services/document-service";
import { moduleService } from "@/services/module-service"; import { moduleService } from "@/services/module-service";
import type { DocumentCategory, DocumentSummary } from "@/types/document"; import type { DocumentCategory, DocumentSummary } from "@/types/document";
import type { MyModule } from "@/types/module"; import type { Module } from "@/types/module";
import { Plus, Search } from "lucide-react"; import { Plus, Search } from "lucide-react";
const formatDate = (value?: string | null): string => { const formatDate = (value?: string | null): string => {
@ -42,7 +42,7 @@ const Documents = (): ReactElement => {
const [categoryFilter, setCategoryFilter] = useState<string | null>(null); const [categoryFilter, setCategoryFilter] = useState<string | null>(null);
const [typeFilter, setTypeFilter] = useState<string | null>(null); const [typeFilter, setTypeFilter] = useState<string | null>(null);
const [moduleFilter, setModuleFilter] = useState<string | null>(null); const [moduleFilter, setModuleFilter] = useState<string | null>(null);
const [modules, setModules] = useState<MyModule[]>([]); const [modules, setModules] = useState<Module[]>([]);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [limit, setLimit] = useState(10); const [limit, setLimit] = useState(10);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
@ -59,7 +59,7 @@ const Documents = (): ReactElement => {
documentService.getCategories(), documentService.getCategories(),
documentService.getStatuses(), documentService.getStatuses(),
documentService.getTypes(), documentService.getTypes(),
moduleService.getMyModules(), moduleService.getAvailable(),
]); ]);
setCategories(categoriesRes.data || []); setCategories(categoriesRes.data || []);
setStatuses(statusesRes.data || []); setStatuses(statusesRes.data || []);

View File

@ -169,13 +169,16 @@ export const Notifications = () => {
} }
// Special handling for system as requested - redirect to Dashboard // Special handling for system as requested - redirect to Dashboard
if (["system"].includes(notification.category || "")) { // if (["system"].includes(notification.category || "")) {
navigate("/tenant"); // navigate("/tenant");
return; // return;
} // }
if (notification.action_url) { if (notification.action_url) {
navigate(notification.action_url); const targetUrl = notification.action_url.startsWith('/tenant')
? notification.action_url
: `/tenant${notification.action_url.startsWith('/') ? '' : '/'}${notification.action_url}`;
navigate(targetUrl);
} }
}; };

View File

@ -342,13 +342,13 @@ const Roles = (): ReactElement => {
{/* Actions */} {/* Actions */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* Export Button */} {/* Export Button */}
<button {/* <button
type="button" type="button"
className="flex items-center gap-1.5 px-3 py-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0f1724] hover:bg-gray-50 transition-colors cursor-pointer" className="flex items-center gap-1.5 px-3 py-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0f1724] hover:bg-gray-50 transition-colors cursor-pointer"
> >
<Download className="w-3.5 h-3.5" /> <Download className="w-3.5 h-3.5" />
<span>Export</span> <span>Export</span>
</button> </button> */}
{/* New Role Button */} {/* New Role Button */}
{canCreate('roles') && ( {canCreate('roles') && (

View File

@ -482,13 +482,13 @@ const Users = (): ReactElement => {
{/* Actions */} {/* Actions */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* Export Button */} {/* Export Button */}
<button {/* <button
type="button" type="button"
className="flex items-center gap-1.5 px-3 py-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0f1724] hover:bg-gray-50 transition-colors cursor-pointer" className="flex items-center gap-1.5 px-3 py-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0f1724] hover:bg-gray-50 transition-colors cursor-pointer"
> >
<Download className="w-3.5 h-3.5" /> <Download className="w-3.5 h-3.5" />
<span>Export</span> <span>Export</span>
</button> </button> */}
{/* New User Button */} {/* New User Button */}
{canCreate("users") && ( {canCreate("users") && (

View File

@ -14,9 +14,29 @@ export interface DashboardStatisticsResponse {
data: DashboardStatistics; data: DashboardStatistics;
} }
export interface TenantDashboardStats {
documentsCount?: number;
pendingTasks?: number;
usersCount?: number;
unreadNotificationsCount?: number;
activeModulesCount?: number;
permissions: string[];
}
export interface TenantDashboardResponse {
success: boolean;
data: TenantDashboardStats;
}
export const dashboardService = { export const dashboardService = {
getStatistics: async (): Promise<DashboardStatisticsResponse> => { getStatistics: async (): Promise<DashboardStatisticsResponse> => {
const response = await apiClient.get<DashboardStatisticsResponse>('/dashboard/statistics'); const response = await apiClient.get<DashboardStatisticsResponse>('/dashboard/statistics');
return response.data; return response.data;
}, },
getTenantStatistics: async (): Promise<TenantDashboardResponse> => {
const response = await apiClient.get<TenantDashboardResponse>('/dashboard/tenant');
return response.data;
}
}; };

View File

@ -67,6 +67,10 @@ const notificationSlice = createSlice({
// Add to notifications if not already present (avoid duplicates from WebSocket/initial load overlap) // Add to notifications if not already present (avoid duplicates from WebSocket/initial load overlap)
if (!state.notifications.find((n) => n.id === action.payload.id)) { if (!state.notifications.find((n) => n.id === action.payload.id)) {
state.notifications = [action.payload, ...state.notifications]; state.notifications = [action.payload, ...state.notifications];
// Increment unread count if it's a new unread notification
if (!action.payload.is_read) {
state.unread_count += 1;
}
} }
}, },
setUnreadCount: (state, action: PayloadAction<number>) => { setUnreadCount: (state, action: PayloadAction<number>) => {