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