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

View File

@ -1,3 +1,4 @@
import { useEffect, useState } from "react";
import { Link, useLocation } from "react-router-dom";
import {
LayoutDashboard,
@ -12,6 +13,8 @@ import {
BadgeCheck,
GitBranch,
Briefcase,
ChevronDown,
ChevronRight,
} from "lucide-react";
import { cn } from "@/lib/utils";
@ -22,7 +25,16 @@ import { AuthenticatedImage } from "@/components/shared";
interface MenuItem {
icon: React.ComponentType<{ className?: string; strokeWidth?: number }>;
label: string;
path: string;
path?: string;
isGroup?: boolean;
children?: Array<{
label: string;
path: string;
requiredPermission?: {
resource: string;
action?: string;
};
}>;
requiredPermission?: {
resource: string;
action?: string; // If not provided, checks for '*' or 'read'
@ -95,8 +107,14 @@ const tenantAdminPlatformMenu: MenuItem[] = [
},
{
icon: FileText,
label: "Document Service",
path: "/tenant/documents",
label: "Document Services",
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" },
},
{ 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) => {
const location = useLocation();
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[] => {
if (isSuperAdmin) {
return items; // Show all items for super admin
return items;
}
return items.filter((item) => {
// If no required permission, always show (e.g., Dashboard, Modules, Settings)
if (!item.requiredPermission) {
return true;
}
return hasPermission(
const hasParentPermission = hasPermission(
item.requiredPermission.resource,
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 className="flex flex-col gap-1 mt-1">
{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 isTenantDashboardPath = item.path === "/tenant";
const isActive = isTenantDashboardPath
? location.pathname === "/tenant"
: location.pathname === item.path ||
location.pathname.startsWith(`${item.path}/`);
: item.path && (location.pathname === item.path ||
location.pathname.startsWith(`${item.path}/`));
return (
<Link
key={item.path}
to={item.path}
to={item.path || "#"}
onClick={() => {
// Close sidebar on mobile when navigating
if (window.innerWidth < 768) {
@ -281,7 +420,7 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
}
>
<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}
</span>
</Link>
@ -352,11 +491,14 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
</button>
</div>
{/* Platform Menu */}
<MenuSection title="Platform" items={platformMenu} />
{/* 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 */}
<MenuSection title="Platform" items={platformMenu} />
{/* System Menu */}
<MenuSection title="System" items={systemMenu} />
{/* System Menu */}
<MenuSection title="System" items={systemMenu} />
</div>
{/* Support Center */}
<div className="mt-auto">
@ -427,15 +569,18 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
</div>
</div>
{/* Platform Menu */}
{platformMenu.length > 0 && (
<MenuSection title="Platform" items={platformMenu} />
)}
{/* 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 */}
{platformMenu.length > 0 && (
<MenuSection title="Platform" items={platformMenu} />
)}
{/* System Menu */}
{systemMenu.length > 0 && (
<MenuSection title="System" items={systemMenu} />
)}
{/* System Menu */}
{systemMenu.length > 0 && (
<MenuSection title="System" items={systemMenu} />
)}
</div>
{/* Support Center */}
<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 type { ReactElement } from 'react';
import { MoreVertical, Eye, Edit, Trash2, Users, BarChart3 } from 'lucide-react';
import { cn } from '@/lib/utils';
export interface ActionItem {
label: string;
onClick: () => void | Promise<void>;
icon?: React.ReactNode;
variant?: 'danger' | 'default';
}
interface ActionDropdownProps {
onView?: () => void;
onEdit?: () => void;
onDelete?: () => void;
onContacts?: () => void;
onScorecards?: () => void;
actions?: ActionItem[];
trigger?: React.ReactNode;
className?: string;
}
@ -19,6 +28,8 @@ export const ActionDropdown = ({
onDelete,
onContacts,
onScorecards,
actions,
trigger,
className,
}: ActionDropdownProps): ReactElement => {
const [isOpen, setIsOpen] = useState<boolean>(false);
@ -56,14 +67,14 @@ export const ActionDropdown = ({
const rect = buttonRef.current.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom;
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
const shouldOpenUp = spaceBelow < dropdownHeight && spaceAbove > spaceBelow;
// Calculate dropdown position
const right = window.innerWidth - rect.right;
const width = 76; // Fixed width of dropdown
const width = actions ? 140 : 76; // Wider for custom actions
if (shouldOpenUp) {
// Position above the button
@ -88,90 +99,123 @@ export const ActionDropdown = ({
document.removeEventListener('mousedown', handleClickOutside);
window.removeEventListener('scroll', handleScroll, true);
};
}, [isOpen]);
}, [isOpen, actions]);
const handleAction = (action: () => void | undefined) => {
const handleAction = (action?: () => void | Promise<void>) => {
if (action) {
action();
void Promise.resolve(action()).catch(console.error);
}
setIsOpen(false);
};
return (
<div className={cn('relative', className)} ref={dropdownRef}>
<button
ref={buttonRef}
type="button"
onClick={() => setIsOpen(!isOpen)}
className={cn(
'flex items-center justify-center w-7 h-7 rounded-full border border-[rgba(0,0,0,0.08)] transition-colors cursor-pointer',
isOpen
? 'bg-[#084cc8] text-white'
: 'bg-white text-[#0f1724] hover:bg-[#084cc8] hover:text-white'
)}
aria-label="Actions"
aria-expanded={isOpen}
>
<MoreVertical className="w-3.5 h-3.5" />
</button>
{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
ref={buttonRef}
type="button"
onClick={() => setIsOpen(!isOpen)}
className={cn(
'flex items-center justify-center w-7 h-7 rounded-full border border-[rgba(0,0,0,0.08)] transition-colors cursor-pointer',
isOpen
? 'bg-[#084cc8] text-white'
: 'bg-white text-[#0f1724] hover:bg-[#084cc8] hover:text-white'
)}
aria-label="Actions"
aria-expanded={isOpen}
>
<MoreVertical className="w-3.5 h-3.5" />
</button>
)}
{isOpen && buttonRef.current && createPortal(
<div
ref={dropdownMenuRef}
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}
>
<div className="flex flex-col py-1.5">
{onView && (
<button
type="button"
onClick={() => handleAction(onView)}
className="flex items-center gap-2.5 px-2 py-1 text-[11px] font-medium text-[#6b7280] hover:bg-gray-50 transition-colors cursor-pointer"
>
<Eye className="w-3.5 h-3.5" />
<span>View</span>
</button>
)}
{onEdit && (
<button
type="button"
onClick={() => handleAction(onEdit)}
className="flex items-center gap-2.5 px-2 py-1 text-[11px] font-medium text-[#6b7280] hover:bg-gray-50 transition-colors cursor-pointer"
>
<Edit className="w-3 h-3" />
<span>Edit</span>
</button>
)}
{onDelete && (
<button
type="button"
onClick={() => handleAction(onDelete)}
className="flex items-center gap-2 px-2 py-1 text-[11px] font-medium text-[#6b7280] hover:bg-gray-50 transition-colors cursor-pointer"
>
<Trash2 className="w-3.5 h-3.5" />
<span>Delete</span>
</button>
)}
{onContacts && (
<button
type="button"
onClick={() => handleAction(onContacts)}
className="flex items-center gap-2.5 px-2 py-1 text-[11px] font-medium text-[#6b7280] hover:bg-gray-50 transition-colors cursor-pointer"
>
<Users className="w-3.5 h-3.5" />
<span>Contacts</span>
</button>
)}
{onScorecards && (
<button
type="button"
onClick={() => handleAction(onScorecards)}
className="flex items-center gap-2.5 px-2 py-1 text-[11px] font-medium text-[#6b7280] hover:bg-gray-50 transition-colors cursor-pointer"
>
<BarChart3 className="w-3.5 h-3.5" />
<span>Scorecards</span>
</button>
{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 && (
<button
type="button"
onClick={() => handleAction(onView)}
className="flex items-center gap-2.5 px-2 py-1 text-[11px] font-medium text-[#6b7280] hover:bg-gray-50 transition-colors cursor-pointer"
>
<Eye className="w-3.5 h-3.5" />
<span>View</span>
</button>
)}
{onEdit && (
<button
type="button"
onClick={() => handleAction(onEdit)}
className="flex items-center gap-2.5 px-2 py-1 text-[11px] font-medium text-[#6b7280] hover:bg-gray-50 transition-colors cursor-pointer"
>
<Edit className="w-3 h-3" />
<span>Edit</span>
</button>
)}
{onDelete && (
<button
type="button"
onClick={() => handleAction(onDelete)}
className="flex items-center gap-2 px-2 py-1 text-[11px] font-medium text-[#6b7280] hover:bg-gray-50 transition-colors cursor-pointer"
>
<Trash2 className="w-3.5 h-3.5" />
<span>Delete</span>
</button>
)}
{onContacts && (
<button
type="button"
onClick={() => handleAction(onContacts)}
className="flex items-center gap-2.5 px-2 py-1 text-[11px] font-medium text-[#6b7280] hover:bg-gray-50 transition-colors cursor-pointer"
>
<Users className="w-3.5 h-3.5" />
<span>Contacts</span>
</button>
)}
{onScorecards && (
<button
type="button"
onClick={() => handleAction(onScorecards)}
className="flex items-center gap-2.5 px-2 py-1 text-[11px] font-medium text-[#6b7280] hover:bg-gray-50 transition-colors cursor-pointer"
>
<BarChart3 className="w-3.5 h-3.5" />
<span>Scorecards</span>
</button>
)}
</>
)}
</div>
</div>,
@ -180,3 +224,4 @@ export const ActionDropdown = ({
</div>
);
};

View File

@ -37,6 +37,7 @@ export const FilterDropdown = ({
bottom?: string;
left: string;
width: string;
minWidth?: string;
}>({ left: '0', width: '0' });
// Handle click outside
@ -82,14 +83,16 @@ export const FilterDropdown = ({
setDropdownStyle({
bottom: `${bottom}px`,
left: `${left}px`,
width: `${width}px`,
minWidth: `${width}px`,
width: 'auto',
});
} else {
const top = rect.bottom;
setDropdownStyle({
top: `${top}px`,
left: `${left}px`,
width: `${width}px`,
minWidth: `${width}px`,
width: 'auto',
});
}
}
@ -120,13 +123,20 @@ export const FilterDropdown = ({
ref={buttonRef}
type="button"
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>}
<span>{label}</span>
<span className="text-[#94a3b8] text-sm">{displayText}</span>
{showIcon && icon && <span className="flex-shrink-0 text-[#94a3b8]">{icon}</span>}
<span className={cn("font-medium", value && "text-[#112868]")}>{label}</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
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>
@ -148,7 +158,7 @@ export const FilterDropdown = ({
setIsOpen(false);
}}
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'
)}
>
@ -169,7 +179,7 @@ export const FilterDropdown = ({
setIsOpen(false);
}}
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'
)}
>

View File

@ -13,6 +13,7 @@ interface PageHeaderProps {
title: string;
description?: string;
tabs?: TabItem[];
action?: React.ReactNode;
}
const defaultTabs: TabItem[] = [
@ -28,6 +29,7 @@ export const PageHeader = ({
title,
description,
tabs,
action,
}: PageHeaderProps): ReactElement => {
const location = useLocation();
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;
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 */}
<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]">
@ -71,28 +73,38 @@ export const PageHeader = ({
)}
</div>
{/* Tabs Navigation - Only show for super_admin */}
{resolvedTabs.length > 0 && (
<div className="border border-[rgba(0,0,0,0.2)] rounded-md p-1.5 flex gap-2 overflow-x-auto">
{resolvedTabs.map((tab) => {
const isActive = tab.path === activeTabPath;
return (
<Link
key={tab.path}
to={tab.path}
className={cn(
'flex items-center justify-center px-3 py-1.5 rounded text-xs font-medium whitespace-nowrap transition-colors min-w-[76px]',
isActive
? 'bg-[#112868] text-white'
: 'bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] text-[#0f1724] hover:bg-gray-100'
)}
>
{tab.label}
</Link>
);
})}
</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 */}
{resolvedTabs.length > 0 && (
<div className="border border-[rgba(0,0,0,0.2)] rounded-md p-1.5 flex gap-2 overflow-x-auto">
{resolvedTabs.map((tab) => {
const isActive = tab.path === activeTabPath;
return (
<Link
key={tab.path}
to={tab.path}
className={cn(
'flex items-center justify-center px-3 py-1.5 rounded text-xs font-medium whitespace-nowrap transition-colors min-w-[76px]',
isActive
? 'bg-[#112868] text-white'
: 'bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] text-[#0f1724] hover:bg-gray-100'
)}
>
{tab.label}
</Link>
);
})}
</div>
)}
</div>
</div>
);
};

View File

@ -4,22 +4,30 @@ 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 { FormField, FormSelect, FormTextArea, PrimaryButton, RichTextEditor } from "@/components/shared";
import { documentService, type FileAttachmentItem } from "@/services/document-service";
import {
FormField,
FormSelect,
FormTextArea,
PrimaryButton,
RichTextEditor,
} from "@/components/shared";
import {
documentService,
type FileAttachmentItem,
} from "@/services/document-service";
import type { DocumentCategory } from "@/types/document";
import { showToast } from "@/utils/toast";
import { ArrowLeft, FileText, Info, Paperclip } from "lucide-react";
import { ArrowLeft, FileText, Info, Paperclip, X } from "lucide-react";
import { moduleService } from "@/services/module-service";
import type { MyModule } from "@/types/module";
const documentSchema = z.object({
title: z.string().min(1, "Document title is required"),
document_number: z.string().optional(),
description: z.string().optional(),
document_type: z.string().min(1, "Document type is required"),
category_id: z.string().optional(),
department: z.string().optional(),
tags: z.string().optional(),
tags: z.array(z.string()),
selectedModuleId: z.string().min(1, "Source module is required"),
content: z.string().optional(),
contentHtml: z.string().min(1, "Document content is required"),
@ -45,12 +53,11 @@ const CreateDocument = (): ReactElement => {
resolver: zodResolver(documentSchema),
defaultValues: {
title: "",
document_number: "",
description: "",
document_type: "",
category_id: "",
department: "",
tags: "",
tags: [],
selectedModuleId: "",
content: "",
contentHtml: "",
@ -121,7 +128,9 @@ const CreateDocument = (): ReactElement => {
setFileHash(selected.checksum);
try {
showToast.success(`Extracting content from "${selected.original_name}"...`);
showToast.success(
`Extracting content from "${selected.original_name}"...`,
);
const res = await documentService.getFileContent(fileId);
if (res.success && res.data) {
setValue("contentHtml", res.data.html || "");
@ -131,11 +140,15 @@ const CreateDocument = (): ReactElement => {
showToast.error("Failed to extract file content");
}
} 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);
const html = `<p>Document sourced from file: <strong>${selected.original_name}</strong></p>`;
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({
title: data.title.trim(),
description: data.description?.trim() || undefined,
document_number: data.document_number?.trim() || undefined,
document_type: data.document_type,
category_id: data.category_id || undefined,
department: data.department?.trim() || undefined,
tags: data.tags
? data.tags
.split(",")
.map((tag) => tag.trim())
.filter(Boolean)
: [],
tags: data.tags || [],
content: data.content?.trim() || undefined,
content_html: data.contentHtml.trim() || undefined,
file_name: fileName || undefined,
@ -162,7 +169,8 @@ const CreateDocument = (): ReactElement => {
file_size: fileSize,
mime_type: mimeType || 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,
});
showToast.success("Document created successfully");
@ -187,11 +195,6 @@ const CreateDocument = (): ReactElement => {
title: "Create Document",
description:
"Fill in document details, classification and draft content before submitting for workflow.",
tabs: [
{ label: "Document List", path: "/tenant/documents" },
{ label: "Create Document", path: "/tenant/documents/create" },
{ label: "Category Management", path: "/tenant/documents/categories" },
],
}}
>
<form onSubmit={handleSubmit(onFormSubmit)} className="space-y-4">
@ -206,7 +209,8 @@ const CreateDocument = (): ReactElement => {
New Controlled Document
</h3>
<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>
</div>
</div>
@ -220,7 +224,7 @@ const CreateDocument = (): ReactElement => {
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-4">
<div className="grid grid-cols-1 gap-x-4">
<FormField
label="Document Title"
required
@ -228,12 +232,6 @@ const CreateDocument = (): ReactElement => {
error={errors.title?.message}
{...register("title")}
/>
<FormField
label="Document Number"
placeholder="Auto-generated if empty"
error={errors.document_number?.message}
{...register("document_number")}
/>
</div>
<FormTextArea
@ -258,7 +256,10 @@ const CreateDocument = (): ReactElement => {
required
value={field.value}
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"
error={errors.document_type?.message}
/>
@ -283,16 +284,62 @@ const CreateDocument = (): ReactElement => {
/>
<FormField
label="Department"
placeholder="Optional"
placeholder="Optional department name"
error={errors.department?.message}
{...register("department")}
/>
<FormField
label="Tags"
placeholder="Comma separated tags (e.g. quality, sop)"
error={errors.tags?.message}
{...register("tags")}
/>
<div className="flex flex-col gap-2 pb-1">
<label className="text-[13px] font-medium text-[#0e1b2a]">
Tags (Press enter to add)
</label>
<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
name="selectedModuleId"
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="flex items-center gap-2 mb-3">
<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>
<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>
<FormSelect
label="Select File"
@ -334,22 +384,36 @@ const CreateDocument = (): ReactElement => {
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 && (
<div className="mt-3 grid grid-cols-1 md:grid-cols-2 gap-x-3 gap-y-0 text-xs text-[#6b7280]">
<p><span className="font-medium">File:</span> {fileName}</p>
<p><span className="font-medium">Type:</span> {mimeType}</p>
<p><span className="font-medium">Size:</span> {fileSize ? `${(fileSize / 1024 / 1024).toFixed(2)} MB` : "-"}</p>
<p><span className="font-medium">Hash:</span> {fileHash ? fileHash.substring(0, 16) + "..." : "-"}</p>
<p>
<span className="font-medium">File:</span> {fileName}
</p>
<p>
<span className="font-medium">Type:</span> {mimeType}
</p>
<p>
<span className="font-medium">Size:</span>{" "}
{fileSize ? `${(fileSize / 1024 / 1024).toFixed(2)} MB` : "-"}
</p>
<p>
<span className="font-medium">Hash:</span>{" "}
{fileHash ? fileHash.substring(0, 16) + "..." : "-"}
</p>
</div>
)}
</div>
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] p-4 md:p-5">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-[#0f1724]">Initial Content</h3>
<h3 className="text-sm font-semibold text-[#0f1724]">
Initial Content
</h3>
<span className="text-[11px] text-[#94a3b8]">
{watch("content")?.length || 0} characters
</span>
@ -363,7 +427,7 @@ const CreateDocument = (): ReactElement => {
value={field.value}
required
placeholder="Write the initial document content..."
minHeightClassName="min-h-[280px]"
minHeightClassName="h-[400px] overflow-y-auto"
onChange={(html, text) => {
field.onChange(html);
setValue("content", text);

View File

@ -1,19 +1,70 @@
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 { 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 type { DocumentCategory } from "@/types/document";
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 navigate = useNavigate();
// const navigate = useNavigate();
const [categories, setCategories] = useState<DocumentCategory[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [name, setName] = useState("");
const [code, setCode] = useState("");
const [description, setDescription] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [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> => {
try {
@ -33,77 +84,145 @@ const DocumentCategories = (): ReactElement => {
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(
() => [
{ key: "name", label: "Name" },
{ key: "code", label: "Code" },
{
key: "description",
label: "Description",
render: (category) => category.description || "-",
key: "name",
label: "Name",
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",
label: "Review (months)",
label: "Review Frequency",
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",
label: "Retention (years)",
render: (category) => category.retention_years?.toString() || "-",
label: "Retention",
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",
label: "Actions",
align: "right",
render: (category) => (
<button
type="button"
className="text-[#ef4444] hover:underline"
onClick={async () => {
try {
await documentService.deleteCategory(category.id);
showToast.success("Category deleted");
await loadCategories();
} catch (err: any) {
showToast.error(
err?.response?.data?.error?.message ||
"Failed to delete category",
);
}
}}
>
Delete
</button>
<ActionDropdown
actions={[
{ label: "View Details", onClick: () => handleView(category), icon: <Eye className="w-4 h-4" /> },
{ label: "Edit Category", onClick: () => handleEdit(category), icon: <Edit className="w-4 h-4" /> },
{ label: "Delete", onClick: () => handleDeleteClick(category), icon: <Trash2 className="w-4 h-4" />, variant: "danger" },
]}
/>
),
},
],
[],
[categories],
);
const onCreateCategory = async (event: React.FormEvent): Promise<void> => {
event.preventDefault();
if (!name.trim() || !code.trim()) {
showToast.error("Name and code are required");
return;
}
const onFormSubmit = async (data: CategoryFormData): Promise<void> => {
try {
setIsSubmitting(true);
await documentService.createCategory({
name: name.trim(),
code: code.trim().toUpperCase(),
description: description.trim() || undefined,
});
showToast.success("Category created");
setName("");
setCode("");
setDescription("");
const payload = {
name: data.name.trim(),
code: data.code.trim().toUpperCase(),
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");
}
setIsModalOpen(false);
reset();
setEditingCategory(null);
await loadCategories();
} catch (err: any) {
showToast.error(
err?.response?.data?.error?.message || "Failed to create category",
err?.response?.data?.error?.message || "Failed to process category",
);
} finally {
setIsSubmitting(false);
@ -113,60 +232,18 @@ const DocumentCategories = (): ReactElement => {
return (
<Layout
currentPage="Document Service"
breadcrumbs={[
{ label: "Document Service", path: "/tenant/documents" },
{ label: "Category Management" },
]}
pageHeader={{
title: "Category Management",
description: "Create and maintain document categories.",
tabs: [
{ label: "Document List", path: "/tenant/documents" },
{ label: "Create Document", path: "/tenant/documents/create" },
{ label: "Category Management", path: "/tenant/documents/categories" },
],
title: "Document Categories",
description: "View and manage the document categories and their retention policies.",
action: (
<PrimaryButton onClick={() => { setEditingCategory(null); reset(); setIsModalOpen(true); }}>
<Plus className="w-4 h-4 mr-1.5" />
Create Category
</PrimaryButton>
),
}}
>
<div className="space-y-4">
<form
onSubmit={onCreateCategory}
className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] p-5"
>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<FormField
label="Category Name"
required
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. SOP"
/>
<FormField
label="Code"
required
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="e.g. SOP"
/>
<FormField
label="Description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Optional"
/>
</div>
<div className="flex gap-2">
<PrimaryButton type="submit" disabled={isSubmitting}>
{isSubmitting ? "Saving..." : "Add Category"}
</PrimaryButton>
<button
type="button"
className="h-10 px-4 border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0f1724] hover:bg-gray-50"
onClick={() => navigate("/tenant/documents")}
>
Back to Documents
</button>
</div>
</form>
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] overflow-hidden">
<DataTable
@ -178,9 +255,227 @@ const DocumentCategories = (): ReactElement => {
/>
</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>
);
};
export default DocumentCategories;

View File

@ -10,7 +10,7 @@ import {
} from "@/components/shared";
import { documentService } from "@/services/document-service";
import type { DocumentCategory, DocumentSummary } from "@/types/document";
import { Plus } from "lucide-react";
import { Plus, Search } from "lucide-react";
const formatDate = (value?: string | null): string => {
if (!value) return "-";
@ -183,81 +183,132 @@ const Documents = (): ReactElement => {
title: "Document List",
description:
"Manage controlled documents, track versions and open document details.",
tabs: [
{ label: "Document List", path: "/tenant/documents" },
{ label: "Create Document", path: "/tenant/documents/create" },
{ label: "Category Management", path: "/tenant/documents/categories" },
],
action: (
<div className="flex gap-2">
{/* <button
type="button"
className="h-10 px-3 border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0f1724] hover:bg-gray-50 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="border-b border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-3 flex flex-col gap-3">
<div className="flex flex-col md:flex-row gap-2 md:items-center md:justify-between">
<div className="flex flex-wrap items-center gap-2">
<FilterDropdown
label="Status"
options={statuses.map((status) => ({
value: status.code,
label: status.name,
}))}
value={statusFilter}
onChange={(value) => {
setStatusFilter(value as string | null);
setCurrentPage(1);
}}
placeholder="All"
/>
<FilterDropdown
label="Category"
options={categories.map((category) => ({
value: category.id,
label: category.name,
}))}
value={categoryFilter}
onChange={(value) => {
setCategoryFilter(value as string | null);
setCurrentPage(1);
}}
placeholder="All"
/>
<FilterDropdown
label="Type"
options={types.map((type) => ({
value: type.code,
label: type.name,
}))}
value={typeFilter}
onChange={(value) => {
setTypeFilter(value as string | null);
setCurrentPage(1);
}}
placeholder="All"
/>
<div 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 items-center justify-between gap-4">
{/* 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
label="Status"
options={statuses.map((status) => ({
value: status.code,
label: status.name,
}))}
value={statusFilter}
onChange={(value) => {
setStatusFilter(value as string | null);
setCurrentPage(1);
}}
placeholder="All"
/>
<FilterDropdown
label="Category"
options={categories.map((category) => ({
value: category.id,
label: category.name,
}))}
value={categoryFilter}
onChange={(value) => {
setCategoryFilter(value as string | null);
setCurrentPage(1);
}}
placeholder="All"
/>
<FilterDropdown
label="Type"
options={types.map((type) => ({
value: type.code,
label: type.name,
}))}
value={typeFilter}
onChange={(value) => {
setTypeFilter(value as string | null);
setCurrentPage(1);
}}
placeholder="All"
/>
{/* <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">
{/* Right side: Clear Filters */}
<div className="flex items-center gap-4">
<button
type="button"
className="h-10 px-3 border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0f1724] hover:bg-gray-50"
onClick={() => navigate("/tenant/documents/categories")}
onClick={() => {
setSearch("");
setStatusFilter(null);
setCategoryFilter(null);
setTypeFilter(null);
setCurrentPage(1);
}}
className="text-sm font-medium text-[#6b7280] hover:text-[#0f1724] transition-colors"
>
Manage Categories
Clear filters
</button>
<PrimaryButton onClick={() => navigate("/tenant/documents/create")}>
<Plus className="w-3.5 h-3.5 mr-1" />
New Document
</PrimaryButton>
</div>
</div>
<input
type="text"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setCurrentPage(1);
}}
placeholder="Search by title, description or document number"
className="h-10 w-full max-w-xl px-3 border border-[rgba(0,0,0,0.08)] rounded-md text-sm"
/>
</div>
<DataTable

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;

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -3,6 +3,7 @@ import type {
DocumentCategory,
DocumentDetail,
DocumentListResponse,
DocumentSummary,
DocumentResponse,
DocumentVersion,
} from "@/types/document";
@ -289,5 +290,13 @@ export const documentService = {
>(`/documents/${id}/checkin`);
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);
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();