1571 lines
68 KiB
TypeScript
1571 lines
68 KiB
TypeScript
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<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('items.export.title')}</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">
|
|
{t('items.export.selectData')}
|
|
</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">
|
|
{t('items.export.selectedRows')}
|
|
</div>
|
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
|
{t('items.export.selectedCount', { count: selectedCount })}
|
|
</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} {t('common.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">
|
|
{t('items.export.currentPage')}
|
|
</div>
|
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
|
{t('items.export.currentPageCount', { count: pageCount })}
|
|
</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} {t('common.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">
|
|
{t('items.export.allWithFilters')}
|
|
</div>
|
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
|
{t('items.export.allWithFiltersCount', { count: totalCount })}
|
|
</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} {t('common.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">
|
|
{t('listPages.exportFormat')}
|
|
</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">{t('listPages.csv')}</div>
|
|
<div className="text-xs text-gray-500 dark:text-gray-400">{t('items.export.csvDesc')}</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">{t('listPages.excel')}</div>
|
|
<div className="text-xs text-gray-500 dark:text-gray-400">{t('items.export.excelDesc')}</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">
|
|
{t('items.export.columnsToExport')}
|
|
</h4>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={selectAllColumns}
|
|
className="text-xs text-blue-600 dark:text-blue-400 hover:underline"
|
|
>
|
|
{t('items.export.selectAll')}
|
|
</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"
|
|
>
|
|
{t('items.export.resetToDefault')}
|
|
</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">
|
|
{t('items.export.columnsSelected', { count: selectedColumns.length })}
|
|
</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' && 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 })}
|
|
</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}
|
|
>
|
|
{t('common.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>
|
|
{t('items.export.exporting')}
|
|
</>
|
|
) : (
|
|
<>
|
|
<FaDownload />
|
|
{t('items.export.exportButton')}
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
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<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: '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<Set<string>>(new Set());
|
|
|
|
// Export modal state
|
|
const [showExportModal, setShowExportModal] = useState(false);
|
|
const [isExporting, setIsExporting] = useState(false);
|
|
|
|
const [userRoles, setUserRoles] = useState<string[]>([]);
|
|
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<string>(() => searchParams.get('sort_by') || 'creation desc');
|
|
|
|
// 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 (item_name init from URL for persistence)
|
|
const [tempItemName, setTempItemName] = useState(() => searchParams.get('item_name') || '');
|
|
|
|
// 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));
|
|
}
|
|
}, []);
|
|
|
|
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<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}%`];
|
|
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<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: {
|
|
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<any[]> => {
|
|
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 (
|
|
<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('items.loadingItems')}</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">{t('items.errorLoadingItems')}</h2>
|
|
<div className="text-red-700 dark:text-red-400 space-y-3">
|
|
<p><strong>{t('items.failedToLoadItems')}</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"
|
|
>
|
|
{t('items.addItem')}
|
|
</button>
|
|
<button
|
|
onClick={refetch}
|
|
className="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded"
|
|
>
|
|
{t('common.tryAgain')}
|
|
</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">{t('items.listTitle')}</h1>
|
|
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
|
{t('items.listTotal', { count: totalCount })}
|
|
{/* Show selection count */}
|
|
{selectedRows.size > 0 && (
|
|
<span className="ml-2 text-blue-600 dark:text-blue-400">
|
|
• {selectedRows.size} {t('common.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>
|
|
{t('common.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">{t('items.listAddItem')}</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">
|
|
{hasDateFilter && (
|
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/90 text-indigo-700 rounded-full text-[10px] font-medium whitespace-nowrap shadow-sm">
|
|
<span className="font-semibold">{t('filters.filterBy')}:</span> {dateFilterBy === 'creation' ? t('filters.createdDate') : t('filters.latestModifiedDate')} {dateStart && ` ${dateStart}`} {dateEnd && ` - ${dateEnd}`}
|
|
<button onClick={(e) => { e.stopPropagation(); setDateFilterBy(''); setDateStart(''); setDateEnd(''); }} className="hover:text-red-500 transition-colors"><FaTimes className="text-[9px]" /></button>
|
|
</span>
|
|
)}
|
|
{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">{t('listPages.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} />
|
|
{t('listPages.savedFilters')}
|
|
</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">
|
|
{/* Sort By */}
|
|
<div className="relative">
|
|
<label className="block text-[10px] font-medium text-gray-700 dark:text-gray-300 mb-0.5">{t('filters.sortBy')}</label>
|
|
<select
|
|
value={sortBy}
|
|
onChange={(e) => setSortBy(e.target.value)}
|
|
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"
|
|
>
|
|
<option value="creation desc">{t('filters.sortCreationNewest')}</option>
|
|
<option value="creation asc">{t('filters.sortCreationOldest')}</option>
|
|
<option value="modified desc">{t('filters.sortModifiedNewest')}</option>
|
|
<option value="modified asc">{t('filters.sortModifiedOldest')}</option>
|
|
<option value="item_name asc">{t('filters.sortItemNameAsc')}</option>
|
|
<option value="item_name desc">{t('filters.sortItemNameDesc')}</option>
|
|
<option value="name asc">{t('filters.sortNameAsc')}</option>
|
|
<option value="name desc">{t('filters.sortNameDesc')}</option>
|
|
</select>
|
|
</div>
|
|
<div className="relative">
|
|
<label className="block text-[10px] font-medium text-gray-700 dark:text-gray-300 mb-0.5">{t('filters.filterBy')}</label>
|
|
<select value={dateFilterBy} onChange={(e) => { const v = e.target.value as '' | 'creation' | 'modified'; setDateFilterBy(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">
|
|
<option value="">{t('filters.filterBy')}</option>
|
|
<option value="creation">{t('filters.createdDate')}</option>
|
|
<option value="modified">{t('filters.latestModifiedDate')}</option>
|
|
</select>
|
|
</div>
|
|
{dateFilterBy && (
|
|
<>
|
|
<div className="relative">
|
|
<label className="block text-[10px] font-medium text-gray-700 dark:text-gray-300 mb-0.5">{t('filters.startDate')}</label>
|
|
<input type="date" value={dateStart} onChange={(e) => { 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" />
|
|
</div>
|
|
<div className="relative">
|
|
<label className="block text-[10px] font-medium text-gray-700 dark:text-gray-300 mb-0.5">{t('filters.endDate')}</label>
|
|
<input type="date" value={dateEnd} onChange={(e) => 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" />
|
|
</div>
|
|
</>
|
|
)}
|
|
{/* Item ID */}
|
|
<div className="relative z-[60]">
|
|
<LinkField
|
|
label={t('items.itemId')}
|
|
doctype="Item"
|
|
value={filterItemId}
|
|
onChange={(val) => setFilterItemId(val)}
|
|
placeholder={t('linkField.selectLabel', { label: t('items.itemId') })}
|
|
disabled={false}
|
|
compact={true}
|
|
filters={{ custom_delete_status: ['!=', 'Deleted'] }}
|
|
/>
|
|
{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">
|
|
{t('items.itemName')}
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={tempItemName}
|
|
onChange={(e) => 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 && (
|
|
<span className="absolute right-2 top-6 text-[9px] text-blue-500 animate-pulse">
|
|
{t('common.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={t('items.selectHospital')}
|
|
doctype="Company"
|
|
value={filterCompany}
|
|
onChange={(val) => setFilterCompany(val)}
|
|
placeholder={t('linkField.selectLabel', { label: t('items.selectHospital') })}
|
|
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={t('items.itemGroup')}
|
|
doctype="Item Group"
|
|
value={filterItemGroup}
|
|
onChange={(val) => setFilterItemGroup(val)}
|
|
placeholder={t('linkField.selectLabel', { label: t('items.itemGroup') })}
|
|
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">
|
|
{t('common.saveFilterPreset')}
|
|
</h3>
|
|
<input
|
|
type="text"
|
|
value={filterPresetName}
|
|
onChange={(e) => 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
|
|
/>
|
|
<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"
|
|
>
|
|
{t('common.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} />
|
|
{t('common.saveFilter')}
|
|
</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">
|
|
{t('common.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">
|
|
{t('items.itemCode')}
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
{t('items.itemName')}
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
{t('items.itemGroup')}
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
{t('items.selectHospital')}
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
{t('items.serialNo')}
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
{t('items.dateIn')}
|
|
</th>
|
|
{/* <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
{t('items.watts')}
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
{t('items.volts')}
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
{t('items.type')}
|
|
</th> */}
|
|
{/* <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
{t('items.code')}
|
|
</th> */}
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
{t('commonFields.modifiedOn')}
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
{t('listPages.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={13} 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>{t('items.noItemsFound')}</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"
|
|
>
|
|
{t('common.clearFilters')}
|
|
</button>
|
|
) : (
|
|
<button
|
|
onClick={handleCreateNew}
|
|
className="mt-4 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline"
|
|
>
|
|
{t('items.createFirstItem')}
|
|
</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'
|
|
: getDeleteStatusRowClass(item.custom_delete_status)
|
|
}`}
|
|
title={item.custom_delete_status ? `Delete Status: ${item.custom_delete_status}` : undefined}
|
|
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">
|
|
{item.custom_serial_no || '-'}
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<div className="text-sm text-gray-700 dark:text-gray-300">
|
|
{item.custom_date_in ? formatDate(item.custom_date_in) : '-'}
|
|
</div>
|
|
</td>
|
|
{/* <td className="px-6 py-4 whitespace-nowrap">
|
|
<div className="text-sm text-gray-700 dark:text-gray-300">
|
|
{item.custom_w ?? '-'}
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<div className="text-sm text-gray-700 dark:text-gray-300">
|
|
{item.custom_volts ?? '-'}
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<div className="text-sm text-gray-700 dark:text-gray-300">
|
|
{item.custom_type || '-'}
|
|
</div>
|
|
</td> */}
|
|
{/* <td className="px-6 py-4 whitespace-nowrap">
|
|
<div className="text-sm text-gray-700 dark:text-gray-300">
|
|
{item.custom_code || '-'}
|
|
</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={t('items.deleteItem')}
|
|
disabled={mutationLoading}
|
|
>
|
|
<FaTrash />
|
|
</button> */}
|
|
|
|
<div onClick={(e) => e.stopPropagation()}>
|
|
<DeleteRequestButton
|
|
doctype="Item"
|
|
docname={item.name}
|
|
currentDeleteStatus={(item.custom_delete_status ?? null) as DeleteStatus}
|
|
userRoles={userRoles}
|
|
isSystemManager={listIsSystemManager}
|
|
triggerMode
|
|
redirectOnDelete="/inventory"
|
|
onStatusChange={() => refetch()}
|
|
/>
|
|
</div>
|
|
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Pagination */}
|
|
{/* <ListPagination
|
|
currentPage={page + 1}
|
|
totalCount={0}
|
|
pageSize={limit}
|
|
hasMore={hasMore}
|
|
itemLabel={t('pagination.items')}
|
|
onPageChange={(p) => setPage(Math.max(0, p - 1))}
|
|
/> */}
|
|
|
|
<ListPagination
|
|
currentPage={page + 1}
|
|
totalCount={totalCount}
|
|
pageSize={limit}
|
|
hasMore={hasMore}
|
|
itemLabel={t('pagination.items')}
|
|
onPageChange={(p) => setPage(Math.max(0, p - 1))}
|
|
/>
|
|
</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">
|
|
{t('items.deleteItem')}
|
|
</h3>
|
|
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
|
{t('items.deleteConfirmMessage')}
|
|
</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>{t('items.itemId')}:</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}
|
|
>
|
|
{t('common.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>
|
|
{t('common.deleting')}
|
|
</>
|
|
) : (
|
|
<>
|
|
<FaTrash />
|
|
{t('items.deleteItem')}
|
|
</>
|
|
)}
|
|
</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;
|