import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { useItems, useItemMutations } from '../hooks/useItem'; import * as XLSX from 'xlsx'; import { FaPlus, FaEdit, FaEye, FaTrash, FaCopy, FaFileExport, FaBox, FaTimes, FaFilter, FaChevronDown, FaChevronUp, FaSave, FaStar, FaCheckSquare, FaSquare, FaFileExcel, FaFileCsv, FaDownload } from 'react-icons/fa'; import LinkField from '../components/LinkField'; import ListPagination from '../components/ListPagination'; import { buildDateRangeFilters, toFrappeFilterArray } from '../utils/listFilterUtils'; import DeleteRequestButton from '../components/DeleteRequestButton'; import type { DeleteStatus } from '../services/deleteRequestService'; // Export types type ExportFormat = 'csv' | 'excel'; type ExportScope = 'selected' | 'all_on_page' | 'all_with_filters'; interface ExportModalProps { isOpen: boolean; onClose: () => void; selectedCount: number; totalCount: number; pageCount: number; onExport: (scope: ExportScope, format: ExportFormat, columns: string[]) => void; isExporting: boolean; exportColumns: Array<{key: string, label: string, default: boolean}>; } const ExportModal: React.FC = ({ isOpen, onClose, selectedCount, totalCount, pageCount, onExport, isExporting, exportColumns }) => { const { t } = useTranslation(); const [scope, setScope] = useState(selectedCount > 0 ? 'selected' : 'all_with_filters'); const [format, setFormat] = useState('csv'); const [selectedColumns, setSelectedColumns] = useState( exportColumns.filter(c => c.default).map(c => c.key) ); useEffect(() => { if (selectedCount > 0) { setScope('selected'); } else { setScope('all_with_filters'); } }, [selectedCount]); const toggleColumn = (key: string) => { setSelectedColumns(prev => prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key] ); }; const selectAllColumns = () => { setSelectedColumns(exportColumns.map(c => c.key)); }; const selectDefaultColumns = () => { setSelectedColumns(exportColumns.filter(c => c.default).map(c => c.key)); }; if (!isOpen) return null; return (
{/* Header */}

{t('items.export.title')}

{/* Export Scope */}

{t('items.export.selectData')}

{/* Export Format */}

{t('listPages.exportFormat')}

{/* Column Selection */}

{t('items.export.columnsToExport')}

|
{exportColumns.map((col) => ( ))}

{t('items.export.columnsSelected', { count: selectedColumns.length })}

{/* Footer */}
{scope === 'selected' && t('items.export.exportingSelected', { count: selectedCount })} {scope === 'all_on_page' && t('items.export.exportingPage', { count: pageCount })} {scope === 'all_with_filters' && t('items.export.exportingAll', { count: totalCount })}
); }; const ItemList: React.FC = () => { const { t } = useTranslation(); const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); const pageFromUrl = useMemo(() => { const p = parseInt(searchParams.get('page') || '1', 10); return Math.max(0, Number.isNaN(p) ? 0 : p - 1); }, [searchParams]); const page = pageFromUrl; const setPage = useCallback((zeroBasedPage: number) => { setSearchParams((prev) => { const next = new URLSearchParams(prev); next.set('page', String(zeroBasedPage + 1)); return next; }); }, [setSearchParams]); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(null); const limit = 20; // Export column configuration const EXPORT_COLUMNS = [ { key: 'name', label: 'Item ID', default: true }, { key: 'item_code', label: 'Item Code', default: true }, { key: 'item_name', label: 'Item Name', default: true }, { key: 'item_group', label: 'Item Group', default: true }, { key: 'custom_hospital_name', label: 'Hospital', default: true }, { key: 'custom_serial_no', label: 'Serial No', default: true }, { key: 'custom_technical_department', label: 'Technical Department', default: true }, { key: 'custom_date_in', label: 'Date In', default: true }, { key: 'custom_w', label: 'Watts', default: true }, { key: 'custom_volts', label: 'Volts', default: true }, { key: 'custom_type', label: 'Type', default: true }, { key: 'custom_code', label: 'Code', default: true }, { key: 'stock_uom', label: 'Stock UOM', default: false }, { key: 'custom_part_description', label: 'Part Description', default: false }, { key: 'brand', label: 'Brand', default: false }, { key: 'valuation_rate', label: 'Valuation Rate', default: false }, { key: 'opening_stock', label: 'Opening Stock', default: false }, { key: 'custom_last_calibration_date', label: 'Last Calibration Date', default: false }, { key: 'custom_next_due_calibration_date', label: 'Next Calibration Date', default: false }, { key: 'description', label: 'Description', default: false }, { key: 'creation', label: 'Created On', default: false }, { key: 'modified', label: 'Modified On', default: true }, { key: 'owner', label: 'Created By', default: false }, { key: 'modified_by', label: 'Modified By', default: false }, ]; // Track initial load to prevent full-page refresh on filter changes const [initialLoadComplete, setInitialLoadComplete] = useState(false); // Row selection state const [selectedRows, setSelectedRows] = useState>(new Set()); // Export modal state const [showExportModal, setShowExportModal] = useState(false); const [isExporting, setIsExporting] = useState(false); const [userRoles, setUserRoles] = useState([]); const [listIsSystemManager, setListIsSystemManager] = useState(false); useEffect(() => { const fetchRoles = async () => { try { const response = await fetch('/api/method/asset_lite.api.user_roles.get_user_roles', { credentials: 'include' }); const data = await response.json(); const rolesList = Array.isArray(data.message) ? data.message : []; setUserRoles(rolesList); setListIsSystemManager(rolesList.includes('System Manager')); } catch (error) { console.error('Failed to fetch user roles:', error); } }; fetchRoles(); }, []); // Filter states (init from URL so filters persist when navigating back from detail) const [dateFilterBy, setDateFilterBy] = useState<'' | 'creation' | 'modified'>(() => (searchParams.get('date_filter_by') as '' | 'creation' | 'modified') || ''); const [dateStart, setDateStart] = useState(() => searchParams.get('date_start') || ''); const [dateEnd, setDateEnd] = useState(() => searchParams.get('date_end') || ''); const [filterItemId, setFilterItemId] = useState(() => searchParams.get('item_id') || ''); const [filterItemGroup, setFilterItemGroup] = useState(() => searchParams.get('item_group') || ''); const [filterCompany, setFilterCompany] = useState(() => searchParams.get('company') || ''); const [filterItemName, setFilterItemName] = useState(() => searchParams.get('item_name') || ''); // Sort state (init from URL) const sortOptions = ['creation desc', 'creation asc', 'modified desc', 'modified asc', 'item_name asc', 'item_name desc', 'name asc', 'name desc'] as const; const [sortBy, setSortBy] = useState(() => searchParams.get('sort_by') || 'creation desc'); // Advanced filter UI states const [isFilterExpanded, setIsFilterExpanded] = useState(true); const [activeFilterCount, setActiveFilterCount] = useState(0); const [savedFilters, setSavedFilters] = useState([]); const [showSaveFilterModal, setShowSaveFilterModal] = useState(false); const [filterPresetName, setFilterPresetName] = useState(''); // Temporary states for text inputs (item_name init from URL for persistence) const [tempItemName, setTempItemName] = useState(() => searchParams.get('item_name') || ''); // Debounce timer refs const itemNameDebounceRef = useRef(null); // Load saved filters from localStorage on mount useEffect(() => { const saved = localStorage.getItem('itemFilterPresets'); if (saved) { setSavedFilters(JSON.parse(saved)); } }, []); const hasDateFilter = dateFilterBy && (dateStart || dateEnd); // Update active filter count useEffect(() => { const count = [filterItemId, filterItemGroup, filterCompany, filterItemName].filter(Boolean).length + (hasDateFilter ? 1 : 0); setActiveFilterCount(count); }, [filterItemId, filterItemGroup, filterCompany, filterItemName, hasDateFilter]); // Build filters object const filters: Record = {}; if (filterItemId) filters['name'] = filterItemId; if (filterItemGroup) filters['item_group'] = filterItemGroup; if (filterCompany) filters['custom_hospital_name'] = filterCompany; if (filterItemName) filters['item_name'] = ['like', `%${filterItemName}%`]; Object.assign(filters, buildDateRangeFilters(dateFilterBy, dateStart, dateEnd)); filters['custom_delete_status'] = ['!=', 'Deleted']; const orderBy = sortOptions.includes(sortBy as any) ? sortBy : 'creation desc'; const { items, totalCount, hasMore, loading, error, refetch } = useItems( Object.keys(filters).length > 0 ? filters : undefined, limit, page * limit, orderBy ); const { deleteItem, loading: mutationLoading } = useItemMutations(); // Mark initial load complete useEffect(() => { if (!loading && !initialLoadComplete) { setInitialLoadComplete(true); } }, [loading, initialLoadComplete]); // Sync filters to URL when user changes a filter (not on mount/return from detail) const filtersChangedOnce = useRef(false); useEffect(() => { if (!filtersChangedOnce.current) { filtersChangedOnce.current = true; return; } setSearchParams((prev) => { const next = new URLSearchParams(prev); if (dateFilterBy) next.set('date_filter_by', dateFilterBy); else next.delete('date_filter_by'); if (dateStart) next.set('date_start', dateStart); else next.delete('date_start'); if (dateEnd) next.set('date_end', dateEnd); else next.delete('date_end'); if (filterItemId) next.set('item_id', filterItemId); else next.delete('item_id'); if (filterItemGroup) next.set('item_group', filterItemGroup); else next.delete('item_group'); if (filterCompany) next.set('company', filterCompany); else next.delete('company'); if (filterItemName) next.set('item_name', filterItemName); else next.delete('item_name'); if (sortBy && sortBy !== 'creation desc') next.set('sort_by', sortBy); else next.delete('sort_by'); next.set('page', '1'); return next; }); }, [dateFilterBy, dateStart, dateEnd, filterItemId, filterItemGroup, filterCompany, filterItemName, sortBy]); // Clear selection when filters/page change useEffect(() => { setSelectedRows(new Set()); }, [dateFilterBy, dateStart, dateEnd, filterItemId, filterItemGroup, filterCompany, filterItemName, page]); // Debounce function for item name const handleItemNameChange = (value: string) => { setTempItemName(value); if (itemNameDebounceRef.current) clearTimeout(itemNameDebounceRef.current); itemNameDebounceRef.current = window.setTimeout(() => { setFilterItemName(value); }, 800); }; // Handle Enter key press const handleKeyPress = (e: React.KeyboardEvent) => { if (e.key === 'Enter') { e.preventDefault(); if (itemNameDebounceRef.current) clearTimeout(itemNameDebounceRef.current); setFilterItemName(tempItemName); } }; // Save filter preset const handleSaveFilterPreset = () => { if (!filterPresetName.trim()) { alert('Please enter a filter name'); return; } const preset = { id: Date.now(), name: filterPresetName, filters: { dateFilterBy, dateStart, dateEnd, sortBy, filterItemId, filterItemGroup, filterCompany, filterItemName } }; const updated = [...savedFilters, preset]; setSavedFilters(updated); setFilterPresetName(''); setShowSaveFilterModal(false); localStorage.setItem('itemFilterPresets', JSON.stringify(updated)); }; // Load filter preset const handleLoadFilterPreset = (preset: any) => { const f = preset.filters; setDateFilterBy(f.dateFilterBy || ''); setDateStart(f.dateStart || ''); setDateEnd(f.dateEnd || ''); setSortBy(f.sortBy || 'creation desc'); setFilterItemId(f.filterItemId || ''); setFilterItemGroup(f.filterItemGroup || ''); setFilterCompany(f.filterCompany || ''); setFilterItemName(f.filterItemName || ''); setTempItemName(f.filterItemName || ''); }; // Delete filter preset const handleDeleteFilterPreset = (id: number) => { const updated = savedFilters.filter(f => f.id !== id); setSavedFilters(updated); localStorage.setItem('itemFilterPresets', JSON.stringify(updated)); }; // Cleanup timeouts on unmount useEffect(() => { return () => { if (itemNameDebounceRef.current) clearTimeout(itemNameDebounceRef.current); }; }, []); // Clear all filters const handleClearFilters = () => { setDateFilterBy(''); setDateStart(''); setDateEnd(''); setSortBy('creation desc'); setFilterItemId(''); setFilterItemGroup(''); setFilterCompany(''); setFilterItemName(''); setTempItemName(''); if (itemNameDebounceRef.current) clearTimeout(itemNameDebounceRef.current); setSearchParams((prev) => { const next = new URLSearchParams(prev); next.delete('date_filter_by'); next.delete('date_start'); next.delete('date_end'); next.delete('sort_by'); next.delete('item_id'); next.delete('item_group'); next.delete('company'); next.delete('item_name'); next.set('page', '1'); return next; }); }; const hasActiveFilters = hasDateFilter || filterItemId || filterItemGroup || filterCompany || filterItemName; const getDeleteStatusRowClass = (deleteStatus: string | undefined): string => { switch (deleteStatus) { case 'Delete Request With Supervisor': return 'bg-orange-50 dark:bg-orange-900/10'; case 'Delete Request With CM': return 'bg-yellow-50 dark:bg-yellow-900/10'; case 'Deleted': return 'bg-red-50 dark:bg-red-900/10'; default: return ''; } }; // Format date helper const formatDate = (dateString?: string) => { if (!dateString) return '-'; const date = new Date(dateString); return date.toLocaleString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); }; // Row selection handlers const handleSelectRow = (itemName: string) => { setSelectedRows(prev => { const newSet = new Set(prev); if (newSet.has(itemName)) { newSet.delete(itemName); } else { newSet.add(itemName); } return newSet; }); }; const handleSelectAll = () => { if (selectedRows.size === items.length) { setSelectedRows(new Set()); } else { setSelectedRows(new Set(items.map(i => i.name))); } }; const isAllSelected = items.length > 0 && selectedRows.size === items.length; const isSomeSelected = selectedRows.size > 0 && selectedRows.size < items.length; // Fetch all items for export const fetchAllItemsForExport = useCallback(async (): Promise => { const allItems: any[] = []; let currentPage = 0; const pageSize = 100; let hasMoreData = true; const filterArrays = toFrappeFilterArray(filters); while (hasMoreData) { try { const response = await fetch('/api/method/frappe.client.get_list', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ doctype: 'Item', filters: filterArrays.length > 0 ? filterArrays : {}, fields: ['*'], limit_start: currentPage * pageSize, limit_page_length: pageSize, order_by: 'creation desc' }) }); const data = await response.json(); const results = data.message || []; allItems.push(...results); if (results.length < pageSize) { hasMoreData = false; } else { currentPage++; } if (currentPage > 100) { console.warn('Export safety limit reached'); hasMoreData = false; } } catch (error) { console.error('Error fetching items for export:', error); throw error; } } return allItems; }, [filters]); // Export handler const handleExport = async (scope: ExportScope, format: ExportFormat, columns: string[]) => { setIsExporting(true); try { let dataToExport: any[] = []; switch (scope) { case 'selected': dataToExport = items.filter(i => selectedRows.has(i.name)); break; case 'all_on_page': dataToExport = items; break; case 'all_with_filters': dataToExport = await fetchAllItemsForExport(); break; } if (dataToExport.length === 0) { alert(t('assets.noDataToExport')); return; } const columnLabels = columns.map(key => { const col = EXPORT_COLUMNS.find(c => c.key === key); return col?.label || key; }); if (format === 'csv') { const csvContent = [ columnLabels.join(','), ...dataToExport.map(item => columns.map(key => { let value = item[key] || ''; if (typeof value === 'string' && (value.includes(',') || value.includes('"') || value.includes('\n'))) { value = `"${value.replace(/"/g, '""')}"`; } return value; }).join(',') ) ].join('\n'); const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = `items_export_${new Date().toISOString().split('T')[0]}.csv`; link.click(); URL.revokeObjectURL(url); } else if (format === 'excel') { const worksheetData = [ columnLabels, ...dataToExport.map(item => columns.map(key => item[key] || '') ) ]; const worksheet = XLSX.utils.aoa_to_sheet(worksheetData); const workbook = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(workbook, worksheet, 'Items'); XLSX.writeFile(workbook, `items_export_${new Date().toISOString().split('T')[0]}.xlsx`); } setShowExportModal(false); setSelectedRows(new Set()); } catch (error) { console.error('Export failed:', error); alert(`Export failed: ${error instanceof Error ? error.message : 'Unknown error'}`); } finally { setIsExporting(false); } }; const handleCreateNew = () => { navigate('/inventory/new'); }; // const handleView = (itemName: string) => { // navigate(`/inventory/${itemName}`); // }; const handleView = (itemName: string) => { navigate(`/inventory/${encodeURIComponent(itemName)}`); }; // const handleEdit = (itemName: string) => { // navigate(`/inventory/${itemName}`); // }; const handleEdit = (itemName: string) => { navigate(`/inventory/${encodeURIComponent(itemName)}`); }; const handleDelete = async (itemName: string) => { try { await deleteItem(itemName); setDeleteConfirmOpen(null); refetch(); alert('Item deleted successfully!'); } catch (err) { alert(`Failed to delete: ${err instanceof Error ? err.message : 'Unknown error'}`); } }; // const handleDuplicate = (itemName: string) => { // navigate(`/inventory/new?duplicate=${itemName}`); // }; const handleDuplicate = (itemName: string) => { navigate(`/inventory/new?duplicate=${encodeURIComponent(itemName)}`); }; // Only show full-page loader on initial load if (loading && !initialLoadComplete) { return (

{t('items.loadingItems')}

); } if (error) { return (

{t('items.errorLoadingItems')}

{t('items.failedToLoadItems')}

Technical Error: {error}

); } return (
{/* Header */}

{t('items.listTitle')}

{t('items.listTotal', { count: totalCount })} {/* Show selection count */} {selectedRows.size > 0 && ( • {selectedRows.size} {t('common.selected')} )} {/* Show loading indicator inline when filtering */} {loading && initialLoadComplete && (

{t('common.updating')} )}

{/* Export Button */}
{/* Advanced Filter Panel */}
{/* Filter Header */}
{/* Left Side - Toggle & Title */}

{t('listPages.filters')}

{activeFilterCount > 0 && ( {activeFilterCount} )}
{/* Center - Active Filter Tags (scrollable) */} {hasActiveFilters && (
{hasDateFilter && ( {t('filters.filterBy')}: {dateFilterBy === 'creation' ? t('filters.createdDate') : t('filters.latestModifiedDate')} {dateStart && ` ${dateStart}`} {dateEnd && ` - ${dateEnd}`} )} {filterItemId && ( Item ID: {filterItemId} )} {filterItemName && ( Name: {filterItemName} )} {filterCompany && ( Hospital: {filterCompany} )} {filterItemGroup && ( Item Group: {filterItemGroup} )}
)} {/* Right Side - Action Buttons */}
{/* Save Filter Button */} {activeFilterCount > 0 && ( )} {/* Clear All Button */} {hasActiveFilters && ( )}
{/* Expandable Filter Content */} {isFilterExpanded && (
{/* Saved Filter Presets */} {savedFilters.length > 0 && (

{t('listPages.savedFilters')}

{savedFilters.map((preset) => (
))}
)} {/* Filter Grid */}
{/* Sort By */}
{dateFilterBy && ( <>
{ const v = e.target.value; setDateStart(v); if (dateEnd && v > dateEnd) setDateEnd(v); }} className="w-full px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
setDateEnd(e.target.value)} min={dateStart || undefined} className="w-full px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
)} {/* Item ID */}
setFilterItemId(val)} placeholder={t('linkField.selectLabel', { label: t('items.itemId') })} disabled={false} compact={true} filters={{ custom_delete_status: ['!=', 'Deleted'] }} /> {filterItemId && ( )}
{/* Item Name */}
handleItemNameChange(e.target.value)} onKeyDown={handleKeyPress} placeholder={t('common.typeToSearch')} className="w-full px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" /> {tempItemName && tempItemName !== filterItemName && ( {t('common.typing')} )} {filterItemName && ( )}
{/* Hospital */}
setFilterCompany(val)} placeholder={t('linkField.selectLabel', { label: t('items.selectHospital') })} disabled={false} compact={true} filters={{ domain: 'Healthcare' }} /> {filterCompany && ( )}
{/* Item Group */}
setFilterItemGroup(val)} placeholder={t('linkField.selectLabel', { label: t('items.itemGroup') })} disabled={false} compact={true} /> {filterItemGroup && ( )}
)}
{/* Save Filter Modal */} {showSaveFilterModal && (

{t('common.saveFilterPreset')}

setFilterPresetName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleSaveFilterPreset(); } }} placeholder={t('common.enterFilterNameExample')} className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white mb-4" autoFocus />
)} {/* Export Modal */} setShowExportModal(false)} selectedCount={selectedRows.size} totalCount={totalCount} pageCount={items.length} onExport={handleExport} isExporting={isExporting} exportColumns={EXPORT_COLUMNS} /> {/* Items Table */}
{/* Inline loading overlay for filter changes */} {loading && initialLoadComplete && (
{t('common.filtering')}
)}
{/* Select All Checkbox Column */} {/* */} {/* */} {items.length === 0 ? ( ) : ( items.map((item) => ( handleView(item.name)} > {/* Row Checkbox */} {/* */} {/* */} {/* Inline Action Icons */} )) )}
{t('items.itemCode')} {t('items.itemName')} {t('items.itemGroup')} {t('items.selectHospital')} {t('items.serialNo')} {t('items.dateIn')} {t('items.watts')} {t('items.volts')} {t('items.type')} {t('items.code')} {t('commonFields.modifiedOn')} {t('listPages.actions')}

{t('items.noItemsFound')}

{hasActiveFilters ? ( ) : ( )}
e.stopPropagation()}>
{item.item_code || item.name}
{item.item_name || '-'}
{item.item_group || '-'}
{item.custom_hospital_name || '-'}
{item.custom_serial_no || '-'}
{item.custom_date_in ? formatDate(item.custom_date_in) : '-'}
{item.custom_w ?? '-'}
{item.custom_volts ?? '-'}
{item.custom_type || '-'}
{item.custom_code || '-'}
{formatDate(item.modified)}
e.stopPropagation()}> {/* */}
e.stopPropagation()}> refetch()} />
{/* Pagination */} {/* setPage(Math.max(0, p - 1))} /> */} setPage(Math.max(0, p - 1))} />
{/* Delete Confirmation Modal */} {deleteConfirmOpen && (

{t('items.deleteItem')}

{t('items.deleteConfirmMessage')}

{t('items.itemId')}: {deleteConfirmOpen}

)}
); }; export default ItemList;