feat: 모바일 UX 대폭 개선 + PWA 구현 + 로그인 루프 수정

- 모바일 하단 네비: 메뉴 제거, 4개 핵심 기능(홈/TBM/작업보고/출근) SVG 아이콘
- 모바일 사이드바 스킵: 768px 이하에서 사이드바 미로드, 레이아웃 오프셋 해결
- 모바일 헤더: 햄버거 메뉴 숨김, 본문 margin/overflow 정리
- TBM 모바일: 풀스크린 모달, 저장 버튼 하단 고정, 터치 UX 개선
- PWA: manifest.json, sw.js(network-first), 앱 아이콘, iOS 메타태그, 킬스위치
- 로그인 무한루프 수정: 토큰 만료 검증, 쿠키 정리, loginPage 경로 수정
- 신고 메뉴 tkreport 리다이렉트: navbar + sidebar cross-system-link 적용
- TBM API: 작업장별 안전점검 체크리스트 조회 엔드포인트 추가
- 안전점검 체크리스트 관리 UI 개선
- tkuser: 이슈유형 관리 기능 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-02-24 08:20:50 +09:00
parent 3cc29c03a8
commit d36303101e
60 changed files with 1418 additions and 270 deletions

View File

@@ -1,5 +1,208 @@
/* ===== Issue Types ===== */
/* Placeholder module for issue type CRUD operations.
This file is reserved for future issue category management functionality.
Currently, issue types are managed through System 3 permissions in tkuser-users.js.
*/
/* ===== Issue Types CRUD ===== */
let issueCategories = [], issueItems = [], issueTypesLoaded = false;
let currentIssueType = 'nonconformity';
const SEVERITY_LABEL = { low: '낮음', medium: '보통', high: '높음', critical: '심각' };
const SEVERITY_CLASS = { low: 'bg-gray-50 text-gray-500', medium: 'bg-blue-50 text-blue-600', high: 'bg-amber-50 text-amber-600', critical: 'bg-red-50 text-red-600' };
const TYPE_LABEL = { nonconformity: '부적합', safety: '안전', facility: '시설설비' };
async function loadIssueTypes() {
try {
const [catRes, itemRes] = await Promise.all([
api('/work-issues/categories'),
api('/work-issues/items')
]);
issueCategories = catRes.data || catRes;
issueItems = itemRes.data || itemRes;
issueTypesLoaded = true;
populateIssueCategorySelects();
displayIssueCategories();
} catch (err) {
document.getElementById('issueCategoryList').innerHTML = `<div class="text-red-500 text-center py-6"><i class="fas fa-exclamation-triangle text-xl"></i><p class="text-sm mt-2">${err.message}</p></div>`;
}
}
function populateIssueCategorySelects() {
['newIssueItemCategory', 'editIssueItemCategory'].forEach(id => {
const sel = document.getElementById(id); if (!sel) return;
const val = sel.value;
sel.innerHTML = '<option value="">선택</option>';
issueCategories
.filter(c => c.is_active !== 0 && c.is_active !== false)
.sort((a, b) => (a.display_order || 0) - (b.display_order || 0))
.forEach(c => {
const o = document.createElement('option');
o.value = c.category_id;
o.textContent = `[${TYPE_LABEL[c.category_type] || c.category_type}] ${c.category_name}`;
sel.appendChild(o);
});
sel.value = val;
});
}
function switchIssueType(type) {
currentIssueType = type;
['nonconformity', 'safety', 'facility'].forEach(t => {
const btn = document.getElementById('issueTypeToggle' + t.charAt(0).toUpperCase() + t.slice(1));
if (!btn) return;
if (t === type) {
btn.className = 'px-3 py-1 rounded-md text-xs font-medium bg-slate-700 text-white';
} else {
btn.className = 'px-3 py-1 rounded-md text-xs font-medium text-gray-500 hover:bg-gray-200';
}
});
displayIssueCategories();
}
function displayIssueCategories() {
const c = document.getElementById('issueCategoryList');
const filtered = issueCategories
.filter(cat => cat.category_type === currentIssueType)
.sort((a, b) => (a.display_order || 0) - (b.display_order || 0));
if (!filtered.length) {
c.innerHTML = `<p class="text-gray-400 text-center py-8 text-sm">${TYPE_LABEL[currentIssueType] || currentIssueType} 유형의 카테고리가 없습니다.</p>`;
return;
}
c.innerHTML = filtered.map(cat => {
const items = issueItems
.filter(item => item.category_id === cat.category_id)
.sort((a, b) => (a.display_order || 0) - (b.display_order || 0));
const inactive = cat.is_active === 0 || cat.is_active === false;
return `
<div class="border rounded-lg ${inactive ? 'opacity-60 border-gray-200' : 'border-gray-200'}">
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-t-lg">
<div class="flex-1 min-w-0">
<div class="text-sm font-semibold text-gray-800"><i class="fas fa-layer-group mr-1.5 text-gray-400 text-xs"></i>${escHtml(cat.category_name)}</div>
<div class="text-xs text-gray-500 flex items-center gap-1.5 mt-0.5">
${cat.description ? `<span>${escHtml(cat.description)}</span>` : ''}
<span class="text-gray-400">순서: ${cat.display_order || 0}</span>
${inactive ? '<span class="px-1.5 py-0.5 rounded bg-gray-100 text-gray-400">비활성</span>' : ''}
</div>
</div>
<div class="flex gap-1 ml-2 flex-shrink-0">
<button onclick="editIssueCategory(${cat.category_id})" class="p-1.5 text-slate-500 hover:text-slate-700 hover:bg-slate-200 rounded" title="편집"><i class="fas fa-pen-to-square text-xs"></i></button>
<button onclick="deleteIssueCategory(${cat.category_id},'${escHtml(cat.category_name).replace(/'/g, "\\'")}')" class="p-1.5 text-red-400 hover:text-red-600 hover:bg-red-100 rounded" title="삭제"><i class="fas fa-trash text-xs"></i></button>
</div>
</div>
${items.length ? `<div class="divide-y divide-gray-100">${items.map(item => {
const itemInactive = item.is_active === 0 || item.is_active === false;
return `
<div class="flex items-center justify-between px-3 py-2 ${itemInactive ? 'opacity-50' : ''}">
<div class="flex-1 min-w-0">
<div class="text-sm text-gray-700">${escHtml(item.item_name)}</div>
<div class="text-xs text-gray-400 flex items-center gap-1.5 mt-0.5">
${item.description ? `<span>${escHtml(item.description)}</span>` : ''}
<span class="px-1.5 py-0.5 rounded ${SEVERITY_CLASS[item.severity] || 'bg-gray-50 text-gray-500'}">${SEVERITY_LABEL[item.severity] || item.severity || '-'}</span>
${itemInactive ? '<span class="px-1.5 py-0.5 rounded bg-gray-100 text-gray-400">비활성</span>' : ''}
</div>
</div>
<div class="flex gap-1 ml-2 flex-shrink-0">
<button onclick="editIssueItem(${item.item_id})" class="p-1 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded" title="편집"><i class="fas fa-pen-to-square text-xs"></i></button>
<button onclick="deleteIssueItem(${item.item_id},'${escHtml(item.item_name).replace(/'/g, "\\'")}')" class="p-1 text-red-300 hover:text-red-500 hover:bg-red-50 rounded" title="삭제"><i class="fas fa-trash text-xs"></i></button>
</div>
</div>`;
}).join('')}</div>` : '<div class="px-3 py-2 text-xs text-gray-400">등록된 아이템이 없습니다.</div>'}
</div>`;
}).join('');
}
// ===== Category CRUD =====
document.getElementById('addIssueCategoryForm').addEventListener('submit', async e => {
e.preventDefault();
try {
await api('/work-issues/categories', { method: 'POST', body: JSON.stringify({
category_type: document.querySelector('input[name="newIssueCatType"]:checked').value,
category_name: document.getElementById('newIssueCatName').value.trim(),
description: document.getElementById('newIssueCatDesc').value.trim() || null,
display_order: parseInt(document.getElementById('newIssueCatOrder').value) || 0
})});
showToast('카테고리가 추가되었습니다.');
document.getElementById('addIssueCategoryForm').reset();
await loadIssueTypes();
} catch(e) { showToast(e.message, 'error'); }
});
function editIssueCategory(id) {
const cat = issueCategories.find(c => c.category_id === id); if (!cat) return;
document.getElementById('editIssueCatId').value = cat.category_id;
document.getElementById('editIssueCatName').value = cat.category_name;
document.getElementById('editIssueCatDesc').value = cat.description || '';
document.getElementById('editIssueCatOrder').value = cat.display_order || 0;
document.getElementById('editIssueCatActive').value = (cat.is_active === 0 || cat.is_active === false) ? '0' : '1';
document.getElementById('editIssueCategoryModal').classList.remove('hidden');
}
function closeIssueCategoryModal() { document.getElementById('editIssueCategoryModal').classList.add('hidden'); }
document.getElementById('editIssueCategoryForm').addEventListener('submit', async e => {
e.preventDefault();
try {
await api(`/work-issues/categories/${document.getElementById('editIssueCatId').value}`, { method: 'PUT', body: JSON.stringify({
category_name: document.getElementById('editIssueCatName').value.trim(),
description: document.getElementById('editIssueCatDesc').value.trim() || null,
display_order: parseInt(document.getElementById('editIssueCatOrder').value) || 0,
is_active: document.getElementById('editIssueCatActive').value === '1'
})});
showToast('수정되었습니다.'); closeIssueCategoryModal(); await loadIssueTypes();
} catch(e) { showToast(e.message, 'error'); }
});
async function deleteIssueCategory(id, name) {
if (!confirm(`"${name}" 카테고리를 삭제하시겠습니까?\n소속 아이템도 모두 삭제됩니다.`)) return;
try { await api(`/work-issues/categories/${id}`, { method: 'DELETE' }); showToast('카테고리 삭제 완료'); await loadIssueTypes(); } catch(e) { showToast(e.message, 'error'); }
}
// ===== Item CRUD =====
document.getElementById('addIssueItemForm').addEventListener('submit', async e => {
e.preventDefault();
try {
await api('/work-issues/items', { method: 'POST', body: JSON.stringify({
category_id: parseInt(document.getElementById('newIssueItemCategory').value),
item_name: document.getElementById('newIssueItemName').value.trim(),
description: document.getElementById('newIssueItemDesc').value.trim() || null,
severity: document.getElementById('newIssueItemSeverity').value,
display_order: parseInt(document.getElementById('newIssueItemOrder').value) || 0
})});
showToast('아이템이 추가되었습니다.');
document.getElementById('addIssueItemForm').reset();
await loadIssueTypes();
} catch(e) { showToast(e.message, 'error'); }
});
function editIssueItem(id) {
const item = issueItems.find(i => i.item_id === id); if (!item) return;
document.getElementById('editIssueItemId').value = item.item_id;
populateIssueCategorySelects();
document.getElementById('editIssueItemCategory').value = item.category_id || '';
document.getElementById('editIssueItemName').value = item.item_name;
document.getElementById('editIssueItemDesc').value = item.description || '';
document.getElementById('editIssueItemSeverity').value = item.severity || 'medium';
document.getElementById('editIssueItemOrder').value = item.display_order || 0;
document.getElementById('editIssueItemActive').value = (item.is_active === 0 || item.is_active === false) ? '0' : '1';
document.getElementById('editIssueItemModal').classList.remove('hidden');
}
function closeIssueItemModal() { document.getElementById('editIssueItemModal').classList.add('hidden'); }
document.getElementById('editIssueItemForm').addEventListener('submit', async e => {
e.preventDefault();
try {
await api(`/work-issues/items/${document.getElementById('editIssueItemId').value}`, { method: 'PUT', body: JSON.stringify({
category_id: parseInt(document.getElementById('editIssueItemCategory').value),
item_name: document.getElementById('editIssueItemName').value.trim(),
description: document.getElementById('editIssueItemDesc').value.trim() || null,
severity: document.getElementById('editIssueItemSeverity').value,
display_order: parseInt(document.getElementById('editIssueItemOrder').value) || 0,
is_active: document.getElementById('editIssueItemActive').value === '1'
})});
showToast('수정되었습니다.'); closeIssueItemModal(); await loadIssueTypes();
} catch(e) { showToast(e.message, 'error'); }
});
async function deleteIssueItem(id, name) {
if (!confirm(`"${name}" 아이템을 삭제하시겠습니까?`)) return;
try { await api(`/work-issues/items/${id}`, { method: 'DELETE' }); showToast('아이템 삭제 완료'); await loadIssueTypes(); } catch(e) { showToast(e.message, 'error'); }
}

View File

@@ -21,5 +21,6 @@ function switchTab(name) {
if (name === 'workplaces' && !workplacesLoaded) loadWorkplaces();
if (name === 'tasks' && !tasksLoaded) loadTasksTab();
if (name === 'vacations' && !vacationsLoaded) loadVacationsTab();
if (name === 'issueTypes' && !issueTypesLoaded) loadIssueTypes();
if (name === 'permissions' && !permissionsTabLoaded) loadPermissionsTab();
}