Files
sam-manage/public/js/table-sort.js
hskwon 767db6f513 feat: 품목 필드 관리 및 UI 개선
- ItemFieldController API 수정
- ItemFieldSeedingService 로직 개선
- Flow Tester 상세 화면 개선
- 레이아웃 및 프로젝트 상세 화면 수정
- 테이블 정렬 JS 추가
2025-12-12 08:51:54 +09:00

369 lines
11 KiB
JavaScript

/**
* 테이블 정렬 유틸리티
*
* 다양한 테이블에서 재사용 가능한 정렬/그룹화 기능 제공
*
* @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 = `
<div class="flex items-center gap-2">
<span class="text-xs text-gray-500">정렬:</span>
<select id="${this.containerId}-sort"
class="text-xs px-2 py-1 border border-gray-300 rounded-lg bg-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="">기본</option>
${this.sortOptions.map(opt =>
`<option value="${opt.value}" ${this.currentSort === opt.value ? 'selected' : ''}>${opt.label}</option>`
).join('')}
</select>
<button id="${this.containerId}-sort-dir"
class="p-1 text-gray-400 hover:text-gray-600 rounded hover:bg-gray-100 transition"
title="정렬 방향 변경">
<svg class="w-4 h-4 transition-transform ${this.currentDirection === 'desc' ? 'rotate-180' : ''}"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"/>
</svg>
</button>
</div>
`;
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;