feat: 품목 필드 관리 및 UI 개선
- ItemFieldController API 수정 - ItemFieldSeedingService 로직 개선 - Flow Tester 상세 화면 개선 - 레이아웃 및 프로젝트 상세 화면 수정 - 테이블 정렬 JS 추가
This commit is contained in:
@@ -143,7 +143,7 @@ public function resetAll(Request $request): JsonResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* 커스텀 필드 목록 (HTMX partial)
|
||||
* 필드 목록 (HTMX partial) - 시스템 + 커스텀
|
||||
*/
|
||||
public function customFields(Request $request): View
|
||||
{
|
||||
@@ -157,7 +157,8 @@ public function customFields(Request $request): View
|
||||
]);
|
||||
}
|
||||
|
||||
$fields = $this->service->getCustomFields($tenantId, $request->all());
|
||||
// getFields() 메서드 사용 (시스템 + 커스텀 모두 조회, 시스템 필드 우선 정렬)
|
||||
$fields = $this->service->getFields($tenantId, $request->all());
|
||||
|
||||
return view('item-fields.partials.custom-fields', [
|
||||
'fields' => $fields,
|
||||
|
||||
@@ -301,12 +301,22 @@ public function resetAll(int $tenantId): array
|
||||
}
|
||||
|
||||
/**
|
||||
* 커스텀 필드 목록 조회
|
||||
* 필드 목록 조회 (시스템 + 커스텀)
|
||||
*
|
||||
* @param array $filters source_table, field_type, field_category (system|custom), search
|
||||
*/
|
||||
public function getCustomFields(int $tenantId, array $filters = []): Collection
|
||||
public function getFields(int $tenantId, array $filters = []): Collection
|
||||
{
|
||||
$query = ItemField::where('tenant_id', $tenantId)
|
||||
->where('storage_type', 'json');
|
||||
$query = ItemField::where('tenant_id', $tenantId);
|
||||
|
||||
// 필드 유형 필터 (system=is_common:1, custom=is_common:0)
|
||||
if (! empty($filters['field_category'])) {
|
||||
if ($filters['field_category'] === 'system') {
|
||||
$query->where('is_common', true);
|
||||
} elseif ($filters['field_category'] === 'custom') {
|
||||
$query->where('is_common', false);
|
||||
}
|
||||
}
|
||||
|
||||
// 소스 테이블 필터
|
||||
if (! empty($filters['source_table'])) {
|
||||
@@ -327,11 +337,26 @@ public function getCustomFields(int $tenantId, array $filters = []): Collection
|
||||
});
|
||||
}
|
||||
|
||||
return $query->orderBy('source_table')
|
||||
// 시스템 필드 우선 정렬 (is_common DESC), 그 다음 source_table, order_no
|
||||
return $query->orderByDesc('is_common')
|
||||
->orderBy('source_table')
|
||||
->orderBy('order_no')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 커스텀 필드 목록 조회 (기존 호환성 유지)
|
||||
*
|
||||
* @deprecated Use getFields() instead
|
||||
*/
|
||||
public function getCustomFields(int $tenantId, array $filters = []): Collection
|
||||
{
|
||||
// 기존 호환성 유지: 커스텀 필드만 반환
|
||||
$filters['field_category'] = 'custom';
|
||||
|
||||
return $this->getFields($tenantId, $filters);
|
||||
}
|
||||
|
||||
/**
|
||||
* 커스텀 필드 추가
|
||||
*/
|
||||
|
||||
368
public/js/table-sort.js
Normal file
368
public/js/table-sort.js
Normal file
@@ -0,0 +1,368 @@
|
||||
/**
|
||||
* 테이블 정렬 유틸리티
|
||||
*
|
||||
* 다양한 테이블에서 재사용 가능한 정렬/그룹화 기능 제공
|
||||
*
|
||||
* @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;
|
||||
@@ -4,8 +4,8 @@
|
||||
|
||||
|
||||
@section('content')
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<!-- 페이지 헤더 (Sticky) -->
|
||||
<div class="flex justify-between items-center mb-6 sticky -top-5 z-10 bg-gray-100 -mx-6 px-6 py-4 border-b border-gray-200">
|
||||
<div class="flex items-center gap-4">
|
||||
<a href="{{ route('dev-tools.flow-tester.history', $run->flow_id) }}"
|
||||
class="p-2 text-gray-600 hover:bg-gray-100 rounded-lg transition">
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
</script>
|
||||
|
||||
<script src="{{ asset('js/pagination.js') }}"></script>
|
||||
<script src="{{ asset('js/table-sort.js') }}"></script>
|
||||
<script src="{{ asset('js/context-menu.js') }}"></script>
|
||||
<script src="{{ asset('js/tenant-modal.js') }}"></script>
|
||||
<script src="{{ asset('js/user-modal.js') }}"></script>
|
||||
|
||||
@@ -109,7 +109,7 @@ class="tab-btn px-6 py-3 text-sm font-medium border-b-2 border-transparent text-
|
||||
<div id="content-tasks" class="tab-content">
|
||||
<!-- 작업 추가 버튼 -->
|
||||
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
|
||||
<div class="flex gap-2">
|
||||
<div class="flex gap-2 items-center">
|
||||
<select id="taskBulkAction" class="px-3 py-2 border border-gray-300 rounded-lg text-sm" onchange="handleTaskBulkAction()">
|
||||
<option value="">일괄 작업</option>
|
||||
<option value="change_status:todo">상태: 할일로 변경</option>
|
||||
@@ -139,7 +139,7 @@ class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition
|
||||
<div id="content-issues" class="tab-content hidden">
|
||||
<!-- 이슈 추가 버튼 -->
|
||||
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
|
||||
<div class="flex gap-2">
|
||||
<div class="flex gap-2 items-center">
|
||||
<select id="issueBulkAction" class="px-3 py-2 border border-gray-300 rounded-lg text-sm" onchange="handleIssueBulkAction()">
|
||||
<option value="">일괄 작업</option>
|
||||
<option value="change_status:open">상태: Open</option>
|
||||
@@ -151,6 +151,8 @@ class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition
|
||||
<option value="change_type:improvement">타입: 개선</option>
|
||||
<option value="delete">삭제</option>
|
||||
</select>
|
||||
<!-- 정렬 컨트롤 -->
|
||||
<div class="border-l border-gray-200 pl-3 ml-1" id="issue-sort-controls"></div>
|
||||
</div>
|
||||
<button onclick="openIssueModal()"
|
||||
class="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg transition text-sm">
|
||||
@@ -341,6 +343,24 @@ class="bg-red-600 hover:bg-red-700 text-white px-6 py-2 rounded-lg">저장</butt
|
||||
let tasksData = [];
|
||||
let issuesData = [];
|
||||
|
||||
// 이슈 정렬 설정 (전역)
|
||||
const issueStatusPriority = { open: 0, in_progress: 1, resolved: 2, closed: 3 };
|
||||
const issueSortOptions = [
|
||||
{ value: 'start_date', label: '시작일순', type: 'date', secondary: 'status' },
|
||||
{ value: 'due_date', label: '마감일순', type: 'date' },
|
||||
{ value: 'status', label: '상태순', type: 'priority', priority: issueStatusPriority },
|
||||
{ value: 'client', label: '고객사별', type: 'group' },
|
||||
{ value: 'team', label: '팀별', type: 'group' },
|
||||
{ value: 'assignee_name', label: '담당자별', type: 'group' },
|
||||
];
|
||||
|
||||
// 현재 정렬 상태 (기본: 시작일)
|
||||
let currentIssueSort = 'start_date';
|
||||
let currentIssueSortDir = 'asc';
|
||||
|
||||
// 작업 탭 아코디언 이슈 정렬 상태: null = 기본정렬, 'assignee' = 회사/팀/담당자, 'status' = 상태순
|
||||
let currentAccordionIssueSort = null;
|
||||
|
||||
// 탭 전환
|
||||
function switchTab(tab) {
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||
@@ -439,10 +459,76 @@ function updateDashboard() {
|
||||
|
||||
// 페이지 로드 시 데이터 로드
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initSortControls();
|
||||
loadTasks();
|
||||
loadIssues();
|
||||
});
|
||||
|
||||
// 정렬 컨트롤 초기화
|
||||
function initSortControls() {
|
||||
// 이슈 탭 정렬 컨트롤
|
||||
const issueControls = document.getElementById('issue-sort-controls');
|
||||
if (issueControls) {
|
||||
issueControls.innerHTML = renderSortControl('issue', issueSortOptions, currentIssueSort, currentIssueSortDir);
|
||||
bindSortEvents('issue', (sort, dir) => {
|
||||
currentIssueSort = sort;
|
||||
currentIssueSortDir = dir;
|
||||
renderIssues(document.getElementById('issue-list'), issuesData);
|
||||
});
|
||||
}
|
||||
|
||||
// 작업 탭 아코디언 이슈 정렬 컨트롤
|
||||
const accordionControls = document.getElementById('accordion-issue-sort-controls');
|
||||
if (accordionControls) {
|
||||
accordionControls.innerHTML = renderSortControl('accordion-issue', issueSortOptions, currentAccordionIssueSort, currentAccordionIssueSortDir);
|
||||
bindSortEvents('accordion-issue', (sort, dir) => {
|
||||
currentAccordionIssueSort = sort;
|
||||
currentAccordionIssueSortDir = dir;
|
||||
renderTasks(document.getElementById('task-list'), tasksData);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 정렬 컨트롤 HTML 생성
|
||||
function renderSortControl(prefix, options, currentSort, currentDir) {
|
||||
return `
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-gray-500">이슈 정렬:</span>
|
||||
<select id="${prefix}-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">
|
||||
${options.map(opt =>
|
||||
`<option value="${opt.value}" ${currentSort === opt.value ? 'selected' : ''}>${opt.label}</option>`
|
||||
).join('')}
|
||||
</select>
|
||||
<button id="${prefix}-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 ${currentDir === '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>
|
||||
`;
|
||||
}
|
||||
|
||||
// 정렬 이벤트 바인딩
|
||||
function bindSortEvents(prefix, callback) {
|
||||
const select = document.getElementById(`${prefix}-sort`);
|
||||
const dirBtn = document.getElementById(`${prefix}-sort-dir`);
|
||||
let currentDir = select?.closest('.flex')?.querySelector('button svg')?.classList.contains('rotate-180') ? 'desc' : 'asc';
|
||||
|
||||
select?.addEventListener('change', (e) => {
|
||||
callback(e.target.value, currentDir);
|
||||
});
|
||||
|
||||
dirBtn?.addEventListener('click', () => {
|
||||
currentDir = currentDir === 'asc' ? 'desc' : 'asc';
|
||||
dirBtn.querySelector('svg').classList.toggle('rotate-180');
|
||||
callback(select.value, currentDir);
|
||||
});
|
||||
}
|
||||
|
||||
// 날짜 포맷 함수 (YYYY-MM-DD)
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
@@ -531,8 +617,12 @@ function renderTasks(container, tasks) {
|
||||
<th class="px-1 py-2 text-center font-medium">긴급</th>
|
||||
<th class="px-2 py-2 text-left font-medium">작업명</th>
|
||||
<th class="px-2 py-2 text-left font-medium">타임라인</th>
|
||||
<th class="px-2 py-2 text-center font-medium">이슈</th>
|
||||
<th class="px-2 py-2 text-center font-medium">상태</th>
|
||||
<th class="px-2 py-2 text-center font-medium cursor-pointer hover:bg-gray-100 select-none" onclick="toggleAccordionIssueSort('assignee')" title="클릭: 회사/팀/담당자 정렬">
|
||||
<span class="inline-flex items-center gap-1">이슈<span id="sort-icon-assignee" class="text-gray-400"></span></span>
|
||||
</th>
|
||||
<th class="px-2 py-2 text-center font-medium cursor-pointer hover:bg-gray-100 select-none" onclick="toggleAccordionIssueSort('status')" title="클릭: 상태순 정렬">
|
||||
<span class="inline-flex items-center gap-1">상태<span id="sort-icon-status" class="text-gray-400"></span></span>
|
||||
</th>
|
||||
<th class="px-2 py-2 text-center font-medium">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -768,11 +858,82 @@ function renderTasks(container, tasks) {
|
||||
});
|
||||
}
|
||||
|
||||
// 이슈 정렬 함수 (시작일 → 상태)
|
||||
function sortIssues(issues) {
|
||||
const statusPriority = { open: 0, in_progress: 1, resolved: 2, closed: 3 };
|
||||
// 작업 탭 아코디언 이슈 정렬 토글 (헤더 클릭)
|
||||
function toggleAccordionIssueSort(sortType) {
|
||||
// 같은 헤더 클릭 시 기본정렬로 리셋
|
||||
if (currentAccordionIssueSort === sortType) {
|
||||
currentAccordionIssueSort = null;
|
||||
} else {
|
||||
currentAccordionIssueSort = sortType;
|
||||
}
|
||||
|
||||
// 아이콘 업데이트
|
||||
updateSortIcons();
|
||||
|
||||
// 목록 다시 렌더링
|
||||
renderTasks(document.getElementById('task-list'), tasksData);
|
||||
}
|
||||
|
||||
// 정렬 아이콘 업데이트
|
||||
function updateSortIcons() {
|
||||
const assigneeIcon = document.getElementById('sort-icon-assignee');
|
||||
const statusIcon = document.getElementById('sort-icon-status');
|
||||
|
||||
if (assigneeIcon) {
|
||||
assigneeIcon.textContent = currentAccordionIssueSort === 'assignee' ? '▼' : '';
|
||||
}
|
||||
if (statusIcon) {
|
||||
statusIcon.textContent = currentAccordionIssueSort === 'status' ? '▼' : '';
|
||||
}
|
||||
}
|
||||
|
||||
// 이슈 정렬 함수
|
||||
function sortIssues(issues, sortField = null, sortDir = null) {
|
||||
if (!issues || issues.length === 0) return issues;
|
||||
|
||||
// 아코디언(작업탭)에서 호출 시 currentAccordionIssueSort 사용
|
||||
const sortType = sortField || currentAccordionIssueSort;
|
||||
|
||||
// 회사/팀/담당자 정렬 (assignee)
|
||||
if (sortType === 'assignee') {
|
||||
return [...issues].sort((a, b) => {
|
||||
// client(회사) → team(팀) → assignee_name(담당자) 순서로 정렬
|
||||
const clientA = (a.client || '').toLowerCase();
|
||||
const clientB = (b.client || '').toLowerCase();
|
||||
if (clientA !== clientB) return clientA.localeCompare(clientB, 'ko');
|
||||
|
||||
const teamA = (a.team || '').toLowerCase();
|
||||
const teamB = (b.team || '').toLowerCase();
|
||||
if (teamA !== teamB) return teamA.localeCompare(teamB, 'ko');
|
||||
|
||||
const assigneeA = (a.assignee_name || '').toLowerCase();
|
||||
const assigneeB = (b.assignee_name || '').toLowerCase();
|
||||
return assigneeA.localeCompare(assigneeB, 'ko');
|
||||
});
|
||||
}
|
||||
|
||||
// 상태순 정렬 (status)
|
||||
if (sortType === 'status') {
|
||||
return [...issues].sort((a, b) => {
|
||||
return (issueStatusPriority[a.status] || 99) - (issueStatusPriority[b.status] || 99);
|
||||
});
|
||||
}
|
||||
|
||||
// 이슈탭 드롭다운 정렬
|
||||
if (sortField && issueSortOptions.find(o => o.value === sortField)) {
|
||||
const dir = sortDir || 'asc';
|
||||
const option = issueSortOptions.find(o => o.value === sortField);
|
||||
|
||||
if (typeof SortUtils !== 'undefined' && SortUtils.multiSort) {
|
||||
return SortUtils.multiSort(issues, [
|
||||
{ field: option.value, direction: dir, type: option.type, priority: option.priority },
|
||||
...(option.secondary ? [{ field: option.secondary, direction: 'asc', type: 'priority', priority: issueStatusPriority }] : [])
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// 기본 정렬: 시작일 → 상태
|
||||
return [...issues].sort((a, b) => {
|
||||
// 1. 시작일 기준 정렬 (null은 맨 뒤)
|
||||
if (a.start_date && b.start_date) {
|
||||
const diff = new Date(a.start_date) - new Date(b.start_date);
|
||||
if (diff !== 0) return diff;
|
||||
@@ -781,8 +942,7 @@ function sortIssues(issues) {
|
||||
} else if (!a.start_date && b.start_date) {
|
||||
return 1;
|
||||
}
|
||||
// 2. 시작일이 같거나 없으면 상태로 정렬
|
||||
return (statusPriority[a.status] || 99) - (statusPriority[b.status] || 99);
|
||||
return (issueStatusPriority[a.status] || 99) - (issueStatusPriority[b.status] || 99);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -793,8 +953,8 @@ function renderIssues(container, issues) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 정렬 적용
|
||||
issues = sortIssues(issues);
|
||||
// 현재 이슈 탭 정렬 설정 적용
|
||||
issues = sortIssues(issues, currentIssueSort, currentIssueSortDir);
|
||||
|
||||
const typeLabels = { bug: '버그', feature: '기능', improvement: '개선' };
|
||||
const statusColors = {
|
||||
|
||||
Reference in New Issue
Block a user