422 lines
19 KiB
Plaintext
422 lines
19 KiB
Plaintext
import React, { useState, useEffect, useRef } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { FaPlus, FaSearch, FaEdit, FaEye, FaTrash, FaEllipsisV, FaCalendarAlt, FaFilter, FaChevronDown, FaChevronUp, FaTimes } from 'react-icons/fa';
|
|
import { usePMSchedules, usePMScheduleMutations } from '../hooks/usePMSchedule';
|
|
import LinkField from '../components/LinkField';
|
|
|
|
const PPMPlannerList: React.FC = () => {
|
|
const navigate = useNavigate();
|
|
const [page, setPage] = useState(0);
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState<string | null>(null);
|
|
const [actionMenuOpen, setActionMenuOpen] = useState<string | null>(null);
|
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Filters
|
|
const [isFilterExpanded, setIsFilterExpanded] = useState(false);
|
|
const [filterHospital, setFilterHospital] = useState('');
|
|
const [filterModality, setFilterModality] = useState('');
|
|
const [filterPeriodicity, setFilterPeriodicity] = useState('');
|
|
|
|
const limit = 20;
|
|
|
|
// Build filters
|
|
const filters: Record<string, any> = {};
|
|
if (filterHospital) filters['hospital'] = filterHospital;
|
|
if (filterModality) filters['modality'] = filterModality;
|
|
if (filterPeriodicity) filters['periodicity'] = filterPeriodicity;
|
|
|
|
const { pmSchedules, totalCount, hasMore, loading, error, refetch } = usePMSchedules(
|
|
filters,
|
|
limit,
|
|
page * limit,
|
|
'creation desc'
|
|
);
|
|
|
|
const { deletePMSchedule, loading: mutationLoading } = usePMScheduleMutations();
|
|
|
|
useEffect(() => {
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
|
setActionMenuOpen(null);
|
|
}
|
|
};
|
|
|
|
if (actionMenuOpen) {
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
}
|
|
|
|
return () => {
|
|
document.removeEventListener('mousedown', handleClickOutside);
|
|
};
|
|
}, [actionMenuOpen]);
|
|
|
|
const handleCreateNew = () => {
|
|
navigate('/ppm-planner/new');
|
|
};
|
|
|
|
const handleView = (scheduleName: string) => {
|
|
navigate(`/ppm-planner/${scheduleName}`);
|
|
};
|
|
|
|
const handleEdit = (scheduleName: string) => {
|
|
navigate(`/ppm-planner/${scheduleName}`);
|
|
};
|
|
|
|
const handleDelete = async (scheduleName: string) => {
|
|
try {
|
|
await deletePMSchedule(scheduleName);
|
|
refetch();
|
|
setDeleteConfirmOpen(null);
|
|
} catch (err) {
|
|
console.error('Error deleting PM Schedule:', err);
|
|
alert('Failed to delete PM Schedule');
|
|
}
|
|
};
|
|
|
|
const handleClearFilters = () => {
|
|
setFilterHospital('');
|
|
setFilterModality('');
|
|
setFilterPeriodicity('');
|
|
setPage(0);
|
|
};
|
|
|
|
const hasActiveFilters = filterHospital || filterModality || filterPeriodicity;
|
|
const activeFilterCount = [filterHospital, filterModality, filterPeriodicity].filter(Boolean).length;
|
|
|
|
// Filter schedules by search term
|
|
const filteredSchedules = pmSchedules.filter(schedule => {
|
|
if (!searchTerm) return true;
|
|
const term = searchTerm.toLowerCase();
|
|
return (
|
|
schedule.name?.toLowerCase().includes(term) ||
|
|
schedule.hospital?.toLowerCase().includes(term) ||
|
|
schedule.modality?.toLowerCase().includes(term) ||
|
|
schedule.maintenance_team?.toLowerCase().includes(term)
|
|
);
|
|
});
|
|
|
|
const totalPages = Math.ceil(totalCount / limit);
|
|
|
|
return (
|
|
<div className="flex flex-col h-screen bg-gray-50 dark:bg-gray-900">
|
|
{/* Header */}
|
|
<div className="flex-shrink-0 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-4 py-3 lg:px-6">
|
|
<div className="flex justify-between items-center mb-3">
|
|
<div className="flex items-center gap-3">
|
|
<FaCalendarAlt className="text-blue-600 dark:text-blue-400" size={24} />
|
|
<div>
|
|
<h1 className="text-xl font-bold text-gray-800 dark:text-white">PPM Planners</h1>
|
|
<p className="text-xs text-gray-600 dark:text-gray-400">
|
|
Manage preventive maintenance schedules
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={handleCreateNew}
|
|
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors flex items-center gap-2 text-sm"
|
|
>
|
|
<FaPlus />
|
|
<span>Create PPM Planner</span>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Search and Filters */}
|
|
<div className="flex gap-2">
|
|
<div className="flex-1 relative">
|
|
<FaSearch className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={14} />
|
|
<input
|
|
type="text"
|
|
placeholder="Search by name, hospital, modality..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="w-full pl-9 pr-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
|
/>
|
|
</div>
|
|
<button
|
|
onClick={() => setIsFilterExpanded(!isFilterExpanded)}
|
|
className={`px-3 py-1.5 border rounded-lg transition-colors flex items-center gap-2 text-sm ${
|
|
hasActiveFilters
|
|
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400'
|
|
: 'border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300'
|
|
}`}
|
|
>
|
|
<FaFilter />
|
|
<span>Filters</span>
|
|
{activeFilterCount > 0 && (
|
|
<span className="bg-blue-600 text-white rounded-full w-5 h-5 flex items-center justify-center text-xs">
|
|
{activeFilterCount}
|
|
</span>
|
|
)}
|
|
{isFilterExpanded ? <FaChevronUp /> : <FaChevronDown />}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Filter Panel */}
|
|
{isFilterExpanded && (
|
|
<div className="mt-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg border border-gray-200 dark:border-gray-600">
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
|
<div>
|
|
{/* <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
Hospital
|
|
</label> */}
|
|
<LinkField
|
|
label = "Hospital"
|
|
doctype="Company"
|
|
value={filterHospital}
|
|
onChange={setFilterHospital}
|
|
placeholder="All Hospitals"
|
|
filters={{ domain: "Healthcare" }}
|
|
/>
|
|
</div>
|
|
<div>
|
|
{/* <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
Modality
|
|
</label> */}
|
|
<LinkField
|
|
label="Modality"
|
|
doctype="Modality"
|
|
value={filterModality}
|
|
onChange={setFilterModality}
|
|
placeholder="All Modalities"
|
|
filters={{}}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
Periodicity
|
|
</label>
|
|
<select
|
|
value={filterPeriodicity}
|
|
onChange={(e) => setFilterPeriodicity(e.target.value)}
|
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
|
>
|
|
<option value="">All</option>
|
|
<option value="Daily">Daily</option>
|
|
<option value="Weekly">Weekly</option>
|
|
<option value="Monthly">Monthly</option>
|
|
<option value="Quarterly">Quarterly</option>
|
|
<option value="Half-yearly">Half-yearly</option>
|
|
<option value="Yearly">Yearly</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
{hasActiveFilters && (
|
|
<div className="mt-4 flex justify-end">
|
|
<button
|
|
onClick={handleClearFilters}
|
|
className="px-4 py-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 flex items-center gap-2"
|
|
>
|
|
<FaTimes />
|
|
Clear Filters
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="flex-1 overflow-auto p-4 lg:p-5">
|
|
{loading && page === 0 ? (
|
|
<div className="flex items-center justify-center h-full">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
|
|
<span className="ml-3 text-gray-600 dark:text-gray-400">Loading PPM Planners...</span>
|
|
</div>
|
|
) : error ? (
|
|
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
|
<p className="text-red-600 dark:text-red-400">{error}</p>
|
|
</div>
|
|
) : filteredSchedules.length === 0 ? (
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-8 text-center">
|
|
<FaCalendarAlt className="mx-auto text-gray-400 mb-4" size={48} />
|
|
<h3 className="text-lg font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
|
No PPM Planners Found
|
|
</h3>
|
|
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
|
{searchTerm || hasActiveFilters
|
|
? 'Try adjusting your search or filters'
|
|
: 'Get started by creating your first PPM Planner'}
|
|
</p>
|
|
{!searchTerm && !hasActiveFilters && (
|
|
<button
|
|
onClick={handleCreateNew}
|
|
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
|
|
>
|
|
Create PPM Planner
|
|
</button>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
|
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
|
<thead className="bg-gray-50 dark:bg-gray-700">
|
|
<tr>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
|
Name
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
|
Hospital
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
|
Modality
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
|
Periodicity
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
|
Due Date
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
|
Status
|
|
</th>
|
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
|
Actions
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
|
{filteredSchedules.map((schedule) => (
|
|
<tr key={schedule.name} className="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<button
|
|
onClick={() => handleView(schedule.name)}
|
|
className="text-blue-600 dark:text-blue-400 hover:underline font-medium"
|
|
>
|
|
{schedule.name}
|
|
</button>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300">
|
|
{schedule.hospital || '-'}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300">
|
|
{schedule.modality || '-'}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300">
|
|
{schedule.periodicity || '-'}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300">
|
|
{schedule.due_date ? new Date(schedule.due_date).toLocaleDateString() : '-'}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<span className={`px-2 py-1 text-xs font-semibold rounded-full ${
|
|
schedule.docstatus === 1
|
|
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
|
|
: schedule.docstatus === 0
|
|
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400'
|
|
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-400'
|
|
}`}>
|
|
{schedule.docstatus === 1 ? 'Submitted' : schedule.docstatus === 0 ? 'Draft' : 'Cancelled'}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
|
<div className="relative inline-block" ref={actionMenuOpen === schedule.name ? dropdownRef : null}>
|
|
<button
|
|
onClick={() => setActionMenuOpen(actionMenuOpen === schedule.name ? null : schedule.name)}
|
|
className="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 p-2"
|
|
>
|
|
<FaEllipsisV />
|
|
</button>
|
|
{actionMenuOpen === schedule.name && (
|
|
<div className="absolute right-0 mt-2 w-48 bg-white dark:bg-gray-700 rounded-lg shadow-lg border border-gray-200 dark:border-gray-600 z-10">
|
|
<button
|
|
onClick={() => {
|
|
handleView(schedule.name);
|
|
setActionMenuOpen(null);
|
|
}}
|
|
className="w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 flex items-center gap-2"
|
|
>
|
|
<FaEye /> View
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
handleEdit(schedule.name);
|
|
setActionMenuOpen(null);
|
|
}}
|
|
className="w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 flex items-center gap-2"
|
|
>
|
|
<FaEdit /> Edit
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
setDeleteConfirmOpen(schedule.name);
|
|
setActionMenuOpen(null);
|
|
}}
|
|
className="w-full px-4 py-2 text-left text-sm text-red-600 dark:text-red-400 hover:bg-gray-100 dark:hover:bg-gray-600 flex items-center gap-2"
|
|
>
|
|
<FaTrash /> Delete
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
|
|
{/* Pagination */}
|
|
{totalPages > 1 && (
|
|
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
|
<div className="text-sm text-gray-600 dark:text-gray-400">
|
|
Showing {page * limit + 1} to {Math.min((page + 1) * limit, totalCount)} of {totalCount} results
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => setPage(Math.max(0, page - 1))}
|
|
disabled={page === 0}
|
|
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed text-gray-700 dark:text-gray-300"
|
|
>
|
|
Previous
|
|
</button>
|
|
<button
|
|
onClick={() => setPage(page + 1)}
|
|
disabled={!hasMore}
|
|
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed text-gray-700 dark:text-gray-300"
|
|
>
|
|
Next
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Delete Confirmation Modal */}
|
|
{deleteConfirmOpen && (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 max-w-md">
|
|
<h3 className="text-lg font-semibold text-gray-800 dark:text-white mb-4">
|
|
Confirm Delete
|
|
</h3>
|
|
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
|
Are you sure you want to delete this PPM Planner? This action cannot be undone.
|
|
</p>
|
|
<div className="flex gap-3 justify-end">
|
|
<button
|
|
onClick={() => setDeleteConfirmOpen(null)}
|
|
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
|
|
disabled={mutationLoading}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={() => handleDelete(deleteConfirmOpen)}
|
|
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors"
|
|
disabled={mutationLoading}
|
|
>
|
|
{mutationLoading ? 'Deleting...' : 'Delete'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default PPMPlannerList;
|
|
|
|
|