feat: implement document editing functionality with metadata and classification updates
This commit is contained in:
parent
7a368b09bc
commit
14bb57a574
@ -232,7 +232,7 @@ const WorkflowDefinitionsTable = ({
|
|||||||
key: "source_module",
|
key: "source_module",
|
||||||
label: "Module",
|
label: "Module",
|
||||||
render: (wf) => (
|
render: (wf) => (
|
||||||
<span className="text-sm text-[#6b7280]">{wf.source_module}</span>
|
<span className="text-sm text-[#6b7280]">{wf.source_module?.join(", ")}</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
import { useEffect, useState, type ReactElement } from "react";
|
import { useEffect, useState, type ReactElement } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
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 { Layout } from "@/components/layout/Layout";
|
||||||
import { FormField, FormSelect, FormTextArea, PrimaryButton, RichTextEditor } from "@/components/shared";
|
import { FormField, FormSelect, FormTextArea, PrimaryButton, RichTextEditor } from "@/components/shared";
|
||||||
import { documentService, type FileAttachmentItem } from "@/services/document-service";
|
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 { moduleService } from "@/services/module-service";
|
||||||
import type { MyModule } from "@/types/module";
|
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<typeof documentSchema>;
|
||||||
|
|
||||||
const CreateDocument = (): ReactElement => {
|
const CreateDocument = (): ReactElement => {
|
||||||
const navigate = useNavigate();
|
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 [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 [modules, setModules] = useState<MyModule[]>([]);
|
||||||
const [selectedModuleId, setSelectedModuleId] = useState("");
|
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
setValue,
|
||||||
|
watch,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<DocumentFormData>({
|
||||||
|
resolver: zodResolver(documentSchema),
|
||||||
|
defaultValues: {
|
||||||
|
title: "",
|
||||||
|
document_number: "",
|
||||||
|
description: "",
|
||||||
|
document_type: "",
|
||||||
|
category_id: "",
|
||||||
|
department: "",
|
||||||
|
tags: "",
|
||||||
|
selectedModuleId: "",
|
||||||
|
content: "",
|
||||||
|
contentHtml: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// File attachment fields
|
// File attachment fields
|
||||||
const [files, setFiles] = useState<FileAttachmentItem[]>([]);
|
const [files, setFiles] = useState<FileAttachmentItem[]>([]);
|
||||||
@ -70,34 +101,31 @@ const CreateDocument = (): ReactElement => {
|
|||||||
const handleFileSelect = async (fileId: string): Promise<void> => {
|
const handleFileSelect = async (fileId: string): Promise<void> => {
|
||||||
setSelectedFileId(fileId);
|
setSelectedFileId(fileId);
|
||||||
if (!fileId) {
|
if (!fileId) {
|
||||||
// Clear file fields when deselected
|
|
||||||
setFileName("");
|
setFileName("");
|
||||||
setFilePath("");
|
setFilePath("");
|
||||||
setFileSize(undefined);
|
setFileSize(undefined);
|
||||||
setMimeType("");
|
setMimeType("");
|
||||||
setFileHash("");
|
setFileHash("");
|
||||||
setContentHtml("");
|
setValue("contentHtml", "");
|
||||||
setContent("");
|
setValue("content", "");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const selected = files.find((f) => f.id === fileId);
|
const selected = files.find((f) => f.id === fileId);
|
||||||
if (!selected) return;
|
if (!selected) return;
|
||||||
|
|
||||||
// Populate file metadata fields
|
|
||||||
setFileName(selected.original_name);
|
setFileName(selected.original_name);
|
||||||
setFilePath(selected.file_path);
|
setFilePath(selected.file_path);
|
||||||
setFileSize(selected.file_size);
|
setFileSize(selected.file_size);
|
||||||
setMimeType(selected.mime_type);
|
setMimeType(selected.mime_type);
|
||||||
setFileHash(selected.checksum);
|
setFileHash(selected.checksum);
|
||||||
|
|
||||||
// Extract actual file content from backend
|
|
||||||
try {
|
try {
|
||||||
showToast.success(`Extracting content from "${selected.original_name}"...`);
|
showToast.success(`Extracting content from "${selected.original_name}"...`);
|
||||||
const res = await documentService.getFileContent(fileId);
|
const res = await documentService.getFileContent(fileId);
|
||||||
if (res.success && res.data) {
|
if (res.success && res.data) {
|
||||||
setContentHtml(res.data.html || "");
|
setValue("contentHtml", res.data.html || "");
|
||||||
setContent(res.data.text || "");
|
setValue("content", res.data.text || "");
|
||||||
showToast.success(`Content loaded from "${selected.original_name}"`);
|
showToast.success(`Content loaded from "${selected.original_name}"`);
|
||||||
} else {
|
} else {
|
||||||
showToast.error("Failed to extract file content");
|
showToast.error("Failed to extract file content");
|
||||||
@ -105,42 +133,37 @@ const CreateDocument = (): ReactElement => {
|
|||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const msg = err?.response?.data?.error?.message || "Failed to extract file content";
|
const msg = err?.response?.data?.error?.message || "Failed to extract file content";
|
||||||
showToast.error(msg);
|
showToast.error(msg);
|
||||||
// Fallback: set a placeholder
|
|
||||||
const html = `<p>Document sourced from file: <strong>${selected.original_name}</strong></p>`;
|
const html = `<p>Document sourced from file: <strong>${selected.original_name}</strong></p>`;
|
||||||
setContentHtml(html);
|
setValue("contentHtml", html);
|
||||||
setContent(`Document sourced from file: ${selected.original_name}`);
|
setValue("content", `Document sourced from file: ${selected.original_name}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSubmit = async (event: React.FormEvent): Promise<void> => {
|
const onFormSubmit = async (data: DocumentFormData): Promise<void> => {
|
||||||
event.preventDefault();
|
|
||||||
if (!title.trim() || !documentType) {
|
|
||||||
showToast.error("Title and document type are required");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
const response = await documentService.create({
|
const response = await documentService.create({
|
||||||
title: title.trim(),
|
title: data.title.trim(),
|
||||||
description: description.trim() || undefined,
|
description: data.description?.trim() || undefined,
|
||||||
document_number: documentNumber.trim() || undefined,
|
document_number: data.document_number?.trim() || undefined,
|
||||||
document_type: documentType,
|
document_type: data.document_type,
|
||||||
category_id: categoryId || undefined,
|
category_id: data.category_id || undefined,
|
||||||
department: department.trim() || undefined,
|
department: data.department?.trim() || undefined,
|
||||||
tags: tags
|
tags: data.tags
|
||||||
.split(",")
|
? data.tags
|
||||||
.map((tag) => tag.trim())
|
.split(",")
|
||||||
.filter(Boolean),
|
.map((tag) => tag.trim())
|
||||||
content: content.trim() || undefined,
|
.filter(Boolean)
|
||||||
content_html: contentHtml.trim() || undefined,
|
: [],
|
||||||
|
content: data.content?.trim() || undefined,
|
||||||
|
content_html: data.contentHtml.trim() || undefined,
|
||||||
file_name: fileName || undefined,
|
file_name: fileName || undefined,
|
||||||
file_path: filePath || undefined,
|
file_path: filePath || undefined,
|
||||||
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: modules.find((m) => m.id === data.selectedModuleId)!.module_id,
|
||||||
source_module_id: selectedModuleId || undefined,
|
source_module_id: data.selectedModuleId,
|
||||||
});
|
});
|
||||||
showToast.success("Document created successfully");
|
showToast.success("Document created successfully");
|
||||||
navigate(`/tenant/documents/${response.data.id}`);
|
navigate(`/tenant/documents/${response.data.id}`);
|
||||||
@ -171,7 +194,7 @@ const CreateDocument = (): ReactElement => {
|
|||||||
],
|
],
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<form onSubmit={onSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit(onFormSubmit)} className="space-y-4">
|
||||||
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] p-4 md:p-5">
|
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] p-4 md:p-5">
|
||||||
<div className="flex items-start justify-between gap-4 border-b border-[rgba(0,0,0,0.08)] pb-4 mb-4">
|
<div className="flex items-start justify-between gap-4 border-b border-[rgba(0,0,0,0.08)] pb-4 mb-4">
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
@ -201,23 +224,23 @@ const CreateDocument = (): ReactElement => {
|
|||||||
<FormField
|
<FormField
|
||||||
label="Document Title"
|
label="Document Title"
|
||||||
required
|
required
|
||||||
value={title}
|
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
|
||||||
placeholder="Enter document title"
|
placeholder="Enter document title"
|
||||||
|
error={errors.title?.message}
|
||||||
|
{...register("title")}
|
||||||
/>
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
label="Document Number"
|
label="Document Number"
|
||||||
value={documentNumber}
|
|
||||||
onChange={(e) => setDocumentNumber(e.target.value)}
|
|
||||||
placeholder="Auto-generated if empty"
|
placeholder="Auto-generated if empty"
|
||||||
|
error={errors.document_number?.message}
|
||||||
|
{...register("document_number")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FormTextArea
|
<FormTextArea
|
||||||
label="Description"
|
label="Description"
|
||||||
value={description}
|
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
|
||||||
placeholder="Brief description of this document"
|
placeholder="Brief description of this document"
|
||||||
|
error={errors.description?.message}
|
||||||
|
{...register("description")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -226,48 +249,67 @@ const CreateDocument = (): ReactElement => {
|
|||||||
Classification
|
Classification
|
||||||
</h3>
|
</h3>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-4">
|
||||||
<FormSelect
|
<Controller
|
||||||
label="Document Type"
|
name="document_type"
|
||||||
required
|
control={control}
|
||||||
value={documentType}
|
render={({ field }) => (
|
||||||
onValueChange={setDocumentType}
|
<FormSelect
|
||||||
options={types.map((type) => ({ value: type.code, label: type.name }))}
|
label="Document Type"
|
||||||
placeholder="Select type"
|
required
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
options={types.map((type) => ({ value: type.code, label: type.name }))}
|
||||||
|
placeholder="Select type"
|
||||||
|
error={errors.document_type?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
<FormSelect
|
<Controller
|
||||||
label="Category"
|
name="category_id"
|
||||||
value={categoryId}
|
control={control}
|
||||||
onValueChange={setCategoryId}
|
render={({ field }) => (
|
||||||
options={categories.map((category) => ({
|
<FormSelect
|
||||||
value: category.id,
|
label="Category"
|
||||||
label: `${category.name} (${category.code})`,
|
value={field.value}
|
||||||
}))}
|
onValueChange={field.onChange}
|
||||||
placeholder="Select category"
|
options={categories.map((category) => ({
|
||||||
|
value: category.id,
|
||||||
|
label: `${category.name} (${category.code})`,
|
||||||
|
}))}
|
||||||
|
placeholder="Select category"
|
||||||
|
error={errors.category_id?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
label="Department"
|
label="Department"
|
||||||
value={department}
|
|
||||||
onChange={(e) => setDepartment(e.target.value)}
|
|
||||||
placeholder="Optional"
|
placeholder="Optional"
|
||||||
|
error={errors.department?.message}
|
||||||
|
{...register("department")}
|
||||||
/>
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
label="Tags"
|
label="Tags"
|
||||||
value={tags}
|
|
||||||
onChange={(e) => setTags(e.target.value)}
|
|
||||||
placeholder="Comma separated tags (e.g. quality, sop)"
|
placeholder="Comma separated tags (e.g. quality, sop)"
|
||||||
|
error={errors.tags?.message}
|
||||||
|
{...register("tags")}
|
||||||
/>
|
/>
|
||||||
<FormSelect
|
<Controller
|
||||||
label="Source Module"
|
name="selectedModuleId"
|
||||||
value={selectedModuleId}
|
control={control}
|
||||||
onValueChange={setSelectedModuleId}
|
render={({ field }) => (
|
||||||
options={[
|
<FormSelect
|
||||||
{ value: "", label: "Platform (Default)" },
|
label="Source Module"
|
||||||
...modules.map((m) => ({
|
required
|
||||||
value: m.id,
|
value={field.value}
|
||||||
label: m.name,
|
onValueChange={field.onChange}
|
||||||
})),
|
options={modules.map((m) => ({
|
||||||
]}
|
value: m.id,
|
||||||
placeholder="Specify originating module (Optional)"
|
label: m.name,
|
||||||
|
}))}
|
||||||
|
placeholder="Select originating module"
|
||||||
|
error={errors.selectedModuleId?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -309,19 +351,26 @@ const CreateDocument = (): ReactElement => {
|
|||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h3 className="text-sm font-semibold text-[#0f1724]">Initial Content</h3>
|
<h3 className="text-sm font-semibold text-[#0f1724]">Initial Content</h3>
|
||||||
<span className="text-[11px] text-[#94a3b8]">
|
<span className="text-[11px] text-[#94a3b8]">
|
||||||
{content.length} characters
|
{watch("content")?.length || 0} characters
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<RichTextEditor
|
<Controller
|
||||||
label="Content"
|
name="contentHtml"
|
||||||
value={contentHtml}
|
control={control}
|
||||||
required
|
render={({ field }) => (
|
||||||
placeholder="Write the initial document content..."
|
<RichTextEditor
|
||||||
minHeightClassName="min-h-[280px]"
|
label="Content"
|
||||||
onChange={(html, text) => {
|
value={field.value}
|
||||||
setContentHtml(html);
|
required
|
||||||
setContent(text);
|
placeholder="Write the initial document content..."
|
||||||
}}
|
minHeightClassName="min-h-[280px]"
|
||||||
|
onChange={(html, text) => {
|
||||||
|
field.onChange(html);
|
||||||
|
setValue("content", text);
|
||||||
|
}}
|
||||||
|
error={errors.contentHtml?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
<div className="mt-1 flex items-start gap-1.5 text-[11px] text-[#6b7280]">
|
<div className="mt-1 flex items-start gap-1.5 text-[11px] text-[#6b7280]">
|
||||||
<Info className="w-3.5 h-3.5 mt-[1px] shrink-0" />
|
<Info className="w-3.5 h-3.5 mt-[1px] shrink-0" />
|
||||||
@ -341,13 +390,18 @@ const CreateDocument = (): ReactElement => {
|
|||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="button"
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
className="h-10 px-4 border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#112868] hover:bg-[#f8fafc] disabled:opacity-60"
|
className="h-10 px-4 border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#112868] hover:bg-[#f8fafc] disabled:opacity-60"
|
||||||
|
onClick={handleSubmit(onFormSubmit)}
|
||||||
>
|
>
|
||||||
{isSaving ? "Saving..." : "Save As Draft"}
|
{isSaving ? "Saving..." : "Save As Draft"}
|
||||||
</button>
|
</button>
|
||||||
<PrimaryButton type="submit" disabled={isSaving}>
|
<PrimaryButton
|
||||||
|
type="submit"
|
||||||
|
disabled={isSaving}
|
||||||
|
onClick={handleSubmit(onFormSubmit)}
|
||||||
|
>
|
||||||
{isSaving ? "Creating..." : "Create Document"}
|
{isSaving ? "Creating..." : "Create Document"}
|
||||||
</PrimaryButton>
|
</PrimaryButton>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -156,6 +156,22 @@ const Documents = (): ReactElement => {
|
|||||||
<span className="text-[#6b7280]">{formatDate(doc.updated_at)}</span>
|
<span className="text-[#6b7280]">{formatDate(doc.updated_at)}</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
label: "Actions",
|
||||||
|
render: (doc) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-xs text-[#084cc8] hover:underline font-medium"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigate(`/tenant/documents/edit/${doc.id}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
[navigate],
|
[navigate],
|
||||||
);
|
);
|
||||||
|
|||||||
266
src/pages/tenant/EditDocument.tsx
Normal file
266
src/pages/tenant/EditDocument.tsx
Normal file
@ -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<typeof documentSchema>;
|
||||||
|
|
||||||
|
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<DocumentCategory[]>([]);
|
||||||
|
const [modules, setModules] = useState<MyModule[]>([]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<DocumentFormData>({
|
||||||
|
resolver: zodResolver(documentSchema),
|
||||||
|
defaultValues: {
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
category_id: "",
|
||||||
|
department: "",
|
||||||
|
tags: "",
|
||||||
|
selectedModuleId: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadData = async (): Promise<void> => {
|
||||||
|
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<void> => {
|
||||||
|
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 (
|
||||||
|
<Layout currentPage="Document Service">
|
||||||
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
|
<p className="text-sm text-[#6b7280]">Loading document details...</p>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
currentPage="Document Service"
|
||||||
|
breadcrumbs={[
|
||||||
|
{ label: "Document Service", path: "/tenant/documents" },
|
||||||
|
{ label: "Edit Document" },
|
||||||
|
]}
|
||||||
|
pageHeader={{
|
||||||
|
title: "Edit Document",
|
||||||
|
description: "Modify document metadata and classification details.",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSubmit(onFormSubmit)} className="space-y-4">
|
||||||
|
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] p-4 md:p-5">
|
||||||
|
<div className="flex items-start justify-between gap-4 border-b border-[rgba(0,0,0,0.08)] pb-4 mb-4">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<div className="mt-0.5 w-8 h-8 rounded-md bg-[#112868]/10 flex items-center justify-center">
|
||||||
|
<FileText className="w-4 h-4 text-[#112868]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-[#0f1724]">
|
||||||
|
Edit Document Metadata
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-[#6b7280] mt-1">
|
||||||
|
Updates will apply to the current version of the document.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
className="inline-flex items-center gap-1.5 text-xs text-[#475569] hover:text-[#0f1724]"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-3.5 h-3.5" />
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-x-4">
|
||||||
|
<FormField
|
||||||
|
label="Document Title"
|
||||||
|
required
|
||||||
|
placeholder="Enter document title"
|
||||||
|
error={errors.title?.message}
|
||||||
|
{...register("title")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormTextArea
|
||||||
|
label="Description"
|
||||||
|
placeholder="Brief description of this document"
|
||||||
|
error={errors.description?.message}
|
||||||
|
{...register("description")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] p-4 md:p-5">
|
||||||
|
<h3 className="text-sm font-semibold text-[#0f1724] mb-3">
|
||||||
|
Classification
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-4">
|
||||||
|
<Controller
|
||||||
|
name="category_id"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormSelect
|
||||||
|
label="Category"
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
options={categories.map((category) => ({
|
||||||
|
value: category.id,
|
||||||
|
label: `${category.name} (${category.code})`,
|
||||||
|
}))}
|
||||||
|
placeholder="Select category"
|
||||||
|
error={errors.category_id?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
label="Department"
|
||||||
|
placeholder="Optional"
|
||||||
|
error={errors.department?.message}
|
||||||
|
{...register("department")}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
label="Tags"
|
||||||
|
placeholder="Comma separated tags (e.g. quality, sop)"
|
||||||
|
error={errors.tags?.message}
|
||||||
|
{...register("tags")}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="selectedModuleId"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormSelect
|
||||||
|
label="Source Module"
|
||||||
|
required
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
options={modules.map((m) => ({
|
||||||
|
value: m.id,
|
||||||
|
label: m.name,
|
||||||
|
}))}
|
||||||
|
placeholder="Select originating module"
|
||||||
|
error={errors.selectedModuleId?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] p-4 md:p-5">
|
||||||
|
<div className="flex gap-2 mt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="h-10 px-4 border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0f1724] hover:bg-gray-50"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<PrimaryButton
|
||||||
|
type="submit"
|
||||||
|
disabled={isSaving}
|
||||||
|
onClick={handleSubmit(onFormSubmit)}
|
||||||
|
>
|
||||||
|
{isSaving ? "Updating..." : "Update Document"}
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EditDocument;
|
||||||
@ -75,6 +75,7 @@ const ViewDocument = (): ReactElement => {
|
|||||||
const [newVersionChangeReason, setNewVersionChangeReason] = useState("minor_edit");
|
const [newVersionChangeReason, setNewVersionChangeReason] = useState("minor_edit");
|
||||||
const [newVersionChangeSummary, setNewVersionChangeSummary] = useState("");
|
const [newVersionChangeSummary, setNewVersionChangeSummary] = useState("");
|
||||||
const [isMajorVersion, setIsMajorVersion] = useState(false);
|
const [isMajorVersion, setIsMajorVersion] = useState(false);
|
||||||
|
const [versionErrors, setVersionErrors] = useState<Record<string, string>>({});
|
||||||
const [isVersionSaving, setIsVersionSaving] = useState(false);
|
const [isVersionSaving, setIsVersionSaving] = useState(false);
|
||||||
const [showWorkflowTracker, setShowWorkflowTracker] = useState(false);
|
const [showWorkflowTracker, setShowWorkflowTracker] = useState(false);
|
||||||
const [workflowInstance, setWorkflowInstance] = useState<WorkflowInstance | null>(null);
|
const [workflowInstance, setWorkflowInstance] = useState<WorkflowInstance | null>(null);
|
||||||
@ -244,6 +245,7 @@ const ViewDocument = (): ReactElement => {
|
|||||||
const response = await workflowService.listDefinitions({
|
const response = await workflowService.listDefinitions({
|
||||||
status: "active",
|
status: "active",
|
||||||
entity_type: "document",
|
entity_type: "document",
|
||||||
|
source_module_id: document?.source_module_id || undefined,
|
||||||
limit: 100,
|
limit: 100,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
});
|
});
|
||||||
@ -320,12 +322,20 @@ const ViewDocument = (): ReactElement => {
|
|||||||
|
|
||||||
const handleCreateVersion = async (): Promise<void> => {
|
const handleCreateVersion = async (): Promise<void> => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
|
|
||||||
|
// Clear previous errors
|
||||||
|
setVersionErrors({});
|
||||||
|
const localErrors: Record<string, string> = {};
|
||||||
|
|
||||||
if (!newVersionChangeReason) {
|
if (!newVersionChangeReason) {
|
||||||
showToast.error("Change reason is required");
|
localErrors.change_reason = "Change reason is required";
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
if (!newVersionContent.trim()) {
|
if (!newVersionContent.trim()) {
|
||||||
showToast.error("Document content is required");
|
localErrors.content = "Document content is required";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(localErrors).length > 0) {
|
||||||
|
setVersionErrors(localErrors);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -462,13 +472,22 @@ const ViewDocument = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{document?.status === "draft" && (
|
{document?.status === "draft" && (
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
type="button"
|
<button
|
||||||
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-[#084cc8] text-white text-xs font-medium hover:bg-[#063a99]"
|
type="button"
|
||||||
onClick={() => void openActionModal("submit")}
|
className="inline-flex items-center gap-2 h-9 px-4 rounded-md border border-[#e2e8f0] text-[#475569] bg-white text-xs font-medium hover:bg-[#f8fafc] transition-colors"
|
||||||
>
|
onClick={() => navigate(`/tenant/documents/edit/${id}`)}
|
||||||
{ACTION_LABELS["submit"]}
|
>
|
||||||
</button>
|
Edit Metadata
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-[#084cc8] text-white text-xs font-medium hover:bg-[#063a99] transition-colors"
|
||||||
|
onClick={() => void openActionModal("submit")}
|
||||||
|
>
|
||||||
|
{ACTION_LABELS["submit"]}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{document?.status === "in_review" && (
|
{document?.status === "in_review" && (
|
||||||
<>
|
<>
|
||||||
@ -675,7 +694,9 @@ const ViewDocument = (): ReactElement => {
|
|||||||
onChange={(html, text) => {
|
onChange={(html, text) => {
|
||||||
setNewVersionContentHtml(html);
|
setNewVersionContentHtml(html);
|
||||||
setNewVersionContent(text);
|
setNewVersionContent(text);
|
||||||
|
if (text.trim()) setVersionErrors(prev => ({ ...prev, content: "" }));
|
||||||
}}
|
}}
|
||||||
|
error={versionErrors.content}
|
||||||
/>
|
/>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<FormSelect
|
<FormSelect
|
||||||
@ -688,8 +709,12 @@ const ViewDocument = (): ReactElement => {
|
|||||||
{ value: "major_rewrite", label: "major_rewrite" },
|
{ value: "major_rewrite", label: "major_rewrite" },
|
||||||
]}
|
]}
|
||||||
value={newVersionChangeReason}
|
value={newVersionChangeReason}
|
||||||
onValueChange={setNewVersionChangeReason}
|
onValueChange={(val) => {
|
||||||
|
setNewVersionChangeReason(val);
|
||||||
|
if (val) setVersionErrors(prev => ({ ...prev, change_reason: "" }));
|
||||||
|
}}
|
||||||
placeholder="Select change reason"
|
placeholder="Select change reason"
|
||||||
|
error={versionErrors.change_reason}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-2 pt-8">
|
<div className="flex items-center gap-2 pt-8">
|
||||||
<input
|
<input
|
||||||
@ -822,14 +847,36 @@ const ViewDocument = (): ReactElement => {
|
|||||||
>
|
>
|
||||||
<div className="p-5 space-y-3">
|
<div className="p-5 space-y-3">
|
||||||
{activeAction === "submit" && (
|
{activeAction === "submit" && (
|
||||||
<FormSelect
|
<div className="space-y-3">
|
||||||
label="Workflow Definition"
|
<FormSelect
|
||||||
required
|
label="Workflow Definition"
|
||||||
options={workflowOptions}
|
required
|
||||||
value={workflowDefinitionId}
|
options={workflowOptions}
|
||||||
onValueChange={setWorkflowDefinitionId}
|
value={workflowDefinitionId}
|
||||||
placeholder="Select active workflow definition"
|
onValueChange={setWorkflowDefinitionId}
|
||||||
/>
|
placeholder="Select active workflow definition"
|
||||||
|
/>
|
||||||
|
{/* <div className="flex flex-col gap-1.5 px-0.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[11px] font-medium text-[#64748b] min-w-[80px]">Entity Type:</span>
|
||||||
|
<span className="text-[11px] font-semibold text-[#0f1724] bg-[#f1f5f9] px-2 py-0.5 rounded border border-[#e2e8f0]">
|
||||||
|
Document
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[11px] font-medium text-[#64748b] min-w-[80px]">Source Module:</span>
|
||||||
|
<span className="text-[11px] font-semibold text-[#0f1724] bg-[#f1f5f9] px-2 py-0.5 rounded border border-[#e2e8f0]">
|
||||||
|
{document?.module_name || "Platform"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div> */}
|
||||||
|
<div className="mt-2 p-2 bg-red-50 border border-red-100 rounded text-[10px] leading-relaxed text-[#e11d48]">
|
||||||
|
<strong>Note:</strong> Currently displaying active workflow definitions registered for
|
||||||
|
<span className="font-semibold mx-0.5">Entity Type: Document</span> and
|
||||||
|
<span className="font-semibold mx-0.5">Module: {document?.module_name || "Platform"}</span>.
|
||||||
|
If the list is empty, please go to Workflow Management and create a definition for this specific configuration.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{activeAction === "approve" && (
|
{activeAction === "approve" && (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -16,6 +16,7 @@ const WorkflowDefination = lazy(
|
|||||||
const Suppliers = lazy(() => import("@/pages/tenant/Suppliers"));
|
const Suppliers = lazy(() => import("@/pages/tenant/Suppliers"));
|
||||||
const Documents = lazy(() => import("@/pages/tenant/Documents"));
|
const Documents = lazy(() => import("@/pages/tenant/Documents"));
|
||||||
const CreateDocument = lazy(() => import("@/pages/tenant/CreateDocument"));
|
const CreateDocument = lazy(() => import("@/pages/tenant/CreateDocument"));
|
||||||
|
const EditDocument = lazy(() => import("@/pages/tenant/EditDocument"));
|
||||||
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"));
|
const Tasks = lazy(() => import("@/pages/tenant/Tasks"));
|
||||||
@ -93,6 +94,10 @@ export const tenantAdminRoutes: RouteConfig[] = [
|
|||||||
path: "/tenant/documents/create",
|
path: "/tenant/documents/create",
|
||||||
element: <LazyRoute component={CreateDocument} />,
|
element: <LazyRoute component={CreateDocument} />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/tenant/documents/edit/:id",
|
||||||
|
element: <LazyRoute component={EditDocument} />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/tenant/documents/:id",
|
path: "/tenant/documents/:id",
|
||||||
element: <LazyRoute component={ViewDocument} />,
|
element: <LazyRoute component={ViewDocument} />,
|
||||||
|
|||||||
@ -55,6 +55,9 @@ export interface DocumentDetail {
|
|||||||
created_by?: string | null;
|
created_by?: string | null;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
|
source_module?: string | null;
|
||||||
|
source_module_id?: string | null;
|
||||||
|
category_id?: string | null;
|
||||||
module_name?: string | null;
|
module_name?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user