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 */}
|
{/* 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 */}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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]);
|
||||||
|
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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" />
|
|
||||||
<span className="text-[11px] font-normal text-[#6b7280] hidden md:inline">Filters</span>
|
|
||||||
</Button>
|
</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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 () => {
|
||||||
try {
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const response = await workflowService.listTasks({ limit: 3 });
|
setTasksLoading(true);
|
||||||
if (response.success) {
|
try {
|
||||||
|
// 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);
|
setTasks(response.data);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
})
|
||||||
|
.catch(error => {
|
||||||
console.error("Error fetching tasks:", error);
|
console.error("Error fetching tasks:", error);
|
||||||
} finally {
|
})
|
||||||
|
.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);
|
setLoading(false);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Critical error in fetchDashboardData:", error);
|
||||||
|
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 ? (
|
||||||
|
<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} />
|
<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) => (
|
||||||
|
|||||||
@ -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 || []);
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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') && (
|
||||||
|
|||||||
@ -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") && (
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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>) => {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user