251 lines
8.4 KiB
TypeScript
251 lines
8.4 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 { useAppTheme } from "@/hooks/useAppTheme";
|
|
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";
|
|
import { GradientStatCard } from "@/components/shared";
|
|
import type { LucideIcon } from "lucide-react";
|
|
|
|
interface StatCardProps {
|
|
icon: LucideIcon;
|
|
value: string | number;
|
|
label: string;
|
|
badge?: {
|
|
text: string;
|
|
variant: "success" | "warning" | "info" | "error";
|
|
};
|
|
}
|
|
|
|
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="flex flex-col items-start self-stretch p-3 gap-2.5 rounded-[6px] border border-[#D1D5DB] bg-[#F9F9F9] 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-[14px] font-semibold text-[#1E293B] leading-[20px] self-stretch line-clamp-2">
|
|
{task.entity.name}
|
|
</div>
|
|
|
|
<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} •{" "}
|
|
{task.assignment?.assigned_to_name || (task.assignment?.assigned_role_ids?.length ? `${task.assignment.assigned_role_ids.length} roles` : "Unassigned")}
|
|
</span>
|
|
</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 cursor-pointer"
|
|
>
|
|
View
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const Dashboard = (): ReactElement => {
|
|
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);
|
|
|
|
useEffect(() => {
|
|
fetchDashboardData();
|
|
}, []);
|
|
|
|
const fetchDashboardData = async () => {
|
|
setLoading(true);
|
|
setTasksLoading(true);
|
|
try {
|
|
// Fetch tasks independently
|
|
workflowService
|
|
.listTasks({ limit: 3 })
|
|
.then((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" },
|
|
},
|
|
].filter(Boolean) as StatCardProps[];
|
|
|
|
return (
|
|
<Layout
|
|
currentPage="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 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) => (
|
|
<GradientStatCard key={index} {...card} />
|
|
))
|
|
) : (
|
|
<div className="col-span-full text-center py-8 text-gray-400 text-sm">
|
|
No statistics available
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Recent Activity Card */}
|
|
<RecentActivity />
|
|
</div>
|
|
|
|
{/* Sidebar area (Right) */}
|
|
<div className="lg:col-span-4 flex flex-col gap-6">
|
|
{/* My Tasks Card */}
|
|
<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 cursor-pointer"
|
|
style={{ color: primaryColor }}
|
|
>
|
|
View all
|
|
</button>
|
|
</div>
|
|
|
|
<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>
|
|
) : 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>
|
|
|
|
<QuickActions />
|
|
</div>
|
|
</div>
|
|
</Layout>
|
|
);
|
|
};
|
|
|
|
export default Dashboard;
|