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 */}
<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 */}

View File

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

View File

@ -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);
};

View File

@ -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]);

View File

@ -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}

View File

@ -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">

View File

@ -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>

View File

@ -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>

View File

@ -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

View File

@ -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

View File

@ -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) => (

View File

@ -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 || []);

View File

@ -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);
}
};

View File

@ -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') && (

View File

@ -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") && (

View File

@ -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;
}
};

View File

@ -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>) => {