893 lines
34 KiB
TypeScript
893 lines
34 KiB
TypeScript
import { useEffect, useState, type ReactElement } from "react";
|
|
import { useNavigate } from "react-router-dom";
|
|
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";
|
|
import {
|
|
FormField,
|
|
FormSelect,
|
|
FormTextArea,
|
|
PrimaryButton,
|
|
RichTextEditor,
|
|
FormTagInput,
|
|
SecondaryButton,
|
|
} from "@/components/shared";
|
|
import {
|
|
documentService,
|
|
type FileAttachmentItem,
|
|
} from "@/services/document-service";
|
|
import type { DocumentCategory } from "@/types/document";
|
|
import { showToast } from "@/utils/toast";
|
|
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"),
|
|
description: z.string().optional(),
|
|
document_type: z.string().min(1, "Document type is required"),
|
|
category_id: z.string().optional(),
|
|
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().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>;
|
|
|
|
const CreateDocument = (): ReactElement => {
|
|
const navigate = useNavigate();
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
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,
|
|
register,
|
|
handleSubmit,
|
|
setValue,
|
|
watch,
|
|
formState: { errors },
|
|
} = useForm<DocumentFormData>({
|
|
resolver: zodResolver(documentSchema),
|
|
defaultValues: {
|
|
title: "",
|
|
description: "",
|
|
document_type: "",
|
|
category_id: "",
|
|
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("");
|
|
const [fileName, setFileName] = useState("");
|
|
const [filePath, setFilePath] = useState("");
|
|
const [fileSize, setFileSize] = useState<number | undefined>(undefined);
|
|
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> => {
|
|
try {
|
|
const [typesRes, categoriesRes, modulesRes] = await Promise.all([
|
|
documentService.getTypes(),
|
|
documentService.getCategories(),
|
|
moduleService.getMyModules(),
|
|
]);
|
|
setTypes(typesRes.data || []);
|
|
setCategories(categoriesRes.data || []);
|
|
setModules(modulesRes.data || []);
|
|
} catch (err: any) {
|
|
showToast.error(
|
|
err?.response?.data?.error?.message ||
|
|
"Failed to load document metadata",
|
|
);
|
|
}
|
|
};
|
|
void loadLookups();
|
|
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 {
|
|
const res = await documentService.listForDropdown();
|
|
setFiles(res.data || []);
|
|
} catch (err: any) {
|
|
showToast.error(
|
|
err?.response?.data?.error?.message || "Failed to load files",
|
|
);
|
|
} finally {
|
|
setIsLoadingFiles(false);
|
|
}
|
|
};
|
|
|
|
const handleFileSelect = async (fileId: string): Promise<void> => {
|
|
setSelectedFileId(fileId);
|
|
if (!fileId) {
|
|
setFileName("");
|
|
setFilePath("");
|
|
setFileSize(undefined);
|
|
setMimeType("");
|
|
setFileHash("");
|
|
setValue("contentHtml", "");
|
|
setValue("content", "");
|
|
return;
|
|
}
|
|
|
|
const selected = files.find((f) => f.id === fileId);
|
|
if (!selected) return;
|
|
|
|
setFileName(selected.original_name);
|
|
setFilePath(selected.file_path);
|
|
setFileSize(selected.file_size);
|
|
setMimeType(selected.mime_type);
|
|
setFileHash(selected.checksum);
|
|
|
|
try {
|
|
showToast.success(
|
|
`Extracting content from "${selected.original_name}"...`,
|
|
);
|
|
const res = await documentService.getFileContent(fileId);
|
|
if (res.success && res.data) {
|
|
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");
|
|
}
|
|
} catch (err: any) {
|
|
const msg =
|
|
err?.response?.data?.error?.message || "Failed to extract file content";
|
|
showToast.error(msg);
|
|
|
|
// 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,
|
|
});
|
|
}
|
|
};
|
|
|
|
const onFormSubmit = async (data: DocumentFormData): Promise<void> => {
|
|
try {
|
|
setIsSaving(true);
|
|
const response = await documentService.create({
|
|
title: data.title.trim(),
|
|
description: data.description?.trim() || undefined,
|
|
document_type: data.document_type,
|
|
category_id: data.category_id || undefined,
|
|
department: data.department?.trim() || undefined,
|
|
tags: data.tags || [],
|
|
content: data.content?.trim() || undefined,
|
|
content_html: data.contentHtml?.trim() || undefined,
|
|
file_name: fileName || undefined,
|
|
file_path: filePath || undefined,
|
|
file_size: fileSize,
|
|
mime_type: mimeType || undefined,
|
|
file_hash: fileHash || undefined,
|
|
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}`);
|
|
} catch (err: any) {
|
|
showToast.error(
|
|
err?.response?.data?.error?.message || "Failed to create document",
|
|
);
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Layout
|
|
currentPage="Document Service"
|
|
// breadcrumbs={[
|
|
// { label: "Document Service", path: "/tenant/documents" },
|
|
// { label: "Create Document" },
|
|
// ]}
|
|
pageHeader={{
|
|
title: "Create Document",
|
|
description:
|
|
"Fill in document details, classification and draft content before submitting for workflow.",
|
|
}}
|
|
>
|
|
<form onSubmit={handleSubmit(onFormSubmit)} className="space-y-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]" />
|
|
</div>
|
|
<div>
|
|
<h3 className="text-sm font-semibold text-[#0f1724]">
|
|
New Controlled Document
|
|
</h3>
|
|
<p className="text-xs text-[#6b7280] mt-1">
|
|
{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>
|
|
<button
|
|
type="button"
|
|
onClick={() => navigate("/tenant/documents")}
|
|
className="inline-flex items-center gap-1.5 text-xs text-[#475569] hover:text-[#0f1724]"
|
|
>
|
|
<ArrowLeft className="w-3.5 h-3.5" />
|
|
Back
|
|
</button>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 gap-x-4">
|
|
<FormField
|
|
label="Document Title"
|
|
required
|
|
placeholder="Enter document title"
|
|
error={errors.title?.message}
|
|
{...register("title")}
|
|
/>
|
|
</div>
|
|
|
|
<FormTextArea
|
|
label="Description"
|
|
placeholder="Brief description of this document"
|
|
error={errors.description?.message}
|
|
{...register("description")}
|
|
/>
|
|
</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-4 md:p-5">
|
|
<h3 className="text-sm font-semibold text-[#0f1724] mb-3">
|
|
Classification
|
|
</h3>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-4">
|
|
<Controller
|
|
name="document_type"
|
|
control={control}
|
|
render={({ field }) => (
|
|
<FormSelect
|
|
label="Document Type"
|
|
required
|
|
value={field.value}
|
|
onValueChange={field.onChange}
|
|
options={types.map((type) => ({
|
|
value: type.code,
|
|
label: type.name,
|
|
}))}
|
|
placeholder="Select type"
|
|
error={errors.document_type?.message}
|
|
/>
|
|
)}
|
|
/>
|
|
<Controller
|
|
name="category_id"
|
|
control={control}
|
|
render={({ field }) => (
|
|
<FormSelect
|
|
label="Category"
|
|
value={field.value}
|
|
onValueChange={field.onChange}
|
|
options={categories.map((category) => ({
|
|
value: category.id,
|
|
label: `${category.name} (${category.code})`,
|
|
}))}
|
|
placeholder="Select category"
|
|
error={errors.category_id?.message}
|
|
/>
|
|
)}
|
|
/>
|
|
<FormField
|
|
label="Department"
|
|
placeholder="Optional department name"
|
|
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}
|
|
render={({ field }) => (
|
|
<FormTagInput
|
|
label="Tags (Press enter to add)"
|
|
value={field.value || []}
|
|
onChange={field.onChange}
|
|
error={errors.tags?.message}
|
|
/>
|
|
)}
|
|
/>
|
|
<Controller
|
|
name="selectedModuleId"
|
|
control={control}
|
|
render={({ field }) => (
|
|
<FormSelect
|
|
label="Source Module"
|
|
required
|
|
value={field.value}
|
|
onValueChange={(val) => {
|
|
field.onChange(val);
|
|
setValue("workflow_definition_id", "");
|
|
}}
|
|
options={modules.map((m) => ({
|
|
value: m.id,
|
|
label: m.name,
|
|
}))}
|
|
placeholder="Select originating module"
|
|
error={errors.selectedModuleId?.message}
|
|
/>
|
|
)}
|
|
/>
|
|
<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>
|
|
|
|
{/* File Attachment 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 gap-2 mb-3">
|
|
<Paperclip className="w-4 h-4 text-[#112868]" />
|
|
<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 and file metadata.
|
|
</p>
|
|
<FormSelect
|
|
label="Select File"
|
|
value={selectedFileId}
|
|
onValueChange={handleFileSelect}
|
|
options={[
|
|
{ value: "", label: "— None —" },
|
|
...files.map((f) => ({
|
|
value: f.id,
|
|
label: `${f.original_name} (${f.file_size_formatted})`,
|
|
})),
|
|
]}
|
|
placeholder={
|
|
isLoadingFiles
|
|
? "Loading files..."
|
|
: "Select a file to attach"
|
|
}
|
|
/>
|
|
|
|
{selectedFileId && (
|
|
<div className="mt-3 grid grid-cols-1 md:grid-cols-2 gap-x-3 gap-y-0 text-xs text-[#6b7280]">
|
|
<p>
|
|
<span className="font-medium">File:</span> {fileName}
|
|
</p>
|
|
<p>
|
|
<span className="font-medium">Type:</span> {mimeType}
|
|
</p>
|
|
<p>
|
|
<span className="font-medium">Size:</span>{" "}
|
|
{fileSize
|
|
? `${(fileSize / 1024 / 1024).toFixed(2)} MB`
|
|
: "-"}
|
|
</p>
|
|
<p>
|
|
<span className="font-medium">Hash:</span>{" "}
|
|
{fileHash ? fileHash.substring(0, 16) + "..." : "-"}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Structured Sections Selection */}
|
|
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] p-4 md:p-5">
|
|
<div className="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>
|
|
|
|
{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>
|
|
|
|
<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 justify-end gap-2 mt-1">
|
|
<button
|
|
type="button"
|
|
className="h-10 px-4 border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0f1724] hover:bg-gray-50"
|
|
onClick={() => navigate("/tenant/documents")}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={isSaving}
|
|
className="h-10 px-4 border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#112868] hover:bg-[#f8fafc] disabled:opacity-60"
|
|
onClick={handleSubmit(onFormSubmit)}
|
|
>
|
|
{isSaving ? "Saving..." : "Save As Draft"}
|
|
</button>
|
|
<PrimaryButton
|
|
type="submit"
|
|
disabled={isSaving}
|
|
onClick={handleSubmit(onFormSubmit)}
|
|
>
|
|
{isSaving ? "Creating..." : "Create Document"}
|
|
</PrimaryButton>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</Layout>
|
|
);
|
|
};
|
|
|
|
export default CreateDocument;
|