Qassure-frontend/src/pages/tenant/Tasks.tsx

321 lines
10 KiB
TypeScript

import { useEffect, useMemo, useState, type ReactElement } from "react";
import { useNavigate } from "react-router-dom";
import { Layout } from "@/components/layout/Layout";
import {
DataTable,
Pagination,
FilterDropdown,
type Column,
} from "@/components/shared";
import { workflowService } from "@/services/workflow-service";
import { moduleService } from "@/services/module-service";
import type { WorkflowTask, WorkflowTaskCounts } from "@/types/workflow";
import { cn } from "@/lib/utils";
import { useAppTheme } from "@/hooks/useAppTheme";
import { Inbox, Clock, Calendar, CheckCircle2, RotateCcw } from "lucide-react";
const formatDate = (value?: string | null): string => {
if (!value) return "-";
return new Date(value).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "2-digit",
minute: "2-digit"
});
};
const StatCard = ({ icon: Icon, label, value, color, style }: { icon: any, label: string, value: number, color?: string, style?: React.CSSProperties }) => (
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-xl p-4 flex items-center gap-4 shadow-sm">
<div
className={cn("w-12 h-12 rounded-lg flex items-center justify-center shrink-0", color)}
style={style}
>
<Icon className="w-6 h-6 text-white" />
</div>
<div>
<div className="text-2xl font-bold text-gray-900">{value}</div>
<div className="text-sm font-medium text-gray-500">{label}</div>
</div>
</div>
);
const Tasks = (): ReactElement => {
const { primaryColor } = useAppTheme();
const navigate = useNavigate();
const [tasks, setTasks] = useState<WorkflowTask[]>([]);
const [counts, setCounts] = useState<WorkflowTaskCounts | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [limit, setLimit] = useState(10);
const [total, setTotal] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Filters
const [statusFilter, setStatusFilter] = useState<string | null>("pending");
const [moduleFilter, setModuleFilter] = useState<string | null>(null);
const [modules, setModules] = useState<{ id: string; name: string }[]>([]);
const offset = (currentPage - 1) * limit;
const totalPages = Math.max(1, Math.ceil(total / limit));
useEffect(() => {
const fetchModules = async () => {
try {
const res = await moduleService.getMyModules();
if (res.success) {
setModules(res.data.map(m => ({ id: m.id, name: m.name })));
}
} catch (err) {
console.error("Failed to load modules", err);
}
};
fetchModules();
}, []);
useEffect(() => {
const loadData = async () => {
try {
setIsLoading(true);
const [tasksRes, countsRes] = await Promise.all([
workflowService.listTasks({
limit,
offset,
status: statusFilter,
module_id: moduleFilter
}),
workflowService.getTaskCounts({ module_id: moduleFilter })
]);
if (tasksRes.success) {
setTasks(tasksRes.data);
setTotal(tasksRes.pagination.total);
}
if (countsRes.success) {
setCounts(countsRes.data);
}
} catch (err: any) {
setError(err?.response?.data?.error?.message || "Failed to load tasks");
} finally {
setIsLoading(false);
}
};
loadData();
}, [limit, offset, statusFilter, moduleFilter]);
const columns: Column<WorkflowTask>[] = useMemo(
() => [
{
key: "entity_name",
label: "Entity",
render: (task) => (
<div className="flex flex-col">
<span className="font-medium text-gray-900">{task.entity.name}</span>
<span className="text-[11px] text-gray-500 uppercase tracking-tight">{task.entity.type}</span>
</div>
),
},
{
key: "workflow_name",
label: "Workflow",
render: (task) => (
<span className="text-gray-700">{task.workflow.name}</span>
),
},
{
key: "step_name",
label: "Step",
render: (task) => (
<div className="flex items-center gap-2">
<span
className="inline-flex items-center rounded-md px-2 py-1 text-[11px] font-medium"
style={{ backgroundColor: `${primaryColor}1A`, color: primaryColor }}
>
{task.step.name}
</span>
</div>
),
},
{
key: "assigned_role",
label: "Assigned To",
render: (task) => (
<span className="text-gray-600">{task.assignment.assigned_role}</span>
),
},
{
key: "status",
label: "Status",
render: (task) => {
const isOverdueActive = task.is_overdue && !["completed", "rejected", "cancelled"].includes(task.status.toLowerCase());
return (
<span className={cn(
"inline-flex items-center rounded-md px-2 py-1 text-[11px] font-medium ring-1 ring-inset",
isOverdueActive
? "bg-red-50 text-red-700 ring-red-600/10"
: "bg-green-50 text-green-700 ring-green-600/10"
)}>
{isOverdueActive ? "Overdue" : task.status.replace(/_/g, " ").replace(/\b\w/g, l => l.toUpperCase())}
</span>
);
},
},
{
key: "due_at",
label: "Due Date",
render: (task) => {
const isOverdueActive = task.is_overdue && !["completed", "rejected", "cancelled"].includes(task.status.toLowerCase());
return (
<span className={cn("text-sm", isOverdueActive ? "text-red-600 font-medium" : "text-gray-600")}>
{formatDate(task.due_at)}
</span>
);
},
},
{
key: "actions",
label: "",
render: (task) => (
<button
onClick={() => {
if (task.entity.type.toLowerCase() === 'document') {
navigate(`/tenant/documents/${task.entity.id}`);
}
}}
className="font-bold text-sm transition-colors hover:opacity-80"
style={{ color: primaryColor }}
>
View
</button>
),
},
],
[navigate],
);
return (
<Layout
currentPage="Workflow Tasks"
pageHeader={{
title: "Workflows & Tasks",
description: "Manage your pending workflow tasks and approvals.",
}}
>
<div className="flex flex-col gap-6 pb-8">
{/* Count Stats Area */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
icon={Inbox}
label="Pending Tasks"
value={counts?.pending || 0}
style={{ backgroundColor: primaryColor }}
/>
<StatCard
icon={Clock}
label="Overdue"
value={counts?.overdue || 0}
color="bg-red-500"
/>
<StatCard
icon={Calendar}
label="Due Soon"
value={counts?.due_soon || 0}
color="bg-yellow-500"
/>
<StatCard
icon={CheckCircle2}
label="Completed (Week)"
value={counts?.completed_this_week || 0}
color="bg-green-500"
/>
</div>
{/* Task Table Area */}
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-xl shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100 flex flex-col md:flex-row md:items-center justify-between gap-4">
<h3 className="text-lg font-bold text-gray-900">
{statusFilter
? `${statusFilter.replace(/_/g, " ").replace(/\b\w/g, l => l.toUpperCase())} Tasks`
: "All Tasks"
}
</h3>
<div className="flex flex-wrap items-center gap-3">
<FilterDropdown
label="Module"
options={modules.map(m => ({ value: m.id, label: m.name }))}
value={moduleFilter}
onChange={(val) => {
setModuleFilter(val as string | null);
setCurrentPage(1);
}}
placeholder="All Modules"
/>
<FilterDropdown
label="Status"
options={[
{ value: "pending", label: "Pending" },
{ value: "in_progress", label: "In Progress" },
{ value: "completed", label: "Completed" },
{ value: "rejected", label: "Rejected" },
{ value: "cancelled", label: "Cancelled" },
]}
value={statusFilter}
onChange={(val) => {
setStatusFilter(val as string | null);
setCurrentPage(1);
}}
placeholder="All Status"
/>
{(statusFilter !== "pending" || moduleFilter) && (
<button
onClick={() => {
setStatusFilter("pending");
setModuleFilter(null);
setCurrentPage(1);
}}
className="flex items-center gap-1.5 text-xs text-red-600 font-medium hover:text-red-700 transition-colors"
>
<RotateCcw className="w-3.5 h-3.5" />
Reset
</button>
)}
</div>
</div>
<DataTable
data={tasks}
columns={columns}
keyExtractor={(task) => task.id}
emptyMessage="No tasks currently assigned to you"
isLoading={isLoading}
error={error}
/>
{total > 0 && (
<div className="px-6 py-4 border-t border-gray-100">
<Pagination
currentPage={currentPage}
totalPages={totalPages}
totalItems={total}
limit={limit}
onPageChange={setCurrentPage}
onLimitChange={(value) => {
setLimit(value);
setCurrentPage(1);
}}
/>
</div>
)}
</div>
</div>
</Layout>
);
};
export default Tasks;