import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { useIssueList } from '../hooks/useIssue'; import ListPagination from '../components/ListPagination'; import * as XLSX from 'xlsx'; import { FaPlus, FaFilter, FaSync, FaEye, FaChevronLeft, FaChevronRight, FaExclamationCircle, FaCheckCircle, FaClock, FaTimesCircle, FaHeadset, FaTimes, FaSave, FaStar, FaTrash, FaEdit, FaCheckSquare, FaSquare, FaFileExport, FaFileExcel, FaFileCsv, FaDownload, FaClipboardList } from 'react-icons/fa'; import LinkField from '../components/LinkField'; import { buildDateRangeFilters, toFrappeFilterArray } from '../utils/listFilterUtils'; import apiService from '../services/apiService'; const ROLES_CAN_CREATE_WO_FROM_ISSUE = ['Work Control', 'System Manager']; const ISSUE_STATUSES_ALLOW_WO = ['Open', 'Replied', 'On Hold']; // Export types type ExportFormat = 'csv' | 'excel'; type ExportScope = 'selected' | 'all_on_page' | 'all_with_filters'; interface ExportModalProps { isOpen: boolean; onClose: () => void; selectedCount: number; totalCount: number; pageCount: number; onExport: (scope: ExportScope, format: ExportFormat, columns: string[]) => void; isExporting: boolean; exportColumns: Array<{key: string, label: string, default: boolean}>; } const ExportModal: React.FC = ({ isOpen, onClose, selectedCount, totalCount, pageCount, onExport, isExporting, exportColumns }) => { const { t } = useTranslation(); const [scope, setScope] = useState(selectedCount > 0 ? 'selected' : 'all_with_filters'); const [format, setFormat] = useState('csv'); const [selectedColumns, setSelectedColumns] = useState( exportColumns.filter(c => c.default).map(c => c.key) ); useEffect(() => { if (selectedCount > 0) { setScope('selected'); } else { setScope('all_with_filters'); } }, [selectedCount]); const toggleColumn = (key: string) => { setSelectedColumns(prev => prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key] ); }; const selectAllColumns = () => setSelectedColumns(exportColumns.map(c => c.key)); const selectDefaultColumns = () => setSelectedColumns(exportColumns.filter(c => c.default).map(c => c.key)); if (!isOpen) return null; return (

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

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

{t('issues.export.exportFormat')}

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

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

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

{scope === 'selected' && t('issues.export.exportingSelected', { count: selectedCount })} {scope === 'all_on_page' && t('issues.export.exportingPage', { count: pageCount })} {scope === 'all_with_filters' && t('issues.export.exportingAll', { count: totalCount })}
); }; // Status badge colors const getStatusStyle = (status: string) => { switch (status?.toLowerCase()) { case 'open': return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300'; case 'replied': return 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300'; case 'on hold': return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300'; case 'resolved': return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'; case 'closed': return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'; default: return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'; } }; // Priority badge colors const getPriorityStyle = (priority: string) => { switch (priority?.toLowerCase()) { case 'high': return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300'; case 'medium': return 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300'; case 'low': return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'; default: return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'; } }; const IssueList: React.FC = () => { const { t } = useTranslation(); const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); const setSearchParamsRef = useRef(setSearchParams); useEffect(() => { setSearchParamsRef.current = setSearchParams; }, [setSearchParams]); const currentPage = useMemo(() => { const p = parseInt(searchParams.get('page') || '1', 10); return Number.isNaN(p) || p < 1 ? 1 : p; }, [searchParams]); const setCurrentPage = useCallback((pageOrUpdater: number | ((p: number) => number)) => { const next = typeof pageOrUpdater === 'function' ? pageOrUpdater(currentPage) : pageOrUpdater; setSearchParams((prev) => { const nextParams = new URLSearchParams(prev); nextParams.set('page', String(next)); return nextParams; }); }, [currentPage, setSearchParams]); const EXPORT_COLUMNS = [ { key: 'name', label: t('issues.issueId'), default: true }, { key: 'subject', label: t('issues.subject'), default: true }, { key: 'status', label: t('commonFields.status'), default: true }, { key: 'priority', label: t('commonFields.priority'), default: true }, { key: 'raised_by', label: t('issues.raisedBy'), default: true }, { key: 'company', label: t('commonFields.company'), default: true }, { key: 'contact', label: t('issues.contact'), default: false }, { key: 'issue_type', label: t('issues.issueType'), default: false }, { key: 'opening_date', label: t('issues.openingDate'), default: true }, { key: 'sla_resolution_date', label: t('issues.resolutionDate'), default: false }, { key: 'sla_resolution_by', label: t('issues.resolvedBy'), default: false }, { key: 'first_responded_on', label: t('issues.firstRespondedOn'), default: false }, { key: 'description', label: t('commonFields.description'), default: false }, { key: 'resolution_details', label: t('issues.resolutionDetails'), default: false }, { key: 'creation', label: t('commonFields.createdOn'), default: false }, { key: 'modified', label: t('commonFields.modifiedOn'), default: false }, { key: 'owner', label: t('commonFields.createdBy'), default: false }, ]; const [pageSize] = useState(20); const [initialLoadComplete, setInitialLoadComplete] = useState(false); const [selectedRows, setSelectedRows] = useState>(new Set()); const [showExportModal, setShowExportModal] = useState(false); const [isExporting, setIsExporting] = useState(false); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(null); const [userRoles, setUserRoles] = useState([]); useEffect(() => { const fetchRoles = async () => { try { const response = await apiService.apiCall( '/api/method/asset_lite.api.user_roles.get_user_roles' ); if (Array.isArray(response)) { setUserRoles(response as string[]); } else if (response && typeof response === 'object' && 'message' in response) { const msg = (response as { message?: string[] }).message; if (Array.isArray(msg)) setUserRoles(msg); } } catch { setUserRoles([]); } }; void fetchRoles(); }, []); const canCreateWorkOrderFromIssue = useMemo( () => ROLES_CAN_CREATE_WO_FROM_ISSUE.some((r) => userRoles.includes(r)), [userRoles] ); 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') || ''); // Default to Open so closed issues (e.g. after WO applied by WC) drop off the main queue const [statusFilter, setStatusFilter] = useState(() => { const raw = searchParams.get('status'); if (raw === null) return 'Open'; return raw; }); const [priorityFilter, setPriorityFilter] = useState(() => searchParams.get('priority') || ''); const [companyFilter, setCompanyFilter] = useState(() => searchParams.get('company') || ''); const [issueIdFilter, setIssueIdFilter] = useState(() => searchParams.get('issue_id') || ''); const [sortBy, setSortBy] = useState(() => searchParams.get('sort_by') || 'creation desc'); const [isFilterExpanded, setIsFilterExpanded] = useState(false); const [activeFilterCount, setActiveFilterCount] = useState(0); const [savedFilters, setSavedFilters] = useState([]); const [showSaveFilterModal, setShowSaveFilterModal] = useState(false); const [filterPresetName, setFilterPresetName] = useState(''); useEffect(() => { const saved = localStorage.getItem('issueFilterPresets'); if (saved) setSavedFilters(JSON.parse(saved)); }, []); const hasDateFilter = dateFilterBy && (dateStart || dateEnd); useEffect(() => { const count = [statusFilter, priorityFilter, companyFilter, issueIdFilter].filter(Boolean).length + (hasDateFilter ? 1 : 0); setActiveFilterCount(count); }, [statusFilter, priorityFilter, companyFilter, issueIdFilter, hasDateFilter]); const apiFilters = useMemo(() => { const filters: Record = {}; if (statusFilter) filters['status'] = statusFilter; if (priorityFilter) filters['priority'] = priorityFilter; if (companyFilter) filters['company'] = companyFilter; if (issueIdFilter) filters['name'] = issueIdFilter; Object.assign(filters, buildDateRangeFilters(dateFilterBy, dateStart, dateEnd)); return filters; }, [statusFilter, priorityFilter, companyFilter, issueIdFilter, dateFilterBy, dateStart, dateEnd]); const orderBy = ['creation desc', 'creation asc', 'modified desc', 'modified asc', 'name asc', 'name desc'].includes(sortBy) ? sortBy : 'creation desc'; const { issues, loading, error, totalCount, refetch } = useIssueList({ filters: apiFilters, limit_start: (currentPage - 1) * pageSize, limit_page_length: pageSize, order_by: orderBy, }); useEffect(() => { if (!loading && !initialLoadComplete) setInitialLoadComplete(true); }, [loading, initialLoadComplete]); const filtersChangedOnce = useRef(false); useEffect(() => { if (!filtersChangedOnce.current) { filtersChangedOnce.current = true; return; } setSearchParamsRef.current((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 (statusFilter) next.set('status', statusFilter); else next.delete('status'); if (priorityFilter) next.set('priority', priorityFilter); else next.delete('priority'); if (companyFilter) next.set('company', companyFilter); else next.delete('company'); if (issueIdFilter) next.set('issue_id', issueIdFilter); else next.delete('issue_id'); if (sortBy && sortBy !== 'creation desc') next.set('sort_by', sortBy); else next.delete('sort_by'); next.set('page', '1'); return next; }); }, [dateFilterBy, dateStart, dateEnd, statusFilter, priorityFilter, companyFilter, issueIdFilter, sortBy]); useEffect(() => { setSelectedRows(new Set()); }, [dateFilterBy, dateStart, dateEnd, statusFilter, priorityFilter, companyFilter, issueIdFilter, currentPage]); const totalPages = Math.ceil(totalCount / pageSize); const formatDate = (dateStr: string) => dateStr ? new Date(dateStr).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) : '-'; const clearFilters = () => { setDateFilterBy(''); setDateStart(''); setDateEnd(''); setSortBy('creation desc'); setStatusFilter(''); setPriorityFilter(''); setCompanyFilter(''); setIssueIdFilter(''); setSearchParamsRef.current((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('status'); next.delete('priority'); next.delete('company'); next.delete('issue_id'); next.set('page', '1'); return next; }); }; const hasActiveFilters = hasDateFilter || !!statusFilter || !!priorityFilter || !!companyFilter || !!issueIdFilter; const handleSaveFilterPreset = () => { if (!filterPresetName.trim()) { alert('Please enter a filter name'); return; } const preset = { id: Date.now(), name: filterPresetName, filters: { dateFilterBy, dateStart, dateEnd, sortBy, statusFilter, priorityFilter, companyFilter, issueIdFilter } }; const updated = [...savedFilters, preset]; setSavedFilters(updated); setFilterPresetName(''); setShowSaveFilterModal(false); localStorage.setItem('issueFilterPresets', JSON.stringify(updated)); }; const handleLoadFilterPreset = (preset: any) => { const f = preset.filters; setDateFilterBy(f.dateFilterBy || ''); setDateStart(f.dateStart || ''); setDateEnd(f.dateEnd || ''); setSortBy(f.sortBy || 'creation desc'); setStatusFilter(f.statusFilter || ''); setPriorityFilter(f.priorityFilter || ''); setCompanyFilter(f.companyFilter || ''); setIssueIdFilter(f.issueIdFilter || ''); }; const handleDeleteFilterPreset = (id: number) => { const updated = savedFilters.filter(f => f.id !== id); setSavedFilters(updated); localStorage.setItem('issueFilterPresets', JSON.stringify(updated)); }; const handleSelectRow = (issueName: string) => { setSelectedRows(prev => { const newSet = new Set(prev); newSet.has(issueName) ? newSet.delete(issueName) : newSet.add(issueName); return newSet; }); }; const handleSelectAll = () => { selectedRows.size === issues.length ? setSelectedRows(new Set()) : setSelectedRows(new Set(issues.map(i => i.name))); }; const isAllSelected = issues.length > 0 && selectedRows.size === issues.length; const isSomeSelected = selectedRows.size > 0 && selectedRows.size < issues.length; const fetchAllIssuesForExport = useCallback(async (): Promise => { const allIssues: any[] = []; let currentPageNum = 0; const pageSizeNum = 100; let hasMoreData = true; const filterArrays = toFrappeFilterArray(apiFilters); while (hasMoreData) { try { const response = await fetch('/api/method/frappe.client.get_list', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ doctype: 'Issue', filters: filterArrays.length > 0 ? filterArrays : {}, fields: ['*'], limit_start: currentPageNum * pageSizeNum, limit_page_length: pageSizeNum, order_by: orderBy }) }); const data = await response.json(); const results = data.message || []; allIssues.push(...results); if (results.length < pageSizeNum) hasMoreData = false; else currentPageNum++; if (currentPageNum > 100) { console.warn('Export safety limit reached'); hasMoreData = false; } } catch (error) { console.error('Error fetching issues for export:', error); throw error; } } return allIssues; }, [apiFilters, orderBy]); const handleExport = async (scope: ExportScope, format: ExportFormat, columns: string[]) => { setIsExporting(true); try { let dataToExport: any[] = []; switch (scope) { case 'selected': dataToExport = issues.filter(i => selectedRows.has(i.name)); break; case 'all_on_page': dataToExport = issues; break; case 'all_with_filters': dataToExport = await fetchAllIssuesForExport(); break; } if (dataToExport.length === 0) { alert(t('assets.noDataToExport')); return; } const columnLabels = columns.map(key => EXPORT_COLUMNS.find(c => c.key === key)?.label || key); if (format === 'csv') { const csvContent = [columnLabels.join(','), ...dataToExport.map(issue => columns.map(key => { let value = issue[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 = `issues_export_${new Date().toISOString().split('T')[0]}.csv`; link.click(); URL.revokeObjectURL(url); } else if (format === 'excel') { const worksheetData = [columnLabels, ...dataToExport.map(issue => columns.map(key => issue[key] || ''))]; const worksheet = XLSX.utils.aoa_to_sheet(worksheetData); const workbook = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(workbook, worksheet, 'Issues'); XLSX.writeFile(workbook, `issues_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 handleDelete = async (issueName: string) => { try { const response = await fetch(`/api/resource/Issue/${issueName}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' } }); if (!response.ok) throw new Error('Failed to delete'); setDeleteConfirmOpen(null); refetch(); alert(t('issues.deletedSuccessfully')); } catch (err) { alert(`Failed to delete: ${err instanceof Error ? err.message : 'Unknown error'}`); } }; if (loading && !initialLoadComplete) { return (

{t('issues.loadingIssues')}

); } if (error) { return (

{t('issues.errorLoadingIssues')}

{error}

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

{t('issues.listTitle')}

{t('issues.listTotal')}: {totalCount} {selectedRows.size > 0 && • {selectedRows.size} {t('issues.listSelected')}} {loading && initialLoadComplete &&

{t('common.filtering')}}

{/* Stats Cards */}

{t('issues.statsTotalIssues')}

{totalCount}

{t('issues.statsOpen')}

{issues.filter(i => i.status === 'Open').length}

{t('issues.statsResolved')}

{issues.filter(i => i.status === 'Resolved').length}

{t('issues.statsClosed')}

{issues.filter(i => i.status === 'Closed').length}

{/* Expandable Filter Panel */} {isFilterExpanded && (

{t('listPages.filters')}

{activeFilterCount > 0 && {activeFilterCount}}
{hasActiveFilters && (
{issueIdFilter && {t('issues.issueId')}: {issueIdFilter}} {statusFilter && {t('filters.status')}: {statusFilter}} {priorityFilter && {t('filters.priority')}: {priorityFilter}} {companyFilter && {t('filters.filterByCompany')}: {companyFilter}} {hasDateFilter && {t('filters.filterBy')}: {dateFilterBy === 'creation' ? t('filters.createdDate') : t('filters.latestModifiedDate')} {dateStart && ` ${dateStart}`} {dateEnd && ` - ${dateEnd}`}}
)}
{activeFilterCount > 0 && } {hasActiveFilters && }
{savedFilters.length > 0 && (

{t('inspections.savedFilters')}

{savedFilters.map((preset) => (
))}
)}
{dateFilterBy && ( <>
{ const v = e.target.value; setDateStart(v); if (dateEnd && v > dateEnd) setDateEnd(v); setCurrentPage(1); }} className="w-full px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
{ setDateEnd(e.target.value); setCurrentPage(1); }} 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" />
)}
{ setIssueIdFilter(val); setCurrentPage(1); }} placeholder={t('linkField.selectLabel', { label: t('issues.issueId') })} disabled={false} compact={true} /> {issueIdFilter && }
{ setPriorityFilter(val); setCurrentPage(1); }} placeholder={t('issues.allPriorities')} disabled={false} compact={true} /> {priorityFilter && }
{ setCompanyFilter(val); setCurrentPage(1); }} placeholder={t('issues.allCompanies')} disabled={false} compact={true} /> {companyFilter && }
)} {/* Save Filter Modal */} {showSaveFilterModal && (

Save Filter Preset

setFilterPresetName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleSaveFilterPreset(); } }} placeholder="Enter filter name (e.g., 'Open High Priority')" className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white mb-4" autoFocus />
)} {/* Export Modal */} setShowExportModal(false)} selectedCount={selectedRows.size} totalCount={totalCount} pageCount={issues.length} onExport={handleExport} isExporting={isExporting} exportColumns={EXPORT_COLUMNS} /> {/* Table */}
{loading && initialLoadComplete && (
{t('common.filtering')}
)}
{issues.length === 0 ? ( ) : issues.map((issue) => ( navigate(`/support/${issue.name}`)}> ))}
{t('issues.issueId')} {t('issues.subject')} {t('commonFields.status')} {t('commonFields.priority')} {t('commonFields.company')} {t('issues.openingDate')} {t('listPages.actions')}

{t('issues.noIssuesFound')}

{hasActiveFilters ? ( ) : ( )}
e.stopPropagation()}> {issue.name} {issue.subject || '-'} {issue.status ? t(`issues.status.${(issue.status as string).toLowerCase().replace(/\\s+/g, '_')}`, issue.status) : '-'} {issue.priority ? ( {t(`issues.priority.${(issue.priority as string).toLowerCase()}`, issue.priority)} ) : ( - )} {issue.company || '-'} {formatDate(issue.opening_date)}
e.stopPropagation()}> {canCreateWorkOrderFromIssue && ISSUE_STATUSES_ALLOW_WO.includes((issue.status || '').trim()) && ( )}
setCurrentPage(p)} />
{/* Delete Confirmation Modal */} {deleteConfirmOpen && (

{t('issues.deleteIssue')}

{t('issues.deleteConfirmMessage')}

{t('issues.issueId')}: {deleteConfirmOpen}

)}
); }; export default IssueList;