- ItemFieldController API 수정 - ItemFieldSeedingService 로직 개선 - Flow Tester 상세 화면 개선 - 레이아웃 및 프로젝트 상세 화면 수정 - 테이블 정렬 JS 추가
369 lines
11 KiB
JavaScript
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;
|