feat: 품목 필드 관리 및 UI 개선

- ItemFieldController API 수정
- ItemFieldSeedingService 로직 개선
- Flow Tester 상세 화면 개선
- 레이아웃 및 프로젝트 상세 화면 수정
- 테이블 정렬 JS 추가
This commit is contained in:
2025-12-12 08:51:54 +09:00
parent 82dbb3cc71
commit 767db6f513
6 changed files with 576 additions and 21 deletions

View File

@@ -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,

View File

@@ -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
View 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;

View File

@@ -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">

View File

@@ -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>

View File

@@ -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 = {