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;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
tabs?: TabItem[];
|
tabs?: TabItem[];
|
||||||
|
action?: React.ReactNode;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -67,6 +68,7 @@ export const Layout = ({
|
|||||||
title={pageHeader.title}
|
title={pageHeader.title}
|
||||||
description={pageHeader.description}
|
description={pageHeader.description}
|
||||||
tabs={pageHeader.tabs}
|
tabs={pageHeader.tabs}
|
||||||
|
action={pageHeader.action}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
@ -12,6 +13,8 @@ import {
|
|||||||
BadgeCheck,
|
BadgeCheck,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
Briefcase,
|
Briefcase,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@ -22,7 +25,16 @@ import { AuthenticatedImage } from "@/components/shared";
|
|||||||
interface MenuItem {
|
interface MenuItem {
|
||||||
icon: React.ComponentType<{ className?: string; strokeWidth?: number }>;
|
icon: React.ComponentType<{ className?: string; strokeWidth?: number }>;
|
||||||
label: string;
|
label: string;
|
||||||
path: string;
|
path?: string;
|
||||||
|
isGroup?: boolean;
|
||||||
|
children?: Array<{
|
||||||
|
label: string;
|
||||||
|
path: string;
|
||||||
|
requiredPermission?: {
|
||||||
|
resource: string;
|
||||||
|
action?: string;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
requiredPermission?: {
|
requiredPermission?: {
|
||||||
resource: string;
|
resource: string;
|
||||||
action?: string; // If not provided, checks for '*' or 'read'
|
action?: string; // If not provided, checks for '*' or 'read'
|
||||||
@ -95,8 +107,14 @@ const tenantAdminPlatformMenu: MenuItem[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: FileText,
|
icon: FileText,
|
||||||
label: "Document Service",
|
label: "Document Services",
|
||||||
path: "/tenant/documents",
|
isGroup: true,
|
||||||
|
children: [
|
||||||
|
{ label: "Document Lists", path: "/tenant/documents", requiredPermission: { resource: "document" } },
|
||||||
|
{ label: "Create Document", path: "/tenant/documents/create", requiredPermission: { resource: "document", action: "create" } },
|
||||||
|
{ label: "Categories", path: "/tenant/documents/categories", requiredPermission: { resource: "document" } },
|
||||||
|
{ label: "Due for Review", path: "/tenant/documents/due-for-review", requiredPermission: { resource: "document" } },
|
||||||
|
],
|
||||||
requiredPermission: { resource: "document" },
|
requiredPermission: { resource: "document" },
|
||||||
},
|
},
|
||||||
{ icon: Package, label: "Modules", path: "/tenant/modules" },
|
{ icon: Package, label: "Modules", path: "/tenant/modules" },
|
||||||
@ -117,6 +135,92 @@ const tenantAdminSystemMenu: MenuItem[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const GroupMenuItem = ({
|
||||||
|
item,
|
||||||
|
childrenItems,
|
||||||
|
location,
|
||||||
|
isSuperAdmin,
|
||||||
|
theme,
|
||||||
|
onClose
|
||||||
|
}: {
|
||||||
|
item: MenuItem;
|
||||||
|
childrenItems: any[];
|
||||||
|
location: any;
|
||||||
|
isSuperAdmin: boolean;
|
||||||
|
theme: any;
|
||||||
|
onClose: () => void;
|
||||||
|
}) => {
|
||||||
|
const isChildActive = (path: string) => {
|
||||||
|
// Special handling for Document Lists to NOT show as active when sub-actions are active
|
||||||
|
if (path === "/tenant/documents") {
|
||||||
|
const subActions = ["/create", "/categories", "/due-for-review", "/edit"];
|
||||||
|
const isSubActionActive = subActions.some(sub => location.pathname.startsWith(path + sub));
|
||||||
|
if (isSubActionActive) return false;
|
||||||
|
}
|
||||||
|
return location.pathname === path || location.pathname.startsWith(`${path}/`);
|
||||||
|
};
|
||||||
|
const isAnyChildActive = childrenItems.some(child => isChildActive(child.path));
|
||||||
|
const [isExpanded, setIsExpanded] = useState(isAnyChildActive);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAnyChildActive) setIsExpanded(true);
|
||||||
|
}, [isAnyChildActive]);
|
||||||
|
|
||||||
|
const Icon = item.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-between gap-2.5 px-3 py-2 rounded-md transition-all min-h-[44px]",
|
||||||
|
isAnyChildActive ? "shadow-[0px_2px_8px_0px_rgba(15,23,42,0.15)]" : "text-[#0f1724] hover:bg-gray-50"
|
||||||
|
)}
|
||||||
|
style={isAnyChildActive ? {
|
||||||
|
backgroundColor: !isSuperAdmin && theme?.primary_color ? theme.primary_color : "#112868",
|
||||||
|
color: !isSuperAdmin && theme?.secondary_color ? theme.secondary_color : "#23dce1"
|
||||||
|
} : undefined}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<Icon className="w-4 h-4 shrink-0" />
|
||||||
|
<span className="text-xs md:text-xs lg:text-[13px] font-medium truncate" title={item.label}>
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{isExpanded ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="flex flex-col mt-1 mb-1 border-l-2 border-[rgba(0,0,0,0.08)] ml-5 py-1 gap-0.5">
|
||||||
|
{childrenItems.map((child) => {
|
||||||
|
const isActive = isChildActive(child.path);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={child.path}
|
||||||
|
to={child.path}
|
||||||
|
onClick={() => {
|
||||||
|
if (window.innerWidth < 768) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center px-4 py-2 rounded-r-md text-[13px] font-medium transition-all",
|
||||||
|
isActive
|
||||||
|
? "text-[#112868] font-bold bg-gray-50"
|
||||||
|
: "text-[#475569] hover:text-[#0f1724] hover:bg-gray-50"
|
||||||
|
)}
|
||||||
|
style={isActive ? { color: !isSuperAdmin && theme?.primary_color ? theme.primary_color : "#112868" } : undefined}
|
||||||
|
>
|
||||||
|
<span className="truncate" title={child.label}>{child.label}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { roles, permissions } = useAppSelector((state) => state.auth);
|
const { roles, permissions } = useAppSelector((state) => state.auth);
|
||||||
@ -200,22 +304,42 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Filter menu items based on permissions for tenant users
|
|
||||||
const filterMenuItems = (items: MenuItem[]): MenuItem[] => {
|
const filterMenuItems = (items: MenuItem[]): MenuItem[] => {
|
||||||
if (isSuperAdmin) {
|
if (isSuperAdmin) {
|
||||||
return items; // Show all items for super admin
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
return items.filter((item) => {
|
return items.filter((item) => {
|
||||||
// If no required permission, always show (e.g., Dashboard, Modules, Settings)
|
|
||||||
if (!item.requiredPermission) {
|
if (!item.requiredPermission) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return hasPermission(
|
const hasParentPermission = hasPermission(
|
||||||
item.requiredPermission.resource,
|
item.requiredPermission.resource,
|
||||||
item.requiredPermission.action,
|
item.requiredPermission.action,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!hasParentPermission) return false;
|
||||||
|
|
||||||
|
if (item.isGroup && item.children) {
|
||||||
|
// Deep copy children to avoid mutating original menu arrays
|
||||||
|
const filteredChildren = item.children.filter((child) => {
|
||||||
|
if (!child.requiredPermission) return true;
|
||||||
|
return hasPermission(
|
||||||
|
child.requiredPermission.resource,
|
||||||
|
child.requiredPermission.action,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// We need to return a new object to avoid issues
|
||||||
|
if (filteredChildren.length > 0) {
|
||||||
|
(item as any)._filteredChildren = filteredChildren;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -243,16 +367,31 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1 mt-1">
|
<div className="flex flex-col gap-1 mt-1">
|
||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
|
if (item.isGroup) {
|
||||||
|
const children = (item as any)._filteredChildren || item.children || [];
|
||||||
|
return (
|
||||||
|
<GroupMenuItem
|
||||||
|
key={item.label}
|
||||||
|
item={item}
|
||||||
|
childrenItems={children}
|
||||||
|
location={location}
|
||||||
|
isSuperAdmin={isSuperAdmin}
|
||||||
|
theme={theme}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
const isTenantDashboardPath = item.path === "/tenant";
|
const isTenantDashboardPath = item.path === "/tenant";
|
||||||
const isActive = isTenantDashboardPath
|
const isActive = isTenantDashboardPath
|
||||||
? location.pathname === "/tenant"
|
? location.pathname === "/tenant"
|
||||||
: location.pathname === item.path ||
|
: item.path && (location.pathname === item.path ||
|
||||||
location.pathname.startsWith(`${item.path}/`);
|
location.pathname.startsWith(`${item.path}/`));
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={item.path}
|
key={item.path}
|
||||||
to={item.path}
|
to={item.path || "#"}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// Close sidebar on mobile when navigating
|
// Close sidebar on mobile when navigating
|
||||||
if (window.innerWidth < 768) {
|
if (window.innerWidth < 768) {
|
||||||
@ -281,7 +420,7 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Icon className="w-4 h-4 shrink-0" />
|
<Icon className="w-4 h-4 shrink-0" />
|
||||||
<span className="text-xs md:text-xs lg:text-[13px] font-medium whitespace-nowrap">
|
<span className="text-xs md:text-xs lg:text-[13px] font-medium truncate" title={item.label}>
|
||||||
{item.label}
|
{item.label}
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
@ -352,11 +491,14 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Platform Menu */}
|
{/* Menu Sections (Only this part should scroll) */}
|
||||||
<MenuSection title="Platform" items={platformMenu} />
|
<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 */}
|
{/* System Menu */}
|
||||||
<MenuSection title="System" items={systemMenu} />
|
<MenuSection title="System" items={systemMenu} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Support Center */}
|
{/* Support Center */}
|
||||||
<div className="mt-auto">
|
<div className="mt-auto">
|
||||||
@ -427,15 +569,18 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Platform Menu */}
|
{/* Menu Sections (Only this part should scroll) */}
|
||||||
{platformMenu.length > 0 && (
|
<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">
|
||||||
<MenuSection title="Platform" items={platformMenu} />
|
{/* Platform Menu */}
|
||||||
)}
|
{platformMenu.length > 0 && (
|
||||||
|
<MenuSection title="Platform" items={platformMenu} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* System Menu */}
|
{/* System Menu */}
|
||||||
{systemMenu.length > 0 && (
|
{systemMenu.length > 0 && (
|
||||||
<MenuSection title="System" items={systemMenu} />
|
<MenuSection title="System" items={systemMenu} />
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Support Center */}
|
{/* Support Center */}
|
||||||
<div className="mt-auto w-full">
|
<div className="mt-auto w-full">
|
||||||
|
|||||||
@ -1,15 +1,24 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
import { MoreVertical, Eye, Edit, Trash2, Users, BarChart3 } from 'lucide-react';
|
import { MoreVertical, Eye, Edit, Trash2, Users, BarChart3 } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface ActionItem {
|
||||||
|
label: string;
|
||||||
|
onClick: () => void | Promise<void>;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
variant?: 'danger' | 'default';
|
||||||
|
}
|
||||||
|
|
||||||
interface ActionDropdownProps {
|
interface ActionDropdownProps {
|
||||||
onView?: () => void;
|
onView?: () => void;
|
||||||
onEdit?: () => void;
|
onEdit?: () => void;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
onContacts?: () => void;
|
onContacts?: () => void;
|
||||||
onScorecards?: () => void;
|
onScorecards?: () => void;
|
||||||
|
actions?: ActionItem[];
|
||||||
|
trigger?: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -19,6 +28,8 @@ export const ActionDropdown = ({
|
|||||||
onDelete,
|
onDelete,
|
||||||
onContacts,
|
onContacts,
|
||||||
onScorecards,
|
onScorecards,
|
||||||
|
actions,
|
||||||
|
trigger,
|
||||||
className,
|
className,
|
||||||
}: ActionDropdownProps): ReactElement => {
|
}: ActionDropdownProps): ReactElement => {
|
||||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||||
@ -56,14 +67,14 @@ export const ActionDropdown = ({
|
|||||||
const rect = buttonRef.current.getBoundingClientRect();
|
const rect = buttonRef.current.getBoundingClientRect();
|
||||||
const spaceBelow = window.innerHeight - rect.bottom;
|
const spaceBelow = window.innerHeight - rect.bottom;
|
||||||
const spaceAbove = rect.top;
|
const spaceAbove = rect.top;
|
||||||
const dropdownHeight = 120; // Approximate height of dropdown menu
|
const dropdownHeight = actions ? actions.length * 32 + 16 : 120; // Approximate height based on actions
|
||||||
|
|
||||||
// Determine if should open upward or downward
|
// Determine if should open upward or downward
|
||||||
const shouldOpenUp = spaceBelow < dropdownHeight && spaceAbove > spaceBelow;
|
const shouldOpenUp = spaceBelow < dropdownHeight && spaceAbove > spaceBelow;
|
||||||
|
|
||||||
// Calculate dropdown position
|
// Calculate dropdown position
|
||||||
const right = window.innerWidth - rect.right;
|
const right = window.innerWidth - rect.right;
|
||||||
const width = 76; // Fixed width of dropdown
|
const width = actions ? 140 : 76; // Wider for custom actions
|
||||||
|
|
||||||
if (shouldOpenUp) {
|
if (shouldOpenUp) {
|
||||||
// Position above the button
|
// Position above the button
|
||||||
@ -88,90 +99,123 @@ export const ActionDropdown = ({
|
|||||||
document.removeEventListener('mousedown', handleClickOutside);
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
window.removeEventListener('scroll', handleScroll, true);
|
window.removeEventListener('scroll', handleScroll, true);
|
||||||
};
|
};
|
||||||
}, [isOpen]);
|
}, [isOpen, actions]);
|
||||||
|
|
||||||
const handleAction = (action: () => void | undefined) => {
|
const handleAction = (action?: () => void | Promise<void>) => {
|
||||||
if (action) {
|
if (action) {
|
||||||
action();
|
void Promise.resolve(action()).catch(console.error);
|
||||||
}
|
}
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('relative', className)} ref={dropdownRef}>
|
<div className={cn('relative', className)} ref={dropdownRef}>
|
||||||
<button
|
{trigger ? (
|
||||||
ref={buttonRef}
|
React.cloneElement(trigger as React.ReactElement<any>, {
|
||||||
type="button"
|
ref: buttonRef,
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick: (e: React.MouseEvent) => {
|
||||||
className={cn(
|
e.stopPropagation();
|
||||||
'flex items-center justify-center w-7 h-7 rounded-full border border-[rgba(0,0,0,0.08)] transition-colors cursor-pointer',
|
setIsOpen(!isOpen);
|
||||||
isOpen
|
const triggerProps = (trigger as React.ReactElement<any>).props;
|
||||||
? 'bg-[#084cc8] text-white'
|
if (triggerProps.onClick) triggerProps.onClick(e);
|
||||||
: 'bg-white text-[#0f1724] hover:bg-[#084cc8] hover:text-white'
|
},
|
||||||
)}
|
})
|
||||||
aria-label="Actions"
|
) : (
|
||||||
aria-expanded={isOpen}
|
<button
|
||||||
>
|
ref={buttonRef}
|
||||||
<MoreVertical className="w-3.5 h-3.5" />
|
type="button"
|
||||||
</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(
|
{isOpen && buttonRef.current && createPortal(
|
||||||
<div
|
<div
|
||||||
ref={dropdownMenuRef}
|
ref={dropdownMenuRef}
|
||||||
data-dropdown-menu="true"
|
data-dropdown-menu="true"
|
||||||
className="fixed bg-white border border-[rgba(0,0,0,0.2)] rounded-md shadow-[0px_4px_4px_0px_rgba(0,0,0,0.08)] z-[250]"
|
className="fixed bg-white border border-[rgba(0,0,0,0.2)] rounded-md shadow-[0px_4px_4px_0px_rgba(0,0,0,0.08)] z-[250] overflow-hidden"
|
||||||
style={dropdownStyle}
|
style={dropdownStyle}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col py-1.5">
|
<div className="flex flex-col py-1.5">
|
||||||
{onView && (
|
{actions ? (
|
||||||
<button
|
actions.map((action, index) => (
|
||||||
type="button"
|
<button
|
||||||
onClick={() => handleAction(onView)}
|
key={index}
|
||||||
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"
|
type="button"
|
||||||
>
|
onClick={() => handleAction(action.onClick)}
|
||||||
<Eye className="w-3.5 h-3.5" />
|
className={cn(
|
||||||
<span>View</span>
|
"flex items-center gap-2.5 px-3 py-2 text-[11px] font-medium transition-colors cursor-pointer w-full text-left",
|
||||||
</button>
|
action.variant === 'danger'
|
||||||
)}
|
? "text-red-600 hover:bg-red-50"
|
||||||
{onEdit && (
|
: "text-[#6b7280] hover:bg-gray-50"
|
||||||
<button
|
)}
|
||||||
type="button"
|
>
|
||||||
onClick={() => handleAction(onEdit)}
|
{action.icon}
|
||||||
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"
|
<span>{action.label}</span>
|
||||||
>
|
</button>
|
||||||
<Edit className="w-3 h-3" />
|
))
|
||||||
<span>Edit</span>
|
) : (
|
||||||
</button>
|
<>
|
||||||
)}
|
{onView && (
|
||||||
{onDelete && (
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
onClick={() => handleAction(onView)}
|
||||||
onClick={() => handleAction(onDelete)}
|
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"
|
||||||
className="flex items-center gap-2 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" />
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
<span>View</span>
|
||||||
<span>Delete</span>
|
</button>
|
||||||
</button>
|
)}
|
||||||
)}
|
{onEdit && (
|
||||||
{onContacts && (
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
onClick={() => handleAction(onEdit)}
|
||||||
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"
|
||||||
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" />
|
||||||
<Users className="w-3.5 h-3.5" />
|
<span>Edit</span>
|
||||||
<span>Contacts</span>
|
</button>
|
||||||
</button>
|
)}
|
||||||
)}
|
{onDelete && (
|
||||||
{onScorecards && (
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
onClick={() => handleAction(onDelete)}
|
||||||
onClick={() => handleAction(onScorecards)}
|
className="flex items-center gap-2 px-2 py-1 text-[11px] font-medium text-[#6b7280] hover:bg-gray-50 transition-colors cursor-pointer"
|
||||||
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"
|
>
|
||||||
>
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
<BarChart3 className="w-3.5 h-3.5" />
|
<span>Delete</span>
|
||||||
<span>Scorecards</span>
|
</button>
|
||||||
</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>
|
||||||
</div>,
|
</div>,
|
||||||
@ -180,3 +224,4 @@ export const ActionDropdown = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -37,6 +37,7 @@ export const FilterDropdown = ({
|
|||||||
bottom?: string;
|
bottom?: string;
|
||||||
left: string;
|
left: string;
|
||||||
width: string;
|
width: string;
|
||||||
|
minWidth?: string;
|
||||||
}>({ left: '0', width: '0' });
|
}>({ left: '0', width: '0' });
|
||||||
|
|
||||||
// Handle click outside
|
// Handle click outside
|
||||||
@ -82,14 +83,16 @@ export const FilterDropdown = ({
|
|||||||
setDropdownStyle({
|
setDropdownStyle({
|
||||||
bottom: `${bottom}px`,
|
bottom: `${bottom}px`,
|
||||||
left: `${left}px`,
|
left: `${left}px`,
|
||||||
width: `${width}px`,
|
minWidth: `${width}px`,
|
||||||
|
width: 'auto',
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const top = rect.bottom;
|
const top = rect.bottom;
|
||||||
setDropdownStyle({
|
setDropdownStyle({
|
||||||
top: `${top}px`,
|
top: `${top}px`,
|
||||||
left: `${left}px`,
|
left: `${left}px`,
|
||||||
width: `${width}px`,
|
minWidth: `${width}px`,
|
||||||
|
width: 'auto',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -120,13 +123,20 @@ export const FilterDropdown = ({
|
|||||||
ref={buttonRef}
|
ref={buttonRef}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
className="flex items-center gap-2 px-3 py-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-sm text-[#475569] hover:bg-gray-50 transition-colors min-h-[44px]"
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-3 py-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-sm transition-all min-h-[40px] hover:bg-gray-50",
|
||||||
|
value ? "border-[#112868]/20 bg-[#112868]/5" : "text-[#475569]"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{showIcon && icon && <span className="flex-shrink-0">{icon}</span>}
|
{showIcon && icon && <span className="flex-shrink-0 text-[#94a3b8]">{icon}</span>}
|
||||||
<span>{label}</span>
|
<span className={cn("font-medium", value && "text-[#112868]")}>{label}</span>
|
||||||
<span className="text-[#94a3b8] text-sm">{displayText}</span>
|
{value && (
|
||||||
|
<span className="text-[#112868] font-bold text-xs bg-white px-1.5 py-0.5 rounded border border-[#112868]/10 ml-0.5">
|
||||||
|
{displayText}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
className={cn('w-3.5 h-3.5 text-[#94a3b8] transition-transform', isOpen && 'rotate-180')}
|
className={cn('w-3.5 h-3.5 text-[#94a3b8] transition-transform ml-0.5', isOpen && 'rotate-180')}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@ -148,7 +158,7 @@ export const FilterDropdown = ({
|
|||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full px-3 py-2 text-left text-sm text-[#0e1b2a] hover:bg-gray-50 transition-colors',
|
'w-full px-3 py-2 text-left text-sm text-[#0e1b2a] hover:bg-gray-50 transition-colors whitespace-nowrap',
|
||||||
!value && 'bg-gray-50'
|
!value && 'bg-gray-50'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@ -169,7 +179,7 @@ export const FilterDropdown = ({
|
|||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full px-3 py-2 text-left text-sm text-[#0e1b2a] hover:bg-gray-50 transition-colors',
|
'w-full px-3 py-2 text-left text-sm text-[#0e1b2a] hover:bg-gray-50 transition-colors whitespace-nowrap',
|
||||||
isSelected && 'bg-gray-50'
|
isSelected && 'bg-gray-50'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -13,6 +13,7 @@ interface PageHeaderProps {
|
|||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
tabs?: TabItem[];
|
tabs?: TabItem[];
|
||||||
|
action?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultTabs: TabItem[] = [
|
const defaultTabs: TabItem[] = [
|
||||||
@ -28,6 +29,7 @@ export const PageHeader = ({
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
tabs,
|
tabs,
|
||||||
|
action,
|
||||||
}: PageHeaderProps): ReactElement => {
|
}: PageHeaderProps): ReactElement => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { roles } = useAppSelector((state) => state.auth);
|
const { roles } = useAppSelector((state) => state.auth);
|
||||||
@ -58,7 +60,7 @@ export const PageHeader = ({
|
|||||||
.sort((a, b) => b.path.length - a.path.length)[0]?.path ?? null;
|
.sort((a, b) => b.path.length - a.path.length)[0]?.path ?? null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-4 md:gap-6 mb-6">
|
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 md:gap-6 mb-6">
|
||||||
{/* Title and Description */}
|
{/* Title and Description */}
|
||||||
<div className="flex flex-col gap-1 max-w-full md:max-w-[434px]">
|
<div className="flex flex-col gap-1 max-w-full md:max-w-[434px]">
|
||||||
<h1 className="text-xl md:text-2xl font-bold text-[#0f1724] tracking-[-0.48px]">
|
<h1 className="text-xl md:text-2xl font-bold text-[#0f1724] tracking-[-0.48px]">
|
||||||
@ -71,28 +73,38 @@ export const PageHeader = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs Navigation - Only show for super_admin */}
|
{/* Navigation Area: Tabs and Actions */}
|
||||||
{resolvedTabs.length > 0 && (
|
<div className="flex items-center gap-3">
|
||||||
<div className="border border-[rgba(0,0,0,0.2)] rounded-md p-1.5 flex gap-2 overflow-x-auto">
|
{/* Action Button */}
|
||||||
{resolvedTabs.map((tab) => {
|
{action && (
|
||||||
const isActive = tab.path === activeTabPath;
|
<div className="flex shrink-0">
|
||||||
return (
|
{action}
|
||||||
<Link
|
</div>
|
||||||
key={tab.path}
|
)}
|
||||||
to={tab.path}
|
|
||||||
className={cn(
|
{/* Tabs Navigation - Only show for super_admin */}
|
||||||
'flex items-center justify-center px-3 py-1.5 rounded text-xs font-medium whitespace-nowrap transition-colors min-w-[76px]',
|
{resolvedTabs.length > 0 && (
|
||||||
isActive
|
<div className="border border-[rgba(0,0,0,0.2)] rounded-md p-1.5 flex gap-2 overflow-x-auto">
|
||||||
? 'bg-[#112868] text-white'
|
{resolvedTabs.map((tab) => {
|
||||||
: 'bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] text-[#0f1724] hover:bg-gray-100'
|
const isActive = tab.path === activeTabPath;
|
||||||
)}
|
return (
|
||||||
>
|
<Link
|
||||||
{tab.label}
|
key={tab.path}
|
||||||
</Link>
|
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]',
|
||||||
</div>
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -4,22 +4,30 @@ import { useForm, Controller } from "react-hook-form";
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import * as z from "zod";
|
import * as z from "zod";
|
||||||
import { Layout } from "@/components/layout/Layout";
|
import { Layout } from "@/components/layout/Layout";
|
||||||
import { FormField, FormSelect, FormTextArea, PrimaryButton, RichTextEditor } from "@/components/shared";
|
import {
|
||||||
import { documentService, type FileAttachmentItem } from "@/services/document-service";
|
FormField,
|
||||||
|
FormSelect,
|
||||||
|
FormTextArea,
|
||||||
|
PrimaryButton,
|
||||||
|
RichTextEditor,
|
||||||
|
} from "@/components/shared";
|
||||||
|
import {
|
||||||
|
documentService,
|
||||||
|
type FileAttachmentItem,
|
||||||
|
} from "@/services/document-service";
|
||||||
import type { DocumentCategory } from "@/types/document";
|
import type { DocumentCategory } from "@/types/document";
|
||||||
import { showToast } from "@/utils/toast";
|
import { showToast } from "@/utils/toast";
|
||||||
import { ArrowLeft, FileText, Info, Paperclip } from "lucide-react";
|
import { ArrowLeft, FileText, Info, Paperclip, X } from "lucide-react";
|
||||||
import { moduleService } from "@/services/module-service";
|
import { moduleService } from "@/services/module-service";
|
||||||
import type { MyModule } from "@/types/module";
|
import type { MyModule } from "@/types/module";
|
||||||
|
|
||||||
const documentSchema = z.object({
|
const documentSchema = z.object({
|
||||||
title: z.string().min(1, "Document title is required"),
|
title: z.string().min(1, "Document title is required"),
|
||||||
document_number: z.string().optional(),
|
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
document_type: z.string().min(1, "Document type is required"),
|
document_type: z.string().min(1, "Document type is required"),
|
||||||
category_id: z.string().optional(),
|
category_id: z.string().optional(),
|
||||||
department: z.string().optional(),
|
department: z.string().optional(),
|
||||||
tags: z.string().optional(),
|
tags: z.array(z.string()),
|
||||||
selectedModuleId: z.string().min(1, "Source module is required"),
|
selectedModuleId: z.string().min(1, "Source module is required"),
|
||||||
content: z.string().optional(),
|
content: z.string().optional(),
|
||||||
contentHtml: z.string().min(1, "Document content is required"),
|
contentHtml: z.string().min(1, "Document content is required"),
|
||||||
@ -45,12 +53,11 @@ const CreateDocument = (): ReactElement => {
|
|||||||
resolver: zodResolver(documentSchema),
|
resolver: zodResolver(documentSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
title: "",
|
title: "",
|
||||||
document_number: "",
|
|
||||||
description: "",
|
description: "",
|
||||||
document_type: "",
|
document_type: "",
|
||||||
category_id: "",
|
category_id: "",
|
||||||
department: "",
|
department: "",
|
||||||
tags: "",
|
tags: [],
|
||||||
selectedModuleId: "",
|
selectedModuleId: "",
|
||||||
content: "",
|
content: "",
|
||||||
contentHtml: "",
|
contentHtml: "",
|
||||||
@ -121,7 +128,9 @@ const CreateDocument = (): ReactElement => {
|
|||||||
setFileHash(selected.checksum);
|
setFileHash(selected.checksum);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
showToast.success(`Extracting content from "${selected.original_name}"...`);
|
showToast.success(
|
||||||
|
`Extracting content from "${selected.original_name}"...`,
|
||||||
|
);
|
||||||
const res = await documentService.getFileContent(fileId);
|
const res = await documentService.getFileContent(fileId);
|
||||||
if (res.success && res.data) {
|
if (res.success && res.data) {
|
||||||
setValue("contentHtml", res.data.html || "");
|
setValue("contentHtml", res.data.html || "");
|
||||||
@ -131,11 +140,15 @@ const CreateDocument = (): ReactElement => {
|
|||||||
showToast.error("Failed to extract file content");
|
showToast.error("Failed to extract file content");
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const msg = err?.response?.data?.error?.message || "Failed to extract file content";
|
const msg =
|
||||||
|
err?.response?.data?.error?.message || "Failed to extract file content";
|
||||||
showToast.error(msg);
|
showToast.error(msg);
|
||||||
const html = `<p>Document sourced from file: <strong>${selected.original_name}</strong></p>`;
|
const html = `<p>Document sourced from file: <strong>${selected.original_name}</strong></p>`;
|
||||||
setValue("contentHtml", html);
|
setValue("contentHtml", html);
|
||||||
setValue("content", `Document sourced from file: ${selected.original_name}`);
|
setValue(
|
||||||
|
"content",
|
||||||
|
`Document sourced from file: ${selected.original_name}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -145,16 +158,10 @@ const CreateDocument = (): ReactElement => {
|
|||||||
const response = await documentService.create({
|
const response = await documentService.create({
|
||||||
title: data.title.trim(),
|
title: data.title.trim(),
|
||||||
description: data.description?.trim() || undefined,
|
description: data.description?.trim() || undefined,
|
||||||
document_number: data.document_number?.trim() || undefined,
|
|
||||||
document_type: data.document_type,
|
document_type: data.document_type,
|
||||||
category_id: data.category_id || undefined,
|
category_id: data.category_id || undefined,
|
||||||
department: data.department?.trim() || undefined,
|
department: data.department?.trim() || undefined,
|
||||||
tags: data.tags
|
tags: data.tags || [],
|
||||||
? data.tags
|
|
||||||
.split(",")
|
|
||||||
.map((tag) => tag.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
: [],
|
|
||||||
content: data.content?.trim() || undefined,
|
content: data.content?.trim() || undefined,
|
||||||
content_html: data.contentHtml.trim() || undefined,
|
content_html: data.contentHtml.trim() || undefined,
|
||||||
file_name: fileName || undefined,
|
file_name: fileName || undefined,
|
||||||
@ -162,7 +169,8 @@ const CreateDocument = (): ReactElement => {
|
|||||||
file_size: fileSize,
|
file_size: fileSize,
|
||||||
mime_type: mimeType || undefined,
|
mime_type: mimeType || undefined,
|
||||||
file_hash: fileHash || undefined,
|
file_hash: fileHash || undefined,
|
||||||
source_module: modules.find((m) => m.id === data.selectedModuleId)!.module_id,
|
source_module: modules.find((m) => m.id === data.selectedModuleId)!
|
||||||
|
.module_id,
|
||||||
source_module_id: data.selectedModuleId,
|
source_module_id: data.selectedModuleId,
|
||||||
});
|
});
|
||||||
showToast.success("Document created successfully");
|
showToast.success("Document created successfully");
|
||||||
@ -187,11 +195,6 @@ const CreateDocument = (): ReactElement => {
|
|||||||
title: "Create Document",
|
title: "Create Document",
|
||||||
description:
|
description:
|
||||||
"Fill in document details, classification and draft content before submitting for workflow.",
|
"Fill in document details, classification and draft content before submitting for workflow.",
|
||||||
tabs: [
|
|
||||||
{ label: "Document List", path: "/tenant/documents" },
|
|
||||||
{ label: "Create Document", path: "/tenant/documents/create" },
|
|
||||||
{ label: "Category Management", path: "/tenant/documents/categories" },
|
|
||||||
],
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<form onSubmit={handleSubmit(onFormSubmit)} className="space-y-4">
|
<form onSubmit={handleSubmit(onFormSubmit)} className="space-y-4">
|
||||||
@ -206,7 +209,8 @@ const CreateDocument = (): ReactElement => {
|
|||||||
New Controlled Document
|
New Controlled Document
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-[#6b7280] mt-1">
|
<p className="text-xs text-[#6b7280] mt-1">
|
||||||
Document will be created in <span className="font-medium">Draft</span> status.
|
Document will be created in{" "}
|
||||||
|
<span className="font-medium">Draft</span> status.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -220,7 +224,7 @@ const CreateDocument = (): ReactElement => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-4">
|
<div className="grid grid-cols-1 gap-x-4">
|
||||||
<FormField
|
<FormField
|
||||||
label="Document Title"
|
label="Document Title"
|
||||||
required
|
required
|
||||||
@ -228,12 +232,6 @@ const CreateDocument = (): ReactElement => {
|
|||||||
error={errors.title?.message}
|
error={errors.title?.message}
|
||||||
{...register("title")}
|
{...register("title")}
|
||||||
/>
|
/>
|
||||||
<FormField
|
|
||||||
label="Document Number"
|
|
||||||
placeholder="Auto-generated if empty"
|
|
||||||
error={errors.document_number?.message}
|
|
||||||
{...register("document_number")}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FormTextArea
|
<FormTextArea
|
||||||
@ -258,7 +256,10 @@ const CreateDocument = (): ReactElement => {
|
|||||||
required
|
required
|
||||||
value={field.value}
|
value={field.value}
|
||||||
onValueChange={field.onChange}
|
onValueChange={field.onChange}
|
||||||
options={types.map((type) => ({ value: type.code, label: type.name }))}
|
options={types.map((type) => ({
|
||||||
|
value: type.code,
|
||||||
|
label: type.name,
|
||||||
|
}))}
|
||||||
placeholder="Select type"
|
placeholder="Select type"
|
||||||
error={errors.document_type?.message}
|
error={errors.document_type?.message}
|
||||||
/>
|
/>
|
||||||
@ -283,16 +284,62 @@ const CreateDocument = (): ReactElement => {
|
|||||||
/>
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
label="Department"
|
label="Department"
|
||||||
placeholder="Optional"
|
placeholder="Optional department name"
|
||||||
error={errors.department?.message}
|
error={errors.department?.message}
|
||||||
{...register("department")}
|
{...register("department")}
|
||||||
/>
|
/>
|
||||||
<FormField
|
<div className="flex flex-col gap-2 pb-1">
|
||||||
label="Tags"
|
<label className="text-[13px] font-medium text-[#0e1b2a]">
|
||||||
placeholder="Comma separated tags (e.g. quality, sop)"
|
Tags (Press enter to add)
|
||||||
error={errors.tags?.message}
|
</label>
|
||||||
{...register("tags")}
|
<Controller
|
||||||
/>
|
name="tags"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<div className="flex flex-wrap gap-2 p-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md min-h-[40px] focus-within:ring-1 focus-within:ring-[#112868]/20 focus-within:border-[#112868]/20 transition-all">
|
||||||
|
{(field.value || []).map((tag, tagIdx) => (
|
||||||
|
<span
|
||||||
|
key={tagIdx}
|
||||||
|
className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-[#112868]/5 text-[#112868] rounded text-xs font-semibold border border-[#112868]/10"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
field.onChange(
|
||||||
|
(field.value || []).filter(
|
||||||
|
(_, i) => i !== tagIdx,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className="hover:text-[#e02424] transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="flex-1 outline-none text-sm min-w-[150px] bg-transparent"
|
||||||
|
placeholder="Type and press enter..."
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
const val = e.currentTarget.value.trim();
|
||||||
|
if (val && !(field.value || []).includes(val)) {
|
||||||
|
field.onChange([...(field.value || []), val]);
|
||||||
|
e.currentTarget.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errors.tags && (
|
||||||
|
<p className="text-xs text-[#ef4444]">{errors.tags.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<Controller
|
<Controller
|
||||||
name="selectedModuleId"
|
name="selectedModuleId"
|
||||||
control={control}
|
control={control}
|
||||||
@ -318,10 +365,13 @@ const CreateDocument = (): ReactElement => {
|
|||||||
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] p-4 md:p-5">
|
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] p-4 md:p-5">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<Paperclip className="w-4 h-4 text-[#112868]" />
|
<Paperclip className="w-4 h-4 text-[#112868]" />
|
||||||
<h3 className="text-sm font-semibold text-[#0f1724]">Attach File (Optional)</h3>
|
<h3 className="text-sm font-semibold text-[#0f1724]">
|
||||||
|
Attach File (Optional)
|
||||||
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-[#6b7280] mb-3">
|
<p className="text-xs text-[#6b7280] mb-3">
|
||||||
Select a previously uploaded file to automatically populate content and file metadata.
|
Select a previously uploaded file to automatically populate content
|
||||||
|
and file metadata.
|
||||||
</p>
|
</p>
|
||||||
<FormSelect
|
<FormSelect
|
||||||
label="Select File"
|
label="Select File"
|
||||||
@ -334,22 +384,36 @@ const CreateDocument = (): ReactElement => {
|
|||||||
label: `${f.original_name} (${f.file_size_formatted})`,
|
label: `${f.original_name} (${f.file_size_formatted})`,
|
||||||
})),
|
})),
|
||||||
]}
|
]}
|
||||||
placeholder={isLoadingFiles ? "Loading files..." : "Select a file to attach"}
|
placeholder={
|
||||||
|
isLoadingFiles ? "Loading files..." : "Select a file to attach"
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{selectedFileId && (
|
{selectedFileId && (
|
||||||
<div className="mt-3 grid grid-cols-1 md:grid-cols-2 gap-x-3 gap-y-0 text-xs text-[#6b7280]">
|
<div className="mt-3 grid grid-cols-1 md:grid-cols-2 gap-x-3 gap-y-0 text-xs text-[#6b7280]">
|
||||||
<p><span className="font-medium">File:</span> {fileName}</p>
|
<p>
|
||||||
<p><span className="font-medium">Type:</span> {mimeType}</p>
|
<span className="font-medium">File:</span> {fileName}
|
||||||
<p><span className="font-medium">Size:</span> {fileSize ? `${(fileSize / 1024 / 1024).toFixed(2)} MB` : "-"}</p>
|
</p>
|
||||||
<p><span className="font-medium">Hash:</span> {fileHash ? fileHash.substring(0, 16) + "..." : "-"}</p>
|
<p>
|
||||||
|
<span className="font-medium">Type:</span> {mimeType}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className="font-medium">Size:</span>{" "}
|
||||||
|
{fileSize ? `${(fileSize / 1024 / 1024).toFixed(2)} MB` : "-"}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className="font-medium">Hash:</span>{" "}
|
||||||
|
{fileHash ? fileHash.substring(0, 16) + "..." : "-"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] p-4 md:p-5">
|
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] p-4 md:p-5">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h3 className="text-sm font-semibold text-[#0f1724]">Initial Content</h3>
|
<h3 className="text-sm font-semibold text-[#0f1724]">
|
||||||
|
Initial Content
|
||||||
|
</h3>
|
||||||
<span className="text-[11px] text-[#94a3b8]">
|
<span className="text-[11px] text-[#94a3b8]">
|
||||||
{watch("content")?.length || 0} characters
|
{watch("content")?.length || 0} characters
|
||||||
</span>
|
</span>
|
||||||
@ -363,7 +427,7 @@ const CreateDocument = (): ReactElement => {
|
|||||||
value={field.value}
|
value={field.value}
|
||||||
required
|
required
|
||||||
placeholder="Write the initial document content..."
|
placeholder="Write the initial document content..."
|
||||||
minHeightClassName="min-h-[280px]"
|
minHeightClassName="h-[400px] overflow-y-auto"
|
||||||
onChange={(html, text) => {
|
onChange={(html, text) => {
|
||||||
field.onChange(html);
|
field.onChange(html);
|
||||||
setValue("content", text);
|
setValue("content", text);
|
||||||
|
|||||||
@ -1,19 +1,70 @@
|
|||||||
import { useEffect, useMemo, useState, type ReactElement } from "react";
|
import { useEffect, useMemo, useState, type ReactElement } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
// import { useNavigate } from "react-router-dom";
|
||||||
|
import { useForm, Controller } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import * as z from "zod";
|
||||||
import { Layout } from "@/components/layout/Layout";
|
import { Layout } from "@/components/layout/Layout";
|
||||||
import { DataTable, FormField, PrimaryButton, type Column } from "@/components/shared";
|
import {
|
||||||
|
DataTable,
|
||||||
|
FormField,
|
||||||
|
FormSelect,
|
||||||
|
FormTextArea,
|
||||||
|
PrimaryButton,
|
||||||
|
Modal,
|
||||||
|
ActionDropdown,
|
||||||
|
DeleteConfirmationModal,
|
||||||
|
type Column
|
||||||
|
} from "@/components/shared";
|
||||||
import { documentService } from "@/services/document-service";
|
import { documentService } from "@/services/document-service";
|
||||||
import type { DocumentCategory } from "@/types/document";
|
import type { DocumentCategory } from "@/types/document";
|
||||||
import { showToast } from "@/utils/toast";
|
import { showToast } from "@/utils/toast";
|
||||||
|
import { Plus, Eye, Edit, Trash2 } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const categorySchema = z.object({
|
||||||
|
name: z.string().min(1, "Category name is required"),
|
||||||
|
code: z.string().min(1, "Code is required").max(10, "Code must be 10 characters or less"),
|
||||||
|
description: z.string().optional(),
|
||||||
|
reviewFrequency: z.string().min(1, "Review frequency is required"),
|
||||||
|
retentionYears: z.string().min(1, "Retention years is required"),
|
||||||
|
requiresTraining: z.boolean().optional(),
|
||||||
|
parentId: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type CategoryFormData = z.infer<typeof categorySchema>;
|
||||||
|
|
||||||
const DocumentCategories = (): ReactElement => {
|
const DocumentCategories = (): ReactElement => {
|
||||||
const navigate = useNavigate();
|
// const navigate = useNavigate();
|
||||||
const [categories, setCategories] = useState<DocumentCategory[]>([]);
|
const [categories, setCategories] = useState<DocumentCategory[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [name, setName] = useState("");
|
|
||||||
const [code, setCode] = useState("");
|
|
||||||
const [description, setDescription] = useState("");
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [isViewModalOpen, setIsViewModalOpen] = useState(false);
|
||||||
|
const [editingCategory, setEditingCategory] = useState<DocumentCategory | null>(null);
|
||||||
|
const [viewingCategory, setViewingCategory] = useState<DocumentCategory | null>(null);
|
||||||
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
|
const [categoryToDelete, setCategoryToDelete] = useState<DocumentCategory | null>(null);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
control,
|
||||||
|
reset,
|
||||||
|
setValue,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<CategoryFormData>({
|
||||||
|
resolver: zodResolver(categorySchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: "",
|
||||||
|
code: "",
|
||||||
|
description: "",
|
||||||
|
reviewFrequency: "12",
|
||||||
|
retentionYears: "7",
|
||||||
|
requiresTraining: false,
|
||||||
|
parentId: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const loadCategories = async (): Promise<void> => {
|
const loadCategories = async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
@ -33,77 +84,145 @@ const DocumentCategories = (): ReactElement => {
|
|||||||
void loadCategories();
|
void loadCategories();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleEdit = (category: DocumentCategory) => {
|
||||||
|
setEditingCategory(category);
|
||||||
|
setValue("name", category.name);
|
||||||
|
setValue("code", category.code);
|
||||||
|
setValue("description", category.description || "");
|
||||||
|
setValue("reviewFrequency", category.review_frequency_months?.toString() || "12");
|
||||||
|
setValue("retentionYears", category.retention_years?.toString() || "7");
|
||||||
|
setValue("requiresTraining", !!category.requires_training);
|
||||||
|
setValue("parentId", category.parent_id || "");
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleView = (category: DocumentCategory) => {
|
||||||
|
setViewingCategory(category);
|
||||||
|
setIsViewModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteClick = (category: DocumentCategory) => {
|
||||||
|
setCategoryToDelete(category);
|
||||||
|
setIsDeleteModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmDelete = async () => {
|
||||||
|
if (!categoryToDelete) return;
|
||||||
|
try {
|
||||||
|
setIsDeleting(true);
|
||||||
|
await documentService.deleteCategory(categoryToDelete.id);
|
||||||
|
showToast.success("Category deleted successfully");
|
||||||
|
setIsDeleteModalOpen(false);
|
||||||
|
setCategoryToDelete(null);
|
||||||
|
await loadCategories();
|
||||||
|
} catch (err: any) {
|
||||||
|
showToast.error(
|
||||||
|
err?.response?.data?.error?.message || "Failed to delete category",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const columns: Column<DocumentCategory>[] = useMemo(
|
const columns: Column<DocumentCategory>[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{ key: "name", label: "Name" },
|
{
|
||||||
{ key: "code", label: "Code" },
|
key: "name",
|
||||||
{
|
label: "Name",
|
||||||
key: "description",
|
render: (cat) => <span className="text-[#0f1724] font-medium">{cat.name}</span>
|
||||||
label: "Description",
|
},
|
||||||
render: (category) => category.description || "-",
|
{
|
||||||
|
key: "code",
|
||||||
|
label: "Code",
|
||||||
|
render: (cat) => <span className="inline-flex items-center rounded-md bg-blue-50 px-2 py-0.5 text-xs font-bold text-blue-600 border border-blue-100">{cat.code}</span>
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "review_frequency_months",
|
key: "review_frequency_months",
|
||||||
label: "Review (months)",
|
label: "Review Frequency",
|
||||||
render: (category) =>
|
render: (category) =>
|
||||||
category.review_frequency_months?.toString() || "-",
|
category.review_frequency_months ? `${category.review_frequency_months} months` : "-",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "parent_id",
|
||||||
|
label: "Parent Category",
|
||||||
|
render: (category) => {
|
||||||
|
const parent = categories.find(c => c.id === category.parent_id);
|
||||||
|
return <span className="text-[#6b7280]">{parent ? parent.name : "-"}</span>;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "retention_years",
|
key: "retention_years",
|
||||||
label: "Retention (years)",
|
label: "Retention",
|
||||||
render: (category) => category.retention_years?.toString() || "-",
|
render: (category) => category.retention_years ? `${category.retention_years} years` : "-",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "requires_training",
|
||||||
|
label: "Requires Training",
|
||||||
|
render: (category) => (
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className={cn(
|
||||||
|
"w-10 h-5 rounded-full relative transition-colors duration-200 pointer-events-none",
|
||||||
|
category.requires_training ? "bg-[#084cc8]" : "bg-gray-200"
|
||||||
|
)}>
|
||||||
|
<div className={cn(
|
||||||
|
"absolute top-1 left-1 w-3 h-3 bg-white rounded-full transition-transform duration-200 shadow-sm",
|
||||||
|
category.requires_training && "translate-x-5"
|
||||||
|
)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "description",
|
||||||
|
label: "Description",
|
||||||
|
render: (category) => <span className="text-gray-500 line-clamp-1 max-w-[300px]">{category.description || "-"}</span>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "actions",
|
key: "actions",
|
||||||
label: "Actions",
|
label: "Actions",
|
||||||
align: "right",
|
align: "right",
|
||||||
render: (category) => (
|
render: (category) => (
|
||||||
<button
|
<ActionDropdown
|
||||||
type="button"
|
actions={[
|
||||||
className="text-[#ef4444] hover:underline"
|
{ label: "View Details", onClick: () => handleView(category), icon: <Eye className="w-4 h-4" /> },
|
||||||
onClick={async () => {
|
{ label: "Edit Category", onClick: () => handleEdit(category), icon: <Edit className="w-4 h-4" /> },
|
||||||
try {
|
{ label: "Delete", onClick: () => handleDeleteClick(category), icon: <Trash2 className="w-4 h-4" />, variant: "danger" },
|
||||||
await documentService.deleteCategory(category.id);
|
]}
|
||||||
showToast.success("Category deleted");
|
/>
|
||||||
await loadCategories();
|
|
||||||
} catch (err: any) {
|
|
||||||
showToast.error(
|
|
||||||
err?.response?.data?.error?.message ||
|
|
||||||
"Failed to delete category",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[],
|
[categories],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onCreateCategory = async (event: React.FormEvent): Promise<void> => {
|
const onFormSubmit = async (data: CategoryFormData): Promise<void> => {
|
||||||
event.preventDefault();
|
|
||||||
if (!name.trim() || !code.trim()) {
|
|
||||||
showToast.error("Name and code are required");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
await documentService.createCategory({
|
const payload = {
|
||||||
name: name.trim(),
|
name: data.name.trim(),
|
||||||
code: code.trim().toUpperCase(),
|
code: data.code.trim().toUpperCase(),
|
||||||
description: description.trim() || undefined,
|
description: data.description?.trim() || undefined,
|
||||||
});
|
review_frequency_months: parseInt(data.reviewFrequency),
|
||||||
showToast.success("Category created");
|
retention_years: parseInt(data.retentionYears),
|
||||||
setName("");
|
requires_training: data.requiresTraining,
|
||||||
setCode("");
|
parent_id: data.parentId || null,
|
||||||
setDescription("");
|
};
|
||||||
|
|
||||||
|
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();
|
await loadCategories();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
showToast.error(
|
showToast.error(
|
||||||
err?.response?.data?.error?.message || "Failed to create category",
|
err?.response?.data?.error?.message || "Failed to process category",
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
@ -113,60 +232,18 @@ const DocumentCategories = (): ReactElement => {
|
|||||||
return (
|
return (
|
||||||
<Layout
|
<Layout
|
||||||
currentPage="Document Service"
|
currentPage="Document Service"
|
||||||
breadcrumbs={[
|
|
||||||
{ label: "Document Service", path: "/tenant/documents" },
|
|
||||||
{ label: "Category Management" },
|
|
||||||
]}
|
|
||||||
pageHeader={{
|
pageHeader={{
|
||||||
title: "Category Management",
|
title: "Document Categories",
|
||||||
description: "Create and maintain document categories.",
|
description: "View and manage the document categories and their retention policies.",
|
||||||
tabs: [
|
action: (
|
||||||
{ label: "Document List", path: "/tenant/documents" },
|
<PrimaryButton onClick={() => { setEditingCategory(null); reset(); setIsModalOpen(true); }}>
|
||||||
{ label: "Create Document", path: "/tenant/documents/create" },
|
<Plus className="w-4 h-4 mr-1.5" />
|
||||||
{ label: "Category Management", path: "/tenant/documents/categories" },
|
Create Category
|
||||||
],
|
</PrimaryButton>
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<form
|
|
||||||
onSubmit={onCreateCategory}
|
|
||||||
className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] p-5"
|
|
||||||
>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<FormField
|
|
||||||
label="Category Name"
|
|
||||||
required
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
placeholder="e.g. SOP"
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
label="Code"
|
|
||||||
required
|
|
||||||
value={code}
|
|
||||||
onChange={(e) => setCode(e.target.value)}
|
|
||||||
placeholder="e.g. SOP"
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
label="Description"
|
|
||||||
value={description}
|
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
|
||||||
placeholder="Optional"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<PrimaryButton type="submit" disabled={isSubmitting}>
|
|
||||||
{isSubmitting ? "Saving..." : "Add Category"}
|
|
||||||
</PrimaryButton>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="h-10 px-4 border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0f1724] hover:bg-gray-50"
|
|
||||||
onClick={() => navigate("/tenant/documents")}
|
|
||||||
>
|
|
||||||
Back to Documents
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] overflow-hidden">
|
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] overflow-hidden">
|
||||||
<DataTable
|
<DataTable
|
||||||
@ -178,9 +255,227 @@ const DocumentCategories = (): ReactElement => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={isModalOpen}
|
||||||
|
onClose={() => { setIsModalOpen(false); setEditingCategory(null); }}
|
||||||
|
title={editingCategory ? "Update Document Category" : "Create Document Category"}
|
||||||
|
maxWidth="lg"
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSubmit(onFormSubmit)} className="p-6 space-y-5">
|
||||||
|
<p className="text-sm text-gray-500 -mt-2">
|
||||||
|
Add a document category with review, retention, and training requirements.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<FormField
|
||||||
|
label="Category Name"
|
||||||
|
required
|
||||||
|
placeholder="e.g. Standard Operating Procedures"
|
||||||
|
error={errors.name?.message}
|
||||||
|
{...register("name")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
label="Code"
|
||||||
|
required
|
||||||
|
placeholder="e.g. SOP"
|
||||||
|
// description="Short code (e.g. INT, EXT, AUD). Max 100 characters."
|
||||||
|
error={errors.code?.message}
|
||||||
|
{...register("code")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||||
|
<Controller
|
||||||
|
name="reviewFrequency"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormSelect
|
||||||
|
label="Review Frequency (months)"
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
options={[
|
||||||
|
{ value: "1", label: "1" },
|
||||||
|
{ value: "3", label: "3" },
|
||||||
|
{ value: "6", label: "6" },
|
||||||
|
{ value: "12", label: "12" },
|
||||||
|
{ value: "24", label: "24" },
|
||||||
|
{ value: "36", label: "36" },
|
||||||
|
{ value: "60", label: "60" },
|
||||||
|
]}
|
||||||
|
placeholder="Select months"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="retentionYears"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormSelect
|
||||||
|
label="Retention (years)"
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
options={[
|
||||||
|
{ value: "1", label: "1" },
|
||||||
|
{ value: "3", label: "3" },
|
||||||
|
{ value: "5", label: "5" },
|
||||||
|
{ value: "7", label: "7" },
|
||||||
|
{ value: "10", label: "10" },
|
||||||
|
{ value: "25", label: "25" },
|
||||||
|
{ value: "99", label: "Permanent" },
|
||||||
|
]}
|
||||||
|
placeholder="Select years"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="parentId"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormSelect
|
||||||
|
label="Parent Category (Optional)"
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: "No Parent (Root)" },
|
||||||
|
...categories
|
||||||
|
.filter(c => !editingCategory || c.id !== editingCategory.id)
|
||||||
|
.map(c => ({ value: c.id, label: c.name }))
|
||||||
|
]}
|
||||||
|
placeholder="Select parent category"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormTextArea
|
||||||
|
label="Description"
|
||||||
|
placeholder="Description of this user category."
|
||||||
|
error={errors.description?.message}
|
||||||
|
rows={3}
|
||||||
|
{...register("description")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between py-4 border-t border-gray-100 mt-2">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-bold text-[#0f1724]">Requires Training</label>
|
||||||
|
<p className="text-[11px] text-gray-500">Users must acknowledge documents in this category</p>
|
||||||
|
</div>
|
||||||
|
<Controller
|
||||||
|
name="requiresTraining"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"w-10 h-5 rounded-full relative transition-colors duration-200 cursor-pointer",
|
||||||
|
field.value ? "bg-[#084cc8]" : "bg-gray-200"
|
||||||
|
)}
|
||||||
|
onClick={() => field.onChange(!field.value)}
|
||||||
|
>
|
||||||
|
<div className={cn(
|
||||||
|
"absolute top-1 left-1 w-3 h-3 bg-white rounded-full transition-transform duration-200 shadow-sm",
|
||||||
|
field.value && "translate-x-5"
|
||||||
|
)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 pt-4 border-t border-gray-100">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setIsModalOpen(false); setEditingCategory(null); }}
|
||||||
|
className="px-6 py-2 border border-gray-200 rounded-md text-sm font-bold text-[#0f1724] hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<PrimaryButton type="submit" disabled={isSubmitting} className="px-6">
|
||||||
|
{isSubmitting ? "Processing..." : editingCategory ? "Update Category" : "Create Category"}
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* View Modal */}
|
||||||
|
<Modal
|
||||||
|
isOpen={isViewModalOpen}
|
||||||
|
onClose={() => { setIsViewModalOpen(false); setViewingCategory(null); }}
|
||||||
|
title="Document Category Details"
|
||||||
|
maxWidth="lg"
|
||||||
|
>
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<div className="grid grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-500 uppercase font-bold tracking-wider">Name</label>
|
||||||
|
<p className="text-sm font-semibold text-[#0f1724] mt-1">{viewingCategory?.name}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-500 uppercase font-bold tracking-wider">Code</label>
|
||||||
|
<p className="text-sm font-semibold text-[#0f1724] mt-1">
|
||||||
|
<span className="bg-blue-50 text-blue-600 px-2 py-0.5 rounded border border-blue-100">{viewingCategory?.code}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-500 uppercase font-bold tracking-wider">Review Frequency</label>
|
||||||
|
<p className="text-sm font-semibold text-[#0f1724] mt-1">{viewingCategory?.review_frequency_months} months</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-500 uppercase font-bold tracking-wider">Retention</label>
|
||||||
|
<p className="text-sm font-semibold text-[#0f1724] mt-1">{viewingCategory?.retention_years} years</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-500 uppercase font-bold tracking-wider">Parent Category</label>
|
||||||
|
<p className="text-sm font-semibold text-[#0f1724] mt-1">
|
||||||
|
{categories.find(c => c.id === viewingCategory?.parent_id)?.name || "None (Root Category)"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-500 uppercase font-bold tracking-wider">Description</label>
|
||||||
|
<p className="text-sm text-gray-600 mt-1 leading-relaxed">{viewingCategory?.description || "No description provided."}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-50 p-4 rounded-lg flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-bold text-[#0f1724]">Requires Training</p>
|
||||||
|
<p className="text-xs text-gray-500">Training acknowledgement is {viewingCategory?.requires_training ? "enabled" : "disabled"} for this category.</p>
|
||||||
|
</div>
|
||||||
|
<div className={cn(
|
||||||
|
"w-10 h-5 rounded-full relative",
|
||||||
|
viewingCategory?.requires_training ? "bg-[#084cc8]" : "bg-gray-200"
|
||||||
|
)}>
|
||||||
|
<div className={cn(
|
||||||
|
"absolute top-1 left-1 w-3 h-3 bg-white rounded-full",
|
||||||
|
viewingCategory?.requires_training && "translate-x-5"
|
||||||
|
)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end pt-4 border-t border-gray-100">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsViewModalOpen(false)}
|
||||||
|
className="px-6 py-2 bg-[#0f1724] rounded-md text-sm font-bold text-white hover:bg-black transition-colors"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<DeleteConfirmationModal
|
||||||
|
isOpen={isDeleteModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setIsDeleteModalOpen(false);
|
||||||
|
setCategoryToDelete(null);
|
||||||
|
}}
|
||||||
|
onConfirm={handleConfirmDelete}
|
||||||
|
title="Delete Document Category"
|
||||||
|
message="Are you sure you want to delete this category? This action cannot be undone and will fail if the category is currently associated with documents."
|
||||||
|
itemName={categoryToDelete?.name || ""}
|
||||||
|
isLoading={isDeleting}
|
||||||
|
/>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default DocumentCategories;
|
export default DocumentCategories;
|
||||||
|
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import {
|
|||||||
} from "@/components/shared";
|
} from "@/components/shared";
|
||||||
import { documentService } from "@/services/document-service";
|
import { documentService } from "@/services/document-service";
|
||||||
import type { DocumentCategory, DocumentSummary } from "@/types/document";
|
import type { DocumentCategory, DocumentSummary } from "@/types/document";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus, Search } from "lucide-react";
|
||||||
|
|
||||||
const formatDate = (value?: string | null): string => {
|
const formatDate = (value?: string | null): string => {
|
||||||
if (!value) return "-";
|
if (!value) return "-";
|
||||||
@ -183,81 +183,132 @@ const Documents = (): ReactElement => {
|
|||||||
title: "Document List",
|
title: "Document List",
|
||||||
description:
|
description:
|
||||||
"Manage controlled documents, track versions and open document details.",
|
"Manage controlled documents, track versions and open document details.",
|
||||||
tabs: [
|
action: (
|
||||||
{ label: "Document List", path: "/tenant/documents" },
|
<div className="flex gap-2">
|
||||||
{ label: "Create Document", path: "/tenant/documents/create" },
|
{/* <button
|
||||||
{ label: "Category Management", path: "/tenant/documents/categories" },
|
type="button"
|
||||||
],
|
className="h-10 px-3 border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0f1724] hover:bg-gray-50 bg-white transition-colors"
|
||||||
|
onClick={() => navigate("/tenant/documents/categories")}
|
||||||
|
>
|
||||||
|
Manage Categories
|
||||||
|
</button> */}
|
||||||
|
<PrimaryButton onClick={() => navigate("/tenant/documents/create")}>
|
||||||
|
<Plus className="w-3.5 h-3.5 mr-1" />
|
||||||
|
New Document
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] overflow-hidden">
|
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] overflow-hidden">
|
||||||
<div className="border-b border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-3 flex flex-col gap-3">
|
<div className="border-b border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-4">
|
||||||
<div className="flex flex-col md:flex-row gap-2 md:items-center md:justify-between">
|
<div className="flex flex-col md:flex-row items-center justify-between gap-4">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
{/* Left side: Search and Filters */}
|
||||||
<FilterDropdown
|
<div className="flex flex-1 flex-wrap items-center gap-3">
|
||||||
label="Status"
|
{/* Search Bar */}
|
||||||
options={statuses.map((status) => ({
|
<div className="relative w-full max-w-[280px]">
|
||||||
value: status.code,
|
<div className="absolute inset-y-0 left-3 flex items-center pointer-events-none text-gray-400">
|
||||||
label: status.name,
|
<Search className="w-4 h-4" />
|
||||||
}))}
|
</div>
|
||||||
value={statusFilter}
|
<input
|
||||||
onChange={(value) => {
|
type="text"
|
||||||
setStatusFilter(value as string | null);
|
value={search}
|
||||||
setCurrentPage(1);
|
onChange={(e) => {
|
||||||
}}
|
setSearch(e.target.value);
|
||||||
placeholder="All"
|
setCurrentPage(1);
|
||||||
/>
|
}}
|
||||||
<FilterDropdown
|
placeholder="Search by name, ID..."
|
||||||
label="Category"
|
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"
|
||||||
options={categories.map((category) => ({
|
/>
|
||||||
value: category.id,
|
</div>
|
||||||
label: category.name,
|
|
||||||
}))}
|
{/* Filters */}
|
||||||
value={categoryFilter}
|
<div className="flex items-center gap-2">
|
||||||
onChange={(value) => {
|
<FilterDropdown
|
||||||
setCategoryFilter(value as string | null);
|
label="Status"
|
||||||
setCurrentPage(1);
|
options={statuses.map((status) => ({
|
||||||
}}
|
value: status.code,
|
||||||
placeholder="All"
|
label: status.name,
|
||||||
/>
|
}))}
|
||||||
<FilterDropdown
|
value={statusFilter}
|
||||||
label="Type"
|
onChange={(value) => {
|
||||||
options={types.map((type) => ({
|
setStatusFilter(value as string | null);
|
||||||
value: type.code,
|
setCurrentPage(1);
|
||||||
label: type.name,
|
}}
|
||||||
}))}
|
placeholder="All"
|
||||||
value={typeFilter}
|
/>
|
||||||
onChange={(value) => {
|
|
||||||
setTypeFilter(value as string | null);
|
<FilterDropdown
|
||||||
setCurrentPage(1);
|
label="Category"
|
||||||
}}
|
options={categories.map((category) => ({
|
||||||
placeholder="All"
|
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>
|
||||||
<div className="flex gap-2">
|
|
||||||
|
{/* Right side: Clear Filters */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="h-10 px-3 border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0f1724] hover:bg-gray-50"
|
onClick={() => {
|
||||||
onClick={() => navigate("/tenant/documents/categories")}
|
setSearch("");
|
||||||
|
setStatusFilter(null);
|
||||||
|
setCategoryFilter(null);
|
||||||
|
setTypeFilter(null);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
|
className="text-sm font-medium text-[#6b7280] hover:text-[#0f1724] transition-colors"
|
||||||
>
|
>
|
||||||
Manage Categories
|
Clear filters
|
||||||
</button>
|
</button>
|
||||||
<PrimaryButton onClick={() => navigate("/tenant/documents/create")}>
|
|
||||||
<Plus className="w-3.5 h-3.5 mr-1" />
|
|
||||||
New Document
|
|
||||||
</PrimaryButton>
|
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<DataTable
|
<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 EditDocument = lazy(() => import("@/pages/tenant/EditDocument"));
|
||||||
const ViewDocument = lazy(() => import("@/pages/tenant/ViewDocument"));
|
const ViewDocument = lazy(() => import("@/pages/tenant/ViewDocument"));
|
||||||
const DocumentCategories = lazy(() => import("@/pages/tenant/DocumentCategories"));
|
const DocumentCategories = lazy(() => import("@/pages/tenant/DocumentCategories"));
|
||||||
|
const DocumentsDueForReview = lazy(() => import("@/pages/tenant/DocumentsDueForReview"));
|
||||||
const Tasks = lazy(() => import("@/pages/tenant/Tasks"));
|
const Tasks = lazy(() => import("@/pages/tenant/Tasks"));
|
||||||
|
|
||||||
// Loading fallback component
|
// Loading fallback component
|
||||||
@ -106,6 +107,10 @@ export const tenantAdminRoutes: RouteConfig[] = [
|
|||||||
path: "/tenant/documents/categories",
|
path: "/tenant/documents/categories",
|
||||||
element: <LazyRoute component={DocumentCategories} />,
|
element: <LazyRoute component={DocumentCategories} />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/tenant/documents/due-for-review",
|
||||||
|
element: <LazyRoute component={DocumentsDueForReview} />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/tenant/tasks",
|
path: "/tenant/tasks",
|
||||||
element: <LazyRoute component={Tasks} />,
|
element: <LazyRoute component={Tasks} />,
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import type {
|
|||||||
DocumentCategory,
|
DocumentCategory,
|
||||||
DocumentDetail,
|
DocumentDetail,
|
||||||
DocumentListResponse,
|
DocumentListResponse,
|
||||||
|
DocumentSummary,
|
||||||
DocumentResponse,
|
DocumentResponse,
|
||||||
DocumentVersion,
|
DocumentVersion,
|
||||||
} from "@/types/document";
|
} from "@/types/document";
|
||||||
@ -289,5 +290,13 @@ export const documentService = {
|
|||||||
>(`/documents/${id}/checkin`);
|
>(`/documents/${id}/checkin`);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getDueForReview: async (daysAhead?: number): Promise<DocumentResponse<DocumentSummary[]>> => {
|
||||||
|
const response = await apiClient.get<DocumentResponse<DocumentSummary[]>>(
|
||||||
|
"/documents/due-for-review",
|
||||||
|
{ params: { days_ahead: daysAhead } }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -107,6 +107,11 @@ class WorkflowService {
|
|||||||
const response = await apiClient.post(`${this.baseUrl}/instances/${instanceId}/transition`, data);
|
const response = await apiClient.post(`${this.baseUrl}/instances/${instanceId}/transition`, data);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getInstanceHistory(id: string): Promise<{ success: boolean; data: any[] }> {
|
||||||
|
const response = await apiClient.get(`${this.baseUrl}/instances/${id}/history`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const workflowService = new WorkflowService();
|
export const workflowService = new WorkflowService();
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user