refactor: optimize notification socket lifecycle, update dashboard services, and refine tenant UI permissions and actions
This commit is contained in:
parent
c2e6d779d4
commit
26566f6620
@ -155,14 +155,14 @@ export const Header = ({ breadcrumbs, currentPage, onMenuClick }: HeaderProps):
|
||||
|
||||
{/* Right Side */}
|
||||
<div className="flex items-center gap-2 md:gap-3">
|
||||
<Button
|
||||
{/* <Button
|
||||
variant="ghost"
|
||||
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]"
|
||||
aria-label="Search"
|
||||
>
|
||||
<Search className="w-4 h-4" />
|
||||
</Button>
|
||||
</Button> */}
|
||||
<NotificationBell />
|
||||
|
||||
{/* Desktop User Dropdown */}
|
||||
|
||||
@ -133,7 +133,6 @@ const tenantAdminSystemMenu: MenuItem[] = [
|
||||
icon: FileText,
|
||||
label: "Audit Logs",
|
||||
path: "/tenant/audit-logs",
|
||||
requiredPermission: { resource: "audit_logs" },
|
||||
},
|
||||
{
|
||||
icon: Settings,
|
||||
|
||||
@ -60,6 +60,13 @@ export const NotificationBell = () => {
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// Fetch notifications when opened
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
dispatch(fetchNotifications({ limit: 20 }));
|
||||
}
|
||||
}, [isOpen, dispatch]);
|
||||
|
||||
const handleMarkAllRead = () => {
|
||||
dispatch(readAllAsync());
|
||||
};
|
||||
@ -77,14 +84,17 @@ export const NotificationBell = () => {
|
||||
}
|
||||
|
||||
// Special handling for system as requested - redirect to Dashboard
|
||||
if (['system'].includes(notification.category || '')) {
|
||||
navigate('/tenant');
|
||||
setIsOpen(false);
|
||||
return;
|
||||
}
|
||||
// if (['system'].includes(notification.category || '')) {
|
||||
// navigate('/tenant');
|
||||
// setIsOpen(false);
|
||||
// return;
|
||||
// }
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { useEffect, useCallback, createContext, useContext, useRef, type ReactNode } from 'react';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
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 { useNavigate } from 'react-router-dom';
|
||||
|
||||
@ -66,6 +66,23 @@ export const NotificationProvider = ({ children }: NotificationProviderProps) =>
|
||||
}
|
||||
}, [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(() => {
|
||||
if (!isAuthenticated || !accessToken) {
|
||||
if (socketRef.current) {
|
||||
@ -78,8 +95,8 @@ export const NotificationProvider = ({ children }: NotificationProviderProps) =>
|
||||
|
||||
if (socketRef.current?.connected) return;
|
||||
|
||||
console.log('WebSocket: Connecting to', SOCKET_URL);
|
||||
// Connect to WebSocket
|
||||
console.log('WebSocket: Connecting to', SOCKET_URL);
|
||||
const socket = io(SOCKET_URL, {
|
||||
auth: {
|
||||
token: accessToken
|
||||
@ -87,11 +104,8 @@ export const NotificationProvider = ({ children }: NotificationProviderProps) =>
|
||||
transports: ["websocket", "polling"],
|
||||
reconnection: true,
|
||||
reconnectionAttempts: 5,
|
||||
reconnectionDelay: 1000
|
||||
});
|
||||
|
||||
socket.on('connected', (data) => {
|
||||
console.log('WebSocket: Authenticated for user', data.userId);
|
||||
reconnectionDelay: 1000,
|
||||
autoConnect: true
|
||||
});
|
||||
|
||||
socket.on('notification', handleNotification);
|
||||
@ -100,31 +114,16 @@ export const NotificationProvider = ({ children }: NotificationProviderProps) =>
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('WebSocket: Connected');
|
||||
// Re-fetch on reconnect to avoid missing updates
|
||||
dispatch(fetchNotifications({ limit: 20 }));
|
||||
dispatch(fetchUnreadCount());
|
||||
});
|
||||
|
||||
socket.on('disconnect', (reason) => {
|
||||
console.log('WebSocket: Disconnected', reason);
|
||||
});
|
||||
|
||||
socket.on('connect_error', (err) => {
|
||||
console.error('WebSocket: Connection error', err.message);
|
||||
// If we've already initialized, re-fetch count on reconnect to be safe
|
||||
if (hasInitialized.current) {
|
||||
dispatch(fetchUnreadCount());
|
||||
}
|
||||
});
|
||||
|
||||
socketRef.current = socket;
|
||||
|
||||
// Initial fetch
|
||||
dispatch(fetchNotifications({ limit: 20 }));
|
||||
dispatch(fetchUnreadCount());
|
||||
|
||||
return () => {
|
||||
if (socketRef.current) {
|
||||
console.log('WebSocket: Cleaning up connection');
|
||||
socketRef.current.disconnect();
|
||||
socketRef.current = null;
|
||||
}
|
||||
// We don't want to disconnect on every route change, so we persist the socketRef
|
||||
};
|
||||
}, [isAuthenticated, accessToken, dispatch, handleNotification, handleUnreadCount, handleNotificationRead]);
|
||||
|
||||
|
||||
@ -649,7 +649,7 @@ export const WorkflowDefinitionModal = ({
|
||||
</div>
|
||||
<FormField
|
||||
label="Entity Type"
|
||||
placeholder="e.g. Document, CAPA, Supplier, Training"
|
||||
placeholder="e.g. document, capa, supplier, training"
|
||||
required
|
||||
{...register("entity_type")}
|
||||
error={errors.entity_type?.message as any}
|
||||
|
||||
@ -5,39 +5,34 @@ import type { QuickAction } from '@/types/dashboard';
|
||||
|
||||
export const QuickActions = () => {
|
||||
const navigate = useNavigate();
|
||||
const { roles } = useAppSelector((state) => state.auth);
|
||||
const { roles, permissions } = useAppSelector((state) => state.auth);
|
||||
|
||||
// Simple roles array parsing
|
||||
let rolesArray: string[] = [];
|
||||
if (Array.isArray(roles)) {
|
||||
rolesArray = roles;
|
||||
} else if (typeof roles === "string") {
|
||||
try {
|
||||
rolesArray = JSON.parse(roles);
|
||||
} catch {
|
||||
rolesArray = [];
|
||||
}
|
||||
}
|
||||
// Helper to check permission
|
||||
const hasPermission = (resource: string, action: string) => {
|
||||
if (roles.includes('super_admin') || roles.includes('tenant_admin')) return true;
|
||||
return permissions.some(p => p.resource === resource && p.action === action);
|
||||
};
|
||||
|
||||
const isSuperAdmin = rolesArray.includes('super_admin');
|
||||
const isSuperAdmin = roles.includes('super_admin');
|
||||
|
||||
// Define actions based on role
|
||||
const superAdminActions: QuickAction[] = [
|
||||
{ icon: Plus, label: 'New Tenant', onClick: () => navigate('/tenants/create-wizard') },
|
||||
{ icon: UserPlus, label: 'New User', onClick: () => console.log('New User') },
|
||||
{ icon: Shield, label: 'New Role', onClick: () => console.log('New Role') },
|
||||
{ icon: Settings, label: 'New Config', onClick: () => console.log('Config') },
|
||||
{ icon: UserPlus, label: 'Module', onClick: () => navigate('/modules') },
|
||||
{ icon: Shield, label: 'Notification', onClick: () => navigate('/notifications') },
|
||||
{ icon: Settings, label: 'Audit Logs', onClick: () => navigate('/audit-logs') },
|
||||
];
|
||||
|
||||
const tenantAdminActions: QuickAction[] = [
|
||||
{ icon: UserPlus, label: 'New User', onClick: () => navigate('/tenant/users') },
|
||||
{ icon: Shield, label: 'New Role', onClick: () => navigate('/tenant/roles') },
|
||||
{ icon: Building2, label: 'New Dept', onClick: () => navigate('/tenant/departments') },
|
||||
{ icon: BadgeCheck, label: 'New Desig', onClick: () => navigate('/tenant/designations') },
|
||||
];
|
||||
hasPermission('users', 'create') && { icon: UserPlus, label: 'New User', onClick: () => navigate('/tenant/users') },
|
||||
hasPermission('roles', 'create') && { icon: Shield, label: 'New Role', onClick: () => navigate('/tenant/roles') },
|
||||
hasPermission('departments', 'create') && { icon: Building2, label: 'New Dept', onClick: () => navigate('/tenant/departments') },
|
||||
hasPermission('designations', 'create') && { icon: BadgeCheck, label: 'New Desig', onClick: () => navigate('/tenant/designations') },
|
||||
].filter(Boolean) as QuickAction[];
|
||||
|
||||
const actions = isSuperAdmin ? superAdminActions : tenantAdminActions;
|
||||
|
||||
|
||||
return (
|
||||
<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">
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
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 type { AuditLog } from '@/types/audit-log';
|
||||
import { useAppSelector } from '@/hooks/redux-hooks';
|
||||
@ -8,16 +9,7 @@ import { Card, CardHeader, CardContent } from '@/components/ui/card';
|
||||
import { StatusBadge } from '@/components/shared';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
// Helper functions (kept from original)
|
||||
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';
|
||||
};
|
||||
|
||||
// Helper functions
|
||||
const formatRelativeTime = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
@ -32,6 +24,23 @@ const formatRelativeTime = (dateString: string): string => {
|
||||
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 {
|
||||
variant?: 'list' | 'table';
|
||||
}
|
||||
@ -40,6 +49,7 @@ export const RecentActivity = ({ variant }: RecentActivityProps) => {
|
||||
const [auditLogs, setAuditLogs] = useState<AuditLog[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const { tenantId } = useAppSelector((state) => state.auth);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Default to table variant for a more professional look
|
||||
const activeVariant = variant || 'table';
|
||||
@ -48,14 +58,12 @@ export const RecentActivity = ({ variant }: RecentActivityProps) => {
|
||||
const fetchRecentActivity = async (): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
// Pass tenantId if we're on a tenant route to get tenant-specific logs
|
||||
const filterTenantId = window.location.pathname.startsWith('/tenant') ? tenantId : null;
|
||||
const response = await auditLogService.getAll(1, 5, { tenant_id: filterTenantId }, ['created_at', 'desc']);
|
||||
const response = await auditLogService.getMyLogs(1, 5);
|
||||
if (response.success) {
|
||||
setAuditLogs(response.data);
|
||||
}
|
||||
} catch (err: any) {
|
||||
// Fallback or log if needed
|
||||
console.error('Failed to fetch recent activity:', err);
|
||||
} finally {
|
||||
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="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>
|
||||
<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 className="flex-1">
|
||||
@ -89,8 +104,7 @@ export const RecentActivity = ({ variant }: RecentActivityProps) => {
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<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-medium text-gray-500 whitespace-nowrap">{log.action.toLowerCase().includes('create') ? 'created' : 'performed'}</span>
|
||||
<span className="text-[12px] font-bold text-[#111827]">{log.action}</span>
|
||||
<span className="text-[12px] font-bold text-[#084cc8] hover:underline cursor-pointer truncate">{log.resource_type}</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -103,22 +117,19 @@ export const RecentActivity = ({ variant }: RecentActivityProps) => {
|
||||
);
|
||||
}
|
||||
|
||||
// TABLE VARIANT (Original design for superadmin dashboard)
|
||||
// TABLE VARIANT
|
||||
return (
|
||||
<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>
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||
<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">
|
||||
<span className="text-[11px] font-medium text-[#0f1724]">Action</span>
|
||||
<span className="text-[11px] font-normal text-[#6b7280]">All</span>
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="gap-1 px-1 min-h-[44px]">
|
||||
<Filter className="w-3 h-3" />
|
||||
<span className="text-[11px] font-normal text-[#6b7280] hidden md:inline">Filters</span>
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-[11px] gap-1.5 border-[rgba(0,0,0,0.08)] hover:bg-gray-50"
|
||||
onClick={() => navigate('/tenant/audit-logs')}
|
||||
>
|
||||
View All <ArrowRight className="w-3 h-3" />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{isLoading ? (
|
||||
@ -129,40 +140,43 @@ export const RecentActivity = ({ variant }: RecentActivityProps) => {
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-white 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-4 text-left text-xs font-bold text-[#374151] uppercase tracking-wider">Timestamp</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-4 text-left text-xs font-bold text-[#374151] uppercase tracking-wider">Action</th>
|
||||
<tr className="bg-gray-50/50 border-b border-[rgba(0,0,0,0.08)]">
|
||||
<th className="px-5 py-3 text-left text-[11px] font-bold text-[#6b7280] 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-3 text-left text-[11px] font-bold text-[#6b7280] uppercase tracking-wider">Method</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>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[rgba(0,0,0,0.04)]">
|
||||
{auditLogs.map((log) => (
|
||||
<tr key={log.id} className="hover:bg-gray-50/50 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>
|
||||
<tr key={log.id} className="hover:bg-gray-50/30 transition-colors">
|
||||
<td className="px-5 py-4 whitespace-nowrap">
|
||||
<span className="text-[12px] font-medium text-gray-500">{formatRelativeTime(log.created_at)}</span>
|
||||
</td>
|
||||
<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 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>
|
||||
</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>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@ -3,7 +3,7 @@ import { Layout } from '@/components/layout/Layout';
|
||||
import { StatsGrid } from '@/features/dashboard/components/StatsGrid';
|
||||
import { RecentActivity } from '@/features/dashboard/components/RecentActivity';
|
||||
import { QuickActions } from '@/features/dashboard/components/QuickActions';
|
||||
import { SystemHealth } from '@/features/dashboard/components/SystemHealth';
|
||||
// import { SystemHealth } from '@/features/dashboard/components/SystemHealth';
|
||||
|
||||
const Dashboard = (): ReactElement => {
|
||||
return (
|
||||
@ -22,7 +22,7 @@ const Dashboard = (): ReactElement => {
|
||||
<RecentActivity />
|
||||
<div className="flex flex-col gap-4 md:gap-6 w-full lg:w-[300px] lg:shrink-0">
|
||||
<QuickActions />
|
||||
<SystemHealth />
|
||||
{/* <SystemHealth /> */}
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
@ -329,13 +329,13 @@ const Modules = (): ReactElement => {
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Export Button */}
|
||||
<button
|
||||
{/* <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"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
<span>Export</span>
|
||||
</button>
|
||||
</button> */}
|
||||
|
||||
{/* New Module Button */}
|
||||
<PrimaryButton
|
||||
|
||||
@ -372,13 +372,13 @@ const Tenants = (): ReactElement => {
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Export Button */}
|
||||
<button
|
||||
{/* <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"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
<span>Export</span>
|
||||
</button>
|
||||
</button> */}
|
||||
|
||||
{/* New Tenant Button (Old) - Commented out, using wizard instead */}
|
||||
{/* <PrimaryButton
|
||||
|
||||
@ -1,28 +1,19 @@
|
||||
import { Layout } from "@/components/layout/Layout";
|
||||
import type { ReactElement } from "react";
|
||||
import {
|
||||
Info,
|
||||
FileCheck,
|
||||
Briefcase,
|
||||
FileText,
|
||||
GraduationCap,
|
||||
Users,
|
||||
Bell,
|
||||
} from "lucide-react";
|
||||
import { QuickActions } from "@/features/dashboard/components/QuickActions";
|
||||
import { RecentActivity } from "@/features/dashboard/components/RecentActivity";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
ComposedChart,
|
||||
CartesianGrid,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Bar,
|
||||
Line
|
||||
} from 'recharts';
|
||||
import { useState, useEffect } from "react";
|
||||
import { workflowService } from "@/services/workflow-service";
|
||||
import { dashboardService, type TenantDashboardStats } from "@/services/dashboard-service";
|
||||
import type { WorkflowTask } from "@/types/workflow";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
@ -117,7 +108,7 @@ const TaskCard = ({ task }: { task: WorkflowTask }) => {
|
||||
task.is_overdue ? "bg-[#ef4444]" : "bg-[#10b981]"
|
||||
)} />
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@ -137,6 +128,7 @@ const TaskCard = ({ task }: { task: WorkflowTask }) => {
|
||||
);
|
||||
};
|
||||
|
||||
/*
|
||||
const CAPASummaryChart = () => {
|
||||
const data = [
|
||||
{ name: 'Jan', open: 35, inProgress: 48, closed: 25, trend: 15 },
|
||||
@ -196,68 +188,98 @@ const CAPASummaryChart = () => {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
*/
|
||||
|
||||
const Dashboard = (): ReactElement => {
|
||||
const navigate = useNavigate();
|
||||
const [tasks, setTasks] = useState<WorkflowTask[]>([]);
|
||||
const [stats, setStats] = useState<TenantDashboardStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [tasksLoading, setTasksLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTasks();
|
||||
fetchDashboardData();
|
||||
}, []);
|
||||
|
||||
const fetchTasks = async () => {
|
||||
const fetchDashboardData = async () => {
|
||||
setLoading(true);
|
||||
setTasksLoading(true);
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await workflowService.listTasks({ limit: 3 });
|
||||
if (response.success) {
|
||||
setTasks(response.data);
|
||||
}
|
||||
// Fetch tasks independently to avoid one failing the other
|
||||
workflowService.listTasks({ limit: 3 })
|
||||
.then(response => {
|
||||
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) {
|
||||
console.error("Error fetching tasks:", error);
|
||||
} finally {
|
||||
console.error("Critical error in fetchDashboardData:", error);
|
||||
setLoading(false);
|
||||
setTasksLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const statCards: StatCardProps[] = [
|
||||
{
|
||||
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" }
|
||||
},
|
||||
{
|
||||
stats?.documentsCount !== undefined && {
|
||||
icon: FileText,
|
||||
value: 14,
|
||||
label: "Docs Pending Review",
|
||||
badge: { text: "Due Within 7 Days", variant: "info" }
|
||||
value: stats.documentsCount,
|
||||
label: "Total Documents",
|
||||
badge: { text: "Controlled", variant: "info" }
|
||||
},
|
||||
{
|
||||
icon: GraduationCap,
|
||||
value: "94%",
|
||||
label: "Training Compliance",
|
||||
badge: { text: "Target Met", variant: "success" }
|
||||
stats?.pendingTasks !== undefined && {
|
||||
icon: FileCheck,
|
||||
value: stats.pendingTasks,
|
||||
label: "My Tasks",
|
||||
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 (
|
||||
<Layout
|
||||
@ -273,13 +295,19 @@ const Dashboard = (): ReactElement => {
|
||||
<div className="lg:col-span-8 xl:col-span-9 flex flex-col gap-6">
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
||||
{statCards.map((card, index) => (
|
||||
<StatCard key={index} {...card} />
|
||||
))}
|
||||
{loading ? (
|
||||
<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>
|
||||
|
||||
{/* CAPA Summary Card */}
|
||||
<div className="bg-white border border-[#e5e7eb] rounded-xl p-6 shadow-sm">
|
||||
{/* CAPA Summary Card (Commented out for now) */}
|
||||
{/* <div className="bg-white border border-[#e5e7eb] rounded-xl p-6 shadow-sm">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<h2 className="text-[16px] font-bold text-[#111827] tracking-tight">
|
||||
CAPA Summary
|
||||
@ -295,7 +323,7 @@ const Dashboard = (): ReactElement => {
|
||||
</div>
|
||||
|
||||
<CAPASummaryChart />
|
||||
</div>
|
||||
</div> */}
|
||||
|
||||
{/* Recent Activity Card */}
|
||||
<RecentActivity />
|
||||
@ -316,7 +344,7 @@ const Dashboard = (): ReactElement => {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{loading ? (
|
||||
{tasksLoading ? (
|
||||
<div className="text-center py-4 text-gray-400 text-sm">Loading tasks...</div>
|
||||
) : tasks.length > 0 ? (
|
||||
tasks.map((task) => (
|
||||
|
||||
@ -11,7 +11,7 @@ import {
|
||||
import { documentService } from "@/services/document-service";
|
||||
import { moduleService } from "@/services/module-service";
|
||||
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";
|
||||
|
||||
const formatDate = (value?: string | null): string => {
|
||||
@ -42,7 +42,7 @@ const Documents = (): ReactElement => {
|
||||
const [categoryFilter, setCategoryFilter] = useState<string | null>(null);
|
||||
const [typeFilter, setTypeFilter] = 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 [limit, setLimit] = useState(10);
|
||||
const [total, setTotal] = useState(0);
|
||||
@ -59,7 +59,7 @@ const Documents = (): ReactElement => {
|
||||
documentService.getCategories(),
|
||||
documentService.getStatuses(),
|
||||
documentService.getTypes(),
|
||||
moduleService.getMyModules(),
|
||||
moduleService.getAvailable(),
|
||||
]);
|
||||
setCategories(categoriesRes.data || []);
|
||||
setStatuses(statusesRes.data || []);
|
||||
|
||||
@ -169,13 +169,16 @@ export const Notifications = () => {
|
||||
}
|
||||
|
||||
// Special handling for system as requested - redirect to Dashboard
|
||||
if (["system"].includes(notification.category || "")) {
|
||||
navigate("/tenant");
|
||||
return;
|
||||
}
|
||||
// if (["system"].includes(notification.category || "")) {
|
||||
// navigate("/tenant");
|
||||
// return;
|
||||
// }
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -342,13 +342,13 @@ const Roles = (): ReactElement => {
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Export Button */}
|
||||
<button
|
||||
{/* <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"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
<span>Export</span>
|
||||
</button>
|
||||
</button> */}
|
||||
|
||||
{/* New Role Button */}
|
||||
{canCreate('roles') && (
|
||||
|
||||
@ -482,13 +482,13 @@ const Users = (): ReactElement => {
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Export Button */}
|
||||
<button
|
||||
{/* <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"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
<span>Export</span>
|
||||
</button>
|
||||
</button> */}
|
||||
|
||||
{/* New User Button */}
|
||||
{canCreate("users") && (
|
||||
|
||||
@ -14,9 +14,29 @@ export interface DashboardStatisticsResponse {
|
||||
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 = {
|
||||
getStatistics: async (): Promise<DashboardStatisticsResponse> => {
|
||||
const response = await apiClient.get<DashboardStatisticsResponse>('/dashboard/statistics');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getTenantStatistics: async (): Promise<TenantDashboardResponse> => {
|
||||
const response = await apiClient.get<TenantDashboardResponse>('/dashboard/tenant');
|
||||
return response.data;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -67,6 +67,10 @@ const notificationSlice = createSlice({
|
||||
// Add to notifications if not already present (avoid duplicates from WebSocket/initial load overlap)
|
||||
if (!state.notifications.find((n) => n.id === action.payload.id)) {
|
||||
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>) => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user