From 7a368b09bc66bbcd273228dba46e7a63b13a81db Mon Sep 17 00:00:00 2001 From: Yashwin Date: Mon, 30 Mar 2026 18:35:00 +0530 Subject: [PATCH] feat: implement workflow tasks management page and define associated data types --- package.json | 2 +- src/components/layout/Sidebar.tsx | 7 + src/lib/utils.ts | 12 + src/pages/superadmin/CreateTenantWizard.tsx | 5 +- src/pages/superadmin/EditTenant.tsx | 7 +- src/pages/tenant/Dashboard.tsx | 139 ++++++++---- src/pages/tenant/Settings.tsx | 5 +- src/pages/tenant/Tasks.tsx | 230 ++++++++++++++++++++ src/pages/tenant/ViewDocument.tsx | 123 ++++++++++- src/routes/tenant-admin-routes.tsx | 5 + src/services/workflow-service.ts | 19 +- src/types/workflow.ts | 53 +++++ 12 files changed, 547 insertions(+), 60 deletions(-) create mode 100644 src/pages/tenant/Tasks.tsx diff --git a/package.json b/package.json index d694629..0af1894 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "version": "0.0.0", "type": "module", "scripts": { - "dev": "vite", + "dev": "vite --host", "build": "tsc -b && vite build", "lint": "eslint .", "preview": "vite preview" diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 4f5eea4..b30f061 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -11,6 +11,7 @@ import { Shield, BadgeCheck, GitBranch, + Briefcase, } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -56,6 +57,12 @@ const tenantAdminPlatformMenu: MenuItem[] = [ path: "/tenant/roles", requiredPermission: { resource: "roles" }, }, + { + icon: Briefcase, + label: "My Tasks", + path: "/tenant/tasks", + requiredPermission: { resource: "workflow" }, + }, { icon: Users, label: "Users", diff --git a/src/lib/utils.ts b/src/lib/utils.ts index bd0c391..c7a38ad 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -4,3 +4,15 @@ import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } + +export function generateUUID() { + if (typeof crypto !== 'undefined' && crypto.randomUUID) { + return crypto.randomUUID(); + } + // Fallback for non-secure contexts + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + var r = (Math.random() * 16) | 0, + v = c === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +} diff --git a/src/pages/superadmin/CreateTenantWizard.tsx b/src/pages/superadmin/CreateTenantWizard.tsx index 4820b41..113ec43 100644 --- a/src/pages/superadmin/CreateTenantWizard.tsx +++ b/src/pages/superadmin/CreateTenantWizard.tsx @@ -18,6 +18,7 @@ import { moduleService } from "@/services/module-service"; import { fileService } from "@/services/file-service"; import { showToast } from "@/utils/toast"; import { ChevronRight, ChevronLeft, Image as ImageIcon, X } from "lucide-react"; +import { generateUUID } from "@/lib/utils"; // Step 1: Tenant Details Schema - matches NewTenantModal const tenantDetailsSchema = z.object({ @@ -1116,7 +1117,7 @@ const CreateTenantWizard = (): ReactElement => { const response = await fileService.upload( file, "tenant", - crypto.randomUUID(), + generateUUID(), ); const fileId = response.data.id; @@ -1244,7 +1245,7 @@ const CreateTenantWizard = (): ReactElement => { const response = await fileService.upload( file, "tenant", - crypto.randomUUID(), + generateUUID(), ); const fileId = response.data.id; diff --git a/src/pages/superadmin/EditTenant.tsx b/src/pages/superadmin/EditTenant.tsx index e36a8f6..abdbfab 100644 --- a/src/pages/superadmin/EditTenant.tsx +++ b/src/pages/superadmin/EditTenant.tsx @@ -24,6 +24,7 @@ import { Loader2, X, } from "lucide-react"; +import { generateUUID } from "@/lib/utils"; // Step 1: Tenant Details Schema const tenantDetailsSchema = z.object({ @@ -118,7 +119,7 @@ const subscriptionTierOptions = [ // Helper function to get base URL with protocol const getBaseUrlWithProtocol = (): string => { - return import.meta.env.VITE_API_BASE_URL || "http://localhost:3000"; + return import.meta.env.VITE_FRONTEND_BASE_URL || "http://localhost:3000"; }; const EditTenant = (): ReactElement => { @@ -1234,7 +1235,7 @@ const EditTenant = (): ReactElement => { const response = await fileService.upload( file, slug || "tenant", - crypto.randomUUID(), + generateUUID(), id, ); const fileId = response.data.id; @@ -1359,7 +1360,7 @@ const EditTenant = (): ReactElement => { const response = await fileService.upload( file, slug || "tenant", - crypto.randomUUID(), + generateUUID(), id, ); const fileId = response.data.id; diff --git a/src/pages/tenant/Dashboard.tsx b/src/pages/tenant/Dashboard.tsx index 56247ef..93f6841 100644 --- a/src/pages/tenant/Dashboard.tsx +++ b/src/pages/tenant/Dashboard.tsx @@ -21,6 +21,10 @@ import { Bar, Line } from 'recharts'; +import { useState, useEffect } from "react"; +import { workflowService } from "@/services/workflow-service"; +import type { WorkflowTask } from "@/types/workflow"; +import { useNavigate } from "react-router-dom"; interface StatCardProps { icon: React.ComponentType<{ className?: string; strokeWidth?: number }>; @@ -74,41 +78,64 @@ const StatCard = ({ ); }; -const TaskCard = ({ type, title, priority, deadlineLabel }: { - type: string; - title: string; - priority: 'High' | 'Medium' | 'Low'; - deadlineLabel: string; -}) => ( -
-
- {type} - {deadlineLabel} -
+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`; + }; -
{title}
+ const handleView = () => { + if (task.entity.type.toLowerCase() === 'document') { + navigate(`/tenant/documents/${task.entity.id}`); + } + }; -
-
-
- {priority} • Owner: You + return ( +
+
+ {task.entity.type} + {formatDeadline(task.due_at)}
-
- - +
{task.entity.name}
+ +
+
+
+ + {task.step.name} • {task.assignment.assigned_role} + +
+ +
+ + +
-
-); + ); +}; const CAPASummaryChart = () => { const data = [ @@ -171,6 +198,28 @@ const CAPASummaryChart = () => { }; const Dashboard = (): ReactElement => { + const navigate = useNavigate(); + const [tasks, setTasks] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetchTasks(); + }, []); + + const fetchTasks = async () => { + try { + setLoading(true); + const response = await workflowService.listTasks({ limit: 3 }); + if (response.success) { + setTasks(response.data); + } + } catch (error) { + console.error("Error fetching tasks:", error); + } finally { + setLoading(false); + } + }; + const statCards: StatCardProps[] = [ { icon: Info, @@ -258,30 +307,24 @@ const Dashboard = (): ReactElement => {

My Tasks

-
- - - + {loading ? ( +
Loading tasks...
+ ) : tasks.length > 0 ? ( + tasks.map((task) => ( + + )) + ) : ( +
No pending tasks
+ )}
diff --git a/src/pages/tenant/Settings.tsx b/src/pages/tenant/Settings.tsx index 0f2406d..e6460ff 100644 --- a/src/pages/tenant/Settings.tsx +++ b/src/pages/tenant/Settings.tsx @@ -7,6 +7,7 @@ import { fileService } from "@/services/file-service"; import { showToast } from "@/utils/toast"; import { updateTheme } from "@/store/themeSlice"; import { PrimaryButton, AuthenticatedImage } from "@/components/shared"; +import { generateUUID } from "@/lib/utils"; import type { Tenant } from "@/types/tenant"; // Helper function to get base URL with protocol @@ -163,7 +164,7 @@ const Settings = (): ReactElement => { const response = await fileService.upload( file, "tenant", - crypto.randomUUID(), + generateUUID(), tenantId || undefined, ); const fileId = response.data.id; @@ -231,7 +232,7 @@ const Settings = (): ReactElement => { const response = await fileService.upload( file, "tenant", - crypto.randomUUID(), + generateUUID(), tenantId || undefined, ); const fileId = response.data.id; diff --git a/src/pages/tenant/Tasks.tsx b/src/pages/tenant/Tasks.tsx new file mode 100644 index 0000000..bc18a58 --- /dev/null +++ b/src/pages/tenant/Tasks.tsx @@ -0,0 +1,230 @@ +import { useEffect, useMemo, useState, type ReactElement } from "react"; +import { useNavigate } from "react-router-dom"; +import { Layout } from "@/components/layout/Layout"; +import { + DataTable, + Pagination, + type Column, +} from "@/components/shared"; +import { workflowService } from "@/services/workflow-service"; +import type { WorkflowTask, WorkflowTaskCounts } from "@/types/workflow"; +import { cn } from "@/lib/utils"; +import { Inbox, Clock, Calendar, CheckCircle2 } 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 }: { icon: any, label: string, value: number, color: string }) => ( +
+
+ +
+
+
{value}
+
{label}
+
+
+); + +const Tasks = (): ReactElement => { + const navigate = useNavigate(); + const [tasks, setTasks] = useState([]); + const [counts, setCounts] = useState(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(null); + + const offset = (currentPage - 1) * limit; + const totalPages = Math.max(1, Math.ceil(total / limit)); + + useEffect(() => { + const loadData = async () => { + try { + setIsLoading(true); + const [tasksRes, countsRes] = await Promise.all([ + workflowService.listTasks({ limit, offset }), + workflowService.getTaskCounts() + ]); + + 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]); + + const columns: Column[] = useMemo( + () => [ + { + key: "entity_name", + label: "Entity", + render: (task) => ( +
+ {task.entity.name} + {task.entity.type} +
+ ), + }, + { + key: "workflow_name", + label: "Workflow", + render: (task) => ( + {task.workflow.name} + ), + }, + { + key: "step_name", + label: "Step", + render: (task) => ( +
+ + {task.step.name} + +
+ ), + }, + { + key: "assigned_role", + label: "Assigned To", + render: (task) => ( + {task.assignment.assigned_role} + ), + }, + { + key: "status", + label: "Status", + render: (task) => ( + + {task.is_overdue ? "Overdue" : task.status} + + ), + }, + { + key: "due_at", + label: "Due Date", + render: (task) => ( + + {formatDate(task.due_at)} + + ), + }, + { + key: "actions", + label: "", + render: (task) => ( + + ), + }, + ], + [navigate], + ); + + return ( + +
+ {/* Count Stats Area */} +
+ + + + +
+ + {/* Task Table Area */} +
+
+

Pending Tasks

+
+ + task.id} + emptyMessage="No tasks currently assigned to you" + isLoading={isLoading} + error={error} + /> + + {total > 0 && ( +
+ { + setLimit(value); + setCurrentPage(1); + }} + /> +
+ )} +
+
+
+ ); +}; + +export default Tasks; diff --git a/src/pages/tenant/ViewDocument.tsx b/src/pages/tenant/ViewDocument.tsx index b00bc0b..04f91ed 100644 --- a/src/pages/tenant/ViewDocument.tsx +++ b/src/pages/tenant/ViewDocument.tsx @@ -84,6 +84,9 @@ const ViewDocument = (): ReactElement => { const [versionFiles, setVersionFiles] = useState([]); const [versionSelectedFileId, setVersionSelectedFileId] = useState(""); const [versionFileName, setVersionFileName] = useState(""); + const [transitionComment, setTransitionComment] = useState(""); + const [selectedWorkflowAction, setSelectedWorkflowAction] = useState(null); + const [isTransitioning, setIsTransitioning] = useState(false); const [versionFilePath, setVersionFilePath] = useState(""); const [versionFileSize, setVersionFileSize] = useState(undefined); const [versionMimeType, setVersionMimeType] = useState(""); @@ -360,6 +363,48 @@ const ViewDocument = (): ReactElement => { } }; + const handleWorkflowTransition = async (): Promise => { + if (!workflowInstance || !selectedWorkflowAction) return; + + const pendingTask = workflowInstance.tasks.find((t) => t.status === "pending"); + if (!pendingTask) { + showToast.error("No pending task found for this workflow instance"); + return; + } + + if (selectedWorkflowAction.requires_comment && !transitionComment.trim()) { + showToast.error("Comments are required for this action"); + return; + } + + try { + setIsTransitioning(true); + await workflowService.transition(workflowInstance.id, { + task_id: pendingTask.id, + action: selectedWorkflowAction.action, + comments: transitionComment.trim() || undefined, + }); + showToast.success(`Action "${selectedWorkflowAction.name}" completed`); + + // Refresh workflow instance data + const res = await workflowService.getInstance(workflowInstance.id); + setWorkflowInstance(res.data); + + // Reset transition state + setSelectedWorkflowAction(null); + setTransitionComment(""); + + // Also refresh document data as it might have changed status + await refreshData(); + } catch (err: any) { + showToast.error( + err?.response?.data?.error?.message || "Failed to complete workflow action", + ); + } finally { + setIsTransitioning(false); + } + }; + const versionColumns: Column[] = [ { key: "version_number", label: "Version" }, { key: "status", label: "Status" }, @@ -849,7 +894,11 @@ const ViewDocument = (): ReactElement => { {/* Workflow Instance Tracker Modal */} setShowWorkflowTracker(false)} + onClose={() => { + setShowWorkflowTracker(false); + setSelectedWorkflowAction(null); + setTransitionComment(""); + }} title="Workflow Status Tracker" description="Track the progress of this document's approval workflow." maxWidth="2xl" @@ -879,13 +928,77 @@ const ViewDocument = (): ReactElement => {
-

{workflowInstance.started_by.name}

+

{workflowInstance.started_by.name} ({workflowInstance.started_by.email})

{formatDateTime(workflowInstance.started_at)}

+ + {/* Available Actions Buttons */} + {workflowInstance.available_actions && workflowInstance.available_actions.length > 0 && ( +
+ +
+ {workflowInstance.available_actions.map((action, idx) => ( + + ))} +
+
+ )} + + {/* Transition Modal content (inline when action selected) */} + {selectedWorkflowAction && ( +
+
+

+ Executing Action: {selectedWorkflowAction.name} +

+ +
+ +
+
+ +