/** * 테이블 정렬 유틸리티 * * 다양한 테이블에서 재사용 가능한 정렬/그룹화 기능 제공 * * @example * // 기본 사용 * const sorter = new TableSorter({ * data: issuesData, * containerId: 'issue-list', * renderFn: renderIssues, * sortOptions: [ * { value: 'start_date', label: '시작일순', type: 'date' }, * { value: 'status', label: '상태순', type: 'priority', priority: {...} }, * { value: 'client', label: '고객사별', type: 'group' }, * ] * }); * sorter.init(); */ class TableSorter { constructor(options) { this.data = options.data || []; this.containerId = options.containerId; this.renderFn = options.renderFn; this.sortOptions = options.sortOptions || []; this.defaultSort = options.defaultSort || null; this.onSort = options.onSort || null; this.controlsContainerId = options.controlsContainerId || null; this.currentSort = this.defaultSort; this.currentDirection = 'asc'; } /** * 초기화 - 정렬 컨트롤 UI 생성 */ init() { if (this.controlsContainerId) { this.renderControls(); } return this; } /** * 데이터 업데이트 */ setData(data) { this.data = data; return this; } /** * 정렬 컨트롤 UI 렌더링 */ renderControls() { const container = document.getElementById(this.controlsContainerId); if (!container) return; const html = `
정렬:
`; container.innerHTML = html; // 이벤트 바인딩 const select = document.getElementById(`${this.containerId}-sort`); const dirBtn = document.getElementById(`${this.containerId}-sort-dir`); select?.addEventListener('change', (e) => { this.currentSort = e.target.value || null; this.apply(); }); dirBtn?.addEventListener('click', () => { this.currentDirection = this.currentDirection === 'asc' ? 'desc' : 'asc'; dirBtn.querySelector('svg').classList.toggle('rotate-180'); this.apply(); }); } /** * 정렬 적용 및 렌더링 */ apply() { const sortedData = this.sort(this.data); if (this.onSort) { this.onSort(sortedData, this.currentSort, this.currentDirection); } else if (this.renderFn) { const container = document.getElementById(this.containerId); if (container) { this.renderFn(container, sortedData); } } return sortedData; } /** * 데이터 정렬 */ sort(data) { if (!this.currentSort || !data || data.length === 0) { return data; } const option = this.sortOptions.find(o => o.value === this.currentSort); if (!option) return data; const sorted = [...data].sort((a, b) => { let result = 0; switch (option.type) { case 'date': result = this.compareDates(a[option.value], b[option.value]); break; case 'number': result = this.compareNumbers(a[option.value], b[option.value]); break; case 'priority': result = this.comparePriority(a[option.value], b[option.value], option.priority); break; case 'group': case 'string': default: result = this.compareStrings(a[option.value], b[option.value]); break; } // 2차 정렬 (옵션) if (result === 0 && option.secondary) { const secOption = this.sortOptions.find(o => o.value === option.secondary); if (secOption) { result = this.compareByType(a, b, secOption); } } return this.currentDirection === 'desc' ? -result : result; }); return sorted; } /** * 타입별 비교 */ compareByType(a, b, option) { switch (option.type) { case 'date': return this.compareDates(a[option.value], b[option.value]); case 'number': return this.compareNumbers(a[option.value], b[option.value]); case 'priority': return this.comparePriority(a[option.value], b[option.value], option.priority); default: return this.compareStrings(a[option.value], b[option.value]); } } /** * 날짜 비교 (null은 맨 뒤) */ compareDates(a, b) { if (!a && !b) return 0; if (!a) return 1; if (!b) return -1; return new Date(a) - new Date(b); } /** * 숫자 비교 (null은 맨 뒤) */ compareNumbers(a, b) { if (a == null && b == null) return 0; if (a == null) return 1; if (b == null) return -1; return Number(a) - Number(b); } /** * 문자열 비교 (null/빈값은 맨 뒤) */ compareStrings(a, b) { const strA = (a || '').toString().toLowerCase(); const strB = (b || '').toString().toLowerCase(); if (!strA && !strB) return 0; if (!strA) return 1; if (!strB) return -1; return strA.localeCompare(strB, 'ko'); } /** * 우선순위 비교 */ comparePriority(a, b, priorityMap) { const prioA = priorityMap[a] ?? 99; const prioB = priorityMap[b] ?? 99; return prioA - prioB; } /** * 현재 정렬 상태 반환 */ getState() { return { sort: this.currentSort, direction: this.currentDirection }; } /** * 정렬 상태 설정 */ setState(sort, direction = 'asc') { this.currentSort = sort; this.currentDirection = direction; return this; } } /** * 그룹화 유틸리티 * 데이터를 특정 필드로 그룹화하여 반환 */ class TableGrouper { /** * 데이터 그룹화 * @param {Array} data - 원본 데이터 * @param {string} groupBy - 그룹화 기준 필드 * @param {Object} options - 옵션 * @returns {Object} 그룹화된 데이터 { groupKey: [items] } */ static group(data, groupBy, options = {}) { const emptyLabel = options.emptyLabel || '(미지정)'; const sortGroups = options.sortGroups !== false; const groups = {}; data.forEach(item => { const key = item[groupBy] || emptyLabel; if (!groups[key]) { groups[key] = []; } groups[key].push(item); }); if (sortGroups) { const sortedKeys = Object.keys(groups).sort((a, b) => { if (a === emptyLabel) return 1; if (b === emptyLabel) return -1; return a.localeCompare(b, 'ko'); }); const sortedGroups = {}; sortedKeys.forEach(key => { sortedGroups[key] = groups[key]; }); return sortedGroups; } return groups; } /** * 그룹 내 아이템 정렬 * @param {Object} groups - 그룹화된 데이터 * @param {Function} sortFn - 정렬 함수 * @returns {Object} 정렬된 그룹 데이터 */ static sortWithinGroups(groups, sortFn) { const result = {}; Object.keys(groups).forEach(key => { result[key] = [...groups[key]].sort(sortFn); }); return result; } } /** * 간단한 정렬 함수 (TableSorter 없이 사용) */ const SortUtils = { /** * 다중 기준 정렬 * @param {Array} data - 정렬할 데이터 * @param {Array} criteria - 정렬 기준 배열 [{ field, direction, type, priority }] */ multiSort(data, criteria) { return [...data].sort((a, b) => { for (const criterion of criteria) { const { field, direction = 'asc', type = 'string', priority } = criterion; let result = 0; switch (type) { case 'date': result = this.compareDates(a[field], b[field]); break; case 'number': result = this.compareNumbers(a[field], b[field]); break; case 'priority': result = this.comparePriority(a[field], b[field], priority); break; default: result = this.compareStrings(a[field], b[field]); } if (result !== 0) { return direction === 'desc' ? -result : result; } } return 0; }); }, compareDates(a, b) { if (!a && !b) return 0; if (!a) return 1; if (!b) return -1; return new Date(a) - new Date(b); }, compareNumbers(a, b) { if (a == null && b == null) return 0; if (a == null) return 1; if (b == null) return -1; return Number(a) - Number(b); }, compareStrings(a, b) { const strA = (a || '').toString().toLowerCase(); const strB = (b || '').toString().toLowerCase(); if (!strA && !strB) return 0; if (!strA) return 1; if (!strB) return -1; return strA.localeCompare(strB, 'ko'); }, comparePriority(a, b, priorityMap) { const prioA = priorityMap[a] ?? 99; const prioB = priorityMap[b] ?? 99; return prioA - prioB; } }; // 전역 등록 window.TableSorter = TableSorter; window.TableGrouper = TableGrouper; window.SortUtils = SortUtils;