feat: implement collapsible sidebar menu groups for document services and add document review page
This commit is contained in:
parent
14bb57a574
commit
dfe6d74993
@ -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}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -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'
|
||||
)}
|
||||
>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
168
src/pages/tenant/DocumentsDueForReview.tsx
Normal file
168
src/pages/tenant/DocumentsDueForReview.tsx
Normal 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
@ -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} />,
|
||||
|
||||
@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user