feat: implement document sections, workflow-enabled creation, text-selection annotation tools, and enhanced form controls
This commit is contained in:
parent
8f5638b4f9
commit
6952a7c6f3
@ -114,7 +114,9 @@ export const FormSelect = ({
|
|||||||
}
|
}
|
||||||
}, [value]);
|
}, [value]);
|
||||||
|
|
||||||
const fieldId = id || `select-${label.toLowerCase().replace(/\s+/g, '-')}`;
|
const fieldId = id || (typeof label === 'string'
|
||||||
|
? `select-${label.toLowerCase().replace(/\s+/g, '-')}`
|
||||||
|
: `select-${Math.random().toString(36).substring(2, 9)}`);
|
||||||
const hasError = Boolean(error);
|
const hasError = Boolean(error);
|
||||||
|
|
||||||
const filteredOptions = options.filter(opt =>
|
const filteredOptions = options.filter(opt =>
|
||||||
@ -145,10 +147,12 @@ export const FormSelect = ({
|
|||||||
ref={buttonRef}
|
ref={buttonRef}
|
||||||
type="button"
|
type="button"
|
||||||
id={fieldId}
|
id={fieldId}
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => !props.disabled && setIsOpen(!isOpen)}
|
||||||
|
disabled={props.disabled}
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-10 w-full px-3.5 py-1 bg-white border rounded-md text-sm transition-colors',
|
'h-10 w-full px-3.5 py-1 bg-white border rounded-md text-sm transition-colors',
|
||||||
'flex items-center justify-between',
|
'flex items-center justify-between',
|
||||||
|
'disabled:bg-gray-50 disabled:text-gray-400 disabled:cursor-not-allowed',
|
||||||
hasError
|
hasError
|
||||||
? 'border-[#ef4444] focus-visible:border-[#ef4444] focus-visible:ring-[#ef4444]/20'
|
? 'border-[#ef4444] focus-visible:border-[#ef4444] focus-visible:ring-[#ef4444]/20'
|
||||||
: 'border-[rgba(0,0,0,0.08)] focus-visible:border-[#112868] focus-visible:ring-[#112868]/20',
|
: 'border-[rgba(0,0,0,0.08)] focus-visible:border-[#112868] focus-visible:ring-[#112868]/20',
|
||||||
|
|||||||
@ -124,4 +124,21 @@
|
|||||||
color: var(--color-foreground);
|
color: var(--color-foreground);
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.custom-scrollbar::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||||
|
background: #e2e8f0;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #cbd5e1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
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 { useForm, Controller, useFieldArray } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import * as z from "zod";
|
import * as z from "zod";
|
||||||
import { Layout } from "@/components/layout/Layout";
|
import { Layout } from "@/components/layout/Layout";
|
||||||
@ -11,16 +11,15 @@ import {
|
|||||||
PrimaryButton,
|
PrimaryButton,
|
||||||
RichTextEditor,
|
RichTextEditor,
|
||||||
FormTagInput,
|
FormTagInput,
|
||||||
|
SecondaryButton,
|
||||||
} from "@/components/shared";
|
} from "@/components/shared";
|
||||||
import {
|
import { documentService, type FileAttachmentItem } from "@/services/document-service";
|
||||||
documentService,
|
|
||||||
type FileAttachmentItem,
|
|
||||||
} from "@/services/document-service";
|
|
||||||
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, Plus, MessageSquare } 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";
|
||||||
|
import { workflowService } from "@/services/workflow-service";
|
||||||
|
|
||||||
const documentSchema = z.object({
|
const documentSchema = z.object({
|
||||||
title: z.string().min(1, "Document title is required"),
|
title: z.string().min(1, "Document title is required"),
|
||||||
@ -30,8 +29,16 @@ const documentSchema = z.object({
|
|||||||
department: z.string().optional(),
|
department: z.string().optional(),
|
||||||
tags: z.array(z.string()),
|
tags: z.array(z.string()),
|
||||||
selectedModuleId: z.string().min(1, "Source module is required"),
|
selectedModuleId: z.string().min(1, "Source module is required"),
|
||||||
|
source_record_id: z.string().optional(),
|
||||||
|
workflow_definition_id: z.string().optional(),
|
||||||
content: z.string().optional(),
|
content: z.string().optional(),
|
||||||
contentHtml: z.string().min(1, "Document content is required"),
|
contentHtml: z.string().optional(),
|
||||||
|
sections: z.array(z.object({
|
||||||
|
title: z.string().min(1, "Section title is required"),
|
||||||
|
content: z.string().optional(),
|
||||||
|
contentHtml: z.string().min(1, "Section content is required"),
|
||||||
|
is_mandatory: z.boolean(),
|
||||||
|
})).min(1, "At least one section is required for structured review"),
|
||||||
});
|
});
|
||||||
|
|
||||||
type DocumentFormData = z.infer<typeof documentSchema>;
|
type DocumentFormData = z.infer<typeof documentSchema>;
|
||||||
@ -42,6 +49,7 @@ const CreateDocument = (): ReactElement => {
|
|||||||
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 [workflowOptions, setWorkflowOptions] = useState<Array<{ value: string; label: string }>>([]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
control,
|
control,
|
||||||
@ -60,11 +68,19 @@ const CreateDocument = (): ReactElement => {
|
|||||||
department: "",
|
department: "",
|
||||||
tags: [],
|
tags: [],
|
||||||
selectedModuleId: "",
|
selectedModuleId: "",
|
||||||
|
source_record_id: "",
|
||||||
|
workflow_definition_id: "",
|
||||||
content: "",
|
content: "",
|
||||||
contentHtml: "",
|
contentHtml: "",
|
||||||
|
sections: [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { fields, append, remove } = useFieldArray({
|
||||||
|
control,
|
||||||
|
name: "sections",
|
||||||
|
});
|
||||||
|
|
||||||
// File attachment fields
|
// File attachment fields
|
||||||
const [files, setFiles] = useState<FileAttachmentItem[]>([]);
|
const [files, setFiles] = useState<FileAttachmentItem[]>([]);
|
||||||
const [selectedFileId, setSelectedFileId] = useState("");
|
const [selectedFileId, setSelectedFileId] = useState("");
|
||||||
@ -74,6 +90,61 @@ const CreateDocument = (): ReactElement => {
|
|||||||
const [mimeType, setMimeType] = useState("");
|
const [mimeType, setMimeType] = useState("");
|
||||||
const [fileHash, setFileHash] = useState("");
|
const [fileHash, setFileHash] = useState("");
|
||||||
const [isLoadingFiles, setIsLoadingFiles] = useState(false);
|
const [isLoadingFiles, setIsLoadingFiles] = useState(false);
|
||||||
|
|
||||||
|
// Split-Pane view state
|
||||||
|
const [showSourcePane, setShowSourcePane] = useState(false);
|
||||||
|
const [sourceHtml, setSourceHtml] = useState("");
|
||||||
|
const [selectionMenu, setSelectionMenu] = useState<{ x: number; y: number; text: string } | null>(null);
|
||||||
|
|
||||||
|
const handleTextSelection = () => {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (!selection || selection.isCollapsed) {
|
||||||
|
setSelectionMenu(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = selection.toString().trim();
|
||||||
|
if (text.length < 2) {
|
||||||
|
setSelectionMenu(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
const rect = range.getBoundingClientRect();
|
||||||
|
|
||||||
|
setSelectionMenu({
|
||||||
|
x: rect.left + rect.width / 2,
|
||||||
|
y: rect.top + window.scrollY,
|
||||||
|
text: text
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const addSelectedToNewSection = () => {
|
||||||
|
if (!selectionMenu) return;
|
||||||
|
append({
|
||||||
|
title: selectionMenu.text.substring(0, 30) + (selectionMenu.text.length > 30 ? "..." : ""),
|
||||||
|
contentHtml: `<p>${selectionMenu.text}</p>`,
|
||||||
|
content: selectionMenu.text,
|
||||||
|
is_mandatory: true
|
||||||
|
});
|
||||||
|
setSelectionMenu(null);
|
||||||
|
window.getSelection()?.removeAllRanges();
|
||||||
|
showToast.success("Added to new section");
|
||||||
|
};
|
||||||
|
|
||||||
|
const appendToLastSection = () => {
|
||||||
|
if (!selectionMenu || fields.length === 0) return;
|
||||||
|
const lastIndex = fields.length - 1;
|
||||||
|
const currentHtml = watch(`sections.${lastIndex}.contentHtml`);
|
||||||
|
const currentText = watch(`sections.${lastIndex}.content`);
|
||||||
|
|
||||||
|
setValue(`sections.${lastIndex}.contentHtml`, `${currentHtml}<p>${selectionMenu.text}</p>`);
|
||||||
|
setValue(`sections.${lastIndex}.content`, `${currentText}\n\n${selectionMenu.text}`);
|
||||||
|
|
||||||
|
setSelectionMenu(null);
|
||||||
|
window.getSelection()?.removeAllRanges();
|
||||||
|
showToast.success("Appended to section " + (lastIndex + 1));
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadLookups = async (): Promise<void> => {
|
const loadLookups = async (): Promise<void> => {
|
||||||
@ -96,6 +167,44 @@ const CreateDocument = (): ReactElement => {
|
|||||||
void loadFiles();
|
void loadFiles();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const selectedModuleId = watch("selectedModuleId");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadWorkflows = async (): Promise<void> => {
|
||||||
|
if (!selectedModuleId) {
|
||||||
|
setWorkflowOptions([]);
|
||||||
|
setValue("workflow_definition_id", "");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const workflowRes = await workflowService.listDefinitions({
|
||||||
|
entity_type: "document",
|
||||||
|
status: "active",
|
||||||
|
source_module_id: selectedModuleId,
|
||||||
|
limit: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
setWorkflowOptions(
|
||||||
|
(workflowRes.data || []).map((d: any) => ({
|
||||||
|
value: d.id,
|
||||||
|
label: `${d.name} (${d.code})`,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
// Clear selection if the current one isn't in the new list
|
||||||
|
const currentWfId = watch("workflow_definition_id");
|
||||||
|
if (currentWfId && !workflowRes.data.some((d: any) => d.id === currentWfId)) {
|
||||||
|
setValue("workflow_definition_id", "");
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("Failed to load workflows:", err);
|
||||||
|
setWorkflowOptions([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void loadWorkflows();
|
||||||
|
}, [selectedModuleId, setValue]);
|
||||||
|
|
||||||
const loadFiles = async (): Promise<void> => {
|
const loadFiles = async (): Promise<void> => {
|
||||||
setIsLoadingFiles(true);
|
setIsLoadingFiles(true);
|
||||||
try {
|
try {
|
||||||
@ -110,6 +219,8 @@ 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) {
|
||||||
@ -138,9 +249,24 @@ const CreateDocument = (): ReactElement => {
|
|||||||
);
|
);
|
||||||
const res = await documentService.getFileContent(fileId);
|
const res = await documentService.getFileContent(fileId);
|
||||||
if (res.success && res.data) {
|
if (res.success && res.data) {
|
||||||
setValue("contentHtml", res.data.html || "");
|
setSourceHtml(res.data.html || "");
|
||||||
setValue("content", res.data.text || "");
|
setShowSourcePane(true); // Automatically show the split pane
|
||||||
showToast.success(`Content loaded from "${selected.original_name}"`);
|
|
||||||
|
// Reset sections and start with a single "Main Content" block
|
||||||
|
while (fields.length > 0) remove(0);
|
||||||
|
|
||||||
|
append({
|
||||||
|
title: "Main Content",
|
||||||
|
contentHtml: res.data.html || "",
|
||||||
|
content: res.data.text || "",
|
||||||
|
is_mandatory: true
|
||||||
|
});
|
||||||
|
|
||||||
|
showToast.success(`Content loaded from "${selected.original_name}". Use the reference pane to build your structure.`);
|
||||||
|
|
||||||
|
// Clear main content fields as we are now strictly using sections
|
||||||
|
setValue("contentHtml", "");
|
||||||
|
setValue("content", "");
|
||||||
} else {
|
} else {
|
||||||
showToast.error("Failed to extract file content");
|
showToast.error("Failed to extract file content");
|
||||||
}
|
}
|
||||||
@ -148,12 +274,15 @@ const CreateDocument = (): ReactElement => {
|
|||||||
const msg =
|
const msg =
|
||||||
err?.response?.data?.error?.message || "Failed to extract file content";
|
err?.response?.data?.error?.message || "Failed to extract file content";
|
||||||
showToast.error(msg);
|
showToast.error(msg);
|
||||||
const html = `<p>Document sourced from file: <strong>${selected.original_name}</strong></p>`;
|
|
||||||
setValue("contentHtml", html);
|
// Fallback: create a section with error information
|
||||||
setValue(
|
while (fields.length > 0) remove(0);
|
||||||
"content",
|
append({
|
||||||
`Document sourced from file: ${selected.original_name}`,
|
title: "Sourced Content",
|
||||||
);
|
contentHtml: `<p>Document sourced from file: <strong>${selected.original_name}</strong></p>`,
|
||||||
|
content: `Document sourced from file: ${selected.original_name}`,
|
||||||
|
is_mandatory: true
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -168,7 +297,7 @@ const CreateDocument = (): ReactElement => {
|
|||||||
department: data.department?.trim() || undefined,
|
department: data.department?.trim() || undefined,
|
||||||
tags: data.tags || [],
|
tags: data.tags || [],
|
||||||
content: data.content?.trim() || undefined,
|
content: data.content?.trim() || undefined,
|
||||||
content_html: data.contentHtml.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,
|
||||||
@ -177,6 +306,15 @@ const CreateDocument = (): ReactElement => {
|
|||||||
source_module: modules.find((m) => m.id === data.selectedModuleId)!
|
source_module: modules.find((m) => m.id === data.selectedModuleId)!
|
||||||
.module_id,
|
.module_id,
|
||||||
source_module_id: data.selectedModuleId,
|
source_module_id: data.selectedModuleId,
|
||||||
|
source_record_id: data.source_record_id?.trim() || undefined,
|
||||||
|
workflow_definition_id: data.workflow_definition_id,
|
||||||
|
sections: data.sections?.map((s, i) => ({
|
||||||
|
title: s.title,
|
||||||
|
content: s.content,
|
||||||
|
content_html: s.contentHtml,
|
||||||
|
order_index: i,
|
||||||
|
is_mandatory: s.is_mandatory
|
||||||
|
}))
|
||||||
});
|
});
|
||||||
showToast.success("Document created successfully");
|
showToast.success("Document created successfully");
|
||||||
navigate(`/tenant/documents/${response.data.id}`);
|
navigate(`/tenant/documents/${response.data.id}`);
|
||||||
@ -203,8 +341,83 @@ const CreateDocument = (): ReactElement => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<form onSubmit={handleSubmit(onFormSubmit)} 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={showSourcePane ? "grid grid-cols-1 lg:grid-cols-12 gap-6 items-start" : "space-y-4"}>
|
||||||
<div className="flex items-start justify-between gap-4 border-b border-[rgba(0,0,0,0.08)] pb-4 mb-4">
|
|
||||||
|
{/* LEFT PANE: Source Reference (Only visible if showSourcePane is true) */}
|
||||||
|
{showSourcePane && (
|
||||||
|
<div className="lg:col-span-5 space-y-4 sticky top-6 animate-in slide-in-from-left duration-500">
|
||||||
|
<div className="bg-[#f8fafc] border border-[rgba(0,0,0,0.08)] rounded-xl shadow-[0px_10px_30px_-10px_rgba(0,0,0,0.05)] overflow-hidden flex flex-col h-[calc(100vh-12rem)]">
|
||||||
|
<div className="px-4 py-3 bg-white border-b border-[rgba(0,0,0,0.08)] flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-7 h-7 rounded-md bg-[#112868]/10 flex items-center justify-center">
|
||||||
|
<FileText className="w-4 h-4 text-[#112868]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs font-bold text-[#0f1724] uppercase tracking-wider">Document Reference</h3>
|
||||||
|
<p className="text-[9px] text-gray-400 font-medium truncate max-w-[200px]">{fileName}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowSourcePane(false)}
|
||||||
|
className="p-1.5 rounded-md hover:bg-gray-100 text-gray-400 hover:text-gray-600 transition-colors"
|
||||||
|
title="Hide Reference Pane"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto p-8 custom-scrollbar bg-white" onMouseUp={handleTextSelection}>
|
||||||
|
<div
|
||||||
|
className="prose prose-sm prose-slate max-w-none select-text selection:bg-blue-100 selection:text-blue-900"
|
||||||
|
dangerouslySetInnerHTML={{ __html: sourceHtml }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Floating Selection Menu */}
|
||||||
|
{selectionMenu && (
|
||||||
|
<div
|
||||||
|
className="fixed z-[9999] -translate-x-1/2 -translate-y-full mb-2 flex items-center gap-1 p-1 bg-[#112868] rounded-lg shadow-xl border border-white/10 animate-in fade-in zoom-in duration-200"
|
||||||
|
style={{ left: selectionMenu.x, top: selectionMenu.y }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addSelectedToNewSection}
|
||||||
|
className="flex items-center gap-1.5 px-2.5 py-1.5 text-[10px] font-bold text-white hover:bg-white/10 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-3 h-3" />
|
||||||
|
Add as New
|
||||||
|
</button>
|
||||||
|
<div className="w-[1px] h-4 bg-white/20 mx-0.5" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={appendToLastSection}
|
||||||
|
disabled={fields.length === 0}
|
||||||
|
className="flex items-center gap-1.5 px-2.5 py-1.5 text-[10px] font-bold text-white hover:bg-white/10 rounded-md transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Paperclip className="w-3 h-3" />
|
||||||
|
Append
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="px-5 py-4 bg-[#f8fafc] border-t border-[rgba(0,0,0,0.05)]">
|
||||||
|
<div className="flex items-start gap-2.5">
|
||||||
|
<div className="mt-0.5 p-1 rounded bg-blue-100">
|
||||||
|
<Info className="w-3 h-3 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] leading-relaxed text-slate-600 font-medium">
|
||||||
|
<span className="text-blue-700 font-bold">Interactive Mode:</span> Select text from this pane and copy it directly into your sections on the right. This ensures accuracy and full traceability from the source document.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* RIGHT PANE: The Form */}
|
||||||
|
<div className={showSourcePane ? "lg:col-span-7 space-y-4" : "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="flex items-start gap-2">
|
||||||
<div className="mt-0.5 w-8 h-8 rounded-md bg-[#112868]/10 flex items-center justify-center">
|
<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]" />
|
<FileText className="w-4 h-4 text-[#112868]" />
|
||||||
@ -214,8 +427,9 @@ const CreateDocument = (): ReactElement => {
|
|||||||
New Controlled Document
|
New Controlled Document
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-[#6b7280] mt-1">
|
<p className="text-xs text-[#6b7280] mt-1">
|
||||||
Document will be created in{" "}
|
{watch("workflow_definition_id")
|
||||||
<span className="font-medium">Draft</span> status.
|
? <span>Document will go directly to <span className="font-medium text-amber-600">In Review</span> status upon creation.</span>
|
||||||
|
: <span>Document will be created in <span className="font-medium">Draft</span> status.</span>}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -293,6 +507,12 @@ const CreateDocument = (): ReactElement => {
|
|||||||
error={errors.department?.message}
|
error={errors.department?.message}
|
||||||
{...register("department")}
|
{...register("department")}
|
||||||
/>
|
/>
|
||||||
|
<FormField
|
||||||
|
label="Source Record ID"
|
||||||
|
placeholder="Internal record ID from source app (e.g. Report_ID_001)"
|
||||||
|
error={errors.source_record_id?.message}
|
||||||
|
{...register("source_record_id")}
|
||||||
|
/>
|
||||||
<Controller
|
<Controller
|
||||||
name="tags"
|
name="tags"
|
||||||
control={control}
|
control={control}
|
||||||
@ -313,7 +533,10 @@ const CreateDocument = (): ReactElement => {
|
|||||||
label="Source Module"
|
label="Source Module"
|
||||||
required
|
required
|
||||||
value={field.value}
|
value={field.value}
|
||||||
onValueChange={field.onChange}
|
onValueChange={(val) => {
|
||||||
|
field.onChange(val);
|
||||||
|
setValue("workflow_definition_id", "");
|
||||||
|
}}
|
||||||
options={modules.map((m) => ({
|
options={modules.map((m) => ({
|
||||||
value: m.id,
|
value: m.id,
|
||||||
label: m.name,
|
label: m.name,
|
||||||
@ -323,6 +546,36 @@ const CreateDocument = (): ReactElement => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<Controller
|
||||||
|
name="workflow_definition_id"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormSelect
|
||||||
|
label="Auto-Start Workflow (Optional)"
|
||||||
|
value={field.value || ""}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: "— Save as Draft —" },
|
||||||
|
...workflowOptions,
|
||||||
|
]}
|
||||||
|
placeholder={
|
||||||
|
selectedModuleId
|
||||||
|
? "Select workflow to auto-start"
|
||||||
|
: "Please select a Source Module first"
|
||||||
|
}
|
||||||
|
disabled={!selectedModuleId}
|
||||||
|
error={errors.workflow_definition_id?.message}
|
||||||
|
isSearchable
|
||||||
|
helperText={
|
||||||
|
!selectedModuleId
|
||||||
|
? "Workflow options depend on the selected Source Module."
|
||||||
|
: watch("workflow_definition_id")
|
||||||
|
? "Document will be created and automatically submitted for review (In Review status)."
|
||||||
|
: "Document will be created in Draft status for manual submission later."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -333,6 +586,15 @@ const CreateDocument = (): ReactElement => {
|
|||||||
<h3 className="text-sm font-semibold text-[#0f1724]">
|
<h3 className="text-sm font-semibold text-[#0f1724]">
|
||||||
Load Content From File (Optional)
|
Load Content From File (Optional)
|
||||||
</h3>
|
</h3>
|
||||||
|
{sourceHtml && !showSourcePane && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowSourcePane(true)}
|
||||||
|
className="ml-auto text-[10px] font-bold text-blue-600 hover:text-blue-700 uppercase tracking-wider bg-blue-50 px-2 py-1 rounded border border-blue-100 transition-all hover:shadow-sm"
|
||||||
|
>
|
||||||
|
Open Reference Pane
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-[#6b7280] mb-3">
|
<p className="text-xs text-[#6b7280] mb-3">
|
||||||
Select a previously uploaded file to automatically populate content
|
Select a previously uploaded file to automatically populate content
|
||||||
@ -374,38 +636,120 @@ const CreateDocument = (): ReactElement => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Structured Sections Selection */}
|
||||||
<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-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h3 className="text-sm font-semibold text-[#0f1724]">
|
<div className="flex items-center gap-2">
|
||||||
Initial Content
|
<FileText className="w-4 h-4 text-[#112868]" />
|
||||||
</h3>
|
<h3 className="text-sm font-semibold text-[#0f1724]">
|
||||||
<span className="text-[11px] text-[#94a3b8]">
|
Structured Sections
|
||||||
{watch("content")?.length || 0} characters
|
</h3>
|
||||||
</span>
|
</div>
|
||||||
|
<SecondaryButton
|
||||||
|
type="button"
|
||||||
|
onClick={() => append({ title: "", content: "", contentHtml: "", is_mandatory: true })}
|
||||||
|
className="h-8 text-[11px]"
|
||||||
|
>
|
||||||
|
+ Add Section
|
||||||
|
</SecondaryButton>
|
||||||
</div>
|
</div>
|
||||||
<Controller
|
|
||||||
name="contentHtml"
|
{fields.length > 0 ? (
|
||||||
control={control}
|
<div className="space-y-6">
|
||||||
render={({ field }) => (
|
{fields.map((field, index) => (
|
||||||
<RichTextEditor
|
<div key={field.id} className="p-4 border border-dashed border-[#e2e8f0] rounded-lg relative bg-[#fafafa]">
|
||||||
label="Content"
|
<button
|
||||||
value={field.value}
|
type="button"
|
||||||
required
|
onClick={() => remove(index)}
|
||||||
placeholder="Write the initial document content..."
|
className="absolute top-3 right-3 p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-md transition-colors"
|
||||||
minHeightClassName="h-[400px] overflow-y-auto"
|
title="Remove Section"
|
||||||
onChange={(html, text) => {
|
>
|
||||||
field.onChange(html);
|
<ArrowLeft className="w-3.5 h-3.5 rotate-45" />
|
||||||
setValue("content", text);
|
</button>
|
||||||
}}
|
|
||||||
error={errors.contentHtml?.message}
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||||
/>
|
<FormField
|
||||||
)}
|
label={`Section ${index + 1} Title`}
|
||||||
/>
|
required
|
||||||
<div className="mt-1 flex items-start gap-1.5 text-[11px] text-[#6b7280]">
|
placeholder="e.g. 1. Purpose, 2. Scope"
|
||||||
<Info className="w-3.5 h-3.5 mt-[1px] shrink-0" />
|
error={errors.sections?.[index]?.title?.message}
|
||||||
<span>
|
{...register(`sections.${index}.title` as const)}
|
||||||
You can create new versions later from the document detail page.
|
/>
|
||||||
</span>
|
<div className="flex items-center gap-2 pt-6">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id={`mandatory-${index}`}
|
||||||
|
className="w-4 h-4 text-[#112868] border-gray-300 rounded focus:ring-[#112868]"
|
||||||
|
{...register(`sections.${index}.is_mandatory` as const)}
|
||||||
|
/>
|
||||||
|
<label htmlFor={`mandatory-${index}`} className="text-xs font-medium text-[#64748b]">
|
||||||
|
Mandatory Review Required
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name={`sections.${index}.contentHtml` as const}
|
||||||
|
control={control}
|
||||||
|
render={({ field: editorField }) => (
|
||||||
|
<RichTextEditor
|
||||||
|
label="Section Content"
|
||||||
|
value={editorField.value || ""}
|
||||||
|
required
|
||||||
|
placeholder="Write section content..."
|
||||||
|
minHeightClassName="h-[200px] overflow-y-auto"
|
||||||
|
onChange={(html, text) => {
|
||||||
|
editorField.onChange(html);
|
||||||
|
setValue(`sections.${index}.content`, text);
|
||||||
|
}}
|
||||||
|
error={errors.sections?.[index]?.contentHtml?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Section Comments (Feedback for rework) */}
|
||||||
|
{(field as any).comments && (field as any).comments.length > 0 && (
|
||||||
|
<div className="mt-4 pt-4 border-t border-[#e2e8f0]">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<MessageSquare className="w-3.5 h-3.5 text-amber-600" />
|
||||||
|
<span className="text-[10px] font-bold text-amber-700 uppercase tracking-wider">Unresolved Feedback ({(field as any).comments.length})</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(field as any).comments.map((comment: any) => (
|
||||||
|
<div key={comment.id} className="p-2.5 bg-amber-50/50 border border-amber-100 rounded-md">
|
||||||
|
<div className="flex justify-between items-start mb-1">
|
||||||
|
<span className="text-[10px] font-semibold text-amber-900">{comment.author_email}</span>
|
||||||
|
<span className="text-[9px] text-amber-600 bg-white px-1.5 py-0.5 rounded border border-amber-100 uppercase tracking-tighter">{comment.workflow_step}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-slate-700 leading-normal">{comment.comment_text}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="py-8 border border-dashed border-[#e2e8f0] rounded-lg flex flex-col items-center justify-center bg-[#fafafa]">
|
||||||
|
<FileText className="w-8 h-8 text-[#cbd5e1] mb-2" />
|
||||||
|
<p className="text-xs text-[#94a3b8]">No structured sections added yet.</p>
|
||||||
|
<p className="text-[10px] text-[#cbd5e1] mt-1">Use sections for granular review and collaboration.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-blue-50/50 border border-blue-100 rounded-lg p-4 mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-blue-100 flex items-center justify-center">
|
||||||
|
<Info className="w-4 h-4 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-blue-900">Structured Review Enabled</p>
|
||||||
|
<p className="text-xs text-blue-700">All documents must use sections to enable granular review, QA validation, and section-level rework tracking.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -16,12 +16,12 @@ import {
|
|||||||
type FileAttachmentItem,
|
type FileAttachmentItem,
|
||||||
} from "@/services/document-service";
|
} 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, DocumentSection, DocumentComment } from "@/types/document";
|
||||||
import type { WorkflowInstance } from "@/types/workflow";
|
import type { WorkflowInstance } from "@/types/workflow";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { showToast } from "@/utils/toast";
|
import { showToast } from "@/utils/toast";
|
||||||
import { useAppTheme } from "@/hooks/useAppTheme";
|
import { useAppTheme } from "@/hooks/useAppTheme";
|
||||||
import { Paperclip, Plus, User } from "lucide-react";
|
import { CheckCircle, MessageSquare, Paperclip, Plus, User, XCircle, RotateCcw, ShieldCheck } from "lucide-react";
|
||||||
|
|
||||||
const formatDateTime = (value?: string | null): string => {
|
const formatDateTime = (value?: string | null): string => {
|
||||||
if (!value) return "-";
|
if (!value) return "-";
|
||||||
@ -62,7 +62,7 @@ const ViewDocument = (): ReactElement => {
|
|||||||
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<
|
const [activeTab, setActiveTab] = useState<
|
||||||
"overview" | "version-history" | "workflow-history"
|
"overview" | "sections" | "comments" | "version-history" | "workflow-history"
|
||||||
>("overview");
|
>("overview");
|
||||||
const [workflowHistory, setWorkflowHistory] = useState<any[]>([]);
|
const [workflowHistory, setWorkflowHistory] = useState<any[]>([]);
|
||||||
const [isHistoryLoading, setIsHistoryLoading] = useState(false);
|
const [isHistoryLoading, setIsHistoryLoading] = useState(false);
|
||||||
@ -93,6 +93,19 @@ const ViewDocument = (): ReactElement => {
|
|||||||
useState<WorkflowInstance | null>(null);
|
useState<WorkflowInstance | null>(null);
|
||||||
const [isWorkflowLoading, setIsWorkflowLoading] = useState(false);
|
const [isWorkflowLoading, setIsWorkflowLoading] = useState(false);
|
||||||
|
|
||||||
|
// Sections & Comments state
|
||||||
|
const [sections, setSections] = useState<DocumentSection[]>([]);
|
||||||
|
const [isSectionsLoading, setIsSectionsLoading] = useState(false);
|
||||||
|
const [editingSectionId, setEditingSectionId] = useState<string | null>(null);
|
||||||
|
const [editingSectionContent, setEditingSectionContent] = useState("");
|
||||||
|
const [isSectionSaving, setIsSectionSaving] = useState(false);
|
||||||
|
|
||||||
|
const [comments, setComments] = useState<DocumentComment[]>([]);
|
||||||
|
const [isCommentsLoading, setIsCommentsLoading] = useState(false);
|
||||||
|
const [newCommentText, setNewCommentText] = useState("");
|
||||||
|
const [isAddingComment, setIsAddingComment] = useState(false);
|
||||||
|
const [selectedSectionForComment, setSelectedSectionForComment] = useState<string | "">("");
|
||||||
|
|
||||||
// File attachment fields for new version
|
// File attachment fields for new version
|
||||||
const [versionFiles, setVersionFiles] = useState<FileAttachmentItem[]>([]);
|
const [versionFiles, setVersionFiles] = useState<FileAttachmentItem[]>([]);
|
||||||
const [versionSelectedFileId, setVersionSelectedFileId] = useState("");
|
const [versionSelectedFileId, setVersionSelectedFileId] = useState("");
|
||||||
@ -141,6 +154,32 @@ const ViewDocument = (): ReactElement => {
|
|||||||
if (activeTab === "workflow-history" && id) {
|
if (activeTab === "workflow-history" && id) {
|
||||||
void loadWorkflowHistory();
|
void loadWorkflowHistory();
|
||||||
}
|
}
|
||||||
|
if (activeTab === "sections" && id) {
|
||||||
|
void (async () => {
|
||||||
|
setIsSectionsLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await documentService.getSections(id);
|
||||||
|
setSections(res.data || []);
|
||||||
|
} catch {
|
||||||
|
showToast.error("Failed to load sections");
|
||||||
|
} finally {
|
||||||
|
setIsSectionsLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
if (activeTab === "comments" && id) {
|
||||||
|
void (async () => {
|
||||||
|
setIsCommentsLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await documentService.listComments(id);
|
||||||
|
setComments(res.data || []);
|
||||||
|
} catch {
|
||||||
|
showToast.error("Failed to load comments");
|
||||||
|
} finally {
|
||||||
|
setIsCommentsLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
}, [activeTab, id]);
|
}, [activeTab, id]);
|
||||||
|
|
||||||
const loadWorkflowHistory = async (): Promise<void> => {
|
const loadWorkflowHistory = async (): Promise<void> => {
|
||||||
@ -568,6 +607,30 @@ const ViewDocument = (): ReactElement => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
{/* Governance Highlight */}
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-3 p-4 rounded-lg border"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${primaryColor}0D`,
|
||||||
|
borderColor: `${primaryColor}33`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center"
|
||||||
|
style={{ backgroundColor: `${primaryColor}1A` }}
|
||||||
|
>
|
||||||
|
<ShieldCheck className="w-5 h-5" style={{ color: primaryColor }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium" style={{ color: primaryColor }}>
|
||||||
|
Governed Document Workflow
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-[#6b7280]">
|
||||||
|
Approved document becomes official governed output for the source application.
|
||||||
|
</p>
|
||||||
|
</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-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-5">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
@ -605,10 +668,10 @@ const ViewDocument = (): ReactElement => {
|
|||||||
</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-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-5">
|
||||||
<div className="flex items-center gap-5 border-b border-[rgba(0,0,0,0.08)] mb-4">
|
<div className="flex items-center gap-5 border-b border-[rgba(0,0,0,0.08)] mb-4 overflow-x-auto">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`text-sm pb-2 transition-colors`}
|
className={`text-sm pb-2 transition-colors whitespace-nowrap`}
|
||||||
style={{
|
style={{
|
||||||
color: activeTab === "overview" ? primaryColor : "#6b7280",
|
color: activeTab === "overview" ? primaryColor : "#6b7280",
|
||||||
borderBottom:
|
borderBottom:
|
||||||
@ -622,7 +685,35 @@ const ViewDocument = (): ReactElement => {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`text-sm pb-2 transition-colors`}
|
className={`text-sm pb-2 transition-colors whitespace-nowrap`}
|
||||||
|
style={{
|
||||||
|
color: activeTab === "sections" ? primaryColor : "#6b7280",
|
||||||
|
borderBottom:
|
||||||
|
activeTab === "sections"
|
||||||
|
? `2px solid ${primaryColor}`
|
||||||
|
: "none",
|
||||||
|
}}
|
||||||
|
onClick={() => setActiveTab("sections")}
|
||||||
|
>
|
||||||
|
Sections{sections.length > 0 ? ` (${sections.length})` : ""}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`text-sm pb-2 transition-colors whitespace-nowrap`}
|
||||||
|
style={{
|
||||||
|
color: activeTab === "comments" ? primaryColor : "#6b7280",
|
||||||
|
borderBottom:
|
||||||
|
activeTab === "comments"
|
||||||
|
? `2px solid ${primaryColor}`
|
||||||
|
: "none",
|
||||||
|
}}
|
||||||
|
onClick={() => setActiveTab("comments")}
|
||||||
|
>
|
||||||
|
Comments{comments.length > 0 ? ` (${comments.length})` : ""}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`text-sm pb-2 transition-colors whitespace-nowrap`}
|
||||||
style={{
|
style={{
|
||||||
color:
|
color:
|
||||||
activeTab === "version-history" ? primaryColor : "#6b7280",
|
activeTab === "version-history" ? primaryColor : "#6b7280",
|
||||||
@ -637,7 +728,7 @@ const ViewDocument = (): ReactElement => {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`text-sm pb-2 transition-colors`}
|
className={`text-sm pb-2 transition-colors whitespace-nowrap`}
|
||||||
style={{
|
style={{
|
||||||
color:
|
color:
|
||||||
activeTab === "workflow-history" ? primaryColor : "#6b7280",
|
activeTab === "workflow-history" ? primaryColor : "#6b7280",
|
||||||
@ -704,27 +795,354 @@ const ViewDocument = (): ReactElement => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-semibold text-[#0f1724] mb-2">
|
|
||||||
Document Content
|
|
||||||
</h4>
|
|
||||||
<div className="rounded-md border border-[rgba(0,0,0,0.08)] p-3 text-sm text-[#0f1724] bg-[#f8fafc]">
|
|
||||||
{document.content_html ? (
|
|
||||||
<div
|
|
||||||
className="prose prose-sm max-w-none"
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: document.content_html,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="whitespace-pre-wrap">
|
|
||||||
{document.content || "-"}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{activeTab === "sections" && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{isSectionsLoading ? (
|
||||||
|
<div className="text-sm text-[#6b7280] py-4">Loading sections...</div>
|
||||||
|
) : sections.length === 0 ? (
|
||||||
|
<div className="text-center py-10 text-sm text-[#6b7280]">
|
||||||
|
No sections defined for this document.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
sections.map((sec) => {
|
||||||
|
const statusColor =
|
||||||
|
sec.review_status === "reviewed"
|
||||||
|
? "bg-emerald-50 border-emerald-200"
|
||||||
|
: sec.review_status === "rework"
|
||||||
|
? "bg-amber-50 border-amber-200"
|
||||||
|
: "bg-white border-[rgba(0,0,0,0.08)]";
|
||||||
|
const isEditing = editingSectionId === sec.id;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={sec.id}
|
||||||
|
className={`border rounded-lg p-4 transition-colors ${statusColor}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3 mb-2">
|
||||||
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
|
{sec.section_number && (
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-wider text-[#94a3b8] shrink-0">
|
||||||
|
§{sec.section_number}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<h4 className="text-sm font-semibold text-[#0f1724] truncate">
|
||||||
|
{sec.title || "Untitled Section"}
|
||||||
|
</h4>
|
||||||
|
{sec.is_mandatory && (document.status === "in_review" || document.status === "draft") && (
|
||||||
|
<span className="text-[9px] font-bold uppercase px-1.5 py-0.5 bg-red-50 text-red-500 border border-red-100 rounded shrink-0">
|
||||||
|
Required
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
{/* Status badge - only in review/draft */}
|
||||||
|
{(document.status === "in_review" || document.status === "draft") && (
|
||||||
|
<span
|
||||||
|
className={`text-[9px] font-bold uppercase px-2 py-0.5 rounded border ${
|
||||||
|
sec.review_status === "reviewed"
|
||||||
|
? "bg-emerald-100 text-emerald-700 border-emerald-200"
|
||||||
|
: sec.review_status === "rework"
|
||||||
|
? "bg-amber-100 text-amber-700 border-amber-200"
|
||||||
|
: "bg-gray-100 text-gray-500 border-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{sec.review_status}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{/* Actions available only in review */}
|
||||||
|
{document.status === "in_review" && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Mark reviewed */}
|
||||||
|
{sec.review_status !== "reviewed" && id && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Mark as Reviewed"
|
||||||
|
className="p-1 rounded hover:bg-emerald-100 text-emerald-600 transition-colors"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
const res = await documentService.markSectionStatus(id, sec.id, "reviewed");
|
||||||
|
setSections((prev) =>
|
||||||
|
prev.map((s) => (s.id === sec.id ? res.data : s))
|
||||||
|
);
|
||||||
|
showToast.success("Section marked as reviewed");
|
||||||
|
} catch (e: any) {
|
||||||
|
showToast.error(e?.response?.data?.error?.message || "Failed to update section");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CheckCircle className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* Mark for rework */}
|
||||||
|
{sec.review_status !== "rework" && id && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Mark for Rework"
|
||||||
|
className="p-1 rounded hover:bg-amber-100 text-amber-600 transition-colors"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
const res = await documentService.markSectionStatus(id, sec.id, "rework");
|
||||||
|
setSections((prev) =>
|
||||||
|
prev.map((s) => (s.id === sec.id ? res.data : s))
|
||||||
|
);
|
||||||
|
showToast.success("Section marked for rework");
|
||||||
|
} catch (e: any) {
|
||||||
|
showToast.error(e?.response?.data?.error?.message || "Failed to update section");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<XCircle className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* Reset to pending */}
|
||||||
|
{sec.review_status !== "pending" && id && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Reset to Pending"
|
||||||
|
className="p-1 rounded hover:bg-gray-100 text-gray-400 transition-colors"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
const res = await documentService.markSectionStatus(id, sec.id, "pending");
|
||||||
|
setSections((prev) =>
|
||||||
|
prev.map((s) => (s.id === sec.id ? res.data : s))
|
||||||
|
);
|
||||||
|
showToast.success("Section reset to pending");
|
||||||
|
} catch (e: any) {
|
||||||
|
showToast.error(e?.response?.data?.error?.message || "Failed to update section");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* Edit toggle */}
|
||||||
|
{id && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-xs font-medium px-2 py-1 rounded border border-[rgba(0,0,0,0.08)] hover:bg-gray-50 text-[#475569] transition-colors"
|
||||||
|
onClick={() => {
|
||||||
|
if (isEditing) {
|
||||||
|
setEditingSectionId(null);
|
||||||
|
} else {
|
||||||
|
setEditingSectionId(sec.id);
|
||||||
|
setEditingSectionContent(sec.content || "");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isEditing ? "Cancel" : "Edit"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section content */}
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<textarea
|
||||||
|
rows={6}
|
||||||
|
className="w-full px-3 py-2 border border-[rgba(0,0,0,0.08)] rounded-md text-sm focus:outline-none focus:ring-2 resize-none"
|
||||||
|
style={{ boxShadow: `0 0 0 0px ${primaryColor}33` }}
|
||||||
|
value={editingSectionContent}
|
||||||
|
onChange={(e) => setEditingSectionContent(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={isSectionSaving}
|
||||||
|
className="px-3 py-1.5 rounded-md text-xs font-semibold text-white transition-opacity disabled:opacity-60"
|
||||||
|
style={{ backgroundColor: primaryColor }}
|
||||||
|
onClick={async () => {
|
||||||
|
if (!id) return;
|
||||||
|
setIsSectionSaving(true);
|
||||||
|
try {
|
||||||
|
const res = await documentService.updateSection(id, sec.id, {
|
||||||
|
content: editingSectionContent,
|
||||||
|
});
|
||||||
|
setSections((prev) =>
|
||||||
|
prev.map((s) => (s.id === sec.id ? res.data : s))
|
||||||
|
);
|
||||||
|
setEditingSectionId(null);
|
||||||
|
showToast.success("Section updated");
|
||||||
|
} catch (e: any) {
|
||||||
|
showToast.error(e?.response?.data?.error?.message || "Failed to save section");
|
||||||
|
} finally {
|
||||||
|
setIsSectionSaving(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isSectionSaving ? "Saving..." : "Save"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-[#374151] whitespace-pre-wrap">
|
||||||
|
{sec.content || <span className="italic text-[#94a3b8]">No content</span>}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add comment to this section (only in review) */}
|
||||||
|
{document.status === "in_review" && (
|
||||||
|
<div className="mt-3 pt-3 border-t border-[rgba(0,0,0,0.06)] flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-1 text-[11px] text-[#6b7280] hover:text-[#0f1724] transition-colors"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedSectionForComment(sec.id);
|
||||||
|
setActiveTab("comments");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MessageSquare className="w-3.5 h-3.5" />
|
||||||
|
Add comment
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === "comments" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Add comment form (only in review) */}
|
||||||
|
{document.status === "in_review" ? (
|
||||||
|
<div className="border border-[rgba(0,0,0,0.08)] rounded-lg p-4 bg-[#f9fafb] space-y-3">
|
||||||
|
<h4 className="text-xs font-semibold text-[#0f1724]">Add Comment</h4>
|
||||||
|
{sections.length > 0 && (
|
||||||
|
<select
|
||||||
|
className="h-9 w-full px-3 border border-[rgba(0,0,0,0.08)] rounded-md text-xs bg-white focus:outline-none"
|
||||||
|
value={selectedSectionForComment}
|
||||||
|
onChange={(e) => setSelectedSectionForComment(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Document-level comment</option>
|
||||||
|
{sections.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>
|
||||||
|
{s.section_number ? `§${s.section_number} — ` : ""}{s.title || "Untitled Section"}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
placeholder="Write a comment..."
|
||||||
|
className="w-full px-3 py-2 border border-[rgba(0,0,0,0.08)] rounded-md text-sm focus:outline-none resize-none"
|
||||||
|
value={newCommentText}
|
||||||
|
onChange={(e) => setNewCommentText(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={isAddingComment || !newCommentText.trim()}
|
||||||
|
className="px-3 py-1.5 rounded-md text-xs font-semibold text-white disabled:opacity-60 transition-opacity"
|
||||||
|
style={{ backgroundColor: primaryColor }}
|
||||||
|
onClick={async () => {
|
||||||
|
if (!id || !newCommentText.trim()) return;
|
||||||
|
setIsAddingComment(true);
|
||||||
|
try {
|
||||||
|
const res = await documentService.addComment(id, {
|
||||||
|
comment_text: newCommentText.trim(),
|
||||||
|
section_id: selectedSectionForComment || undefined,
|
||||||
|
});
|
||||||
|
setComments((prev) => [...prev, res.data]);
|
||||||
|
setNewCommentText("");
|
||||||
|
showToast.success("Comment added");
|
||||||
|
} catch (e: any) {
|
||||||
|
showToast.error(e?.response?.data?.error?.message || "Failed to add comment");
|
||||||
|
} finally {
|
||||||
|
setIsAddingComment(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isAddingComment ? "Posting..." : "Post Comment"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-[#6b7280] italic px-1">
|
||||||
|
Commenting is only available during the review phase.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Comment list */}
|
||||||
|
{isCommentsLoading ? (
|
||||||
|
<div className="text-sm text-[#6b7280] py-4">Loading comments...</div>
|
||||||
|
) : comments.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-sm text-[#6b7280]">No comments yet.</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{comments.map((c) => {
|
||||||
|
const sectionName = sections.find((s) => s.id === c.section_id)?.title;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={c.id}
|
||||||
|
className={`border rounded-lg p-4 ${
|
||||||
|
c.status === "resolved"
|
||||||
|
? "border-[rgba(0,0,0,0.06)] bg-[#f9fafb] opacity-70"
|
||||||
|
: "border-[rgba(0,0,0,0.08)] bg-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{/* Meta */}
|
||||||
|
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||||
|
<span className="text-[11px] font-semibold text-[#0f1724]">
|
||||||
|
{c.author_name || c.authored_by}
|
||||||
|
</span>
|
||||||
|
{sectionName && (
|
||||||
|
<span className="text-[10px] bg-blue-50 text-blue-600 border border-blue-100 rounded px-1.5 py-0.5">
|
||||||
|
§{sectionName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{c.workflow_step && (
|
||||||
|
<span className="text-[10px] bg-purple-50 text-purple-600 border border-purple-100 rounded px-1.5 py-0.5">
|
||||||
|
{c.workflow_step}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{c.status === "resolved" && (
|
||||||
|
<span className="text-[9px] font-bold uppercase bg-emerald-50 text-emerald-600 border border-emerald-100 rounded px-1.5 py-0.5">
|
||||||
|
Resolved
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-[10px] text-[#94a3b8] ml-auto">
|
||||||
|
{c.created_at ? new Date(c.created_at).toLocaleString("en-US", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-[#374151]">{c.comment_text}</p>
|
||||||
|
</div>
|
||||||
|
{/* Resolve button (only in review) */}
|
||||||
|
{c.status === "open" && id && document.status === "in_review" && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Resolve comment"
|
||||||
|
className="shrink-0 p-1 rounded hover:bg-emerald-100 text-emerald-600 transition-colors"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
const res = await documentService.resolveComment(id, c.id);
|
||||||
|
setComments((prev) =>
|
||||||
|
prev.map((cm) => (cm.id === c.id ? res.data : cm))
|
||||||
|
);
|
||||||
|
showToast.success("Comment resolved");
|
||||||
|
} catch (e: any) {
|
||||||
|
showToast.error(e?.response?.data?.error?.message || "Failed to resolve comment");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CheckCircle className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeTab === "version-history" && (
|
{activeTab === "version-history" && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
|
|||||||
@ -14,6 +14,7 @@ export interface ListDocumentsParams {
|
|||||||
document_type?: string;
|
document_type?: string;
|
||||||
owner_id?: string;
|
owner_id?: string;
|
||||||
source_module_id?: string;
|
source_module_id?: string;
|
||||||
|
source_record_id?: string;
|
||||||
search?: string;
|
search?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
@ -38,6 +39,19 @@ export interface CreateDocumentPayload {
|
|||||||
file_hash?: string;
|
file_hash?: string;
|
||||||
source_module?: string;
|
source_module?: string;
|
||||||
source_module_id?: string;
|
source_module_id?: string;
|
||||||
|
/** Links to a specific record in the source app (e.g. Report_ID_001) */
|
||||||
|
source_record_id?: string;
|
||||||
|
/** When provided, workflow is auto-started and document goes directly to in_review */
|
||||||
|
workflow_definition_id?: string;
|
||||||
|
/** Structured sections to create alongside the document */
|
||||||
|
sections?: Array<{
|
||||||
|
section_number?: string;
|
||||||
|
title?: string;
|
||||||
|
content?: string;
|
||||||
|
content_html?: string;
|
||||||
|
order_index?: number;
|
||||||
|
is_mandatory?: boolean;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileAttachmentItem {
|
export interface FileAttachmentItem {
|
||||||
@ -86,8 +100,30 @@ export const documentService = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
getFileContent: async (fileId: string): Promise<{ success: boolean; data: { html: string; text: string; original_name: string; file_path: string; file_size: number; mime_type: string; checksum: string } }> => {
|
getFileContent: async (fileId: string): Promise<{
|
||||||
const response = await apiClient.get<{ success: boolean; data: { html: string; text: string; original_name: string; file_path: string; file_size: number; mime_type: string; checksum: string } }>(`/files/${fileId}/content`);
|
success: boolean;
|
||||||
|
data: {
|
||||||
|
html: string;
|
||||||
|
text: string;
|
||||||
|
original_name: string;
|
||||||
|
file_path: string;
|
||||||
|
file_size: number;
|
||||||
|
mime_type: string;
|
||||||
|
checksum: string
|
||||||
|
}
|
||||||
|
}> => {
|
||||||
|
const response = await apiClient.get<{
|
||||||
|
success: boolean;
|
||||||
|
data: {
|
||||||
|
html: string;
|
||||||
|
text: string;
|
||||||
|
original_name: string;
|
||||||
|
file_path: string;
|
||||||
|
file_size: number;
|
||||||
|
mime_type: string;
|
||||||
|
checksum: string
|
||||||
|
}
|
||||||
|
}>(`/files/${fileId}/content`);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -100,6 +136,8 @@ export const documentService = {
|
|||||||
if (params.owner_id) queryParams.append("owner_id", params.owner_id);
|
if (params.owner_id) queryParams.append("owner_id", params.owner_id);
|
||||||
if (params.source_module_id)
|
if (params.source_module_id)
|
||||||
queryParams.append("source_module_id", params.source_module_id);
|
queryParams.append("source_module_id", params.source_module_id);
|
||||||
|
if (params.source_record_id)
|
||||||
|
queryParams.append("source_record_id", params.source_record_id);
|
||||||
if (params.search) queryParams.append("search", params.search);
|
if (params.search) queryParams.append("search", params.search);
|
||||||
if (params.limit !== undefined)
|
if (params.limit !== undefined)
|
||||||
queryParams.append("limit", params.limit.toString());
|
queryParams.append("limit", params.limit.toString());
|
||||||
@ -301,5 +339,87 @@ export const documentService = {
|
|||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Sections ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
getSections: async (
|
||||||
|
documentId: string,
|
||||||
|
): Promise<DocumentResponse<import("@/types/document").DocumentSection[]>> => {
|
||||||
|
const response = await apiClient.get(
|
||||||
|
`/documents/${documentId}/sections`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateSection: async (
|
||||||
|
documentId: string,
|
||||||
|
sectionId: string,
|
||||||
|
data: Partial<{
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
content_html: string;
|
||||||
|
section_number: string;
|
||||||
|
order_index: number;
|
||||||
|
is_mandatory: boolean;
|
||||||
|
}>,
|
||||||
|
): Promise<DocumentResponse<import("@/types/document").DocumentSection>> => {
|
||||||
|
const response = await apiClient.patch(
|
||||||
|
`/documents/${documentId}/sections/${sectionId}`,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
markSectionStatus: async (
|
||||||
|
documentId: string,
|
||||||
|
sectionId: string,
|
||||||
|
status: "reviewed" | "rework" | "pending",
|
||||||
|
): Promise<DocumentResponse<import("@/types/document").DocumentSection>> => {
|
||||||
|
const response = await apiClient.post(
|
||||||
|
`/documents/${documentId}/sections/${sectionId}/review`,
|
||||||
|
{ status },
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Comments ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
listComments: async (
|
||||||
|
documentId: string,
|
||||||
|
sectionId?: string,
|
||||||
|
): Promise<DocumentResponse<import("@/types/document").DocumentComment[]>> => {
|
||||||
|
const params = sectionId ? { section_id: sectionId } : {};
|
||||||
|
const response = await apiClient.get(
|
||||||
|
`/documents/${documentId}/comments`,
|
||||||
|
{ params },
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
addComment: async (
|
||||||
|
documentId: string,
|
||||||
|
payload: {
|
||||||
|
comment_text: string;
|
||||||
|
section_id?: string;
|
||||||
|
parent_id?: string;
|
||||||
|
},
|
||||||
|
): Promise<DocumentResponse<import("@/types/document").DocumentComment>> => {
|
||||||
|
const response = await apiClient.post(
|
||||||
|
`/documents/${documentId}/comments`,
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
resolveComment: async (
|
||||||
|
documentId: string,
|
||||||
|
commentId: string,
|
||||||
|
): Promise<DocumentResponse<import("@/types/document").DocumentComment>> => {
|
||||||
|
const response = await apiClient.patch(
|
||||||
|
`/documents/${documentId}/comments/${commentId}/resolve`,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -363,8 +363,28 @@ export const fileAttachmentService = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/** GET /files/:id/content — extract text/html */
|
/** GET /files/:id/content — extract text/html */
|
||||||
extractContent: async (id: string): Promise<{ success: boolean; data: { html: string; text: string; original_name: string; file_size: number; mime_type: string; checksum: string } }> => {
|
extractContent: async (id: string): Promise<{
|
||||||
const response = await apiClient.get(`/files/${id}/content`);
|
success: boolean;
|
||||||
|
data: {
|
||||||
|
html: string;
|
||||||
|
text: string;
|
||||||
|
original_name: string;
|
||||||
|
file_size: number;
|
||||||
|
mime_type: string;
|
||||||
|
checksum: string
|
||||||
|
}
|
||||||
|
}> => {
|
||||||
|
const response = await apiClient.get<{
|
||||||
|
success: boolean;
|
||||||
|
data: {
|
||||||
|
html: string;
|
||||||
|
text: string;
|
||||||
|
original_name: string;
|
||||||
|
file_size: number;
|
||||||
|
mime_type: string;
|
||||||
|
checksum: string
|
||||||
|
}
|
||||||
|
}>(`/files/${id}/content`);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -23,6 +23,7 @@ export interface DocumentSummary {
|
|||||||
next_review_date?: string | null;
|
next_review_date?: string | null;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
module_name?: string | null;
|
module_name?: string | null;
|
||||||
|
source_record_id?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DocumentDetail {
|
export interface DocumentDetail {
|
||||||
@ -57,6 +58,7 @@ export interface DocumentDetail {
|
|||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
source_module?: string | null;
|
source_module?: string | null;
|
||||||
source_module_id?: string | null;
|
source_module_id?: string | null;
|
||||||
|
source_record_id?: string | null;
|
||||||
category_id?: string | null;
|
category_id?: string | null;
|
||||||
module_name?: string | null;
|
module_name?: string | null;
|
||||||
}
|
}
|
||||||
@ -78,6 +80,56 @@ export interface DocumentVersion {
|
|||||||
module_name?: string | null;
|
module_name?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Section & Comment types (new) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
export type SectionReviewStatus = "pending" | "reviewed" | "rework";
|
||||||
|
|
||||||
|
export interface DocumentSection {
|
||||||
|
id: string;
|
||||||
|
document_id: string;
|
||||||
|
version_id: string;
|
||||||
|
section_number?: string | null;
|
||||||
|
title?: string | null;
|
||||||
|
content?: string | null;
|
||||||
|
content_html?: string | null;
|
||||||
|
order_index: number;
|
||||||
|
parent_id?: string | null;
|
||||||
|
review_status: SectionReviewStatus;
|
||||||
|
reviewed_by?: string | null;
|
||||||
|
reviewed_at?: string | null;
|
||||||
|
is_mandatory: boolean;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentComment {
|
||||||
|
id: string;
|
||||||
|
document_id: string;
|
||||||
|
section_id?: string | null;
|
||||||
|
comment_text: string;
|
||||||
|
workflow_step?: string | null;
|
||||||
|
authored_by: string;
|
||||||
|
author_name?: string | null;
|
||||||
|
status: "open" | "resolved";
|
||||||
|
resolved_by?: string | null;
|
||||||
|
resolved_at?: string | null;
|
||||||
|
parent_id?: string | null;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Payload for creating a document section ────────────────────────────────
|
||||||
|
|
||||||
|
export interface DocumentSectionInput {
|
||||||
|
section_number?: string;
|
||||||
|
title?: string;
|
||||||
|
content?: string;
|
||||||
|
content_html?: string;
|
||||||
|
order_index?: number;
|
||||||
|
parent_id?: string;
|
||||||
|
is_mandatory?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DocumentListResponse {
|
export interface DocumentListResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data: DocumentSummary[];
|
data: DocumentSummary[];
|
||||||
@ -93,4 +145,3 @@ export interface DocumentResponse<T> {
|
|||||||
data: T;
|
data: T;
|
||||||
message?: string;
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user