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

@@ -171,11 +171,30 @@
}
}
// 이미 로그인 되어있으면 포털로 (쿠키 또는 localStorage 체크)
// 토큰 만료 확인
function isTokenValid(token) {
try {
var payload = JSON.parse(atob(token.split('.')[1]));
return payload.exp > Math.floor(Date.now() / 1000);
} catch (e) {
return false;
}
}
// 이미 로그인 되어있으면 포털로 (쿠키 또는 localStorage 체크 + 만료 확인)
var existingToken = ssoCookie.get('sso_token') || localStorage.getItem('sso_token');
if (existingToken && existingToken !== 'undefined' && existingToken !== 'null') {
var redirect = new URLSearchParams(location.search).get('redirect');
window.location.href = redirect || '/';
if (isTokenValid(existingToken)) {
var redirect = new URLSearchParams(location.search).get('redirect');
window.location.href = redirect || '/';
} else {
// 만료된 토큰 정리
ssoCookie.remove('sso_token');
ssoCookie.remove('sso_user');
ssoCookie.remove('sso_refresh_token');
localStorage.removeItem('sso_token');
localStorage.removeItem('sso_user');
}
}
</script>
</body>

View File

@@ -48,7 +48,7 @@ function setupRoutes(app) {
const vacationTypeRoutes = require('../routes/vacationTypeRoutes');
const vacationBalanceRoutes = require('../routes/vacationBalanceRoutes');
const visitRequestRoutes = require('../routes/visitRequestRoutes');
// workIssueRoutes removed - moved to System 2 (신고 시스템)
const workIssueRoutes = require('../routes/workIssueRoutes');
const departmentRoutes = require('../routes/departmentRoutes');
const patrolRoutes = require('../routes/patrolRoutes');
const notificationRoutes = require('../routes/notificationRoutes');
@@ -159,14 +159,7 @@ function setupRoutes(app) {
app.use('/api/workplace-visits', visitRequestRoutes); // 출입 신청 및 안전교육 관리
app.use('/api', pageAccessRoutes); // 페이지 접근 권한 관리
app.use('/api/tbm', tbmRoutes); // TBM 시스템
// work-issues moved to System 2 - redirect
app.use('/api/work-issues', (req, res) => {
res.status(301).json({
success: false,
error: '신고 시스템이 분리되었습니다',
redirect: '/report/api/work-issues' + req.url
});
});
app.use('/api/work-issues', workIssueRoutes); // 카테고리/아이템 + 신고 조회 (같은 MariaDB 공유)
app.use('/api/departments', departmentRoutes); // 부서 관리
app.use('/api/patrol', patrolRoutes); // 일일순회점검 시스템
app.use('/api/notifications', notificationRoutes); // 알림 시스템

View File

@@ -176,6 +176,36 @@ const TbmController = {
});
},
/**
* TBM 세션 삭제 (draft 상태만)
*/
deleteSession: (req, res) => {
const { sessionId } = req.params;
TbmModel.deleteSession(sessionId, (err, result) => {
if (err) {
console.error('TBM 세션 삭제 오류:', err);
return res.status(500).json({
success: false,
message: 'TBM 세션 삭제 중 오류가 발생했습니다.',
error: err.message
});
}
if (result.affectedRows === 0) {
return res.status(404).json({
success: false,
message: 'TBM 세션을 찾을 수 없거나 이미 완료된 세션입니다.'
});
}
res.json({
success: true,
message: 'TBM 세션이 삭제되었습니다.'
});
});
},
// ==================== 팀 구성 관련 ====================
/**

View File

@@ -26,7 +26,7 @@ exports.getAllCategories = (req, res) => {
exports.getCategoriesByType = (req, res) => {
const { type } = req.params;
if (!['nonconformity', 'safety'].includes(type)) {
if (!['nonconformity', 'safety', 'facility'].includes(type)) {
return res.status(400).json({ success: false, error: '유효하지 않은 카테고리 타입입니다.' });
}

View File

@@ -97,22 +97,36 @@ const TbmModel = {
w.worker_name as leader_name,
w.job_type as leader_job_type,
w.phone_number as leader_phone,
p.project_name,
p.job_no,
p.site,
wt.name as work_type_name,
wt.category as work_type_category,
t.task_name,
t.description as task_description,
u.username as created_by_username,
u.name as created_by_name
u.name as created_by_name,
COUNT(DISTINCT ta.worker_id) as team_member_count,
first_p.project_name,
first_p.job_no,
first_wt.name as work_type_name,
first_wt.category as work_type_category,
first_t.task_name,
first_t.description as task_description,
first_wp.workplace_name as work_location,
first_wc.category_name as workplace_category_name
FROM tbm_sessions s
LEFT JOIN workers w ON s.leader_id = w.worker_id
LEFT JOIN projects p ON s.project_id = p.project_id
LEFT JOIN work_types wt ON s.work_type_id = wt.id
LEFT JOIN tasks t ON s.task_id = t.task_id
LEFT JOIN users u ON s.created_by = u.user_id
LEFT JOIN tbm_team_assignments ta ON s.session_id = ta.session_id
LEFT JOIN (
SELECT * FROM tbm_team_assignments
WHERE (session_id, assignment_id) IN (
SELECT session_id, MIN(assignment_id)
FROM tbm_team_assignments
GROUP BY session_id
)
) first_ta ON s.session_id = first_ta.session_id
LEFT JOIN projects first_p ON first_ta.project_id = first_p.project_id
LEFT JOIN work_types first_wt ON first_ta.work_type_id = first_wt.id
LEFT JOIN tasks first_t ON first_ta.task_id = first_t.task_id
LEFT JOIN workplaces first_wp ON first_ta.workplace_id = first_wp.workplace_id
LEFT JOIN workplace_categories first_wc ON first_ta.workplace_category_id = first_wc.category_id
WHERE s.session_id = ?
GROUP BY s.session_id
`;
const [rows] = await db.query(sql, [sessionId]);
@@ -174,6 +188,23 @@ const TbmModel = {
}
},
/**
* TBM 세션 삭제 (draft 상태만 가능)
*/
deleteSession: async (sessionId, callback) => {
try {
const db = await getDb();
// draft 상태인 세션만 삭제 허용
const [result] = await db.query(
`DELETE FROM tbm_sessions WHERE session_id = ? AND status = 'draft'`,
[sessionId]
);
callback(null, result);
} catch (err) {
callback(err);
}
},
// ==================== 팀 구성 관련 ====================
/**

View File

@@ -24,6 +24,9 @@ router.put('/sessions/:sessionId', requireAuth, TbmController.updateSession);
// TBM 세션 완료 처리
router.post('/sessions/:sessionId/complete', requireAuth, TbmController.completeSession);
// TBM 세션 삭제 (draft 상태만)
router.delete('/sessions/:sessionId', requireAuth, TbmController.deleteSession);
// ==================== 팀 구성 관련 ====================
// 팀원 추가 (단일)

View File

@@ -1,26 +1,37 @@
<!-- components/mobile-nav.html -->
<!-- 모바일 하단 네비게이션 -->
<!-- 모바일 하단 네비게이션 (4개 핵심 기능) -->
<nav class="mobile-bottom-nav" id="mobileBottomNav">
<a href="/pages/dashboard.html" class="mobile-nav-item" data-page="dashboard">
<span class="mobile-nav-icon">🏠</span>
<svg class="mobile-nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
</svg>
<span class="mobile-nav-label"></span>
</a>
<a href="/pages/work/tbm.html" class="mobile-nav-item" data-page="tbm">
<span class="mobile-nav-icon">📋</span>
<svg class="mobile-nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 11l3 3L22 4"/>
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>
</svg>
<span class="mobile-nav-label">TBM</span>
</a>
<a href="/pages/work/report-create.html" class="mobile-nav-item" data-page="report">
<span class="mobile-nav-icon">📝</span>
<svg class="mobile-nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
<polyline points="10 9 9 9 8 9"/>
</svg>
<span class="mobile-nav-label">작업보고</span>
</a>
<a href="/pages/attendance/checkin.html" class="mobile-nav-item" data-page="checkin">
<span class="mobile-nav-icon"></span>
<svg class="mobile-nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
<span class="mobile-nav-label">출근</span>
</a>
<button class="mobile-nav-item" id="mobileMoreBtn">
<span class="mobile-nav-icon"></span>
<span class="mobile-nav-label">메뉴</span>
</button>
</nav>
<style>
@@ -31,10 +42,10 @@
bottom: 0;
left: 0;
right: 0;
height: 64px;
height: 68px;
background: #ffffff;
border-top: 1px solid #e5e7eb;
box-shadow: 0 -2px 10px rgba(0,0,0,0.1);
box-shadow: 0 -2px 12px rgba(0,0,0,0.08);
z-index: 1000;
padding-bottom: env(safe-area-inset-bottom);
}
@@ -46,9 +57,8 @@
justify-content: space-around;
}
/* 바디 패딩 추가 */
body {
padding-bottom: calc(64px + env(safe-area-inset-bottom)) !important;
padding-bottom: calc(68px + env(safe-area-inset-bottom)) !important;
}
}
@@ -60,76 +70,78 @@
flex: 1;
height: 100%;
text-decoration: none;
color: #6b7280;
color: #9ca3af;
background: none;
border: none;
font-family: inherit;
cursor: pointer;
transition: color 0.2s;
padding: 0.5rem;
padding: 0.5rem 0.25rem;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
position: relative;
transition: color 0.15s;
}
.mobile-nav-item:active {
background: #f3f4f6;
color: #6b7280;
}
.mobile-nav-item.active {
color: #2563eb;
}
.mobile-nav-icon {
font-size: 1.5rem;
line-height: 1;
margin-bottom: 0.25rem;
/* SVG 아이콘 */
.mobile-nav-svg {
width: 26px;
height: 26px;
margin-bottom: 4px;
transition: transform 0.15s;
}
.mobile-nav-label {
font-size: 0.6875rem;
font-weight: 500;
line-height: 1;
letter-spacing: -0.01em;
}
/* 활성 상태 */
.mobile-nav-item.active .mobile-nav-icon {
transform: scale(1.1);
.mobile-nav-item.active {
color: #2563eb;
}
.mobile-nav-item.active .mobile-nav-svg {
transform: scale(1.08);
stroke-width: 2.5;
}
.mobile-nav-item.active .mobile-nav-label {
font-weight: 600;
font-weight: 700;
}
/* 활성 인디케이터 점 */
.mobile-nav-item.active::before {
content: '';
position: absolute;
top: 4px;
width: 4px;
height: 4px;
border-radius: 50%;
background: #2563eb;
}
</style>
<script>
(function() {
// 현재 페이지 하이라이트
const currentPath = window.location.pathname;
const navItems = document.querySelectorAll('.mobile-nav-item[data-page]');
var currentPath = window.location.pathname;
var navItems = document.querySelectorAll('.mobile-nav-item[data-page]');
navItems.forEach(item => {
const href = item.getAttribute('href');
navItems.forEach(function(item) {
var href = item.getAttribute('href');
if (href && currentPath.includes(href.replace('/pages/', '').replace('.html', ''))) {
item.classList.add('active');
}
});
// 대시보드 페이지 체크
if (currentPath.includes('dashboard')) {
document.querySelector('[data-page="dashboard"]')?.classList.add('active');
}
// 더보기 버튼 - 사이드바 열기
const moreBtn = document.getElementById('mobileMoreBtn');
if (moreBtn) {
moreBtn.addEventListener('click', () => {
const sidebar = document.getElementById('sidebarNav');
const overlay = document.getElementById('sidebarOverlay');
if (sidebar) {
sidebar.classList.add('mobile-open');
overlay?.classList.add('show');
document.body.classList.add('sidebar-mobile-open');
}
});
var dashItem = document.querySelector('[data-page="dashboard"]');
if (dashItem) dashItem.classList.add('active');
}
})();
</script>

View File

@@ -56,7 +56,7 @@
<span class="btn-text">대시보드</span>
</a>
<a href="/pages/safety/report.html" class="report-btn">
<a href="https://tkreport.technicalkorea.net" class="report-btn">
<span class="btn-icon">&#9888;</span>
<span class="btn-text">신고</span>
</a>
@@ -739,12 +739,7 @@ body {
}
.mobile-menu-btn {
width: 32px;
height: 32px;
margin-right: 0.25rem;
font-size: 1.125rem;
background: rgba(255, 255, 255, 0.12);
border: none;
display: none !important;
}
.user-profile {

View File

@@ -139,7 +139,7 @@
<a href="/pages/admin/equipments.html" class="nav-item" data-page-key="admin.equipments">
<span class="nav-text">설비 관리</span>
</a>
<a href="/pages/admin/issue-categories.html" class="nav-item" data-page-key="admin.issue_categories">
<a href="#" class="nav-item cross-system-link admin-only" data-system="report" data-path="/pages/admin/issue-categories.html" data-page-key="admin.issue_categories">
<span class="nav-text">신고 카테고리 관리</span>
</a>
<a href="/pages/admin/attendance-report.html" class="nav-item" data-page-key="admin.attendance_report">

View File

@@ -10,6 +10,11 @@
padding: 0 0.5rem !important;
}
html, body {
overflow-x: hidden !important;
max-width: 100vw !important;
}
body {
padding-top: 52px !important;
}
@@ -81,6 +86,19 @@
/* ========== 공통 모바일 스타일 ========== */
@media (max-width: 768px) {
/* 사이드바 마진 완전 제거 */
.dashboard-container,
.dashboard-main,
.page-container,
.main-content,
.work-report-container,
.analysis-container {
margin-left: 0 !important;
margin-right: 0 !important;
max-width: 100% !important;
width: 100% !important;
}
/* 기본 여백 조정 */
.dashboard-main,
.page-container,

View File

@@ -1050,8 +1050,9 @@
@media (max-width: 768px) {
.dashboard-main {
padding: 0.75rem;
margin-left: 0;
margin: 0;
max-width: 100%;
width: 100%;
}
/* 헤더는 항상 가로 배치 유지 (navbar.html에서 관리) */

View File

@@ -496,10 +496,15 @@
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
z-index: 1100;
padding: 1rem;
}
/* 모달 열릴 때 하단 네비게이션 숨기기 */
body.tbm-modal-open .mobile-bottom-nav {
display: none !important;
}
.tbm-modal {
background: white;
border-radius: 16px;
@@ -1037,6 +1042,13 @@
color: #92400e;
}
/* ===== 태스크 그리드 ===== */
.tbm-task-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.5rem;
}
/* ===== 반응형 ===== */
@media (max-width: 768px) {
.tbm-container {
@@ -1075,18 +1087,114 @@
.tbm-worker-select-grid {
grid-template-columns: repeat(2, 1fr);
max-height: 50vh;
}
/* 모달 → 풀스크린 시트 */
.tbm-modal-overlay {
padding: 0;
}
.tbm-modal {
max-width: 100%;
max-height: 100%;
max-width: 100% !important;
width: 100%;
height: 100vh;
height: 100dvh;
max-height: none;
border-radius: 0;
overflow: hidden;
animation: mobileSlideUp 0.25s ease-out;
}
.tbm-modal-header {
flex-shrink: 0;
border-radius: 0;
}
.tbm-modal-header,
.tbm-modal-footer {
border-radius: 0;
.tbm-modal-body {
flex: 1;
min-height: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
padding: 1rem;
}
.tbm-modal-footer {
flex-shrink: 0;
border-radius: 0;
padding: 0.75rem 1rem;
padding-bottom: calc(0.75rem + env(safe-area-inset-bottom));
box-shadow: 0 -2px 10px rgba(0,0,0,0.15);
background: #f8fafc;
border-top: 1px solid #e2e8f0;
}
.tbm-modal-footer .tbm-btn {
flex: 1;
justify-content: center;
}
/* 버튼 최소 터치 영역 44px */
.tbm-btn { min-height: 44px; }
.tbm-btn-sm { min-height: 40px; padding: 0.5rem 1rem; }
/* 셀렉트 버튼 확대 */
.tbm-select-btn {
min-height: 48px;
font-size: 0.9rem;
padding: 0.75rem 1rem;
}
/* 작업자 카드 터치 영역 */
.tbm-worker-select-card { padding: 1rem; }
/* 안전 체크 항목 */
.tbm-safety-item { padding: 0.875rem; min-height: 48px; }
/* 항목 선택 리스트 */
.tbm-item-option { padding: 1rem; min-height: 48px; }
.tbm-item-list { max-height: 60vh; }
/* 삭제 버튼 확대 (28px → 36px) */
.tbm-worker-remove { width: 36px; height: 36px; font-size: 1.25rem; }
/* 세션 카드 액션 버튼 */
.tbm-card-footer {
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.75rem 1rem;
}
.tbm-card-footer .tbm-btn {
flex: 1;
min-width: 0;
justify-content: center;
}
/* 터치 피드백 */
.tbm-btn:active,
.tbm-select-btn:active,
.tbm-worker-select-card:active,
.tbm-safety-item:active,
.tbm-item-option:active,
.tbm-session-card:active {
transform: scale(0.97);
opacity: 0.85;
transition: transform 0.1s, opacity 0.1s;
}
/* 탭 하이라이트 제거 */
.tbm-btn, .tbm-select-btn, .tbm-worker-select-card,
.tbm-safety-item, .tbm-item-option, .tbm-session-card,
.tbm-tab-btn, .tbm-modal-close {
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
}
}
@keyframes mobileSlideUp {
from { transform: translateY(30%); opacity: 0.5; }
to { transform: translateY(0); opacity: 1; }
}
@media (max-width: 480px) {
@@ -1098,6 +1206,11 @@
grid-template-columns: 1fr;
}
.tbm-task-grid {
grid-template-columns: 1fr;
gap: 0.375rem;
}
.tbm-section-header {
flex-direction: column;
align-items: stretch;

View File

@@ -0,0 +1,82 @@
# PWA (Progressive Web App) 가이드
## 개요
tkfb.technicalkorea.net에 PWA를 적용하여 모바일에서 홈 화면 앱처럼 사용 가능합니다.
- **전략**: network-first (항상 네트워크 우선, 실패 시 캐시)
- **목적**: 앱 느낌 제공 (주소창 없음, 스플래시 화면, 홈 아이콘)
- **오프라인**: 제한적 (이전 방문 페이지만 캐시에서 제공)
## 파일 구조
```
web/
├── manifest.json # PWA 매니페스트 (앱 이름, 아이콘, 테마)
├── sw.js # 서비스 워커 (network-first 캐시)
├── js/app-init.js # SW 등록 + manifest 동적 삽입 (setupPWA 함수)
└── img/
├── icon-192x192.png # PWA 아이콘 (192px)
└── icon-512x512.png # PWA 아이콘 (512px)
```
## 주의사항
### sw.js 수정 시 반드시 지킬 것
1. **CACHE_VERSION을 반드시 올릴 것** (예: `tkfb-v1``tkfb-v2`)
- 버전을 안 올리면 사용자가 이전 캐시를 계속 사용
2. **network-first 전략을 유지할 것**
- `cache-first`로 바꾸면 배포해도 사용자에게 반영 안 됨
3. **API 요청은 절대 캐시하지 말 것**
- `if (request.url.includes('/api/')) return;` 라인 유지
4. **가능하면 sw.js를 건드리지 말 것**
- 잘못된 sw.js가 배포되면 최대 24시간 동안 사용자 브라우저에 캐시됨
- 일반 HTML/CSS/JS 수정은 sw.js와 무관하게 정상 반영됨
### 비상 복구 (킬스위치)
sw.js에 문제가 생겼을 때 사용자 브라우저에서 서비스 워커를 해제하는 방법:
```
https://tkfb.technicalkorea.net/pages/dashboard.html?sw-kill
```
이 URL로 접속하면:
- 등록된 서비스 워커 모두 해제
- 캐시 스토리지 전체 삭제
- 페이지 자동 새로고침
### 일반 배포 시
- HTML/CSS/JS 파일 수정 → **sw.js 수정 불필요** (network-first라 항상 최신 파일 가져옴)
- Cloudflare 캐시 제거 → 정상 반영됨
- 버전 파라미터 변경 (`?v=5``?v=6`) → 브라우저 캐시도 우회
### Cloudflare 캐시와 관계
| 상황 | 동작 |
|------|------|
| CF 캐시 제거 + 일반 파일 수정 | 즉시 반영 (network-first) |
| CF 캐시 제거 + sw.js 수정 | 최대 24시간 후 반영 (브라우저 SW 갱신 주기) |
| 네트워크 끊김 | 이전 방문 페이지만 캐시에서 제공 |
| ?sw-kill 사용 | SW + 캐시 전체 삭제, 원래 웹사이트로 동작 |
## 홈 화면 추가 방법
### iPhone (iOS Safari)
1. Safari에서 tkfb.technicalkorea.net 접속
2. 하단 공유 버튼 (□↑) 탭
3. "홈 화면에 추가" 선택
4. "추가" 탭
### Android (Chrome)
1. Chrome에서 tkfb.technicalkorea.net 접속
2. 자동으로 "홈 화면에 추가" 배너 표시 (또는 메뉴 → "앱 설치")
3. "설치" 탭
## 알려진 제한사항
- **iOS**: tkreport, tkqc 외부 링크 클릭 시 Safari가 별도로 열림 (PWA 한계)
- **iOS**: 백그라운드 동기화 미지원
- **전체**: 완전한 오프라인 지원은 아님 (network-first 전략)

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View File

@@ -128,7 +128,8 @@
var url = window.API_BASE_URL + endpoint;
var config = {
method: method,
headers: window.getAuthHeaders()
headers: window.getAuthHeaders(),
cache: 'no-store'
};
if (data && (method === 'POST' || method === 'PUT' || method === 'PATCH' || method === 'DELETE')) {

View File

@@ -77,6 +77,12 @@ function clearAuthData() {
localStorage.removeItem('sso_user');
localStorage.removeItem('userInfo');
localStorage.removeItem('currentUser');
// SSO 쿠키도 삭제 (로그인 페이지 자동 리다이렉트 방지)
var cookieDomain = window.location.hostname.includes('technicalkorea.net')
? '; domain=.technicalkorea.net' : '';
document.cookie = 'sso_token=; path=/; max-age=0' + cookieDomain;
document.cookie = 'sso_user=; path=/; max-age=0' + cookieDomain;
document.cookie = 'sso_refresh_token=; path=/; max-age=0' + cookieDomain;
}
function getAuthHeaders() {

View File

@@ -491,28 +491,35 @@
}
}
// 3. 사이드바 컨테이너 생성 (없으면)
let sidebarContainer = document.getElementById('sidebar-container');
if (!sidebarContainer) {
sidebarContainer = document.createElement('div');
sidebarContainer.id = 'sidebar-container';
document.body.prepend(sidebarContainer);
console.log('📦 사이드바 컨테이너 생성됨');
// 3. 네비바 로드 (모바일이면 사이드바 스킵)
var isMobile = window.innerWidth <= 768;
if (!isMobile) {
// 데스크톱: 사이드바 컨테이너 생성 및 로드
let sidebarContainer = document.getElementById('sidebar-container');
if (!sidebarContainer) {
sidebarContainer = document.createElement('div');
sidebarContainer.id = 'sidebar-container';
document.body.prepend(sidebarContainer);
}
console.log('📥 컴포넌트 로딩 시작 (데스크톱: 네비바+사이드바)');
await Promise.all([
loadComponent('navbar', '#navbar-container', (doc) => processNavbar(doc, currentUser, accessiblePageKeys)),
loadComponent('sidebar-nav', '#sidebar-container', (doc) => processSidebar(doc, currentUser, accessiblePageKeys))
]);
setupNavbarEvents();
setupSidebarEvents();
document.body.classList.add('has-sidebar');
} else {
// 모바일: 네비바만 로드, 사이드바 없음
console.log('📥 컴포넌트 로딩 시작 (모바일: 네비바만)');
await loadComponent('navbar', '#navbar-container', (doc) => processNavbar(doc, currentUser, accessiblePageKeys));
setupNavbarEvents();
}
// 4. 네비바와 사이드바 동시 로드
console.log('📥 컴포넌트 로딩 시작');
await Promise.all([
loadComponent('navbar', '#navbar-container', (doc) => processNavbar(doc, currentUser, accessiblePageKeys)),
loadComponent('sidebar-nav', '#sidebar-container', (doc) => processSidebar(doc, currentUser, accessiblePageKeys))
]);
console.log('✅ 컴포넌트 로딩 완료');
// 5. 이벤트 설정
setupNavbarEvents();
setupSidebarEvents();
document.body.classList.add('has-sidebar');
// 6. 페이지 전환 로딩 인디케이터 설정
setupPageTransitionLoader();
@@ -527,9 +534,69 @@
setTimeout(loadNotifications, 200);
setInterval(loadNotifications, 30000);
// 10. PWA 설정 (manifest + 서비스 워커 + iOS 메타태그)
setupPWA();
console.log('✅ app-init 완료');
}
// ===== PWA 설정 =====
function setupPWA() {
// manifest.json 동적 추가
if (!document.querySelector('link[rel="manifest"]')) {
var manifest = document.createElement('link');
manifest.rel = 'manifest';
manifest.href = '/manifest.json';
document.head.appendChild(manifest);
}
// iOS 홈 화면 앱 메타태그
if (!document.querySelector('meta[name="apple-mobile-web-app-capable"]')) {
var metaTags = [
{ name: 'apple-mobile-web-app-capable', content: 'yes' },
{ name: 'apple-mobile-web-app-status-bar-style', content: 'default' },
{ name: 'apple-mobile-web-app-title', content: 'TK공장' },
{ name: 'theme-color', content: '#1e40af' }
];
metaTags.forEach(function(tag) {
var meta = document.createElement('meta');
meta.name = tag.name;
meta.content = tag.content;
document.head.appendChild(meta);
});
// iOS 아이콘
var appleIcon = document.createElement('link');
appleIcon.rel = 'apple-touch-icon';
appleIcon.href = '/img/icon-192x192.png';
document.head.appendChild(appleIcon);
}
// 서비스 워커 등록 (킬스위치 포함)
if ('serviceWorker' in navigator) {
// 킬스위치: ?sw-kill 파라미터로 서비스 워커 해제
if (window.location.search.includes('sw-kill')) {
navigator.serviceWorker.getRegistrations().then(function(regs) {
regs.forEach(function(r) { r.unregister(); });
caches.keys().then(function(keys) {
keys.forEach(function(k) { caches.delete(k); });
});
console.log('SW 해제 완료');
window.location.replace(window.location.pathname);
});
return;
}
navigator.serviceWorker.register('/sw.js')
.then(function(reg) {
console.log('SW 등록 완료');
})
.catch(function(err) {
console.warn('SW 등록 실패:', err);
});
}
}
// ===== 페이지 전환 로딩 인디케이터 =====
function setupPageTransitionLoader() {
// 로딩 바 스타일 추가

View File

@@ -15,7 +15,7 @@ export const config = {
// 페이지 경로 설정
paths: {
// 로그인 페이지 경로
loginPage: '/index.html',
loginPage: '/login',
// 메인 대시보드 경로 (모든 사용자 공통)
dashboard: '/pages/dashboard.html',
// 하위 호환성을 위한 별칭들

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;

View File

@@ -31,6 +31,31 @@ let loadedDaysCount = 7; // 처음에 로드할 일수
let dateGroupedSessions = {}; // 날짜별로 그룹화된 세션
let allLoadedSessions = []; // 전체 로드된 세션
// 모달 스크롤 잠금
let scrollLockY = 0;
let scrollLockCount = 0;
function lockBodyScroll() {
scrollLockCount++;
if (scrollLockCount > 1) return; // 이미 잠금 상태
scrollLockY = window.scrollY;
document.body.style.overflow = 'hidden';
document.body.style.position = 'fixed';
document.body.style.width = '100%';
document.body.style.top = `-${scrollLockY}px`;
document.body.classList.add('tbm-modal-open');
}
function unlockBodyScroll() {
scrollLockCount--;
if (scrollLockCount > 0) return; // 아직 열린 모달 있음
scrollLockCount = 0;
document.body.style.overflow = '';
document.body.style.position = '';
document.body.style.width = '';
document.body.style.top = '';
window.scrollTo(0, scrollLockY);
document.body.classList.remove('tbm-modal-open');
}
// ==================== 유틸리티 함수 ====================
/**
@@ -541,11 +566,14 @@ function createSessionCard(session) {
${session.status === 'draft' ? `
<div class="tbm-card-footer">
<button class="tbm-btn tbm-btn-primary tbm-btn-sm" onclick="event.stopPropagation(); openTeamCompositionModal(${safeSessionId})">
&#128101; 팀 구성
&#128101; 수정
</button>
<button class="tbm-btn tbm-btn-secondary tbm-btn-sm" onclick="event.stopPropagation(); openSafetyCheckModal(${safeSessionId})">
&#10003; 안전 체크
</button>
<button class="tbm-btn tbm-btn-danger tbm-btn-sm" onclick="event.stopPropagation(); confirmDeleteTbm(${safeSessionId})">
&#128465; 삭제
</button>
</div>
` : ''}
</div>
@@ -591,7 +619,7 @@ function openNewTbmModal() {
renderWorkerTaskList();
document.getElementById('tbmModal').style.display = 'flex';
document.body.style.overflow = 'hidden';
lockBodyScroll();
}
window.openNewTbmModal = openNewTbmModal;
@@ -697,7 +725,7 @@ window.loadTasksByWorkType = loadTasksByWorkType;
// TBM 모달 닫기
function closeTbmModal() {
document.getElementById('tbmModal').style.display = 'none';
document.body.style.overflow = 'auto';
unlockBodyScroll();
}
window.closeTbmModal = closeTbmModal;
@@ -915,7 +943,7 @@ function renderTaskLine(workerData, workerIndex, taskLine, taskIndex) {
return `
<div style="padding: 0.75rem; margin-bottom: 0.5rem; background: #f9fafb; border-radius: 0.5rem; border: 1px solid #e5e7eb;">
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.5rem; margin-bottom: 0.5rem;">
<div class="tbm-task-grid" style="margin-bottom: 0.5rem;">
<!-- 프로젝트 선택 -->
<button type="button"
onclick="openItemSelect('project', ${safeWorkerIndex}, ${safeTaskIndex})"
@@ -992,6 +1020,7 @@ function openWorkerSelectionModal() {
}).join('');
document.getElementById('workerSelectionModal').style.display = 'flex';
lockBodyScroll();
}
window.openWorkerSelectionModal = openWorkerSelectionModal;
@@ -1090,6 +1119,7 @@ window.confirmWorkerSelection = confirmWorkerSelection;
// 작업자 선택 모달 닫기
function closeWorkerSelectionModal() {
document.getElementById('workerSelectionModal').style.display = 'none';
unlockBodyScroll();
selectedWorkersInModal.clear();
}
window.closeWorkerSelectionModal = closeWorkerSelectionModal;
@@ -1168,6 +1198,7 @@ function openBulkSettingModal() {
document.getElementById('bulkWorkplaceBtn').classList.add('btn-secondary');
document.getElementById('bulkSettingModal').style.display = 'flex';
lockBodyScroll();
}
window.openBulkSettingModal = openBulkSettingModal;
@@ -1221,6 +1252,7 @@ window.deselectAllForBulk = deselectAllForBulk;
// 일괄 설정 모달 닫기
function closeBulkSettingModal() {
document.getElementById('bulkSettingModal').style.display = 'none';
unlockBodyScroll();
isBulkMode = false;
}
window.closeBulkSettingModal = closeBulkSettingModal;
@@ -1279,6 +1311,7 @@ function openBulkItemSelect(type) {
`).join('') : '<div style="text-align: center; padding: 2rem; color: #9ca3af;">선택 가능한 항목이 없습니다</div>';
modal.style.display = 'flex';
lockBodyScroll();
}
window.openBulkItemSelect = openBulkItemSelect;
@@ -1318,6 +1351,7 @@ function openBulkWorkplaceSelect() {
isBulkMode = true;
loadWorkplaceCategories();
document.getElementById('workplaceSelectModal').style.display = 'flex';
lockBodyScroll();
}
window.openBulkWorkplaceSelect = openBulkWorkplaceSelect;
@@ -1418,6 +1452,7 @@ function openItemSelect(type, workerIndex, taskIndex) {
`).join('') : '<div style="text-align: center; padding: 2rem; color: #9ca3af;">선택 가능한 항목이 없습니다</div>';
modal.style.display = 'flex';
lockBodyScroll();
}
window.openItemSelect = openItemSelect;
@@ -1449,6 +1484,7 @@ window.selectItem = selectItem;
// 항목 선택 모달 닫기
function closeItemSelectModal() {
document.getElementById('itemSelectModal').style.display = 'none';
unlockBodyScroll();
currentEditingTaskLine = null;
}
window.closeItemSelectModal = closeItemSelectModal;
@@ -1460,12 +1496,14 @@ async function openWorkplaceSelect(workerIndex, taskIndex) {
currentEditingTaskLine = { workerIndex, taskIndex };
await loadWorkplaceCategories();
document.getElementById('workplaceSelectModal').style.display = 'flex';
lockBodyScroll();
}
window.openWorkplaceSelect = openWorkplaceSelect;
// 작업장 선택 모달 닫기
function closeWorkplaceSelectModal() {
document.getElementById('workplaceSelectModal').style.display = 'none';
unlockBodyScroll();
document.getElementById('workplaceSelectionArea').style.display = 'none';
document.getElementById('layoutMapArea').style.display = 'none';
document.getElementById('workplaceList').style.display = 'none';
@@ -1527,19 +1565,34 @@ async function selectCategory(categoryId, categoryName) {
// 해당 카테고리 정보 가져오기
const category = allWorkplaceCategories.find(c => c.category_id === categoryId);
const isMobile = window.innerWidth <= 768;
// 지도 또는 리스트 로드
if (category && category.layout_image) {
// 지도가 있는 경우 - 지도 영역 표시
// 지도가 있는 경우 - 지도를 기본 표시
await loadWorkplaceMap(categoryId, category.layout_image);
document.getElementById('layoutMapArea').style.display = 'block';
if (isMobile) {
// 모바일: 리스트 숨기고 "리스트로 선택" 토글 표시
document.getElementById('workplaceListSection').style.display = 'none';
document.getElementById('toggleListBtn').style.display = 'inline-flex';
document.getElementById('toggleListBtn').textContent = '리스트로 선택';
} else {
// 데스크톱: 리스트도 함께 표시
document.getElementById('workplaceList').style.display = 'flex';
document.getElementById('workplaceListSection').style.display = 'block';
document.getElementById('toggleListBtn').style.display = 'none';
}
} else {
// 지도가 없는 경우 - 리스트만 표시
document.getElementById('layoutMapArea').style.display = 'none';
document.getElementById('workplaceList').style.display = 'flex';
document.getElementById('toggleListBtn').style.display = 'none';
document.getElementById('workplaceList').style.display = 'flex';
document.getElementById('workplaceListSection').style.display = 'block';
}
// 해당 카테고리의 작업장 리스트 로드 (오류 대비용)
// 해당 카테고리의 작업장 리스트 로드
await loadWorkplacesByCategory(categoryId);
// 선택 완료 버튼 비활성화 (작업장 선택 필요)
@@ -1638,22 +1691,18 @@ function confirmWorkplaceSelection() {
}
window.confirmWorkplaceSelection = confirmWorkplaceSelection;
// 리스트 토글 함수 (레거시 호환)
// 리스트 토글 함수
function toggleWorkplaceList() {
const list = document.getElementById('workplaceList');
const icon = document.getElementById('toggleListIcon');
const listSection = document.getElementById('workplaceListSection');
const btn = document.getElementById('toggleListBtn');
if (list.style.display === 'none' || list.style.display === '') {
list.style.display = 'flex';
icon.textContent = '';
btn.textContent = ' 리스트 닫기';
btn.insertBefore(icon, btn.firstChild);
if (listSection.style.display === 'none') {
listSection.style.display = 'block';
document.getElementById('workplaceList').style.display = 'flex';
btn.textContent = '리스트 숨기기';
} else {
list.style.display = 'none';
icon.textContent = '';
btn.textContent = ' 리스트 보기';
btn.insertBefore(icon, btn.firstChild);
listSection.style.display = 'none';
btn.textContent = '리스트로 선택';
}
}
window.toggleWorkplaceList = toggleWorkplaceList;
@@ -1693,8 +1742,10 @@ async function loadWorkplaceMap(categoryId, layoutImagePath) {
mapImage.crossOrigin = 'anonymous';
mapImage.onload = function() {
// 캔버스 크기 설정 (최대 너비 800px)
const maxWidth = 800;
// 캔버스 크기 설정 (모바일 대응)
const maxWidth = window.innerWidth <= 768
? Math.min(window.innerWidth - 32, 600)
: 800;
const scale = mapImage.width > maxWidth ? maxWidth / mapImage.width : 1;
mapCanvas.width = mapImage.width * scale;
@@ -1712,6 +1763,7 @@ async function loadWorkplaceMap(categoryId, layoutImagePath) {
mapImage.onerror = function() {
console.error('❌ 지도 이미지 로드 실패');
document.getElementById('layoutMapArea').style.display = 'none';
document.getElementById('workplaceListSection').style.display = 'block';
document.getElementById('workplaceList').style.display = 'flex';
document.getElementById('toggleListBtn').style.display = 'none';
showToast('지도를 불러올 수 없어 리스트로 표시합니다.', 'warning');
@@ -1779,8 +1831,12 @@ function handleMapClick(event) {
if (!mapCanvas || mapRegions.length === 0) return;
const rect = mapCanvas.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
// CSS 스케일 보정: 캔버스의 논리적 크기와 화면 표시 크기가 다를 수 있음
const scaleX = mapCanvas.width / rect.width;
const scaleY = mapCanvas.height / rect.height;
const x = (event.clientX - rect.left) * scaleX;
const y = (event.clientY - rect.top) * scaleY;
// 클릭한 위치에 있는 영역 찾기
for (let i = mapRegions.length - 1; i >= 0; i--) {
@@ -1894,7 +1950,7 @@ async function openTeamCompositionModal(sessionId) {
renderWorkerTaskList();
document.getElementById('tbmModal').style.display = 'flex';
document.body.style.overflow = 'hidden';
lockBodyScroll();
} catch (error) {
console.error('❌ 팀 구성 로드 오류:', error);
@@ -1964,7 +2020,7 @@ window.deselectAllWorkers = deselectAllWorkers;
// 팀 구성 모달 닫기
function closeTeamModal() {
document.getElementById('teamModal').style.display = 'none';
document.body.style.overflow = 'auto';
unlockBodyScroll();
}
window.closeTeamModal = closeTeamModal;
@@ -2094,7 +2150,7 @@ async function openSafetyCheckModal(sessionId) {
container.innerHTML = html;
document.getElementById('safetyModal').style.display = 'flex';
document.body.style.overflow = 'hidden';
lockBodyScroll();
} catch (error) {
console.error('❌ 안전 체크 조회 오류:', error);
@@ -2183,7 +2239,7 @@ function renderCheckItems(items) {
// 안전 체크 모달 닫기
function closeSafetyModal() {
document.getElementById('safetyModal').style.display = 'none';
document.body.style.overflow = 'auto';
unlockBodyScroll();
}
window.closeSafetyModal = closeSafetyModal;
@@ -2226,14 +2282,14 @@ function openCompleteTbmModal(sessionId) {
document.getElementById('endTime').value = timeString;
document.getElementById('completeModal').style.display = 'flex';
document.body.style.overflow = 'hidden';
lockBodyScroll();
}
window.openCompleteTbmModal = openCompleteTbmModal;
// 완료 모달 닫기
function closeCompleteModal() {
document.getElementById('completeModal').style.display = 'none';
document.body.style.overflow = 'auto';
unlockBodyScroll();
}
window.closeCompleteModal = closeCompleteModal;
@@ -2289,46 +2345,86 @@ async function viewTbmSession(sessionId) {
}
// 기본 정보 표시
const leaderDisplay = session.leader_name || session.created_by_name || '-';
const dateDisplay = formatDate(session.session_date) || '-';
const statusMap = { draft: '진행중', completed: '완료', cancelled: '취소' };
const statusText = statusMap[session.status] || session.status;
const basicInfo = document.getElementById('detailBasicInfo');
basicInfo.innerHTML = `
<div style="padding: 0.75rem; background: #f9fafb; border-radius: 0.375rem;">
<div style="font-size: 0.75rem; color: #6b7280; margin-bottom: 0.25rem;">팀장</div>
<div style="font-weight: 600; color: #111827;">${session.leader_name}</div>
<div style="font-size: 0.75rem; color: #6b7280; margin-bottom: 0.25rem;">입력자</div>
<div style="font-weight: 600; color: #111827;">${escapeHtml(leaderDisplay)}</div>
</div>
<div style="padding: 0.75rem; background: #f9fafb; border-radius: 0.375rem;">
<div style="font-size: 0.75rem; color: #6b7280; margin-bottom: 0.25rem;">날짜</div>
<div style="font-weight: 600; color: #111827;">${session.session_date}</div>
<div style="font-weight: 600; color: #111827;">${escapeHtml(dateDisplay)}</div>
</div>
<div style="padding: 0.75rem; background: #f9fafb; border-radius: 0.375rem;">
<div style="font-size: 0.75rem; color: #6b7280; margin-bottom: 0.25rem;">프로젝트</div>
<div style="font-weight: 600; color: #111827;">${session.project_name || '-'}</div>
<div style="font-size: 0.75rem; color: #6b7280; margin-bottom: 0.25rem;">상태</div>
<div style="font-weight: 600; color: #111827;">${escapeHtml(statusText)}</div>
</div>
<div style="padding: 0.75rem; background: #f9fafb; border-radius: 0.375rem;">
<div style="font-size: 0.75rem; color: #6b7280; margin-bottom: 0.25rem;">작업 장소</div>
<div style="font-weight: 600; color: #111827;">${session.work_location || '-'}</div>
<div style="font-size: 0.75rem; color: #6b7280; margin-bottom: 0.25rem;">팀원 수</div>
<div style="font-weight: 600; color: #111827;">${parseInt(session.team_member_count) || team.length}</div>
</div>
<div style="padding: 0.75rem; background: #f9fafb; border-radius: 0.375rem; grid-column: span 2;">
<div style="font-size: 0.75rem; color: #6b7280; margin-bottom: 0.25rem;">작업 내용</div>
<div style="color: #111827;">${session.work_description || '-'}</div>
</div>
${session.safety_notes ? `
<div style="padding: 0.75rem; background: #fef3c7; border-radius: 0.375rem; grid-column: span 2;">
<div style="font-size: 0.75rem; color: #92400e; margin-bottom: 0.25rem;">⚠️ 안전 특이사항</div>
<div style="color: #78350f;">${session.safety_notes}</div>
${session.project_name ? `
<div style="padding: 0.75rem; background: #f9fafb; border-radius: 0.375rem;">
<div style="font-size: 0.75rem; color: #6b7280; margin-bottom: 0.25rem;">프로젝트</div>
<div style="font-weight: 600; color: #111827;">${escapeHtml(session.project_name)}</div>
</div>
` : ''}
${session.work_location ? `
<div style="padding: 0.75rem; background: #f9fafb; border-radius: 0.375rem;">
<div style="font-size: 0.75rem; color: #6b7280; margin-bottom: 0.25rem;">작업장</div>
<div style="font-weight: 600; color: #111827;">${escapeHtml(session.work_location)}</div>
</div>
` : ''}
`;
// 팀 구성 표시
const teamMembers = document.getElementById('detailTeamMembers');
// 팀 구성 표시 (작업자별 작업 정보 포함)
const teamContainer = document.getElementById('detailTeamMembers');
if (team.length === 0) {
teamMembers.innerHTML = '<p style="color: #6b7280; font-size: 0.875rem;">등록된 팀원이 없습니다.</p>';
teamContainer.innerHTML = '<p style="color: #6b7280; font-size: 0.875rem;">등록된 팀원이 없습니다.</p>';
} else {
teamMembers.innerHTML = team.map(member => `
<div style="padding: 0.75rem; background: #f9fafb; border-radius: 0.375rem; border: 1px solid #e5e7eb;">
<div style="font-weight: 600; color: #111827; margin-bottom: 0.25rem;">${member.worker_name}</div>
<div style="font-size: 0.75rem; color: #6b7280;">${member.job_type || ''}</div>
${member.is_present ? '' : '<div style="font-size: 0.75rem; color: #ef4444; margin-top: 0.25rem;">결석</div>'}
// 작업자별로 그룹화
const workerMap = new Map();
team.forEach(member => {
if (!workerMap.has(member.worker_id)) {
workerMap.set(member.worker_id, {
worker_name: member.worker_name,
job_type: member.job_type,
is_present: member.is_present,
tasks: []
});
}
workerMap.get(member.worker_id).tasks.push(member);
});
teamContainer.style.display = 'flex';
teamContainer.style.flexDirection = 'column';
teamContainer.style.gap = '0.75rem';
teamContainer.style.gridTemplateColumns = '';
teamContainer.innerHTML = Array.from(workerMap.values()).map(worker => `
<div style="border: 1px solid #e5e7eb; border-radius: 0.5rem; overflow: hidden;">
<div style="padding: 0.625rem 0.875rem; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; display: flex; align-items: center; justify-content: space-between;">
<div>
<span style="font-weight: 600;">${escapeHtml(worker.worker_name)}</span>
<span style="font-size: 0.75rem; opacity: 0.85; margin-left: 0.25rem;">${escapeHtml(worker.job_type || '')}</span>
</div>
${!worker.is_present ? '<span style="font-size: 0.75rem; background: rgba(239,68,68,0.8); padding: 0.125rem 0.5rem; border-radius: 4px;">결석</span>' : ''}
</div>
<div style="padding: 0.625rem 0.875rem;">
${worker.tasks.map(t => `
<div style="display: flex; flex-wrap: wrap; gap: 0.375rem; margin-bottom: 0.375rem;">
${t.project_name ? `<span style="font-size: 0.75rem; padding: 0.125rem 0.5rem; background: #dbeafe; color: #1e40af; border-radius: 4px;">${escapeHtml(t.project_name)}</span>` : ''}
${t.work_type_name ? `<span style="font-size: 0.75rem; padding: 0.125rem 0.5rem; background: #fef3c7; color: #92400e; border-radius: 4px;">${escapeHtml(t.work_type_name)}</span>` : ''}
${t.task_name ? `<span style="font-size: 0.75rem; padding: 0.125rem 0.5rem; background: #dcfce7; color: #166534; border-radius: 4px;">${escapeHtml(t.task_name)}</span>` : ''}
${t.workplace_name ? `<span style="font-size: 0.75rem; padding: 0.125rem 0.5rem; background: #f1f5f9; color: #475569; border-radius: 4px;">${escapeHtml((t.workplace_category_name ? t.workplace_category_name + ' > ' : '') + t.workplace_name)}</span>` : ''}
</div>
`).join('')}
</div>
</div>
`).join('');
}
@@ -2371,8 +2467,28 @@ async function viewTbmSession(sessionId) {
`).join('');
}
// 푸터 버튼 동적 생성
const footer = document.getElementById('detailModalFooter');
const safeId = parseInt(session.session_id) || 0;
console.log('📋 TBM 상세 - session_id:', safeId, 'status:', session.status);
if (session.status === 'draft') {
footer.innerHTML = `
<button type="button" class="tbm-btn tbm-btn-danger" onclick="confirmDeleteTbm(${safeId})">
삭제
</button>
<button type="button" class="tbm-btn tbm-btn-primary" onclick="closeDetailModal(); openTeamCompositionModal(${safeId})">
수정
</button>
<button type="button" class="tbm-btn tbm-btn-secondary" onclick="closeDetailModal()">닫기</button>
`;
} else {
footer.innerHTML = `
<button type="button" class="tbm-btn tbm-btn-secondary" onclick="closeDetailModal()">닫기</button>
`;
}
document.getElementById('detailModal').style.display = 'flex';
document.body.style.overflow = 'hidden';
lockBodyScroll();
} catch (error) {
console.error('❌ TBM 상세 조회 오류:', error);
@@ -2381,10 +2497,42 @@ async function viewTbmSession(sessionId) {
}
window.viewTbmSession = viewTbmSession;
// TBM 삭제 확인
function confirmDeleteTbm(sessionId) {
if (!confirm('이 TBM을 삭제하시겠습니까?\n삭제 후 복구할 수 없습니다.')) return;
deleteTbmSession(sessionId);
}
window.confirmDeleteTbm = confirmDeleteTbm;
// TBM 세션 삭제
async function deleteTbmSession(sessionId) {
try {
const response = await window.apiCall(`/tbm/sessions/${sessionId}`, 'DELETE');
if (response && response.success) {
showToast('TBM이 삭제되었습니다.', 'success');
closeDetailModal();
// 목록 새로고침
if (currentTab === 'tbm-input') {
await loadTodayOnlyTbm();
} else {
await loadRecentTbmGroupedByDate();
}
} else {
showToast(response?.message || 'TBM 삭제에 실패했습니다.', 'error');
}
} catch (error) {
console.error('❌ TBM 삭제 오류:', error);
showToast('TBM 삭제 중 오류가 발생했습니다.', 'error');
}
}
window.deleteTbmSession = deleteTbmSession;
// 상세보기 모달 닫기
function closeDetailModal() {
document.getElementById('detailModal').style.display = 'none';
document.body.style.overflow = 'auto';
unlockBodyScroll();
}
window.closeDetailModal = closeDetailModal;
@@ -2448,7 +2596,7 @@ async function openHandoverModal(sessionId) {
document.getElementById('handoverNotes').value = '';
document.getElementById('handoverModal').style.display = 'flex';
document.body.style.overflow = 'hidden';
lockBodyScroll();
} catch (error) {
console.error('❌ 인계 모달 열기 오류:', error);
@@ -2460,7 +2608,7 @@ window.openHandoverModal = openHandoverModal;
// 인계 모달 닫기
function closeHandoverModal() {
document.getElementById('handoverModal').style.display = 'none';
document.body.style.overflow = 'auto';
unlockBodyScroll();
}
window.closeHandoverModal = closeHandoverModal;

View File

@@ -0,0 +1,24 @@
{
"name": "TK 공장관리 - 테크니컬코리아",
"short_name": "TK공장",
"description": "테크니컬코리아 공장관리 시스템",
"start_url": "/pages/dashboard.html",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#1e40af",
"orientation": "any",
"icons": [
{
"src": "/img/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/img/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}

View File

@@ -279,7 +279,7 @@
<!-- JavaScript -->
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=2" defer></script>
<script src="/js/app-init.js?v=3" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script>
<script src="/js/admin-settings.js?v=9"></script>
</body>

View File

@@ -8,7 +8,7 @@
<link rel="stylesheet" href="/css/admin-pages.css?v=8">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=2" defer></script>
<script src="/js/app-init.js?v=3" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script>
<style>
.comparison-grid {

View File

@@ -8,7 +8,7 @@
<link rel="stylesheet" href="/css/admin-pages.css?v=8">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=2" defer></script>
<script src="/js/app-init.js?v=3" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script>
<style>
.department-grid {

View File

@@ -9,7 +9,7 @@
<link rel="stylesheet" href="/css/equipment-detail.css?v=1">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=2" defer></script>
<script src="/js/app-init.js?v=3" defer></script>
</head>
<body>
<!-- 네비게이션 바 -->

View File

@@ -9,7 +9,7 @@
<link rel="stylesheet" href="/css/equipment-management.css?v=1">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=2" defer></script>
<script src="/js/app-init.js?v=3" defer></script>
</head>
<body>
<!-- 네비게이션 바 -->

View File

@@ -8,7 +8,7 @@
<link rel="stylesheet" href="/css/admin-pages.css?v=8">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=2" defer></script>
<script src="/js/app-init.js?v=3" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script>
<style>
.type-tabs {

View File

@@ -8,7 +8,7 @@
<link rel="stylesheet" href="/css/admin-pages.css?v=8">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=2" defer></script>
<script src="/js/app-init.js?v=3" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script>
<style>
.notification-page-container {

View File

@@ -367,7 +367,7 @@
</div>
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=2" defer></script>
<script src="/js/app-init.js?v=3" defer></script>
<script>
let allProjects = [];
let filteredProjects = [];

View File

@@ -8,7 +8,7 @@
<link rel="stylesheet" href="/css/admin-pages.css?v=8">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=2" defer></script>
<script src="/js/app-init.js?v=3" defer></script>
<style>
.page-wrapper { padding: 1rem 1.5rem; max-width: 1400px; }
.page-header {

View File

@@ -9,7 +9,7 @@
<link rel="icon" type="image/png" href="/img/favicon.png">
<!-- 최적화된 로딩: API 설정 → 앱 초기화 (병렬 컴포넌트 로딩) -->
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=2" defer></script>
<script src="/js/app-init.js?v=3" defer></script>
<!-- instant.page: 링크 호버 시 페이지 프리로딩 -->
<script src="https://instant.page/5.2.0" type="module"></script>
<style>

View File

@@ -9,7 +9,7 @@
<link rel="stylesheet" href="/css/workplace-management.css?v=7">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=2" defer></script>
<script src="/js/app-init.js?v=3" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script>
</head>
<body>

View File

@@ -8,7 +8,7 @@
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=2" defer></script>
<script src="/js/app-init.js?v=3" defer></script>
<style>
.page-wrapper {
padding: 1.5rem;

View File

@@ -9,7 +9,7 @@
<link rel="stylesheet" href="/css/mobile.css?v=1">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=2" defer></script>
<script src="/js/app-init.js?v=3" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script>
<style>
.page-wrapper {

View File

@@ -8,7 +8,7 @@
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=2" defer></script>
<script src="/js/app-init.js?v=3" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script>
</head>
<body>

View File

@@ -8,7 +8,7 @@
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=2" defer></script>
<script src="/js/app-init.js?v=3" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script>
<style>
/* 테이블 컨테이너 */

View File

@@ -8,7 +8,7 @@
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=2" defer></script>
<script src="/js/app-init.js?v=3" defer></script>
<style>
.page-wrapper {
padding: 1.5rem;

View File

@@ -13,7 +13,7 @@
<!-- 스크립트 -->
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=2" defer></script>
<script src="/js/app-init.js?v=3" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script>
<script type="module" src="/js/vacation-allocation.js" defer></script>
</head>

View File

@@ -8,7 +8,7 @@
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=2" defer></script>
<script src="/js/app-init.js?v=3" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script>
<style>
.tabs {

View File

@@ -8,7 +8,7 @@
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=2" defer></script>
<script src="/js/app-init.js?v=3" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script>
</head>
<body>

View File

@@ -8,7 +8,7 @@
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=2" defer></script>
<script src="/js/app-init.js?v=3" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script>
<style>
.tabs {

View File

@@ -8,7 +8,7 @@
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=2" defer></script>
<script src="/js/app-init.js?v=3" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script>
</head>
<body>

View File

@@ -9,7 +9,7 @@
<link rel="stylesheet" href="/css/mobile.css?v=1">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=2" defer></script>
<script src="/js/app-init.js?v=3" defer></script>
<style>
.page-wrapper {
padding: 1.5rem;

View File

@@ -10,17 +10,17 @@
<!-- preconnect는 Gateway 프록시 사용 시 불필요 -->
<link rel="preload" href="/css/design-system.css" as="style">
<link rel="preload" href="/js/api-base.js" as="script">
<link rel="preload" href="/js/app-init.js?v=2" as="script">
<link rel="preload" href="/js/app-init.js?v=3" as="script">
<!-- 모던 디자인 시스템 적용 -->
<link rel="stylesheet" href="/css/design-system.css">
<link rel="stylesheet" href="/css/modern-dashboard.css?v=2">
<link rel="stylesheet" href="/css/mobile.css?v=1">
<link rel="stylesheet" href="/css/modern-dashboard.css?v=3">
<link rel="stylesheet" href="/css/mobile.css?v=2">
<link rel="icon" type="image/png" href="/img/favicon.png">
<!-- 최적화된 로딩: API 설정 → 앱 초기화 (병렬 컴포넌트 로딩) -->
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=2" defer></script>
<script src="/js/app-init.js?v=3" defer></script>
<script type="module" src="/js/modern-dashboard.js?v=10" defer></script>
<script type="module" src="/js/group-leader-dashboard.js?v=1" defer></script>
<script src="/js/workplace-status.js" defer></script>

View File

@@ -9,7 +9,7 @@
<link rel="stylesheet" href="/css/daily-patrol.css?v=4">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=2" defer></script>
<script src="/js/app-init.js?v=3" defer></script>
</head>
<body>
<!-- 네비게이션 바 -->

View File

@@ -9,7 +9,7 @@
<link rel="stylesheet" href="/css/zone-detail.css?v=3">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=2" defer></script>
<script src="/js/app-init.js?v=3" defer></script>
</head>
<body>
<!-- 네비게이션 바 -->

View File

@@ -29,23 +29,6 @@
margin: 0;
}
.btn-add {
padding: 0.75rem 1.5rem;
background: linear-gradient(135deg, #3b82f6, #2563eb);
color: white;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.5rem;
}
.btn-add:hover {
background: linear-gradient(135deg, #2563eb, #1d4ed8);
}
/* 탭 메뉴 */
.tab-menu {
display: flex;
@@ -401,6 +384,65 @@
margin-bottom: 1rem;
}
/* 인라인 추가 행 */
.inline-add-row {
display: flex;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
background: #f9fafb;
border-top: 1px dashed #e5e7eb;
align-items: center;
}
.inline-add-input {
flex: 1;
padding: 0.5rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 0.9rem;
}
.inline-add-input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
}
.inline-add-btn {
padding: 0.5rem 1rem;
background: #3b82f6;
color: white;
border: none;
border-radius: 6px;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
}
.inline-add-btn:hover {
background: #2563eb;
}
.inline-add-select {
padding: 0.5rem;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 0.85rem;
max-width: 160px;
}
.inline-add-standalone {
background: white;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
margin-bottom: 1rem;
overflow: hidden;
}
.inline-add-standalone .inline-add-row {
border-top: none;
}
/* 날씨 아이콘 */
.weather-icon {
display: inline-flex;
@@ -437,9 +479,6 @@
<div class="page-container">
<div class="page-header">
<h1 class="page-title">안전 체크리스트 관리</h1>
<button class="btn-add" onclick="openAddModal()">
<span>+</span> 항목 추가
</button>
</div>
<!-- 탭 메뉴 -->
@@ -589,7 +628,7 @@
</div>
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=2" defer></script>
<script src="/js/app-init.js?v=3" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script>
<script type="module" src="/js/safety-checklist-manage.js"></script>
</body>

View File

@@ -9,7 +9,7 @@
<link rel="stylesheet" href="/css/project-management.css?v=3">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=2" defer></script>
<script src="/js/app-init.js?v=3" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script>
<style>
.status-tabs {

View File

@@ -9,7 +9,7 @@
<link rel="stylesheet" href="/css/project-management.css?v=3">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=2" defer></script>
<script src="/js/app-init.js?v=3" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script>
<style>
/* 스텝 인디케이터 */

View File

@@ -9,7 +9,7 @@
<link rel="stylesheet" href="/css/project-management.css?v=3">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=2" defer></script>
<script src="/js/app-init.js?v=3" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script>
<style>
.training-container {

View File

@@ -9,7 +9,7 @@
<link rel="stylesheet" href="/css/project-management.css?v=3">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=2" defer></script>
<script src="/js/app-init.js?v=3" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script>
<style>
.visit-form-container {

View File

@@ -11,7 +11,7 @@
<link rel="stylesheet" href="/css/work-analysis.css?v=41">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=2" defer></script>
<script src="/js/app-init.js?v=3" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.js"></script>
</head>

View File

@@ -9,7 +9,7 @@
<link rel="stylesheet" href="/css/project-management.css?v=3">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=2" defer></script>
<script src="/js/app-init.js?v=3" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script>
<style>
/* 통계 카드 */

View File

@@ -10,7 +10,7 @@
<link rel="icon" type="image/png" href="/img/favicon.png">
<!-- 최적화된 로딩 -->
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=2" defer></script>
<script src="/js/app-init.js?v=3" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script>
</head>
<body>
@@ -169,7 +169,7 @@
<!-- 스크립트 -->
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=2" defer></script>
<script src="/js/app-init.js?v=3" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script>
<!-- 작업보고서 모듈 (리팩토링된 구조) -->

View File

@@ -6,12 +6,12 @@
<title>TBM 관리 | (주)테크니컬코리아</title>
<link rel="stylesheet" href="/css/design-system.css">
<link rel="stylesheet" href="/css/common.css?v=2">
<link rel="stylesheet" href="/css/tbm.css?v=1">
<link rel="stylesheet" href="/css/tbm.css?v=5">
<link rel="stylesheet" href="/css/mobile.css?v=1">
<link rel="icon" type="image/png" href="/img/favicon.png">
<!-- 최적화된 로딩: API 설정 → 앱 초기화 (병렬 컴포넌트 로딩) -->
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=2" defer></script>
<script src="/js/app-init.js?v=3" defer></script>
<!-- instant.page: 링크 호버 시 페이지 프리로딩 -->
<script src="https://instant.page/5.2.0" type="module"></script>
</head>
@@ -220,7 +220,7 @@
</div>
<!-- 일괄 설정 모달 -->
<div id="bulkSettingModal" class="tbm-modal-overlay" style="display: none; z-index: 1001;">
<div id="bulkSettingModal" class="tbm-modal-overlay" style="display: none; z-index: 1101;">
<div class="tbm-modal" style="max-width: 700px;">
<div class="tbm-modal-header">
<h2 class="tbm-modal-title">
@@ -310,7 +310,7 @@
</div>
<!-- 작업자 선택 모달 -->
<div id="workerSelectionModal" class="tbm-modal-overlay" style="display: none; z-index: 1001;">
<div id="workerSelectionModal" class="tbm-modal-overlay" style="display: none; z-index: 1101;">
<div class="tbm-modal" style="max-width: 800px;">
<div class="tbm-modal-header">
<h2 class="tbm-modal-title">
@@ -342,7 +342,7 @@
</div>
<!-- 항목 선택 모달 (프로젝트/공정/작업 선택용) -->
<div id="itemSelectModal" class="tbm-modal-overlay" style="display: none; z-index: 1002;">
<div id="itemSelectModal" class="tbm-modal-overlay" style="display: none; z-index: 1102;">
<div class="tbm-modal" style="max-width: 600px;">
<div class="tbm-modal-header">
<h2 class="tbm-modal-title" id="itemSelectModalTitle">항목 선택</h2>
@@ -362,7 +362,7 @@
</div>
<!-- 작업장 선택 모달 (2단계: 공장 → 작업장) -->
<div id="workplaceSelectModal" class="tbm-modal-overlay" style="display: none; z-index: 1002;">
<div id="workplaceSelectModal" class="tbm-modal-overlay" style="display: none; z-index: 1102;">
<div class="tbm-modal" style="max-width: 1000px;">
<div class="tbm-modal-header">
<h2 class="tbm-modal-title">
@@ -384,7 +384,7 @@
</div>
</div>
<!-- 2단계: 작업장 선택 (지도 + 리스트) -->
<!-- 2단계: 작업장 선택 (지도 기본 + 리스트 토글) -->
<div id="workplaceSelectionArea" style="display: none;">
<div class="tbm-form-section">
<h3 class="tbm-form-section-title">
@@ -392,8 +392,8 @@
작업장 선택
</h3>
<!-- 지도 기반 선택 영역 -->
<div id="layoutMapArea" style="display: none; margin-bottom: 1rem; padding: 1rem; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 10px;">
<!-- 지도 기반 선택 (기본 표시) -->
<div id="layoutMapArea" style="display: none; padding: 1rem; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 10px;">
<div style="font-size: 0.875rem; color: #64748b; margin-bottom: 0.75rem;">
지도에서 작업장을 클릭하여 선택하세요
</div>
@@ -402,18 +402,17 @@
</div>
</div>
<!-- 리스트 기반 선택 (오류 대비용) -->
<div>
<div style="font-size: 0.875rem; color: #64748b; margin-bottom: 0.75rem; display: flex; align-items: center; justify-content: space-between;">
<span>리스트에서 선택 (지도 오류 시)</span>
<button type="button" class="tbm-btn tbm-btn-secondary tbm-btn-sm" onclick="toggleWorkplaceList()" id="toggleListBtn">
<span id="toggleListIcon">&#9660;</span>
리스트 보기
</button>
</div>
<div id="workplaceList" class="tbm-item-list" style="display: none; max-height: 250px;">
<div style="color: #94a3b8; text-align: center; padding: 2rem;">
공장을 먼저 선택해주세요
<!-- 리스트 기반 선택 (모바일에서 토글) -->
<div style="margin-top: 0.75rem;">
<button type="button" class="tbm-btn tbm-btn-secondary tbm-btn-sm"
onclick="toggleWorkplaceList()" id="toggleListBtn" style="display: none;">
리스트로 선택
</button>
<div id="workplaceListSection">
<div id="workplaceList" class="tbm-item-list">
<div style="color: #94a3b8; text-align: center; padding: 2rem;">
공장을 먼저 선택해주세요
</div>
</div>
</div>
</div>
@@ -652,7 +651,7 @@
</div>
</div>
<div class="tbm-modal-footer">
<div class="tbm-modal-footer" id="detailModalFooter">
<button type="button" class="tbm-btn tbm-btn-secondary" onclick="closeDetailModal()">닫기</button>
</div>
</div>
@@ -668,7 +667,7 @@
<script src="/js/tbm/api.js?v=1"></script>
<!-- 기존 UI 로직 (점진적 마이그레이션) -->
<script type="module" src="/js/tbm.js?v=4"></script>
<script type="module" src="/js/tbm.js?v=8"></script>
<!-- 모바일 하단 네비게이션 -->
<div id="mobile-nav-container"></div>

73
system1-factory/web/sw.js Normal file
View File

@@ -0,0 +1,73 @@
// sw.js - TK공장관리 Service Worker (network-first)
// 주의: 이 파일을 수정할 때는 반드시 CACHE_VERSION을 올려주세요.
// 잘못된 수정은 사용자 브라우저에 최대 24시간 캐시됩니다.
// 자세한 내용: /docs/PWA-GUIDE.md
const CACHE_VERSION = 'tkfb-v3';
const CACHE_NAME = `tkfb-cache-${CACHE_VERSION}`;
// 캐시할 정적 리소스 (앱 셸)
const APP_SHELL = [
'/pages/dashboard.html',
'/css/design-system.css',
'/css/mobile.css',
'/img/icon-192x192.png'
];
// 설치: 앱 셸 프리캐시
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => cache.addAll(APP_SHELL))
.then(() => self.skipWaiting())
);
});
// 활성화: 이전 버전 캐시 삭제
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys()
.then((keys) => Promise.all(
keys
.filter((key) => key.startsWith('tkfb-cache-') && key !== CACHE_NAME)
.map((key) => caches.delete(key))
))
.then(() => self.clients.claim())
);
});
// 요청 가로채기: network-first 전략
self.addEventListener('fetch', (event) => {
const request = event.request;
// API 요청은 캐시하지 않음 (항상 네트워크)
if (request.url.includes('/api/')) {
return;
}
// 로그인 관련 경로는 캐시하지 않음
if (request.url.includes('/login')) {
return;
}
// GET 요청만 캐시
if (request.method !== 'GET') {
return;
}
event.respondWith(
fetch(request)
.then((response) => {
// 정상 응답이면 캐시에 저장
if (response.ok) {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
}
return response;
})
.catch(() => {
// 네트워크 실패 시 캐시에서 응답
return caches.match(request);
})
);
});

View File

@@ -7,7 +7,7 @@
<link rel="preload" href="https://cdn.tailwindcss.com" as="script">
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/tkuser.css?v=20260223">
<link rel="stylesheet" href="/static/css/tkuser.css?v=20260224">
</head>
<body>
<!-- Header -->
@@ -43,6 +43,9 @@
<button class="tab-btn px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap" onclick="switchTab('workplaces')">
<i class="fas fa-building mr-2"></i>작업장
</button>
<button class="tab-btn px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap" onclick="switchTab('workers')">
<i class="fas fa-hard-hat mr-2"></i>작업자
</button>
<button class="tab-btn px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap" onclick="switchTab('departments')">
<i class="fas fa-sitemap mr-2"></i>부서
</button>
@@ -315,6 +318,56 @@
</div>
</div>
<!-- ============ 작업자 탭 ============ -->
<div id="tab-workers" class="hidden">
<div class="grid lg:grid-cols-5 gap-6">
<div class="lg:col-span-2 bg-white rounded-xl shadow-sm p-5">
<h2 class="text-base font-semibold text-gray-800 mb-4"><i class="fas fa-hard-hat text-slate-400 mr-2"></i>작업자 등록</h2>
<form id="addWorkerForm" class="space-y-3">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">이름 <span class="text-red-400">*</span></label>
<input type="text" id="newWorkerName" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" placeholder="작업자 이름" required>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">직종</label>
<select id="newJobType" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
<option value="">선택</option>
<option value="leader">반장</option>
<option value="worker">작업자</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">부서</label>
<select id="newWorkerDept" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
<option value="">선택</option>
</select>
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">연락처</label>
<input type="text" id="newWorkerPhone" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" placeholder="010-0000-0000">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">입사일</label>
<input type="date" id="newWorkerHireDate" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">메모</label>
<input type="text" id="newWorkerNotes" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" placeholder="비고">
</div>
<button type="submit" class="w-full px-4 py-2 bg-slate-700 text-white rounded-lg hover:bg-slate-800 text-sm font-medium">
<i class="fas fa-plus mr-1"></i>추가
</button>
</form>
</div>
<div class="lg:col-span-3 bg-white rounded-xl shadow-sm p-5">
<h2 class="text-base font-semibold text-gray-800 mb-4"><i class="fas fa-people-group text-slate-400 mr-2"></i>작업자 목록</h2>
<div id="workerList" class="space-y-2 max-h-[520px] overflow-y-auto">
<div class="text-gray-400 text-center py-8"><i class="fas fa-spinner fa-spin text-2xl"></i><p class="mt-2 text-sm">로딩 중...</p></div>
</div>
</div>
</div>
</div>
<!-- ============ 부서 탭 ============ -->
<div id="tab-departments" class="hidden">
<div class="grid lg:grid-cols-5 gap-6">
@@ -869,6 +922,71 @@
</div>
</div>
<!-- 작업자 편집 모달 -->
<div id="editWorkerModal" class="fixed inset-0 bg-black bg-opacity-50 hidden z-50 flex items-center justify-center p-4">
<div class="bg-white rounded-xl max-w-md w-full p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-base font-semibold text-gray-900">작업자 정보 수정</h3>
<button onclick="closeWorkerModal()" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times"></i></button>
</div>
<form id="editWorkerForm" class="space-y-3">
<input type="hidden" id="editWorkerId">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">이름</label>
<input type="text" id="editWorkerName" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" required>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">직종</label>
<select id="editJobType" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
<option value="">선택</option>
<option value="leader">반장</option>
<option value="worker">작업자</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">부서</label>
<select id="editWorkerDept" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
<option value="">선택</option>
</select>
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">연락처</label>
<input type="text" id="editWorkerPhone" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">입사일</label>
<input type="date" id="editWorkerHireDate" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">메모</label>
<input type="text" id="editWorkerNotes" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">상태</label>
<select id="editWorkerStatus" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
<option value="active">재직</option>
<option value="inactive">비활성</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">재직 상태</label>
<select id="editEmploymentStatus" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
<option value="employed">재직</option>
<option value="resigned">퇴직</option>
</select>
</div>
</div>
<div class="flex gap-3 pt-3">
<button type="button" onclick="closeWorkerModal()" class="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 text-sm">취소</button>
<button type="submit" class="flex-1 px-4 py-2 bg-slate-700 text-white rounded-lg hover:bg-slate-800 text-sm font-medium"><i class="fas fa-save mr-1"></i>저장</button>
</div>
</form>
</div>
</div>
<!-- 부서 편집 모달 -->
<div id="editDepartmentModal" class="fixed inset-0 bg-black bg-opacity-50 hidden z-50 flex items-center justify-center p-4">
<div class="bg-white rounded-xl max-w-md w-full p-6">
@@ -1270,18 +1388,18 @@
</div>
<!-- JS: Core (config, token, api, toast, helpers, init) -->
<script src="/static/js/tkuser-core.js?v=20260223"></script>
<script src="/static/js/tkuser-core.js?v=20260224"></script>
<!-- JS: Tabs -->
<script src="/static/js/tkuser-tabs.js?v=20260223"></script>
<script src="/static/js/tkuser-tabs.js?v=20260224"></script>
<!-- JS: Individual modules -->
<script src="/static/js/tkuser-users.js?v=20260223"></script>
<script src="/static/js/tkuser-projects.js?v=20260223"></script>
<script src="/static/js/tkuser-departments.js?v=20260223"></script>
<script src="/static/js/tkuser-issue-types.js?v=20260223"></script>
<script src="/static/js/tkuser-workplaces.js?v=20260223"></script>
<script src="/static/js/tkuser-tasks.js?v=20260223"></script>
<script src="/static/js/tkuser-vacations.js?v=20260223"></script>
<script src="/static/js/tkuser-layout-map.js?v=20260223"></script>
<script src="/static/js/tkuser-users.js?v=20260224"></script>
<script src="/static/js/tkuser-projects.js?v=20260224"></script>
<script src="/static/js/tkuser-departments.js?v=20260224"></script>
<script src="/static/js/tkuser-issue-types.js?v=20260224"></script>
<script src="/static/js/tkuser-workplaces.js?v=20260224"></script>
<script src="/static/js/tkuser-tasks.js?v=20260224"></script>
<script src="/static/js/tkuser-vacations.js?v=20260224"></script>
<script src="/static/js/tkuser-layout-map.js?v=20260224"></script>
<!-- Boot -->
<script>init();</script>
</body>

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();
}