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 }) => (
+
+);
+
+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) => (
+ {
+ if (task.entity.type.toLowerCase() === 'document') {
+ navigate(`/tenant/documents/${task.entity.id}`);
+ }
+ }}
+ className="text-[#084cc8] hover:text-[#063ba1] font-bold text-sm"
+ >
+ View
+
+ ),
+ },
+ ],
+ [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) => (
+ setSelectedWorkflowAction(action)}
+ className="px-4 py-2 bg-white border border-blue-200 rounded-md text-xs font-bold text-blue-700 hover:bg-blue-600 hover:text-white transition-all shadow-sm"
+ >
+ {action.name}
+
+ ))}
+
+
+ )}
+
+ {/* Transition Modal content (inline when action selected) */}
+ {selectedWorkflowAction && (
+
+
+
+ Executing Action: {selectedWorkflowAction.name}
+
+ {
+ setSelectedWorkflowAction(null);
+ setTransitionComment("");
+ }}
+ className="text-amber-500 hover:text-amber-700"
+ >
+ Cancel
+
+
+
+
+
+
+
+
+
+
void handleWorkflowTransition()}
+ disabled={isTransitioning}
+ className="bg-amber-600 hover:bg-amber-700 text-white"
+ >
+ {isTransitioning ? "Processing..." : "Confirm Action"}
+
+
+
+
+ )}
+
{/* Tasks Table View */}
@@ -945,7 +1058,11 @@ const ViewDocument = (): ReactElement => {
)}
- setShowWorkflowTracker(false)}>
+ {
+ setShowWorkflowTracker(false);
+ setSelectedWorkflowAction(null);
+ setTransitionComment("");
+ }}>
Close Tracker
diff --git a/src/routes/tenant-admin-routes.tsx b/src/routes/tenant-admin-routes.tsx
index a307ef5..583e80e 100644
--- a/src/routes/tenant-admin-routes.tsx
+++ b/src/routes/tenant-admin-routes.tsx
@@ -18,6 +18,7 @@ const Documents = lazy(() => import("@/pages/tenant/Documents"));
const CreateDocument = lazy(() => import("@/pages/tenant/CreateDocument"));
const ViewDocument = lazy(() => import("@/pages/tenant/ViewDocument"));
const DocumentCategories = lazy(() => import("@/pages/tenant/DocumentCategories"));
+const Tasks = lazy(() => import("@/pages/tenant/Tasks"));
// Loading fallback component
const RouteLoader = (): ReactElement => (
@@ -100,4 +101,8 @@ export const tenantAdminRoutes: RouteConfig[] = [
path: "/tenant/documents/categories",
element: ,
},
+ {
+ path: "/tenant/tasks",
+ element: ,
+ },
];
diff --git a/src/services/workflow-service.ts b/src/services/workflow-service.ts
index c975d9f..d4e08cc 100644
--- a/src/services/workflow-service.ts
+++ b/src/services/workflow-service.ts
@@ -5,7 +5,9 @@ import type {
WorkflowDefinitionsResponse,
WorkflowDefinitionResponse,
WorkflowInstanceResponse,
- WorkflowDeleteResponse
+ WorkflowDeleteResponse,
+ WorkflowTasksResponse,
+ WorkflowTaskCountsResponse
} from '@/types/workflow';
@@ -90,6 +92,21 @@ class WorkflowService {
const response = await apiClient.post(`${this.baseUrl}/definitions/${id}/deprecate`, {}, { params });
return response.data;
}
+
+ async listTasks(params?: { limit?: number; offset?: number }): Promise {
+ const response = await apiClient.get(`${this.baseUrl}/tasks`, { params });
+ return response.data;
+ }
+
+ async getTaskCounts(): Promise {
+ const response = await apiClient.get(`${this.baseUrl}/tasks/counts`);
+ return response.data;
+ }
+
+ async transition(instanceId: string, data: { task_id: string; action: string; comments?: string; signature_id?: string }): Promise {
+ const response = await apiClient.post(`${this.baseUrl}/instances/${instanceId}/transition`, data);
+ return response.data;
+ }
}
export const workflowService = new WorkflowService();
diff --git a/src/types/workflow.ts b/src/types/workflow.ts
index 73bbc8e..cf89573 100644
--- a/src/types/workflow.ts
+++ b/src/types/workflow.ts
@@ -153,3 +153,56 @@ export interface WorkflowDeleteResponse {
success: boolean;
message: string;
}
+
+export interface WorkflowTask {
+ id: string;
+ workflow: {
+ instance_id: string;
+ name: string;
+ };
+ step: {
+ id: string;
+ code: string;
+ name: string;
+ requires_signature: boolean;
+ };
+ entity: {
+ type: string;
+ id: string;
+ name: string;
+ };
+ assignment: {
+ assigned_to: string | null;
+ assigned_to_name: string | null;
+ assigned_role: string;
+ assigned_at: string;
+ };
+ status: string;
+ due_at: string;
+ is_overdue: boolean;
+ completion: any | null;
+ escalation: any | null;
+ created_at: string;
+}
+
+export interface WorkflowTasksResponse {
+ success: boolean;
+ data: WorkflowTask[];
+ pagination: {
+ total: number;
+ limit: number;
+ offset: number;
+ };
+}
+
+export interface WorkflowTaskCounts {
+ pending: number;
+ overdue: number;
+ due_soon: number;
+ completed_this_week: number;
+}
+
+export interface WorkflowTaskCountsResponse {
+ success: boolean;
+ data: WorkflowTaskCounts;
+}