444 lines
19 KiB
TypeScript
444 lines
19 KiB
TypeScript
import React, { useState, useEffect, useRef } from 'react';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import { useTranslation } from 'react-i18next';
|
||
import { usePPMs, usePPMMutations } from '../hooks/usePPM';
|
||
import { FaPlus, FaSearch, FaEdit, FaEye, FaTrash, FaCopy, FaEllipsisV, FaFileExport, FaCalendarCheck, FaBuilding } from 'react-icons/fa';
|
||
|
||
const PPMList: React.FC = () => {
|
||
const { t } = useTranslation();
|
||
const navigate = useNavigate();
|
||
const [page, setPage] = useState(0);
|
||
const [searchTerm, setSearchTerm] = useState('');
|
||
const [companyFilter, setCompanyFilter] = useState<string>('');
|
||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState<string | null>(null);
|
||
const [actionMenuOpen, setActionMenuOpen] = useState<string | null>(null);
|
||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||
const limit = 20;
|
||
|
||
const filters = companyFilter ? { company: companyFilter } : {};
|
||
|
||
const { ppms, totalCount, hasMore, loading, error, refetch } = usePPMs(
|
||
filters,
|
||
limit,
|
||
page * limit,
|
||
'creation desc'
|
||
);
|
||
|
||
const { deletePPM, loading: mutationLoading } = usePPMMutations();
|
||
|
||
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/new');
|
||
};
|
||
|
||
const handleView = (ppmName: string) => {
|
||
navigate(`/ppm/${ppmName}`);
|
||
};
|
||
|
||
const handleEdit = (ppmName: string) => {
|
||
navigate(`/ppm/${ppmName}`);
|
||
};
|
||
|
||
const handleDelete = async (ppmName: string) => {
|
||
try {
|
||
await deletePPM(ppmName);
|
||
setDeleteConfirmOpen(null);
|
||
refetch();
|
||
alert('PPM schedule deleted successfully!');
|
||
} catch (err) {
|
||
alert(`Failed to delete: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||
}
|
||
};
|
||
|
||
const handleDuplicate = (ppmName: string) => {
|
||
navigate(`/ppm/new?duplicate=${ppmName}`);
|
||
};
|
||
|
||
const handleExport = (ppm: any) => {
|
||
const dataStr = JSON.stringify(ppm, null, 2);
|
||
const dataBlob = new Blob([dataStr], { type: 'application/json' });
|
||
const url = URL.createObjectURL(dataBlob);
|
||
const link = document.createElement('a');
|
||
link.href = url;
|
||
link.download = `ppm_${ppm.name}.json`;
|
||
link.click();
|
||
URL.revokeObjectURL(url);
|
||
};
|
||
|
||
const handleExportAll = () => {
|
||
const headers = ['PPM ID', 'Company', 'Asset', 'Asset Type', 'Frequency', 'No. of PMs', 'Total Amount'];
|
||
const csvContent = [
|
||
headers.join(','),
|
||
...ppms.map(ppm => [
|
||
ppm.name,
|
||
ppm.company || '',
|
||
ppm.asset_name || '',
|
||
ppm.custom_asset_type || '',
|
||
ppm.custom_frequency || '',
|
||
ppm.custom_no_of_pms || '',
|
||
ppm.custom_total_amount || ''
|
||
].join(','))
|
||
].join('\n');
|
||
|
||
const dataBlob = new Blob([csvContent], { type: 'text/csv' });
|
||
const url = URL.createObjectURL(dataBlob);
|
||
const link = document.createElement('a');
|
||
link.href = url;
|
||
link.download = `ppm_schedules_${new Date().toISOString().split('T')[0]}.csv`;
|
||
link.click();
|
||
URL.revokeObjectURL(url);
|
||
};
|
||
|
||
if (loading && page === 0) {
|
||
return (
|
||
<div className="flex items-center justify-center h-screen bg-gray-50 dark:bg-gray-900">
|
||
<div className="text-center">
|
||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto"></div>
|
||
<p className="mt-4 text-gray-600 dark:text-gray-400">{t('listPages.loading')}</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<div className="p-4 sm:p-6 bg-gray-50 dark:bg-gray-900 min-h-screen min-w-0 overflow-x-hidden">
|
||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-6">
|
||
<h2 className="text-xl font-bold text-yellow-800 dark:text-yellow-300 mb-4">⚠️ PPM API Not Available</h2>
|
||
<div className="text-yellow-700 dark:text-yellow-400 space-y-3">
|
||
<p><strong>The PPM API endpoint is not deployed yet.</strong></p>
|
||
<div className="mt-4 flex gap-3">
|
||
<button
|
||
onClick={() => navigate('/ppm/new')}
|
||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded"
|
||
>
|
||
Try Creating New (Demo)
|
||
</button>
|
||
<button
|
||
onClick={refetch}
|
||
className="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded"
|
||
>
|
||
Try Again
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="mt-4 p-4 bg-white dark:bg-gray-800 rounded border border-yellow-300 dark:border-yellow-700">
|
||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||
<strong>Technical Error:</strong> {error}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const filteredPPMs = ppms.filter(ppm =>
|
||
ppm.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
ppm.asset_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
ppm.company?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
ppm.custom_asset_type?.toLowerCase().includes(searchTerm.toLowerCase())
|
||
);
|
||
|
||
return (
|
||
<div className="p-4 sm:p-6 bg-gray-50 dark:bg-gray-900 min-h-screen min-w-0 overflow-x-hidden">
|
||
{/* Header */}
|
||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between lg:items-center">
|
||
<div>
|
||
<h1 className="text-2xl sm:text-3xl font-bold text-gray-800 dark:text-white break-words">{t('ppm.title')}</h1>
|
||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||
Total: {totalCount} PPM schedule{totalCount !== 1 ? 's' : ''}
|
||
</p>
|
||
</div>
|
||
<div className="flex gap-3">
|
||
<button
|
||
onClick={handleExportAll}
|
||
className="bg-green-600 hover:bg-green-700 text-white px-4 py-3 rounded-lg flex items-center gap-2 shadow transition-all"
|
||
disabled={ppms.length === 0}
|
||
>
|
||
<FaFileExport />
|
||
<span className="font-medium">{t('listPages.export')}</span>
|
||
</button>
|
||
<button
|
||
onClick={handleCreateNew}
|
||
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-lg flex items-center gap-2 shadow-lg transition-all hover:shadow-xl"
|
||
>
|
||
<FaPlus />
|
||
<span className="font-medium">{t('ppm.addPPM')}</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Filters Bar */}
|
||
<div className="mb-6 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
|
||
<div className="flex items-center gap-2 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 bg-white dark:bg-gray-700">
|
||
<FaSearch className="text-gray-400 dark:text-gray-500" />
|
||
<input
|
||
type="text"
|
||
placeholder="Search by ID, asset, company..."
|
||
value={searchTerm}
|
||
onChange={(e) => setSearchTerm(e.target.value)}
|
||
className="flex-1 outline-none text-gray-700 dark:text-gray-200 bg-transparent"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
|
||
<input
|
||
type="text"
|
||
placeholder="Filter by Company"
|
||
value={companyFilter}
|
||
onChange={(e) => {
|
||
setCompanyFilter(e.target.value);
|
||
setPage(0);
|
||
}}
|
||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* PPM Schedules Table */}
|
||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full">
|
||
<thead className="bg-gray-100 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
|
||
<tr>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||
PPM ID
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||
Company
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||
Asset
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||
Asset Type
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||
Frequency
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||
No. of PMs
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||
Total Amount
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||
Actions
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||
{filteredPPMs.length === 0 ? (
|
||
<tr>
|
||
<td colSpan={8} className="px-6 py-12 text-center text-gray-500 dark:text-gray-400">
|
||
<div className="flex flex-col items-center">
|
||
<FaSearch className="text-4xl text-gray-300 dark:text-gray-600 mb-2" />
|
||
<p>No PPM schedules found</p>
|
||
<button
|
||
onClick={handleCreateNew}
|
||
className="mt-4 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline"
|
||
>
|
||
Create your first PPM schedule
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
) : (
|
||
filteredPPMs.map((ppm) => (
|
||
<tr
|
||
key={ppm.name}
|
||
className="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors cursor-pointer"
|
||
onClick={() => handleView(ppm.name)}
|
||
>
|
||
<td className="px-6 py-4 whitespace-nowrap">
|
||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||
{ppm.name}
|
||
</div>
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap">
|
||
<div className="flex items-center gap-2">
|
||
<FaBuilding className="text-gray-400" />
|
||
<span className="text-sm text-gray-700 dark:text-gray-300">
|
||
{ppm.company || '-'}
|
||
</span>
|
||
</div>
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap">
|
||
<div className="text-sm text-gray-700 dark:text-gray-300">
|
||
{ppm.asset_name || '-'}
|
||
</div>
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap">
|
||
<div className="text-sm text-gray-700 dark:text-gray-300">
|
||
{ppm.custom_asset_type || '-'}
|
||
</div>
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap">
|
||
<div className="flex items-center gap-2">
|
||
<FaCalendarCheck className="text-blue-500" />
|
||
<span className="text-sm text-gray-700 dark:text-gray-300">
|
||
{ppm.custom_frequency || '-'}
|
||
</span>
|
||
</div>
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap">
|
||
<div className="text-sm text-gray-700 dark:text-gray-300">
|
||
{ppm.custom_no_of_pms || '-'}
|
||
</div>
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap">
|
||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||
{ppm.custom_total_amount ? `$${ppm.custom_total_amount.toLocaleString()}` : '-'}
|
||
</div>
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||
<div className="relative" onClick={(e) => e.stopPropagation()}>
|
||
<button
|
||
onClick={() => setActionMenuOpen(actionMenuOpen === ppm.name ? null : ppm.name)}
|
||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
|
||
>
|
||
<FaEllipsisV />
|
||
</button>
|
||
{actionMenuOpen === ppm.name && (
|
||
<div
|
||
ref={dropdownRef}
|
||
className="absolute right-0 mt-2 w-48 bg-white dark:bg-gray-800 rounded-lg shadow-lg z-10 border border-gray-200 dark:border-gray-700"
|
||
>
|
||
<button
|
||
onClick={() => {
|
||
handleView(ppm.name);
|
||
setActionMenuOpen(null);
|
||
}}
|
||
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2"
|
||
>
|
||
<FaEye />
|
||
View
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
handleEdit(ppm.name);
|
||
setActionMenuOpen(null);
|
||
}}
|
||
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2"
|
||
>
|
||
<FaEdit />
|
||
Edit
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
handleDuplicate(ppm.name);
|
||
setActionMenuOpen(null);
|
||
}}
|
||
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2"
|
||
>
|
||
<FaCopy />
|
||
Duplicate
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
handleExport(ppm);
|
||
setActionMenuOpen(null);
|
||
}}
|
||
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2"
|
||
>
|
||
<FaFileExport />
|
||
Export
|
||
</button>
|
||
<div className="border-t border-gray-200 dark:border-gray-700"></div>
|
||
<button
|
||
onClick={() => {
|
||
setDeleteConfirmOpen(ppm.name);
|
||
setActionMenuOpen(null);
|
||
}}
|
||
className="w-full text-left px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center gap-2"
|
||
>
|
||
<FaTrash />
|
||
Delete
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{/* Pagination */}
|
||
{(hasMore || page > 0) && (
|
||
<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-700 dark:text-gray-300">
|
||
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 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
Previous
|
||
</button>
|
||
<button
|
||
onClick={() => setPage(page + 1)}
|
||
disabled={!hasMore}
|
||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
Next
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Delete Confirmation Modal */}
|
||
{deleteConfirmOpen && (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full mx-4">
|
||
<h3 className="text-lg font-bold text-gray-900 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 schedule? This action cannot be undone.
|
||
</p>
|
||
<div className="flex gap-3 justify-end">
|
||
<button
|
||
onClick={() => setDeleteConfirmOpen(null)}
|
||
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-200 dark:bg-gray-700 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
onClick={() => handleDelete(deleteConfirmOpen)}
|
||
disabled={mutationLoading}
|
||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50"
|
||
>
|
||
{mutationLoading ? 'Deleting...' : 'Delete'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default PPMList;
|
||
|