feat: Add module association to documents and integrate workflow history and tracking.

This commit is contained in:
Yashwin 2026-03-27 21:18:15 +05:30
parent 254bc9f40e
commit e4aede83d9
7 changed files with 335 additions and 3 deletions

View File

@ -6,6 +6,8 @@ import { documentService, type FileAttachmentItem } from "@/services/document-se
import type { DocumentCategory } from "@/types/document"; import type { DocumentCategory } from "@/types/document";
import { showToast } from "@/utils/toast"; import { showToast } from "@/utils/toast";
import { ArrowLeft, FileText, Info, Paperclip } from "lucide-react"; import { ArrowLeft, FileText, Info, Paperclip } from "lucide-react";
import { moduleService } from "@/services/module-service";
import type { MyModule } from "@/types/module";
const CreateDocument = (): ReactElement => { const CreateDocument = (): ReactElement => {
const navigate = useNavigate(); const navigate = useNavigate();
@ -21,6 +23,8 @@ const CreateDocument = (): ReactElement => {
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [types, setTypes] = useState<Array<{ code: string; name: string }>>([]); const [types, setTypes] = useState<Array<{ code: string; name: string }>>([]);
const [categories, setCategories] = useState<DocumentCategory[]>([]); const [categories, setCategories] = useState<DocumentCategory[]>([]);
const [modules, setModules] = useState<MyModule[]>([]);
const [selectedModuleId, setSelectedModuleId] = useState("");
// File attachment fields // File attachment fields
const [files, setFiles] = useState<FileAttachmentItem[]>([]); const [files, setFiles] = useState<FileAttachmentItem[]>([]);
@ -35,12 +39,14 @@ const CreateDocument = (): ReactElement => {
useEffect(() => { useEffect(() => {
const loadLookups = async (): Promise<void> => { const loadLookups = async (): Promise<void> => {
try { try {
const [typesRes, categoriesRes] = await Promise.all([ const [typesRes, categoriesRes, modulesRes] = await Promise.all([
documentService.getTypes(), documentService.getTypes(),
documentService.getCategories(), documentService.getCategories(),
moduleService.getMyModules(),
]); ]);
setTypes(typesRes.data || []); setTypes(typesRes.data || []);
setCategories(categoriesRes.data || []); setCategories(categoriesRes.data || []);
setModules(modulesRes.data || []);
} catch { } catch {
showToast.error("Failed to load document metadata"); showToast.error("Failed to load document metadata");
} }
@ -133,6 +139,8 @@ const CreateDocument = (): ReactElement => {
file_size: fileSize, file_size: fileSize,
mime_type: mimeType || undefined, mime_type: mimeType || undefined,
file_hash: fileHash || 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"); showToast.success("Document created successfully");
navigate(`/tenant/documents/${response.data.id}`); navigate(`/tenant/documents/${response.data.id}`);
@ -248,6 +256,19 @@ const CreateDocument = (): ReactElement => {
onChange={(e) => setTags(e.target.value)} onChange={(e) => setTags(e.target.value)}
placeholder="Comma separated tags (e.g. quality, sop)" placeholder="Comma separated tags (e.g. quality, sop)"
/> />
<FormSelect
label="Source Module"
value={selectedModuleId}
onValueChange={setSelectedModuleId}
options={[
{ value: "", label: "Platform (Default)" },
...modules.map((m) => ({
value: m.id,
label: m.name,
})),
]}
placeholder="Specify originating module (Optional)"
/>
</div> </div>
</div> </div>

View File

@ -135,6 +135,13 @@ const Documents = (): ReactElement => {
</span> </span>
), ),
}, },
{
key: "module_name",
label: "Module",
render: (doc) => (
<span className="text-[#0f1724]">{doc.module_name || "Platform"}</span>
),
},
{ {
key: "current_version", key: "current_version",
label: "Version", label: "Version",

View File

@ -13,8 +13,9 @@ import {
import { documentService, type FileAttachmentItem } from "@/services/document-service"; import { documentService, type FileAttachmentItem } from "@/services/document-service";
import { workflowService } from "@/services/workflow-service"; import { workflowService } from "@/services/workflow-service";
import type { DocumentDetail, DocumentVersion } from "@/types/document"; import type { DocumentDetail, DocumentVersion } from "@/types/document";
import type { WorkflowInstance } from "@/types/workflow";
import { showToast } from "@/utils/toast"; import { showToast } from "@/utils/toast";
import { Paperclip, Plus } from "lucide-react"; import { Paperclip, Plus, User } from "lucide-react";
const formatDateTime = (value?: string | null): string => { const formatDateTime = (value?: string | null): string => {
if (!value) return "-"; if (!value) return "-";
@ -53,9 +54,11 @@ const ViewDocument = (): ReactElement => {
const [versions, setVersions] = useState<DocumentVersion[]>([]); const [versions, setVersions] = useState<DocumentVersion[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<"overview" | "version-history">( const [activeTab, setActiveTab] = useState<"overview" | "version-history" | "workflow-history">(
"overview", "overview",
); );
const [workflowInstances, setWorkflowInstances] = useState<any[]>([]);
const [isHistoryLoading, setIsHistoryLoading] = useState(false);
const [activeAction, setActiveAction] = useState<DocumentAction | null>(null); const [activeAction, setActiveAction] = useState<DocumentAction | null>(null);
const [isActionLoading, setIsActionLoading] = useState(false); const [isActionLoading, setIsActionLoading] = useState(false);
@ -73,6 +76,9 @@ const ViewDocument = (): ReactElement => {
const [newVersionChangeSummary, setNewVersionChangeSummary] = useState(""); const [newVersionChangeSummary, setNewVersionChangeSummary] = useState("");
const [isMajorVersion, setIsMajorVersion] = useState(false); const [isMajorVersion, setIsMajorVersion] = useState(false);
const [isVersionSaving, setIsVersionSaving] = useState(false); const [isVersionSaving, setIsVersionSaving] = useState(false);
const [showWorkflowTracker, setShowWorkflowTracker] = useState(false);
const [workflowInstance, setWorkflowInstance] = useState<WorkflowInstance | null>(null);
const [isWorkflowLoading, setIsWorkflowLoading] = useState(false);
// File attachment fields for new version // File attachment fields for new version
const [versionFiles, setVersionFiles] = useState<FileAttachmentItem[]>([]); const [versionFiles, setVersionFiles] = useState<FileAttachmentItem[]>([]);
@ -111,6 +117,29 @@ const ViewDocument = (): ReactElement => {
void loadVersionFiles(); void loadVersionFiles();
}, [id]); }, [id]);
useEffect(() => {
if (activeTab === "workflow-history" && id) {
void loadWorkflowHistory();
}
}, [activeTab, id]);
const loadWorkflowHistory = async (): Promise<void> => {
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<void> => { const loadVersionFiles = async (): Promise<void> => {
setIsLoadingVersionFiles(true); setIsLoadingVersionFiles(true);
try { try {
@ -172,6 +201,26 @@ const ViewDocument = (): ReactElement => {
setVersions(versionsRes.data || []); setVersions(versionsRes.data || []);
}; };
const openWorkflowTracker = async (instanceId?: string): Promise<void> => {
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 => { const resetActionModal = (): void => {
setActiveAction(null); setActiveAction(null);
setWorkflowDefinitionId(""); setWorkflowDefinitionId("");
@ -319,6 +368,11 @@ const ViewDocument = (): ReactElement => {
label: "Change Summary", label: "Change Summary",
render: (version) => version.change_summary || "-", render: (version) => version.change_summary || "-",
}, },
{
key: "module_name",
label: "Module",
render: (version) => version.module_name || "Platform",
},
{ {
key: "created_by", key: "created_by",
label: "Author", label: "Author",
@ -373,6 +427,13 @@ const ViewDocument = (): ReactElement => {
)} )}
{document?.status === "in_review" && ( {document?.status === "in_review" && (
<> <>
<button
type="button"
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-[#112868] text-white text-xs font-medium hover:bg-[#0c1d4a]"
onClick={() => void openWorkflowTracker()}
>
Workflow Status
</button>
<button <button
type="button" type="button"
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-emerald-600 text-white text-xs font-medium hover:bg-emerald-700" className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-emerald-600 text-white text-xs font-medium hover:bg-emerald-700"
@ -419,6 +480,11 @@ const ViewDocument = (): ReactElement => {
<span className="px-2 py-1 rounded-full bg-blue-100 text-blue-700 text-[11px] font-medium"> <span className="px-2 py-1 rounded-full bg-blue-100 text-blue-700 text-[11px] font-medium">
v{document?.current_version || "-"} v{document?.current_version || "-"}
</span> </span>
{document?.module_name && (
<span className="px-2 py-1 rounded-full bg-purple-100 text-purple-700 text-[11px] font-medium">
{document.module_name}
</span>
)}
</div> </div>
</div> </div>
@ -438,6 +504,13 @@ const ViewDocument = (): ReactElement => {
> >
Version History Version History
</button> </button>
<button
type="button"
className={`text-sm pb-2 ${activeTab === "workflow-history" ? "text-[#084cc8] border-b-2 border-[#084cc8]" : "text-[#6b7280]"}`}
onClick={() => setActiveTab("workflow-history")}
>
Workflow History
</button>
</div> </div>
{isLoading ? ( {isLoading ? (
@ -622,6 +695,53 @@ const ViewDocument = (): ReactElement => {
</div> </div>
</div> </div>
)} )}
{activeTab === "workflow-history" && (
<div className="space-y-4">
<div className="border border-[rgba(0,0,0,0.08)] rounded-lg overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-[10px] font-bold text-gray-400 uppercase tracking-wider">Workflow</th>
<th className="px-4 py-3 text-left text-[10px] font-bold text-gray-400 uppercase tracking-wider">Current Step</th>
<th className="px-4 py-3 text-left text-[10px] font-bold text-gray-400 uppercase tracking-wider">Status</th>
<th className="px-4 py-3 text-left text-[10px] font-bold text-gray-400 uppercase tracking-wider">Started At</th>
<th className="px-4 py-3 text-right text-[10px] font-bold text-gray-400 uppercase tracking-wider font-bold">Action</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-100">
{isHistoryLoading ? (
<tr><td colSpan={5} className="px-4 py-4 text-center text-xs text-gray-500">Loading history...</td></tr>
) : workflowInstances.length === 0 ? (
<tr><td colSpan={5} className="px-4 py-4 text-center text-xs text-gray-500">No workflow history found.</td></tr>
) : workflowInstances.map((inst) => (
<tr key={inst.id}>
<td className="px-4 py-3 whitespace-nowrap text-xs font-semibold text-gray-800">{inst.workflow_name}</td>
<td className="px-4 py-3 whitespace-nowrap text-xs text-gray-600">{inst.current_step || '-'}</td>
<td className="px-4 py-3 whitespace-nowrap">
<span className={`inline-flex items-center px-1.5 py-0.5 rounded text-[9px] font-bold uppercase ${
inst.status === 'completed' ? 'bg-emerald-100 text-emerald-700' :
inst.status === 'active' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-700'
}`}>
{inst.status}
</span>
</td>
<td className="px-4 py-3 whitespace-nowrap text-xs text-gray-500">{formatDateTime(inst.started_at)}</td>
<td className="px-4 py-3 whitespace-nowrap text-right">
<button
type="button"
className="text-xs font-bold text-[#112868] hover:underline"
onClick={() => void openWorkflowTracker(inst.id)}
>
View Tracker
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</> </>
) : null} ) : null}
<div className="mt-4"> <div className="mt-4">
@ -725,6 +845,112 @@ const ViewDocument = (): ReactElement => {
)} )}
</div> </div>
</Modal> </Modal>
{/* Workflow Instance Tracker Modal */}
<Modal
isOpen={showWorkflowTracker}
onClose={() => setShowWorkflowTracker(false)}
title="Workflow Status Tracker"
description="Track the progress of this document's approval workflow."
maxWidth="2xl"
>
<div className="p-6">
{isWorkflowLoading ? (
<div className="flex flex-col items-center justify-center py-10 space-y-3">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[#112868]"></div>
<p className="text-sm text-gray-500 font-medium">Loading workflow details...</p>
</div>
) : workflowInstance ? (
<div className="space-y-6">
{/* Workflow Header Info */}
<div className="grid grid-cols-2 gap-4 p-4 bg-gray-50 rounded-lg border border-gray-100">
<div>
<label className="text-[10px] uppercase tracking-wider font-bold text-gray-400 block">Workflow</label>
<p className="text-sm font-semibold text-gray-800">{workflowInstance.workflow.name}</p>
</div>
<div>
<label className="text-[10px] uppercase tracking-wider font-bold text-gray-400 block">Current Status</label>
<span className={`inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold uppercase ${
workflowInstance.status === 'completed' ? 'bg-emerald-100 text-emerald-700' :
workflowInstance.status === 'active' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-700'
}`}>
{workflowInstance.status}
</span>
</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>
</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>
{/* Tasks Table View */}
<div className="overflow-hidden border border-gray-100 rounded-lg">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th scope="col" className="px-4 py-3 text-left text-[10px] font-bold text-gray-400 uppercase tracking-wider">Step</th>
<th scope="col" className="px-4 py-3 text-left text-[10px] font-bold text-gray-400 uppercase tracking-wider">Assignee</th>
<th scope="col" className="px-4 py-3 text-left text-[10px] font-bold text-gray-400 uppercase tracking-wider">Status</th>
<th scope="col" className="px-4 py-3 text-left text-[10px] font-bold text-gray-400 uppercase tracking-wider">Action</th>
<th scope="col" className="px-4 py-3 text-left text-[10px] font-bold text-gray-400 uppercase tracking-wider">Completed At</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-100">
{workflowInstance.tasks.map((task) => (
<tr key={task.id} className={task.status === 'pending' ? 'bg-blue-50/30' : ''}>
<td className="px-4 py-3 whitespace-nowrap">
<span className={`text-xs font-bold ${task.status === 'pending' ? 'text-blue-700' : 'text-gray-800'}`}>
{task.step}
</span>
</td>
<td className="px-4 py-3 whitespace-nowrap">
<div className="flex items-center gap-1.5 text-xs text-gray-600">
<User className="w-3 h-3 text-gray-400" />
{task.assigned_to}
</div>
</td>
<td className="px-4 py-3 whitespace-nowrap">
<span className={`inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold uppercase ${
task.status === 'completed' ? 'bg-emerald-100 text-emerald-700' :
task.status === 'pending' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-700'
}`}>
{task.status}
</span>
</td>
<td className="px-4 py-3 whitespace-nowrap">
{task.action_taken ? (
<span className="px-1.5 py-0.5 bg-gray-100 text-gray-700 rounded font-bold uppercase text-[9px]">
{task.action_taken}
</span>
) : (
<span className="text-gray-400 text-[10px] italic">No action</span>
)}
</td>
<td className="px-4 py-3 whitespace-nowrap text-[11px] text-gray-500 font-medium">
{task.completed_at ? formatDateTime(task.completed_at) : '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : (
<div className="text-center py-10">
<p className="text-sm text-gray-500">No workflow tracking information available.</p>
</div>
)}
<div className="mt-8 flex justify-end">
<SecondaryButton onClick={() => setShowWorkflowTracker(false)}>
Close Tracker
</SecondaryButton>
</div>
</div>
</Modal>
</Layout> </Layout>
); );
}; };

View File

@ -34,6 +34,8 @@ export interface CreateDocumentPayload {
file_size?: number; file_size?: number;
mime_type?: string; mime_type?: string;
file_hash?: string; file_hash?: string;
source_module?: string;
source_module_id?: string;
} }
export interface FileAttachmentItem { export interface FileAttachmentItem {
@ -72,6 +74,8 @@ export interface CreateDocumentVersionPayload {
file_size?: number; file_size?: number;
mime_type?: string; mime_type?: string;
file_hash?: string; file_hash?: string;
source_module?: string;
source_module_id?: string;
} }
export const documentService = { export const documentService = {

View File

@ -4,6 +4,7 @@ import type {
UpdateWorkflowDefinitionData, UpdateWorkflowDefinitionData,
WorkflowDefinitionsResponse, WorkflowDefinitionsResponse,
WorkflowDefinitionResponse, WorkflowDefinitionResponse,
WorkflowInstanceResponse,
WorkflowDeleteResponse WorkflowDeleteResponse
} from '@/types/workflow'; } from '@/types/workflow';
@ -35,6 +36,25 @@ class WorkflowService {
return response.data; return response.data;
} }
async getInstance(id: string, tenantId?: string): Promise<WorkflowInstanceResponse> {
const params = tenantId ? { tenantId: tenantId } : {};
const response = await apiClient.get<WorkflowInstanceResponse>(`${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<WorkflowDefinitionResponse> { async createDefinition(data: CreateWorkflowDefinitionData, tenantId?: string): Promise<WorkflowDefinitionResponse> {
const params = tenantId ? { tenantId: tenantId } : {}; const params = tenantId ? { tenantId: tenantId } : {};
const response = await apiClient.post<WorkflowDefinitionResponse>(`${this.baseUrl}/definitions`, data, { params }); const response = await apiClient.post<WorkflowDefinitionResponse>(`${this.baseUrl}/definitions`, data, { params });

View File

@ -22,6 +22,7 @@ export interface DocumentSummary {
owner?: string | null; owner?: string | null;
next_review_date?: string | null; next_review_date?: string | null;
updated_at?: string; updated_at?: string;
module_name?: string | null;
} }
export interface DocumentDetail { export interface DocumentDetail {
@ -54,6 +55,7 @@ export interface DocumentDetail {
created_by?: string | null; created_by?: string | null;
created_at?: string; created_at?: string;
updated_at?: string; updated_at?: string;
module_name?: string | null;
} }
export interface DocumentVersion { export interface DocumentVersion {
@ -70,6 +72,7 @@ export interface DocumentVersion {
approved_at?: string | null; approved_at?: string | null;
created_by?: string | null; created_by?: string | null;
created_at?: string; created_at?: string;
module_name?: string | null;
} }
export interface DocumentListResponse { export interface DocumentListResponse {

View File

@ -98,6 +98,57 @@ export interface WorkflowDefinitionResponse {
data: WorkflowDefinition; 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<string, any>;
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 { export interface WorkflowDeleteResponse {
success: boolean; success: boolean;
message: string; message: string;