refactor: update UI styling and layout for dashboard task components
This commit is contained in:
parent
d8d7e542d0
commit
f4838422c2
@ -1,8 +1,15 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Plus, UserPlus, Shield, Settings, Building2, BadgeCheck } from 'lucide-react';
|
||||
import { useAppSelector } from '@/hooks/redux-hooks';
|
||||
import type { QuickAction } from '@/types/dashboard';
|
||||
import { useAppTheme } from '@/hooks/useAppTheme';
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Plus,
|
||||
UserPlus,
|
||||
Shield,
|
||||
Settings,
|
||||
Building2,
|
||||
BadgeCheck,
|
||||
} from "lucide-react";
|
||||
import { useAppSelector } from "@/hooks/redux-hooks";
|
||||
import type { QuickAction } from "@/types/dashboard";
|
||||
import { useAppTheme } from "@/hooks/useAppTheme";
|
||||
|
||||
export const QuickActions = () => {
|
||||
const { primaryColor } = useAppTheme();
|
||||
@ -11,50 +18,102 @@ export const QuickActions = () => {
|
||||
|
||||
// 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);
|
||||
if (roles.includes("super_admin") || roles.includes("tenant_admin"))
|
||||
return true;
|
||||
return permissions.some(
|
||||
(p) => p.resource === resource && p.action === action,
|
||||
);
|
||||
};
|
||||
|
||||
const isSuperAdmin = roles.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: 'Module', onClick: () => navigate('/modules') },
|
||||
{ icon: Shield, label: 'Notification', onClick: () => navigate('/notifications') },
|
||||
{ icon: Settings, label: 'Audit Logs', onClick: () => navigate('/audit-logs') },
|
||||
{
|
||||
icon: Plus,
|
||||
label: "New Tenant",
|
||||
onClick: () => navigate("/tenants/create-wizard"),
|
||||
},
|
||||
{ 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[] = [
|
||||
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') },
|
||||
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">
|
||||
<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 className="flex flex-col items-start self-stretch rounded-[8px] border border-[#D1D5DB] bg-white overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex w-full h-[48px] px-[20px] py-[16px] items-center border-b border-[#D1D5DB]">
|
||||
<h2 className="text-[16px] font-semibold text-[#111827] leading-none">
|
||||
Quick Actions
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Actions Grid */}
|
||||
<div className="inline-grid grid-cols-2 gap-[10px] p-[16px] w-full">
|
||||
{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"
|
||||
className="
|
||||
flex flex-col justify-center items-center
|
||||
w-full min-h-[100px]
|
||||
p-[16px]
|
||||
gap-[8px]
|
||||
rounded-[6px]
|
||||
border border-dashed border-[#D1D5DB]
|
||||
bg-white
|
||||
transition-all duration-200
|
||||
hover:bg-[#F9FAFB]
|
||||
hover:border-[#9CA3AF]
|
||||
"
|
||||
>
|
||||
<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" color={primaryColor} strokeWidth={2} />
|
||||
{/* Icon */}
|
||||
<div className="flex items-center justify-center">
|
||||
<Icon
|
||||
className="w-[20px] h-[20px]"
|
||||
color={primaryColor}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[11px] font-bold text-[#111827] text-center leading-none">
|
||||
|
||||
{/* Label */}
|
||||
<span className="text-[14px] font-medium text-[#111827] text-center leading-[20px]">
|
||||
{action.label}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@ -1,49 +1,50 @@
|
||||
import { useState, useEffect } from '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';
|
||||
import { useAppTheme } from '@/hooks/useAppTheme';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/card';
|
||||
import { StatusBadge } from '@/components/shared';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useState, useEffect, useMemo } from "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";
|
||||
import { useAppTheme } from "@/hooks/useAppTheme";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { StatusBadge, DataTable, type Column } from "@/components/shared";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
// Helper functions
|
||||
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 'Just now';
|
||||
if (diffInSeconds < 60) return "Just now";
|
||||
const diffInMinutes = Math.floor(diffInSeconds / 60);
|
||||
if (diffInMinutes < 60) return `${diffInMinutes} min ago`;
|
||||
const diffInHours = Math.floor(diffInMinutes / 60);
|
||||
if (diffInHours < 24) return `${diffInHours} hours ago`;
|
||||
const diffInDays = Math.floor(diffInHours / 24);
|
||||
if (diffInDays === 1) return 'Yesterday';
|
||||
if (diffInDays === 1) return "Yesterday";
|
||||
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]';
|
||||
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 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';
|
||||
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';
|
||||
variant?: "list" | "table";
|
||||
}
|
||||
|
||||
export const RecentActivity = ({ variant }: RecentActivityProps) => {
|
||||
@ -53,10 +54,12 @@ export const RecentActivity = ({ variant }: RecentActivityProps) => {
|
||||
const { tenantId, roles } = useAppSelector((state) => state.auth);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const auditLogPath = roles?.includes('super_admin') ? '/audit-logs' : '/tenant/audit-logs';
|
||||
const auditLogPath = roles?.includes("super_admin")
|
||||
? "/audit-logs"
|
||||
: "/tenant/audit-logs";
|
||||
|
||||
// Default to table variant for a more professional look
|
||||
const activeVariant = variant || 'table';
|
||||
const activeVariant = variant || "table";
|
||||
|
||||
useEffect(() => {
|
||||
const fetchRecentActivity = async (): Promise<void> => {
|
||||
@ -67,7 +70,7 @@ export const RecentActivity = ({ variant }: RecentActivityProps) => {
|
||||
setAuditLogs(response.data);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Failed to fetch recent activity:', err);
|
||||
console.error("Failed to fetch recent activity:", err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@ -76,15 +79,73 @@ export const RecentActivity = ({ variant }: RecentActivityProps) => {
|
||||
fetchRecentActivity();
|
||||
}, [tenantId, activeVariant]);
|
||||
|
||||
if (activeVariant === 'list') {
|
||||
const columns = useMemo<Column<AuditLog>[]>(() => [
|
||||
{
|
||||
key: "created_at",
|
||||
label: "Timestamp",
|
||||
render: (log) => (
|
||||
<span className="text-[12px] font-medium text-gray-500">
|
||||
{formatRelativeTime(log.created_at)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "resource_type",
|
||||
label: "Resource Type",
|
||||
render: (log) => (
|
||||
<span
|
||||
className="text-[12px] font-bold truncate max-w-[150px] inline-block"
|
||||
style={{ color: primaryColor }}
|
||||
>
|
||||
{log.resource_type}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "request_method",
|
||||
label: "Method",
|
||||
render: (log) => (
|
||||
<StatusBadge variant={getMethodVariant(log.request_method)}>
|
||||
{log.request_method || "N/A"}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "response_status",
|
||||
label: "Status",
|
||||
render: (log) => (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[12px] font-bold",
|
||||
getStatusColor(log.response_status),
|
||||
)}
|
||||
>
|
||||
{log.response_status || "---"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "ip_address",
|
||||
label: "IP Address",
|
||||
render: (log) => (
|
||||
<span className="text-[12px] font-mono text-gray-500">
|
||||
{log.ip_address || "---"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
], [primaryColor]);
|
||||
|
||||
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>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-[11px] font-bold gap-1 h-7"
|
||||
<div className="flex flex-col items-start self-stretch rounded-[8px] border border-[#D1D5DB] bg-white overflow-hidden">
|
||||
<div className="flex w-full h-[48px] px-[20px] py-[16px] items-center border-b border-[#D1D5DB]">
|
||||
<h2 className="text-[15px] font-bold text-[#111827] tracking-tight">
|
||||
Recent Activity
|
||||
</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-[15px] font-bold gap-1 h-7"
|
||||
style={{ color: primaryColor }}
|
||||
onClick={() => navigate(auditLogPath)}
|
||||
>
|
||||
@ -92,25 +153,41 @@ export const RecentActivity = ({ variant }: RecentActivityProps) => {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex-1 w-full">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-6 h-6 animate-spin" style={{ color: primaryColor }} />
|
||||
<Loader2
|
||||
className="w-6 h-6 animate-spin"
|
||||
style={{ color: primaryColor }}
|
||||
/>
|
||||
</div>
|
||||
) : auditLogs.length === 0 ? (
|
||||
<div className="p-12 text-center text-[12px] text-gray-400 font-medium">No recent activity</div>
|
||||
<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
|
||||
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">{log.action}</span>
|
||||
<span
|
||||
<span className="text-[12px] font-bold">
|
||||
{log.action}
|
||||
</span>
|
||||
<span
|
||||
className="text-[12px] font-bold hover:underline cursor-pointer truncate"
|
||||
style={{ color: primaryColor }}
|
||||
>
|
||||
@ -129,74 +206,30 @@ export const RecentActivity = ({ variant }: RecentActivityProps) => {
|
||||
|
||||
// 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-row items-center justify-between gap-2">
|
||||
<h2 className="text-sm md:text-[15px] font-semibold text-[#0f1724]">Recent Activity</h2>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
<div className="flex flex-col items-start self-stretch rounded-[8px] border border-[#D1D5DB] bg-white overflow-hidden w-full">
|
||||
<div className="flex w-full h-[48px] px-[20px] py-[16px] items-center border-b border-[#D1D5DB] justify-between">
|
||||
<h2 className="text-[16px] font-semibold text-[#111827] leading-none">
|
||||
Recent Activity
|
||||
</h2>
|
||||
<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(auditLogPath)}
|
||||
>
|
||||
View All <ArrowRight className="w-3 h-3" />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-5 h-5 text-[#112868] animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<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/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 truncate max-w-[150px] inline-block"
|
||||
style={{ color: primaryColor }}
|
||||
>
|
||||
{log.resource_type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-4 whitespace-nowrap">
|
||||
<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>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<DataTable
|
||||
data={auditLogs}
|
||||
columns={columns}
|
||||
keyExtractor={(log) => log.id}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="No recent activity recorded"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,60 +1,60 @@
|
||||
import { Activity } from 'lucide-react';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/card';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { HealthMetric } from '@/types/dashboard';
|
||||
// import { Activity } from 'lucide-react';
|
||||
// import { Card, CardHeader, CardContent } from '@/components/ui/card';
|
||||
// import { cn } from '@/lib/utils';
|
||||
// import type { HealthMetric } from '@/types/dashboard';
|
||||
|
||||
const healthMetrics: HealthMetric[] = [
|
||||
{ label: 'API Latency', value: '45ms', percentage: 100, variant: 'success' },
|
||||
{ label: 'Database Load', value: '42%', percentage: 42, variant: 'info' },
|
||||
{ label: 'Storage Usage', value: '78%', percentage: 78, variant: 'warning' },
|
||||
];
|
||||
// const healthMetrics: HealthMetric[] = [
|
||||
// { label: 'API Latency', value: '45ms', percentage: 100, variant: 'success' },
|
||||
// { label: 'Database Load', value: '42%', percentage: 42, variant: 'info' },
|
||||
// { label: 'Storage Usage', value: '78%', percentage: 78, variant: 'warning' },
|
||||
// ];
|
||||
|
||||
const getVariantStyles = (variant: HealthMetric['variant']) => {
|
||||
switch (variant) {
|
||||
case 'success':
|
||||
return { text: 'text-[#059669]', bg: 'bg-[#059669]' };
|
||||
case 'info':
|
||||
return { text: 'text-[#23dce1]', bg: 'bg-[#23dce1]' };
|
||||
case 'warning':
|
||||
return { text: 'text-[#f59e0b]', bg: 'bg-[#f59e0b]' };
|
||||
}
|
||||
};
|
||||
// const getVariantStyles = (variant: HealthMetric['variant']) => {
|
||||
// switch (variant) {
|
||||
// case 'success':
|
||||
// return { text: 'text-[#059669]', bg: 'bg-[#059669]' };
|
||||
// case 'info':
|
||||
// return { text: 'text-[#23dce1]', bg: 'bg-[#23dce1]' };
|
||||
// case 'warning':
|
||||
// return { text: 'text-[#f59e0b]', bg: 'bg-[#f59e0b]' };
|
||||
// }
|
||||
// };
|
||||
|
||||
export const SystemHealth = () => {
|
||||
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 flex items-center justify-between">
|
||||
<h2 className="text-[15px] font-semibold text-[#0f1724] h-[19px]">System Health</h2>
|
||||
<Activity className="w-4 h-4" />
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
{healthMetrics.map((metric, index) => {
|
||||
const styles = getVariantStyles(metric.variant);
|
||||
return (
|
||||
<div key={index} className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-[#0f1724] h-4 leading-4">
|
||||
{metric.label}
|
||||
</span>
|
||||
<span className={cn('text-xs font-medium h-4 leading-4', styles.text)}>
|
||||
{metric.value}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-white border border-[rgba(0,0,0,0.2)] rounded-full h-1.5 overflow-hidden relative">
|
||||
<div
|
||||
className={cn(
|
||||
'absolute top-[-1px] bottom-[-1px] left-[-1px] rounded-full',
|
||||
styles.bg
|
||||
)}
|
||||
style={{ width: `${metric.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
// export const SystemHealth = () => {
|
||||
// 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 flex items-center justify-between">
|
||||
// <h2 className="text-[15px] font-semibold text-[#0f1724] h-[19px]">System Health</h2>
|
||||
// <Activity className="w-4 h-4" />
|
||||
// </CardHeader>
|
||||
// <CardContent className="p-4">
|
||||
// <div className="flex flex-col gap-4">
|
||||
// {healthMetrics.map((metric, index) => {
|
||||
// const styles = getVariantStyles(metric.variant);
|
||||
// return (
|
||||
// <div key={index} className="flex flex-col gap-2">
|
||||
// <div className="flex items-center justify-between">
|
||||
// <span className="text-xs font-medium text-[#0f1724] h-4 leading-4">
|
||||
// {metric.label}
|
||||
// </span>
|
||||
// <span className={cn('text-xs font-medium h-4 leading-4', styles.text)}>
|
||||
// {metric.value}
|
||||
// </span>
|
||||
// </div>
|
||||
// <div className="bg-white border border-[rgba(0,0,0,0.2)] rounded-full h-1.5 overflow-hidden relative">
|
||||
// <div
|
||||
// className={cn(
|
||||
// 'absolute top-[-1px] bottom-[-1px] left-[-1px] rounded-full',
|
||||
// styles.bg
|
||||
// )}
|
||||
// style={{ width: `${metric.percentage}%` }}
|
||||
// />
|
||||
// </div>
|
||||
// </div>
|
||||
// );
|
||||
// })}
|
||||
// </div>
|
||||
// </CardContent>
|
||||
// </Card>
|
||||
// );
|
||||
// };
|
||||
|
||||
@ -1,19 +1,16 @@
|
||||
import { Layout } from "@/components/layout/Layout";
|
||||
import type { ReactElement } from "react";
|
||||
import {
|
||||
FileCheck,
|
||||
Briefcase,
|
||||
FileText,
|
||||
Users,
|
||||
Bell,
|
||||
} from "lucide-react";
|
||||
import { FileCheck, Briefcase, FileText, 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 { useState, useEffect } from "react";
|
||||
import { useAppTheme } from "@/hooks/useAppTheme";
|
||||
import { workflowService } from "@/services/workflow-service";
|
||||
import { dashboardService, type TenantDashboardStats } from "@/services/dashboard-service";
|
||||
import {
|
||||
dashboardService,
|
||||
type TenantDashboardStats,
|
||||
} from "@/services/dashboard-service";
|
||||
import type { WorkflowTask } from "@/types/workflow";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { GradientStatCard } from "@/components/shared";
|
||||
@ -25,148 +22,86 @@ interface StatCardProps {
|
||||
label: string;
|
||||
badge?: {
|
||||
text: string;
|
||||
variant: 'success' | 'warning' | 'info' | 'error';
|
||||
variant: "success" | "warning" | "info" | "error";
|
||||
};
|
||||
}
|
||||
|
||||
const TaskCard = ({ task }: { task: WorkflowTask }) => {
|
||||
// const { primaryColor } = useAppTheme();
|
||||
const navigate = useNavigate();
|
||||
const formatDeadline = (dueDate: string) => {
|
||||
const now = new Date();
|
||||
const due = new Date(dueDate);
|
||||
const diffTime = due.getTime() - now.getTime();
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) return 'Due today';
|
||||
if (diffDays === 1) return 'Tomorrow';
|
||||
if (diffDays < 0) return 'Overdue';
|
||||
|
||||
if (diffDays === 0) return "Due today";
|
||||
if (diffDays === 1) return "Tomorrow";
|
||||
if (diffDays < 0) return "Overdue";
|
||||
return `In ${diffDays} Days`;
|
||||
};
|
||||
|
||||
const handleView = () => {
|
||||
if (task.entity.type.toLowerCase() === 'document') {
|
||||
if (task.entity.type.toLowerCase() === "document") {
|
||||
navigate(`/tenant/documents/${task.entity.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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">{task.entity.type}</span>
|
||||
<span className={cn(
|
||||
"text-[10px] font-bold",
|
||||
task.is_overdue ? "text-[#ef4444]" : "text-gray-400"
|
||||
)}>{formatDeadline(task.due_at)}</span>
|
||||
<div className="flex flex-col items-start self-stretch p-3 gap-2.5 rounded-[6px] border border-[#D1D5DB] bg-white hover:border-[#9CA3AF] transition-colors group">
|
||||
<div className="flex justify-between items-start self-stretch">
|
||||
<span className="text-[10px] font-bold text-[#94A3B8] uppercase tracking-[0.05em] leading-none">
|
||||
{task.entity.type}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] font-bold leading-none",
|
||||
task.is_overdue ? "text-[#F87171]" : "text-[#94A3B8]",
|
||||
)}
|
||||
>
|
||||
{formatDeadline(task.due_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-[13px] font-semibold text-[#111827] leading-tight">{task.entity.name}</div>
|
||||
<div className="text-[14px] font-semibold text-[#1E293B] leading-[20px] self-stretch line-clamp-2">
|
||||
{task.entity.name}
|
||||
</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",
|
||||
task.is_overdue ? "bg-[#ef4444]" : "bg-[#10b981]"
|
||||
)} />
|
||||
<span className="text-[11px] text-[#6b7280] font-medium leading-none">
|
||||
{task.step.name} • {
|
||||
(() => {
|
||||
const role = task.assignment?.assigned_role;
|
||||
if (role) return Array.isArray(role) ? role.join(", ") : role;
|
||||
return task.assignment?.assigned_to_name || 'Unassigned';
|
||||
})()
|
||||
}
|
||||
<div className="flex justify-between items-center self-stretch mt-0.5">
|
||||
<div className="flex items-center gap-1.5 overflow-hidden mr-2">
|
||||
<div
|
||||
className={cn(
|
||||
"w-2 h-2 rounded-full shrink-0",
|
||||
task.is_overdue ? "bg-[#F87171]" : "bg-[#34D399]",
|
||||
)}
|
||||
/>
|
||||
<span className="text-[12px] text-[#64748B] font-medium leading-[16px] truncate">
|
||||
{task.step.name} •{" "}
|
||||
{(() => {
|
||||
const role = task.assignment?.assigned_role;
|
||||
if (role) return Array.isArray(role) ? role.join(", ") : role;
|
||||
return task.assignment?.assigned_to_name || "Unassigned";
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleView}
|
||||
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 text-white rounded-md font-bold transition-colors shadow-sm"
|
||||
style={{ backgroundColor: primaryColor, boxShadow: `0 2px 4px ${primaryColor}33` }}
|
||||
>
|
||||
Complete
|
||||
</button> */}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleView}
|
||||
className="flex px-3 py-1.5 justify-center items-center rounded-[4px] border border-[#D1D5DB] bg-white text-[12px] font-bold text-[#1E293B] hover:bg-[#F9FAFB] hover:border-[#9CA3AF] transition-all shrink-0"
|
||||
>
|
||||
View
|
||||
</button>
|
||||
</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 { primaryColor } = useAppTheme();
|
||||
const navigate = useNavigate();
|
||||
const { primaryColor } = useAppTheme();
|
||||
const [tasks, setTasks] = useState<WorkflowTask[]>([]);
|
||||
const [tasksLoading, setTasksLoading] = useState(true);
|
||||
const [stats, setStats] = useState<TenantDashboardStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [tasksLoading, setTasksLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDashboardData();
|
||||
@ -176,15 +111,15 @@ const Dashboard = (): ReactElement => {
|
||||
setLoading(true);
|
||||
setTasksLoading(true);
|
||||
try {
|
||||
// Fetch tasks independently to avoid one failing the other
|
||||
workflowService.listTasks({ limit: 3 })
|
||||
.then(response => {
|
||||
console.log("[Dashboard] Tasks response:", response);
|
||||
// Fetch tasks independently
|
||||
workflowService
|
||||
.listTasks({ limit: 3 })
|
||||
.then((response) => {
|
||||
if (response.success && Array.isArray(response.data)) {
|
||||
setTasks(response.data);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
.catch((error) => {
|
||||
console.error("Error fetching tasks:", error);
|
||||
})
|
||||
.finally(() => {
|
||||
@ -192,13 +127,14 @@ const Dashboard = (): ReactElement => {
|
||||
});
|
||||
|
||||
// Fetch statistics independently
|
||||
dashboardService.getTenantStatistics()
|
||||
.then(response => {
|
||||
dashboardService
|
||||
.getTenantStatistics()
|
||||
.then((response) => {
|
||||
if (response.success) {
|
||||
setStats(response.data);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
.catch((error) => {
|
||||
console.error("Error fetching dashboard statistics:", error);
|
||||
})
|
||||
.finally(() => {
|
||||
@ -211,51 +147,42 @@ const Dashboard = (): ReactElement => {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const statCards: StatCardProps[] = [
|
||||
stats?.documentsCount !== undefined && {
|
||||
icon: FileText,
|
||||
value: stats.documentsCount,
|
||||
label: "Total Documents",
|
||||
badge: { text: "Controlled", variant: "info" }
|
||||
badge: { text: "Controlled", variant: "info" },
|
||||
},
|
||||
stats?.pendingTasks !== undefined && {
|
||||
icon: FileCheck,
|
||||
value: stats.pendingTasks,
|
||||
label: "My Tasks",
|
||||
badge: { text: "Action Needed", variant: "warning" }
|
||||
badge: { text: "Action Needed", variant: "warning" },
|
||||
},
|
||||
stats?.usersCount !== undefined && {
|
||||
icon: Users,
|
||||
value: stats.usersCount,
|
||||
label: "Total Users",
|
||||
badge: { text: "Team Members", variant: "success" }
|
||||
badge: { text: "Team Members", variant: "success" },
|
||||
},
|
||||
stats?.unreadNotificationsCount !== undefined && {
|
||||
icon: Bell,
|
||||
value: stats.unreadNotificationsCount,
|
||||
label: "Notifications",
|
||||
badge: { text: "Unread", variant: "error" }
|
||||
badge: { text: "Unread", variant: "error" },
|
||||
},
|
||||
stats?.activeModulesCount !== undefined && {
|
||||
icon: Briefcase,
|
||||
value: stats.activeModulesCount,
|
||||
label: "Running Modules",
|
||||
badge: { text: "Operational", variant: "success" }
|
||||
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
|
||||
currentPage="Dashboard"
|
||||
// breadcrumbs={[{ label: "QAssure - Tenant" }, { label: "Dashboard" }]}
|
||||
pageHeader={{
|
||||
title: "Tenant Overview",
|
||||
description: "Key quality metrics and performance indicators.",
|
||||
@ -263,51 +190,38 @@ const Dashboard = (): ReactElement => {
|
||||
>
|
||||
<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">
|
||||
<div className="lg:col-span-8 flex flex-col gap-6">
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-5 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
||||
{loading ? (
|
||||
<div className="col-span-full text-center py-8 text-gray-400 text-sm">Loading statistics...</div>
|
||||
<div className="col-span-full text-center py-8 text-gray-400 text-sm">
|
||||
Loading statistics...
|
||||
</div>
|
||||
) : statCards.length > 0 ? (
|
||||
statCards.map((card, index) => (
|
||||
<GradientStatCard key={index} {...card} />
|
||||
))
|
||||
) : (
|
||||
<div className="col-span-full text-center py-8 text-gray-400 text-sm">No statistics available</div>
|
||||
<div className="col-span-full text-center py-8 text-gray-400 text-sm">
|
||||
No statistics available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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
|
||||
</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">
|
||||
<div className="lg:col-span-4 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
|
||||
onClick={() => navigate('/tenant/workflows/tasks')}
|
||||
<div className="flex flex-col items-start self-stretch rounded-[8px] border border-[#D1D5DB] bg-white overflow-hidden">
|
||||
<div className="flex w-full h-[48px] px-[20px] py-[16px] items-center border-b border-[#D1D5DB] justify-between">
|
||||
<h2 className="text-[16px] font-semibold text-[#111827] leading-none">
|
||||
My Tasks
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => navigate("/tenant/workflows/tasks")}
|
||||
className="text-[11px] font-bold hover:underline"
|
||||
style={{ color: primaryColor }}
|
||||
>
|
||||
@ -315,20 +229,21 @@ const Dashboard = (): ReactElement => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-3 p-4 w-full">
|
||||
{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.map((task) => (
|
||||
<TaskCard key={task.id} task={task} />
|
||||
))
|
||||
tasks.map((task) => <TaskCard key={task.id} task={task} />)
|
||||
) : (
|
||||
<div className="text-center py-4 text-gray-400 text-sm">No pending tasks</div>
|
||||
<div className="text-center py-4 text-gray-400 text-sm">
|
||||
No pending tasks
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions Card */}
|
||||
<QuickActions />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user