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]);
|
||||
|
||||
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 filteredOptions = options.filter(opt =>
|
||||
@ -145,10 +147,12 @@ export const FormSelect = ({
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
id={fieldId}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
onClick={() => !props.disabled && setIsOpen(!isOpen)}
|
||||
disabled={props.disabled}
|
||||
className={cn(
|
||||
'h-10 w-full px-3.5 py-1 bg-white border rounded-md text-sm transition-colors',
|
||||
'flex items-center justify-between',
|
||||
'disabled:bg-gray-50 disabled:text-gray-400 disabled:cursor-not-allowed',
|
||||
hasError
|
||||
? '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',
|
||||
|
||||
@ -124,4 +124,21 @@
|
||||
color: var(--color-foreground);
|
||||
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 { 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 * as z from "zod";
|
||||
import { Layout } from "@/components/layout/Layout";
|
||||
@ -11,16 +11,15 @@ import {
|
||||
PrimaryButton,
|
||||
RichTextEditor,
|
||||
FormTagInput,
|
||||
SecondaryButton,
|
||||
} from "@/components/shared";
|
||||
import {
|
||||
documentService,
|
||||
type FileAttachmentItem,
|
||||
} from "@/services/document-service";
|
||||
import { documentService, type FileAttachmentItem } from "@/services/document-service";
|
||||
import type { DocumentCategory } from "@/types/document";
|
||||
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 type { MyModule } from "@/types/module";
|
||||
import { workflowService } from "@/services/workflow-service";
|
||||
|
||||
const documentSchema = z.object({
|
||||
title: z.string().min(1, "Document title is required"),
|
||||
@ -30,8 +29,16 @@ const documentSchema = z.object({
|
||||
department: z.string().optional(),
|
||||
tags: z.array(z.string()),
|
||||
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(),
|
||||
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>;
|
||||
@ -42,6 +49,7 @@ const CreateDocument = (): ReactElement => {
|
||||
const [types, setTypes] = useState<Array<{ code: string; name: string }>>([]);
|
||||
const [categories, setCategories] = useState<DocumentCategory[]>([]);
|
||||
const [modules, setModules] = useState<MyModule[]>([]);
|
||||
const [workflowOptions, setWorkflowOptions] = useState<Array<{ value: string; label: string }>>([]);
|
||||
|
||||
const {
|
||||
control,
|
||||
@ -60,11 +68,19 @@ const CreateDocument = (): ReactElement => {
|
||||
department: "",
|
||||
tags: [],
|
||||
selectedModuleId: "",
|
||||
source_record_id: "",
|
||||
workflow_definition_id: "",
|
||||
content: "",
|
||||
contentHtml: "",
|
||||
sections: [],
|
||||
},
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: "sections",
|
||||
});
|
||||
|
||||
// File attachment fields
|
||||
const [files, setFiles] = useState<FileAttachmentItem[]>([]);
|
||||
const [selectedFileId, setSelectedFileId] = useState("");
|
||||
@ -74,6 +90,61 @@ const CreateDocument = (): ReactElement => {
|
||||
const [mimeType, setMimeType] = useState("");
|
||||
const [fileHash, setFileHash] = useState("");
|
||||
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(() => {
|
||||
const loadLookups = async (): Promise<void> => {
|
||||
@ -96,6 +167,44 @@ const CreateDocument = (): ReactElement => {
|
||||
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> => {
|
||||
setIsLoadingFiles(true);
|
||||
try {
|
||||
@ -110,6 +219,8 @@ const CreateDocument = (): ReactElement => {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
const handleFileSelect = async (fileId: string): Promise<void> => {
|
||||
setSelectedFileId(fileId);
|
||||
if (!fileId) {
|
||||
@ -138,9 +249,24 @@ const CreateDocument = (): ReactElement => {
|
||||
);
|
||||
const res = await documentService.getFileContent(fileId);
|
||||
if (res.success && res.data) {
|
||||
setValue("contentHtml", res.data.html || "");
|
||||
setValue("content", res.data.text || "");
|
||||
showToast.success(`Content loaded from "${selected.original_name}"`);
|
||||
setSourceHtml(res.data.html || "");
|
||||
setShowSourcePane(true); // Automatically show the split pane
|
||||
|
||||
// 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 {
|
||||
showToast.error("Failed to extract file content");
|
||||
}
|
||||
@ -148,12 +274,15 @@ const CreateDocument = (): ReactElement => {
|
||||
const msg =
|
||||
err?.response?.data?.error?.message || "Failed to extract file content";
|
||||
showToast.error(msg);
|
||||
const html = `<p>Document sourced from file: <strong>${selected.original_name}</strong></p>`;
|
||||
setValue("contentHtml", html);
|
||||
setValue(
|
||||
"content",
|
||||
`Document sourced from file: ${selected.original_name}`,
|
||||
);
|
||||
|
||||
// Fallback: create a section with error information
|
||||
while (fields.length > 0) remove(0);
|
||||
append({
|
||||
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,
|
||||
tags: data.tags || [],
|
||||
content: data.content?.trim() || undefined,
|
||||
content_html: data.contentHtml.trim() || undefined,
|
||||
content_html: data.contentHtml?.trim() || undefined,
|
||||
file_name: fileName || undefined,
|
||||
file_path: filePath || undefined,
|
||||
file_size: fileSize,
|
||||
@ -177,6 +306,15 @@ const CreateDocument = (): ReactElement => {
|
||||
source_module: modules.find((m) => m.id === data.selectedModuleId)!
|
||||
.module_id,
|
||||
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");
|
||||
navigate(`/tenant/documents/${response.data.id}`);
|
||||
@ -203,8 +341,83 @@ const CreateDocument = (): ReactElement => {
|
||||
}}
|
||||
>
|
||||
<form onSubmit={handleSubmit(onFormSubmit)} className="space-y-4">
|
||||
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] p-4 md:p-5">
|
||||
<div className="flex items-start justify-between gap-4 border-b border-[rgba(0,0,0,0.08)] pb-4 mb-4">
|
||||
<div className={showSourcePane ? "grid grid-cols-1 lg:grid-cols-12 gap-6 items-start" : "space-y-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="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]" />
|
||||
@ -214,8 +427,9 @@ const CreateDocument = (): ReactElement => {
|
||||
New Controlled Document
|
||||
</h3>
|
||||
<p className="text-xs text-[#6b7280] mt-1">
|
||||
Document will be created in{" "}
|
||||
<span className="font-medium">Draft</span> status.
|
||||
{watch("workflow_definition_id")
|
||||
? <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>
|
||||
</div>
|
||||
</div>
|
||||
@ -293,6 +507,12 @@ const CreateDocument = (): ReactElement => {
|
||||
error={errors.department?.message}
|
||||
{...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
|
||||
name="tags"
|
||||
control={control}
|
||||
@ -313,7 +533,10 @@ const CreateDocument = (): ReactElement => {
|
||||
label="Source Module"
|
||||
required
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
onValueChange={(val) => {
|
||||
field.onChange(val);
|
||||
setValue("workflow_definition_id", "");
|
||||
}}
|
||||
options={modules.map((m) => ({
|
||||
value: m.id,
|
||||
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>
|
||||
|
||||
@ -333,6 +586,15 @@ const CreateDocument = (): ReactElement => {
|
||||
<h3 className="text-sm font-semibold text-[#0f1724]">
|
||||
Load Content From File (Optional)
|
||||
</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>
|
||||
<p className="text-xs text-[#6b7280] mb-3">
|
||||
Select a previously uploaded file to automatically populate content
|
||||
@ -374,38 +636,120 @@ const CreateDocument = (): ReactElement => {
|
||||
)}
|
||||
</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="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-[#0f1724]">
|
||||
Initial Content
|
||||
</h3>
|
||||
<span className="text-[11px] text-[#94a3b8]">
|
||||
{watch("content")?.length || 0} characters
|
||||
</span>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="w-4 h-4 text-[#112868]" />
|
||||
<h3 className="text-sm font-semibold text-[#0f1724]">
|
||||
Structured Sections
|
||||
</h3>
|
||||
</div>
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() => append({ title: "", content: "", contentHtml: "", is_mandatory: true })}
|
||||
className="h-8 text-[11px]"
|
||||
>
|
||||
+ Add Section
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
<Controller
|
||||
name="contentHtml"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<RichTextEditor
|
||||
label="Content"
|
||||
value={field.value}
|
||||
required
|
||||
placeholder="Write the initial document content..."
|
||||
minHeightClassName="h-[400px] overflow-y-auto"
|
||||
onChange={(html, text) => {
|
||||
field.onChange(html);
|
||||
setValue("content", text);
|
||||
}}
|
||||
error={errors.contentHtml?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div className="mt-1 flex items-start gap-1.5 text-[11px] text-[#6b7280]">
|
||||
<Info className="w-3.5 h-3.5 mt-[1px] shrink-0" />
|
||||
<span>
|
||||
You can create new versions later from the document detail page.
|
||||
</span>
|
||||
|
||||
{fields.length > 0 ? (
|
||||
<div className="space-y-6">
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="p-4 border border-dashed border-[#e2e8f0] rounded-lg relative bg-[#fafafa]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(index)}
|
||||
className="absolute top-3 right-3 p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-md transition-colors"
|
||||
title="Remove Section"
|
||||
>
|
||||
<ArrowLeft className="w-3.5 h-3.5 rotate-45" />
|
||||
</button>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<FormField
|
||||
label={`Section ${index + 1} Title`}
|
||||
required
|
||||
placeholder="e.g. 1. Purpose, 2. Scope"
|
||||
error={errors.sections?.[index]?.title?.message}
|
||||
{...register(`sections.${index}.title` as const)}
|
||||
/>
|
||||
<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>
|
||||
|
||||
|
||||
@ -16,12 +16,12 @@ import {
|
||||
type FileAttachmentItem,
|
||||
} from "@/services/document-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 { cn } from "@/lib/utils";
|
||||
import { showToast } from "@/utils/toast";
|
||||
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 => {
|
||||
if (!value) return "-";
|
||||
@ -62,7 +62,7 @@ const ViewDocument = (): ReactElement => {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<
|
||||
"overview" | "version-history" | "workflow-history"
|
||||
"overview" | "sections" | "comments" | "version-history" | "workflow-history"
|
||||
>("overview");
|
||||
const [workflowHistory, setWorkflowHistory] = useState<any[]>([]);
|
||||
const [isHistoryLoading, setIsHistoryLoading] = useState(false);
|
||||
@ -93,6 +93,19 @@ const ViewDocument = (): ReactElement => {
|
||||
useState<WorkflowInstance | null>(null);
|
||||
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
|
||||
const [versionFiles, setVersionFiles] = useState<FileAttachmentItem[]>([]);
|
||||
const [versionSelectedFileId, setVersionSelectedFileId] = useState("");
|
||||
@ -141,6 +154,32 @@ const ViewDocument = (): ReactElement => {
|
||||
if (activeTab === "workflow-history" && id) {
|
||||
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]);
|
||||
|
||||
const loadWorkflowHistory = async (): Promise<void> => {
|
||||
@ -568,6 +607,30 @@ const ViewDocument = (): ReactElement => {
|
||||
}}
|
||||
>
|
||||
<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="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
@ -605,10 +668,10 @@ const ViewDocument = (): ReactElement => {
|
||||
</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="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
|
||||
type="button"
|
||||
className={`text-sm pb-2 transition-colors`}
|
||||
className={`text-sm pb-2 transition-colors whitespace-nowrap`}
|
||||
style={{
|
||||
color: activeTab === "overview" ? primaryColor : "#6b7280",
|
||||
borderBottom:
|
||||
@ -622,7 +685,35 @@ const ViewDocument = (): ReactElement => {
|
||||
</button>
|
||||
<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={{
|
||||
color:
|
||||
activeTab === "version-history" ? primaryColor : "#6b7280",
|
||||
@ -637,7 +728,7 @@ const ViewDocument = (): ReactElement => {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`text-sm pb-2 transition-colors`}
|
||||
className={`text-sm pb-2 transition-colors whitespace-nowrap`}
|
||||
style={{
|
||||
color:
|
||||
activeTab === "workflow-history" ? primaryColor : "#6b7280",
|
||||
@ -704,27 +795,354 @@ const ViewDocument = (): ReactElement => {
|
||||
</p>
|
||||
</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>
|
||||
)}
|
||||
{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" && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
|
||||
@ -14,6 +14,7 @@ export interface ListDocumentsParams {
|
||||
document_type?: string;
|
||||
owner_id?: string;
|
||||
source_module_id?: string;
|
||||
source_record_id?: string;
|
||||
search?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
@ -38,6 +39,19 @@ export interface CreateDocumentPayload {
|
||||
file_hash?: string;
|
||||
source_module?: 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 {
|
||||
@ -86,8 +100,30 @@ export const documentService = {
|
||||
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 } }> => {
|
||||
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`);
|
||||
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
|
||||
}
|
||||
}> => {
|
||||
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;
|
||||
},
|
||||
|
||||
@ -100,6 +136,8 @@ export const documentService = {
|
||||
if (params.owner_id) queryParams.append("owner_id", params.owner_id);
|
||||
if (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.limit !== undefined)
|
||||
queryParams.append("limit", params.limit.toString());
|
||||
@ -301,5 +339,87 @@ export const documentService = {
|
||||
);
|
||||
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 */
|
||||
extractContent: async (id: string): Promise<{ success: boolean; data: { html: string; text: string; original_name: string; file_size: number; mime_type: string; checksum: string } }> => {
|
||||
const response = await apiClient.get(`/files/${id}/content`);
|
||||
extractContent: async (id: string): Promise<{
|
||||
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;
|
||||
},
|
||||
};
|
||||
|
||||
@ -23,6 +23,7 @@ export interface DocumentSummary {
|
||||
next_review_date?: string | null;
|
||||
updated_at?: string;
|
||||
module_name?: string | null;
|
||||
source_record_id?: string | null;
|
||||
}
|
||||
|
||||
export interface DocumentDetail {
|
||||
@ -57,6 +58,7 @@ export interface DocumentDetail {
|
||||
updated_at?: string;
|
||||
source_module?: string | null;
|
||||
source_module_id?: string | null;
|
||||
source_record_id?: string | null;
|
||||
category_id?: string | null;
|
||||
module_name?: string | null;
|
||||
}
|
||||
@ -78,6 +80,56 @@ export interface DocumentVersion {
|
||||
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 {
|
||||
success: boolean;
|
||||
data: DocumentSummary[];
|
||||
@ -93,4 +145,3 @@ export interface DocumentResponse<T> {
|
||||
data: T;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user