Files
HRM-System/resources/js/pages/hr/documents/document-acknowledgments/index.tsx
2026-04-13 08:16:56 +08:00

681 lines
26 KiB
TypeScript
Executable File

import { CrudDeleteModal } from '@/components/CrudDeleteModal';
import { CrudFormModal } from '@/components/CrudFormModal';
import { CrudTable } from '@/components/CrudTable';
import { toast } from '@/components/custom-toast';
import { PageTemplate } from '@/components/page-template';
import { Pagination } from '@/components/ui/pagination';
import { SearchAndFilterBar } from '@/components/ui/search-and-filter-bar';
import { hasPermission } from '@/utils/authorization';
import { router, usePage } from '@inertiajs/react';
import { AlertTriangle, Calendar, CheckCircle, Clock, FileText, Plus, User, XCircle } from 'lucide-react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Dialog } from '@/components/ui/dialog';
import View from './view';
export default function DocumentAcknowledgments() {
const { t } = useTranslation();
const { auth, documentAcknowledgments, documents, users, filters: pageFilters = {}, globalSettings } = usePage().props as any;
const permissions = auth?.permissions || [];
const [searchTerm, setSearchTerm] = useState(pageFilters.search || '');
const [documentFilter, setDocumentFilter] = useState(pageFilters.document_id || '_empty_');
const [userFilter, setUserFilter] = useState(pageFilters.user_id || '_empty_');
const [statusFilter, setStatusFilter] = useState(pageFilters.status || '_empty_');
const [showFilters, setShowFilters] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isFormModalOpen, setIsFormModalOpen] = useState(false);
const [isAcknowledgeModalOpen, setIsAcknowledgeModalOpen] = useState(false);
const [currentItem, setCurrentItem] = useState<any>(null);
const [formMode, setFormMode] = useState<'create' | 'edit'>('create');
const [viewingItem, setViewingItem] = useState<any>(null);
const hasActiveFilters = () => {
return documentFilter !== '_empty_' || userFilter !== '_empty_' || statusFilter !== '_empty_' || searchTerm !== '';
};
const activeFilterCount = () => {
return (
(documentFilter !== '_empty_' ? 1 : 0) +
(userFilter !== '_empty_' ? 1 : 0) +
(statusFilter !== '_empty_' ? 1 : 0) +
(searchTerm !== '' ? 1 : 0)
);
};
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
applyFilters();
};
const applyFilters = () => {
router.get(
route('hr.documents.document-acknowledgments.index'),
{
page: 1,
search: searchTerm || undefined,
document_id: documentFilter !== '_empty_' ? documentFilter : undefined,
user_id: userFilter !== '_empty_' ? userFilter : undefined,
status: statusFilter !== '_empty_' ? statusFilter : undefined,
per_page: pageFilters.per_page,
},
{ preserveState: true, preserveScroll: true },
);
};
const handleSort = (field: string) => {
const direction = pageFilters.sort_field === field && pageFilters.sort_direction === 'asc' ? 'desc' : 'asc';
router.get(
route('hr.documents.document-acknowledgments.index'),
{
sort_field: field,
sort_direction: direction,
page: 1,
search: searchTerm || undefined,
document_id: documentFilter !== '_empty_' ? documentFilter : undefined,
user_id: userFilter !== '_empty_' ? userFilter : undefined,
status: statusFilter !== '_empty_' ? statusFilter : undefined,
per_page: pageFilters.per_page,
},
{ preserveState: true, preserveScroll: true },
);
};
const handleAction = (action: string, item: any) => {
setCurrentItem(item);
switch (action) {
case 'view':
setViewingItem(item);
break;
case 'edit':
setFormMode('edit');
setIsFormModalOpen(true);
break;
case 'delete':
setIsDeleteModalOpen(true);
break;
case 'acknowledge':
setIsAcknowledgeModalOpen(true);
break;
}
};
const handleAddNew = () => {
setCurrentItem(null);
setFormMode('create');
setIsFormModalOpen(true);
};
const handleFormSubmit = (formData: any) => {
if (formMode === 'create') {
if (!globalSettings?.is_demo) {
toast.loading(t('Assigning document acknowledgment...'));
}
router.post(route('hr.documents.document-acknowledgments.store'), formData, {
onSuccess: (page) => {
setIsFormModalOpen(false);
if (!globalSettings?.is_demo) {
toast.dismiss();
}
if (page.props.flash.success) {
toast.success(t(page.props.flash.success));
} else if (page.props.flash.error) {
toast.error(t(page.props.flash.error));
}
},
onError: (errors) => {
if (!globalSettings?.is_demo) {
toast.dismiss();
}
if (typeof errors === 'string') {
toast.error(errors);
} else {
toast.error(`Failed to assign acknowledgment: ${Object.values(errors).join(', ')}`);
}
},
});
} else if (formMode === 'edit') {
if (!globalSettings?.is_demo) {
toast.loading(t('Updating document acknowledgment...'));
}
router.put(route('hr.documents.document-acknowledgments.update', currentItem.id), formData, {
onSuccess: (page) => {
setIsFormModalOpen(false);
if (!globalSettings?.is_demo) {
toast.dismiss();
}
if (page.props.flash.success) {
toast.success(t(page.props.flash.success));
} else if (page.props.flash.error) {
toast.error(t(page.props.flash.error));
}
},
onError: (errors) => {
if (!globalSettings?.is_demo) {
toast.dismiss();
}
if (typeof errors === 'string') {
toast.error(errors);
} else {
toast.error(`Failed to update acknowledgment: ${Object.values(errors).join(', ')}`);
}
},
});
}
};
const handleDeleteConfirm = () => {
if (!globalSettings?.is_demo) {
toast.loading(t('Deleting document acknowledgment...'));
}
router.delete(route('hr.documents.document-acknowledgments.destroy', currentItem.id), {
onSuccess: (page) => {
setIsDeleteModalOpen(false);
if (!globalSettings?.is_demo) {
toast.dismiss();
}
if (page.props.flash.success) {
toast.success(t(page.props.flash.success));
} else if (page.props.flash.error) {
toast.error(t(page.props.flash.error));
}
},
onError: (errors) => {
if (!globalSettings?.is_demo) {
toast.dismiss();
}
if (typeof errors === 'string') {
toast.error(errors);
} else {
toast.error(`Failed to delete acknowledgment: ${Object.values(errors).join(', ')}`);
}
},
});
};
const handleResetFilters = () => {
setSearchTerm('');
setDocumentFilter('_empty_');
setUserFilter('_empty_');
setStatusFilter('_empty_');
setShowFilters(false);
router.get(
route('hr.documents.document-acknowledgments.index'),
{
page: 1,
per_page: pageFilters.per_page,
},
{ preserveState: true, preserveScroll: true },
);
};
const handleAcknowledgeSubmit = (formData: any) => {
if (!globalSettings?.is_demo) {
toast.loading(t('Acknowledging document...'));
}
router.put(
route('hr.documents.document-acknowledgments.acknowledge', currentItem.id),
{
acknowledgment_note: formData.acknowledgment_note || 'Document acknowledged',
},
{
onSuccess: (page) => {
setIsAcknowledgeModalOpen(false);
if (!globalSettings?.is_demo) {
toast.dismiss();
}
if (page.props.flash.success) {
toast.success(t(page.props.flash.success));
} else if (page.props.flash.error) {
toast.error(t(page.props.flash.error));
}
},
onError: (errors) => {
if (!globalSettings?.is_demo) {
toast.dismiss();
}
if (typeof errors === 'string') {
toast.error(errors);
} else {
toast.error(`Failed to acknowledge document: ${Object.values(errors).join(', ')}`);
}
},
},
);
};
const pageActions = [];
if (hasPermission(permissions, 'create-document-acknowledgments')) {
pageActions.push({
label: t('Assign Document'),
icon: <Plus className="mr-2 h-4 w-4" />,
variant: 'default',
onClick: () => handleAddNew(),
});
}
const breadcrumbs = [
{ title: t('Dashboard'), href: route('dashboard') },
{ title: t('Document Management'), href: route('hr.documents.document-acknowledgments.index') },
{ title: t('Acknowledgments') },
];
const getStatusColor = (status: string) => {
switch (status) {
case 'Pending':
return 'bg-yellow-50 text-yellow-800 ring-yellow-600/20';
case 'Acknowledged':
return 'bg-green-50 text-green-700 ring-green-600/20';
case 'Overdue':
return 'bg-red-50 text-red-700 ring-red-600/10';
case 'Exempted':
return 'bg-gray-50 text-gray-600 ring-gray-500/10';
default:
return 'bg-gray-50 text-gray-600 ring-gray-500/10';
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'Pending':
return <Clock className="h-3 w-3" />;
case 'Acknowledged':
return <CheckCircle className="h-3 w-3" />;
case 'Overdue':
return <AlertTriangle className="h-3 w-3" />;
case 'Exempted':
return <XCircle className="h-3 w-3" />;
default:
return <Clock className="h-3 w-3" />;
}
};
const getDaysInfo = (item: any) => {
if (item.status === 'Acknowledged' || item.status === 'Exempted') return null;
if (!item.due_date) return null;
const today = new Date();
const dueDate = new Date(item.due_date);
const diffTime = dueDate.getTime() - today.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays < 0) {
return { type: 'overdue', days: Math.abs(diffDays), text: `${Math.abs(diffDays)} days overdue` };
} else if (diffDays === 0) {
return { type: 'today', days: 0, text: 'Due today' };
} else {
return { type: 'remaining', days: diffDays, text: `${diffDays} days remaining` };
}
};
const columns = [
{
key: 'document.title',
label: t('Document'),
sortable: false,
render: (_, row) => (
<div className="flex items-center gap-3">
<div
className="flex h-10 w-10 items-center justify-center rounded-lg text-white"
style={{ backgroundColor: row.document?.category?.color || '#3B82F6' }}
>
<FileText className="h-5 w-5" />
</div>
<div>
<div className="font-medium">{row.document?.title || 'Unknown Document'}</div>
<div className="text-xs text-gray-500">{row.document?.category?.name}</div>
</div>
</div>
),
},
{
key: 'user.name',
label: t('Employee'),
render: (_, row) => (
<div className="flex items-center gap-2">
<User className="h-4 w-4 text-gray-500" />
{row.user?.name || 'Unknown User'}
</div>
),
},
{
key: 'status',
label: t('Status'),
render: (value) => (
<span
className={`inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium ring-1 ring-inset ${getStatusColor(value)}`}
>
{getStatusIcon(value)}
{t(value)}
</span>
),
},
{
key: 'acknowledged_at',
label: t('Acknowledged'),
sortable: true,
render: (value, row) => {
if (!value) return '-';
return (
<div>
<div className="text-sm">
{value ? window.appSettings?.formatDateTimeSimple(value, false) || new Date(value).toLocaleString() : '-'}
</div>
</div>
);
},
},
{
key: 'due_date',
label: t('Due Date'),
sortable: true,
render: (value, row) => {
if (!value) return '-';
const daysInfo = getDaysInfo(row);
return (
<div>
<div className="flex items-center gap-1">
<Calendar className="h-4 w-4 text-gray-500" />
{value ? window.appSettings?.formatDateTimeSimple(value, false) || new Date(value).toLocaleString() : '-'}
</div>
{daysInfo && (
<div
className={`text-xs ${
daysInfo.type === 'overdue' ? 'text-red-600' : daysInfo.type === 'today' ? 'text-orange-600' : 'text-gray-500'
}`}
>
{daysInfo.text}
</div>
)}
</div>
);
},
},
{
key: 'assigned_by.name',
label: t('Assigned By'),
render: (_, row) => row.assigned_by?.name || '-',
},
{
key: 'assigned_at',
label: t('Assigned'),
sortable: true,
render: (value) => window.appSettings?.formatDateTimeSimple(value, false) || new Date(value).toLocaleDateString(),
},
];
const actions = [
{
label: t('View'),
icon: 'Eye',
action: 'view',
className: 'text-blue-500',
requiredPermission: 'view-document-acknowledgments',
},
{
label: t('Edit'),
icon: 'Edit',
action: 'edit',
className: 'text-amber-500',
requiredPermission: 'edit-document-acknowledgments',
},
{
label: t('Acknowledge'),
icon: 'CheckCircle',
action: 'acknowledge',
className: 'text-green-500',
requiredPermission: 'acknowledge-document-acknowledgments',
condition: (item: any) => item.status === 'Pending' || item.status === 'Overdue',
},
{
label: t('Delete'),
icon: 'Trash2',
action: 'delete',
className: 'text-red-500',
requiredPermission: 'delete-document-acknowledgments',
},
];
const documentOptions = [
{ value: '_empty_', label: t('All Documents'), disabled: true },
...(documents || []).map((doc: any) => ({
value: doc.id.toString(),
label: doc.title,
})),
];
const userOptions = [
{ value: '_empty_', label: t('All Users'), disabled: true },
...(users || []).map((user: any) => ({
value: user.id.toString(),
label: user.name,
})),
];
const statusOptions = [
{ value: '_empty_', label: t('All Statuses'), disabled: true },
{ value: 'Pending', label: t('Pending') },
{ value: 'Acknowledged', label: t('Acknowledged') },
{ value: 'Overdue', label: t('Overdue') },
{ value: 'Exempted', label: t('Exempted') },
];
const documentSelectOptions = [
{ value: '_empty_', label: t('Select Document') },
...(documents || []).map((doc: any) => ({
value: doc.id.toString(),
label: doc.title,
})),
];
const userSelectOptions = [
{ value: '_empty_', label: t('Select User') },
...(users || []).map((user: any) => ({
value: user.id.toString(),
label: user.name,
})),
];
return (
<PageTemplate
title={t('Acknowledgments')}
url="/hr/documents/document-acknowledgments"
actions={pageActions}
breadcrumbs={breadcrumbs}
noPadding
>
<div className="mb-4 rounded-lg bg-white p-4 shadow dark:bg-gray-900">
<SearchAndFilterBar
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
onSearch={handleSearch}
filters={[
{
name: 'document_id',
label: t('Document'),
type: 'select',
value: documentFilter,
onChange: setDocumentFilter,
options: documentOptions,
searchable: true,
},
{
name: 'user_id',
label: t('User'),
type: 'select',
value: userFilter,
onChange: setUserFilter,
options: userOptions,
searchable: true,
},
{
name: 'status',
label: t('Status'),
type: 'select',
value: statusFilter,
onChange: setStatusFilter,
options: statusOptions,
},
]}
showFilters={showFilters}
setShowFilters={setShowFilters}
hasActiveFilters={hasActiveFilters}
activeFilterCount={activeFilterCount}
onResetFilters={handleResetFilters}
onApplyFilters={applyFilters}
currentPerPage={pageFilters.per_page?.toString() || '10'}
onPerPageChange={(value) => {
router.get(
route('hr.documents.document-acknowledgments.index'),
{
page: 1,
per_page: parseInt(value),
search: searchTerm || undefined,
document_id: documentFilter !== '_empty_' ? documentFilter : undefined,
user_id: userFilter !== '_empty_' ? userFilter : undefined,
status: statusFilter !== '_empty_' ? statusFilter : undefined,
},
{ preserveState: true, preserveScroll: true },
);
}}
/>
</div>
<div className="overflow-hidden rounded-lg bg-white shadow dark:bg-gray-900">
<CrudTable
columns={columns}
actions={actions}
data={documentAcknowledgments?.data || []}
from={documentAcknowledgments?.from || 1}
onAction={handleAction}
sortField={pageFilters.sort_field}
sortDirection={pageFilters.sort_direction}
onSort={handleSort}
permissions={permissions}
entityPermissions={{
view: 'view-document-acknowledgments',
create: 'create-document-acknowledgments',
edit: 'edit-document-acknowledgments',
delete: 'delete-document-acknowledgments',
}}
/>
<Pagination
from={documentAcknowledgments?.from || 0}
to={documentAcknowledgments?.to || 0}
total={documentAcknowledgments?.total || 0}
links={documentAcknowledgments?.links}
entityName={t('acknowledgments')}
onPageChange={(url) => router.get(url)}
/>
</div>
<CrudFormModal
isOpen={isFormModalOpen}
onClose={() => setIsFormModalOpen(false)}
onSubmit={handleFormSubmit}
formConfig={{
fields: [
{
name: 'document_id',
label: t('Document'),
type: 'select',
required: true,
options: documentSelectOptions.filter((opt) => opt.value !== '_empty_'),
searchable: true,
},
{
name: 'user_id',
label: t('User'),
type: 'select',
required: true,
options: userSelectOptions.filter((opt) => opt.value !== '_empty_'),
searchable: true,
},
{
name: 'due_date',
label: t('Due Date'),
type: 'date',
helpText: formMode === 'create' ? t('Leave empty to set 7 days from today') : undefined,
},
{
name: 'status',
label: t('Status'),
type: 'select',
required: formMode === 'edit',
options: statusOptions.filter((opt) => opt.value !== '_empty_'),
conditional: (mode) => mode === 'edit' || mode === 'view',
},
{
name: 'acknowledgment_note',
label: t('Note'),
type: 'textarea',
rows: 3,
},
],
modalSize: 'lg',
}}
initialData={
currentItem
? {
...currentItem,
document_id: currentItem.document_id?.toString(),
user_id: currentItem.user_id?.toString(),
due_date: currentItem.due_date
? window.appSettings.formatDateTimeSimple(currentItem.due_date, false)
: currentItem.due_date,
}
: null
}
title={
formMode === 'create'
? t('Assign Document for Acknowledgment')
: t('Edit Document Acknowledgment')
}
mode={formMode}
/>
<CrudDeleteModal
isOpen={isDeleteModalOpen}
onClose={() => setIsDeleteModalOpen(false)}
onConfirm={handleDeleteConfirm}
itemName={currentItem ? `${currentItem.document?.title} - ${currentItem.user?.name}` : ''}
entityName="acknowledgment"
/>
<CrudFormModal
isOpen={isAcknowledgeModalOpen}
onClose={() => setIsAcknowledgeModalOpen(false)}
onSubmit={handleAcknowledgeSubmit}
formConfig={{
fields: [
{
name: 'acknowledgment_note',
label: t('Acknowledgment Note'),
type: 'textarea',
rows: 3,
placeholder: t('Enter acknowledgment note (optional)'),
},
],
}}
initialData={{ acknowledgment_note: '' }}
title={t('Acknowledge Document')}
mode="edit"
/>
{/* View Modal */}
<Dialog open={!!viewingItem} onOpenChange={() => setViewingItem(null)}>
{viewingItem && <View acknowledgment={viewingItem} />}
</Dialog>
</PageTemplate>
);
}