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

@@ -146,7 +146,7 @@ function populateWorkTypeSelects() {
const modalSelect = document.getElementById('modalWorkType');
const options = workTypes.map(wt =>
`<option value="${wt.work_type_id}">${wt.work_type_name}</option>`
`<option value="${wt.id}">${wt.name}</option>`
).join('');
if (filterSelect) {
@@ -204,7 +204,7 @@ function renderBasicChecks() {
console.log('기본 체크항목:', basicChecks.length, '개');
if (basicChecks.length === 0) {
container.innerHTML = renderEmptyState('기본 체크 항목이 없습니다.');
container.innerHTML = renderEmptyState('기본 체크 항목이 없습니다.') + renderInlineAddStandalone('basic');
return;
}
@@ -213,7 +213,7 @@ function renderBasicChecks() {
container.innerHTML = Object.entries(grouped).map(([category, items]) =>
renderChecklistGroup(category, items)
).join('');
).join('') + renderInlineAddStandalone('basic');
}
/**
@@ -229,8 +229,10 @@ function renderWeatherChecks() {
weatherChecks = weatherChecks.filter(c => c.weather_condition === filterValue);
}
const inlineRow = filterValue ? renderInlineAddStandalone('weather') : '';
if (weatherChecks.length === 0) {
container.innerHTML = renderEmptyState('날씨별 체크 항목이 없습니다.');
container.innerHTML = renderEmptyState('날씨별 체크 항목이 없습니다.') + inlineRow;
return;
}
@@ -243,7 +245,7 @@ function renderWeatherChecks() {
const name = conditionInfo?.condition_name || condition;
return renderChecklistGroup(`${icon} ${name}`, items, condition);
}).join('');
}).join('') + inlineRow;
}
/**
@@ -254,6 +256,12 @@ function renderTaskChecks() {
const workTypeId = document.getElementById('workTypeFilter')?.value;
const taskId = document.getElementById('taskFilter')?.value;
// 공정 미선택 시 안내
if (!workTypeId) {
container.innerHTML = renderGuideState('공정을 먼저 선택해주세요.');
return;
}
let taskChecks = allChecks.filter(c => c.check_type === 'task');
if (taskId) {
@@ -264,8 +272,10 @@ function renderTaskChecks() {
taskChecks = taskChecks.filter(c => taskIds.includes(c.task_id));
}
const inlineRow = taskId ? renderInlineAddStandalone('task') : '';
if (taskChecks.length === 0) {
container.innerHTML = renderEmptyState('작업별 체크 항목이 없습니다.');
container.innerHTML = renderEmptyState('작업별 체크 항목이 없습니다.') + inlineRow;
return;
}
@@ -277,7 +287,7 @@ function renderTaskChecks() {
const taskName = task?.task_name || `작업 ${taskId}`;
return renderChecklistGroup(`📋 ${taskName}`, items, null, taskId);
}).join('');
}).join('') + inlineRow;
}
/**
@@ -384,6 +394,18 @@ function renderEmptyState(message) {
`;
}
/**
* 안내 상태 렌더링 (필터 미선택 시)
*/
function renderGuideState(message) {
return `
<div class="empty-state">
<div class="empty-state-icon">👆</div>
<p>${message}</p>
</div>
`;
}
/**
* 날씨 필터 변경
*/
@@ -409,7 +431,7 @@ async function filterByWorkType() {
}
try {
const response = await apiCall(`/tasks/work-type/${workTypeId}`);
const response = await apiCall(`/tasks/by-work-type/${workTypeId}`);
if (response && response.success) {
tasks = response.data || [];
taskSelect.innerHTML = '<option value="">작업 선택</option>' +
@@ -446,7 +468,7 @@ async function loadModalTasks() {
}
try {
const response = await apiCall(`/tasks/work-type/${workTypeId}`);
const response = await apiCall(`/tasks/by-work-type/${workTypeId}`);
if (response && response.success) {
const modalTasks = response.data || [];
taskSelect.innerHTML = '<option value="">작업 선택</option>' +
@@ -486,6 +508,29 @@ function openAddModal() {
}
toggleConditionalFields();
// 날씨별 탭: 현재 필터의 날씨 조건 반영
if (currentTab === 'weather') {
const weatherFilter = document.getElementById('weatherFilter')?.value;
if (weatherFilter) {
document.getElementById('weatherCondition').value = weatherFilter;
}
}
// 작업별 탭: 현재 필터의 공정/작업 반영
if (currentTab === 'task') {
const workTypeId = document.getElementById('workTypeFilter')?.value;
if (workTypeId) {
document.getElementById('modalWorkType').value = workTypeId;
loadModalTasks().then(() => {
const taskId = document.getElementById('taskFilter')?.value;
if (taskId) {
document.getElementById('modalTask').value = taskId;
}
});
}
}
showModal();
}
@@ -660,6 +705,132 @@ async function deleteCheck(checkId) {
}
}
/**
* 인라인 추가 행 렌더링
*/
function renderInlineAddRow(tabType) {
if (tabType === 'basic') {
const categoryOptions = Object.entries(CATEGORIES)
.filter(([key]) => !['WEATHER', 'TASK'].includes(key))
.map(([key, val]) => `<option value="${key}">${val.name}</option>`)
.join('');
return `
<div class="inline-add-row">
<select class="inline-add-select" id="inlineCategory">${categoryOptions}</select>
<input type="text" class="inline-add-input" id="inlineBasicInput"
placeholder="새 체크 항목 입력..." onkeydown="if(event.key==='Enter'){event.preventDefault();addInlineCheck('basic');}">
<button class="inline-add-btn" onclick="addInlineCheck('basic')">추가</button>
</div>
`;
}
if (tabType === 'weather') {
return `
<div class="inline-add-row">
<input type="text" class="inline-add-input" id="inlineWeatherInput"
placeholder="새 날씨별 체크 항목 입력..." onkeydown="if(event.key==='Enter'){event.preventDefault();addInlineCheck('weather');}">
<button class="inline-add-btn" onclick="addInlineCheck('weather')">추가</button>
</div>
`;
}
if (tabType === 'task') {
return `
<div class="inline-add-row">
<input type="text" class="inline-add-input" id="inlineTaskInput"
placeholder="새 작업별 체크 항목 입력..." onkeydown="if(event.key==='Enter'){event.preventDefault();addInlineCheck('task');}">
<button class="inline-add-btn" onclick="addInlineCheck('task')">추가</button>
</div>
`;
}
return '';
}
/**
* 인라인 추가 행을 standalone 컨테이너로 감싸기 (빈 상태용)
*/
function renderInlineAddStandalone(tabType) {
return `<div class="inline-add-standalone">${renderInlineAddRow(tabType)}</div>`;
}
/**
* 인라인으로 체크 항목 추가
*/
async function addInlineCheck(tabType) {
let checkItem, data;
if (tabType === 'basic') {
const input = document.getElementById('inlineBasicInput');
const categorySelect = document.getElementById('inlineCategory');
checkItem = input?.value.trim();
if (!checkItem) { input?.focus(); return; }
data = {
check_type: 'basic',
check_item: checkItem,
check_category: categorySelect?.value || 'PPE',
is_required: true,
display_order: 0
};
} else if (tabType === 'weather') {
const input = document.getElementById('inlineWeatherInput');
checkItem = input?.value.trim();
if (!checkItem) { input?.focus(); return; }
const weatherFilter = document.getElementById('weatherFilter')?.value;
if (!weatherFilter) {
showToast('날씨 조건을 먼저 선택해주세요.', 'error');
return;
}
data = {
check_type: 'weather',
check_item: checkItem,
check_category: 'WEATHER',
weather_condition: weatherFilter,
is_required: true,
display_order: 0
};
} else if (tabType === 'task') {
const input = document.getElementById('inlineTaskInput');
checkItem = input?.value.trim();
if (!checkItem) { input?.focus(); return; }
const taskId = document.getElementById('taskFilter')?.value;
if (!taskId) {
showToast('작업을 먼저 선택해주세요.', 'error');
return;
}
data = {
check_type: 'task',
check_item: checkItem,
check_category: 'TASK',
task_id: parseInt(taskId),
is_required: true,
display_order: 0
};
} else {
return;
}
try {
const response = await apiCall('/tbm/safety-checks', 'POST', data);
if (response && response.success) {
showToast('항목이 추가되었습니다.', 'success');
await loadAllChecks();
renderCurrentTab();
} else {
showToast(response?.message || '추가에 실패했습니다.', 'error');
}
} catch (error) {
console.error('인라인 추가 실패:', error);
showToast('추가 중 오류가 발생했습니다.', 'error');
}
}
/**
* 토스트 메시지 표시
*/
@@ -716,3 +887,4 @@ window.filterByWorkType = filterByWorkType;
window.filterByTask = filterByTask;
window.loadModalTasks = loadModalTasks;
window.toggleConditionalFields = toggleConditionalFields;
window.addInlineCheck = addInlineCheck;