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",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite --host",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import {
|
|||||||
Shield,
|
Shield,
|
||||||
BadgeCheck,
|
BadgeCheck,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
|
Briefcase,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@ -56,6 +57,12 @@ const tenantAdminPlatformMenu: MenuItem[] = [
|
|||||||
path: "/tenant/roles",
|
path: "/tenant/roles",
|
||||||
requiredPermission: { resource: "roles" },
|
requiredPermission: { resource: "roles" },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: Briefcase,
|
||||||
|
label: "My Tasks",
|
||||||
|
path: "/tenant/tasks",
|
||||||
|
requiredPermission: { resource: "workflow" },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: Users,
|
icon: Users,
|
||||||
label: "Users",
|
label: "Users",
|
||||||
|
|||||||
@ -4,3 +4,15 @@ import { twMerge } from "tailwind-merge"
|
|||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
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 { fileService } from "@/services/file-service";
|
||||||
import { showToast } from "@/utils/toast";
|
import { showToast } from "@/utils/toast";
|
||||||
import { ChevronRight, ChevronLeft, Image as ImageIcon, X } from "lucide-react";
|
import { ChevronRight, ChevronLeft, Image as ImageIcon, X } from "lucide-react";
|
||||||
|
import { generateUUID } from "@/lib/utils";
|
||||||
|
|
||||||
// Step 1: Tenant Details Schema - matches NewTenantModal
|
// Step 1: Tenant Details Schema - matches NewTenantModal
|
||||||
const tenantDetailsSchema = z.object({
|
const tenantDetailsSchema = z.object({
|
||||||
@ -1116,7 +1117,7 @@ const CreateTenantWizard = (): ReactElement => {
|
|||||||
const response = await fileService.upload(
|
const response = await fileService.upload(
|
||||||
file,
|
file,
|
||||||
"tenant",
|
"tenant",
|
||||||
crypto.randomUUID(),
|
generateUUID(),
|
||||||
);
|
);
|
||||||
const fileId = response.data.id;
|
const fileId = response.data.id;
|
||||||
|
|
||||||
@ -1244,7 +1245,7 @@ const CreateTenantWizard = (): ReactElement => {
|
|||||||
const response = await fileService.upload(
|
const response = await fileService.upload(
|
||||||
file,
|
file,
|
||||||
"tenant",
|
"tenant",
|
||||||
crypto.randomUUID(),
|
generateUUID(),
|
||||||
);
|
);
|
||||||
const fileId = response.data.id;
|
const fileId = response.data.id;
|
||||||
|
|
||||||
|
|||||||
@ -24,6 +24,7 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { generateUUID } from "@/lib/utils";
|
||||||
|
|
||||||
// Step 1: Tenant Details Schema
|
// Step 1: Tenant Details Schema
|
||||||
const tenantDetailsSchema = z.object({
|
const tenantDetailsSchema = z.object({
|
||||||
@ -118,7 +119,7 @@ const subscriptionTierOptions = [
|
|||||||
|
|
||||||
// Helper function to get base URL with protocol
|
// Helper function to get base URL with protocol
|
||||||
const getBaseUrlWithProtocol = (): string => {
|
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 => {
|
const EditTenant = (): ReactElement => {
|
||||||
@ -1234,7 +1235,7 @@ const EditTenant = (): ReactElement => {
|
|||||||
const response = await fileService.upload(
|
const response = await fileService.upload(
|
||||||
file,
|
file,
|
||||||
slug || "tenant",
|
slug || "tenant",
|
||||||
crypto.randomUUID(),
|
generateUUID(),
|
||||||
id,
|
id,
|
||||||
);
|
);
|
||||||
const fileId = response.data.id;
|
const fileId = response.data.id;
|
||||||
@ -1359,7 +1360,7 @@ const EditTenant = (): ReactElement => {
|
|||||||
const response = await fileService.upload(
|
const response = await fileService.upload(
|
||||||
file,
|
file,
|
||||||
slug || "tenant",
|
slug || "tenant",
|
||||||
crypto.randomUUID(),
|
generateUUID(),
|
||||||
id,
|
id,
|
||||||
);
|
);
|
||||||
const fileId = response.data.id;
|
const fileId = response.data.id;
|
||||||
|
|||||||
@ -21,6 +21,10 @@ import {
|
|||||||
Bar,
|
Bar,
|
||||||
Line
|
Line
|
||||||
} from 'recharts';
|
} 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 {
|
interface StatCardProps {
|
||||||
icon: React.ComponentType<{ className?: string; strokeWidth?: number }>;
|
icon: React.ComponentType<{ className?: string; strokeWidth?: number }>;
|
||||||
@ -74,41 +78,64 @@ const StatCard = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const TaskCard = ({ type, title, priority, deadlineLabel }: {
|
const TaskCard = ({ task }: { task: WorkflowTask }) => {
|
||||||
type: string;
|
const navigate = useNavigate();
|
||||||
title: string;
|
const formatDeadline = (dueDate: string) => {
|
||||||
priority: 'High' | 'Medium' | 'Low';
|
const now = new Date();
|
||||||
deadlineLabel: string;
|
const due = new Date(dueDate);
|
||||||
}) => (
|
const diffTime = due.getTime() - now.getTime();
|
||||||
<div className="border border-[#e5e7eb] rounded-xl p-4 flex flex-col gap-3 bg-white hover:border-[#cbd5e1] transition-colors">
|
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||||
<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={cn(
|
|
||||||
"text-[10px] font-bold",
|
|
||||||
deadlineLabel === 'Due today' ? "text-[#ef4444]" : "text-gray-400"
|
|
||||||
)}>{deadlineLabel}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-[13px] font-semibold text-[#111827] leading-tight">{title}</div>
|
if (diffDays === 0) return 'Due today';
|
||||||
|
if (diffDays === 1) return 'Tomorrow';
|
||||||
|
if (diffDays < 0) return 'Overdue';
|
||||||
|
return `In ${diffDays} Days`;
|
||||||
|
};
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 overflow-hidden mt-1">
|
const handleView = () => {
|
||||||
<div className="flex items-center gap-1.5 shrink-0">
|
if (task.entity.type.toLowerCase() === 'document') {
|
||||||
<div className={cn(
|
navigate(`/tenant/documents/${task.entity.id}`);
|
||||||
"w-2 h-2 rounded-full",
|
}
|
||||||
priority === 'High' ? "bg-[#ef4444]" : priority === 'Medium' ? "bg-[#f59e0b]" : "bg-[#10b981]"
|
};
|
||||||
)} />
|
|
||||||
<span className="text-[11px] text-[#6b7280] font-medium leading-none">{priority} • Owner: You</span>
|
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">{task.entity.type}</span>
|
||||||
|
<span className={cn(
|
||||||
|
"text-[10px] font-bold",
|
||||||
|
task.is_overdue ? "text-[#ef4444]" : "text-gray-400"
|
||||||
|
)}>{formatDeadline(task.due_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="text-[13px] font-semibold text-[#111827] leading-tight">{task.entity.name}</div>
|
||||||
<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 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">
|
<div className="flex flex-wrap items-center justify-between gap-3 overflow-hidden mt-1">
|
||||||
Complete
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
</button>
|
<div className={cn(
|
||||||
|
"w-2 h-2 rounded-full",
|
||||||
|
task.is_overdue ? "bg-[#ef4444]" : "bg-[#10b981]"
|
||||||
|
)} />
|
||||||
|
<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
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
};
|
||||||
|
|
||||||
const CAPASummaryChart = () => {
|
const CAPASummaryChart = () => {
|
||||||
const data = [
|
const data = [
|
||||||
@ -171,6 +198,28 @@ const CAPASummaryChart = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const Dashboard = (): ReactElement => {
|
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[] = [
|
const statCards: StatCardProps[] = [
|
||||||
{
|
{
|
||||||
icon: Info,
|
icon: Info,
|
||||||
@ -258,30 +307,24 @@ const Dashboard = (): ReactElement => {
|
|||||||
<div className="bg-white border border-[#e5e7eb] rounded-xl p-6 shadow-sm">
|
<div className="bg-white border border-[#e5e7eb] rounded-xl p-6 shadow-sm">
|
||||||
<div className="flex justify-between items-center mb-5">
|
<div className="flex justify-between items-center mb-5">
|
||||||
<h2 className="text-[16px] font-bold text-[#111827] tracking-tight">My Tasks</h2>
|
<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
|
View all
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<TaskCard
|
{loading ? (
|
||||||
type="CAPA"
|
<div className="text-center py-4 text-gray-400 text-sm">Loading tasks...</div>
|
||||||
title="Approve CAPA #CP-2024-021"
|
) : tasks.length > 0 ? (
|
||||||
priority="High"
|
tasks.map((task) => (
|
||||||
deadlineLabel="Due today"
|
<TaskCard key={task.id} task={task} />
|
||||||
/>
|
))
|
||||||
<TaskCard
|
) : (
|
||||||
type="DOCUMENT"
|
<div className="text-center py-4 text-gray-400 text-sm">No pending tasks</div>
|
||||||
title="Review SOP-QA-110 v3"
|
)}
|
||||||
priority="Medium"
|
|
||||||
deadlineLabel="Tomorrow"
|
|
||||||
/>
|
|
||||||
<TaskCard
|
|
||||||
type="TRAINING"
|
|
||||||
title="Complete Data Integrity module"
|
|
||||||
priority="Low"
|
|
||||||
deadlineLabel="In 5 Days"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { fileService } from "@/services/file-service";
|
|||||||
import { showToast } from "@/utils/toast";
|
import { showToast } from "@/utils/toast";
|
||||||
import { updateTheme } from "@/store/themeSlice";
|
import { updateTheme } from "@/store/themeSlice";
|
||||||
import { PrimaryButton, AuthenticatedImage } from "@/components/shared";
|
import { PrimaryButton, AuthenticatedImage } from "@/components/shared";
|
||||||
|
import { generateUUID } from "@/lib/utils";
|
||||||
import type { Tenant } from "@/types/tenant";
|
import type { Tenant } from "@/types/tenant";
|
||||||
|
|
||||||
// Helper function to get base URL with protocol
|
// Helper function to get base URL with protocol
|
||||||
@ -163,7 +164,7 @@ const Settings = (): ReactElement => {
|
|||||||
const response = await fileService.upload(
|
const response = await fileService.upload(
|
||||||
file,
|
file,
|
||||||
"tenant",
|
"tenant",
|
||||||
crypto.randomUUID(),
|
generateUUID(),
|
||||||
tenantId || undefined,
|
tenantId || undefined,
|
||||||
);
|
);
|
||||||
const fileId = response.data.id;
|
const fileId = response.data.id;
|
||||||
@ -231,7 +232,7 @@ const Settings = (): ReactElement => {
|
|||||||
const response = await fileService.upload(
|
const response = await fileService.upload(
|
||||||
file,
|
file,
|
||||||
"tenant",
|
"tenant",
|
||||||
crypto.randomUUID(),
|
generateUUID(),
|
||||||
tenantId || undefined,
|
tenantId || undefined,
|
||||||
);
|
);
|
||||||
const fileId = response.data.id;
|
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 [versionFiles, setVersionFiles] = useState<FileAttachmentItem[]>([]);
|
||||||
const [versionSelectedFileId, setVersionSelectedFileId] = useState("");
|
const [versionSelectedFileId, setVersionSelectedFileId] = useState("");
|
||||||
const [versionFileName, setVersionFileName] = 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 [versionFilePath, setVersionFilePath] = useState("");
|
||||||
const [versionFileSize, setVersionFileSize] = useState<number | undefined>(undefined);
|
const [versionFileSize, setVersionFileSize] = useState<number | undefined>(undefined);
|
||||||
const [versionMimeType, setVersionMimeType] = useState("");
|
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>[] = [
|
const versionColumns: Column<DocumentVersion>[] = [
|
||||||
{ key: "version_number", label: "Version" },
|
{ key: "version_number", label: "Version" },
|
||||||
{ key: "status", label: "Status" },
|
{ key: "status", label: "Status" },
|
||||||
@ -849,7 +894,11 @@ const ViewDocument = (): ReactElement => {
|
|||||||
{/* Workflow Instance Tracker Modal */}
|
{/* Workflow Instance Tracker Modal */}
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={showWorkflowTracker}
|
isOpen={showWorkflowTracker}
|
||||||
onClose={() => setShowWorkflowTracker(false)}
|
onClose={() => {
|
||||||
|
setShowWorkflowTracker(false);
|
||||||
|
setSelectedWorkflowAction(null);
|
||||||
|
setTransitionComment("");
|
||||||
|
}}
|
||||||
title="Workflow Status Tracker"
|
title="Workflow Status Tracker"
|
||||||
description="Track the progress of this document's approval workflow."
|
description="Track the progress of this document's approval workflow."
|
||||||
maxWidth="2xl"
|
maxWidth="2xl"
|
||||||
@ -879,13 +928,77 @@ const ViewDocument = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[10px] uppercase tracking-wider font-bold text-gray-400 block">Started By</label>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[10px] uppercase tracking-wider font-bold text-gray-400 block">Started At</label>
|
<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>
|
<p className="text-sm font-medium text-gray-700">{formatDateTime(workflowInstance.started_at)}</p>
|
||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Tasks Table View */}
|
||||||
<div className="overflow-hidden border border-gray-100 rounded-lg">
|
<div className="overflow-hidden border border-gray-100 rounded-lg">
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
@ -945,7 +1058,11 @@ const ViewDocument = (): ReactElement => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-8 flex justify-end">
|
<div className="mt-8 flex justify-end">
|
||||||
<SecondaryButton onClick={() => setShowWorkflowTracker(false)}>
|
<SecondaryButton onClick={() => {
|
||||||
|
setShowWorkflowTracker(false);
|
||||||
|
setSelectedWorkflowAction(null);
|
||||||
|
setTransitionComment("");
|
||||||
|
}}>
|
||||||
Close Tracker
|
Close Tracker
|
||||||
</SecondaryButton>
|
</SecondaryButton>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -18,6 +18,7 @@ const Documents = lazy(() => import("@/pages/tenant/Documents"));
|
|||||||
const CreateDocument = lazy(() => import("@/pages/tenant/CreateDocument"));
|
const CreateDocument = lazy(() => import("@/pages/tenant/CreateDocument"));
|
||||||
const ViewDocument = lazy(() => import("@/pages/tenant/ViewDocument"));
|
const ViewDocument = lazy(() => import("@/pages/tenant/ViewDocument"));
|
||||||
const DocumentCategories = lazy(() => import("@/pages/tenant/DocumentCategories"));
|
const DocumentCategories = lazy(() => import("@/pages/tenant/DocumentCategories"));
|
||||||
|
const Tasks = lazy(() => import("@/pages/tenant/Tasks"));
|
||||||
|
|
||||||
// Loading fallback component
|
// Loading fallback component
|
||||||
const RouteLoader = (): ReactElement => (
|
const RouteLoader = (): ReactElement => (
|
||||||
@ -100,4 +101,8 @@ export const tenantAdminRoutes: RouteConfig[] = [
|
|||||||
path: "/tenant/documents/categories",
|
path: "/tenant/documents/categories",
|
||||||
element: <LazyRoute component={DocumentCategories} />,
|
element: <LazyRoute component={DocumentCategories} />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/tenant/tasks",
|
||||||
|
element: <LazyRoute component={Tasks} />,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@ -5,7 +5,9 @@ import type {
|
|||||||
WorkflowDefinitionsResponse,
|
WorkflowDefinitionsResponse,
|
||||||
WorkflowDefinitionResponse,
|
WorkflowDefinitionResponse,
|
||||||
WorkflowInstanceResponse,
|
WorkflowInstanceResponse,
|
||||||
WorkflowDeleteResponse
|
WorkflowDeleteResponse,
|
||||||
|
WorkflowTasksResponse,
|
||||||
|
WorkflowTaskCountsResponse
|
||||||
} from '@/types/workflow';
|
} from '@/types/workflow';
|
||||||
|
|
||||||
|
|
||||||
@ -90,6 +92,21 @@ class WorkflowService {
|
|||||||
const response = await apiClient.post<WorkflowDefinitionResponse>(`${this.baseUrl}/definitions/${id}/deprecate`, {}, { params });
|
const response = await apiClient.post<WorkflowDefinitionResponse>(`${this.baseUrl}/definitions/${id}/deprecate`, {}, { params });
|
||||||
return response.data;
|
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();
|
export const workflowService = new WorkflowService();
|
||||||
|
|||||||
@ -153,3 +153,56 @@ export interface WorkflowDeleteResponse {
|
|||||||
success: boolean;
|
success: boolean;
|
||||||
message: string;
|
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