feat: implement document sections, workflow-enabled creation, text-selection annotation tools, and enhanced form controls

This commit is contained in:
Yashwin 2026-05-15 18:17:34 +05:30
parent 8f5638b4f9
commit 6952a7c6f3
7 changed files with 1059 additions and 85 deletions

View File

@ -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',

View File

@ -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;
}
} }

View File

@ -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>

View File

@ -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">

View File

@ -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;
},
}; };

View File

@ -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;
}, },
}; };

View File

@ -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;
} }