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]);
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',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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