feat: Add document management features including document creation, categorization, and a rich text editor for content editing.

This commit is contained in:
Yashwin 2026-03-23 18:58:58 +05:30
parent 4b76f71cf4
commit 083d10fdff
13 changed files with 3054 additions and 19 deletions

876
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -14,6 +14,16 @@
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@reduxjs/toolkit": "^2.11.2", "@reduxjs/toolkit": "^2.11.2",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"@tiptap/extension-color": "^3.20.4",
"@tiptap/extension-font-family": "^3.20.4",
"@tiptap/extension-highlight": "^3.20.4",
"@tiptap/extension-link": "^3.20.4",
"@tiptap/extension-placeholder": "^3.20.4",
"@tiptap/extension-text-align": "^3.20.4",
"@tiptap/extension-text-style": "^3.20.4",
"@tiptap/extension-underline": "^3.20.4",
"@tiptap/react": "^3.20.4",
"@tiptap/starter-kit": "^3.20.4",
"axios": "^1.13.2", "axios": "^1.13.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",

View File

@ -86,6 +86,12 @@ const tenantAdminPlatformMenu: MenuItem[] = [
path: "/tenant/suppliers", path: "/tenant/suppliers",
requiredPermission: { resource: "supplier" }, requiredPermission: { resource: "supplier" },
}, },
{
icon: FileText,
label: "Document Service",
path: "/tenant/documents",
requiredPermission: { resource: "document" },
},
{ icon: Package, label: "Modules", path: "/tenant/modules" }, { icon: Package, label: "Modules", path: "/tenant/modules" },
]; ];
@ -231,7 +237,11 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
<div className="flex flex-col gap-1 mt-1"> <div className="flex flex-col gap-1 mt-1">
{items.map((item) => { {items.map((item) => {
const Icon = item.icon; const Icon = item.icon;
const isActive = location.pathname === item.path; const isTenantDashboardPath = item.path === "/tenant";
const isActive = isTenantDashboardPath
? location.pathname === "/tenant"
: location.pathname === item.path ||
location.pathname.startsWith(`${item.path}/`);
return ( return (
<Link <Link
key={item.path} key={item.path}

View File

@ -27,7 +27,7 @@ const defaultTabs: TabItem[] = [
export const PageHeader = ({ export const PageHeader = ({
title, title,
description, description,
tabs = defaultTabs, tabs,
}: PageHeaderProps): ReactElement => { }: PageHeaderProps): ReactElement => {
const location = useLocation(); const location = useLocation();
const { roles } = useAppSelector((state) => state.auth); const { roles } = useAppSelector((state) => state.auth);
@ -45,14 +45,17 @@ export const PageHeader = ({
} }
const isSuperAdmin = rolesArray.includes('super_admin'); const isSuperAdmin = rolesArray.includes('super_admin');
const isActiveTab = (path: string): boolean => { const isPathMatch = (tabPath: string): boolean =>
// Exact match for dashboard location.pathname === tabPath || location.pathname.startsWith(`${tabPath}/`);
if (path === '/dashboard') {
return location.pathname === '/dashboard'; const resolvedTabs = tabs ?? (isSuperAdmin ? defaultTabs : []);
}
// For other paths, check if current path starts with the tab path // Pick the most specific matching tab (longest path), so parent tabs
return location.pathname.startsWith(path); // like /tenant/documents don't stay active on child routes.
}; const activeTabPath =
resolvedTabs
.filter((tab) => isPathMatch(tab.path))
.sort((a, b) => b.path.length - a.path.length)[0]?.path ?? null;
return ( return (
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-4 md:gap-6 mb-6"> <div className="flex flex-col md:flex-row md:items-start md:justify-between gap-4 md:gap-6 mb-6">
@ -69,10 +72,10 @@ export const PageHeader = ({
</div> </div>
{/* Tabs Navigation - Only show for super_admin */} {/* Tabs Navigation - Only show for super_admin */}
{isSuperAdmin && tabs.length > 0 && ( {resolvedTabs.length > 0 && (
<div className="border border-[rgba(0,0,0,0.2)] rounded-md p-1.5 flex gap-2 overflow-x-auto"> <div className="border border-[rgba(0,0,0,0.2)] rounded-md p-1.5 flex gap-2 overflow-x-auto">
{tabs.map((tab) => { {resolvedTabs.map((tab) => {
const isActive = isActiveTab(tab.path); const isActive = tab.path === activeTabPath;
return ( return (
<Link <Link
key={tab.path} key={tab.path}

View File

@ -0,0 +1,477 @@
import { useEffect, useMemo, useState, type ReactElement } from "react";
import { EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { Extension } from "@tiptap/core";
import Underline from "@tiptap/extension-underline";
import TextAlign from "@tiptap/extension-text-align";
import Link from "@tiptap/extension-link";
import { TextStyle } from "@tiptap/extension-text-style";
import Color from "@tiptap/extension-color";
import Highlight from "@tiptap/extension-highlight";
import Placeholder from "@tiptap/extension-placeholder";
import FontFamily from "@tiptap/extension-font-family";
import {
AlignCenter,
AlignJustify,
AlignLeft,
AlignRight,
Bold,
ChevronDown,
ChevronUp,
Eraser,
Highlighter,
IndentDecrease,
IndentIncrease,
Italic,
Link2,
List,
ListOrdered,
RemoveFormatting,
Quote,
Redo2,
Strikethrough,
Underline as UnderlineIcon,
Undo2,
} from "lucide-react";
import { cn } from "@/lib/utils";
interface RichTextEditorProps {
label: string;
value: string;
onChange: (html: string, text: string) => void;
placeholder?: string;
required?: boolean;
error?: string;
minHeightClassName?: string;
}
const FontSize = Extension.create({
name: "fontSize",
addGlobalAttributes() {
return [
{
types: ["textStyle"],
attributes: {
fontSize: {
default: null,
parseHTML: (element: HTMLElement) => element.style.fontSize || null,
renderHTML: (attributes: Record<string, string>) => {
if (!attributes.fontSize) return {};
return { style: `font-size: ${attributes.fontSize}` };
},
},
},
},
];
},
addCommands() {
return {
setFontSize:
(fontSize: string) =>
({ chain }: { chain: any }) => {
return chain().setMark("textStyle", { fontSize }).run();
},
unsetFontSize:
() =>
({ chain }: { chain: any }) => {
return chain().setMark("textStyle", { fontSize: null }).run();
},
} as any;
},
});
const ToolButton = ({
isActive,
onClick,
title,
children,
}: {
isActive?: boolean;
onClick: () => void;
title: string;
children: React.ReactNode;
}): ReactElement => (
<button
type="button"
onMouseDown={(event) => {
// Keep editor selection/focus while clicking toolbar actions.
event.preventDefault();
}}
onClick={onClick}
title={title}
className={cn(
"h-8 w-8 rounded border text-[#334155] flex items-center justify-center",
isActive
? "bg-[#112868]/10 border-[#112868]/20"
: "border-transparent hover:border-[rgba(0,0,0,0.08)] hover:bg-white",
)}
>
{children}
</button>
);
export const RichTextEditor = ({
label,
value,
onChange,
placeholder = "Start writing...",
required = false,
error,
minHeightClassName = "min-h-[300px]",
}: RichTextEditorProps): ReactElement => {
const [showMoreTools, setShowMoreTools] = useState(false);
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: {
levels: [1, 2, 3],
},
}),
Underline,
TextStyle,
FontFamily.configure({
types: ["textStyle"],
}),
FontSize,
Color,
Highlight.configure({
multicolor: true,
}),
Link.configure({
openOnClick: false,
autolink: true,
}),
TextAlign.configure({
types: ["heading", "paragraph"],
}),
Placeholder.configure({
placeholder,
}),
],
content: value || "",
onUpdate: ({ editor: currentEditor }) => {
onChange(currentEditor.getHTML(), currentEditor.getText());
},
});
useEffect(() => {
if (!editor) return;
if (value !== editor.getHTML()) {
editor.commands.setContent(value || "", { emitUpdate: false });
}
}, [value, editor]);
const activeBlock = useMemo((): string => {
if (!editor) return "paragraph";
if (editor.isActive("heading", { level: 1 })) return "h1";
if (editor.isActive("heading", { level: 2 })) return "h2";
if (editor.isActive("heading", { level: 3 })) return "h3";
return "paragraph";
}, [editor, editor?.state]);
const activeFontFamily = editor?.getAttributes("textStyle").fontFamily || "Verdana";
const activeFontSize = editor?.getAttributes("textStyle").fontSize || "12pt";
const applyBlockStyle = (value: string): void => {
if (!editor) return;
const chain = editor.chain().focus();
if (value === "paragraph") {
chain.setParagraph().run();
return;
}
const level = Number(value.replace("h", ""));
if ([1, 2, 3].includes(level)) {
chain.setHeading({ level: level as 1 | 2 | 3 }).run();
}
};
return (
<div className="flex flex-col gap-2 pb-1">
<label className="flex items-center gap-1 text-[13px] font-medium text-[#0e1b2a]">
<span>{label}</span>
{required && <span className="text-[#e02424]">*</span>}
</label>
<div
className={cn(
"border rounded-md overflow-hidden bg-white",
error ? "border-[#ef4444]" : "border-[rgba(0,0,0,0.08)]",
)}
>
<div className="flex flex-wrap items-center gap-1 p-2 border-b border-[rgba(0,0,0,0.08)] bg-[#f8fafc]">
<ToolButton
title="Plain Text"
onClick={() =>
editor
?.chain()
.focus()
.unsetAllMarks()
.clearNodes()
.setParagraph()
.unsetColor()
.run()
}
>
<RemoveFormatting className="w-3.5 h-3.5" />
</ToolButton>
<div className="h-5 w-px bg-[rgba(0,0,0,0.08)] mx-1" />
<ToolButton
title="Bold"
isActive={editor?.isActive("bold")}
onClick={() => editor?.chain().focus().toggleBold().run()}
>
<Bold className="w-3.5 h-3.5" />
</ToolButton>
<ToolButton
title="Italic"
isActive={editor?.isActive("italic")}
onClick={() => editor?.chain().focus().toggleItalic().run()}
>
<Italic className="w-3.5 h-3.5" />
</ToolButton>
<ToolButton
title="Underline"
isActive={editor?.isActive("underline")}
onClick={() => editor?.chain().focus().toggleUnderline().run()}
>
<UnderlineIcon className="w-3.5 h-3.5" />
</ToolButton>
<div className="h-5 w-px bg-[rgba(0,0,0,0.08)] mx-1" />
<ToolButton
title="Align Left"
isActive={editor?.isActive({ textAlign: "left" })}
onClick={() => editor?.chain().focus().setTextAlign("left").run()}
>
<AlignLeft className="w-3.5 h-3.5" />
</ToolButton>
<ToolButton
title="Align Center"
isActive={editor?.isActive({ textAlign: "center" })}
onClick={() => editor?.chain().focus().setTextAlign("center").run()}
>
<AlignCenter className="w-3.5 h-3.5" />
</ToolButton>
<ToolButton
title="Align Right"
isActive={editor?.isActive({ textAlign: "right" })}
onClick={() => editor?.chain().focus().setTextAlign("right").run()}
>
<AlignRight className="w-3.5 h-3.5" />
</ToolButton>
<ToolButton
title="Justify"
isActive={editor?.isActive({ textAlign: "justify" })}
onClick={() => editor?.chain().focus().setTextAlign("justify").run()}
>
<AlignJustify className="w-3.5 h-3.5" />
</ToolButton>
<div className="h-5 w-px bg-[rgba(0,0,0,0.08)] mx-1" />
<select
value={activeBlock}
onChange={(event) => applyBlockStyle(event.target.value)}
className="h-8 px-2 rounded border border-[rgba(0,0,0,0.08)] bg-white text-xs text-[#334155]"
title="Block style"
>
<option value="paragraph">Paragraph</option>
<option value="h1">Heading 1</option>
<option value="h2">Heading 2</option>
<option value="h3">Heading 3</option>
</select>
<select
value={activeFontFamily}
onChange={(event) =>
editor?.chain().focus().setFontFamily(event.target.value).run()
}
className="h-8 px-2 rounded border border-[rgba(0,0,0,0.08)] bg-white text-xs text-[#334155]"
title="Font family"
>
<option value="Verdana">Verdana</option>
<option value="Arial">Arial</option>
<option value="Times New Roman">Times New Roman</option>
<option value="Georgia">Georgia</option>
<option value="Tahoma">Tahoma</option>
</select>
<select
value={activeFontSize}
onChange={(event) =>
(editor as any)?.chain().focus().setFontSize(event.target.value).run()
}
className="h-8 px-2 rounded border border-[rgba(0,0,0,0.08)] bg-white text-xs text-[#334155]"
title="Font size"
>
<option value="10pt">10pt</option>
<option value="11pt">11pt</option>
<option value="12pt">12pt</option>
<option value="14pt">14pt</option>
<option value="16pt">16pt</option>
<option value="18pt">18pt</option>
<option value="24pt">24pt</option>
</select>
<div className="h-5 w-px bg-[rgba(0,0,0,0.08)] mx-1" />
<input
type="color"
title="Text Color"
className="h-8 w-8 rounded border border-[rgba(0,0,0,0.08)] bg-white cursor-pointer"
onChange={(event) =>
editor?.chain().focus().setColor(event.target.value).run()
}
/>
<input
type="color"
title="Background Color"
className="h-8 w-8 rounded border border-[rgba(0,0,0,0.08)] bg-white cursor-pointer"
onChange={(event) =>
editor
?.chain()
.focus()
.setHighlight({ color: event.target.value })
.run()
}
/>
<ToolButton
title="More Options"
onClick={() => setShowMoreTools((prev) => !prev)}
>
{showMoreTools ? (
<ChevronUp className="w-3.5 h-3.5" />
) : (
<ChevronDown className="w-3.5 h-3.5" />
)}
</ToolButton>
</div>
{showMoreTools && (
<div className="flex flex-wrap items-center gap-1 p-2 border-b border-[rgba(0,0,0,0.08)] bg-[#f1f5f9]">
<ToolButton
title="Bullet List"
isActive={editor?.isActive("bulletList")}
onClick={() => editor?.chain().focus().toggleBulletList().run()}
>
<List className="w-3.5 h-3.5" />
</ToolButton>
<ToolButton
title="Numbered List"
isActive={editor?.isActive("orderedList")}
onClick={() => editor?.chain().focus().toggleOrderedList().run()}
>
<ListOrdered className="w-3.5 h-3.5" />
</ToolButton>
<ToolButton
title="Decrease Indent"
onClick={() =>
editor?.chain().focus().liftListItem("listItem").run()
}
>
<IndentDecrease className="w-3.5 h-3.5" />
</ToolButton>
<ToolButton
title="Increase Indent"
onClick={() =>
editor?.chain().focus().sinkListItem("listItem").run()
}
>
<IndentIncrease className="w-3.5 h-3.5" />
</ToolButton>
<ToolButton
title="Blockquote"
isActive={editor?.isActive("blockquote")}
onClick={() => editor?.chain().focus().toggleBlockquote().run()}
>
<Quote className="w-3.5 h-3.5" />
</ToolButton>
<ToolButton
title="Insert Link"
isActive={editor?.isActive("link")}
onClick={() => {
if (!editor) return;
const selection = editor.state.selection;
const url = window.prompt("Enter URL");
if (url) {
const normalizedUrl = /^https?:\/\//i.test(url)
? url
: `https://${url}`;
const isSelectionEmpty = selection.empty;
const from = selection.from;
const to = selection.to;
if (isSelectionEmpty) {
editor
.chain()
.focus()
.setTextSelection({ from, to })
.insertContent(normalizedUrl)
.setTextSelection({
from,
to: from + normalizedUrl.length,
})
.setLink({ href: normalizedUrl })
.run();
return;
}
editor
.chain()
.focus()
.setTextSelection({ from, to })
.extendMarkRange("link")
.setLink({ href: normalizedUrl })
.run();
}
}}
>
<Link2 className="w-3.5 h-3.5" />
</ToolButton>
<ToolButton
title="Remove Link"
onClick={() => editor?.chain().focus().unsetLink().run()}
>
<Link2 className="w-3.5 h-3.5 opacity-60" />
</ToolButton>
<ToolButton
title="Highlight"
isActive={editor?.isActive("highlight")}
onClick={() => editor?.chain().focus().toggleHighlight().run()}
>
<Highlighter className="w-3.5 h-3.5" />
</ToolButton>
<ToolButton
title="Strike"
isActive={editor?.isActive("strike")}
onClick={() => editor?.chain().focus().toggleStrike().run()}
>
<Strikethrough className="w-3.5 h-3.5" />
</ToolButton>
<ToolButton
title="Clear Formatting"
onClick={() =>
editor?.chain().focus().unsetAllMarks().clearNodes().run()
}
>
<Eraser className="w-3.5 h-3.5" />
</ToolButton>
<ToolButton
title="Undo"
onClick={() => editor?.chain().focus().undo().run()}
>
<Undo2 className="w-3.5 h-3.5" />
</ToolButton>
<ToolButton
title="Redo"
onClick={() => editor?.chain().focus().redo().run()}
>
<Redo2 className="w-3.5 h-3.5" />
</ToolButton>
</div>
)}
<EditorContent
editor={editor}
className={cn(
"p-3 text-sm text-[#0f1724] [&_.ProseMirror]:outline-none [&_.ProseMirror]:min-h-[inherit] [&_.ProseMirror]:leading-6 [&_.ProseMirror_p.is-editor-empty:first-child::before]:text-[#9aa6b2] [&_.ProseMirror_p.is-editor-empty:first-child::before]:content-[attr(data-placeholder)] [&_.ProseMirror_p.is-editor-empty:first-child::before]:float-left [&_.ProseMirror_p.is-editor-empty:first-child::before]:pointer-events-none [&_.ProseMirror_h1]:text-[30px] [&_.ProseMirror_h1]:font-bold [&_.ProseMirror_h1]:leading-tight [&_.ProseMirror_h1]:my-2 [&_.ProseMirror_h2]:text-[24px] [&_.ProseMirror_h2]:font-semibold [&_.ProseMirror_h2]:leading-tight [&_.ProseMirror_h2]:my-2 [&_.ProseMirror_h3]:text-[20px] [&_.ProseMirror_h3]:font-semibold [&_.ProseMirror_h3]:leading-tight [&_.ProseMirror_h3]:my-2 [&_.ProseMirror_ul]:list-disc [&_.ProseMirror_ul]:pl-6 [&_.ProseMirror_ol]:list-decimal [&_.ProseMirror_ol]:pl-6 [&_.ProseMirror_li]:my-1 [&_.ProseMirror_blockquote]:border-l-4 [&_.ProseMirror_blockquote]:border-[#cbd5e1] [&_.ProseMirror_blockquote]:pl-3 [&_.ProseMirror_blockquote]:text-[#475569] [&_.ProseMirror_a]:text-[#084cc8] [&_.ProseMirror_a]:underline",
minHeightClassName,
)}
/>
</div>
{error && <p className="text-sm text-[#ef4444]">{error}</p>}
</div>
);
};

View File

@ -34,3 +34,4 @@ export { ViewSupplierModal } from './ViewSupplierModal';
export { SupplierContactsModal } from './SupplierContactsModal'; export { SupplierContactsModal } from './SupplierContactsModal';
export { SupplierScorecardsModal } from './SupplierScorecardsModal'; export { SupplierScorecardsModal } from './SupplierScorecardsModal';
export { FormTextArea } from './FormTextArea'; export { FormTextArea } from './FormTextArea';
export { RichTextEditor } from './RichTextEditor';

View File

@ -0,0 +1,234 @@
import { useEffect, useState, type ReactElement } from "react";
import { useNavigate } from "react-router-dom";
import { Layout } from "@/components/layout/Layout";
import { FormField, FormSelect, FormTextArea, PrimaryButton, RichTextEditor } from "@/components/shared";
import { documentService } from "@/services/document-service";
import type { DocumentCategory } from "@/types/document";
import { showToast } from "@/utils/toast";
import { ArrowLeft, FileText, Info } from "lucide-react";
const CreateDocument = (): ReactElement => {
const navigate = useNavigate();
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [documentNumber, setDocumentNumber] = useState("");
const [documentType, setDocumentType] = useState("");
const [categoryId, setCategoryId] = useState("");
const [department, setDepartment] = useState("");
const [tags, setTags] = useState("");
const [content, setContent] = useState("");
const [contentHtml, setContentHtml] = useState("");
const [isSaving, setIsSaving] = useState(false);
const [types, setTypes] = useState<Array<{ code: string; name: string }>>([]);
const [categories, setCategories] = useState<DocumentCategory[]>([]);
useEffect(() => {
const loadLookups = async (): Promise<void> => {
try {
const [typesRes, categoriesRes] = await Promise.all([
documentService.getTypes(),
documentService.getCategories(),
]);
setTypes(typesRes.data || []);
setCategories(categoriesRes.data || []);
} catch {
showToast.error("Failed to load document metadata");
}
};
void loadLookups();
}, []);
const onSubmit = async (event: React.FormEvent): Promise<void> => {
event.preventDefault();
if (!title.trim() || !documentType) {
showToast.error("Title and document type are required");
return;
}
try {
setIsSaving(true);
const response = await documentService.create({
title: title.trim(),
description: description.trim() || undefined,
document_number: documentNumber.trim() || undefined,
document_type: documentType,
category_id: categoryId || undefined,
department: department.trim() || undefined,
tags: tags
.split(",")
.map((tag) => tag.trim())
.filter(Boolean),
content: content.trim() || undefined,
content_html: contentHtml.trim() || undefined,
});
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.",
tabs: [
{ label: "Document List", path: "/tenant/documents" },
{ label: "Create Document", path: "/tenant/documents/create" },
{ label: "Category Management", path: "/tenant/documents/categories" },
],
}}
>
<form onSubmit={onSubmit} 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="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">
Document will be created in <span className="font-medium">Draft</span> status.
</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 md:grid-cols-2 gap-x-4">
<FormField
label="Document Title"
required
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Enter document title"
/>
<FormField
label="Document Number"
value={documentNumber}
onChange={(e) => setDocumentNumber(e.target.value)}
placeholder="Auto-generated if empty"
/>
</div>
<FormTextArea
label="Description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Brief description of this document"
/>
</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">
<FormSelect
label="Document Type"
required
value={documentType}
onValueChange={setDocumentType}
options={types.map((type) => ({ value: type.code, label: type.name }))}
placeholder="Select type"
/>
<FormSelect
label="Category"
value={categoryId}
onValueChange={setCategoryId}
options={categories.map((category) => ({
value: category.id,
label: `${category.name} (${category.code})`,
}))}
placeholder="Select category"
/>
<FormField
label="Department"
value={department}
onChange={(e) => setDepartment(e.target.value)}
placeholder="Optional"
/>
<FormField
label="Tags"
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder="Comma separated tags (e.g. quality, sop)"
/>
</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 items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-[#0f1724]">Initial Content</h3>
<span className="text-[11px] text-[#94a3b8]">
{content.length} characters
</span>
</div>
<RichTextEditor
label="Content"
value={contentHtml}
required
placeholder="Write the initial document content..."
minHeightClassName="min-h-[280px]"
onChange={(html, text) => {
setContentHtml(html);
setContent(text);
}}
/>
<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>
</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 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="submit"
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"
>
{isSaving ? "Saving..." : "Save As Draft"}
</button>
<PrimaryButton type="submit" disabled={isSaving}>
{isSaving ? "Creating..." : "Create Document"}
</PrimaryButton>
</div>
</div>
</form>
</Layout>
);
};
export default CreateDocument;

View File

@ -0,0 +1,186 @@
import { useEffect, useMemo, useState, type ReactElement } from "react";
import { useNavigate } from "react-router-dom";
import { Layout } from "@/components/layout/Layout";
import { DataTable, FormField, PrimaryButton, type Column } from "@/components/shared";
import { documentService } from "@/services/document-service";
import type { DocumentCategory } from "@/types/document";
import { showToast } from "@/utils/toast";
const DocumentCategories = (): ReactElement => {
const navigate = useNavigate();
const [categories, setCategories] = useState<DocumentCategory[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [name, setName] = useState("");
const [code, setCode] = useState("");
const [description, setDescription] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const loadCategories = async (): Promise<void> => {
try {
setIsLoading(true);
const response = await documentService.getCategories();
setCategories(response.data || []);
} catch (err: any) {
showToast.error(
err?.response?.data?.error?.message || "Failed to load categories",
);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
void loadCategories();
}, []);
const columns: Column<DocumentCategory>[] = useMemo(
() => [
{ key: "name", label: "Name" },
{ key: "code", label: "Code" },
{
key: "description",
label: "Description",
render: (category) => category.description || "-",
},
{
key: "review_frequency_months",
label: "Review (months)",
render: (category) =>
category.review_frequency_months?.toString() || "-",
},
{
key: "retention_years",
label: "Retention (years)",
render: (category) => category.retention_years?.toString() || "-",
},
{
key: "actions",
label: "Actions",
align: "right",
render: (category) => (
<button
type="button"
className="text-[#ef4444] hover:underline"
onClick={async () => {
try {
await documentService.deleteCategory(category.id);
showToast.success("Category deleted");
await loadCategories();
} catch (err: any) {
showToast.error(
err?.response?.data?.error?.message ||
"Failed to delete category",
);
}
}}
>
Delete
</button>
),
},
],
[],
);
const onCreateCategory = async (event: React.FormEvent): Promise<void> => {
event.preventDefault();
if (!name.trim() || !code.trim()) {
showToast.error("Name and code are required");
return;
}
try {
setIsSubmitting(true);
await documentService.createCategory({
name: name.trim(),
code: code.trim().toUpperCase(),
description: description.trim() || undefined,
});
showToast.success("Category created");
setName("");
setCode("");
setDescription("");
await loadCategories();
} catch (err: any) {
showToast.error(
err?.response?.data?.error?.message || "Failed to create category",
);
} finally {
setIsSubmitting(false);
}
};
return (
<Layout
currentPage="Document Service"
breadcrumbs={[
{ label: "Document Service", path: "/tenant/documents" },
{ label: "Category Management" },
]}
pageHeader={{
title: "Category Management",
description: "Create and maintain document categories.",
tabs: [
{ label: "Document List", path: "/tenant/documents" },
{ label: "Create Document", path: "/tenant/documents/create" },
{ label: "Category Management", path: "/tenant/documents/categories" },
],
}}
>
<div className="space-y-4">
<form
onSubmit={onCreateCategory}
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="grid grid-cols-1 md:grid-cols-3 gap-4">
<FormField
label="Category Name"
required
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. SOP"
/>
<FormField
label="Code"
required
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="e.g. SOP"
/>
<FormField
label="Description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Optional"
/>
</div>
<div className="flex gap-2">
<PrimaryButton type="submit" disabled={isSubmitting}>
{isSubmitting ? "Saving..." : "Add Category"}
</PrimaryButton>
<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")}
>
Back to Documents
</button>
</div>
</form>
<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)] overflow-hidden">
<DataTable
data={categories}
columns={columns}
keyExtractor={(category) => category.id}
emptyMessage="No categories found"
isLoading={isLoading}
/>
</div>
</div>
</Layout>
);
};
export default DocumentCategories;

View File

@ -0,0 +1,268 @@
import { useEffect, useMemo, useState, type ReactElement } from "react";
import { useNavigate } from "react-router-dom";
import { Layout } from "@/components/layout/Layout";
import {
DataTable,
FilterDropdown,
Pagination,
PrimaryButton,
type Column,
} from "@/components/shared";
import { documentService } from "@/services/document-service";
import type { DocumentCategory, DocumentSummary } from "@/types/document";
import { Plus } from "lucide-react";
const formatDate = (value?: string | null): string => {
if (!value) return "-";
return new Date(value).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
};
const toLabel = (value: string): string =>
value
.split("_")
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
const Documents = (): ReactElement => {
const navigate = useNavigate();
const [documents, setDocuments] = useState<DocumentSummary[]>([]);
const [categories, setCategories] = useState<DocumentCategory[]>([]);
const [statuses, setStatuses] = useState<Array<{ code: string; name: string }>>(
[],
);
const [types, setTypes] = useState<Array<{ code: string; name: string }>>([]);
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<string | null>(null);
const [categoryFilter, setCategoryFilter] = useState<string | null>(null);
const [typeFilter, setTypeFilter] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [limit, setLimit] = useState(10);
const [total, setTotal] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const offset = (currentPage - 1) * limit;
const totalPages = Math.max(1, Math.ceil(total / limit));
useEffect(() => {
const loadDropdownData = async (): Promise<void> => {
try {
const [categoriesRes, statusesRes, typesRes] = await Promise.all([
documentService.getCategories(),
documentService.getStatuses(),
documentService.getTypes(),
]);
setCategories(categoriesRes.data || []);
setStatuses(statusesRes.data || []);
setTypes(typesRes.data || []);
} catch {
// Keep page usable even if some filter metadata endpoints fail.
}
};
void loadDropdownData();
}, []);
useEffect(() => {
const loadDocuments = async (): Promise<void> => {
try {
setIsLoading(true);
setError(null);
const response = await documentService.list({
status: statusFilter || undefined,
category_id: categoryFilter || undefined,
document_type: typeFilter || undefined,
search: search.trim() || undefined,
limit,
offset,
});
setDocuments(response.data || []);
setTotal(response.pagination?.total || 0);
} catch (err: any) {
setError(
err?.response?.data?.error?.message || "Failed to load documents",
);
} finally {
setIsLoading(false);
}
};
void loadDocuments();
}, [statusFilter, categoryFilter, typeFilter, search, limit, offset]);
const columns: Column<DocumentSummary>[] = useMemo(
() => [
{
key: "document_number",
label: "Document No",
render: (doc) => (
<button
type="button"
className="text-[#084cc8] hover:underline"
onClick={() => navigate(`/tenant/documents/${doc.id}`)}
>
{doc.document_number}
</button>
),
},
{
key: "title",
label: "Title",
render: (doc) => <span className="text-[#0f1724]">{doc.title}</span>,
},
{
key: "document_type",
label: "Type",
render: (doc) => (
<span className="text-[#0f1724]">{doc.document_type || "-"}</span>
),
},
{
key: "category",
label: "Category",
render: (doc) => <span className="text-[#0f1724]">{doc.category || "-"}</span>,
},
{
key: "status",
label: "Status",
render: (doc) => (
<span className="inline-flex items-center rounded-md bg-[#112868]/10 px-2 py-1 text-[11px] text-[#112868]">
{toLabel(doc.status)}
</span>
),
},
{
key: "current_version",
label: "Version",
render: (doc) => (
<span className="text-[#0f1724]">{doc.current_version || "-"}</span>
),
},
{
key: "updated_at",
label: "Updated",
render: (doc) => (
<span className="text-[#6b7280]">{formatDate(doc.updated_at)}</span>
),
},
],
[navigate],
);
return (
<Layout
currentPage="Document Service"
pageHeader={{
title: "Document List",
description:
"Manage controlled documents, track versions and open document details.",
tabs: [
{ label: "Document List", path: "/tenant/documents" },
{ label: "Create Document", path: "/tenant/documents/create" },
{ label: "Category Management", path: "/tenant/documents/categories" },
],
}}
>
<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)] overflow-hidden">
<div className="border-b border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-3 flex flex-col gap-3">
<div className="flex flex-col md:flex-row gap-2 md:items-center md:justify-between">
<div className="flex flex-wrap items-center gap-2">
<FilterDropdown
label="Status"
options={statuses.map((status) => ({
value: status.code,
label: status.name,
}))}
value={statusFilter}
onChange={(value) => {
setStatusFilter(value as string | null);
setCurrentPage(1);
}}
placeholder="All"
/>
<FilterDropdown
label="Category"
options={categories.map((category) => ({
value: category.id,
label: category.name,
}))}
value={categoryFilter}
onChange={(value) => {
setCategoryFilter(value as string | null);
setCurrentPage(1);
}}
placeholder="All"
/>
<FilterDropdown
label="Type"
options={types.map((type) => ({
value: type.code,
label: type.name,
}))}
value={typeFilter}
onChange={(value) => {
setTypeFilter(value as string | null);
setCurrentPage(1);
}}
placeholder="All"
/>
</div>
<div className="flex gap-2">
<button
type="button"
className="h-10 px-3 border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0f1724] hover:bg-gray-50"
onClick={() => navigate("/tenant/documents/categories")}
>
Manage Categories
</button>
<PrimaryButton onClick={() => navigate("/tenant/documents/create")}>
<Plus className="w-3.5 h-3.5 mr-1" />
New Document
</PrimaryButton>
</div>
</div>
<input
type="text"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setCurrentPage(1);
}}
placeholder="Search by title, description or document number"
className="h-10 w-full max-w-xl px-3 border border-[rgba(0,0,0,0.08)] rounded-md text-sm"
/>
</div>
<DataTable
data={documents}
columns={columns}
keyExtractor={(doc) => doc.id}
emptyMessage="No documents found"
isLoading={isLoading}
error={error}
/>
{total > 0 && (
<Pagination
currentPage={currentPage}
totalPages={totalPages}
totalItems={total}
limit={limit}
onPageChange={setCurrentPage}
onLimitChange={(value) => {
setLimit(value);
setCurrentPage(1);
}}
/>
)}
</div>
</Layout>
);
};
export default Documents;

View File

@ -0,0 +1,616 @@
import { useEffect, useMemo, useState, type ReactElement } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { Layout } from "@/components/layout/Layout";
import {
DataTable,
FormSelect,
Modal,
PrimaryButton,
RichTextEditor,
SecondaryButton,
type Column,
} from "@/components/shared";
import { documentService } from "@/services/document-service";
import { workflowService } from "@/services/workflow-service";
import type { DocumentDetail, DocumentVersion } from "@/types/document";
import { showToast } from "@/utils/toast";
import { ChevronDown, Plus } from "lucide-react";
const formatDateTime = (value?: string | null): string => {
if (!value) return "-";
return new Date(value).toLocaleString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
type DocumentAction =
| "submit"
| "approve"
| "reject"
| "effective"
| "obsolete"
| "checkout"
| "checkin";
const ACTION_LABELS: Record<DocumentAction, string> = {
submit: "Submit For Review",
approve: "Approve",
reject: "Reject",
effective: "Make Effective",
obsolete: "Make Obsolete",
checkout: "Checkout",
checkin: "Checkin",
};
const ViewDocument = (): ReactElement => {
const navigate = useNavigate();
const { id } = useParams();
const [document, setDocument] = useState<DocumentDetail | null>(null);
const [versions, setVersions] = useState<DocumentVersion[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<"overview" | "version-history">(
"overview",
);
const [actionMenuOpen, setActionMenuOpen] = useState(false);
const [activeAction, setActiveAction] = useState<DocumentAction | null>(null);
const [isActionLoading, setIsActionLoading] = useState(false);
const [workflowDefinitionId, setWorkflowDefinitionId] = useState("");
const [workflowOptions, setWorkflowOptions] = useState<
Array<{ value: string; label: string }>
>([]);
const [actionComment, setActionComment] = useState("");
const [effectiveDate, setEffectiveDate] = useState("");
const [signatureId, setSignatureId] = useState("");
const [showNewVersionForm, setShowNewVersionForm] = useState(false);
const [newVersionContent, setNewVersionContent] = useState("");
const [newVersionContentHtml, setNewVersionContentHtml] = useState("");
const [newVersionChangeReason, setNewVersionChangeReason] = useState("minor_edit");
const [newVersionChangeSummary, setNewVersionChangeSummary] = useState("");
const [isMajorVersion, setIsMajorVersion] = useState(false);
const [isVersionSaving, setIsVersionSaving] = useState(false);
useEffect(() => {
if (!id) return;
const loadDocument = async (): Promise<void> => {
try {
setIsLoading(true);
setError(null);
const [documentRes, versionsRes] = await Promise.all([
documentService.getById(id),
documentService.getVersions(id),
]);
setDocument(documentRes.data);
setVersions(versionsRes.data || []);
} catch (err: any) {
const message =
err?.response?.data?.error?.message || "Failed to load document details";
setError(message);
showToast.error(message);
} finally {
setIsLoading(false);
}
};
void loadDocument();
}, [id]);
const refreshData = async (): Promise<void> => {
if (!id) return;
const [documentRes, versionsRes] = await Promise.all([
documentService.getById(id),
documentService.getVersions(id),
]);
setDocument(documentRes.data);
setVersions(versionsRes.data || []);
};
const resetActionModal = (): void => {
setActiveAction(null);
setWorkflowDefinitionId("");
setActionComment("");
setEffectiveDate("");
setSignatureId("");
setWorkflowOptions([]);
};
const openActionModal = async (action: DocumentAction): Promise<void> => {
setActionMenuOpen(false);
if (action === "checkin") {
await handleAction(action);
return;
}
setActiveAction(action);
if (action === "submit") {
try {
const response = await workflowService.listDefinitions({
status: "active",
entity_type: "document",
limit: 100,
offset: 0,
});
setWorkflowOptions(
(response.data || []).map((definition) => ({
value: definition.id,
label: `${definition.name} (${definition.code})`,
})),
);
} catch {
setWorkflowOptions([]);
showToast.error("Failed to load active workflow definitions");
}
}
};
const handleAction = async (action: DocumentAction): Promise<void> => {
if (!id) return;
if (action === "submit" && !workflowDefinitionId) {
showToast.error("workflow_definition_id is required");
return;
}
if (action === "reject" && !actionComment.trim()) {
showToast.error("Reason is required for reject");
return;
}
if (action === "effective" && !effectiveDate) {
showToast.error("Effective date is required");
return;
}
if (action === "effective" && !signatureId.trim()) {
showToast.error("signature_id is required");
return;
}
if (action === "obsolete" && !actionComment.trim()) {
showToast.error("Reason is required to obsolete");
return;
}
if (action === "checkout" && !actionComment.trim()) {
showToast.error("Reason is required for checkout");
return;
}
try {
setIsActionLoading(true);
if (action === "submit")
await documentService.submitForReview(id, workflowDefinitionId);
if (action === "approve") await documentService.approve(id, actionComment);
if (action === "reject") {
await documentService.reject(id, actionComment.trim());
}
if (action === "effective") {
await documentService.makeEffective(id, effectiveDate, signatureId.trim());
}
if (action === "obsolete") {
await documentService.makeObsolete(id, actionComment.trim());
}
if (action === "checkout")
await documentService.checkout(id, actionComment.trim());
if (action === "checkin") await documentService.checkin(id);
showToast.success("Document action completed");
await refreshData();
resetActionModal();
} catch (err: any) {
showToast.error(
err?.response?.data?.error?.message || "Failed to execute action",
);
} finally {
setIsActionLoading(false);
}
};
const handleCreateVersion = async (): Promise<void> => {
if (!id) return;
if (!newVersionChangeReason) {
showToast.error("Change reason is required");
return;
}
if (!newVersionContent.trim()) {
showToast.error("Document content is required");
return;
}
try {
setIsVersionSaving(true);
await documentService.createVersion(id, {
content: newVersionContent.trim(),
content_html: newVersionContentHtml.trim() || undefined,
change_reason: newVersionChangeReason,
change_summary: newVersionChangeSummary.trim() || undefined,
is_major_version: isMajorVersion,
});
showToast.success("New version created successfully");
setShowNewVersionForm(false);
setNewVersionChangeSummary("");
setIsMajorVersion(false);
await refreshData();
} catch (err: any) {
showToast.error(
err?.response?.data?.error?.message || "Failed to create version",
);
} finally {
setIsVersionSaving(false);
}
};
const versionColumns: Column<DocumentVersion>[] = [
{ key: "version_number", label: "Version" },
{ key: "status", label: "Status" },
{
key: "change_summary",
label: "Change Summary",
render: (version) => version.change_summary || "-",
},
{
key: "created_by",
label: "Author",
render: (version) => version.created_by || "-",
},
{
key: "created_at",
label: "Created At",
render: (version) => formatDateTime(version.created_at),
},
];
const actionOptions: DocumentAction[] = useMemo(
() => [
"submit",
"approve",
"reject",
"effective",
"obsolete",
"checkout",
"checkin",
],
[],
);
return (
<Layout
currentPage="Document Service"
breadcrumbs={[
{ label: "Document Service", path: "/tenant/documents" },
{ label: "View Document" },
]}
pageHeader={{
title: document?.title || "View Document",
description: "View document metadata and version history.",
tabs: [
{ label: "Document List", path: "/tenant/documents" },
{ label: "Create Document", path: "/tenant/documents/create" },
{ label: "Category Management", path: "/tenant/documents/categories" },
],
}}
>
<div 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-5">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-xl font-semibold text-[#0f1724]">
{document?.title || "Document Detail"}
</h3>
<p className="text-xs text-[#6b7280] mt-1">
{document?.document_number || "-"}
</p>
</div>
<div className="relative">
<button
type="button"
className="inline-flex items-center gap-2 h-9 px-3 rounded-md border border-[rgba(0,0,0,0.12)] text-xs text-[#0f1724]"
onClick={() => setActionMenuOpen((prev) => !prev)}
>
Actions
<ChevronDown className="w-3.5 h-3.5" />
</button>
{actionMenuOpen && (
<div className="absolute right-0 top-10 z-20 bg-white border border-[rgba(0,0,0,0.08)] rounded-md shadow-md min-w-[180px]">
{actionOptions.map((action) => (
<button
key={action}
type="button"
className="w-full text-left px-3 py-2 text-xs text-[#0f1724] hover:bg-[#f8fafc]"
onClick={() => void openActionModal(action)}
>
{ACTION_LABELS[action]}
</button>
))}
</div>
)}
</div>
</div>
<div className="flex gap-2 mt-3">
<span className="px-2 py-1 rounded-full bg-emerald-100 text-emerald-700 text-[11px] font-medium">
{document?.status || "draft"}
</span>
<span className="px-2 py-1 rounded-full bg-amber-100 text-amber-700 text-[11px] font-medium">
{document?.document_type || "-"}
</span>
<span className="px-2 py-1 rounded-full bg-blue-100 text-blue-700 text-[11px] font-medium">
v{document?.current_version || "-"}
</span>
</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 gap-5 border-b border-[rgba(0,0,0,0.08)] mb-4">
<button
type="button"
className={`text-sm pb-2 ${activeTab === "overview" ? "text-[#084cc8] border-b-2 border-[#084cc8]" : "text-[#6b7280]"}`}
onClick={() => setActiveTab("overview")}
>
Overview
</button>
<button
type="button"
className={`text-sm pb-2 ${activeTab === "version-history" ? "text-[#084cc8] border-b-2 border-[#084cc8]" : "text-[#6b7280]"}`}
onClick={() => setActiveTab("version-history")}
>
Version History
</button>
</div>
{isLoading ? (
<div className="text-sm text-[#6b7280]">Loading document...</div>
) : error ? (
<div className="text-sm text-[#ef4444]">{error}</div>
) : document ? (
<>
{activeTab === "overview" && (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-3 text-sm">
<div>
<span className="text-[#9aa6b2]">Document Number:</span>
<p className="text-[#0f1724]">{document.document_number}</p>
</div>
<div>
<span className="text-[#9aa6b2]">Title:</span>
<p className="text-[#0f1724]">{document.title}</p>
</div>
<div>
<span className="text-[#9aa6b2]">Category:</span>
<p className="text-[#0f1724]">{document.category?.name || "-"}</p>
</div>
<div>
<span className="text-[#9aa6b2]">Department:</span>
<p className="text-[#0f1724]">{document.department || "-"}</p>
</div>
<div>
<span className="text-[#9aa6b2]">Effective Date:</span>
<p className="text-[#0f1724]">{formatDateTime(document.effective_date)}</p>
</div>
<div>
<span className="text-[#9aa6b2]">Next Review Date:</span>
<p className="text-[#0f1724]">{formatDateTime(document.next_review_date)}</p>
</div>
<div className="md:col-span-2">
<span className="text-[#9aa6b2]">Tags:</span>
<p className="text-[#0f1724]">
{document.tags && document.tags.length > 0
? document.tags.join(", ")
: "-"}
</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 === "version-history" && (
<div className="space-y-4">
<div className="flex justify-end">
<button
type="button"
className="inline-flex items-center gap-1.5 h-9 px-3 rounded-md bg-[#112868] text-white text-xs"
onClick={() => {
setShowNewVersionForm((prev) => !prev);
setNewVersionContent(document.content || "");
setNewVersionContentHtml(document.content_html || "");
}}
>
<Plus className="w-3.5 h-3.5" />
New Version
</button>
</div>
{showNewVersionForm && (
<div className="border border-[rgba(0,0,0,0.08)] rounded-lg p-4 bg-[#f8fafc]">
<h4 className="text-base font-semibold text-[#0f1724] mb-3">Create New Version</h4>
<RichTextEditor
label="Document Content"
value={newVersionContentHtml}
required
minHeightClassName="min-h-[200px]"
onChange={(html, text) => {
setNewVersionContentHtml(html);
setNewVersionContent(text);
}}
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormSelect
label="Change Reason"
required
options={[
{ value: "minor_edit", label: "minor_edit" },
{ value: "correction", label: "correction" },
{ value: "regulatory_update", label: "regulatory_update" },
{ value: "major_rewrite", label: "major_rewrite" },
]}
value={newVersionChangeReason}
onValueChange={setNewVersionChangeReason}
placeholder="Select change reason"
/>
<div className="flex items-center gap-2 pt-8">
<input
id="major-version"
type="checkbox"
checked={isMajorVersion}
onChange={(event) => setIsMajorVersion(event.target.checked)}
className="w-4 h-4"
/>
<label htmlFor="major-version" className="text-sm text-[#0f1724]">
Major Version?
</label>
</div>
</div>
<div className="mt-1">
<label className="text-[13px] font-medium text-[#0e1b2a] mb-2 block">
Change Summary
</label>
<textarea
rows={3}
value={newVersionChangeSummary}
onChange={(event) => setNewVersionChangeSummary(event.target.value)}
placeholder="Provide a brief description of the changes..."
className="w-full px-3 py-2 border border-[rgba(0,0,0,0.08)] rounded-md text-sm"
/>
</div>
<div className="mt-4 flex justify-end gap-2">
<SecondaryButton onClick={() => setShowNewVersionForm(false)}>
Cancel
</SecondaryButton>
<PrimaryButton
onClick={() => void handleCreateVersion()}
disabled={isVersionSaving}
>
{isVersionSaving ? "Creating..." : "Create Version"}
</PrimaryButton>
</div>
</div>
)}
<div className="border border-[rgba(0,0,0,0.08)] rounded-lg overflow-hidden">
<DataTable
data={versions}
columns={versionColumns}
keyExtractor={(version) => version.id}
emptyMessage="No versions found"
isLoading={isLoading}
/>
</div>
</div>
)}
</>
) : null}
<div className="mt-4">
<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")}
>
Back to Documents
</button>
</div>
</div>
</div>
<Modal
isOpen={Boolean(activeAction)}
onClose={resetActionModal}
title={activeAction ? ACTION_LABELS[activeAction] : "Action"}
description="Complete required details to continue."
footer={
<>
<SecondaryButton onClick={resetActionModal} disabled={isActionLoading}>
Cancel
</SecondaryButton>
<PrimaryButton
onClick={() => activeAction && void handleAction(activeAction)}
disabled={isActionLoading || !activeAction}
>
{isActionLoading ? "Submitting..." : "Submit"}
</PrimaryButton>
</>
}
>
<div className="p-5 space-y-3">
{activeAction === "submit" && (
<FormSelect
label="Workflow Definition"
required
options={workflowOptions}
value={workflowDefinitionId}
onValueChange={setWorkflowDefinitionId}
placeholder="Select active workflow definition"
/>
)}
{activeAction === "approve" && (
<div>
<label className="text-[13px] font-medium text-[#0e1b2a] mb-2 block">
Comments
</label>
<textarea
rows={4}
value={actionComment}
onChange={(event) => setActionComment(event.target.value)}
placeholder="Add comments (optional)"
className="w-full px-3 py-2 border border-[rgba(0,0,0,0.08)] rounded-md text-sm"
/>
</div>
)}
{(activeAction === "reject" ||
activeAction === "obsolete" ||
activeAction === "checkout") && (
<div>
<label className="text-[13px] font-medium text-[#0e1b2a] mb-2 block">
Reason
</label>
<textarea
rows={4}
value={actionComment}
onChange={(event) => setActionComment(event.target.value)}
placeholder="Enter reason"
className="w-full px-3 py-2 border border-[rgba(0,0,0,0.08)] rounded-md text-sm"
/>
</div>
)}
{activeAction === "effective" && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="text-[13px] font-medium text-[#0e1b2a] mb-2 block">
Effective Date
</label>
<input
type="date"
value={effectiveDate}
onChange={(event) => setEffectiveDate(event.target.value)}
className="h-10 px-3 w-full border border-[rgba(0,0,0,0.08)] rounded-md text-sm"
/>
</div>
<div>
<label className="text-[13px] font-medium text-[#0e1b2a] mb-2 block">
Signature ID
</label>
<input
type="text"
value={signatureId}
onChange={(event) => setSignatureId(event.target.value)}
placeholder="Enter signature ID"
className="h-10 px-3 w-full border border-[rgba(0,0,0,0.08)] rounded-md text-sm"
/>
</div>
</div>
)}
</div>
</Modal>
</Layout>
);
};
export default ViewDocument;

View File

@ -14,6 +14,10 @@ const WorkflowDefination = lazy(
() => import("@/pages/tenant/WorkflowDefination"), () => import("@/pages/tenant/WorkflowDefination"),
); );
const Suppliers = lazy(() => import("@/pages/tenant/Suppliers")); const Suppliers = lazy(() => import("@/pages/tenant/Suppliers"));
const Documents = lazy(() => import("@/pages/tenant/Documents"));
const CreateDocument = lazy(() => import("@/pages/tenant/CreateDocument"));
const ViewDocument = lazy(() => import("@/pages/tenant/ViewDocument"));
const DocumentCategories = lazy(() => import("@/pages/tenant/DocumentCategories"));
// Loading fallback component // Loading fallback component
const RouteLoader = (): ReactElement => ( const RouteLoader = (): ReactElement => (
@ -80,4 +84,20 @@ export const tenantAdminRoutes: RouteConfig[] = [
path: "/tenant/suppliers", path: "/tenant/suppliers",
element: <LazyRoute component={Suppliers} />, element: <LazyRoute component={Suppliers} />,
}, },
{
path: "/tenant/documents",
element: <LazyRoute component={Documents} />,
},
{
path: "/tenant/documents/create",
element: <LazyRoute component={CreateDocument} />,
},
{
path: "/tenant/documents/:id",
element: <LazyRoute component={ViewDocument} />,
},
{
path: "/tenant/documents/categories",
element: <LazyRoute component={DocumentCategories} />,
},
]; ];

View File

@ -0,0 +1,254 @@
import apiClient from "@/services/api-client";
import type {
DocumentCategory,
DocumentDetail,
DocumentListResponse,
DocumentResponse,
DocumentVersion,
} from "@/types/document";
export interface ListDocumentsParams {
status?: string;
category_id?: string;
document_type?: string;
owner_id?: string;
search?: string;
limit?: number;
offset?: number;
}
export interface CreateDocumentPayload {
title: string;
description?: string;
document_number?: string;
category_id?: string;
document_type: string;
owner_id?: string;
department?: string;
tags?: string[];
custom_fields?: Record<string, unknown>;
content?: string;
content_html?: string;
}
export interface CreateCategoryPayload {
name: string;
code: string;
description?: string;
parent_id?: string | null;
requires_training?: boolean;
retention_years?: number;
review_frequency_months?: number;
}
export interface CreateDocumentVersionPayload {
content?: string;
content_html?: string;
change_summary?: string;
change_reason?: string;
is_major_version?: boolean;
}
export const documentService = {
list: async (params: ListDocumentsParams): Promise<DocumentListResponse> => {
const queryParams = new URLSearchParams();
if (params.status) queryParams.append("status", params.status);
if (params.category_id) queryParams.append("category_id", params.category_id);
if (params.document_type)
queryParams.append("document_type", params.document_type);
if (params.owner_id) queryParams.append("owner_id", params.owner_id);
if (params.search) queryParams.append("search", params.search);
if (params.limit !== undefined)
queryParams.append("limit", params.limit.toString());
if (params.offset !== undefined)
queryParams.append("offset", params.offset.toString());
const response = await apiClient.get<DocumentListResponse>(
`/documents?${queryParams.toString()}`,
);
return response.data;
},
getById: async (id: string): Promise<DocumentResponse<DocumentDetail>> => {
const response = await apiClient.get<DocumentResponse<DocumentDetail>>(
`/documents/${id}`,
);
return response.data;
},
create: async (
payload: CreateDocumentPayload,
): Promise<DocumentResponse<DocumentDetail>> => {
const response = await apiClient.post<DocumentResponse<DocumentDetail>>(
"/documents",
payload,
);
return response.data;
},
update: async (
id: string,
payload: Partial<CreateDocumentPayload>,
): Promise<DocumentResponse<DocumentDetail>> => {
const response = await apiClient.put<DocumentResponse<DocumentDetail>>(
`/documents/${id}`,
payload,
);
return response.data;
},
getTypes: async (): Promise<
DocumentResponse<Array<{ code: string; name: string }>>
> => {
const response = await apiClient.get<
DocumentResponse<Array<{ code: string; name: string }>>
>("/documents/types");
return response.data;
},
getStatuses: async (): Promise<
DocumentResponse<
Array<{ code: string; name: string; description?: string }>
>
> => {
const response = await apiClient.get<
DocumentResponse<
Array<{ code: string; name: string; description?: string }>
>
>("/documents/statuses");
return response.data;
},
getCategories: async (): Promise<DocumentResponse<DocumentCategory[]>> => {
const response = await apiClient.get<DocumentResponse<DocumentCategory[]>>(
"/documents/categories",
);
return response.data;
},
createCategory: async (
payload: CreateCategoryPayload,
): Promise<DocumentResponse<DocumentCategory>> => {
const response = await apiClient.post<DocumentResponse<DocumentCategory>>(
"/documents/categories",
payload,
);
return response.data;
},
updateCategory: async (
id: string,
payload: Partial<CreateCategoryPayload>,
): Promise<DocumentResponse<DocumentCategory>> => {
const response = await apiClient.put<DocumentResponse<DocumentCategory>>(
`/documents/categories/${id}`,
payload,
);
return response.data;
},
deleteCategory: async (
id: string,
): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.delete<{ success: boolean; message: string }>(
`/documents/categories/${id}`,
);
return response.data;
},
getVersions: async (
id: string,
): Promise<DocumentResponse<DocumentVersion[]>> => {
const response = await apiClient.get<DocumentResponse<DocumentVersion[]>>(
`/documents/${id}/versions`,
);
return response.data;
},
createVersion: async (
id: string,
payload: CreateDocumentVersionPayload,
): Promise<DocumentResponse<DocumentVersion>> => {
const response = await apiClient.post<DocumentResponse<DocumentVersion>>(
`/documents/${id}/versions`,
payload,
);
return response.data;
},
submitForReview: async (
id: string,
workflow_definition_id: string,
): Promise<DocumentResponse<DocumentDetail>> => {
const response = await apiClient.post<DocumentResponse<DocumentDetail>>(
`/documents/${id}/submit`,
{ workflow_definition_id },
);
return response.data;
},
approve: async (
id: string,
comments?: string,
): Promise<DocumentResponse<DocumentDetail>> => {
const response = await apiClient.post<DocumentResponse<DocumentDetail>>(
`/documents/${id}/approve`,
{ comments: comments || "" },
);
return response.data;
},
reject: async (
id: string,
reason: string,
): Promise<DocumentResponse<DocumentDetail>> => {
const response = await apiClient.post<DocumentResponse<DocumentDetail>>(
`/documents/${id}/reject`,
{ reason },
);
return response.data;
},
makeEffective: async (
id: string,
effective_date: string,
signature_id?: string,
): Promise<DocumentResponse<DocumentDetail>> => {
const response = await apiClient.post<DocumentResponse<DocumentDetail>>(
`/documents/${id}/make-effective`,
{ effective_date, signature_id: signature_id || null },
);
return response.data;
},
makeObsolete: async (
id: string,
reason: string,
): Promise<DocumentResponse<DocumentDetail>> => {
const response = await apiClient.post<DocumentResponse<DocumentDetail>>(
`/documents/${id}/obsolete`,
{ reason },
);
return response.data;
},
checkout: async (
id: string,
reason?: string,
): Promise<DocumentResponse<{ checked_out?: boolean; expires_at?: string }>> => {
const response = await apiClient.post<
DocumentResponse<{ checked_out?: boolean; expires_at?: string }>
>(`/documents/${id}/checkout`, { reason: reason || "" });
return response.data;
},
checkin: async (
id: string,
): Promise<DocumentResponse<{ checked_in?: boolean }>> => {
const response = await apiClient.post<
DocumentResponse<{ checked_in?: boolean }>
>(`/documents/${id}/checkin`);
return response.data;
},
};

90
src/types/document.ts Normal file
View File

@ -0,0 +1,90 @@
export interface DocumentCategory {
id: string;
name: string;
code: string;
description?: string | null;
parent_id?: string | null;
requires_training?: boolean;
retention_years?: number;
review_frequency_months?: number;
created_at?: string;
updated_at?: string;
}
export interface DocumentSummary {
id: string;
document_number: string;
title: string;
document_type: string;
category: string | null;
status: string;
current_version?: string | null;
owner?: string | null;
next_review_date?: string | null;
updated_at?: string;
}
export interface DocumentDetail {
id: string;
document_number: string;
title: string;
description?: string | null;
document_type: string;
category?: {
id: string;
name: string;
code?: string;
} | null;
status: string;
current_version?: string | null;
version_status?: string | null;
owner?: {
id: string;
email: string;
name?: string;
} | null;
department?: string | null;
effective_date?: string | null;
next_review_date?: string | null;
tags?: string[];
content?: string | null;
content_html?: string | null;
custom_fields?: Record<string, unknown>;
workflow_instance_id?: string | null;
created_by?: string | null;
created_at?: string;
updated_at?: string;
}
export interface DocumentVersion {
id: string;
version_number: string;
major_version: number;
minor_version: number;
status: string;
change_summary?: string | null;
change_reason?: string | null;
content?: string | null;
content_html?: string | null;
approved_by?: string | null;
approved_at?: string | null;
created_by?: string | null;
created_at?: string;
}
export interface DocumentListResponse {
success: boolean;
data: DocumentSummary[];
pagination: {
total: number;
limit: number;
offset: number;
};
}
export interface DocumentResponse<T> {
success: boolean;
data: T;
message?: string;
}