feat: Implement role-based quick actions and enhance recent activity display with tenant filtering and UI refinements.
This commit is contained in:
parent
93ad8feea9
commit
4b76f71cf4
@ -19,7 +19,7 @@ import { useTenantTheme } from "@/hooks/useTenantTheme";
|
||||
import { AuthenticatedImage } from "@/components/shared";
|
||||
|
||||
interface MenuItem {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
icon: React.ComponentType<{ className?: string; strokeWidth?: number }>;
|
||||
label: string;
|
||||
path: string;
|
||||
requiredPermission?: {
|
||||
|
||||
@ -1,42 +1,69 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Plus, UserPlus, Shield, Settings } from 'lucide-react';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/card';
|
||||
import { Plus, UserPlus, Shield, Settings, Building2, BadgeCheck } from 'lucide-react';
|
||||
import { useAppSelector } from '@/hooks/redux-hooks';
|
||||
import type { QuickAction } from '@/types/dashboard';
|
||||
|
||||
export const QuickActions = () => {
|
||||
const navigate = useNavigate();
|
||||
const { roles } = useAppSelector((state) => state.auth);
|
||||
|
||||
const quickActions: QuickAction[] = [
|
||||
{ icon: Plus, label: 'New Tenant', onClick: () => navigate('/tenants') },
|
||||
{ icon: UserPlus, label: 'Invite User', onClick: () => navigate('/tenants') },
|
||||
{ icon: Shield, label: 'Add Role', onClick: () => navigate('/tenants') },
|
||||
{ icon: Settings, label: 'Config', onClick: () => console.log('Config') },
|
||||
// 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 = [];
|
||||
}
|
||||
}
|
||||
|
||||
const isSuperAdmin = rolesArray.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') },
|
||||
];
|
||||
|
||||
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') },
|
||||
];
|
||||
|
||||
const actions = isSuperAdmin ? superAdminActions : tenantAdminActions;
|
||||
|
||||
return (
|
||||
<Card className="w-[300px] shrink-0">
|
||||
<CardHeader className="border-b border-[rgba(0,0,0,0.08)] pb-4 pt-4 px-5 h-12">
|
||||
<h2 className="text-[15px] font-semibold text-[#0f1724] h-[19px]">Quick Actions</h2>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{quickActions.map((action, index) => {
|
||||
const Icon = action.icon;
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
onClick={action.onClick}
|
||||
className="bg-white border border-dashed border-[rgba(0,0,0,0.08)] rounded-md p-[17px] flex flex-col gap-2 items-center justify-center hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<Icon className="w-6 h-6" />
|
||||
<span className="text-xs font-medium text-[#0f1724] text-center h-4 leading-4">
|
||||
{action.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<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">
|
||||
<h2 className="text-[16px] font-bold text-[#111827] tracking-tight">Quick Actions</h2>
|
||||
<span className="w-1 h-1 bg-gray-200 rounded-full" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{actions.map((action, index) => {
|
||||
const Icon = action.icon;
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
onClick={action.onClick}
|
||||
className="bg-white border-2 border-dashed border-[#f1f5f9] hover:border-[#cbd5e1] hover:bg-gray-50 flex flex-col items-center justify-center p-4 min-h-[100px] rounded-xl transition-all gap-3 group"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-gray-50 border border-gray-100 flex items-center justify-center group-hover:bg-white transition-colors overflow-hidden">
|
||||
<Icon className="w-4 h-4 text-[#084cc8]" strokeWidth={2} />
|
||||
</div>
|
||||
<span className="text-[11px] font-bold text-[#111827] text-center leading-none">
|
||||
{action.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { ChevronDown, Filter, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/card';
|
||||
import { StatusBadge } from '@/components/shared';
|
||||
import { ChevronDown, Filter, Loader2, User } from 'lucide-react';
|
||||
import { auditLogService } from '@/services/audit-log-service';
|
||||
import type { AuditLog } from '@/types/audit-log';
|
||||
import { useAppSelector } from '@/hooks/redux-hooks';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/card';
|
||||
import { StatusBadge } from '@/components/shared';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
// Helper function to get action badge variant
|
||||
// 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';
|
||||
@ -16,65 +18,94 @@ const getActionVariant = (action: string): 'success' | 'failure' | 'info' | 'pro
|
||||
return 'info';
|
||||
};
|
||||
|
||||
// Helper function to format relative time
|
||||
const formatRelativeTime = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
|
||||
|
||||
if (diffInSeconds < 60) {
|
||||
return `${diffInSeconds} sec${diffInSeconds !== 1 ? 's' : ''} ago`;
|
||||
}
|
||||
|
||||
if (diffInSeconds < 60) return 'Just now';
|
||||
const diffInMinutes = Math.floor(diffInSeconds / 60);
|
||||
if (diffInMinutes < 60) {
|
||||
return `${diffInMinutes} min${diffInMinutes !== 1 ? 's' : ''} ago`;
|
||||
}
|
||||
|
||||
if (diffInMinutes < 60) return `${diffInMinutes} min ago`;
|
||||
const diffInHours = Math.floor(diffInMinutes / 60);
|
||||
if (diffInHours < 24) {
|
||||
return `${diffInHours} hour${diffInHours !== 1 ? 's' : ''} ago`;
|
||||
}
|
||||
|
||||
if (diffInHours < 24) return `${diffInHours} hours ago`;
|
||||
const diffInDays = Math.floor(diffInHours / 24);
|
||||
if (diffInDays < 7) {
|
||||
return `${diffInDays} day${diffInDays !== 1 ? 's' : ''} ago`;
|
||||
}
|
||||
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
|
||||
});
|
||||
if (diffInDays === 1) return 'Yesterday';
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
|
||||
export const RecentActivity = () => {
|
||||
export interface RecentActivityProps {
|
||||
variant?: 'list' | 'table';
|
||||
}
|
||||
|
||||
export const RecentActivity = ({ variant }: RecentActivityProps) => {
|
||||
const [auditLogs, setAuditLogs] = useState<AuditLog[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { tenantId } = useAppSelector((state) => state.auth);
|
||||
|
||||
// Default to table variant for a more professional look
|
||||
const activeVariant = variant || 'table';
|
||||
|
||||
useEffect(() => {
|
||||
const fetchRecentActivity = async (): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const response = await auditLogService.getAll(1, 5, null, ['created_at', 'desc']);
|
||||
// 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, null, ['created_at', 'desc'], filterTenantId);
|
||||
if (response.success) {
|
||||
setAuditLogs(response.data);
|
||||
} else {
|
||||
setError('Failed to load recent activity');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error?.message || 'Failed to load recent activity');
|
||||
// Fallback or log if needed
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchRecentActivity();
|
||||
}, []);
|
||||
}, [tenantId, activeVariant]);
|
||||
|
||||
if (activeVariant === 'list') {
|
||||
return (
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-6 h-6 text-[#084cc8] animate-spin" />
|
||||
</div>
|
||||
) : auditLogs.length === 0 ? (
|
||||
<div className="p-12 text-center text-[12px] text-gray-400 font-medium">No recent activity</div>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{auditLogs.map((log, index) => (
|
||||
<div key={log.id} className={cn("px-6 py-4 flex items-center gap-4 transition-colors hover:bg-gray-50", index !== auditLogs.length - 1 && "border-b border-[#f1f5f9 ]")}>
|
||||
<span className="text-[11px] font-medium text-gray-400 w-20 shrink-0">{formatRelativeTime(log.created_at)}</span>
|
||||
<div className="w-8 h-8 rounded-full bg-gray-100 border border-gray-200 flex items-center justify-center shrink-0 overflow-hidden">
|
||||
<User className="w-4 h-4 text-gray-400" />
|
||||
</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-[#084cc8] hover:underline cursor-pointer truncate">{log.resource_type}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// TABLE VARIANT (Original design for superadmin dashboard)
|
||||
return (
|
||||
<Card className="flex-1 border-[rgba(0,0,0,0.2)] 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">
|
||||
<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">
|
||||
@ -83,81 +114,57 @@ export const RecentActivity = () => {
|
||||
<span className="text-[11px] font-normal text-[#6b7280]">All</span>
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
</div>
|
||||
<div className="w-px h-4 bg-[rgba(0,0,0,0.08)] hidden sm:block" />
|
||||
<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">More filters</span>
|
||||
<span className="text-[11px] font-normal text-[#6b7280] hidden md:inline">Filters</span>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{isLoading && (
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-5 h-5 text-[#112868] animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-4 text-center">
|
||||
<p className="text-xs text-[#ef4444]">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && (
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
{auditLogs.length === 0 ? (
|
||||
<div className="p-4 text-center">
|
||||
<p className="text-xs text-[#6b7280]">No recent activity found</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full border-collapse min-w-[600px]">
|
||||
<thead>
|
||||
<tr className="bg-white border-b border-[rgba(0,0,0,0.08)]">
|
||||
<th className="px-3 md:px-5 py-2 md:py-3 text-left text-xs md:text-[13px] font-medium text-[#6b7280]">
|
||||
Action
|
||||
</th>
|
||||
<th className="px-3 md:px-5 py-2 md:py-3 text-left text-xs md:text-[13px] font-medium text-[#6b7280]">
|
||||
Resource Type
|
||||
</th>
|
||||
<th className="px-3 md:px-5 py-2 md:py-3 text-left text-xs md:text-[13px] font-medium text-[#6b7280]">
|
||||
Resource ID
|
||||
</th>
|
||||
<th className="px-3 md:px-5 py-2 md:py-3 text-left text-xs md:text-[13px] font-medium text-[#6b7280]">
|
||||
IP Address
|
||||
</th>
|
||||
<th className="px-3 md:px-5 py-2 md:py-3 text-left text-xs md:text-[13px] font-medium text-[#6b7280]">
|
||||
Timestamp
|
||||
</th>
|
||||
<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>
|
||||
</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>
|
||||
<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>
|
||||
</td>
|
||||
<td className="px-5 py-4 whitespace-nowrap">
|
||||
<StatusBadge variant={getActionVariant(log.action)}>{log.action}</StatusBadge>
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{auditLogs.map((log) => (
|
||||
<tr
|
||||
key={log.id}
|
||||
className="border-b border-[rgba(0,0,0,0.08)] last:border-b-0"
|
||||
>
|
||||
<td className="px-3 md:px-5 py-2.5 md:py-3.5">
|
||||
<StatusBadge variant={getActionVariant(log.action)}>
|
||||
{log.action}
|
||||
</StatusBadge>
|
||||
</td>
|
||||
<td className="px-3 md:px-5 py-2.5 md:py-3.5 text-xs md:text-[13px] font-medium text-[#0f1724]">
|
||||
{log.resource_type}
|
||||
</td>
|
||||
<td className="px-3 md:px-5 py-2.5 md:py-3.5 text-xs md:text-[13px] font-normal text-[#0f1724] whitespace-nowrap">
|
||||
{log.resource_id || 'N/A'}
|
||||
</td>
|
||||
<td className="px-3 md:px-5 py-2 md:py-4 text-xs md:text-[13px] font-normal text-[#6b7280]">
|
||||
{log.ip_address || 'N/A'}
|
||||
</td>
|
||||
<td className="px-3 md:px-5 py-2.5 md:py-3.5 text-xs md:text-[13px] font-normal text-[#6b7280] whitespace-nowrap">
|
||||
{formatRelativeTime(log.created_at)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@ -1,60 +1,71 @@
|
||||
import { Layout } from '@/components/layout/Layout';
|
||||
import type { ReactElement } from 'react';
|
||||
import { Info, FileCheck, Briefcase, FileText, GraduationCap } from 'lucide-react';
|
||||
import { Layout } from "@/components/layout/Layout";
|
||||
import type { ReactElement } from "react";
|
||||
import {
|
||||
Info,
|
||||
FileCheck,
|
||||
Briefcase,
|
||||
FileText,
|
||||
GraduationCap,
|
||||
} 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';
|
||||
|
||||
interface StatCardProps {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
icon: React.ComponentType<{ className?: string; strokeWidth?: number }>;
|
||||
value: string | number;
|
||||
label: string;
|
||||
status: 'success' | 'process' | 'warning' | 'disabled';
|
||||
statusLabel: string;
|
||||
badge?: {
|
||||
text: string;
|
||||
variant: 'success' | 'warning' | 'info' | 'error';
|
||||
};
|
||||
}
|
||||
|
||||
const StatCard = ({ icon: Icon, value, label, status, statusLabel }: StatCardProps): ReactElement => {
|
||||
const statusConfig = {
|
||||
success: {
|
||||
bg: 'bg-[#f1fffb]',
|
||||
dot: 'bg-[#16c784]',
|
||||
text: 'text-[#16c784]',
|
||||
},
|
||||
process: {
|
||||
bg: 'bg-[#fff5e5]',
|
||||
dot: 'bg-[#fca004]',
|
||||
text: 'text-[#fca004]',
|
||||
},
|
||||
warning: {
|
||||
bg: 'bg-[#fdf5f4]',
|
||||
dot: 'bg-[#e0352a]',
|
||||
text: 'text-[#e0352a]',
|
||||
},
|
||||
disabled: {
|
||||
bg: 'bg-[#e5e7eb]',
|
||||
dot: 'bg-[#9ca3af]',
|
||||
text: 'text-[#9ca3af]',
|
||||
},
|
||||
};
|
||||
|
||||
const config = statusConfig[status];
|
||||
const valueColor = status === 'warning' && label === 'Overdue Tasks' ? 'text-[#e0352a]' : 'text-[#0f1724]';
|
||||
|
||||
const StatCard = ({
|
||||
icon: Icon,
|
||||
value,
|
||||
label,
|
||||
badge
|
||||
}: StatCardProps): ReactElement => {
|
||||
return (
|
||||
<div className="relative p-[1px] rounded-lg" style={{ backgroundImage: 'linear-gradient(172.99deg, rgb(8, 76, 200) 1.15%, rgb(117, 192, 68) 44.3%, rgb(254, 211, 20) 89.74%)' }}>
|
||||
<div className="bg-white border border-[#d1d5db] rounded-lg p-[17px] flex flex-col gap-3">
|
||||
{/* Header with icon and status */}
|
||||
<div className="relative group h-full">
|
||||
<div className="bg-white border border-[#e5e7eb] rounded-xl p-4.5 flex flex-col gap-4 shadow-sm hover:shadow-md transition-all h-full relative overflow-hidden">
|
||||
{/* Interaction Gradient */}
|
||||
<div className="absolute top-0 left-0 w-full h-[2px] bg-gradient-to-r from-[#084cc8] via-[#75c044] to-[#fed314] opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
|
||||
<div className="flex items-start justify-between">
|
||||
<Icon className="w-5 h-5 text-[#0f1724] shrink-0" />
|
||||
<div className={`${config.bg} flex gap-1 items-center px-3 py-1 rounded-full`}>
|
||||
<div className={`${config.dot} rounded-sm w-1.5 h-1.5`} />
|
||||
<span className={`${config.text} text-xs font-medium capitalize`}>{statusLabel}</span>
|
||||
<div className="w-10 h-10 rounded-md bg-gray-50 flex items-center justify-center border border-gray-100">
|
||||
<Icon className="w-5 h-5 text-[#374151]" strokeWidth={1.5} />
|
||||
</div>
|
||||
{badge && (
|
||||
<div className={cn(
|
||||
"px-2.5 py-1 rounded-full text-[10px] font-bold tracking-tight whitespace-nowrap",
|
||||
badge.variant === 'success' ? "bg-[#f1fffb] text-[#16c784]" :
|
||||
badge.variant === 'warning' ? "bg-[#fff5e5] text-[#fca004]" :
|
||||
badge.variant === 'info' ? "bg-[#f0f9ff] text-[#0ea5e9]" :
|
||||
"bg-[#fdf5f4] text-[#e0352a]"
|
||||
)}>
|
||||
{badge.text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Value and Label */}
|
||||
<div className="flex flex-col gap-0">
|
||||
<div className={`text-2xl font-bold tracking-[-0.48px] ${valueColor}`}>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="text-[28px] font-bold tracking-tight text-[#111827] leading-none">
|
||||
{value}
|
||||
</div>
|
||||
<div className="text-xs font-medium text-[#6b7280]">
|
||||
<div className="text-[11px] font-semibold text-[#6b7280] uppercase tracking-wider mt-1">
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
@ -63,64 +74,220 @@ const StatCard = ({ icon: Icon, value, label, status, statusLabel }: StatCardPro
|
||||
);
|
||||
};
|
||||
|
||||
const TaskCard = ({ type, title, priority, deadlineLabel }: {
|
||||
type: string;
|
||||
title: string;
|
||||
priority: 'High' | 'Medium' | 'Low';
|
||||
deadlineLabel: string;
|
||||
}) => (
|
||||
<div className="border border-[#e5e7eb] rounded-xl p-4 flex flex-col gap-3 bg-white hover:border-[#cbd5e1] transition-colors">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-widest leading-none">{type}</span>
|
||||
<span className={cn(
|
||||
"text-[10px] font-bold",
|
||||
deadlineLabel === 'Due today' ? "text-[#ef4444]" : "text-gray-400"
|
||||
)}>{deadlineLabel}</span>
|
||||
</div>
|
||||
|
||||
<div className="text-[13px] font-semibold text-[#111827] leading-tight">{title}</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 overflow-hidden mt-1">
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<div className={cn(
|
||||
"w-2 h-2 rounded-full",
|
||||
priority === 'High' ? "bg-[#ef4444]" : priority === 'Medium' ? "bg-[#f59e0b]" : "bg-[#10b981]"
|
||||
)} />
|
||||
<span className="text-[11px] text-[#6b7280] font-medium leading-none">{priority} • Owner: You</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="text-[11px] px-2.5 py-1.5 border border-[#e5e7eb] rounded-md font-bold text-[#374151] hover:bg-gray-50 transition-colors shrink-0">View</button>
|
||||
<button className="text-[11px] px-2.5 py-1.5 bg-[#084cc8] text-white rounded-md font-bold hover:bg-[#063ba1] transition-colors shadow-sm shadow-[#084cc8]/20 shrink-0">
|
||||
Complete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const CAPASummaryChart = () => {
|
||||
const data = [
|
||||
{ name: 'Jan', open: 35, inProgress: 48, closed: 25, trend: 15 },
|
||||
{ name: 'Feb', open: 28, inProgress: 35, closed: 20, trend: 12 },
|
||||
{ name: 'Mar', open: 45, inProgress: 75, closed: 38, trend: 32 },
|
||||
{ name: 'Apr', open: 40, inProgress: 65, closed: 42, trend: 28 },
|
||||
{ name: 'May', open: 55, inProgress: 95, closed: 78, trend: 52 },
|
||||
{ name: 'Jun', open: 42, inProgress: 82, closed: 72, trend: 45 },
|
||||
{ name: 'Jul', open: 38, inProgress: 70, closed: 65, trend: 38 },
|
||||
{ name: 'Aug', open: 48, inProgress: 94, closed: 82, trend: 48 },
|
||||
{ name: 'Sep', open: 32, inProgress: 65, closed: 58, trend: 35 },
|
||||
{ name: 'Oct', open: 44, inProgress: 88, closed: 85, trend: 58 },
|
||||
{ name: 'Nov', open: 52, inProgress: 92, closed: 98, trend: 62 },
|
||||
{ name: 'Dec', open: 60, inProgress: 105, closed: 115, trend: 58 },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="h-[320px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f1f5f9" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fontSize: 11, fill: '#64748b', fontWeight: 500 }}
|
||||
dy={10}
|
||||
/>
|
||||
<YAxis
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fontSize: 11, fill: '#64748b', fontWeight: 500 }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{ borderRadius: '12px', border: 'none', boxShadow: '0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1)', fontSize: '12px' }}
|
||||
/>
|
||||
<Legend
|
||||
verticalAlign="bottom"
|
||||
align="left"
|
||||
wrapperStyle={{ paddingTop: '24px', fontSize: '11px', fontWeight: 600, color: '#64748b' }}
|
||||
iconType="circle"
|
||||
iconSize={8}
|
||||
/>
|
||||
<Bar dataKey="open" name="Open CAPA" fill="#94a3b8" radius={[2, 2, 0, 0]} barSize={8} />
|
||||
<Bar dataKey="inProgress" name="In Progress" fill="#6366f1" radius={[2, 2, 0, 0]} barSize={8} />
|
||||
<Bar dataKey="closed" name="Closed" fill="#1e1b4b" radius={[2, 2, 0, 0]} barSize={8} />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="trend"
|
||||
name="Total Trend"
|
||||
stroke="#06b6d4"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Dashboard = (): ReactElement => {
|
||||
const statCards: StatCardProps[] = [
|
||||
// {
|
||||
// icon: Info,
|
||||
// value: '18',
|
||||
// label: 'Open CAPAs',
|
||||
// status: 'success',
|
||||
// statusLabel: 'Success',
|
||||
// },
|
||||
{
|
||||
icon: Info,
|
||||
value: 18,
|
||||
label: "Open CAPAs",
|
||||
badge: { text: "2 New This Week", variant: "success" }
|
||||
},
|
||||
{
|
||||
icon: FileCheck,
|
||||
value: '7',
|
||||
label: 'Pending Approvals',
|
||||
status: 'process',
|
||||
statusLabel: 'Process',
|
||||
value: 7,
|
||||
label: "Pending Approvals",
|
||||
badge: { text: "Awaiting Manager Review", variant: "warning" }
|
||||
},
|
||||
{
|
||||
icon: Briefcase,
|
||||
value: '9',
|
||||
label: 'Active Projects',
|
||||
status: 'warning',
|
||||
statusLabel: 'Warning',
|
||||
value: 9,
|
||||
label: "Active Projects",
|
||||
badge: { text: "3 At Risk Of Delay", variant: "error" }
|
||||
},
|
||||
{
|
||||
icon: Info,
|
||||
value: '3',
|
||||
label: 'Overdue Tasks',
|
||||
status: 'warning',
|
||||
statusLabel: 'Warning',
|
||||
value: 3,
|
||||
label: "Overdue Tasks",
|
||||
badge: { text: "Action Needed", variant: "error" }
|
||||
},
|
||||
{
|
||||
icon: FileText,
|
||||
value: '14',
|
||||
label: 'Docs Pending Review',
|
||||
status: 'disabled',
|
||||
statusLabel: 'Disabled',
|
||||
value: 14,
|
||||
label: "Docs Pending Review",
|
||||
badge: { text: "Due Within 7 Days", variant: "info" }
|
||||
},
|
||||
{
|
||||
icon: GraduationCap,
|
||||
value: '94%',
|
||||
label: 'Training Compliance',
|
||||
status: 'success',
|
||||
statusLabel: 'Success',
|
||||
value: "94%",
|
||||
label: "Training Compliance",
|
||||
badge: { text: "Target Met", variant: "success" }
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Layout
|
||||
currentPage="Dashboard Overview"
|
||||
currentPage="Dashboard"
|
||||
breadcrumbs={[{ label: "QAssure - Tenant" }, { label: "Dashboard" }]}
|
||||
pageHeader={{
|
||||
title: 'Tenant Overview',
|
||||
description: 'Key quality metrics and performance indicators.',
|
||||
title: "Tenant Overview",
|
||||
description: "Key quality metrics and performance indicators.",
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{statCards.map((card, index) => (
|
||||
<StatCard key={index} {...card} />
|
||||
))}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-4 lg:gap-6 pb-8">
|
||||
{/* Main Content Area (Left) */}
|
||||
<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} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* CAPA Summary Card */}
|
||||
<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
|
||||
</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[11px] font-bold text-gray-400 tracking-wide">Data Range</span>
|
||||
<select className="text-[11px] font-bold border border-[#e2e8f0] rounded-md px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-[#084cc8]/10 bg-white">
|
||||
<option>Last Year</option>
|
||||
<option>Last 6 Months</option>
|
||||
<option>Last Month</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CAPASummaryChart />
|
||||
</div>
|
||||
|
||||
{/* Recent Activity Card */}
|
||||
<RecentActivity />
|
||||
</div>
|
||||
|
||||
{/* Sidebar area (Right) */}
|
||||
<div className="lg:col-span-4 xl:col-span-3 flex flex-col gap-6">
|
||||
{/* My Tasks Card */}
|
||||
<div className="bg-white border border-[#e5e7eb] rounded-xl p-6 shadow-sm">
|
||||
<div className="flex justify-between items-center mb-5">
|
||||
<h2 className="text-[16px] font-bold text-[#111827] tracking-tight">My Tasks</h2>
|
||||
<button className="text-[11px] font-bold text-[#084cc8] hover:underline">
|
||||
View all
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<TaskCard
|
||||
type="CAPA"
|
||||
title="Approve CAPA #CP-2024-021"
|
||||
priority="High"
|
||||
deadlineLabel="Due today"
|
||||
/>
|
||||
<TaskCard
|
||||
type="DOCUMENT"
|
||||
title="Review SOP-QA-110 v3"
|
||||
priority="Medium"
|
||||
deadlineLabel="Tomorrow"
|
||||
/>
|
||||
<TaskCard
|
||||
type="TRAINING"
|
||||
title="Complete Data Integrity module"
|
||||
priority="Low"
|
||||
deadlineLabel="In 5 Days"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions Card */}
|
||||
<QuickActions />
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
export interface StatCardData {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
icon: React.ComponentType<{ className?: string; strokeWidth?: number }>;
|
||||
value: string | number;
|
||||
label: string;
|
||||
badge?: {
|
||||
@ -17,7 +17,7 @@ export interface ActivityLog {
|
||||
}
|
||||
|
||||
export interface QuickAction {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
icon: React.ComponentType<{ className?: string; strokeWidth?: number }>;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user