feat: implement collapsible sidebar menu groups for document services and add document review page

This commit is contained in:
Yashwin 2026-04-01 21:30:47 +05:30
parent 14bb57a574
commit dfe6d74993
13 changed files with 1646 additions and 576 deletions

View File

@ -12,6 +12,7 @@ interface LayoutProps {
title: string; title: string;
description?: string; description?: string;
tabs?: TabItem[]; tabs?: TabItem[];
action?: React.ReactNode;
}; };
} }
@ -67,6 +68,7 @@ export const Layout = ({
title={pageHeader.title} title={pageHeader.title}
description={pageHeader.description} description={pageHeader.description}
tabs={pageHeader.tabs} tabs={pageHeader.tabs}
action={pageHeader.action}
/> />
)} )}
{children} {children}

View File

@ -1,3 +1,4 @@
import { useEffect, useState } from "react";
import { Link, useLocation } from "react-router-dom"; import { Link, useLocation } from "react-router-dom";
import { import {
LayoutDashboard, LayoutDashboard,
@ -12,6 +13,8 @@ import {
BadgeCheck, BadgeCheck,
GitBranch, GitBranch,
Briefcase, Briefcase,
ChevronDown,
ChevronRight,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -21,8 +24,17 @@ import { AuthenticatedImage } from "@/components/shared";
interface MenuItem { interface MenuItem {
icon: React.ComponentType<{ className?: string; strokeWidth?: number }>; icon: React.ComponentType<{ className?: string; strokeWidth?: number }>;
label: string;
path?: string;
isGroup?: boolean;
children?: Array<{
label: string; label: string;
path: string; path: string;
requiredPermission?: {
resource: string;
action?: string;
};
}>;
requiredPermission?: { requiredPermission?: {
resource: string; resource: string;
action?: string; // If not provided, checks for '*' or 'read' action?: string; // If not provided, checks for '*' or 'read'
@ -95,8 +107,14 @@ const tenantAdminPlatformMenu: MenuItem[] = [
}, },
{ {
icon: FileText, icon: FileText,
label: "Document Service", label: "Document Services",
path: "/tenant/documents", isGroup: true,
children: [
{ label: "Document Lists", path: "/tenant/documents", requiredPermission: { resource: "document" } },
{ label: "Create Document", path: "/tenant/documents/create", requiredPermission: { resource: "document", action: "create" } },
{ label: "Categories", path: "/tenant/documents/categories", requiredPermission: { resource: "document" } },
{ label: "Due for Review", path: "/tenant/documents/due-for-review", requiredPermission: { resource: "document" } },
],
requiredPermission: { resource: "document" }, requiredPermission: { resource: "document" },
}, },
{ icon: Package, label: "Modules", path: "/tenant/modules" }, { icon: Package, label: "Modules", path: "/tenant/modules" },
@ -117,6 +135,92 @@ const tenantAdminSystemMenu: MenuItem[] = [
}, },
]; ];
const GroupMenuItem = ({
item,
childrenItems,
location,
isSuperAdmin,
theme,
onClose
}: {
item: MenuItem;
childrenItems: any[];
location: any;
isSuperAdmin: boolean;
theme: any;
onClose: () => void;
}) => {
const isChildActive = (path: string) => {
// Special handling for Document Lists to NOT show as active when sub-actions are active
if (path === "/tenant/documents") {
const subActions = ["/create", "/categories", "/due-for-review", "/edit"];
const isSubActionActive = subActions.some(sub => location.pathname.startsWith(path + sub));
if (isSubActionActive) return false;
}
return location.pathname === path || location.pathname.startsWith(`${path}/`);
};
const isAnyChildActive = childrenItems.some(child => isChildActive(child.path));
const [isExpanded, setIsExpanded] = useState(isAnyChildActive);
useEffect(() => {
if (isAnyChildActive) setIsExpanded(true);
}, [isAnyChildActive]);
const Icon = item.icon;
return (
<div className="flex flex-col">
<button
onClick={() => setIsExpanded(!isExpanded)}
className={cn(
"flex items-center justify-between gap-2.5 px-3 py-2 rounded-md transition-all min-h-[44px]",
isAnyChildActive ? "shadow-[0px_2px_8px_0px_rgba(15,23,42,0.15)]" : "text-[#0f1724] hover:bg-gray-50"
)}
style={isAnyChildActive ? {
backgroundColor: !isSuperAdmin && theme?.primary_color ? theme.primary_color : "#112868",
color: !isSuperAdmin && theme?.secondary_color ? theme.secondary_color : "#23dce1"
} : undefined}
>
<div className="flex items-center gap-2.5">
<Icon className="w-4 h-4 shrink-0" />
<span className="text-xs md:text-xs lg:text-[13px] font-medium truncate" title={item.label}>
{item.label}
</span>
</div>
{isExpanded ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
</button>
{isExpanded && (
<div className="flex flex-col mt-1 mb-1 border-l-2 border-[rgba(0,0,0,0.08)] ml-5 py-1 gap-0.5">
{childrenItems.map((child) => {
const isActive = isChildActive(child.path);
return (
<Link
key={child.path}
to={child.path}
onClick={() => {
if (window.innerWidth < 768) {
onClose();
}
}}
className={cn(
"flex items-center px-4 py-2 rounded-r-md text-[13px] font-medium transition-all",
isActive
? "text-[#112868] font-bold bg-gray-50"
: "text-[#475569] hover:text-[#0f1724] hover:bg-gray-50"
)}
style={isActive ? { color: !isSuperAdmin && theme?.primary_color ? theme.primary_color : "#112868" } : undefined}
>
<span className="truncate" title={child.label}>{child.label}</span>
</Link>
);
})}
</div>
)}
</div>
);
};
export const Sidebar = ({ isOpen, onClose }: SidebarProps) => { export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
const location = useLocation(); const location = useLocation();
const { roles, permissions } = useAppSelector((state) => state.auth); const { roles, permissions } = useAppSelector((state) => state.auth);
@ -200,22 +304,42 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
}); });
}; };
// Filter menu items based on permissions for tenant users
const filterMenuItems = (items: MenuItem[]): MenuItem[] => { const filterMenuItems = (items: MenuItem[]): MenuItem[] => {
if (isSuperAdmin) { if (isSuperAdmin) {
return items; // Show all items for super admin return items;
} }
return items.filter((item) => { return items.filter((item) => {
// If no required permission, always show (e.g., Dashboard, Modules, Settings)
if (!item.requiredPermission) { if (!item.requiredPermission) {
return true; return true;
} }
return hasPermission( const hasParentPermission = hasPermission(
item.requiredPermission.resource, item.requiredPermission.resource,
item.requiredPermission.action, item.requiredPermission.action,
); );
if (!hasParentPermission) return false;
if (item.isGroup && item.children) {
// Deep copy children to avoid mutating original menu arrays
const filteredChildren = item.children.filter((child) => {
if (!child.requiredPermission) return true;
return hasPermission(
child.requiredPermission.resource,
child.requiredPermission.action,
);
});
// We need to return a new object to avoid issues
if (filteredChildren.length > 0) {
(item as any)._filteredChildren = filteredChildren;
return true;
}
return false;
}
return true;
}); });
}; };
@ -243,16 +367,31 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
</div> </div>
<div className="flex flex-col gap-1 mt-1"> <div className="flex flex-col gap-1 mt-1">
{items.map((item) => { {items.map((item) => {
if (item.isGroup) {
const children = (item as any)._filteredChildren || item.children || [];
return (
<GroupMenuItem
key={item.label}
item={item}
childrenItems={children}
location={location}
isSuperAdmin={isSuperAdmin}
theme={theme}
onClose={onClose}
/>
);
}
const Icon = item.icon; const Icon = item.icon;
const isTenantDashboardPath = item.path === "/tenant"; const isTenantDashboardPath = item.path === "/tenant";
const isActive = isTenantDashboardPath const isActive = isTenantDashboardPath
? location.pathname === "/tenant" ? location.pathname === "/tenant"
: location.pathname === item.path || : item.path && (location.pathname === item.path ||
location.pathname.startsWith(`${item.path}/`); location.pathname.startsWith(`${item.path}/`));
return ( return (
<Link <Link
key={item.path} key={item.path}
to={item.path} to={item.path || "#"}
onClick={() => { onClick={() => {
// Close sidebar on mobile when navigating // Close sidebar on mobile when navigating
if (window.innerWidth < 768) { if (window.innerWidth < 768) {
@ -281,7 +420,7 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
} }
> >
<Icon className="w-4 h-4 shrink-0" /> <Icon className="w-4 h-4 shrink-0" />
<span className="text-xs md:text-xs lg:text-[13px] font-medium whitespace-nowrap"> <span className="text-xs md:text-xs lg:text-[13px] font-medium truncate" title={item.label}>
{item.label} {item.label}
</span> </span>
</Link> </Link>
@ -352,11 +491,14 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
</button> </button>
</div> </div>
{/* Menu Sections (Only this part should scroll) */}
<div className="flex-1 overflow-y-auto pr-1 flex flex-col gap-6 custom-scrollbar">
{/* Platform Menu */} {/* Platform Menu */}
<MenuSection title="Platform" items={platformMenu} /> <MenuSection title="Platform" items={platformMenu} />
{/* System Menu */} {/* System Menu */}
<MenuSection title="System" items={systemMenu} /> <MenuSection title="System" items={systemMenu} />
</div>
{/* Support Center */} {/* Support Center */}
<div className="mt-auto"> <div className="mt-auto">
@ -427,6 +569,8 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
</div> </div>
</div> </div>
{/* Menu Sections (Only this part should scroll) */}
<div className="flex-1 overflow-y-auto pr-1 flex flex-col gap-3.5 md:gap-3.5 lg:gap-4 xl:gap-6 custom-scrollbar">
{/* Platform Menu */} {/* Platform Menu */}
{platformMenu.length > 0 && ( {platformMenu.length > 0 && (
<MenuSection title="Platform" items={platformMenu} /> <MenuSection title="Platform" items={platformMenu} />
@ -436,6 +580,7 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
{systemMenu.length > 0 && ( {systemMenu.length > 0 && (
<MenuSection title="System" items={systemMenu} /> <MenuSection title="System" items={systemMenu} />
)} )}
</div>
{/* Support Center */} {/* Support Center */}
<div className="mt-auto w-full"> <div className="mt-auto w-full">

View File

@ -1,15 +1,24 @@
import { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import type { ReactElement } from 'react'; import type { ReactElement } from 'react';
import { MoreVertical, Eye, Edit, Trash2, Users, BarChart3 } from 'lucide-react'; import { MoreVertical, Eye, Edit, Trash2, Users, BarChart3 } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
export interface ActionItem {
label: string;
onClick: () => void | Promise<void>;
icon?: React.ReactNode;
variant?: 'danger' | 'default';
}
interface ActionDropdownProps { interface ActionDropdownProps {
onView?: () => void; onView?: () => void;
onEdit?: () => void; onEdit?: () => void;
onDelete?: () => void; onDelete?: () => void;
onContacts?: () => void; onContacts?: () => void;
onScorecards?: () => void; onScorecards?: () => void;
actions?: ActionItem[];
trigger?: React.ReactNode;
className?: string; className?: string;
} }
@ -19,6 +28,8 @@ export const ActionDropdown = ({
onDelete, onDelete,
onContacts, onContacts,
onScorecards, onScorecards,
actions,
trigger,
className, className,
}: ActionDropdownProps): ReactElement => { }: ActionDropdownProps): ReactElement => {
const [isOpen, setIsOpen] = useState<boolean>(false); const [isOpen, setIsOpen] = useState<boolean>(false);
@ -56,14 +67,14 @@ export const ActionDropdown = ({
const rect = buttonRef.current.getBoundingClientRect(); const rect = buttonRef.current.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom; const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top; const spaceAbove = rect.top;
const dropdownHeight = 120; // Approximate height of dropdown menu const dropdownHeight = actions ? actions.length * 32 + 16 : 120; // Approximate height based on actions
// Determine if should open upward or downward // Determine if should open upward or downward
const shouldOpenUp = spaceBelow < dropdownHeight && spaceAbove > spaceBelow; const shouldOpenUp = spaceBelow < dropdownHeight && spaceAbove > spaceBelow;
// Calculate dropdown position // Calculate dropdown position
const right = window.innerWidth - rect.right; const right = window.innerWidth - rect.right;
const width = 76; // Fixed width of dropdown const width = actions ? 140 : 76; // Wider for custom actions
if (shouldOpenUp) { if (shouldOpenUp) {
// Position above the button // Position above the button
@ -88,17 +99,28 @@ export const ActionDropdown = ({
document.removeEventListener('mousedown', handleClickOutside); document.removeEventListener('mousedown', handleClickOutside);
window.removeEventListener('scroll', handleScroll, true); window.removeEventListener('scroll', handleScroll, true);
}; };
}, [isOpen]); }, [isOpen, actions]);
const handleAction = (action: () => void | undefined) => { const handleAction = (action?: () => void | Promise<void>) => {
if (action) { if (action) {
action(); void Promise.resolve(action()).catch(console.error);
} }
setIsOpen(false); setIsOpen(false);
}; };
return ( return (
<div className={cn('relative', className)} ref={dropdownRef}> <div className={cn('relative', className)} ref={dropdownRef}>
{trigger ? (
React.cloneElement(trigger as React.ReactElement<any>, {
ref: buttonRef,
onClick: (e: React.MouseEvent) => {
e.stopPropagation();
setIsOpen(!isOpen);
const triggerProps = (trigger as React.ReactElement<any>).props;
if (triggerProps.onClick) triggerProps.onClick(e);
},
})
) : (
<button <button
ref={buttonRef} ref={buttonRef}
type="button" type="button"
@ -114,15 +136,35 @@ export const ActionDropdown = ({
> >
<MoreVertical className="w-3.5 h-3.5" /> <MoreVertical className="w-3.5 h-3.5" />
</button> </button>
)}
{isOpen && buttonRef.current && createPortal( {isOpen && buttonRef.current && createPortal(
<div <div
ref={dropdownMenuRef} ref={dropdownMenuRef}
data-dropdown-menu="true" data-dropdown-menu="true"
className="fixed bg-white border border-[rgba(0,0,0,0.2)] rounded-md shadow-[0px_4px_4px_0px_rgba(0,0,0,0.08)] z-[250]" className="fixed bg-white border border-[rgba(0,0,0,0.2)] rounded-md shadow-[0px_4px_4px_0px_rgba(0,0,0,0.08)] z-[250] overflow-hidden"
style={dropdownStyle} style={dropdownStyle}
> >
<div className="flex flex-col py-1.5"> <div className="flex flex-col py-1.5">
{actions ? (
actions.map((action, index) => (
<button
key={index}
type="button"
onClick={() => handleAction(action.onClick)}
className={cn(
"flex items-center gap-2.5 px-3 py-2 text-[11px] font-medium transition-colors cursor-pointer w-full text-left",
action.variant === 'danger'
? "text-red-600 hover:bg-red-50"
: "text-[#6b7280] hover:bg-gray-50"
)}
>
{action.icon}
<span>{action.label}</span>
</button>
))
) : (
<>
{onView && ( {onView && (
<button <button
type="button" type="button"
@ -173,6 +215,8 @@ export const ActionDropdown = ({
<span>Scorecards</span> <span>Scorecards</span>
</button> </button>
)} )}
</>
)}
</div> </div>
</div>, </div>,
document.body document.body
@ -180,3 +224,4 @@ export const ActionDropdown = ({
</div> </div>
); );
}; };

View File

@ -37,6 +37,7 @@ export const FilterDropdown = ({
bottom?: string; bottom?: string;
left: string; left: string;
width: string; width: string;
minWidth?: string;
}>({ left: '0', width: '0' }); }>({ left: '0', width: '0' });
// Handle click outside // Handle click outside
@ -82,14 +83,16 @@ export const FilterDropdown = ({
setDropdownStyle({ setDropdownStyle({
bottom: `${bottom}px`, bottom: `${bottom}px`,
left: `${left}px`, left: `${left}px`,
width: `${width}px`, minWidth: `${width}px`,
width: 'auto',
}); });
} else { } else {
const top = rect.bottom; const top = rect.bottom;
setDropdownStyle({ setDropdownStyle({
top: `${top}px`, top: `${top}px`,
left: `${left}px`, left: `${left}px`,
width: `${width}px`, minWidth: `${width}px`,
width: 'auto',
}); });
} }
} }
@ -120,13 +123,20 @@ export const FilterDropdown = ({
ref={buttonRef} ref={buttonRef}
type="button" type="button"
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-2 px-3 py-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-sm text-[#475569] hover:bg-gray-50 transition-colors min-h-[44px]" className={cn(
"flex items-center gap-2 px-3 py-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-sm transition-all min-h-[40px] hover:bg-gray-50",
value ? "border-[#112868]/20 bg-[#112868]/5" : "text-[#475569]"
)}
> >
{showIcon && icon && <span className="flex-shrink-0">{icon}</span>} {showIcon && icon && <span className="flex-shrink-0 text-[#94a3b8]">{icon}</span>}
<span>{label}</span> <span className={cn("font-medium", value && "text-[#112868]")}>{label}</span>
<span className="text-[#94a3b8] text-sm">{displayText}</span> {value && (
<span className="text-[#112868] font-bold text-xs bg-white px-1.5 py-0.5 rounded border border-[#112868]/10 ml-0.5">
{displayText}
</span>
)}
<ChevronDown <ChevronDown
className={cn('w-3.5 h-3.5 text-[#94a3b8] transition-transform', isOpen && 'rotate-180')} className={cn('w-3.5 h-3.5 text-[#94a3b8] transition-transform ml-0.5', isOpen && 'rotate-180')}
/> />
</button> </button>
@ -148,7 +158,7 @@ export const FilterDropdown = ({
setIsOpen(false); setIsOpen(false);
}} }}
className={cn( className={cn(
'w-full px-3 py-2 text-left text-sm text-[#0e1b2a] hover:bg-gray-50 transition-colors', 'w-full px-3 py-2 text-left text-sm text-[#0e1b2a] hover:bg-gray-50 transition-colors whitespace-nowrap',
!value && 'bg-gray-50' !value && 'bg-gray-50'
)} )}
> >
@ -169,7 +179,7 @@ export const FilterDropdown = ({
setIsOpen(false); setIsOpen(false);
}} }}
className={cn( className={cn(
'w-full px-3 py-2 text-left text-sm text-[#0e1b2a] hover:bg-gray-50 transition-colors', 'w-full px-3 py-2 text-left text-sm text-[#0e1b2a] hover:bg-gray-50 transition-colors whitespace-nowrap',
isSelected && 'bg-gray-50' isSelected && 'bg-gray-50'
)} )}
> >

View File

@ -13,6 +13,7 @@ interface PageHeaderProps {
title: string; title: string;
description?: string; description?: string;
tabs?: TabItem[]; tabs?: TabItem[];
action?: React.ReactNode;
} }
const defaultTabs: TabItem[] = [ const defaultTabs: TabItem[] = [
@ -28,6 +29,7 @@ export const PageHeader = ({
title, title,
description, description,
tabs, tabs,
action,
}: PageHeaderProps): ReactElement => { }: PageHeaderProps): ReactElement => {
const location = useLocation(); const location = useLocation();
const { roles } = useAppSelector((state) => state.auth); const { roles } = useAppSelector((state) => state.auth);
@ -58,7 +60,7 @@ export const PageHeader = ({
.sort((a, b) => b.path.length - a.path.length)[0]?.path ?? null; .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-center md:justify-between gap-4 md:gap-6 mb-6">
{/* Title and Description */} {/* Title and Description */}
<div className="flex flex-col gap-1 max-w-full md:max-w-[434px]"> <div className="flex flex-col gap-1 max-w-full md:max-w-[434px]">
<h1 className="text-xl md:text-2xl font-bold text-[#0f1724] tracking-[-0.48px]"> <h1 className="text-xl md:text-2xl font-bold text-[#0f1724] tracking-[-0.48px]">
@ -71,6 +73,15 @@ export const PageHeader = ({
)} )}
</div> </div>
{/* Navigation Area: Tabs and Actions */}
<div className="flex items-center gap-3">
{/* Action Button */}
{action && (
<div className="flex shrink-0">
{action}
</div>
)}
{/* Tabs Navigation - Only show for super_admin */} {/* Tabs Navigation - Only show for super_admin */}
{resolvedTabs.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">
@ -94,5 +105,6 @@ export const PageHeader = ({
</div> </div>
)} )}
</div> </div>
</div>
); );
}; };

View File

@ -4,22 +4,30 @@ import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod"; import * as z from "zod";
import { Layout } from "@/components/layout/Layout"; import { Layout } from "@/components/layout/Layout";
import { FormField, FormSelect, FormTextArea, PrimaryButton, RichTextEditor } from "@/components/shared"; import {
import { documentService, type FileAttachmentItem } from "@/services/document-service"; FormField,
FormSelect,
FormTextArea,
PrimaryButton,
RichTextEditor,
} from "@/components/shared";
import {
documentService,
type FileAttachmentItem,
} from "@/services/document-service";
import type { DocumentCategory } from "@/types/document"; import type { DocumentCategory } from "@/types/document";
import { showToast } from "@/utils/toast"; import { showToast } from "@/utils/toast";
import { ArrowLeft, FileText, Info, Paperclip } from "lucide-react"; import { ArrowLeft, FileText, Info, Paperclip, X } from "lucide-react";
import { moduleService } from "@/services/module-service"; import { moduleService } from "@/services/module-service";
import type { MyModule } from "@/types/module"; import type { MyModule } from "@/types/module";
const documentSchema = z.object({ const documentSchema = z.object({
title: z.string().min(1, "Document title is required"), title: z.string().min(1, "Document title is required"),
document_number: z.string().optional(),
description: z.string().optional(), description: z.string().optional(),
document_type: z.string().min(1, "Document type is required"), document_type: z.string().min(1, "Document type is required"),
category_id: z.string().optional(), category_id: z.string().optional(),
department: z.string().optional(), department: z.string().optional(),
tags: z.string().optional(), tags: z.array(z.string()),
selectedModuleId: z.string().min(1, "Source module is required"), selectedModuleId: z.string().min(1, "Source module is required"),
content: z.string().optional(), content: z.string().optional(),
contentHtml: z.string().min(1, "Document content is required"), contentHtml: z.string().min(1, "Document content is required"),
@ -45,12 +53,11 @@ const CreateDocument = (): ReactElement => {
resolver: zodResolver(documentSchema), resolver: zodResolver(documentSchema),
defaultValues: { defaultValues: {
title: "", title: "",
document_number: "",
description: "", description: "",
document_type: "", document_type: "",
category_id: "", category_id: "",
department: "", department: "",
tags: "", tags: [],
selectedModuleId: "", selectedModuleId: "",
content: "", content: "",
contentHtml: "", contentHtml: "",
@ -121,7 +128,9 @@ const CreateDocument = (): ReactElement => {
setFileHash(selected.checksum); setFileHash(selected.checksum);
try { try {
showToast.success(`Extracting content from "${selected.original_name}"...`); showToast.success(
`Extracting content from "${selected.original_name}"...`,
);
const res = await documentService.getFileContent(fileId); const res = await documentService.getFileContent(fileId);
if (res.success && res.data) { if (res.success && res.data) {
setValue("contentHtml", res.data.html || ""); setValue("contentHtml", res.data.html || "");
@ -131,11 +140,15 @@ const CreateDocument = (): ReactElement => {
showToast.error("Failed to extract file content"); showToast.error("Failed to extract file content");
} }
} catch (err: any) { } catch (err: any) {
const msg = err?.response?.data?.error?.message || "Failed to extract file content"; const msg =
err?.response?.data?.error?.message || "Failed to extract file content";
showToast.error(msg); showToast.error(msg);
const html = `<p>Document sourced from file: <strong>${selected.original_name}</strong></p>`; const html = `<p>Document sourced from file: <strong>${selected.original_name}</strong></p>`;
setValue("contentHtml", html); setValue("contentHtml", html);
setValue("content", `Document sourced from file: ${selected.original_name}`); setValue(
"content",
`Document sourced from file: ${selected.original_name}`,
);
} }
}; };
@ -145,16 +158,10 @@ const CreateDocument = (): ReactElement => {
const response = await documentService.create({ const response = await documentService.create({
title: data.title.trim(), title: data.title.trim(),
description: data.description?.trim() || undefined, description: data.description?.trim() || undefined,
document_number: data.document_number?.trim() || undefined,
document_type: data.document_type, document_type: data.document_type,
category_id: data.category_id || undefined, category_id: data.category_id || undefined,
department: data.department?.trim() || undefined, department: data.department?.trim() || undefined,
tags: data.tags tags: data.tags || [],
? data.tags
.split(",")
.map((tag) => tag.trim())
.filter(Boolean)
: [],
content: data.content?.trim() || undefined, content: data.content?.trim() || undefined,
content_html: data.contentHtml.trim() || undefined, content_html: data.contentHtml.trim() || undefined,
file_name: fileName || undefined, file_name: fileName || undefined,
@ -162,7 +169,8 @@ const CreateDocument = (): ReactElement => {
file_size: fileSize, file_size: fileSize,
mime_type: mimeType || undefined, mime_type: mimeType || undefined,
file_hash: fileHash || undefined, file_hash: fileHash || undefined,
source_module: modules.find((m) => m.id === data.selectedModuleId)!.module_id, source_module: modules.find((m) => m.id === data.selectedModuleId)!
.module_id,
source_module_id: data.selectedModuleId, source_module_id: data.selectedModuleId,
}); });
showToast.success("Document created successfully"); showToast.success("Document created successfully");
@ -187,11 +195,6 @@ const CreateDocument = (): ReactElement => {
title: "Create Document", title: "Create Document",
description: description:
"Fill in document details, classification and draft content before submitting for workflow.", "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={handleSubmit(onFormSubmit)} className="space-y-4"> <form onSubmit={handleSubmit(onFormSubmit)} className="space-y-4">
@ -206,7 +209,8 @@ const CreateDocument = (): ReactElement => {
New Controlled Document New Controlled Document
</h3> </h3>
<p className="text-xs text-[#6b7280] mt-1"> <p className="text-xs text-[#6b7280] mt-1">
Document will be created in <span className="font-medium">Draft</span> status. Document will be created in{" "}
<span className="font-medium">Draft</span> status.
</p> </p>
</div> </div>
</div> </div>
@ -220,7 +224,7 @@ const CreateDocument = (): ReactElement => {
</button> </button>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-4"> <div className="grid grid-cols-1 gap-x-4">
<FormField <FormField
label="Document Title" label="Document Title"
required required
@ -228,12 +232,6 @@ const CreateDocument = (): ReactElement => {
error={errors.title?.message} error={errors.title?.message}
{...register("title")} {...register("title")}
/> />
<FormField
label="Document Number"
placeholder="Auto-generated if empty"
error={errors.document_number?.message}
{...register("document_number")}
/>
</div> </div>
<FormTextArea <FormTextArea
@ -258,7 +256,10 @@ const CreateDocument = (): ReactElement => {
required required
value={field.value} value={field.value}
onValueChange={field.onChange} onValueChange={field.onChange}
options={types.map((type) => ({ value: type.code, label: type.name }))} options={types.map((type) => ({
value: type.code,
label: type.name,
}))}
placeholder="Select type" placeholder="Select type"
error={errors.document_type?.message} error={errors.document_type?.message}
/> />
@ -283,16 +284,62 @@ const CreateDocument = (): ReactElement => {
/> />
<FormField <FormField
label="Department" label="Department"
placeholder="Optional" placeholder="Optional department name"
error={errors.department?.message} error={errors.department?.message}
{...register("department")} {...register("department")}
/> />
<FormField <div className="flex flex-col gap-2 pb-1">
label="Tags" <label className="text-[13px] font-medium text-[#0e1b2a]">
placeholder="Comma separated tags (e.g. quality, sop)" Tags (Press enter to add)
error={errors.tags?.message} </label>
{...register("tags")} <Controller
name="tags"
control={control}
render={({ field }) => (
<div className="flex flex-wrap gap-2 p-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md min-h-[40px] focus-within:ring-1 focus-within:ring-[#112868]/20 focus-within:border-[#112868]/20 transition-all">
{(field.value || []).map((tag, tagIdx) => (
<span
key={tagIdx}
className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-[#112868]/5 text-[#112868] rounded text-xs font-semibold border border-[#112868]/10"
>
{tag}
<button
type="button"
onClick={() => {
field.onChange(
(field.value || []).filter(
(_, i) => i !== tagIdx,
),
);
}}
className="hover:text-[#e02424] transition-colors"
>
<X className="w-3 h-3" />
</button>
</span>
))}
<input
type="text"
className="flex-1 outline-none text-sm min-w-[150px] bg-transparent"
placeholder="Type and press enter..."
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
const val = e.currentTarget.value.trim();
if (val && !(field.value || []).includes(val)) {
field.onChange([...(field.value || []), val]);
e.currentTarget.value = "";
}
}
}}
/> />
</div>
)}
/>
{errors.tags && (
<p className="text-xs text-[#ef4444]">{errors.tags.message}</p>
)}
</div>
<Controller <Controller
name="selectedModuleId" name="selectedModuleId"
control={control} control={control}
@ -318,10 +365,13 @@ const CreateDocument = (): ReactElement => {
<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="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] p-4 md:p-5">
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-3">
<Paperclip className="w-4 h-4 text-[#112868]" /> <Paperclip className="w-4 h-4 text-[#112868]" />
<h3 className="text-sm font-semibold text-[#0f1724]">Attach File (Optional)</h3> <h3 className="text-sm font-semibold text-[#0f1724]">
Attach File (Optional)
</h3>
</div> </div>
<p className="text-xs text-[#6b7280] mb-3"> <p className="text-xs text-[#6b7280] mb-3">
Select a previously uploaded file to automatically populate content and file metadata. Select a previously uploaded file to automatically populate content
and file metadata.
</p> </p>
<FormSelect <FormSelect
label="Select File" label="Select File"
@ -334,22 +384,36 @@ const CreateDocument = (): ReactElement => {
label: `${f.original_name} (${f.file_size_formatted})`, label: `${f.original_name} (${f.file_size_formatted})`,
})), })),
]} ]}
placeholder={isLoadingFiles ? "Loading files..." : "Select a file to attach"} placeholder={
isLoadingFiles ? "Loading files..." : "Select a file to attach"
}
/> />
{selectedFileId && ( {selectedFileId && (
<div className="mt-3 grid grid-cols-1 md:grid-cols-2 gap-x-3 gap-y-0 text-xs text-[#6b7280]"> <div className="mt-3 grid grid-cols-1 md:grid-cols-2 gap-x-3 gap-y-0 text-xs text-[#6b7280]">
<p><span className="font-medium">File:</span> {fileName}</p> <p>
<p><span className="font-medium">Type:</span> {mimeType}</p> <span className="font-medium">File:</span> {fileName}
<p><span className="font-medium">Size:</span> {fileSize ? `${(fileSize / 1024 / 1024).toFixed(2)} MB` : "-"}</p> </p>
<p><span className="font-medium">Hash:</span> {fileHash ? fileHash.substring(0, 16) + "..." : "-"}</p> <p>
<span className="font-medium">Type:</span> {mimeType}
</p>
<p>
<span className="font-medium">Size:</span>{" "}
{fileSize ? `${(fileSize / 1024 / 1024).toFixed(2)} MB` : "-"}
</p>
<p>
<span className="font-medium">Hash:</span>{" "}
{fileHash ? fileHash.substring(0, 16) + "..." : "-"}
</p>
</div> </div>
)} )}
</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="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"> <div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-[#0f1724]">Initial Content</h3> <h3 className="text-sm font-semibold text-[#0f1724]">
Initial Content
</h3>
<span className="text-[11px] text-[#94a3b8]"> <span className="text-[11px] text-[#94a3b8]">
{watch("content")?.length || 0} characters {watch("content")?.length || 0} characters
</span> </span>
@ -363,7 +427,7 @@ const CreateDocument = (): ReactElement => {
value={field.value} value={field.value}
required required
placeholder="Write the initial document content..." placeholder="Write the initial document content..."
minHeightClassName="min-h-[280px]" minHeightClassName="h-[400px] overflow-y-auto"
onChange={(html, text) => { onChange={(html, text) => {
field.onChange(html); field.onChange(html);
setValue("content", text); setValue("content", text);

View File

@ -1,19 +1,70 @@
import { useEffect, useMemo, useState, type ReactElement } from "react"; import { useEffect, useMemo, useState, type ReactElement } from "react";
import { useNavigate } from "react-router-dom"; // import { useNavigate } from "react-router-dom";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { Layout } from "@/components/layout/Layout"; import { Layout } from "@/components/layout/Layout";
import { DataTable, FormField, PrimaryButton, type Column } from "@/components/shared"; import {
DataTable,
FormField,
FormSelect,
FormTextArea,
PrimaryButton,
Modal,
ActionDropdown,
DeleteConfirmationModal,
type Column
} from "@/components/shared";
import { documentService } from "@/services/document-service"; import { documentService } from "@/services/document-service";
import type { DocumentCategory } from "@/types/document"; import type { DocumentCategory } from "@/types/document";
import { showToast } from "@/utils/toast"; import { showToast } from "@/utils/toast";
import { Plus, Eye, Edit, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
const categorySchema = z.object({
name: z.string().min(1, "Category name is required"),
code: z.string().min(1, "Code is required").max(10, "Code must be 10 characters or less"),
description: z.string().optional(),
reviewFrequency: z.string().min(1, "Review frequency is required"),
retentionYears: z.string().min(1, "Retention years is required"),
requiresTraining: z.boolean().optional(),
parentId: z.string().optional(),
});
type CategoryFormData = z.infer<typeof categorySchema>;
const DocumentCategories = (): ReactElement => { const DocumentCategories = (): ReactElement => {
const navigate = useNavigate(); // const navigate = useNavigate();
const [categories, setCategories] = useState<DocumentCategory[]>([]); const [categories, setCategories] = useState<DocumentCategory[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [name, setName] = useState("");
const [code, setCode] = useState("");
const [description, setDescription] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isViewModalOpen, setIsViewModalOpen] = useState(false);
const [editingCategory, setEditingCategory] = useState<DocumentCategory | null>(null);
const [viewingCategory, setViewingCategory] = useState<DocumentCategory | null>(null);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [categoryToDelete, setCategoryToDelete] = useState<DocumentCategory | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const {
register,
handleSubmit,
control,
reset,
setValue,
formState: { errors },
} = useForm<CategoryFormData>({
resolver: zodResolver(categorySchema),
defaultValues: {
name: "",
code: "",
description: "",
reviewFrequency: "12",
retentionYears: "7",
requiresTraining: false,
parentId: "",
},
});
const loadCategories = async (): Promise<void> => { const loadCategories = async (): Promise<void> => {
try { try {
@ -33,77 +84,145 @@ const DocumentCategories = (): ReactElement => {
void loadCategories(); void loadCategories();
}, []); }, []);
const handleEdit = (category: DocumentCategory) => {
setEditingCategory(category);
setValue("name", category.name);
setValue("code", category.code);
setValue("description", category.description || "");
setValue("reviewFrequency", category.review_frequency_months?.toString() || "12");
setValue("retentionYears", category.retention_years?.toString() || "7");
setValue("requiresTraining", !!category.requires_training);
setValue("parentId", category.parent_id || "");
setIsModalOpen(true);
};
const handleView = (category: DocumentCategory) => {
setViewingCategory(category);
setIsViewModalOpen(true);
};
const handleDeleteClick = (category: DocumentCategory) => {
setCategoryToDelete(category);
setIsDeleteModalOpen(true);
};
const handleConfirmDelete = async () => {
if (!categoryToDelete) return;
try {
setIsDeleting(true);
await documentService.deleteCategory(categoryToDelete.id);
showToast.success("Category deleted successfully");
setIsDeleteModalOpen(false);
setCategoryToDelete(null);
await loadCategories();
} catch (err: any) {
showToast.error(
err?.response?.data?.error?.message || "Failed to delete category",
);
} finally {
setIsDeleting(false);
}
};
const columns: Column<DocumentCategory>[] = useMemo( const columns: Column<DocumentCategory>[] = useMemo(
() => [ () => [
{ key: "name", label: "Name" },
{ key: "code", label: "Code" },
{ {
key: "description", key: "name",
label: "Description", label: "Name",
render: (category) => category.description || "-", render: (cat) => <span className="text-[#0f1724] font-medium">{cat.name}</span>
},
{
key: "code",
label: "Code",
render: (cat) => <span className="inline-flex items-center rounded-md bg-blue-50 px-2 py-0.5 text-xs font-bold text-blue-600 border border-blue-100">{cat.code}</span>
}, },
{ {
key: "review_frequency_months", key: "review_frequency_months",
label: "Review (months)", label: "Review Frequency",
render: (category) => render: (category) =>
category.review_frequency_months?.toString() || "-", category.review_frequency_months ? `${category.review_frequency_months} months` : "-",
},
{
key: "parent_id",
label: "Parent Category",
render: (category) => {
const parent = categories.find(c => c.id === category.parent_id);
return <span className="text-[#6b7280]">{parent ? parent.name : "-"}</span>;
}
}, },
{ {
key: "retention_years", key: "retention_years",
label: "Retention (years)", label: "Retention",
render: (category) => category.retention_years?.toString() || "-", render: (category) => category.retention_years ? `${category.retention_years} years` : "-",
},
{
key: "requires_training",
label: "Requires Training",
render: (category) => (
<div className="flex items-center">
<div className={cn(
"w-10 h-5 rounded-full relative transition-colors duration-200 pointer-events-none",
category.requires_training ? "bg-[#084cc8]" : "bg-gray-200"
)}>
<div className={cn(
"absolute top-1 left-1 w-3 h-3 bg-white rounded-full transition-transform duration-200 shadow-sm",
category.requires_training && "translate-x-5"
)} />
</div>
</div>
)
},
{
key: "description",
label: "Description",
render: (category) => <span className="text-gray-500 line-clamp-1 max-w-[300px]">{category.description || "-"}</span>,
}, },
{ {
key: "actions", key: "actions",
label: "Actions", label: "Actions",
align: "right", align: "right",
render: (category) => ( render: (category) => (
<button <ActionDropdown
type="button" actions={[
className="text-[#ef4444] hover:underline" { label: "View Details", onClick: () => handleView(category), icon: <Eye className="w-4 h-4" /> },
onClick={async () => { { label: "Edit Category", onClick: () => handleEdit(category), icon: <Edit className="w-4 h-4" /> },
try { { label: "Delete", onClick: () => handleDeleteClick(category), icon: <Trash2 className="w-4 h-4" />, variant: "danger" },
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>
), ),
}, },
], ],
[], [categories],
); );
const onCreateCategory = async (event: React.FormEvent): Promise<void> => { const onFormSubmit = async (data: CategoryFormData): Promise<void> => {
event.preventDefault();
if (!name.trim() || !code.trim()) {
showToast.error("Name and code are required");
return;
}
try { try {
setIsSubmitting(true); setIsSubmitting(true);
await documentService.createCategory({ const payload = {
name: name.trim(), name: data.name.trim(),
code: code.trim().toUpperCase(), code: data.code.trim().toUpperCase(),
description: description.trim() || undefined, description: data.description?.trim() || undefined,
}); review_frequency_months: parseInt(data.reviewFrequency),
retention_years: parseInt(data.retentionYears),
requires_training: data.requiresTraining,
parent_id: data.parentId || null,
};
if (editingCategory) {
await documentService.updateCategory(editingCategory.id, payload);
showToast.success("Category updated");
} else {
await documentService.createCategory(payload);
showToast.success("Category created"); showToast.success("Category created");
setName(""); }
setCode("");
setDescription(""); setIsModalOpen(false);
reset();
setEditingCategory(null);
await loadCategories(); await loadCategories();
} catch (err: any) { } catch (err: any) {
showToast.error( showToast.error(
err?.response?.data?.error?.message || "Failed to create category", err?.response?.data?.error?.message || "Failed to process category",
); );
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
@ -113,60 +232,18 @@ const DocumentCategories = (): ReactElement => {
return ( return (
<Layout <Layout
currentPage="Document Service" currentPage="Document Service"
breadcrumbs={[
{ label: "Document Service", path: "/tenant/documents" },
{ label: "Category Management" },
]}
pageHeader={{ pageHeader={{
title: "Category Management", title: "Document Categories",
description: "Create and maintain document categories.", description: "View and manage the document categories and their retention policies.",
tabs: [ action: (
{ label: "Document List", path: "/tenant/documents" }, <PrimaryButton onClick={() => { setEditingCategory(null); reset(); setIsModalOpen(true); }}>
{ label: "Create Document", path: "/tenant/documents/create" }, <Plus className="w-4 h-4 mr-1.5" />
{ label: "Category Management", path: "/tenant/documents/categories" }, Create Category
], </PrimaryButton>
),
}} }}
> >
<div className="space-y-4"> <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"> <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 <DataTable
@ -178,9 +255,227 @@ const DocumentCategories = (): ReactElement => {
/> />
</div> </div>
</div> </div>
<Modal
isOpen={isModalOpen}
onClose={() => { setIsModalOpen(false); setEditingCategory(null); }}
title={editingCategory ? "Update Document Category" : "Create Document Category"}
maxWidth="lg"
>
<form onSubmit={handleSubmit(onFormSubmit)} className="p-6 space-y-5">
<p className="text-sm text-gray-500 -mt-2">
Add a document category with review, retention, and training requirements.
</p>
<div className="flex flex-col gap-4">
<FormField
label="Category Name"
required
placeholder="e.g. Standard Operating Procedures"
error={errors.name?.message}
{...register("name")}
/>
<FormField
label="Code"
required
placeholder="e.g. SOP"
// description="Short code (e.g. INT, EXT, AUD). Max 100 characters."
error={errors.code?.message}
{...register("code")}
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<Controller
name="reviewFrequency"
control={control}
render={({ field }) => (
<FormSelect
label="Review Frequency (months)"
value={field.value}
onValueChange={field.onChange}
options={[
{ value: "1", label: "1" },
{ value: "3", label: "3" },
{ value: "6", label: "6" },
{ value: "12", label: "12" },
{ value: "24", label: "24" },
{ value: "36", label: "36" },
{ value: "60", label: "60" },
]}
placeholder="Select months"
/>
)}
/>
<Controller
name="retentionYears"
control={control}
render={({ field }) => (
<FormSelect
label="Retention (years)"
value={field.value}
onValueChange={field.onChange}
options={[
{ value: "1", label: "1" },
{ value: "3", label: "3" },
{ value: "5", label: "5" },
{ value: "7", label: "7" },
{ value: "10", label: "10" },
{ value: "25", label: "25" },
{ value: "99", label: "Permanent" },
]}
placeholder="Select years"
/>
)}
/>
</div>
<Controller
name="parentId"
control={control}
render={({ field }) => (
<FormSelect
label="Parent Category (Optional)"
value={field.value}
onValueChange={field.onChange}
options={[
{ value: "", label: "No Parent (Root)" },
...categories
.filter(c => !editingCategory || c.id !== editingCategory.id)
.map(c => ({ value: c.id, label: c.name }))
]}
placeholder="Select parent category"
/>
)}
/>
<FormTextArea
label="Description"
placeholder="Description of this user category."
error={errors.description?.message}
rows={3}
{...register("description")}
/>
<div className="flex items-center justify-between py-4 border-t border-gray-100 mt-2">
<div>
<label className="text-sm font-bold text-[#0f1724]">Requires Training</label>
<p className="text-[11px] text-gray-500">Users must acknowledge documents in this category</p>
</div>
<Controller
name="requiresTraining"
control={control}
render={({ field }) => (
<div
className={cn(
"w-10 h-5 rounded-full relative transition-colors duration-200 cursor-pointer",
field.value ? "bg-[#084cc8]" : "bg-gray-200"
)}
onClick={() => field.onChange(!field.value)}
>
<div className={cn(
"absolute top-1 left-1 w-3 h-3 bg-white rounded-full transition-transform duration-200 shadow-sm",
field.value && "translate-x-5"
)} />
</div>
)}
/>
</div>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-gray-100">
<button
type="button"
onClick={() => { setIsModalOpen(false); setEditingCategory(null); }}
className="px-6 py-2 border border-gray-200 rounded-md text-sm font-bold text-[#0f1724] hover:bg-gray-50 transition-colors"
>
Cancel
</button>
<PrimaryButton type="submit" disabled={isSubmitting} className="px-6">
{isSubmitting ? "Processing..." : editingCategory ? "Update Category" : "Create Category"}
</PrimaryButton>
</div>
</form>
</Modal>
{/* View Modal */}
<Modal
isOpen={isViewModalOpen}
onClose={() => { setIsViewModalOpen(false); setViewingCategory(null); }}
title="Document Category Details"
maxWidth="lg"
>
<div className="p-6 space-y-6">
<div className="grid grid-cols-2 gap-6">
<div>
<label className="text-xs text-gray-500 uppercase font-bold tracking-wider">Name</label>
<p className="text-sm font-semibold text-[#0f1724] mt-1">{viewingCategory?.name}</p>
</div>
<div>
<label className="text-xs text-gray-500 uppercase font-bold tracking-wider">Code</label>
<p className="text-sm font-semibold text-[#0f1724] mt-1">
<span className="bg-blue-50 text-blue-600 px-2 py-0.5 rounded border border-blue-100">{viewingCategory?.code}</span>
</p>
</div>
<div>
<label className="text-xs text-gray-500 uppercase font-bold tracking-wider">Review Frequency</label>
<p className="text-sm font-semibold text-[#0f1724] mt-1">{viewingCategory?.review_frequency_months} months</p>
</div>
<div>
<label className="text-xs text-gray-500 uppercase font-bold tracking-wider">Retention</label>
<p className="text-sm font-semibold text-[#0f1724] mt-1">{viewingCategory?.retention_years} years</p>
</div>
<div>
<label className="text-xs text-gray-500 uppercase font-bold tracking-wider">Parent Category</label>
<p className="text-sm font-semibold text-[#0f1724] mt-1">
{categories.find(c => c.id === viewingCategory?.parent_id)?.name || "None (Root Category)"}
</p>
</div>
</div>
<div>
<label className="text-xs text-gray-500 uppercase font-bold tracking-wider">Description</label>
<p className="text-sm text-gray-600 mt-1 leading-relaxed">{viewingCategory?.description || "No description provided."}</p>
</div>
<div className="bg-gray-50 p-4 rounded-lg flex items-center justify-between">
<div>
<p className="text-sm font-bold text-[#0f1724]">Requires Training</p>
<p className="text-xs text-gray-500">Training acknowledgement is {viewingCategory?.requires_training ? "enabled" : "disabled"} for this category.</p>
</div>
<div className={cn(
"w-10 h-5 rounded-full relative",
viewingCategory?.requires_training ? "bg-[#084cc8]" : "bg-gray-200"
)}>
<div className={cn(
"absolute top-1 left-1 w-3 h-3 bg-white rounded-full",
viewingCategory?.requires_training && "translate-x-5"
)} />
</div>
</div>
<div className="flex justify-end pt-4 border-t border-gray-100">
<button
onClick={() => setIsViewModalOpen(false)}
className="px-6 py-2 bg-[#0f1724] rounded-md text-sm font-bold text-white hover:bg-black transition-colors"
>
Close
</button>
</div>
</div>
</Modal>
<DeleteConfirmationModal
isOpen={isDeleteModalOpen}
onClose={() => {
setIsDeleteModalOpen(false);
setCategoryToDelete(null);
}}
onConfirm={handleConfirmDelete}
title="Delete Document Category"
message="Are you sure you want to delete this category? This action cannot be undone and will fail if the category is currently associated with documents."
itemName={categoryToDelete?.name || ""}
isLoading={isDeleting}
/>
</Layout> </Layout>
); );
}; };
export default DocumentCategories; export default DocumentCategories;

View File

@ -10,7 +10,7 @@ import {
} from "@/components/shared"; } from "@/components/shared";
import { documentService } from "@/services/document-service"; import { documentService } from "@/services/document-service";
import type { DocumentCategory, DocumentSummary } from "@/types/document"; import type { DocumentCategory, DocumentSummary } from "@/types/document";
import { Plus } from "lucide-react"; import { Plus, Search } from "lucide-react";
const formatDate = (value?: string | null): string => { const formatDate = (value?: string | null): string => {
if (!value) return "-"; if (!value) return "-";
@ -183,17 +183,47 @@ const Documents = (): ReactElement => {
title: "Document List", title: "Document List",
description: description:
"Manage controlled documents, track versions and open document details.", "Manage controlled documents, track versions and open document details.",
tabs: [ action: (
{ label: "Document List", path: "/tenant/documents" }, <div className="flex gap-2">
{ label: "Create Document", path: "/tenant/documents/create" }, {/* <button
{ label: "Category Management", path: "/tenant/documents/categories" }, 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 bg-white transition-colors"
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 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="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="border-b border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-4">
<div className="flex flex-col md:flex-row gap-2 md:items-center md:justify-between"> <div className="flex flex-col md:flex-row items-center justify-between gap-4">
<div className="flex flex-wrap items-center gap-2"> {/* Left side: Search and Filters */}
<div className="flex flex-1 flex-wrap items-center gap-3">
{/* Search Bar */}
<div className="relative w-full max-w-[280px]">
<div className="absolute inset-y-0 left-3 flex items-center pointer-events-none text-gray-400">
<Search className="w-4 h-4" />
</div>
<input
type="text"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setCurrentPage(1);
}}
placeholder="Search by name, ID..."
className="h-10 w-full pl-9 pr-3 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-sm focus:outline-none focus:ring-1 focus:ring-[#112868]/10 transition-all"
/>
</div>
{/* Filters */}
<div className="flex items-center gap-2">
<FilterDropdown <FilterDropdown
label="Status" label="Status"
options={statuses.map((status) => ({ options={statuses.map((status) => ({
@ -207,6 +237,7 @@ const Documents = (): ReactElement => {
}} }}
placeholder="All" placeholder="All"
/> />
<FilterDropdown <FilterDropdown
label="Category" label="Category"
options={categories.map((category) => ({ options={categories.map((category) => ({
@ -220,6 +251,7 @@ const Documents = (): ReactElement => {
}} }}
placeholder="All" placeholder="All"
/> />
<FilterDropdown <FilterDropdown
label="Type" label="Type"
options={types.map((type) => ({ options={types.map((type) => ({
@ -233,31 +265,50 @@ const Documents = (): ReactElement => {
}} }}
placeholder="All" placeholder="All"
/> />
{/* <FilterDropdown
label="Priority"
options={[
{ value: "high", label: "High" },
{ value: "medium", label: "Medium" },
{ value: "low", label: "Low" },
]}
value={null}
onChange={() => {}}
placeholder="All"
/>
<FilterDropdown
label=""
showIcon={true}
icon={<SlidersHorizontal className="w-3.5 h-3.5" />}
options={[
{ value: "more", label: "More Filters..." },
]}
value={null}
onChange={() => {}}
placeholder="More"
/> */}
</div> </div>
<div className="flex gap-2"> </div>
{/* Right side: Clear Filters */}
<div className="flex items-center gap-4">
<button <button
type="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={() => {
onClick={() => navigate("/tenant/documents/categories")} setSearch("");
> setStatusFilter(null);
Manage Categories setCategoryFilter(null);
</button> setTypeFilter(null);
<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); setCurrentPage(1);
}} }}
placeholder="Search by title, description or document number" className="text-sm font-medium text-[#6b7280] hover:text-[#0f1724] transition-colors"
className="h-10 w-full max-w-xl px-3 border border-[rgba(0,0,0,0.08)] rounded-md text-sm" >
/> Clear filters
</button>
</div>
</div>
</div> </div>
<DataTable <DataTable

View File

@ -0,0 +1,168 @@
import { useEffect, useMemo, useState, type ReactElement } from "react";
import { useNavigate } from "react-router-dom";
import { Layout } from "@/components/layout/Layout";
import {
DataTable,
FilterDropdown,
type Column,
} from "@/components/shared";
import { documentService } from "@/services/document-service";
import type { DocumentSummary } from "@/types/document";
import { AlertCircle } 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 getDaysRemaining = (dateStr?: string | null): number | null => {
if (!dateStr) return null;
const target = new Date(dateStr);
const now = new Date();
const diffTime = target.getTime() - now.getTime();
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
};
const DocumentsDueForReview = (): ReactElement => {
const navigate = useNavigate();
const [documents, setDocuments] = useState<DocumentSummary[]>([]);
const [daysFilter, setDaysFilter] = useState<string>("30");
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const loadDueDocuments = async (): Promise<void> => {
try {
setIsLoading(true);
setError(null);
const response = await documentService.getDueForReview(parseInt(daysFilter));
setDocuments(response.data || []);
} catch (err: any) {
setError(
err?.response?.data?.error?.message || "Failed to load documents due for review",
);
} finally {
setIsLoading(false);
}
};
void loadDueDocuments();
}, [daysFilter]);
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] font-medium">{doc.title}</span>,
},
{
key: "category",
label: "Category",
render: (doc) => <span className="text-[#6b7280]">{doc.category || "-"}</span>,
},
{
key: "owner",
label: "Owner",
render: (doc) => <span className="text-[#6b7280]">{doc.owner || "-"}</span>,
},
{
key: "next_review_date",
label: "Review Due Date",
render: (doc) => (
<span className="text-[#0f1724] font-semibold">{formatDate(doc.next_review_date)}</span>
),
},
{
key: "days_remaining",
label: "Status",
render: (doc) => {
const days = getDaysRemaining(doc.next_review_date);
if (days === null) return "-";
let colorClass = "bg-emerald-100 text-emerald-700";
if (days <= 0) colorClass = "bg-rose-100 text-rose-700";
else if (days <= 7) colorClass = "bg-amber-100 text-amber-700";
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold uppercase ${colorClass}`}>
{days <= 0 ? "Overdue" : `${days} days left`}
</span>
);
},
},
{
key: "actions",
label: "Actions",
render: (doc) => (
<button
type="button"
className="text-xs text-[#084cc8] hover:underline font-bold"
onClick={() => navigate(`/tenant/documents/${doc.id}`)}
>
Review Now
</button>
),
},
],
[navigate],
);
return (
<Layout
currentPage="Document Service"
pageHeader={{
title: "Documents Due for Review",
description: "Review and update documents approaching their periodic review cycle to ensure compliance.",
}}
>
<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-4 bg-amber-50/30 flex items-center justify-between">
<div className="flex items-center gap-2 text-amber-800">
<AlertCircle className="w-5 h-5" />
<h3 className="text-sm font-bold">Heads up: These documents need attention soon.</h3>
</div>
<FilterDropdown
label="Review within"
options={[
{ value: "7", label: "7 Days" },
{ value: "30", label: "30 Days" },
{ value: "60", label: "60 Days" },
{ value: "90", label: "90 Days" },
]}
value={daysFilter}
onChange={(value) => setDaysFilter(value as string)}
/>
</div>
<DataTable
data={documents}
columns={columns}
keyExtractor={(doc) => doc.id}
emptyMessage="No documents due for review within this period."
isLoading={isLoading}
error={error}
/>
</div>
</Layout>
);
};
export default DocumentsDueForReview;

View File

@ -10,7 +10,10 @@ import {
SecondaryButton, SecondaryButton,
type Column, type Column,
} from "@/components/shared"; } from "@/components/shared";
import { documentService, type FileAttachmentItem } from "@/services/document-service"; import {
documentService,
type FileAttachmentItem,
} from "@/services/document-service";
import { workflowService } from "@/services/workflow-service"; import { workflowService } from "@/services/workflow-service";
import type { DocumentDetail, DocumentVersion } from "@/types/document"; import type { DocumentDetail, DocumentVersion } from "@/types/document";
import type { WorkflowInstance } from "@/types/workflow"; import type { WorkflowInstance } from "@/types/workflow";
@ -54,10 +57,10 @@ const ViewDocument = (): ReactElement => {
const [versions, setVersions] = useState<DocumentVersion[]>([]); const [versions, setVersions] = useState<DocumentVersion[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<"overview" | "version-history" | "workflow-history">( const [activeTab, setActiveTab] = useState<
"overview", "overview" | "version-history" | "workflow-history"
); >("overview");
const [workflowInstances, setWorkflowInstances] = useState<any[]>([]); const [workflowHistory, setWorkflowHistory] = useState<any[]>([]);
const [isHistoryLoading, setIsHistoryLoading] = useState(false); const [isHistoryLoading, setIsHistoryLoading] = useState(false);
const [activeAction, setActiveAction] = useState<DocumentAction | null>(null); const [activeAction, setActiveAction] = useState<DocumentAction | null>(null);
@ -72,13 +75,18 @@ const ViewDocument = (): ReactElement => {
const [showNewVersionForm, setShowNewVersionForm] = useState(false); const [showNewVersionForm, setShowNewVersionForm] = useState(false);
const [newVersionContent, setNewVersionContent] = useState(""); const [newVersionContent, setNewVersionContent] = useState("");
const [newVersionContentHtml, setNewVersionContentHtml] = useState(""); const [newVersionContentHtml, setNewVersionContentHtml] = useState("");
const [newVersionChangeReason, setNewVersionChangeReason] = useState("minor_edit"); const [newVersionChangeReason, setNewVersionChangeReason] =
useState("minor_edit");
const [newVersionChangeSummary, setNewVersionChangeSummary] = useState(""); const [newVersionChangeSummary, setNewVersionChangeSummary] = useState("");
const [isMajorVersion, setIsMajorVersion] = useState(false); const [isMajorVersion, setIsMajorVersion] = useState(false);
const [versionErrors, setVersionErrors] = useState<Record<string, string>>({}); const [versionErrors, setVersionErrors] = useState<Record<string, string>>(
{},
);
const [actionErrors, setActionErrors] = useState<Record<string, string>>({});
const [isVersionSaving, setIsVersionSaving] = useState(false); const [isVersionSaving, setIsVersionSaving] = useState(false);
const [showWorkflowTracker, setShowWorkflowTracker] = useState(false); const [showWorkflowTracker, setShowWorkflowTracker] = useState(false);
const [workflowInstance, setWorkflowInstance] = useState<WorkflowInstance | null>(null); const [workflowInstance, setWorkflowInstance] =
useState<WorkflowInstance | null>(null);
const [isWorkflowLoading, setIsWorkflowLoading] = useState(false); const [isWorkflowLoading, setIsWorkflowLoading] = useState(false);
// File attachment fields for new version // File attachment fields for new version
@ -86,10 +94,13 @@ const ViewDocument = (): ReactElement => {
const [versionSelectedFileId, setVersionSelectedFileId] = useState(""); const [versionSelectedFileId, setVersionSelectedFileId] = useState("");
const [versionFileName, setVersionFileName] = useState(""); const [versionFileName, setVersionFileName] = useState("");
const [transitionComment, setTransitionComment] = useState(""); const [transitionComment, setTransitionComment] = useState("");
const [selectedWorkflowAction, setSelectedWorkflowAction] = useState<any>(null); const [selectedWorkflowAction, setSelectedWorkflowAction] =
useState<any>(null);
const [isTransitioning, setIsTransitioning] = useState(false); const [isTransitioning, setIsTransitioning] = useState(false);
const [versionFilePath, setVersionFilePath] = useState(""); const [versionFilePath, setVersionFilePath] = useState("");
const [versionFileSize, setVersionFileSize] = useState<number | undefined>(undefined); const [versionFileSize, setVersionFileSize] = useState<number | undefined>(
undefined,
);
const [versionMimeType, setVersionMimeType] = useState(""); const [versionMimeType, setVersionMimeType] = useState("");
const [versionFileHash, setVersionFileHash] = useState(""); const [versionFileHash, setVersionFileHash] = useState("");
const [isLoadingVersionFiles, setIsLoadingVersionFiles] = useState(false); const [isLoadingVersionFiles, setIsLoadingVersionFiles] = useState(false);
@ -109,7 +120,8 @@ const ViewDocument = (): ReactElement => {
setVersions(versionsRes.data || []); setVersions(versionsRes.data || []);
} catch (err: any) { } catch (err: any) {
const message = const message =
err?.response?.data?.error?.message || "Failed to load document details"; err?.response?.data?.error?.message ||
"Failed to load document details";
setError(message); setError(message);
showToast.error(message); showToast.error(message);
} finally { } finally {
@ -128,16 +140,28 @@ const ViewDocument = (): ReactElement => {
}, [activeTab, id]); }, [activeTab, id]);
const loadWorkflowHistory = async (): Promise<void> => { const loadWorkflowHistory = async (): Promise<void> => {
if (!document?.workflow_instance_id) {
setWorkflowHistory([]);
return;
}
try { try {
setIsHistoryLoading(true); setIsHistoryLoading(true);
const res = await workflowService.listInstances({ const res = await workflowService.getInstanceHistory(
entity_type: "document", document.workflow_instance_id,
entity_id: id, );
limit: 100, const history = res.data || [];
offset: 0,
}); // Sort history descending by default
setWorkflowInstances(res.data || []); const sortedHistory = [...history].sort(
(a: any, b: any) =>
new Date(b.performed_at).getTime() -
new Date(a.performed_at).getTime(),
);
setWorkflowHistory(sortedHistory);
} catch { } catch {
setWorkflowHistory([]);
showToast.error("Failed to load workflow history"); showToast.error("Failed to load workflow history");
} finally { } finally {
setIsHistoryLoading(false); setIsHistoryLoading(false);
@ -177,7 +201,9 @@ const ViewDocument = (): ReactElement => {
setVersionFileHash(selected.checksum); setVersionFileHash(selected.checksum);
try { try {
showToast.success(`Extracting content from "${selected.original_name}"...`); showToast.success(
`Extracting content from "${selected.original_name}"...`,
);
const res = await documentService.getFileContent(fileId); const res = await documentService.getFileContent(fileId);
if (res.success && res.data) { if (res.success && res.data) {
setNewVersionContentHtml(res.data.html || ""); setNewVersionContentHtml(res.data.html || "");
@ -187,11 +213,14 @@ const ViewDocument = (): ReactElement => {
showToast.error("Failed to extract file content"); showToast.error("Failed to extract file content");
} }
} catch (err: any) { } catch (err: any) {
const msg = err?.response?.data?.error?.message || "Failed to extract file content"; const msg =
err?.response?.data?.error?.message || "Failed to extract file content";
showToast.error(msg); showToast.error(msg);
const html = `<p>Document sourced from file: <strong>${selected.original_name}</strong></p>`; const html = `<p>Document sourced from file: <strong>${selected.original_name}</strong></p>`;
setNewVersionContentHtml(html); setNewVersionContentHtml(html);
setNewVersionContent(`Document sourced from file: ${selected.original_name}`); setNewVersionContent(
`Document sourced from file: ${selected.original_name}`,
);
} }
}; };
@ -217,7 +246,9 @@ const ViewDocument = (): ReactElement => {
const res = await workflowService.getInstance(targetId); const res = await workflowService.getInstance(targetId);
setWorkflowInstance(res.data); setWorkflowInstance(res.data);
} catch (err: any) { } catch (err: any) {
const msg = err?.response?.data?.error?.message || "Failed to load workflow tracker"; const msg =
err?.response?.data?.error?.message ||
"Failed to load workflow tracker";
showToast.error(msg); showToast.error(msg);
setShowWorkflowTracker(false); setShowWorkflowTracker(false);
} finally { } finally {
@ -232,6 +263,7 @@ const ViewDocument = (): ReactElement => {
setEffectiveDate(""); setEffectiveDate("");
setSignatureId(""); setSignatureId("");
setWorkflowOptions([]); setWorkflowOptions([]);
setActionErrors({});
}; };
const openActionModal = async (action: DocumentAction): Promise<void> => { const openActionModal = async (action: DocumentAction): Promise<void> => {
@ -265,28 +297,28 @@ const ViewDocument = (): ReactElement => {
const handleAction = async (action: DocumentAction): Promise<void> => { const handleAction = async (action: DocumentAction): Promise<void> => {
if (!id) return; if (!id) return;
setActionErrors({});
const localErrors: Record<string, string> = {};
if (action === "submit" && !workflowDefinitionId) { if (action === "submit" && !workflowDefinitionId) {
showToast.error("workflow_definition_id is required"); showToast.error("workflow_definition_id is required");
return; return;
} }
if (action === "reject" && !actionComment.trim()) { if (action === "reject" && !actionComment.trim()) {
showToast.error("Reason is required for reject"); localErrors.comment = "Reason is required for reject";
return;
} }
if (action === "effective" && !effectiveDate) { if (action === "effective" && !effectiveDate) {
showToast.error("Effective date is required"); localErrors.effective_date = "Effective date is required";
return;
}
if (action === "effective" && !signatureId.trim()) {
showToast.error("signature_id is required");
return;
} }
if (action === "obsolete" && !actionComment.trim()) { if (action === "obsolete" && !actionComment.trim()) {
showToast.error("Reason is required to obsolete"); localErrors.comment = "Reason is required to obsolete";
return;
} }
if (action === "checkout" && !actionComment.trim()) { if (action === "checkout" && !actionComment.trim()) {
showToast.error("Reason is required for checkout"); localErrors.comment = "Reason is required for checkout";
}
if (Object.keys(localErrors).length > 0) {
setActionErrors(localErrors);
return; return;
} }
@ -294,12 +326,17 @@ const ViewDocument = (): ReactElement => {
setIsActionLoading(true); setIsActionLoading(true);
if (action === "submit") if (action === "submit")
await documentService.submitForReview(id, workflowDefinitionId); await documentService.submitForReview(id, workflowDefinitionId);
if (action === "approve") await documentService.approve(id, actionComment); if (action === "approve")
await documentService.approve(id, actionComment);
if (action === "reject") { if (action === "reject") {
await documentService.reject(id, actionComment.trim()); await documentService.reject(id, actionComment.trim());
} }
if (action === "effective") { if (action === "effective") {
await documentService.makeEffective(id, effectiveDate, signatureId.trim()); await documentService.makeEffective(
id,
effectiveDate,
signatureId.trim(),
);
} }
if (action === "obsolete") { if (action === "obsolete") {
await documentService.makeObsolete(id, actionComment.trim()); await documentService.makeObsolete(id, actionComment.trim());
@ -376,7 +413,9 @@ const ViewDocument = (): ReactElement => {
const handleWorkflowTransition = async (): Promise<void> => { const handleWorkflowTransition = async (): Promise<void> => {
if (!workflowInstance || !selectedWorkflowAction) return; if (!workflowInstance || !selectedWorkflowAction) return;
const pendingTask = workflowInstance.tasks.find((t) => t.status === "pending"); const pendingTask = workflowInstance.tasks.find(
(t) => t.status === "pending",
);
if (!pendingTask) { if (!pendingTask) {
showToast.error("No pending task found for this workflow instance"); showToast.error("No pending task found for this workflow instance");
return; return;
@ -408,7 +447,8 @@ const ViewDocument = (): ReactElement => {
await refreshData(); await refreshData();
} catch (err: any) { } catch (err: any) {
showToast.error( showToast.error(
err?.response?.data?.error?.message || "Failed to complete workflow action", err?.response?.data?.error?.message ||
"Failed to complete workflow action",
); );
} finally { } finally {
setIsTransitioning(false); setIsTransitioning(false);
@ -440,8 +480,6 @@ const ViewDocument = (): ReactElement => {
}, },
]; ];
return ( return (
<Layout <Layout
currentPage="Document Service" currentPage="Document Service"
@ -452,24 +490,7 @@ const ViewDocument = (): ReactElement => {
pageHeader={{ pageHeader={{
title: document?.title || "View Document", title: document?.title || "View Document",
description: "View document metadata and version history.", description: "View document metadata and version history.",
tabs: [ action: (
{ 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="flex items-center gap-2"> <div className="flex items-center gap-2">
{document?.status === "draft" && ( {document?.status === "draft" && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -515,6 +536,7 @@ const ViewDocument = (): ReactElement => {
</> </>
)} )}
{document?.status === "approved" && ( {document?.status === "approved" && (
<div className="flex items-center gap-2">
<button <button
type="button" type="button"
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-[#084cc8] text-white text-xs font-medium hover:bg-[#063a99]" className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-[#084cc8] text-white text-xs font-medium hover:bg-[#063a99]"
@ -522,6 +544,14 @@ const ViewDocument = (): ReactElement => {
> >
{ACTION_LABELS["effective"]} {ACTION_LABELS["effective"]}
</button> </button>
<button
type="button"
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-red-600 text-white text-xs font-medium hover:bg-red-700"
onClick={() => void openActionModal("reject")}
>
{ACTION_LABELS["reject"]}
</button>
</div>
)} )}
{document?.status === "effective" && ( {document?.status === "effective" && (
<button <button
@ -533,6 +563,21 @@ const ViewDocument = (): ReactElement => {
</button> </button>
)} )}
</div> </div>
),
}}
>
<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-sm font-semibold text-[#0f1724]">
Document Properties
</h3>
<p className="text-xs text-[#6b7280] mt-1">
Details and classification for{" "}
{document?.document_number || "-"}
</p>
</div>
</div> </div>
<div className="flex gap-2 mt-3"> <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"> <span className="px-2 py-1 rounded-full bg-emerald-100 text-emerald-700 text-[11px] font-medium">
@ -588,7 +633,9 @@ const ViewDocument = (): ReactElement => {
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-3 text-sm"> <div className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-3 text-sm">
<div> <div>
<span className="text-[#9aa6b2]">Document Number:</span> <span className="text-[#9aa6b2]">Document Number:</span>
<p className="text-[#0f1724]">{document.document_number}</p> <p className="text-[#0f1724]">
{document.document_number}
</p>
</div> </div>
<div> <div>
<span className="text-[#9aa6b2]">Title:</span> <span className="text-[#9aa6b2]">Title:</span>
@ -596,19 +643,27 @@ const ViewDocument = (): ReactElement => {
</div> </div>
<div> <div>
<span className="text-[#9aa6b2]">Category:</span> <span className="text-[#9aa6b2]">Category:</span>
<p className="text-[#0f1724]">{document.category?.name || "-"}</p> <p className="text-[#0f1724]">
{document.category?.name || "-"}
</p>
</div> </div>
<div> <div>
<span className="text-[#9aa6b2]">Department:</span> <span className="text-[#9aa6b2]">Department:</span>
<p className="text-[#0f1724]">{document.department || "-"}</p> <p className="text-[#0f1724]">
{document.department || "-"}
</p>
</div> </div>
<div> <div>
<span className="text-[#9aa6b2]">Effective Date:</span> <span className="text-[#9aa6b2]">Effective Date:</span>
<p className="text-[#0f1724]">{formatDateTime(document.effective_date)}</p> <p className="text-[#0f1724]">
{formatDateTime(document.effective_date)}
</p>
</div> </div>
<div> <div>
<span className="text-[#9aa6b2]">Next Review Date:</span> <span className="text-[#9aa6b2]">Next Review Date:</span>
<p className="text-[#0f1724]">{formatDateTime(document.next_review_date)}</p> <p className="text-[#0f1724]">
{formatDateTime(document.next_review_date)}
</p>
</div> </div>
<div className="md:col-span-2"> <div className="md:col-span-2">
<span className="text-[#9aa6b2]">Tags:</span> <span className="text-[#9aa6b2]">Tags:</span>
@ -620,15 +675,21 @@ const ViewDocument = (): ReactElement => {
</div> </div>
</div> </div>
<div> <div>
<h4 className="text-sm font-semibold text-[#0f1724] mb-2">Document Content</h4> <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]"> <div className="rounded-md border border-[rgba(0,0,0,0.08)] p-3 text-sm text-[#0f1724] bg-[#f8fafc]">
{document.content_html ? ( {document.content_html ? (
<div <div
className="prose prose-sm max-w-none" className="prose prose-sm max-w-none"
dangerouslySetInnerHTML={{ __html: document.content_html }} dangerouslySetInnerHTML={{
__html: document.content_html,
}}
/> />
) : ( ) : (
<div className="whitespace-pre-wrap">{document.content || "-"}</div> <div className="whitespace-pre-wrap">
{document.content || "-"}
</div>
)} )}
</div> </div>
</div> </div>
@ -652,13 +713,17 @@ const ViewDocument = (): ReactElement => {
</div> </div>
{showNewVersionForm && ( {showNewVersionForm && (
<div className="border border-[rgba(0,0,0,0.08)] rounded-lg p-4 bg-[#f8fafc]"> <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> <h4 className="text-base font-semibold text-[#0f1724] mb-3">
Create New Version
</h4>
{/* File Attachment Selection */} {/* File Attachment Selection */}
<div className="mb-4 border border-[rgba(0,0,0,0.08)] rounded-lg p-3 bg-white"> <div className="mb-4 border border-[rgba(0,0,0,0.08)] rounded-lg p-3 bg-white">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<Paperclip className="w-4 h-4 text-[#112868]" /> <Paperclip className="w-4 h-4 text-[#112868]" />
<span className="text-sm font-medium text-[#0f1724]">Load Content From File (Optional)</span> <span className="text-sm font-medium text-[#0f1724]">
Load Content From File (Optional)
</span>
</div> </div>
<p className="text-xs text-[#6b7280] mb-2"> <p className="text-xs text-[#6b7280] mb-2">
Select a file to extract and auto-fill content below. Select a file to extract and auto-fill content below.
@ -666,7 +731,9 @@ const ViewDocument = (): ReactElement => {
<FormSelect <FormSelect
label="Select File" label="Select File"
value={versionSelectedFileId} value={versionSelectedFileId}
onValueChange={(val) => void handleVersionFileSelect(val)} onValueChange={(val) =>
void handleVersionFileSelect(val)
}
options={[ options={[
{ value: "", label: "— None —" }, { value: "", label: "— None —" },
...versionFiles.map((f) => ({ ...versionFiles.map((f) => ({
@ -674,14 +741,34 @@ const ViewDocument = (): ReactElement => {
label: `${f.original_name} (${f.file_size_formatted})`, label: `${f.original_name} (${f.file_size_formatted})`,
})), })),
]} ]}
placeholder={isLoadingVersionFiles ? "Loading files..." : "Select a file to attach"} placeholder={
isLoadingVersionFiles
? "Loading files..."
: "Select a file to attach"
}
/> />
{versionSelectedFileId && ( {versionSelectedFileId && (
<div className="mt-2 grid grid-cols-1 md:grid-cols-2 gap-x-3 gap-y-0 text-xs text-[#6b7280]"> <div className="mt-2 grid grid-cols-1 md:grid-cols-2 gap-x-3 gap-y-0 text-xs text-[#6b7280]">
<p><span className="font-medium">File:</span> {versionFileName}</p> <p>
<p><span className="font-medium">Type:</span> {versionMimeType}</p> <span className="font-medium">File:</span>{" "}
<p><span className="font-medium">Size:</span> {versionFileSize ? `${(versionFileSize / 1024 / 1024).toFixed(2)} MB` : "-"}</p> {versionFileName}
<p><span className="font-medium">Hash:</span> {versionFileHash ? versionFileHash.substring(0, 16) + "..." : "-"}</p> </p>
<p>
<span className="font-medium">Type:</span>{" "}
{versionMimeType}
</p>
<p>
<span className="font-medium">Size:</span>{" "}
{versionFileSize
? `${(versionFileSize / 1024 / 1024).toFixed(2)} MB`
: "-"}
</p>
<p>
<span className="font-medium">Hash:</span>{" "}
{versionFileHash
? versionFileHash.substring(0, 16) + "..."
: "-"}
</p>
</div> </div>
)} )}
</div> </div>
@ -690,11 +777,15 @@ const ViewDocument = (): ReactElement => {
label="Document Content" label="Document Content"
value={newVersionContentHtml} value={newVersionContentHtml}
required required
minHeightClassName="min-h-[200px]" minHeightClassName="h-[400px] overflow-y-auto"
onChange={(html, text) => { onChange={(html, text) => {
setNewVersionContentHtml(html); setNewVersionContentHtml(html);
setNewVersionContent(text); setNewVersionContent(text);
if (text.trim()) setVersionErrors(prev => ({ ...prev, content: "" })); if (text.trim())
setVersionErrors((prev) => ({
...prev,
content: "",
}));
}} }}
error={versionErrors.content} error={versionErrors.content}
/> />
@ -705,13 +796,20 @@ const ViewDocument = (): ReactElement => {
options={[ options={[
{ value: "minor_edit", label: "minor_edit" }, { value: "minor_edit", label: "minor_edit" },
{ value: "correction", label: "correction" }, { value: "correction", label: "correction" },
{ value: "regulatory_update", label: "regulatory_update" }, {
value: "regulatory_update",
label: "regulatory_update",
},
{ value: "major_rewrite", label: "major_rewrite" }, { value: "major_rewrite", label: "major_rewrite" },
]} ]}
value={newVersionChangeReason} value={newVersionChangeReason}
onValueChange={(val) => { onValueChange={(val) => {
setNewVersionChangeReason(val); setNewVersionChangeReason(val);
if (val) setVersionErrors(prev => ({ ...prev, change_reason: "" })); if (val)
setVersionErrors((prev) => ({
...prev,
change_reason: "",
}));
}} }}
placeholder="Select change reason" placeholder="Select change reason"
error={versionErrors.change_reason} error={versionErrors.change_reason}
@ -721,10 +819,15 @@ const ViewDocument = (): ReactElement => {
id="major-version" id="major-version"
type="checkbox" type="checkbox"
checked={isMajorVersion} checked={isMajorVersion}
onChange={(event) => setIsMajorVersion(event.target.checked)} onChange={(event) =>
setIsMajorVersion(event.target.checked)
}
className="w-4 h-4" className="w-4 h-4"
/> />
<label htmlFor="major-version" className="text-sm text-[#0f1724]"> <label
htmlFor="major-version"
className="text-sm text-[#0f1724]"
>
Major Version? Major Version?
</label> </label>
</div> </div>
@ -736,13 +839,17 @@ const ViewDocument = (): ReactElement => {
<textarea <textarea
rows={3} rows={3}
value={newVersionChangeSummary} value={newVersionChangeSummary}
onChange={(event) => setNewVersionChangeSummary(event.target.value)} onChange={(event) =>
setNewVersionChangeSummary(event.target.value)
}
placeholder="Provide a brief description of the changes..." 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" className="w-full px-3 py-2 border border-[rgba(0,0,0,0.08)] rounded-md text-sm"
/> />
</div> </div>
<div className="mt-4 flex justify-end gap-2"> <div className="mt-4 flex justify-end gap-2">
<SecondaryButton onClick={() => setShowNewVersionForm(false)}> <SecondaryButton
onClick={() => setShowNewVersionForm(false)}
>
Cancel Cancel
</SecondaryButton> </SecondaryButton>
<PrimaryButton <PrimaryButton
@ -771,42 +878,78 @@ const ViewDocument = (): ReactElement => {
<table className="min-w-full divide-y divide-gray-200"> <table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50"> <thead className="bg-gray-50">
<tr> <tr>
<th className="px-4 py-3 text-left text-[10px] font-bold text-gray-400 uppercase tracking-wider">Workflow</th> <th className="px-4 py-3 text-left text-[10px] font-bold text-gray-400 uppercase tracking-wider">
<th className="px-4 py-3 text-left text-[10px] font-bold text-gray-400 uppercase tracking-wider">Current Step</th> Date
<th className="px-4 py-3 text-left text-[10px] font-bold text-gray-400 uppercase tracking-wider">Status</th> </th>
<th className="px-4 py-3 text-left text-[10px] font-bold text-gray-400 uppercase tracking-wider">Started At</th> <th className="px-4 py-3 text-left text-[10px] font-bold text-gray-400 uppercase tracking-wider">
<th className="px-4 py-3 text-right text-[10px] font-bold text-gray-400 uppercase tracking-wider font-bold">Action</th> Event
</th>
<th className="px-4 py-3 text-left text-[10px] font-bold text-gray-400 uppercase tracking-wider">
Activity/Step
</th>
<th className="px-4 py-3 text-left text-[10px] font-bold text-gray-400 uppercase tracking-wider">
Performed By
</th>
<th className="px-4 py-3 text-left text-[10px] font-bold text-gray-400 uppercase tracking-wider">
Comments
</th>
</tr> </tr>
</thead> </thead>
<tbody className="bg-white divide-y divide-gray-100"> <tbody className="bg-white divide-y divide-gray-100">
{isHistoryLoading ? ( {isHistoryLoading ? (
<tr><td colSpan={5} className="px-4 py-4 text-center text-xs text-gray-500">Loading history...</td></tr> <tr>
) : workflowInstances.length === 0 ? ( <td
<tr><td colSpan={5} className="px-4 py-4 text-center text-xs text-gray-500">No workflow history found.</td></tr> colSpan={5}
) : workflowInstances.map((inst) => ( className="px-4 py-4 text-center text-xs text-gray-500"
<tr key={inst.id}>
<td className="px-4 py-3 whitespace-nowrap text-xs font-semibold text-gray-800">{inst.workflow_name}</td>
<td className="px-4 py-3 whitespace-nowrap text-xs text-gray-600">{inst.current_step || '-'}</td>
<td className="px-4 py-3 whitespace-nowrap">
<span className={`inline-flex items-center px-1.5 py-0.5 rounded text-[9px] font-bold uppercase ${
inst.status === 'completed' ? 'bg-emerald-100 text-emerald-700' :
inst.status === 'active' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-700'
}`}>
{inst.status}
</span>
</td>
<td className="px-4 py-3 whitespace-nowrap text-xs text-gray-500">{formatDateTime(inst.started_at)}</td>
<td className="px-4 py-3 whitespace-nowrap text-right">
<button
type="button"
className="text-xs font-bold text-[#112868] hover:underline"
onClick={() => void openWorkflowTracker(inst.id)}
> >
View Tracker Loading history...
</button>
</td> </td>
</tr> </tr>
))} ) : workflowHistory.length === 0 ? (
<tr>
<td
colSpan={5}
className="px-4 py-4 text-center text-xs text-gray-500"
>
No workflow history found.
</td>
</tr>
) : (
workflowHistory.map((item) => (
<tr key={item.id}>
<td className="px-4 py-3 whitespace-nowrap text-[11px] text-gray-500">
{formatDateTime(item.performed_at)}
</td>
<td className="px-4 py-3 whitespace-nowrap">
<span
className={`inline-flex items-center px-1.5 py-0.5 rounded text-[9px] font-bold uppercase ${
item.event_type === "workflow_started"
? "bg-blue-100 text-blue-700"
: item.event_type === "task_assigned"
? "bg-orange-100 text-orange-700"
: item.event_type === "task_completed"
? "bg-emerald-100 text-emerald-700"
: item.event_type ===
"workflow_completed"
? "bg-purple-100 text-purple-700"
: "bg-gray-100 text-gray-700"
}`}
>
{item.event_type.replace("_", " ")}
</span>
</td>
<td className="px-4 py-3 whitespace-nowrap text-xs font-semibold text-gray-800">
{item.to_step || item.action || "-"}
</td>
<td className="px-4 py-3 whitespace-nowrap text-xs text-gray-600">
{item.performed_by || "-"}
</td>
<td className="px-4 py-3 text-xs text-gray-500 italic max-w-xs truncate">
{item.comments || "-"}
</td>
</tr>
))
)}
</tbody> </tbody>
</table> </table>
</div> </div>
@ -833,7 +976,10 @@ const ViewDocument = (): ReactElement => {
description="Complete required details to continue." description="Complete required details to continue."
footer={ footer={
<> <>
<SecondaryButton onClick={resetActionModal} disabled={isActionLoading}> <SecondaryButton
onClick={resetActionModal}
disabled={isActionLoading}
>
Cancel Cancel
</SecondaryButton> </SecondaryButton>
<PrimaryButton <PrimaryButton
@ -871,10 +1017,17 @@ const ViewDocument = (): ReactElement => {
</div> </div>
</div> */} </div> */}
<div className="mt-2 p-2 bg-red-50 border border-red-100 rounded text-[10px] leading-relaxed text-[#e11d48]"> <div className="mt-2 p-2 bg-red-50 border border-red-100 rounded text-[10px] leading-relaxed text-[#e11d48]">
<strong>Note:</strong> Currently displaying active workflow definitions registered for <strong>Note:</strong> Currently displaying active workflow
<span className="font-semibold mx-0.5">Entity Type: Document</span> and definitions registered for
<span className="font-semibold mx-0.5">Module: {document?.module_name || "Platform"}</span>. <span className="font-semibold mx-0.5">
If the list is empty, please go to Workflow Management and create a definition for this specific configuration. Entity Type: Document
</span>{" "}
and
<span className="font-semibold mx-0.5">
Module: {document?.module_name || "Platform"}
</span>
. If the list is empty, please go to Workflow Management and
create a definition for this specific configuration.
</div> </div>
</div> </div>
)} )}
@ -897,33 +1050,63 @@ const ViewDocument = (): ReactElement => {
activeAction === "checkout") && ( activeAction === "checkout") && (
<div> <div>
<label className="text-[13px] font-medium text-[#0e1b2a] mb-2 block"> <label className="text-[13px] font-medium text-[#0e1b2a] mb-2 block">
Reason Reason <span className="text-red-500">*</span>
</label> </label>
<textarea <textarea
rows={4} rows={4}
value={actionComment} value={actionComment}
onChange={(event) => setActionComment(event.target.value)} onChange={(event) => {
setActionComment(event.target.value);
if (event.target.value.trim())
setActionErrors((prev) => ({ ...prev, comment: "" }));
}}
placeholder="Enter reason" placeholder="Enter reason"
className="w-full px-3 py-2 border border-[rgba(0,0,0,0.08)] rounded-md text-sm" className={`w-full px-3 py-2 border rounded-md text-sm transition-colors ${
actionErrors.comment
? "border-red-500 bg-red-50/30"
: "border-[rgba(0,0,0,0.08)]"
}`}
/> />
{actionErrors.comment && (
<p className="text-[11px] text-red-500 mt-1 font-medium italic">
{actionErrors.comment}
</p>
)}
</div> </div>
)} )}
{activeAction === "effective" && ( {activeAction === "effective" && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3"> <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div> <div>
<label className="text-[13px] font-medium text-[#0e1b2a] mb-2 block"> <label className="text-[13px] font-medium text-[#0e1b2a] mb-2 block">
Effective Date Effective Date <span className="text-red-500">*</span>
</label> </label>
<input <input
type="date" type="date"
value={effectiveDate} value={effectiveDate}
onChange={(event) => setEffectiveDate(event.target.value)} onChange={(event) => {
className="h-10 px-3 w-full border border-[rgba(0,0,0,0.08)] rounded-md text-sm" setEffectiveDate(event.target.value);
if (event.target.value)
setActionErrors((prev) => ({
...prev,
effective_date: "",
}));
}}
className={`h-10 px-3 w-full border rounded-md text-sm transition-colors ${
actionErrors.effective_date
? "border-red-500 bg-red-50/30"
: "border-[rgba(0,0,0,0.08)]"
}`}
/> />
{actionErrors.effective_date && (
<p className="text-[11px] text-red-500 mt-1 font-medium italic">
{actionErrors.effective_date}
</p>
)}
</div> </div>
<div> <div>
<label className="text-[13px] font-medium text-[#0e1b2a] mb-2 block"> <label className="text-[13px] font-medium text-[#0e1b2a] mb-2 block">
Signature ID Signature ID{" "}
<span className="text-gray-400 font-normal">(Optional)</span>
</label> </label>
<input <input
type="text" type="text"
@ -954,39 +1137,64 @@ const ViewDocument = (): ReactElement => {
{isWorkflowLoading ? ( {isWorkflowLoading ? (
<div className="flex flex-col items-center justify-center py-10 space-y-3"> <div className="flex flex-col items-center justify-center py-10 space-y-3">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[#112868]"></div> <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[#112868]"></div>
<p className="text-sm text-gray-500 font-medium">Loading workflow details...</p> <p className="text-sm text-gray-500 font-medium">
Loading workflow details...
</p>
</div> </div>
) : workflowInstance ? ( ) : workflowInstance ? (
<div className="space-y-6"> <div className="space-y-6">
{/* Workflow Header Info */} {/* Workflow Header Info */}
<div className="grid grid-cols-2 gap-4 p-4 bg-gray-50 rounded-lg border border-gray-100"> <div className="grid grid-cols-2 gap-4 p-4 bg-gray-50 rounded-lg border border-gray-100">
<div> <div>
<label className="text-[10px] uppercase tracking-wider font-bold text-gray-400 block">Workflow</label> <label className="text-[10px] uppercase tracking-wider font-bold text-gray-400 block">
<p className="text-sm font-semibold text-gray-800">{workflowInstance.workflow.name}</p> Workflow
</label>
<p className="text-sm font-semibold text-gray-800">
{workflowInstance.workflow.name}
</p>
</div> </div>
<div> <div>
<label className="text-[10px] uppercase tracking-wider font-bold text-gray-400 block">Current Status</label> <label className="text-[10px] uppercase tracking-wider font-bold text-gray-400 block">
<span className={`inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold uppercase ${ Current Status
workflowInstance.status === 'completed' ? 'bg-emerald-100 text-emerald-700' : </label>
workflowInstance.status === 'active' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-700' <span
}`}> className={`inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold uppercase ${
workflowInstance.status === "completed"
? "bg-emerald-100 text-emerald-700"
: workflowInstance.status === "active"
? "bg-blue-100 text-blue-700"
: "bg-gray-100 text-gray-700"
}`}
>
{workflowInstance.status} {workflowInstance.status}
</span> </span>
</div> </div>
<div> <div>
<label className="text-[10px] uppercase tracking-wider font-bold text-gray-400 block">Started By</label> <label className="text-[10px] uppercase tracking-wider font-bold text-gray-400 block">
<p className="text-sm font-medium text-gray-700">{workflowInstance.started_by.name} ({workflowInstance.started_by.email})</p> Started By
</label>
<p className="text-sm font-medium text-gray-700">
{workflowInstance.started_by.name} (
{workflowInstance.started_by.email})
</p>
</div> </div>
<div> <div>
<label className="text-[10px] uppercase tracking-wider font-bold text-gray-400 block">Started At</label> <label className="text-[10px] uppercase tracking-wider font-bold text-gray-400 block">
<p className="text-sm font-medium text-gray-700">{formatDateTime(workflowInstance.started_at)}</p> Started At
</label>
<p className="text-sm font-medium text-gray-700">
{formatDateTime(workflowInstance.started_at)}
</p>
</div> </div>
</div> </div>
{/* Available Actions Buttons */} {/* Available Actions Buttons */}
{workflowInstance.available_actions && workflowInstance.available_actions.length > 0 && ( {workflowInstance.available_actions &&
workflowInstance.available_actions.length > 0 && (
<div className="p-4 border border-blue-100 bg-blue-50/30 rounded-lg"> <div className="p-4 border border-blue-100 bg-blue-50/30 rounded-lg">
<label className="text-[10px] uppercase tracking-wider font-bold text-blue-600 block mb-3">Available Actions</label> <label className="text-[10px] uppercase tracking-wider font-bold text-blue-600 block mb-3">
Available Actions
</label>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{workflowInstance.available_actions.map((action, idx) => ( {workflowInstance.available_actions.map((action, idx) => (
<button <button
@ -1022,13 +1230,20 @@ const ViewDocument = (): ReactElement => {
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
<label className="text-[11px] font-bold text-amber-800 mb-1.5 block"> <label className="text-[11px] font-bold text-amber-800 mb-1.5 block">
Comments {selectedWorkflowAction.requires_comment ? "(Required)" : "(Optional)"} Comments{" "}
{selectedWorkflowAction.requires_comment
? "(Required)"
: "(Optional)"}
</label> </label>
<textarea <textarea
rows={3} rows={3}
value={transitionComment} value={transitionComment}
onChange={(e) => setTransitionComment(e.target.value)} onChange={(e) => setTransitionComment(e.target.value)}
placeholder={selectedWorkflowAction.requires_comment ? "Enter required comments..." : "Enter optional comments..."} placeholder={
selectedWorkflowAction.requires_comment
? "Enter required comments..."
: "Enter optional comments..."
}
className="w-full px-3 py-2 border border-amber-200 rounded-md text-xs focus:ring-1 focus:ring-amber-500 focus:outline-none" className="w-full px-3 py-2 border border-amber-200 rounded-md text-xs focus:ring-1 focus:ring-amber-500 focus:outline-none"
/> />
</div> </div>
@ -1051,18 +1266,50 @@ const ViewDocument = (): ReactElement => {
<table className="min-w-full divide-y divide-gray-200"> <table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50"> <thead className="bg-gray-50">
<tr> <tr>
<th scope="col" className="px-4 py-3 text-left text-[10px] font-bold text-gray-400 uppercase tracking-wider">Step</th> <th
<th scope="col" className="px-4 py-3 text-left text-[10px] font-bold text-gray-400 uppercase tracking-wider">Assignee</th> scope="col"
<th scope="col" className="px-4 py-3 text-left text-[10px] font-bold text-gray-400 uppercase tracking-wider">Status</th> className="px-4 py-3 text-left text-[10px] font-bold text-gray-400 uppercase tracking-wider"
<th scope="col" className="px-4 py-3 text-left text-[10px] font-bold text-gray-400 uppercase tracking-wider">Action</th> >
<th scope="col" className="px-4 py-3 text-left text-[10px] font-bold text-gray-400 uppercase tracking-wider">Completed At</th> Step
</th>
<th
scope="col"
className="px-4 py-3 text-left text-[10px] font-bold text-gray-400 uppercase tracking-wider"
>
Assignee
</th>
<th
scope="col"
className="px-4 py-3 text-left text-[10px] font-bold text-gray-400 uppercase tracking-wider"
>
Status
</th>
<th
scope="col"
className="px-4 py-3 text-left text-[10px] font-bold text-gray-400 uppercase tracking-wider"
>
Action
</th>
<th
scope="col"
className="px-4 py-3 text-left text-[10px] font-bold text-gray-400 uppercase tracking-wider"
>
Completed At
</th>
</tr> </tr>
</thead> </thead>
<tbody className="bg-white divide-y divide-gray-100"> <tbody className="bg-white divide-y divide-gray-100">
{workflowInstance.tasks.map((task) => ( {workflowInstance.tasks.map((task) => (
<tr key={task.id} className={task.status === 'pending' ? 'bg-blue-50/30' : ''}> <tr
key={task.id}
className={
task.status === "pending" ? "bg-blue-50/30" : ""
}
>
<td className="px-4 py-3 whitespace-nowrap"> <td className="px-4 py-3 whitespace-nowrap">
<span className={`text-xs font-bold ${task.status === 'pending' ? 'text-blue-700' : 'text-gray-800'}`}> <span
className={`text-xs font-bold ${task.status === "pending" ? "text-blue-700" : "text-gray-800"}`}
>
{task.step} {task.step}
</span> </span>
</td> </td>
@ -1073,10 +1320,15 @@ const ViewDocument = (): ReactElement => {
</div> </div>
</td> </td>
<td className="px-4 py-3 whitespace-nowrap"> <td className="px-4 py-3 whitespace-nowrap">
<span className={`inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold uppercase ${ <span
task.status === 'completed' ? 'bg-emerald-100 text-emerald-700' : className={`inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold uppercase ${
task.status === 'pending' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-700' task.status === "completed"
}`}> ? "bg-emerald-100 text-emerald-700"
: task.status === "pending"
? "bg-blue-100 text-blue-700"
: "bg-gray-100 text-gray-700"
}`}
>
{task.status} {task.status}
</span> </span>
</td> </td>
@ -1086,11 +1338,15 @@ const ViewDocument = (): ReactElement => {
{task.action_taken} {task.action_taken}
</span> </span>
) : ( ) : (
<span className="text-gray-400 text-[10px] italic">No action</span> <span className="text-gray-400 text-[10px] italic">
No action
</span>
)} )}
</td> </td>
<td className="px-4 py-3 whitespace-nowrap text-[11px] text-gray-500 font-medium"> <td className="px-4 py-3 whitespace-nowrap text-[11px] text-gray-500 font-medium">
{task.completed_at ? formatDateTime(task.completed_at) : '-'} {task.completed_at
? formatDateTime(task.completed_at)
: "-"}
</td> </td>
</tr> </tr>
))} ))}
@ -1100,16 +1356,20 @@ const ViewDocument = (): ReactElement => {
</div> </div>
) : ( ) : (
<div className="text-center py-10"> <div className="text-center py-10">
<p className="text-sm text-gray-500">No workflow tracking information available.</p> <p className="text-sm text-gray-500">
No workflow tracking information available.
</p>
</div> </div>
)} )}
<div className="mt-8 flex justify-end"> <div className="mt-8 flex justify-end">
<SecondaryButton onClick={() => { <SecondaryButton
onClick={() => {
setShowWorkflowTracker(false); setShowWorkflowTracker(false);
setSelectedWorkflowAction(null); setSelectedWorkflowAction(null);
setTransitionComment(""); setTransitionComment("");
}}> }}
>
Close Tracker Close Tracker
</SecondaryButton> </SecondaryButton>
</div> </div>
@ -1120,4 +1380,3 @@ const ViewDocument = (): ReactElement => {
}; };
export default ViewDocument; export default ViewDocument;

View File

@ -19,6 +19,7 @@ const CreateDocument = lazy(() => import("@/pages/tenant/CreateDocument"));
const EditDocument = lazy(() => import("@/pages/tenant/EditDocument")); const EditDocument = lazy(() => import("@/pages/tenant/EditDocument"));
const ViewDocument = lazy(() => import("@/pages/tenant/ViewDocument")); const ViewDocument = lazy(() => import("@/pages/tenant/ViewDocument"));
const DocumentCategories = lazy(() => import("@/pages/tenant/DocumentCategories")); const DocumentCategories = lazy(() => import("@/pages/tenant/DocumentCategories"));
const DocumentsDueForReview = lazy(() => import("@/pages/tenant/DocumentsDueForReview"));
const Tasks = lazy(() => import("@/pages/tenant/Tasks")); const Tasks = lazy(() => import("@/pages/tenant/Tasks"));
// Loading fallback component // Loading fallback component
@ -106,6 +107,10 @@ export const tenantAdminRoutes: RouteConfig[] = [
path: "/tenant/documents/categories", path: "/tenant/documents/categories",
element: <LazyRoute component={DocumentCategories} />, element: <LazyRoute component={DocumentCategories} />,
}, },
{
path: "/tenant/documents/due-for-review",
element: <LazyRoute component={DocumentsDueForReview} />,
},
{ {
path: "/tenant/tasks", path: "/tenant/tasks",
element: <LazyRoute component={Tasks} />, element: <LazyRoute component={Tasks} />,

View File

@ -3,6 +3,7 @@ import type {
DocumentCategory, DocumentCategory,
DocumentDetail, DocumentDetail,
DocumentListResponse, DocumentListResponse,
DocumentSummary,
DocumentResponse, DocumentResponse,
DocumentVersion, DocumentVersion,
} from "@/types/document"; } from "@/types/document";
@ -289,5 +290,13 @@ export const documentService = {
>(`/documents/${id}/checkin`); >(`/documents/${id}/checkin`);
return response.data; return response.data;
}, },
getDueForReview: async (daysAhead?: number): Promise<DocumentResponse<DocumentSummary[]>> => {
const response = await apiClient.get<DocumentResponse<DocumentSummary[]>>(
"/documents/due-for-review",
{ params: { days_ahead: daysAhead } }
);
return response.data;
},
}; };

View File

@ -107,6 +107,11 @@ class WorkflowService {
const response = await apiClient.post(`${this.baseUrl}/instances/${instanceId}/transition`, data); const response = await apiClient.post(`${this.baseUrl}/instances/${instanceId}/transition`, data);
return response.data; return response.data;
} }
async getInstanceHistory(id: string): Promise<{ success: boolean; data: any[] }> {
const response = await apiClient.get(`${this.baseUrl}/instances/${id}/history`);
return response.data;
}
} }
export const workflowService = new WorkflowService(); export const workflowService = new WorkflowService();