843 lines
55 KiB
TypeScript
843 lines
55 KiB
TypeScript
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<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">
|
|
<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('issues.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)]">
|
|
<div className="mb-6">
|
|
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">{t('issues.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('issues.export.selectedRows')}</div>
|
|
<div className="text-sm text-gray-500 dark:text-gray-400">{t('issues.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} 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('issues.export.currentPage')}</div>
|
|
<div className="text-sm text-gray-500 dark:text-gray-400">{t('issues.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} 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('issues.export.allWithFilters')}</div>
|
|
<div className="text-sm text-gray-500 dark:text-gray-400">{t('issues.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} total</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mb-6">
|
|
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">{t('issues.export.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('issues.export.csv')}</div>
|
|
<div className="text-xs text-gray-500 dark:text-gray-400">{t('issues.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('issues.export.excel')}</div>
|
|
<div className="text-xs text-gray-500 dark:text-gray-400">{t('issues.export.excelDesc')}</div>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300">{t('issues.export.columnsToExport')}</h4>
|
|
<div className="flex gap-2">
|
|
<button onClick={selectAllColumns} className="text-xs text-blue-600 dark:text-blue-400 hover:underline">{t('issues.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('issues.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('issues.export.columnsSelected', { count: selectedColumns.length })}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<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('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 })}
|
|
</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('issues.export.exporting')}</>) : (<><FaDownload />{t('issues.export.exportButton')}</>)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// 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<Set<string>>(new Set());
|
|
const [showExportModal, setShowExportModal] = useState(false);
|
|
const [isExporting, setIsExporting] = useState(false);
|
|
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState<string | null>(null);
|
|
const [userRoles, setUserRoles] = useState<string[]>([]);
|
|
|
|
useEffect(() => {
|
|
const fetchRoles = async () => {
|
|
try {
|
|
const response = await apiService.apiCall<unknown>(
|
|
'/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<string>(() => searchParams.get('date_start') || '');
|
|
const [dateEnd, setDateEnd] = useState<string>(() => 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<string>(() => {
|
|
const raw = searchParams.get('status');
|
|
if (raw === null) return 'Open';
|
|
return raw;
|
|
});
|
|
const [priorityFilter, setPriorityFilter] = useState<string>(() => searchParams.get('priority') || '');
|
|
const [companyFilter, setCompanyFilter] = useState<string>(() => searchParams.get('company') || '');
|
|
const [issueIdFilter, setIssueIdFilter] = useState<string>(() => searchParams.get('issue_id') || '');
|
|
const [sortBy, setSortBy] = useState<string>(() => searchParams.get('sort_by') || 'creation desc');
|
|
|
|
const [isFilterExpanded, setIsFilterExpanded] = useState(false);
|
|
const [activeFilterCount, setActiveFilterCount] = useState(0);
|
|
const [savedFilters, setSavedFilters] = useState<any[]>([]);
|
|
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<string, any> = {};
|
|
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<any[]> => {
|
|
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 (
|
|
<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('issues.loadingIssues')}</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('issues.errorLoadingIssues')}</h2>
|
|
<p className="text-red-700 dark:text-red-400 mb-4">{error}</p>
|
|
<button onClick={refetch} className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded">{t('common.tryAgain')}</button>
|
|
</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>
|
|
<div className="flex items-center gap-3">
|
|
<FaHeadset className="text-3xl text-blue-600 dark:text-blue-400" />
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-gray-800 dark:text-white">{t('issues.listTitle')}</h1>
|
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
{t('issues.listTotal')}: {totalCount}
|
|
{selectedRows.size > 0 && <span className="ml-2 text-blue-600 dark:text-blue-400">• {selectedRows.size} {t('issues.listSelected')}</span>}
|
|
{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.filtering')}</span>}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-3">
|
|
<button onClick={() => setIsFilterExpanded(!isFilterExpanded)} className={`px-4 py-2 rounded-lg flex items-center gap-2 transition-colors ${isFilterExpanded || hasActiveFilters ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300' : 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'}`}>
|
|
<FaFilter />{t('listPages.filters')}
|
|
{activeFilterCount > 0 && <span className="bg-blue-600 text-white text-xs px-1.5 py-0.5 rounded-full">{activeFilterCount}</span>}
|
|
</button>
|
|
<button onClick={refetch} disabled={loading} className="px-4 py-2 rounded-lg bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center gap-2 disabled:opacity-50">
|
|
<FaSync className={loading ? 'animate-spin' : ''} />{t('listPages.refresh')}
|
|
</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={() =>
|
|
navigate('/support/new?skip_precheck=1', {
|
|
state: { newIssuePrecheckDone: true },
|
|
})
|
|
}
|
|
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow-lg transition-all hover:shadow-xl"
|
|
>
|
|
<FaPlus /><span className="font-medium">{t('issues.newIssue')}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Stats Cards */}
|
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border border-gray-200 dark:border-gray-700">
|
|
<div className="flex items-center justify-between"><div><p className="text-sm text-gray-500 dark:text-gray-400">{t('issues.statsTotalIssues')}</p><p className="text-2xl font-bold text-gray-800 dark:text-white">{totalCount}</p></div><FaExclamationCircle className="text-3xl text-blue-500" /></div>
|
|
</div>
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border border-gray-200 dark:border-gray-700">
|
|
<div className="flex items-center justify-between"><div><p className="text-sm text-gray-500 dark:text-gray-400">{t('issues.statsOpen')}</p><p className="text-2xl font-bold text-blue-600">{issues.filter(i => i.status === 'Open').length}</p></div><FaClock className="text-3xl text-blue-500" /></div>
|
|
</div>
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border border-gray-200 dark:border-gray-700">
|
|
<div className="flex items-center justify-between"><div><p className="text-sm text-gray-500 dark:text-gray-400">{t('issues.statsResolved')}</p><p className="text-2xl font-bold text-green-600">{issues.filter(i => i.status === 'Resolved').length}</p></div><FaCheckCircle className="text-3xl text-green-500" /></div>
|
|
</div>
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border border-gray-200 dark:border-gray-700">
|
|
<div className="flex items-center justify-between"><div><p className="text-sm text-gray-500 dark:text-gray-400">{t('issues.statsClosed')}</p><p className="text-2xl font-bold text-gray-600 dark:text-gray-300">{issues.filter(i => i.status === 'Closed').length}</p></div><FaTimesCircle className="text-3xl text-gray-500" /></div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Expandable Filter Panel */}
|
|
{isFilterExpanded && (
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 mb-4">
|
|
<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">
|
|
<div className="flex items-center gap-3">
|
|
<FaFilter className="text-white" size={16} /><h3 className="text-white font-semibold text-sm">{t('listPages.filters')}</h3>
|
|
{activeFilterCount > 0 && <span className="bg-white text-blue-600 px-2 py-0.5 rounded-full text-xs font-bold">{activeFilterCount}</span>}
|
|
</div>
|
|
{hasActiveFilters && (
|
|
<div className="flex-1 overflow-x-auto scrollbar-hide mx-2">
|
|
<div className="flex items-center gap-2 py-1">
|
|
{issueIdFilter && <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">{t('issues.issueId')}:</span> {issueIdFilter}<button onClick={() => setIssueIdFilter('')} className="hover:text-red-500"><FaTimes className="text-[9px]" /></button></span>}
|
|
{statusFilter && <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">{t('filters.status')}:</span> {statusFilter}<button onClick={() => setStatusFilter('')} className="hover:text-red-500"><FaTimes className="text-[9px]" /></button></span>}
|
|
{priorityFilter && <span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/90 text-orange-700 rounded-full text-[10px] font-medium whitespace-nowrap shadow-sm"><span className="font-semibold">{t('filters.priority')}:</span> {priorityFilter}<button onClick={() => setPriorityFilter('')} className="hover:text-red-500"><FaTimes className="text-[9px]" /></button></span>}
|
|
{companyFilter && <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">{t('filters.filterByCompany')}:</span> {companyFilter}<button onClick={() => setCompanyFilter('')} className="hover:text-red-500"><FaTimes className="text-[9px]" /></button></span>}
|
|
{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={() => { setDateFilterBy(''); setDateStart(''); setDateEnd(''); }} className="hover:text-red-500"><FaTimes className="text-[9px]" /></button></span>}
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div className="flex items-center gap-2 flex-shrink-0">
|
|
{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">{t('listPages.saveFilterPreset')}</span></button>}
|
|
{hasActiveFilters && <button onClick={clearFilters} 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.clearFilters')}</span></button>}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="p-4">
|
|
{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('inspections.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>
|
|
)}
|
|
<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">
|
|
<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); 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">
|
|
<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="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); 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">
|
|
<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); 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" />
|
|
</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); 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" />
|
|
</div>
|
|
</>
|
|
)}
|
|
<div className="relative z-[60]">
|
|
<LinkField
|
|
label={t('issues.issueId')}
|
|
doctype="Issue"
|
|
value={issueIdFilter}
|
|
onChange={(val) => { setIssueIdFilter(val); setCurrentPage(1); }}
|
|
placeholder={t('linkField.selectLabel', { label: t('issues.issueId') })}
|
|
disabled={false}
|
|
compact={true}
|
|
/>
|
|
{issueIdFilter && <button onClick={() => setIssueIdFilter('')} className="absolute right-2 top-6 text-gray-400 hover:text-red-500 transition-colors z-10"><FaTimes size={10} /></button>}
|
|
</div>
|
|
<div className="relative">
|
|
<label className="block text-[10px] font-medium text-gray-700 dark:text-gray-300 mb-0.5">
|
|
{t('filters.status')}
|
|
</label>
|
|
<select
|
|
value={statusFilter}
|
|
onChange={(e) => { setStatusFilter(e.target.value); 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"
|
|
>
|
|
<option value="">{t('filters.allStatuses')}</option>
|
|
<option value="Open">{t('issues.status.open')}</option>
|
|
<option value="Replied">{t('issues.status.replied')}</option>
|
|
<option value="On Hold">{t('issues.status.on_hold')}</option>
|
|
<option value="Resolved">{t('issues.status.resolved')}</option>
|
|
<option value="Closed">{t('issues.status.closed')}</option>
|
|
</select>
|
|
</div>
|
|
<div className="relative z-[59]">
|
|
<LinkField label={t('commonFields.priority')} doctype="Issue Priority" value={priorityFilter} onChange={(val) => { setPriorityFilter(val); setCurrentPage(1); }} placeholder={t('issues.allPriorities')} disabled={false} compact={true} />
|
|
{priorityFilter && <button onClick={() => setPriorityFilter('')} className="absolute right-2 top-6 text-gray-400 hover:text-red-500 transition-colors z-10"><FaTimes size={10} /></button>}
|
|
</div>
|
|
<div className="relative z-[58]">
|
|
<LinkField label={t('commonFields.company')} doctype="Company" value={companyFilter} onChange={(val) => { setCompanyFilter(val); setCurrentPage(1); }} placeholder={t('issues.allCompanies')} disabled={false} compact={true} />
|
|
{companyFilter && <button onClick={() => setCompanyFilter('')} 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>
|
|
)}
|
|
|
|
{/* 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., '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 />
|
|
<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={issues.length} onExport={handleExport} isExporting={isExporting} exportColumns={EXPORT_COLUMNS} />
|
|
|
|
{/* Table */}
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 overflow-hidden relative">
|
|
{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>
|
|
<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 ? t('listPages.deselectAllTitle') : t('listPages.selectAllTitle')}>
|
|
{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-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('issues.issueId')}</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('issues.subject')}</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('commonFields.status')}</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('commonFields.priority')}</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('commonFields.company')}</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('issues.openingDate')}</th>
|
|
<th className="px-4 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">
|
|
{issues.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={8} className="px-4 py-12 text-center text-gray-500 dark:text-gray-400">
|
|
<div className="flex flex-col items-center">
|
|
<FaHeadset className="text-4xl text-gray-300 dark:text-gray-600 mb-2" />
|
|
<p>{t('issues.noIssuesFound')}</p>
|
|
{hasActiveFilters ? (
|
|
<button
|
|
onClick={clearFilters}
|
|
className="mt-4 text-blue-600 dark:text-blue-400 hover:underline"
|
|
>
|
|
{t('common.clearFilters')}
|
|
</button>
|
|
) : (
|
|
<button
|
|
onClick={() =>
|
|
navigate('/support/new?skip_precheck=1', {
|
|
state: { newIssuePrecheckDone: true },
|
|
})
|
|
}
|
|
className="mt-4 text-blue-600 dark:text-blue-400 hover:underline"
|
|
>
|
|
{t('issues.createFirstIssue')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
) : issues.map((issue) => (
|
|
<tr key={issue.name} className={`hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer transition-colors ${selectedRows.has(issue.name) ? 'bg-blue-50 dark:bg-blue-900/20' : ''}`} onClick={() => navigate(`/support/${issue.name}`)}>
|
|
<td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
|
|
<button onClick={() => handleSelectRow(issue.name)} className="text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors">
|
|
{selectedRows.has(issue.name) ? <FaCheckSquare className="text-blue-600 dark:text-blue-400" size={18} /> : <FaSquare size={18} />}
|
|
</button>
|
|
</td>
|
|
<td className="px-4 py-3"><span className="text-sm font-medium text-blue-600 dark:text-blue-400">{issue.name}</span></td>
|
|
<td className="px-4 py-3"><span className="text-sm text-gray-900 dark:text-white line-clamp-1">{issue.subject || '-'}</span></td>
|
|
<td className="px-4 py-3">
|
|
<span className={`inline-flex px-2 py-1 text-xs font-medium rounded-full ${getStatusStyle(issue.status)}`}>
|
|
{issue.status
|
|
? t(`issues.status.${(issue.status as string).toLowerCase().replace(/\\s+/g, '_')}`, issue.status)
|
|
: '-'}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
{issue.priority ? (
|
|
<span className={`inline-flex px-2 py-1 text-xs font-medium rounded-full ${getPriorityStyle(issue.priority)}`}>
|
|
{t(`issues.priority.${(issue.priority as string).toLowerCase()}`, issue.priority)}
|
|
</span>
|
|
) : (
|
|
<span className="text-gray-400">-</span>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-3"><span className="text-sm text-gray-600 dark:text-gray-300 line-clamp-1">{issue.company || '-'}</span></td>
|
|
<td className="px-4 py-3"><span className="text-sm text-gray-600 dark:text-gray-300">{formatDate(issue.opening_date)}</span></td>
|
|
<td className="px-4 py-3">
|
|
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
|
<button onClick={() => navigate(`/support/${issue.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={t('issues.viewDetails')}><FaEye /></button>
|
|
<button onClick={() => navigate(`/support/${issue.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={t('issues.editIssue')}><FaEdit /></button>
|
|
{canCreateWorkOrderFromIssue &&
|
|
ISSUE_STATUSES_ALLOW_WO.includes((issue.status || '').trim()) && (
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
navigate(`/work-orders/new?from_issue=${encodeURIComponent(issue.name)}`)
|
|
}
|
|
className="text-emerald-600 dark:text-emerald-400 hover:text-emerald-900 dark:hover:text-emerald-300 p-2 hover:bg-emerald-50 dark:hover:bg-emerald-900/30 rounded transition-colors"
|
|
title={t('issues.createWorkOrderFromIssue')}
|
|
>
|
|
<FaClipboardList />
|
|
</button>
|
|
)}
|
|
<button onClick={() => setDeleteConfirmOpen(issue.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('issues.deleteIssue')}><FaTrash /></button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<ListPagination
|
|
currentPage={currentPage}
|
|
totalCount={totalCount}
|
|
pageSize={pageSize}
|
|
itemLabel={t('pagination.issues')}
|
|
onPageChange={(p) => setCurrentPage(p)}
|
|
/>
|
|
</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('issues.deleteIssue')}</h3>
|
|
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">{t('issues.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('issues.issueId')}:</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">{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"><FaTrash />{t('issues.deleteIssue')}</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 IssueList; |