367 lines
13 KiB
TypeScript
367 lines
13 KiB
TypeScript
import { Layout } from "@/components/layout/Layout";
|
|
import type { ReactElement } from "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 { workflowService } from "@/services/workflow-service";
|
|
import { dashboardService, type TenantDashboardStats } from "@/services/dashboard-service";
|
|
import type { WorkflowTask } from "@/types/workflow";
|
|
import { useNavigate } from "react-router-dom";
|
|
|
|
interface StatCardProps {
|
|
icon: React.ComponentType<{ className?: string; strokeWidth?: number }>;
|
|
value: string | number;
|
|
label: string;
|
|
badge?: {
|
|
text: string;
|
|
variant: 'success' | 'warning' | 'info' | 'error';
|
|
};
|
|
}
|
|
|
|
const StatCard = ({
|
|
icon: Icon,
|
|
value,
|
|
label,
|
|
badge
|
|
}: StatCardProps): ReactElement => {
|
|
return (
|
|
<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">
|
|
<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>
|
|
|
|
<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-[11px] font-semibold text-[#6b7280] uppercase tracking-wider mt-1">
|
|
{label}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const TaskCard = ({ task }: { task: WorkflowTask }) => {
|
|
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';
|
|
return `In ${diffDays} Days`;
|
|
};
|
|
|
|
const handleView = () => {
|
|
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>
|
|
|
|
<div className="text-[13px] font-semibold text-[#111827] leading-tight">{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} • {task.assignment?.assigned_role || 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 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 navigate = useNavigate();
|
|
const [tasks, setTasks] = useState<WorkflowTask[]>([]);
|
|
const [stats, setStats] = useState<TenantDashboardStats | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [tasksLoading, setTasksLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
fetchDashboardData();
|
|
}, []);
|
|
|
|
const fetchDashboardData = async () => {
|
|
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);
|
|
if (response.success && Array.isArray(response.data)) {
|
|
setTasks(response.data);
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error("Error fetching tasks:", error);
|
|
})
|
|
.finally(() => {
|
|
setTasksLoading(false);
|
|
});
|
|
|
|
// Fetch statistics independently
|
|
dashboardService.getTenantStatistics()
|
|
.then(response => {
|
|
if (response.success) {
|
|
setStats(response.data);
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error("Error fetching dashboard statistics:", error);
|
|
})
|
|
.finally(() => {
|
|
setLoading(false);
|
|
});
|
|
} catch (error) {
|
|
console.error("Critical error in fetchDashboardData:", error);
|
|
setLoading(false);
|
|
setTasksLoading(false);
|
|
}
|
|
};
|
|
|
|
|
|
const statCards: StatCardProps[] = [
|
|
stats?.documentsCount !== undefined && {
|
|
icon: FileText,
|
|
value: stats.documentsCount,
|
|
label: "Total Documents",
|
|
badge: { text: "Controlled", variant: "info" }
|
|
},
|
|
stats?.pendingTasks !== undefined && {
|
|
icon: FileCheck,
|
|
value: stats.pendingTasks,
|
|
label: "My Tasks",
|
|
badge: { text: "Action Needed", variant: "warning" }
|
|
},
|
|
stats?.usersCount !== undefined && {
|
|
icon: Users,
|
|
value: stats.usersCount,
|
|
label: "Total Users",
|
|
badge: { text: "Team Members", variant: "success" }
|
|
},
|
|
stats?.unreadNotificationsCount !== undefined && {
|
|
icon: Bell,
|
|
value: stats.unreadNotificationsCount,
|
|
label: "Notifications",
|
|
badge: { text: "Unread", variant: "error" }
|
|
},
|
|
stats?.activeModulesCount !== undefined && {
|
|
icon: Briefcase,
|
|
value: stats.activeModulesCount,
|
|
label: "Running Modules",
|
|
badge: { text: "Operational", variant: "success" }
|
|
},
|
|
// Training Compliance is still static/placeholder for now
|
|
// {
|
|
// icon: GraduationCap,
|
|
// value: "94%",
|
|
// label: "Compliance",
|
|
// badge: { text: "Target Met", variant: "success" }
|
|
// },
|
|
].filter(Boolean) as StatCardProps[];
|
|
|
|
return (
|
|
<Layout
|
|
currentPage="Dashboard"
|
|
breadcrumbs={[{ label: "QAssure - Tenant" }, { label: "Dashboard" }]}
|
|
pageHeader={{
|
|
title: "Tenant Overview",
|
|
description: "Key quality metrics and performance indicators.",
|
|
}}
|
|
>
|
|
<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">
|
|
{loading ? (
|
|
<div className="col-span-full text-center py-8 text-gray-400 text-sm">Loading statistics...</div>
|
|
) : statCards.length > 0 ? (
|
|
statCards.map((card, index) => (
|
|
<StatCard key={index} {...card} />
|
|
))
|
|
) : (
|
|
<div className="col-span-full text-center py-8 text-gray-400 text-sm">No statistics available</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* CAPA Summary Card (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">
|
|
{/* 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')}
|
|
className="text-[11px] font-bold text-[#084cc8] hover:underline"
|
|
>
|
|
View all
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-3">
|
|
{tasksLoading ? (
|
|
<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} />
|
|
))
|
|
) : (
|
|
<div className="text-center py-4 text-gray-400 text-sm">No pending tasks</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Quick Actions Card */}
|
|
<QuickActions />
|
|
</div>
|
|
</div>
|
|
</Layout>
|
|
);
|
|
};
|
|
|
|
export default Dashboard;
|