From 6952a7c6f3b3955305f9533d17765b7e124e9b05 Mon Sep 17 00:00:00 2001 From: Yashwin Date: Fri, 15 May 2026 18:17:34 +0530 Subject: [PATCH] feat: implement document sections, workflow-enabled creation, text-selection annotation tools, and enhanced form controls --- src/components/shared/FormSelect.tsx | 8 +- src/index.css | 17 + src/pages/tenant/CreateDocument.tsx | 448 +++++++++++++++++++--- src/pages/tenant/ViewDocument.tsx | 470 ++++++++++++++++++++++-- src/services/document-service.ts | 124 ++++++- src/services/file-attachment-service.ts | 24 +- src/types/document.ts | 53 ++- 7 files changed, 1059 insertions(+), 85 deletions(-) diff --git a/src/components/shared/FormSelect.tsx b/src/components/shared/FormSelect.tsx index 0f64791..551420d 100644 --- a/src/components/shared/FormSelect.tsx +++ b/src/components/shared/FormSelect.tsx @@ -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', diff --git a/src/index.css b/src/index.css index 19ef123..07e0fe2 100644 --- a/src/index.css +++ b/src/index.css @@ -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; + } } \ No newline at end of file diff --git a/src/pages/tenant/CreateDocument.tsx b/src/pages/tenant/CreateDocument.tsx index e768ae4..6eccd70 100644 --- a/src/pages/tenant/CreateDocument.tsx +++ b/src/pages/tenant/CreateDocument.tsx @@ -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; @@ -42,6 +49,7 @@ const CreateDocument = (): ReactElement => { const [types, setTypes] = useState>([]); const [categories, setCategories] = useState([]); const [modules, setModules] = useState([]); + const [workflowOptions, setWorkflowOptions] = useState>([]); 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([]); 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: `

${selectionMenu.text}

`, + 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}

${selectionMenu.text}

`); + 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 => { @@ -96,6 +167,44 @@ const CreateDocument = (): ReactElement => { void loadFiles(); }, []); + const selectedModuleId = watch("selectedModuleId"); + + useEffect(() => { + const loadWorkflows = async (): Promise => { + 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 => { setIsLoadingFiles(true); try { @@ -110,6 +219,8 @@ const CreateDocument = (): ReactElement => { } }; + + const handleFileSelect = async (fileId: string): Promise => { 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 = `

Document sourced from file: ${selected.original_name}

`; - 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: `

Document sourced from file: ${selected.original_name}

`, + 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 => { }} >
-
-
+
+ + {/* LEFT PANE: Source Reference (Only visible if showSourcePane is true) */} + {showSourcePane && ( +
+
+
+
+
+ +
+
+

Document Reference

+

{fileName}

+
+
+ +
+
+
+
+ + {/* Floating Selection Menu */} + {selectionMenu && ( +
+ +
+ +
+ )} + +
+
+
+ +
+

+ Interactive Mode: 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. +

+
+
+
+
+ )} + + {/* RIGHT PANE: The Form */} +
+
+
@@ -214,8 +427,9 @@ const CreateDocument = (): ReactElement => { New Controlled Document

- Document will be created in{" "} - Draft status. + {watch("workflow_definition_id") + ? Document will go directly to In Review status upon creation. + : Document will be created in Draft status.}

@@ -293,6 +507,12 @@ const CreateDocument = (): ReactElement => { error={errors.department?.message} {...register("department")} /> + { 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 => { /> )} /> + ( + + )} + />
@@ -333,6 +586,15 @@ const CreateDocument = (): ReactElement => {

Load Content From File (Optional)

+ {sourceHtml && !showSourcePane && ( + + )}

Select a previously uploaded file to automatically populate content @@ -374,38 +636,120 @@ const CreateDocument = (): ReactElement => { )}

+ {/* Structured Sections Selection */}
-
-

- Initial Content -

- - {watch("content")?.length || 0} characters - +
+
+ +

+ Structured Sections +

+
+ append({ title: "", content: "", contentHtml: "", is_mandatory: true })} + className="h-8 text-[11px]" + > + + Add Section +
- ( - { - field.onChange(html); - setValue("content", text); - }} - error={errors.contentHtml?.message} - /> - )} - /> -
- - - You can create new versions later from the document detail page. - + + {fields.length > 0 ? ( +
+ {fields.map((field, index) => ( +
+ + +
+ +
+ + +
+
+ + ( + { + 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 && ( +
+
+ + Unresolved Feedback ({(field as any).comments.length}) +
+
+ {(field as any).comments.map((comment: any) => ( +
+
+ {comment.author_email} + {comment.workflow_step} +
+

{comment.comment_text}

+
+ ))} +
+
+ )} +
+ ))} +
+ ) : ( +
+ +

No structured sections added yet.

+

Use sections for granular review and collaboration.

+
+ )} +
+
+
+ +
+
+
+ +
+
+

Structured Review Enabled

+

All documents must use sections to enable granular review, QA validation, and section-level rework tracking.

+
diff --git a/src/pages/tenant/ViewDocument.tsx b/src/pages/tenant/ViewDocument.tsx index 4635ca7..d97a6ed 100644 --- a/src/pages/tenant/ViewDocument.tsx +++ b/src/pages/tenant/ViewDocument.tsx @@ -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(null); const [activeTab, setActiveTab] = useState< - "overview" | "version-history" | "workflow-history" + "overview" | "sections" | "comments" | "version-history" | "workflow-history" >("overview"); const [workflowHistory, setWorkflowHistory] = useState([]); const [isHistoryLoading, setIsHistoryLoading] = useState(false); @@ -93,6 +93,19 @@ const ViewDocument = (): ReactElement => { useState(null); const [isWorkflowLoading, setIsWorkflowLoading] = useState(false); + // Sections & Comments state + const [sections, setSections] = useState([]); + const [isSectionsLoading, setIsSectionsLoading] = useState(false); + const [editingSectionId, setEditingSectionId] = useState(null); + const [editingSectionContent, setEditingSectionContent] = useState(""); + const [isSectionSaving, setIsSectionSaving] = useState(false); + + const [comments, setComments] = useState([]); + const [isCommentsLoading, setIsCommentsLoading] = useState(false); + const [newCommentText, setNewCommentText] = useState(""); + const [isAddingComment, setIsAddingComment] = useState(false); + const [selectedSectionForComment, setSelectedSectionForComment] = useState(""); + // File attachment fields for new version const [versionFiles, setVersionFiles] = useState([]); 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 => { @@ -568,6 +607,30 @@ const ViewDocument = (): ReactElement => { }} >
+ {/* Governance Highlight */} +
+
+ +
+
+

+ Governed Document Workflow +

+

+ Approved document becomes official governed output for the source application. +

+
+
+
@@ -605,10 +668,10 @@ const ViewDocument = (): ReactElement => {
-
+
+ +
-
-

- Document Content -

-
- {document.content_html ? ( -
- ) : ( -
- {document.content || "-"} -
- )} -
-
)} + {activeTab === "sections" && ( +
+ {isSectionsLoading ? ( +
Loading sections...
+ ) : sections.length === 0 ? ( +
+ No sections defined for this document. +
+ ) : ( + 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 ( +
+
+
+ {sec.section_number && ( + + §{sec.section_number} + + )} +

+ {sec.title || "Untitled Section"} +

+ {sec.is_mandatory && (document.status === "in_review" || document.status === "draft") && ( + + Required + + )} +
+
+ {/* Status badge - only in review/draft */} + {(document.status === "in_review" || document.status === "draft") && ( + + {sec.review_status} + + )} + {/* Actions available only in review */} + {document.status === "in_review" && ( +
+ {/* Mark reviewed */} + {sec.review_status !== "reviewed" && id && ( + + )} + {/* Mark for rework */} + {sec.review_status !== "rework" && id && ( + + )} + {/* Reset to pending */} + {sec.review_status !== "pending" && id && ( + + )} + {/* Edit toggle */} + {id && ( + + )} +
+ )} +
+
+ + {/* Section content */} + {isEditing ? ( +
+