diff --git a/src/components/shared/WorkflowDefinitionsTable.tsx b/src/components/shared/WorkflowDefinitionsTable.tsx index 3af5566..22f23bb 100644 --- a/src/components/shared/WorkflowDefinitionsTable.tsx +++ b/src/components/shared/WorkflowDefinitionsTable.tsx @@ -232,7 +232,7 @@ const WorkflowDefinitionsTable = ({ key: "source_module", label: "Module", render: (wf) => ( - {wf.source_module} + {wf.source_module?.join(", ")} ), }, { diff --git a/src/pages/tenant/CreateDocument.tsx b/src/pages/tenant/CreateDocument.tsx index 1e18577..e7efb93 100644 --- a/src/pages/tenant/CreateDocument.tsx +++ b/src/pages/tenant/CreateDocument.tsx @@ -1,5 +1,8 @@ import { useEffect, useState, type ReactElement } from "react"; import { useNavigate } from "react-router-dom"; +import { useForm, Controller } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; import { Layout } from "@/components/layout/Layout"; import { FormField, FormSelect, FormTextArea, PrimaryButton, RichTextEditor } from "@/components/shared"; import { documentService, type FileAttachmentItem } from "@/services/document-service"; @@ -9,22 +12,50 @@ import { ArrowLeft, FileText, Info, Paperclip } from "lucide-react"; import { moduleService } from "@/services/module-service"; import type { MyModule } from "@/types/module"; +const documentSchema = z.object({ + title: z.string().min(1, "Document title is required"), + document_number: z.string().optional(), + description: z.string().optional(), + document_type: z.string().min(1, "Document type is required"), + category_id: z.string().optional(), + department: z.string().optional(), + tags: z.string().optional(), + selectedModuleId: z.string().min(1, "Source module is required"), + content: z.string().optional(), + contentHtml: z.string().min(1, "Document content is required"), +}); + +type DocumentFormData = z.infer; + const CreateDocument = (): ReactElement => { const navigate = useNavigate(); - const [title, setTitle] = useState(""); - const [description, setDescription] = useState(""); - const [documentNumber, setDocumentNumber] = useState(""); - const [documentType, setDocumentType] = useState(""); - const [categoryId, setCategoryId] = useState(""); - const [department, setDepartment] = useState(""); - const [tags, setTags] = useState(""); - const [content, setContent] = useState(""); - const [contentHtml, setContentHtml] = useState(""); const [isSaving, setIsSaving] = useState(false); const [types, setTypes] = useState>([]); const [categories, setCategories] = useState([]); const [modules, setModules] = useState([]); - const [selectedModuleId, setSelectedModuleId] = useState(""); + + const { + control, + register, + handleSubmit, + setValue, + watch, + formState: { errors }, + } = useForm({ + resolver: zodResolver(documentSchema), + defaultValues: { + title: "", + document_number: "", + description: "", + document_type: "", + category_id: "", + department: "", + tags: "", + selectedModuleId: "", + content: "", + contentHtml: "", + }, + }); // File attachment fields const [files, setFiles] = useState([]); @@ -70,34 +101,31 @@ const CreateDocument = (): ReactElement => { const handleFileSelect = async (fileId: string): Promise => { setSelectedFileId(fileId); if (!fileId) { - // Clear file fields when deselected setFileName(""); setFilePath(""); setFileSize(undefined); setMimeType(""); setFileHash(""); - setContentHtml(""); - setContent(""); + setValue("contentHtml", ""); + setValue("content", ""); return; } const selected = files.find((f) => f.id === fileId); if (!selected) return; - // Populate file metadata fields setFileName(selected.original_name); setFilePath(selected.file_path); setFileSize(selected.file_size); setMimeType(selected.mime_type); setFileHash(selected.checksum); - // Extract actual file content from backend try { showToast.success(`Extracting content from "${selected.original_name}"...`); const res = await documentService.getFileContent(fileId); if (res.success && res.data) { - setContentHtml(res.data.html || ""); - setContent(res.data.text || ""); + setValue("contentHtml", res.data.html || ""); + setValue("content", res.data.text || ""); showToast.success(`Content loaded from "${selected.original_name}"`); } else { showToast.error("Failed to extract file content"); @@ -105,42 +133,37 @@ const CreateDocument = (): ReactElement => { } catch (err: any) { const msg = err?.response?.data?.error?.message || "Failed to extract file content"; showToast.error(msg); - // Fallback: set a placeholder const html = `

Document sourced from file: ${selected.original_name}

`; - setContentHtml(html); - setContent(`Document sourced from file: ${selected.original_name}`); + setValue("contentHtml", html); + setValue("content", `Document sourced from file: ${selected.original_name}`); } }; - const onSubmit = async (event: React.FormEvent): Promise => { - event.preventDefault(); - if (!title.trim() || !documentType) { - showToast.error("Title and document type are required"); - return; - } - + const onFormSubmit = async (data: DocumentFormData): Promise => { try { setIsSaving(true); const response = await documentService.create({ - title: title.trim(), - description: description.trim() || undefined, - document_number: documentNumber.trim() || undefined, - document_type: documentType, - category_id: categoryId || undefined, - department: department.trim() || undefined, - tags: tags - .split(",") - .map((tag) => tag.trim()) - .filter(Boolean), - content: content.trim() || undefined, - content_html: contentHtml.trim() || undefined, + title: data.title.trim(), + description: data.description?.trim() || undefined, + document_number: data.document_number?.trim() || undefined, + document_type: data.document_type, + category_id: data.category_id || undefined, + department: data.department?.trim() || undefined, + tags: data.tags + ? data.tags + .split(",") + .map((tag) => tag.trim()) + .filter(Boolean) + : [], + content: data.content?.trim() || undefined, + content_html: data.contentHtml.trim() || undefined, file_name: fileName || undefined, file_path: filePath || undefined, 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, + source_module: modules.find((m) => m.id === data.selectedModuleId)!.module_id, + source_module_id: data.selectedModuleId, }); showToast.success("Document created successfully"); navigate(`/tenant/documents/${response.data.id}`); @@ -171,7 +194,7 @@ const CreateDocument = (): ReactElement => { ], }} > -
+
@@ -201,23 +224,23 @@ const CreateDocument = (): ReactElement => { setTitle(e.target.value)} placeholder="Enter document title" + error={errors.title?.message} + {...register("title")} /> setDocumentNumber(e.target.value)} placeholder="Auto-generated if empty" + error={errors.document_number?.message} + {...register("document_number")} />
setDescription(e.target.value)} placeholder="Brief description of this document" + error={errors.description?.message} + {...register("description")} />
@@ -226,48 +249,67 @@ const CreateDocument = (): ReactElement => { Classification
- ({ value: type.code, label: type.name }))} - placeholder="Select type" + ( + ({ value: type.code, label: type.name }))} + placeholder="Select type" + error={errors.document_type?.message} + /> + )} /> - ({ - value: category.id, - label: `${category.name} (${category.code})`, - }))} - placeholder="Select category" + ( + ({ + value: category.id, + label: `${category.name} (${category.code})`, + }))} + placeholder="Select category" + error={errors.category_id?.message} + /> + )} /> setDepartment(e.target.value)} placeholder="Optional" + error={errors.department?.message} + {...register("department")} /> setTags(e.target.value)} placeholder="Comma separated tags (e.g. quality, sop)" + error={errors.tags?.message} + {...register("tags")} /> - ({ - value: m.id, - label: m.name, - })), - ]} - placeholder="Specify originating module (Optional)" + ( + ({ + value: m.id, + label: m.name, + }))} + placeholder="Select originating module" + error={errors.selectedModuleId?.message} + /> + )} />
@@ -309,19 +351,26 @@ const CreateDocument = (): ReactElement => {

Initial Content

- {content.length} characters + {watch("content")?.length || 0} characters
- { - setContentHtml(html); - setContent(text); - }} + ( + { + field.onChange(html); + setValue("content", text); + }} + error={errors.contentHtml?.message} + /> + )} />
@@ -341,13 +390,18 @@ const CreateDocument = (): ReactElement => { Cancel - + {isSaving ? "Creating..." : "Create Document"}
diff --git a/src/pages/tenant/Documents.tsx b/src/pages/tenant/Documents.tsx index ed6383b..63433de 100644 --- a/src/pages/tenant/Documents.tsx +++ b/src/pages/tenant/Documents.tsx @@ -156,6 +156,22 @@ const Documents = (): ReactElement => { {formatDate(doc.updated_at)} ), }, + { + key: "actions", + label: "Actions", + render: (doc) => ( + + ), + }, ], [navigate], ); diff --git a/src/pages/tenant/EditDocument.tsx b/src/pages/tenant/EditDocument.tsx new file mode 100644 index 0000000..f814f93 --- /dev/null +++ b/src/pages/tenant/EditDocument.tsx @@ -0,0 +1,266 @@ +import { useEffect, useState, type ReactElement } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { useForm, Controller } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; +import { Layout } from "@/components/layout/Layout"; +import { FormField, FormSelect, FormTextArea, PrimaryButton } from "@/components/shared"; +import { documentService } from "@/services/document-service"; +import type { DocumentCategory } from "@/types/document"; +import { showToast } from "@/utils/toast"; +import { ArrowLeft, FileText } from "lucide-react"; +import { moduleService } from "@/services/module-service"; +import type { MyModule } from "@/types/module"; + +const documentSchema = z.object({ + title: z.string().min(1, "Document title is required"), + description: z.string().optional(), + category_id: z.string().optional(), + department: z.string().optional(), + tags: z.string().optional(), + selectedModuleId: z.string().min(1, "Source module is required"), +}); + +type DocumentFormData = z.infer; + +const EditDocument = (): ReactElement => { + const navigate = useNavigate(); + const { id } = useParams<{ id: string }>(); + const [isSaving, setIsSaving] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [categories, setCategories] = useState([]); + const [modules, setModules] = useState([]); + + const { + control, + register, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ + resolver: zodResolver(documentSchema), + defaultValues: { + title: "", + description: "", + category_id: "", + department: "", + tags: "", + selectedModuleId: "", + }, + }); + + useEffect(() => { + const loadData = async (): Promise => { + if (!id) return; + try { + setIsLoading(true); + const [categoriesRes, modulesRes, docRes] = await Promise.all([ + documentService.getCategories(), + moduleService.getMyModules(), + documentService.getById(id), + ]); + + setCategories(categoriesRes.data || []); + const myModules = modulesRes.data || []; + setModules(myModules); + + const doc = docRes.data; + // Find matching module by id (UUID) or module_id (code) + const matchedModule = myModules.find(m => + (doc.source_module_id && m.id === doc.source_module_id) || + (doc.source_module && m.module_id === doc.source_module) + ); + + reset({ + title: doc.title, + description: doc.description || "", + category_id: doc.category_id || "", + department: doc.department || "", + tags: (doc.tags || []).join(", "), + selectedModuleId: matchedModule?.id || "", + }); + } catch (err: any) { + showToast.error(err?.response?.data?.error?.message || "Failed to load document data"); + navigate("/tenant/documents"); + } finally { + setIsLoading(false); + } + }; + void loadData(); + }, [id, reset, navigate]); + + const onFormSubmit = async (data: DocumentFormData): Promise => { + if (!id) return; + try { + setIsSaving(true); + await documentService.update(id, { + title: data.title.trim(), + description: data.description?.trim() || undefined, + category_id: data.category_id || undefined, + department: data.department?.trim() || undefined, + tags: data.tags + ? data.tags + .split(",") + .map((tag) => tag.trim()) + .filter(Boolean) + : [], + source_module: modules.find((m) => m.id === data.selectedModuleId)!.module_id, + source_module_id: data.selectedModuleId, + }); + showToast.success("Document updated successfully"); + navigate(`/tenant/documents/${id}`); + } catch (err: any) { + showToast.error( + err?.response?.data?.error?.message || "Failed to update document", + ); + } finally { + setIsSaving(false); + } + }; + + if (isLoading) { + return ( + +
+

Loading document details...

+
+
+ ); + } + + return ( + + +
+
+
+
+ +
+
+

+ Edit Document Metadata +

+

+ Updates will apply to the current version of the document. +

+
+
+ +
+ +
+ +
+ + +
+ +
+

+ Classification +

+
+ ( + ({ + value: category.id, + label: `${category.name} (${category.code})`, + }))} + placeholder="Select category" + error={errors.category_id?.message} + /> + )} + /> + + + ( + ({ + value: m.id, + label: m.name, + }))} + placeholder="Select originating module" + error={errors.selectedModuleId?.message} + /> + )} + /> +
+
+ + +
+
+ + + {isSaving ? "Updating..." : "Update Document"} + +
+
+ +
+ ); +}; + +export default EditDocument; diff --git a/src/pages/tenant/ViewDocument.tsx b/src/pages/tenant/ViewDocument.tsx index 04f91ed..133ef7f 100644 --- a/src/pages/tenant/ViewDocument.tsx +++ b/src/pages/tenant/ViewDocument.tsx @@ -75,6 +75,7 @@ const ViewDocument = (): ReactElement => { const [newVersionChangeReason, setNewVersionChangeReason] = useState("minor_edit"); const [newVersionChangeSummary, setNewVersionChangeSummary] = useState(""); const [isMajorVersion, setIsMajorVersion] = useState(false); + const [versionErrors, setVersionErrors] = useState>({}); const [isVersionSaving, setIsVersionSaving] = useState(false); const [showWorkflowTracker, setShowWorkflowTracker] = useState(false); const [workflowInstance, setWorkflowInstance] = useState(null); @@ -244,6 +245,7 @@ const ViewDocument = (): ReactElement => { const response = await workflowService.listDefinitions({ status: "active", entity_type: "document", + source_module_id: document?.source_module_id || undefined, limit: 100, offset: 0, }); @@ -320,12 +322,20 @@ const ViewDocument = (): ReactElement => { const handleCreateVersion = async (): Promise => { if (!id) return; + + // Clear previous errors + setVersionErrors({}); + const localErrors: Record = {}; + if (!newVersionChangeReason) { - showToast.error("Change reason is required"); - return; + localErrors.change_reason = "Change reason is required"; } if (!newVersionContent.trim()) { - showToast.error("Document content is required"); + localErrors.content = "Document content is required"; + } + + if (Object.keys(localErrors).length > 0) { + setVersionErrors(localErrors); return; } @@ -462,13 +472,22 @@ const ViewDocument = (): ReactElement => {
{document?.status === "draft" && ( - +
+ + +
)} {document?.status === "in_review" && ( <> @@ -675,7 +694,9 @@ const ViewDocument = (): ReactElement => { onChange={(html, text) => { setNewVersionContentHtml(html); setNewVersionContent(text); + if (text.trim()) setVersionErrors(prev => ({ ...prev, content: "" })); }} + error={versionErrors.content} />
{ { value: "major_rewrite", label: "major_rewrite" }, ]} value={newVersionChangeReason} - onValueChange={setNewVersionChangeReason} + onValueChange={(val) => { + setNewVersionChangeReason(val); + if (val) setVersionErrors(prev => ({ ...prev, change_reason: "" })); + }} placeholder="Select change reason" + error={versionErrors.change_reason} />
{ >
{activeAction === "submit" && ( - +
+ + {/*
+
+ Entity Type: + + Document + +
+
+ Source Module: + + {document?.module_name || "Platform"} + +
+
*/} +
+ Note: Currently displaying active workflow definitions registered for + Entity Type: Document and + Module: {document?.module_name || "Platform"}. + If the list is empty, please go to Workflow Management and create a definition for this specific configuration. +
+
)} {activeAction === "approve" && (
diff --git a/src/routes/tenant-admin-routes.tsx b/src/routes/tenant-admin-routes.tsx index 583e80e..0cd41da 100644 --- a/src/routes/tenant-admin-routes.tsx +++ b/src/routes/tenant-admin-routes.tsx @@ -16,6 +16,7 @@ const WorkflowDefination = lazy( const Suppliers = lazy(() => import("@/pages/tenant/Suppliers")); const Documents = lazy(() => import("@/pages/tenant/Documents")); const CreateDocument = lazy(() => import("@/pages/tenant/CreateDocument")); +const EditDocument = lazy(() => import("@/pages/tenant/EditDocument")); const ViewDocument = lazy(() => import("@/pages/tenant/ViewDocument")); const DocumentCategories = lazy(() => import("@/pages/tenant/DocumentCategories")); const Tasks = lazy(() => import("@/pages/tenant/Tasks")); @@ -93,6 +94,10 @@ export const tenantAdminRoutes: RouteConfig[] = [ path: "/tenant/documents/create", element: , }, + { + path: "/tenant/documents/edit/:id", + element: , + }, { path: "/tenant/documents/:id", element: , diff --git a/src/types/document.ts b/src/types/document.ts index fa8e554..3a5d89e 100644 --- a/src/types/document.ts +++ b/src/types/document.ts @@ -55,6 +55,9 @@ export interface DocumentDetail { created_by?: string | null; created_at?: string; updated_at?: string; + source_module?: string | null; + source_module_id?: string | null; + category_id?: string | null; module_name?: string | null; }