feat: Add document management features including document creation, categorization, and a rich text editor for content editing.
This commit is contained in:
parent
4b76f71cf4
commit
083d10fdff
876
package-lock.json
generated
876
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@ -14,6 +14,16 @@
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@reduxjs/toolkit": "^2.11.2",
|
||||
"@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",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
@ -86,6 +86,12 @@ const tenantAdminPlatformMenu: MenuItem[] = [
|
||||
path: "/tenant/suppliers",
|
||||
requiredPermission: { resource: "supplier" },
|
||||
},
|
||||
{
|
||||
icon: FileText,
|
||||
label: "Document Service",
|
||||
path: "/tenant/documents",
|
||||
requiredPermission: { resource: "document" },
|
||||
},
|
||||
{ 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">
|
||||
{items.map((item) => {
|
||||
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 (
|
||||
<Link
|
||||
key={item.path}
|
||||
|
||||
@ -27,7 +27,7 @@ const defaultTabs: TabItem[] = [
|
||||
export const PageHeader = ({
|
||||
title,
|
||||
description,
|
||||
tabs = defaultTabs,
|
||||
tabs,
|
||||
}: PageHeaderProps): ReactElement => {
|
||||
const location = useLocation();
|
||||
const { roles } = useAppSelector((state) => state.auth);
|
||||
@ -45,14 +45,17 @@ export const PageHeader = ({
|
||||
}
|
||||
const isSuperAdmin = rolesArray.includes('super_admin');
|
||||
|
||||
const isActiveTab = (path: string): boolean => {
|
||||
// Exact match for dashboard
|
||||
if (path === '/dashboard') {
|
||||
return location.pathname === '/dashboard';
|
||||
}
|
||||
// For other paths, check if current path starts with the tab path
|
||||
return location.pathname.startsWith(path);
|
||||
};
|
||||
const isPathMatch = (tabPath: string): boolean =>
|
||||
location.pathname === tabPath || location.pathname.startsWith(`${tabPath}/`);
|
||||
|
||||
const resolvedTabs = tabs ?? (isSuperAdmin ? defaultTabs : []);
|
||||
|
||||
// Pick the most specific matching tab (longest path), so parent tabs
|
||||
// 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 (
|
||||
<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>
|
||||
|
||||
{/* 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">
|
||||
{tabs.map((tab) => {
|
||||
const isActive = isActiveTab(tab.path);
|
||||
{resolvedTabs.map((tab) => {
|
||||
const isActive = tab.path === activeTabPath;
|
||||
return (
|
||||
<Link
|
||||
key={tab.path}
|
||||
|
||||
477
src/components/shared/RichTextEditor.tsx
Normal file
477
src/components/shared/RichTextEditor.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -34,3 +34,4 @@ export { ViewSupplierModal } from './ViewSupplierModal';
|
||||
export { SupplierContactsModal } from './SupplierContactsModal';
|
||||
export { SupplierScorecardsModal } from './SupplierScorecardsModal';
|
||||
export { FormTextArea } from './FormTextArea';
|
||||
export { RichTextEditor } from './RichTextEditor';
|
||||
234
src/pages/tenant/CreateDocument.tsx
Normal file
234
src/pages/tenant/CreateDocument.tsx
Normal 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;
|
||||
|
||||
186
src/pages/tenant/DocumentCategories.tsx
Normal file
186
src/pages/tenant/DocumentCategories.tsx
Normal 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;
|
||||
|
||||
268
src/pages/tenant/Documents.tsx
Normal file
268
src/pages/tenant/Documents.tsx
Normal 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;
|
||||
|
||||
616
src/pages/tenant/ViewDocument.tsx
Normal file
616
src/pages/tenant/ViewDocument.tsx
Normal 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;
|
||||
|
||||
@ -14,6 +14,10 @@ const WorkflowDefination = lazy(
|
||||
() => import("@/pages/tenant/WorkflowDefination"),
|
||||
);
|
||||
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
|
||||
const RouteLoader = (): ReactElement => (
|
||||
@ -80,4 +84,20 @@ export const tenantAdminRoutes: RouteConfig[] = [
|
||||
path: "/tenant/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} />,
|
||||
},
|
||||
];
|
||||
|
||||
254
src/services/document-service.ts
Normal file
254
src/services/document-service.ts
Normal 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
90
src/types/document.ts
Normal 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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user