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",
|
"@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",
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
@ -33,4 +33,5 @@ export { SupplierModal } from './SupplierModal';
|
|||||||
export { ViewSupplierModal } from './ViewSupplierModal';
|
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';
|
||||||
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"),
|
() => 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} />,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
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