feat: implement workflow tasks management page and define associated data types

This commit is contained in:
Yashwin 2026-03-30 18:35:00 +05:30
parent e4aede83d9
commit 7a368b09bc
12 changed files with 547 additions and 60 deletions

View File

@ -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"

View File

@ -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",

View File

@ -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);
});
}

View File

@ -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;

View File

@ -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;

View File

@ -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,34 +78,56 @@ const StatCard = ({
);
};
const TaskCard = ({ type, title, priority, deadlineLabel }: {
type: string;
title: string;
priority: 'High' | 'Medium' | 'Low';
deadlineLabel: string;
}) => (
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">{type}</span>
<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",
deadlineLabel === 'Due today' ? "text-[#ef4444]" : "text-gray-400"
)}>{deadlineLabel}</span>
task.is_overdue ? "text-[#ef4444]" : "text-gray-400"
)}>{formatDeadline(task.due_at)}</span>
</div>
<div className="text-[13px] font-semibold text-[#111827] leading-tight">{title}</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",
priority === 'High' ? "bg-[#ef4444]" : priority === 'Medium' ? "bg-[#f59e0b]" : "bg-[#10b981]"
task.is_overdue ? "bg-[#ef4444]" : "bg-[#10b981]"
)} />
<span className="text-[11px] text-[#6b7280] font-medium leading-none">{priority} Owner: You</span>
<span className="text-[11px] text-[#6b7280] font-medium leading-none">
{task.step.name} {task.assignment.assigned_role}
</span>
</div>
<div className="flex items-center gap-2">
<button 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
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>
@ -109,6 +135,7 @@ const TaskCard = ({ type, title, priority, deadlineLabel }: {
</div>
</div>
);
};
const CAPASummaryChart = () => {
const data = [
@ -171,6 +198,28 @@ const CAPASummaryChart = () => {
};
const Dashboard = (): ReactElement => {
const navigate = useNavigate();
const [tasks, setTasks] = useState<WorkflowTask[]>([]);
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 => {
<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 className="text-[11px] font-bold text-[#084cc8] hover:underline">
<button
onClick={() => navigate('/tenant/tasks')}
className="text-[11px] font-bold text-[#084cc8] hover:underline"
>
View all
</button>
</div>
<div className="flex flex-col gap-3">
<TaskCard
type="CAPA"
title="Approve CAPA #CP-2024-021"
priority="High"
deadlineLabel="Due today"
/>
<TaskCard
type="DOCUMENT"
title="Review SOP-QA-110 v3"
priority="Medium"
deadlineLabel="Tomorrow"
/>
<TaskCard
type="TRAINING"
title="Complete Data Integrity module"
priority="Low"
deadlineLabel="In 5 Days"
/>
{loading ? (
<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>

View File

@ -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;

230
src/pages/tenant/Tasks.tsx Normal file
View File

@ -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 }) => (
<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", color)}>
<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 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);
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<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 bg-blue-50 px-2 py-1 text-[11px] font-medium text-blue-700 ring-1 ring-inset ring-blue-700/10">
{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) => (
<span className={cn(
"inline-flex items-center rounded-md px-2 py-1 text-[11px] font-medium ring-1 ring-inset",
task.is_overdue
? "bg-red-50 text-red-700 ring-red-600/10"
: "bg-green-50 text-green-700 ring-green-600/10"
)}>
{task.is_overdue ? "Overdue" : task.status}
</span>
),
},
{
key: "due_at",
label: "Due Date",
render: (task) => (
<span className={cn("text-sm", task.is_overdue ? "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="text-[#084cc8] hover:text-[#063ba1] font-bold text-sm"
>
View
</button>
),
},
],
[navigate],
);
return (
<Layout
currentPage="My 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}
color="bg-blue-600"
/>
<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 items-center justify-between">
<h3 className="text-lg font-bold text-gray-900">Pending Tasks</h3>
</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;

View File

@ -84,6 +84,9 @@ const ViewDocument = (): ReactElement => {
const [versionFiles, setVersionFiles] = useState<FileAttachmentItem[]>([]);
const [versionSelectedFileId, setVersionSelectedFileId] = useState("");
const [versionFileName, setVersionFileName] = useState("");
const [transitionComment, setTransitionComment] = useState("");
const [selectedWorkflowAction, setSelectedWorkflowAction] = useState<any>(null);
const [isTransitioning, setIsTransitioning] = useState(false);
const [versionFilePath, setVersionFilePath] = useState("");
const [versionFileSize, setVersionFileSize] = useState<number | undefined>(undefined);
const [versionMimeType, setVersionMimeType] = useState("");
@ -360,6 +363,48 @@ const ViewDocument = (): ReactElement => {
}
};
const handleWorkflowTransition = async (): Promise<void> => {
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<DocumentVersion>[] = [
{ key: "version_number", label: "Version" },
{ key: "status", label: "Status" },
@ -849,7 +894,11 @@ const ViewDocument = (): ReactElement => {
{/* Workflow Instance Tracker Modal */}
<Modal
isOpen={showWorkflowTracker}
onClose={() => 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 => {
</div>
<div>
<label className="text-[10px] uppercase tracking-wider font-bold text-gray-400 block">Started By</label>
<p className="text-sm font-medium text-gray-700">{workflowInstance.started_by.name}</p>
<p className="text-sm font-medium text-gray-700">{workflowInstance.started_by.name} ({workflowInstance.started_by.email})</p>
</div>
<div>
<label className="text-[10px] uppercase tracking-wider font-bold text-gray-400 block">Started At</label>
<p className="text-sm font-medium text-gray-700">{formatDateTime(workflowInstance.started_at)}</p>
</div>
</div>
{/* Available Actions Buttons */}
{workflowInstance.available_actions && workflowInstance.available_actions.length > 0 && (
<div className="p-4 border border-blue-100 bg-blue-50/30 rounded-lg">
<label className="text-[10px] uppercase tracking-wider font-bold text-blue-600 block mb-3">Available Actions</label>
<div className="flex flex-wrap gap-2">
{workflowInstance.available_actions.map((action, idx) => (
<button
key={idx}
onClick={() => 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}
</button>
))}
</div>
</div>
)}
{/* Transition Modal content (inline when action selected) */}
{selectedWorkflowAction && (
<div className="p-4 border border-amber-200 bg-amber-50 rounded-lg animate-in fade-in slide-in-from-top-2">
<div className="flex justify-between items-start mb-3">
<h4 className="text-xs font-bold text-amber-900 uppercase tracking-wider">
Executing Action: {selectedWorkflowAction.name}
</h4>
<button
onClick={() => {
setSelectedWorkflowAction(null);
setTransitionComment("");
}}
className="text-amber-500 hover:text-amber-700"
>
Cancel
</button>
</div>
<div className="space-y-3">
<div>
<label className="text-[11px] font-bold text-amber-800 mb-1.5 block">
Comments {selectedWorkflowAction.requires_comment ? "(Required)" : "(Optional)"}
</label>
<textarea
rows={3}
value={transitionComment}
onChange={(e) => setTransitionComment(e.target.value)}
placeholder={selectedWorkflowAction.requires_comment ? "Enter required comments..." : "Enter optional comments..."}
className="w-full px-3 py-2 border border-amber-200 rounded-md text-xs focus:ring-1 focus:ring-amber-500 focus:outline-none"
/>
</div>
<div className="flex justify-end">
<PrimaryButton
onClick={() => void handleWorkflowTransition()}
disabled={isTransitioning}
className="bg-amber-600 hover:bg-amber-700 text-white"
>
{isTransitioning ? "Processing..." : "Confirm Action"}
</PrimaryButton>
</div>
</div>
</div>
)}
{/* Tasks Table View */}
<div className="overflow-hidden border border-gray-100 rounded-lg">
<table className="min-w-full divide-y divide-gray-200">
@ -945,7 +1058,11 @@ const ViewDocument = (): ReactElement => {
)}
<div className="mt-8 flex justify-end">
<SecondaryButton onClick={() => setShowWorkflowTracker(false)}>
<SecondaryButton onClick={() => {
setShowWorkflowTracker(false);
setSelectedWorkflowAction(null);
setTransitionComment("");
}}>
Close Tracker
</SecondaryButton>
</div>

View File

@ -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: <LazyRoute component={DocumentCategories} />,
},
{
path: "/tenant/tasks",
element: <LazyRoute component={Tasks} />,
},
];

View File

@ -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<WorkflowDefinitionResponse>(`${this.baseUrl}/definitions/${id}/deprecate`, {}, { params });
return response.data;
}
async listTasks(params?: { limit?: number; offset?: number }): Promise<WorkflowTasksResponse> {
const response = await apiClient.get<WorkflowTasksResponse>(`${this.baseUrl}/tasks`, { params });
return response.data;
}
async getTaskCounts(): Promise<WorkflowTaskCountsResponse> {
const response = await apiClient.get<WorkflowTaskCountsResponse>(`${this.baseUrl}/tasks/counts`);
return response.data;
}
async transition(instanceId: string, data: { task_id: string; action: string; comments?: string; signature_id?: string }): Promise<any> {
const response = await apiClient.post(`${this.baseUrl}/instances/${instanceId}/transition`, data);
return response.data;
}
}
export const workflowService = new WorkflowService();

View File

@ -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;
}