Iman_AMS/asm_app/src/pages/ItemList.tsx

1358 lines
55 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate } 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';
// 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<ExportModalProps> = ({
isOpen,
onClose,
selectedCount,
totalCount,
pageCount,
onExport,
isExporting,
exportColumns
}) => {
const { t } = useTranslation();
const [scope, setScope] = useState<ExportScope>(selectedCount > 0 ? 'selected' : 'all_with_filters');
const [format, setFormat] = useState<ExportFormat>('csv');
const [selectedColumns, setSelectedColumns] = useState<string[]>(
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 (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[70] p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-hidden animate-scale-in">
{/* Header */}
<div className="bg-gradient-to-r from-green-500 to-green-600 px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<FaFileExport className="text-white text-xl" />
<h3 className="text-lg font-semibold text-white">{t('listPages.export')} Items</h3>
</div>
<button
onClick={onClose}
className="text-white/80 hover:text-white transition-colors"
disabled={isExporting}
>
<FaTimes size={20} />
</button>
</div>
</div>
<div className="p-6 overflow-y-auto max-h-[calc(90vh-180px)]">
{/* Export Scope */}
<div className="mb-6">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
Select Data to Export
</h4>
<div className="space-y-2">
<label className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${
scope === 'selected'
? 'border-green-500 bg-green-50 dark:bg-green-900/20'
: 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'
} ${selectedCount === 0 ? 'opacity-50 cursor-not-allowed' : ''}`}>
<input
type="radio"
name="scope"
value="selected"
checked={scope === 'selected'}
onChange={() => setScope('selected')}
disabled={selectedCount === 0}
className="text-green-600 focus:ring-green-500"
/>
<div className="flex-1">
<div className="font-medium text-gray-900 dark:text-white">
Selected Rows
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
Export {selectedCount} selected item{selectedCount !== 1 ? 's' : ''}
</div>
</div>
{selectedCount > 0 && (
<span className="bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300 px-2 py-1 rounded text-xs font-medium">
{selectedCount} selected
</span>
)}
</label>
<label className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${
scope === 'all_on_page'
? 'border-green-500 bg-green-50 dark:bg-green-900/20'
: 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'
}`}>
<input
type="radio"
name="scope"
value="all_on_page"
checked={scope === 'all_on_page'}
onChange={() => setScope('all_on_page')}
className="text-green-600 focus:ring-green-500"
/>
<div className="flex-1">
<div className="font-medium text-gray-900 dark:text-white">
Current Page
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
Export {pageCount} item{pageCount !== 1 ? 's' : ''} on current page
</div>
</div>
<span className="bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300 px-2 py-1 rounded text-xs font-medium">
{pageCount} rows
</span>
</label>
<label className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${
scope === 'all_with_filters'
? 'border-green-500 bg-green-50 dark:bg-green-900/20'
: 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'
}`}>
<input
type="radio"
name="scope"
value="all_with_filters"
checked={scope === 'all_with_filters'}
onChange={() => setScope('all_with_filters')}
className="text-green-600 focus:ring-green-500"
/>
<div className="flex-1">
<div className="font-medium text-gray-900 dark:text-white">
All Records (with current filters)
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
Export all {totalCount} item{totalCount !== 1 ? 's' : ''} matching current filters
</div>
</div>
<span className="bg-purple-100 dark:bg-purple-900/40 text-purple-700 dark:text-purple-300 px-2 py-1 rounded text-xs font-medium">
{totalCount} total
</span>
</label>
</div>
</div>
{/* Export Format */}
<div className="mb-6">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
Export Format
</h4>
<div className="flex gap-3">
<label className={`flex-1 flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${
format === 'csv'
? 'border-green-500 bg-green-50 dark:bg-green-900/20'
: 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'
}`}>
<input
type="radio"
name="format"
value="csv"
checked={format === 'csv'}
onChange={() => setFormat('csv')}
className="text-green-600 focus:ring-green-500"
/>
<FaFileCsv className="text-green-600 text-xl" />
<div>
<div className="font-medium text-gray-900 dark:text-white">CSV</div>
<div className="text-xs text-gray-500 dark:text-gray-400">Comma-separated values</div>
</div>
</label>
<label className={`flex-1 flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${
format === 'excel'
? 'border-green-500 bg-green-50 dark:bg-green-900/20'
: 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'
}`}>
<input
type="radio"
name="format"
value="excel"
checked={format === 'excel'}
onChange={() => setFormat('excel')}
className="text-green-600 focus:ring-green-500"
/>
<FaFileExcel className="text-green-700 text-xl" />
<div>
<div className="font-medium text-gray-900 dark:text-white">Excel</div>
<div className="text-xs text-gray-500 dark:text-gray-400">XLSX spreadsheet</div>
</div>
</label>
</div>
</div>
{/* Column Selection */}
<div>
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300">
Columns to Export
</h4>
<div className="flex gap-2">
<button
onClick={selectAllColumns}
className="text-xs text-blue-600 dark:text-blue-400 hover:underline"
>
Select All
</button>
<span className="text-gray-300 dark:text-gray-600">|</span>
<button
onClick={selectDefaultColumns}
className="text-xs text-blue-600 dark:text-blue-400 hover:underline"
>
Reset to Default
</button>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 max-h-48 overflow-y-auto p-3 bg-gray-50 dark:bg-gray-900/50 rounded-lg">
{exportColumns.map((col) => (
<label
key={col.key}
className={`flex items-center gap-2 p-2 rounded cursor-pointer transition-all ${
selectedColumns.includes(col.key)
? 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300'
: 'hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-400'
}`}
>
<input
type="checkbox"
checked={selectedColumns.includes(col.key)}
onChange={() => toggleColumn(col.key)}
className="rounded text-green-600 focus:ring-green-500"
/>
<span className="text-sm truncate">{col.label}</span>
</label>
))}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
{selectedColumns.length} column{selectedColumns.length !== 1 ? 's' : ''} selected
</p>
</div>
</div>
{/* Footer */}
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-200 dark:border-gray-700 flex justify-between items-center">
<div className="text-sm text-gray-600 dark:text-gray-400">
{scope === 'selected' && `Exporting ${selectedCount} selected row${selectedCount !== 1 ? 's' : ''}`}
{scope === 'all_on_page' && `Exporting ${pageCount} row${pageCount !== 1 ? 's' : ''} from current page`}
{scope === 'all_with_filters' && `Exporting all ${totalCount} row${totalCount !== 1 ? 's' : ''}`}
</div>
<div className="flex gap-3">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
disabled={isExporting}
>
Cancel
</button>
<button
onClick={() => onExport(scope, format, selectedColumns)}
disabled={selectedColumns.length === 0 || isExporting}
className="px-4 py-2 text-sm font-medium text-white bg-green-600 hover:bg-green-700 rounded-lg transition-colors flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isExporting ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
Exporting...
</>
) : (
<>
<FaDownload />
Export
</>
)}
</button>
</div>
</div>
</div>
</div>
);
};
const ItemList: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [page, setPage] = useState(0);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState<string | null>(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: '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<Set<string>>(new Set());
// Export modal state
const [showExportModal, setShowExportModal] = useState(false);
const [isExporting, setIsExporting] = useState(false);
// Filter states
const [filterItemId, setFilterItemId] = useState('');
const [filterItemGroup, setFilterItemGroup] = useState('');
const [filterCompany, setFilterCompany] = useState('');
const [filterItemName, setFilterItemName] = useState('');
// Advanced filter UI states
const [isFilterExpanded, setIsFilterExpanded] = useState(true);
const [activeFilterCount, setActiveFilterCount] = useState(0);
const [savedFilters, setSavedFilters] = useState<any[]>([]);
const [showSaveFilterModal, setShowSaveFilterModal] = useState(false);
const [filterPresetName, setFilterPresetName] = useState('');
// Temporary states for text inputs
const [tempItemName, setTempItemName] = useState('');
// Debounce timer refs
const itemNameDebounceRef = useRef<number | null>(null);
// Load saved filters from localStorage on mount
useEffect(() => {
const saved = localStorage.getItem('itemFilterPresets');
if (saved) {
setSavedFilters(JSON.parse(saved));
}
}, []);
// Update active filter count
useEffect(() => {
const count = [
filterItemId,
filterItemGroup,
filterCompany,
filterItemName
].filter(Boolean).length;
setActiveFilterCount(count);
}, [filterItemId, filterItemGroup, filterCompany, filterItemName]);
// Build filters object
const filters: Record<string, any> = {};
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}%`];
const { items, totalCount, hasMore, loading, error, refetch } = useItems(
Object.keys(filters).length > 0 ? filters : undefined,
limit,
page * limit,
'creation desc'
);
const { deleteItem, loading: mutationLoading } = useItemMutations();
// Mark initial load complete
useEffect(() => {
if (!loading && !initialLoadComplete) {
setInitialLoadComplete(true);
}
}, [loading, initialLoadComplete]);
// Reset page when filters change
useEffect(() => {
if (page !== 0) {
setPage(0);
}
}, [filterItemId, filterItemGroup, filterCompany, filterItemName]);
// Clear selection when filters/page change
useEffect(() => {
setSelectedRows(new Set());
}, [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<HTMLInputElement>) => {
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: {
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;
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 = () => {
setFilterItemId('');
setFilterItemGroup('');
setFilterCompany('');
setFilterItemName('');
setTempItemName('');
if (itemNameDebounceRef.current) clearTimeout(itemNameDebounceRef.current);
};
const hasActiveFilters = filterItemId || filterItemGroup || filterCompany || filterItemName;
// 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<any[]> => {
const allItems: any[] = [];
let currentPage = 0;
const pageSize = 100;
let hasMoreData = true;
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: filters,
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('No data to export');
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 handleEdit = (itemName: string) => {
navigate(`/inventory/${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}`);
};
// Only show full-page loader on initial load
if (loading && !initialLoadComplete) {
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">Loading items...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-6">
<h2 className="text-xl font-bold text-red-800 dark:text-red-300 mb-4"> Error Loading Items</h2>
<div className="text-red-700 dark:text-red-400 space-y-3">
<p><strong>Failed to load items.</strong></p>
<div className="mt-4 flex gap-3">
<button
onClick={() => navigate('/inventory/new')}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded"
>
Create New Item
</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-red-300 dark:border-red-700">
<p className="text-sm text-gray-600 dark:text-gray-400">
<strong>Technical Error:</strong> {error}
</p>
</div>
</div>
</div>
);
}
return (
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
{/* Header */}
<div className="mb-6 flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-800 dark:text-white">Inventory</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Total: {totalCount} item{totalCount !== 1 ? 's' : ''}
{/* Show selection count */}
{selectedRows.size > 0 && (
<span className="ml-2 text-blue-600 dark:text-blue-400">
{selectedRows.size} selected
</span>
)}
{/* Show loading indicator inline when filtering */}
{loading && initialLoadComplete && (
<span className="ml-2 inline-flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400">
<div className="animate-spin rounded-full h-3 w-3 border-b-2 border-blue-500"></div>
Updating...
</span>
)}
</p>
</div>
<div className="flex gap-3">
{/* Export Button */}
<button
onClick={() => setShowExportModal(true)}
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow transition-all"
disabled={totalCount === 0}
>
<FaFileExport />
<span className="font-medium">{t('listPages.export')}</span>
{selectedRows.size > 0 && (
<span className="bg-white/20 px-1.5 py-0.5 rounded text-xs">
{selectedRows.size}
</span>
)}
</button>
<button
onClick={handleCreateNew}
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg flex items-center gap-2 shadow-lg transition-all hover:shadow-xl"
>
<FaPlus />
<span className="font-medium">Add Item</span>
</button>
</div>
</div>
{/* Advanced Filter Panel */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 mb-4">
{/* Filter Header */}
<div className="bg-gradient-to-r from-blue-500 to-blue-600 dark:from-blue-600 dark:to-blue-700 px-4 py-3 rounded-t-lg">
<div className="flex items-center justify-between gap-4">
{/* Left Side - Toggle & Title */}
<div className="flex items-center gap-3 flex-shrink-0">
<button
onClick={() => setIsFilterExpanded(!isFilterExpanded)}
className="text-white hover:bg-white/20 p-2 rounded-lg transition-all"
>
{isFilterExpanded ? <FaChevronUp size={14} /> : <FaChevronDown size={14} />}
</button>
<div className="flex items-center gap-2">
<FaFilter className="text-white" size={16} />
<h3 className="text-white font-semibold text-sm">{t('listPages.filters')}</h3>
</div>
{activeFilterCount > 0 && (
<span className="bg-white text-blue-600 px-2 py-0.5 rounded-full text-xs font-bold">
{activeFilterCount}
</span>
)}
</div>
{/* Center - Active Filter Tags (scrollable) */}
{hasActiveFilters && (
<div className="flex-1 overflow-x-auto scrollbar-hide mx-2">
<div className="flex items-center gap-2 py-1">
{filterItemId && (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/90 text-blue-700 rounded-full text-[10px] font-medium whitespace-nowrap shadow-sm">
<span className="font-semibold">Item ID:</span> {filterItemId}
<button
onClick={(e) => { e.stopPropagation(); setFilterItemId(''); }}
className="hover:text-red-500 transition-colors"
>
<FaTimes className="text-[9px]" />
</button>
</span>
)}
{filterItemName && (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/90 text-cyan-700 rounded-full text-[10px] font-medium whitespace-nowrap shadow-sm">
<span className="font-semibold">Name:</span> {filterItemName}
<button
onClick={(e) => { e.stopPropagation(); setFilterItemName(''); setTempItemName(''); }}
className="hover:text-red-500 transition-colors"
>
<FaTimes className="text-[9px]" />
</button>
</span>
)}
{filterCompany && (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/90 text-green-700 rounded-full text-[10px] font-medium whitespace-nowrap shadow-sm">
<span className="font-semibold">Hospital:</span> {filterCompany}
<button
onClick={(e) => { e.stopPropagation(); setFilterCompany(''); }}
className="hover:text-red-500 transition-colors"
>
<FaTimes className="text-[9px]" />
</button>
</span>
)}
{filterItemGroup && (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/90 text-purple-700 rounded-full text-[10px] font-medium whitespace-nowrap shadow-sm">
<span className="font-semibold">Item Group:</span> {filterItemGroup}
<button
onClick={(e) => { e.stopPropagation(); setFilterItemGroup(''); }}
className="hover:text-red-500 transition-colors"
>
<FaTimes className="text-[9px]" />
</button>
</span>
)}
</div>
</div>
)}
{/* Right Side - Action Buttons */}
<div className="flex items-center gap-2 flex-shrink-0">
{/* Save Filter Button */}
{activeFilterCount > 0 && (
<button
onClick={() => setShowSaveFilterModal(true)}
className="px-3 py-1.5 bg-white text-blue-600 hover:bg-blue-50 rounded-md text-xs font-medium transition-all flex items-center gap-1.5"
>
<FaSave size={12} />
<span className="hidden sm:inline">Save</span>
</button>
)}
{/* Clear All Button */}
{hasActiveFilters && (
<button
onClick={handleClearFilters}
className="px-3 py-1.5 bg-red-500 hover:bg-red-600 text-white rounded-md text-xs font-medium transition-all flex items-center gap-1.5"
>
<FaTimes size={12} />
<span className="hidden sm:inline">Clear</span>
</button>
)}
</div>
</div>
</div>
{/* Expandable Filter Content */}
{isFilterExpanded && (
<div className="p-4">
{/* Saved Filter Presets */}
{savedFilters.length > 0 && (
<div className="mb-4 pb-4 border-b border-gray-200 dark:border-gray-700">
<h4 className="text-xs font-semibold text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-2">
<FaStar className="text-yellow-500" size={12} />
Saved Filters
</h4>
<div className="flex flex-wrap gap-2">
{savedFilters.map((preset) => (
<div
key={preset.id}
className="group relative inline-flex items-center gap-2 px-3 py-1.5 bg-gradient-to-r from-purple-100 to-blue-100 dark:from-purple-900/30 dark:to-blue-900/30 border border-purple-200 dark:border-purple-700 rounded-lg hover:shadow-md transition-all"
>
<button
onClick={() => handleLoadFilterPreset(preset)}
className="text-xs font-medium text-purple-700 dark:text-purple-300"
>
{preset.name}
</button>
<button
onClick={() => handleDeleteFilterPreset(preset.id)}
className="opacity-0 group-hover:opacity-100 text-red-500 hover:text-red-700 transition-opacity"
>
<FaTrash size={10} />
</button>
</div>
))}
</div>
</div>
)}
{/* Filter Grid */}
<div className="space-y-5">
<div className="bg-gray-50 dark:bg-gray-900/50 p-3 rounded-lg">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
{/* Item ID */}
<div className="relative z-[60]">
<LinkField
label="Item"
doctype="Item"
value={filterItemId}
onChange={(val) => setFilterItemId(val)}
placeholder="Select Item"
disabled={false}
compact={true}
/>
{filterItemId && (
<button
onClick={() => setFilterItemId('')}
className="absolute right-2 top-6 text-gray-400 hover:text-red-500 transition-colors z-10"
>
<FaTimes size={10} />
</button>
)}
</div>
{/* Item Name */}
<div className="relative">
<label className="block text-[10px] font-medium text-gray-700 dark:text-gray-300 mb-0.5">
Item Name
</label>
<input
type="text"
value={tempItemName}
onChange={(e) => handleItemNameChange(e.target.value)}
onKeyDown={handleKeyPress}
placeholder="Type to search..."
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 && (
<span className="absolute right-2 top-6 text-[9px] text-blue-500 animate-pulse">
typing...
</span>
)}
{filterItemName && (
<button
onClick={() => {
setFilterItemName('');
setTempItemName('');
}}
className="absolute right-2 top-6 text-gray-400 hover:text-red-500 transition-colors"
>
<FaTimes size={10} />
</button>
)}
</div>
{/* Hospital */}
<div className="relative z-[59]">
<LinkField
label="Hospital"
doctype="Company"
value={filterCompany}
onChange={(val) => setFilterCompany(val)}
placeholder="Select Hospital"
disabled={false}
compact={true}
filters={{ domain: 'Healthcare' }}
/>
{filterCompany && (
<button
onClick={() => setFilterCompany('')}
className="absolute right-2 top-6 text-gray-400 hover:text-red-500 transition-colors z-10"
>
<FaTimes size={10} />
</button>
)}
</div>
{/* Item Group */}
<div className="relative z-[58]">
<LinkField
label="Item Group"
doctype="Item Group"
value={filterItemGroup}
onChange={(val) => setFilterItemGroup(val)}
placeholder="Select Item Group"
disabled={false}
compact={true}
/>
{filterItemGroup && (
<button
onClick={() => setFilterItemGroup('')}
className="absolute right-2 top-6 text-gray-400 hover:text-red-500 transition-colors z-10"
>
<FaTimes size={10} />
</button>
)}
</div>
</div>
</div>
</div>
</div>
)}
</div>
{/* Save Filter Modal */}
{showSaveFilterModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full p-6 animate-scale-in">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Save Filter Preset
</h3>
<input
type="text"
value={filterPresetName}
onChange={(e) => setFilterPresetName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSaveFilterPreset();
}
}}
placeholder="Enter filter name (e.g., 'Medical Supplies')"
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
/>
<div className="flex gap-2 justify-end">
<button
onClick={() => {
setShowSaveFilterModal(false);
setFilterPresetName('');
}}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors"
>
Cancel
</button>
<button
onClick={handleSaveFilterPreset}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors flex items-center gap-2"
>
<FaSave size={12} />
Save Filter
</button>
</div>
</div>
</div>
)}
{/* Export Modal */}
<ExportModal
isOpen={showExportModal}
onClose={() => setShowExportModal(false)}
selectedCount={selectedRows.size}
totalCount={totalCount}
pageCount={items.length}
onExport={handleExport}
isExporting={isExporting}
exportColumns={EXPORT_COLUMNS}
/>
{/* Items Table */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden relative">
{/* Inline loading overlay for filter changes */}
{loading && initialLoadComplete && (
<div className="absolute inset-0 bg-white/60 dark:bg-gray-800/60 flex items-center justify-center z-10 backdrop-blur-[1px]">
<div className="flex items-center gap-3 bg-white dark:bg-gray-700 px-4 py-2 rounded-lg shadow-lg">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-500"></div>
<span className="text-sm text-gray-600 dark:text-gray-300">Filtering...</span>
</div>
</div>
)}
<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>
{/* Select All Checkbox Column */}
<th className="px-4 py-3 text-left">
<button
onClick={handleSelectAll}
className="text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
title={isAllSelected ? "Deselect all" : "Select all"}
>
{isAllSelected ? (
<FaCheckSquare className="text-blue-600 dark:text-blue-400" size={18} />
) : isSomeSelected ? (
<div className="relative">
<FaSquare size={18} />
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-2 h-0.5 bg-current"></div>
</div>
</div>
) : (
<FaSquare size={18} />
)}
</button>
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Item Code
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Item Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Item Group
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Hospital
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Updated On
</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">
{items.length === 0 ? (
<tr>
<td colSpan={7} className="px-6 py-12 text-center text-gray-500 dark:text-gray-400">
<div className="flex flex-col items-center">
<FaBox className="text-4xl text-gray-300 dark:text-gray-600 mb-2" />
<p>No items found</p>
{hasActiveFilters ? (
<button
onClick={handleClearFilters}
className="mt-4 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline"
>
Clear filters
</button>
) : (
<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 item
</button>
)}
</div>
</td>
</tr>
) : (
items.map((item) => (
<tr
key={item.name}
className={`hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors cursor-pointer ${
selectedRows.has(item.name) ? 'bg-blue-50 dark:bg-blue-900/20' : ''
}`}
onClick={() => handleView(item.name)}
>
{/* Row Checkbox */}
<td className="px-4 py-4" onClick={(e) => e.stopPropagation()}>
<button
onClick={() => handleSelectRow(item.name)}
className="text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
>
{selectedRows.has(item.name) ? (
<FaCheckSquare className="text-blue-600 dark:text-blue-400" size={18} />
) : (
<FaSquare size={18} />
)}
</button>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900 dark:text-white">
{item.item_code || item.name}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-700 dark:text-gray-300">
{item.item_name || '-'}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-700 dark:text-gray-300">
{item.item_group || '-'}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-700 dark:text-gray-300">
{item.custom_hospital_name || '-'}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-700 dark:text-gray-300">
{formatDate(item.modified)}
</div>
</td>
{/* Inline Action Icons */}
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<button
onClick={() => handleView(item.name)}
className="text-blue-600 dark:text-blue-400 hover:text-blue-900 dark:hover:text-blue-300 p-2 hover:bg-blue-50 dark:hover:bg-blue-900/30 rounded transition-colors"
title="View Details"
>
<FaEye />
</button>
<button
onClick={() => handleEdit(item.name)}
className="text-green-600 dark:text-green-400 hover:text-green-900 dark:hover:text-green-300 p-2 hover:bg-green-50 dark:hover:bg-green-900/30 rounded transition-colors"
title="Edit Item"
>
<FaEdit />
</button>
<button
onClick={() => handleDuplicate(item.name)}
className="text-purple-600 dark:text-purple-400 hover:text-purple-900 dark:hover:text-purple-300 p-2 hover:bg-purple-50 dark:hover:bg-purple-900/30 rounded transition-colors"
title="Duplicate Item"
>
<FaCopy />
</button>
<button
onClick={() => setDeleteConfirmOpen(item.name)}
className="text-red-600 dark:text-red-400 hover:text-red-900 dark:hover:text-red-300 p-2 hover:bg-red-50 dark:hover:bg-red-900/30 rounded transition-colors"
title="Delete Item"
disabled={mutationLoading}
>
<FaTrash />
</button>
</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 shadow-2xl">
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-12 h-12 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
<FaTrash className="text-red-600 dark:text-red-400 text-xl" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
Delete Item
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
Are you sure you want to delete this item? This action cannot be undone.
</p>
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-md p-3 mb-4">
<p className="text-xs text-yellow-800 dark:text-yellow-300">
<strong>Item ID:</strong> {deleteConfirmOpen}
</p>
</div>
<div className="flex gap-3 justify-end">
<button
onClick={() => setDeleteConfirmOpen(null)}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
disabled={mutationLoading}
>
Cancel
</button>
<button
onClick={() => handleDelete(deleteConfirmOpen)}
className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-lg transition-colors flex items-center gap-2 disabled:opacity-50"
disabled={mutationLoading}
>
{mutationLoading ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
Deleting...
</>
) : (
<>
<FaTrash />
Delete Item
</>
)}
</button>
</div>
</div>
</div>
</div>
</div>
)}
<style>{`
@keyframes scale-in {
from {
transform: scale(0.95);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
.animate-scale-in {
animation: scale-in 0.2s ease-out;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
`}</style>
</div>
);
};
export default ItemList;