From e4aede83d91fe06d492ecd4ce95173105a88eab5 Mon Sep 17 00:00:00 2001 From: Yashwin Date: Fri, 27 Mar 2026 21:18:15 +0530 Subject: [PATCH] feat: Add module association to documents and integrate workflow history and tracking. --- src/pages/tenant/CreateDocument.tsx | 23 ++- src/pages/tenant/Documents.tsx | 7 + src/pages/tenant/ViewDocument.tsx | 230 +++++++++++++++++++++++++++- src/services/document-service.ts | 4 + src/services/workflow-service.ts | 20 +++ src/types/document.ts | 3 + src/types/workflow.ts | 51 ++++++ 7 files changed, 335 insertions(+), 3 deletions(-) diff --git a/src/pages/tenant/CreateDocument.tsx b/src/pages/tenant/CreateDocument.tsx index 61e0574..1e18577 100644 --- a/src/pages/tenant/CreateDocument.tsx +++ b/src/pages/tenant/CreateDocument.tsx @@ -6,6 +6,8 @@ import { documentService, type FileAttachmentItem } from "@/services/document-se import type { DocumentCategory } from "@/types/document"; import { showToast } from "@/utils/toast"; import { ArrowLeft, FileText, Info, Paperclip } from "lucide-react"; +import { moduleService } from "@/services/module-service"; +import type { MyModule } from "@/types/module"; const CreateDocument = (): ReactElement => { const navigate = useNavigate(); @@ -21,6 +23,8 @@ const CreateDocument = (): ReactElement => { const [isSaving, setIsSaving] = useState(false); const [types, setTypes] = useState>([]); const [categories, setCategories] = useState([]); + const [modules, setModules] = useState([]); + const [selectedModuleId, setSelectedModuleId] = useState(""); // File attachment fields const [files, setFiles] = useState([]); @@ -35,12 +39,14 @@ const CreateDocument = (): ReactElement => { useEffect(() => { const loadLookups = async (): Promise => { try { - const [typesRes, categoriesRes] = await Promise.all([ + const [typesRes, categoriesRes, modulesRes] = await Promise.all([ documentService.getTypes(), documentService.getCategories(), + moduleService.getMyModules(), ]); setTypes(typesRes.data || []); setCategories(categoriesRes.data || []); + setModules(modulesRes.data || []); } catch { showToast.error("Failed to load document metadata"); } @@ -133,6 +139,8 @@ const CreateDocument = (): ReactElement => { file_size: fileSize, mime_type: mimeType || undefined, file_hash: fileHash || undefined, + source_module: selectedModuleId ? modules.find(m => m.id === selectedModuleId)?.module_id : undefined, + source_module_id: selectedModuleId || undefined, }); showToast.success("Document created successfully"); navigate(`/tenant/documents/${response.data.id}`); @@ -248,6 +256,19 @@ const CreateDocument = (): ReactElement => { onChange={(e) => setTags(e.target.value)} placeholder="Comma separated tags (e.g. quality, sop)" /> + ({ + value: m.id, + label: m.name, + })), + ]} + placeholder="Specify originating module (Optional)" + /> diff --git a/src/pages/tenant/Documents.tsx b/src/pages/tenant/Documents.tsx index 69083cc..ed6383b 100644 --- a/src/pages/tenant/Documents.tsx +++ b/src/pages/tenant/Documents.tsx @@ -135,6 +135,13 @@ const Documents = (): ReactElement => { ), }, + { + key: "module_name", + label: "Module", + render: (doc) => ( + {doc.module_name || "Platform"} + ), + }, { key: "current_version", label: "Version", diff --git a/src/pages/tenant/ViewDocument.tsx b/src/pages/tenant/ViewDocument.tsx index e04e54b..b00bc0b 100644 --- a/src/pages/tenant/ViewDocument.tsx +++ b/src/pages/tenant/ViewDocument.tsx @@ -13,8 +13,9 @@ import { import { documentService, type FileAttachmentItem } from "@/services/document-service"; import { workflowService } from "@/services/workflow-service"; import type { DocumentDetail, DocumentVersion } from "@/types/document"; +import type { WorkflowInstance } from "@/types/workflow"; import { showToast } from "@/utils/toast"; -import { Paperclip, Plus } from "lucide-react"; +import { Paperclip, Plus, User } from "lucide-react"; const formatDateTime = (value?: string | null): string => { if (!value) return "-"; @@ -53,9 +54,11 @@ const ViewDocument = (): ReactElement => { const [versions, setVersions] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); - const [activeTab, setActiveTab] = useState<"overview" | "version-history">( + const [activeTab, setActiveTab] = useState<"overview" | "version-history" | "workflow-history">( "overview", ); + const [workflowInstances, setWorkflowInstances] = useState([]); + const [isHistoryLoading, setIsHistoryLoading] = useState(false); const [activeAction, setActiveAction] = useState(null); const [isActionLoading, setIsActionLoading] = useState(false); @@ -73,6 +76,9 @@ const ViewDocument = (): ReactElement => { const [newVersionChangeSummary, setNewVersionChangeSummary] = useState(""); const [isMajorVersion, setIsMajorVersion] = useState(false); const [isVersionSaving, setIsVersionSaving] = useState(false); + const [showWorkflowTracker, setShowWorkflowTracker] = useState(false); + const [workflowInstance, setWorkflowInstance] = useState(null); + const [isWorkflowLoading, setIsWorkflowLoading] = useState(false); // File attachment fields for new version const [versionFiles, setVersionFiles] = useState([]); @@ -111,6 +117,29 @@ const ViewDocument = (): ReactElement => { void loadVersionFiles(); }, [id]); + useEffect(() => { + if (activeTab === "workflow-history" && id) { + void loadWorkflowHistory(); + } + }, [activeTab, id]); + + const loadWorkflowHistory = async (): Promise => { + try { + setIsHistoryLoading(true); + const res = await workflowService.listInstances({ + entity_type: "document", + entity_id: id, + limit: 100, + offset: 0, + }); + setWorkflowInstances(res.data || []); + } catch { + showToast.error("Failed to load workflow history"); + } finally { + setIsHistoryLoading(false); + } + }; + const loadVersionFiles = async (): Promise => { setIsLoadingVersionFiles(true); try { @@ -172,6 +201,26 @@ const ViewDocument = (): ReactElement => { setVersions(versionsRes.data || []); }; + const openWorkflowTracker = async (instanceId?: string): Promise => { + const targetId = instanceId || document?.workflow_instance_id; + if (!targetId) { + showToast.error("No workflow instance associated with this document"); + return; + } + try { + setIsWorkflowLoading(true); + setShowWorkflowTracker(true); + const res = await workflowService.getInstance(targetId); + setWorkflowInstance(res.data); + } catch (err: any) { + const msg = err?.response?.data?.error?.message || "Failed to load workflow tracker"; + showToast.error(msg); + setShowWorkflowTracker(false); + } finally { + setIsWorkflowLoading(false); + } + }; + const resetActionModal = (): void => { setActiveAction(null); setWorkflowDefinitionId(""); @@ -319,6 +368,11 @@ const ViewDocument = (): ReactElement => { label: "Change Summary", render: (version) => version.change_summary || "-", }, + { + key: "module_name", + label: "Module", + render: (version) => version.module_name || "Platform", + }, { key: "created_by", label: "Author", @@ -373,6 +427,13 @@ const ViewDocument = (): ReactElement => { )} {document?.status === "in_review" && ( <> + + {isLoading ? ( @@ -622,6 +695,53 @@ const ViewDocument = (): ReactElement => { )} + {activeTab === "workflow-history" && ( +
+
+ + + + + + + + + + + + {isHistoryLoading ? ( + + ) : workflowInstances.length === 0 ? ( + + ) : workflowInstances.map((inst) => ( + + + + + + + + ))} + +
WorkflowCurrent StepStatusStarted AtAction
Loading history...
No workflow history found.
{inst.workflow_name}{inst.current_step || '-'} + + {inst.status} + + {formatDateTime(inst.started_at)} + +
+
+
+ )} ) : null}
@@ -725,6 +845,112 @@ const ViewDocument = (): ReactElement => { )}
+ + {/* Workflow Instance Tracker Modal */} + setShowWorkflowTracker(false)} + title="Workflow Status Tracker" + description="Track the progress of this document's approval workflow." + maxWidth="2xl" + > +
+ {isWorkflowLoading ? ( +
+
+

Loading workflow details...

+
+ ) : workflowInstance ? ( +
+ {/* Workflow Header Info */} +
+
+ +

{workflowInstance.workflow.name}

+
+
+ + + {workflowInstance.status} + +
+
+ +

{workflowInstance.started_by.name}

+
+
+ +

{formatDateTime(workflowInstance.started_at)}

+
+
+ {/* Tasks Table View */} +
+ + + + + + + + + + + + {workflowInstance.tasks.map((task) => ( + + + + + + + + ))} + +
StepAssigneeStatusActionCompleted At
+ + {task.step} + + +
+ + {task.assigned_to} +
+
+ + {task.status} + + + {task.action_taken ? ( + + {task.action_taken} + + ) : ( + No action + )} + + {task.completed_at ? formatDateTime(task.completed_at) : '-'} +
+
+
+ ) : ( +
+

No workflow tracking information available.

+
+ )} + +
+ setShowWorkflowTracker(false)}> + Close Tracker + +
+
+
); }; diff --git a/src/services/document-service.ts b/src/services/document-service.ts index 5fe406d..c7545f8 100644 --- a/src/services/document-service.ts +++ b/src/services/document-service.ts @@ -34,6 +34,8 @@ export interface CreateDocumentPayload { file_size?: number; mime_type?: string; file_hash?: string; + source_module?: string; + source_module_id?: string; } export interface FileAttachmentItem { @@ -72,6 +74,8 @@ export interface CreateDocumentVersionPayload { file_size?: number; mime_type?: string; file_hash?: string; + source_module?: string; + source_module_id?: string; } export const documentService = { diff --git a/src/services/workflow-service.ts b/src/services/workflow-service.ts index e66a54e..c975d9f 100644 --- a/src/services/workflow-service.ts +++ b/src/services/workflow-service.ts @@ -4,6 +4,7 @@ import type { UpdateWorkflowDefinitionData, WorkflowDefinitionsResponse, WorkflowDefinitionResponse, + WorkflowInstanceResponse, WorkflowDeleteResponse } from '@/types/workflow'; @@ -35,6 +36,25 @@ class WorkflowService { return response.data; } + async getInstance(id: string, tenantId?: string): Promise { + const params = tenantId ? { tenantId: tenantId } : {}; + const response = await apiClient.get(`${this.baseUrl}/instances/${id}`, { params }); + return response.data; + } + + async listInstances(params: { + entity_type?: string; + entity_id?: string; + status?: string; + source_module?: string; + source_module_id?: string; + limit?: number; + offset?: number; + }): Promise<{ success: boolean; data: any[]; pagination: { total: number; limit: number; offset: number } }> { + const response = await apiClient.get(`${this.baseUrl}/instances`, { params }); + return response.data; + } + async createDefinition(data: CreateWorkflowDefinitionData, tenantId?: string): Promise { const params = tenantId ? { tenantId: tenantId } : {}; const response = await apiClient.post(`${this.baseUrl}/definitions`, data, { params }); diff --git a/src/types/document.ts b/src/types/document.ts index 458b734..fa8e554 100644 --- a/src/types/document.ts +++ b/src/types/document.ts @@ -22,6 +22,7 @@ export interface DocumentSummary { owner?: string | null; next_review_date?: string | null; updated_at?: string; + module_name?: string | null; } export interface DocumentDetail { @@ -54,6 +55,7 @@ export interface DocumentDetail { created_by?: string | null; created_at?: string; updated_at?: string; + module_name?: string | null; } export interface DocumentVersion { @@ -70,6 +72,7 @@ export interface DocumentVersion { approved_at?: string | null; created_by?: string | null; created_at?: string; + module_name?: string | null; } export interface DocumentListResponse { diff --git a/src/types/workflow.ts b/src/types/workflow.ts index a79e0c7..73bbc8e 100644 --- a/src/types/workflow.ts +++ b/src/types/workflow.ts @@ -98,6 +98,57 @@ export interface WorkflowDefinitionResponse { data: WorkflowDefinition; } +export interface WorkflowInstance { + id: string; + workflow: { + id: string; + name: string; + code: string; + }; + entity: { + type: string; + id: string; + name: string | null; + }; + current_step: { + id: string; + code: string; + name: string; + }; + status: string; + context: Record; + tasks: Array<{ + id: string; + step: string; + assigned_to: string; + status: string; + action_taken: string | null; + completed_at: string | null; + due_at: string | null; + }>; + available_actions: Array<{ + action: string; + name: string; + requires_signature: boolean; + requires_comment: boolean; + next_step: string; + }>; + started_by: { + email: string; + name: string; + }; + started_at: string; + completed_at: string | null; + cancelled_at: string | null; + cancelled_by: string | null; + cancellation_reason: string | null; +} + +export interface WorkflowInstanceResponse { + success: boolean; + data: WorkflowInstance; +} + export interface WorkflowDeleteResponse { success: boolean; message: string;