feat: implement workflow tasks management page and define associated data types
This commit is contained in:
parent
e4aede83d9
commit
7a368b09bc
@ -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"
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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
230
src/pages/tenant/Tasks.tsx
Normal 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;
|
||||
@ -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>
|
||||
|
||||
@ -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} />,
|
||||
},
|
||||
];
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user