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:
@@ -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');
|
var existingToken = ssoCookie.get('sso_token') || localStorage.getItem('sso_token');
|
||||||
if (existingToken && existingToken !== 'undefined' && existingToken !== 'null') {
|
if (existingToken && existingToken !== 'undefined' && existingToken !== 'null') {
|
||||||
var redirect = new URLSearchParams(location.search).get('redirect');
|
if (isTokenValid(existingToken)) {
|
||||||
window.location.href = redirect || '/';
|
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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ function setupRoutes(app) {
|
|||||||
const vacationTypeRoutes = require('../routes/vacationTypeRoutes');
|
const vacationTypeRoutes = require('../routes/vacationTypeRoutes');
|
||||||
const vacationBalanceRoutes = require('../routes/vacationBalanceRoutes');
|
const vacationBalanceRoutes = require('../routes/vacationBalanceRoutes');
|
||||||
const visitRequestRoutes = require('../routes/visitRequestRoutes');
|
const visitRequestRoutes = require('../routes/visitRequestRoutes');
|
||||||
// workIssueRoutes removed - moved to System 2 (신고 시스템)
|
const workIssueRoutes = require('../routes/workIssueRoutes');
|
||||||
const departmentRoutes = require('../routes/departmentRoutes');
|
const departmentRoutes = require('../routes/departmentRoutes');
|
||||||
const patrolRoutes = require('../routes/patrolRoutes');
|
const patrolRoutes = require('../routes/patrolRoutes');
|
||||||
const notificationRoutes = require('../routes/notificationRoutes');
|
const notificationRoutes = require('../routes/notificationRoutes');
|
||||||
@@ -159,14 +159,7 @@ function setupRoutes(app) {
|
|||||||
app.use('/api/workplace-visits', visitRequestRoutes); // 출입 신청 및 안전교육 관리
|
app.use('/api/workplace-visits', visitRequestRoutes); // 출입 신청 및 안전교육 관리
|
||||||
app.use('/api', pageAccessRoutes); // 페이지 접근 권한 관리
|
app.use('/api', pageAccessRoutes); // 페이지 접근 권한 관리
|
||||||
app.use('/api/tbm', tbmRoutes); // TBM 시스템
|
app.use('/api/tbm', tbmRoutes); // TBM 시스템
|
||||||
// work-issues moved to System 2 - redirect
|
app.use('/api/work-issues', workIssueRoutes); // 카테고리/아이템 + 신고 조회 (같은 MariaDB 공유)
|
||||||
app.use('/api/work-issues', (req, res) => {
|
|
||||||
res.status(301).json({
|
|
||||||
success: false,
|
|
||||||
error: '신고 시스템이 분리되었습니다',
|
|
||||||
redirect: '/report/api/work-issues' + req.url
|
|
||||||
});
|
|
||||||
});
|
|
||||||
app.use('/api/departments', departmentRoutes); // 부서 관리
|
app.use('/api/departments', departmentRoutes); // 부서 관리
|
||||||
app.use('/api/patrol', patrolRoutes); // 일일순회점검 시스템
|
app.use('/api/patrol', patrolRoutes); // 일일순회점검 시스템
|
||||||
app.use('/api/notifications', notificationRoutes); // 알림 시스템
|
app.use('/api/notifications', notificationRoutes); // 알림 시스템
|
||||||
|
|||||||
@@ -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 세션이 삭제되었습니다.'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
// ==================== 팀 구성 관련 ====================
|
// ==================== 팀 구성 관련 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ exports.getAllCategories = (req, res) => {
|
|||||||
exports.getCategoriesByType = (req, res) => {
|
exports.getCategoriesByType = (req, res) => {
|
||||||
const { type } = req.params;
|
const { type } = req.params;
|
||||||
|
|
||||||
if (!['nonconformity', 'safety'].includes(type)) {
|
if (!['nonconformity', 'safety', 'facility'].includes(type)) {
|
||||||
return res.status(400).json({ success: false, error: '유효하지 않은 카테고리 타입입니다.' });
|
return res.status(400).json({ success: false, error: '유효하지 않은 카테고리 타입입니다.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -97,22 +97,36 @@ const TbmModel = {
|
|||||||
w.worker_name as leader_name,
|
w.worker_name as leader_name,
|
||||||
w.job_type as leader_job_type,
|
w.job_type as leader_job_type,
|
||||||
w.phone_number as leader_phone,
|
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.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
|
FROM tbm_sessions s
|
||||||
LEFT JOIN workers w ON s.leader_id = w.worker_id
|
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 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 = ?
|
WHERE s.session_id = ?
|
||||||
|
GROUP BY s.session_id
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const [rows] = await db.query(sql, [sessionId]);
|
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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// ==================== 팀 구성 관련 ====================
|
// ==================== 팀 구성 관련 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ router.put('/sessions/:sessionId', requireAuth, TbmController.updateSession);
|
|||||||
// TBM 세션 완료 처리
|
// TBM 세션 완료 처리
|
||||||
router.post('/sessions/:sessionId/complete', requireAuth, TbmController.completeSession);
|
router.post('/sessions/:sessionId/complete', requireAuth, TbmController.completeSession);
|
||||||
|
|
||||||
|
// TBM 세션 삭제 (draft 상태만)
|
||||||
|
router.delete('/sessions/:sessionId', requireAuth, TbmController.deleteSession);
|
||||||
|
|
||||||
// ==================== 팀 구성 관련 ====================
|
// ==================== 팀 구성 관련 ====================
|
||||||
|
|
||||||
// 팀원 추가 (단일)
|
// 팀원 추가 (단일)
|
||||||
|
|||||||
@@ -1,26 +1,37 @@
|
|||||||
<!-- components/mobile-nav.html -->
|
<!-- components/mobile-nav.html -->
|
||||||
<!-- 모바일 하단 네비게이션 -->
|
<!-- 모바일 하단 네비게이션 (4개 핵심 기능) -->
|
||||||
<nav class="mobile-bottom-nav" id="mobileBottomNav">
|
<nav class="mobile-bottom-nav" id="mobileBottomNav">
|
||||||
<a href="/pages/dashboard.html" class="mobile-nav-item" data-page="dashboard">
|
<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>
|
<span class="mobile-nav-label">홈</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="/pages/work/tbm.html" class="mobile-nav-item" data-page="tbm">
|
<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>
|
<span class="mobile-nav-label">TBM</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="/pages/work/report-create.html" class="mobile-nav-item" data-page="report">
|
<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>
|
<span class="mobile-nav-label">작업보고</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="/pages/attendance/checkin.html" class="mobile-nav-item" data-page="checkin">
|
<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>
|
<span class="mobile-nav-label">출근</span>
|
||||||
</a>
|
</a>
|
||||||
<button class="mobile-nav-item" id="mobileMoreBtn">
|
|
||||||
<span class="mobile-nav-icon">☰</span>
|
|
||||||
<span class="mobile-nav-label">메뉴</span>
|
|
||||||
</button>
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -31,10 +42,10 @@
|
|||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
height: 64px;
|
height: 68px;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
border-top: 1px solid #e5e7eb;
|
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;
|
z-index: 1000;
|
||||||
padding-bottom: env(safe-area-inset-bottom);
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
}
|
}
|
||||||
@@ -46,9 +57,8 @@
|
|||||||
justify-content: space-around;
|
justify-content: space-around;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 바디 패딩 추가 */
|
|
||||||
body {
|
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;
|
flex: 1;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: #6b7280;
|
color: #9ca3af;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: color 0.2s;
|
padding: 0.5rem 0.25rem;
|
||||||
padding: 0.5rem;
|
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
touch-action: manipulation;
|
||||||
|
position: relative;
|
||||||
|
transition: color 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-nav-item:active {
|
.mobile-nav-item:active {
|
||||||
background: #f3f4f6;
|
color: #6b7280;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-nav-item.active {
|
/* SVG 아이콘 */
|
||||||
color: #2563eb;
|
.mobile-nav-svg {
|
||||||
}
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
.mobile-nav-icon {
|
margin-bottom: 4px;
|
||||||
font-size: 1.5rem;
|
transition: transform 0.15s;
|
||||||
line-height: 1;
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-nav-label {
|
.mobile-nav-label {
|
||||||
font-size: 0.6875rem;
|
font-size: 0.6875rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 활성 상태 */
|
/* 활성 상태 */
|
||||||
.mobile-nav-item.active .mobile-nav-icon {
|
.mobile-nav-item.active {
|
||||||
transform: scale(1.1);
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-nav-item.active .mobile-nav-svg {
|
||||||
|
transform: scale(1.08);
|
||||||
|
stroke-width: 2.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-nav-item.active .mobile-nav-label {
|
.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>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
// 현재 페이지 하이라이트
|
var currentPath = window.location.pathname;
|
||||||
const currentPath = window.location.pathname;
|
var navItems = document.querySelectorAll('.mobile-nav-item[data-page]');
|
||||||
const navItems = document.querySelectorAll('.mobile-nav-item[data-page]');
|
|
||||||
|
|
||||||
navItems.forEach(item => {
|
navItems.forEach(function(item) {
|
||||||
const href = item.getAttribute('href');
|
var href = item.getAttribute('href');
|
||||||
if (href && currentPath.includes(href.replace('/pages/', '').replace('.html', ''))) {
|
if (href && currentPath.includes(href.replace('/pages/', '').replace('.html', ''))) {
|
||||||
item.classList.add('active');
|
item.classList.add('active');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 대시보드 페이지 체크
|
|
||||||
if (currentPath.includes('dashboard')) {
|
if (currentPath.includes('dashboard')) {
|
||||||
document.querySelector('[data-page="dashboard"]')?.classList.add('active');
|
var dashItem = document.querySelector('[data-page="dashboard"]');
|
||||||
}
|
if (dashItem) dashItem.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');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
<span class="btn-text">대시보드</span>
|
<span class="btn-text">대시보드</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a href="/pages/safety/report.html" class="report-btn">
|
<a href="https://tkreport.technicalkorea.net" class="report-btn">
|
||||||
<span class="btn-icon">⚠</span>
|
<span class="btn-icon">⚠</span>
|
||||||
<span class="btn-text">신고</span>
|
<span class="btn-text">신고</span>
|
||||||
</a>
|
</a>
|
||||||
@@ -739,12 +739,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mobile-menu-btn {
|
.mobile-menu-btn {
|
||||||
width: 32px;
|
display: none !important;
|
||||||
height: 32px;
|
|
||||||
margin-right: 0.25rem;
|
|
||||||
font-size: 1.125rem;
|
|
||||||
background: rgba(255, 255, 255, 0.12);
|
|
||||||
border: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-profile {
|
.user-profile {
|
||||||
|
|||||||
@@ -139,7 +139,7 @@
|
|||||||
<a href="/pages/admin/equipments.html" class="nav-item" data-page-key="admin.equipments">
|
<a href="/pages/admin/equipments.html" class="nav-item" data-page-key="admin.equipments">
|
||||||
<span class="nav-text">설비 관리</span>
|
<span class="nav-text">설비 관리</span>
|
||||||
</a>
|
</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>
|
<span class="nav-text">신고 카테고리 관리</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="/pages/admin/attendance-report.html" class="nav-item" data-page-key="admin.attendance_report">
|
<a href="/pages/admin/attendance-report.html" class="nav-item" data-page-key="admin.attendance_report">
|
||||||
|
|||||||
@@ -10,6 +10,11 @@
|
|||||||
padding: 0 0.5rem !important;
|
padding: 0 0.5rem !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
overflow-x: hidden !important;
|
||||||
|
max-width: 100vw !important;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
padding-top: 52px !important;
|
padding-top: 52px !important;
|
||||||
}
|
}
|
||||||
@@ -81,6 +86,19 @@
|
|||||||
|
|
||||||
/* ========== 공통 모바일 스타일 ========== */
|
/* ========== 공통 모바일 스타일 ========== */
|
||||||
@media (max-width: 768px) {
|
@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,
|
.dashboard-main,
|
||||||
.page-container,
|
.page-container,
|
||||||
|
|||||||
@@ -1050,8 +1050,9 @@
|
|||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.dashboard-main {
|
.dashboard-main {
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
margin-left: 0;
|
margin: 0;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 헤더는 항상 가로 배치 유지 (navbar.html에서 관리) */
|
/* 헤더는 항상 가로 배치 유지 (navbar.html에서 관리) */
|
||||||
|
|||||||
@@ -496,10 +496,15 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 1000;
|
z-index: 1100;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 모달 열릴 때 하단 네비게이션 숨기기 */
|
||||||
|
body.tbm-modal-open .mobile-bottom-nav {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
.tbm-modal {
|
.tbm-modal {
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
@@ -1037,6 +1042,13 @@
|
|||||||
color: #92400e;
|
color: #92400e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== 태스크 그리드 ===== */
|
||||||
|
.tbm-task-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== 반응형 ===== */
|
/* ===== 반응형 ===== */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.tbm-container {
|
.tbm-container {
|
||||||
@@ -1075,18 +1087,114 @@
|
|||||||
|
|
||||||
.tbm-worker-select-grid {
|
.tbm-worker-select-grid {
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
max-height: 50vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 모달 → 풀스크린 시트 */
|
||||||
|
.tbm-modal-overlay {
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tbm-modal {
|
.tbm-modal {
|
||||||
max-width: 100%;
|
max-width: 100% !important;
|
||||||
max-height: 100%;
|
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;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tbm-modal-header,
|
.tbm-modal-body {
|
||||||
.tbm-modal-footer {
|
flex: 1;
|
||||||
border-radius: 0;
|
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) {
|
@media (max-width: 480px) {
|
||||||
@@ -1098,6 +1206,11 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tbm-task-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
.tbm-section-header {
|
.tbm-section-header {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
|
|||||||
82
system1-factory/web/docs/PWA-GUIDE.md
Normal file
82
system1-factory/web/docs/PWA-GUIDE.md
Normal 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 전략)
|
||||||
BIN
system1-factory/web/img/icon-192x192.png
Normal file
BIN
system1-factory/web/img/icon-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.7 KiB |
BIN
system1-factory/web/img/icon-512x512.png
Normal file
BIN
system1-factory/web/img/icon-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
@@ -128,7 +128,8 @@
|
|||||||
var url = window.API_BASE_URL + endpoint;
|
var url = window.API_BASE_URL + endpoint;
|
||||||
var config = {
|
var config = {
|
||||||
method: method,
|
method: method,
|
||||||
headers: window.getAuthHeaders()
|
headers: window.getAuthHeaders(),
|
||||||
|
cache: 'no-store'
|
||||||
};
|
};
|
||||||
|
|
||||||
if (data && (method === 'POST' || method === 'PUT' || method === 'PATCH' || method === 'DELETE')) {
|
if (data && (method === 'POST' || method === 'PUT' || method === 'PATCH' || method === 'DELETE')) {
|
||||||
|
|||||||
@@ -77,6 +77,12 @@ function clearAuthData() {
|
|||||||
localStorage.removeItem('sso_user');
|
localStorage.removeItem('sso_user');
|
||||||
localStorage.removeItem('userInfo');
|
localStorage.removeItem('userInfo');
|
||||||
localStorage.removeItem('currentUser');
|
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() {
|
function getAuthHeaders() {
|
||||||
|
|||||||
@@ -491,28 +491,35 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 사이드바 컨테이너 생성 (없으면)
|
// 3. 네비바 로드 (모바일이면 사이드바 스킵)
|
||||||
let sidebarContainer = document.getElementById('sidebar-container');
|
var isMobile = window.innerWidth <= 768;
|
||||||
if (!sidebarContainer) {
|
|
||||||
sidebarContainer = document.createElement('div');
|
if (!isMobile) {
|
||||||
sidebarContainer.id = 'sidebar-container';
|
// 데스크톱: 사이드바 컨테이너 생성 및 로드
|
||||||
document.body.prepend(sidebarContainer);
|
let sidebarContainer = document.getElementById('sidebar-container');
|
||||||
console.log('📦 사이드바 컨테이너 생성됨');
|
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('✅ 컴포넌트 로딩 완료');
|
console.log('✅ 컴포넌트 로딩 완료');
|
||||||
|
|
||||||
// 5. 이벤트 설정
|
|
||||||
setupNavbarEvents();
|
|
||||||
setupSidebarEvents();
|
|
||||||
document.body.classList.add('has-sidebar');
|
|
||||||
|
|
||||||
// 6. 페이지 전환 로딩 인디케이터 설정
|
// 6. 페이지 전환 로딩 인디케이터 설정
|
||||||
setupPageTransitionLoader();
|
setupPageTransitionLoader();
|
||||||
|
|
||||||
@@ -527,9 +534,69 @@
|
|||||||
setTimeout(loadNotifications, 200);
|
setTimeout(loadNotifications, 200);
|
||||||
setInterval(loadNotifications, 30000);
|
setInterval(loadNotifications, 30000);
|
||||||
|
|
||||||
|
// 10. PWA 설정 (manifest + 서비스 워커 + iOS 메타태그)
|
||||||
|
setupPWA();
|
||||||
|
|
||||||
console.log('✅ app-init 완료');
|
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() {
|
function setupPageTransitionLoader() {
|
||||||
// 로딩 바 스타일 추가
|
// 로딩 바 스타일 추가
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export const config = {
|
|||||||
// 페이지 경로 설정
|
// 페이지 경로 설정
|
||||||
paths: {
|
paths: {
|
||||||
// 로그인 페이지 경로
|
// 로그인 페이지 경로
|
||||||
loginPage: '/index.html',
|
loginPage: '/login',
|
||||||
// 메인 대시보드 경로 (모든 사용자 공통)
|
// 메인 대시보드 경로 (모든 사용자 공통)
|
||||||
dashboard: '/pages/dashboard.html',
|
dashboard: '/pages/dashboard.html',
|
||||||
// 하위 호환성을 위한 별칭들
|
// 하위 호환성을 위한 별칭들
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ function populateWorkTypeSelects() {
|
|||||||
const modalSelect = document.getElementById('modalWorkType');
|
const modalSelect = document.getElementById('modalWorkType');
|
||||||
|
|
||||||
const options = workTypes.map(wt =>
|
const options = workTypes.map(wt =>
|
||||||
`<option value="${wt.work_type_id}">${wt.work_type_name}</option>`
|
`<option value="${wt.id}">${wt.name}</option>`
|
||||||
).join('');
|
).join('');
|
||||||
|
|
||||||
if (filterSelect) {
|
if (filterSelect) {
|
||||||
@@ -204,7 +204,7 @@ function renderBasicChecks() {
|
|||||||
console.log('기본 체크항목:', basicChecks.length, '개');
|
console.log('기본 체크항목:', basicChecks.length, '개');
|
||||||
|
|
||||||
if (basicChecks.length === 0) {
|
if (basicChecks.length === 0) {
|
||||||
container.innerHTML = renderEmptyState('기본 체크 항목이 없습니다.');
|
container.innerHTML = renderEmptyState('기본 체크 항목이 없습니다.') + renderInlineAddStandalone('basic');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,7 +213,7 @@ function renderBasicChecks() {
|
|||||||
|
|
||||||
container.innerHTML = Object.entries(grouped).map(([category, items]) =>
|
container.innerHTML = Object.entries(grouped).map(([category, items]) =>
|
||||||
renderChecklistGroup(category, items)
|
renderChecklistGroup(category, items)
|
||||||
).join('');
|
).join('') + renderInlineAddStandalone('basic');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -229,8 +229,10 @@ function renderWeatherChecks() {
|
|||||||
weatherChecks = weatherChecks.filter(c => c.weather_condition === filterValue);
|
weatherChecks = weatherChecks.filter(c => c.weather_condition === filterValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const inlineRow = filterValue ? renderInlineAddStandalone('weather') : '';
|
||||||
|
|
||||||
if (weatherChecks.length === 0) {
|
if (weatherChecks.length === 0) {
|
||||||
container.innerHTML = renderEmptyState('날씨별 체크 항목이 없습니다.');
|
container.innerHTML = renderEmptyState('날씨별 체크 항목이 없습니다.') + inlineRow;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,7 +245,7 @@ function renderWeatherChecks() {
|
|||||||
const name = conditionInfo?.condition_name || condition;
|
const name = conditionInfo?.condition_name || condition;
|
||||||
|
|
||||||
return renderChecklistGroup(`${icon} ${name}`, items, condition);
|
return renderChecklistGroup(`${icon} ${name}`, items, condition);
|
||||||
}).join('');
|
}).join('') + inlineRow;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -254,6 +256,12 @@ function renderTaskChecks() {
|
|||||||
const workTypeId = document.getElementById('workTypeFilter')?.value;
|
const workTypeId = document.getElementById('workTypeFilter')?.value;
|
||||||
const taskId = document.getElementById('taskFilter')?.value;
|
const taskId = document.getElementById('taskFilter')?.value;
|
||||||
|
|
||||||
|
// 공정 미선택 시 안내
|
||||||
|
if (!workTypeId) {
|
||||||
|
container.innerHTML = renderGuideState('공정을 먼저 선택해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let taskChecks = allChecks.filter(c => c.check_type === 'task');
|
let taskChecks = allChecks.filter(c => c.check_type === 'task');
|
||||||
|
|
||||||
if (taskId) {
|
if (taskId) {
|
||||||
@@ -264,8 +272,10 @@ function renderTaskChecks() {
|
|||||||
taskChecks = taskChecks.filter(c => taskIds.includes(c.task_id));
|
taskChecks = taskChecks.filter(c => taskIds.includes(c.task_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const inlineRow = taskId ? renderInlineAddStandalone('task') : '';
|
||||||
|
|
||||||
if (taskChecks.length === 0) {
|
if (taskChecks.length === 0) {
|
||||||
container.innerHTML = renderEmptyState('작업별 체크 항목이 없습니다.');
|
container.innerHTML = renderEmptyState('작업별 체크 항목이 없습니다.') + inlineRow;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,7 +287,7 @@ function renderTaskChecks() {
|
|||||||
const taskName = task?.task_name || `작업 ${taskId}`;
|
const taskName = task?.task_name || `작업 ${taskId}`;
|
||||||
|
|
||||||
return renderChecklistGroup(`📋 ${taskName}`, items, null, 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 {
|
try {
|
||||||
const response = await apiCall(`/tasks/work-type/${workTypeId}`);
|
const response = await apiCall(`/tasks/by-work-type/${workTypeId}`);
|
||||||
if (response && response.success) {
|
if (response && response.success) {
|
||||||
tasks = response.data || [];
|
tasks = response.data || [];
|
||||||
taskSelect.innerHTML = '<option value="">작업 선택</option>' +
|
taskSelect.innerHTML = '<option value="">작업 선택</option>' +
|
||||||
@@ -446,7 +468,7 @@ async function loadModalTasks() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await apiCall(`/tasks/work-type/${workTypeId}`);
|
const response = await apiCall(`/tasks/by-work-type/${workTypeId}`);
|
||||||
if (response && response.success) {
|
if (response && response.success) {
|
||||||
const modalTasks = response.data || [];
|
const modalTasks = response.data || [];
|
||||||
taskSelect.innerHTML = '<option value="">작업 선택</option>' +
|
taskSelect.innerHTML = '<option value="">작업 선택</option>' +
|
||||||
@@ -486,6 +508,29 @@ function openAddModal() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
toggleConditionalFields();
|
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();
|
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.filterByTask = filterByTask;
|
||||||
window.loadModalTasks = loadModalTasks;
|
window.loadModalTasks = loadModalTasks;
|
||||||
window.toggleConditionalFields = toggleConditionalFields;
|
window.toggleConditionalFields = toggleConditionalFields;
|
||||||
|
window.addInlineCheck = addInlineCheck;
|
||||||
|
|||||||
@@ -31,6 +31,31 @@ let loadedDaysCount = 7; // 처음에 로드할 일수
|
|||||||
let dateGroupedSessions = {}; // 날짜별로 그룹화된 세션
|
let dateGroupedSessions = {}; // 날짜별로 그룹화된 세션
|
||||||
let allLoadedSessions = []; // 전체 로드된 세션
|
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' ? `
|
${session.status === 'draft' ? `
|
||||||
<div class="tbm-card-footer">
|
<div class="tbm-card-footer">
|
||||||
<button class="tbm-btn tbm-btn-primary tbm-btn-sm" onclick="event.stopPropagation(); openTeamCompositionModal(${safeSessionId})">
|
<button class="tbm-btn tbm-btn-primary tbm-btn-sm" onclick="event.stopPropagation(); openTeamCompositionModal(${safeSessionId})">
|
||||||
👥 팀 구성
|
👥 수정
|
||||||
</button>
|
</button>
|
||||||
<button class="tbm-btn tbm-btn-secondary tbm-btn-sm" onclick="event.stopPropagation(); openSafetyCheckModal(${safeSessionId})">
|
<button class="tbm-btn tbm-btn-secondary tbm-btn-sm" onclick="event.stopPropagation(); openSafetyCheckModal(${safeSessionId})">
|
||||||
✓ 안전 체크
|
✓ 안전 체크
|
||||||
</button>
|
</button>
|
||||||
|
<button class="tbm-btn tbm-btn-danger tbm-btn-sm" onclick="event.stopPropagation(); confirmDeleteTbm(${safeSessionId})">
|
||||||
|
🗑 삭제
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
</div>
|
</div>
|
||||||
@@ -591,7 +619,7 @@ function openNewTbmModal() {
|
|||||||
renderWorkerTaskList();
|
renderWorkerTaskList();
|
||||||
|
|
||||||
document.getElementById('tbmModal').style.display = 'flex';
|
document.getElementById('tbmModal').style.display = 'flex';
|
||||||
document.body.style.overflow = 'hidden';
|
lockBodyScroll();
|
||||||
}
|
}
|
||||||
window.openNewTbmModal = openNewTbmModal;
|
window.openNewTbmModal = openNewTbmModal;
|
||||||
|
|
||||||
@@ -697,7 +725,7 @@ window.loadTasksByWorkType = loadTasksByWorkType;
|
|||||||
// TBM 모달 닫기
|
// TBM 모달 닫기
|
||||||
function closeTbmModal() {
|
function closeTbmModal() {
|
||||||
document.getElementById('tbmModal').style.display = 'none';
|
document.getElementById('tbmModal').style.display = 'none';
|
||||||
document.body.style.overflow = 'auto';
|
unlockBodyScroll();
|
||||||
}
|
}
|
||||||
window.closeTbmModal = closeTbmModal;
|
window.closeTbmModal = closeTbmModal;
|
||||||
|
|
||||||
@@ -915,7 +943,7 @@ function renderTaskLine(workerData, workerIndex, taskLine, taskIndex) {
|
|||||||
|
|
||||||
return `
|
return `
|
||||||
<div style="padding: 0.75rem; margin-bottom: 0.5rem; background: #f9fafb; border-radius: 0.5rem; border: 1px solid #e5e7eb;">
|
<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"
|
<button type="button"
|
||||||
onclick="openItemSelect('project', ${safeWorkerIndex}, ${safeTaskIndex})"
|
onclick="openItemSelect('project', ${safeWorkerIndex}, ${safeTaskIndex})"
|
||||||
@@ -992,6 +1020,7 @@ function openWorkerSelectionModal() {
|
|||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
document.getElementById('workerSelectionModal').style.display = 'flex';
|
document.getElementById('workerSelectionModal').style.display = 'flex';
|
||||||
|
lockBodyScroll();
|
||||||
}
|
}
|
||||||
window.openWorkerSelectionModal = openWorkerSelectionModal;
|
window.openWorkerSelectionModal = openWorkerSelectionModal;
|
||||||
|
|
||||||
@@ -1090,6 +1119,7 @@ window.confirmWorkerSelection = confirmWorkerSelection;
|
|||||||
// 작업자 선택 모달 닫기
|
// 작업자 선택 모달 닫기
|
||||||
function closeWorkerSelectionModal() {
|
function closeWorkerSelectionModal() {
|
||||||
document.getElementById('workerSelectionModal').style.display = 'none';
|
document.getElementById('workerSelectionModal').style.display = 'none';
|
||||||
|
unlockBodyScroll();
|
||||||
selectedWorkersInModal.clear();
|
selectedWorkersInModal.clear();
|
||||||
}
|
}
|
||||||
window.closeWorkerSelectionModal = closeWorkerSelectionModal;
|
window.closeWorkerSelectionModal = closeWorkerSelectionModal;
|
||||||
@@ -1168,6 +1198,7 @@ function openBulkSettingModal() {
|
|||||||
document.getElementById('bulkWorkplaceBtn').classList.add('btn-secondary');
|
document.getElementById('bulkWorkplaceBtn').classList.add('btn-secondary');
|
||||||
|
|
||||||
document.getElementById('bulkSettingModal').style.display = 'flex';
|
document.getElementById('bulkSettingModal').style.display = 'flex';
|
||||||
|
lockBodyScroll();
|
||||||
}
|
}
|
||||||
window.openBulkSettingModal = openBulkSettingModal;
|
window.openBulkSettingModal = openBulkSettingModal;
|
||||||
|
|
||||||
@@ -1221,6 +1252,7 @@ window.deselectAllForBulk = deselectAllForBulk;
|
|||||||
// 일괄 설정 모달 닫기
|
// 일괄 설정 모달 닫기
|
||||||
function closeBulkSettingModal() {
|
function closeBulkSettingModal() {
|
||||||
document.getElementById('bulkSettingModal').style.display = 'none';
|
document.getElementById('bulkSettingModal').style.display = 'none';
|
||||||
|
unlockBodyScroll();
|
||||||
isBulkMode = false;
|
isBulkMode = false;
|
||||||
}
|
}
|
||||||
window.closeBulkSettingModal = closeBulkSettingModal;
|
window.closeBulkSettingModal = closeBulkSettingModal;
|
||||||
@@ -1279,6 +1311,7 @@ function openBulkItemSelect(type) {
|
|||||||
`).join('') : '<div style="text-align: center; padding: 2rem; color: #9ca3af;">선택 가능한 항목이 없습니다</div>';
|
`).join('') : '<div style="text-align: center; padding: 2rem; color: #9ca3af;">선택 가능한 항목이 없습니다</div>';
|
||||||
|
|
||||||
modal.style.display = 'flex';
|
modal.style.display = 'flex';
|
||||||
|
lockBodyScroll();
|
||||||
}
|
}
|
||||||
window.openBulkItemSelect = openBulkItemSelect;
|
window.openBulkItemSelect = openBulkItemSelect;
|
||||||
|
|
||||||
@@ -1318,6 +1351,7 @@ function openBulkWorkplaceSelect() {
|
|||||||
isBulkMode = true;
|
isBulkMode = true;
|
||||||
loadWorkplaceCategories();
|
loadWorkplaceCategories();
|
||||||
document.getElementById('workplaceSelectModal').style.display = 'flex';
|
document.getElementById('workplaceSelectModal').style.display = 'flex';
|
||||||
|
lockBodyScroll();
|
||||||
}
|
}
|
||||||
window.openBulkWorkplaceSelect = openBulkWorkplaceSelect;
|
window.openBulkWorkplaceSelect = openBulkWorkplaceSelect;
|
||||||
|
|
||||||
@@ -1418,6 +1452,7 @@ function openItemSelect(type, workerIndex, taskIndex) {
|
|||||||
`).join('') : '<div style="text-align: center; padding: 2rem; color: #9ca3af;">선택 가능한 항목이 없습니다</div>';
|
`).join('') : '<div style="text-align: center; padding: 2rem; color: #9ca3af;">선택 가능한 항목이 없습니다</div>';
|
||||||
|
|
||||||
modal.style.display = 'flex';
|
modal.style.display = 'flex';
|
||||||
|
lockBodyScroll();
|
||||||
}
|
}
|
||||||
window.openItemSelect = openItemSelect;
|
window.openItemSelect = openItemSelect;
|
||||||
|
|
||||||
@@ -1449,6 +1484,7 @@ window.selectItem = selectItem;
|
|||||||
// 항목 선택 모달 닫기
|
// 항목 선택 모달 닫기
|
||||||
function closeItemSelectModal() {
|
function closeItemSelectModal() {
|
||||||
document.getElementById('itemSelectModal').style.display = 'none';
|
document.getElementById('itemSelectModal').style.display = 'none';
|
||||||
|
unlockBodyScroll();
|
||||||
currentEditingTaskLine = null;
|
currentEditingTaskLine = null;
|
||||||
}
|
}
|
||||||
window.closeItemSelectModal = closeItemSelectModal;
|
window.closeItemSelectModal = closeItemSelectModal;
|
||||||
@@ -1460,12 +1496,14 @@ async function openWorkplaceSelect(workerIndex, taskIndex) {
|
|||||||
currentEditingTaskLine = { workerIndex, taskIndex };
|
currentEditingTaskLine = { workerIndex, taskIndex };
|
||||||
await loadWorkplaceCategories();
|
await loadWorkplaceCategories();
|
||||||
document.getElementById('workplaceSelectModal').style.display = 'flex';
|
document.getElementById('workplaceSelectModal').style.display = 'flex';
|
||||||
|
lockBodyScroll();
|
||||||
}
|
}
|
||||||
window.openWorkplaceSelect = openWorkplaceSelect;
|
window.openWorkplaceSelect = openWorkplaceSelect;
|
||||||
|
|
||||||
// 작업장 선택 모달 닫기
|
// 작업장 선택 모달 닫기
|
||||||
function closeWorkplaceSelectModal() {
|
function closeWorkplaceSelectModal() {
|
||||||
document.getElementById('workplaceSelectModal').style.display = 'none';
|
document.getElementById('workplaceSelectModal').style.display = 'none';
|
||||||
|
unlockBodyScroll();
|
||||||
document.getElementById('workplaceSelectionArea').style.display = 'none';
|
document.getElementById('workplaceSelectionArea').style.display = 'none';
|
||||||
document.getElementById('layoutMapArea').style.display = 'none';
|
document.getElementById('layoutMapArea').style.display = 'none';
|
||||||
document.getElementById('workplaceList').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 category = allWorkplaceCategories.find(c => c.category_id === categoryId);
|
||||||
|
|
||||||
|
const isMobile = window.innerWidth <= 768;
|
||||||
|
|
||||||
// 지도 또는 리스트 로드
|
// 지도 또는 리스트 로드
|
||||||
if (category && category.layout_image) {
|
if (category && category.layout_image) {
|
||||||
// 지도가 있는 경우 - 지도 영역 표시
|
// 지도가 있는 경우 - 지도를 기본 표시
|
||||||
await loadWorkplaceMap(categoryId, category.layout_image);
|
await loadWorkplaceMap(categoryId, category.layout_image);
|
||||||
document.getElementById('layoutMapArea').style.display = 'block';
|
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 {
|
} else {
|
||||||
// 지도가 없는 경우 - 리스트만 표시
|
// 지도가 없는 경우 - 리스트만 표시
|
||||||
document.getElementById('layoutMapArea').style.display = 'none';
|
document.getElementById('layoutMapArea').style.display = 'none';
|
||||||
document.getElementById('workplaceList').style.display = 'flex';
|
|
||||||
document.getElementById('toggleListBtn').style.display = 'none';
|
document.getElementById('toggleListBtn').style.display = 'none';
|
||||||
|
document.getElementById('workplaceList').style.display = 'flex';
|
||||||
|
document.getElementById('workplaceListSection').style.display = 'block';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 해당 카테고리의 작업장 리스트 로드 (오류 대비용)
|
// 해당 카테고리의 작업장 리스트 로드
|
||||||
await loadWorkplacesByCategory(categoryId);
|
await loadWorkplacesByCategory(categoryId);
|
||||||
|
|
||||||
// 선택 완료 버튼 비활성화 (작업장 선택 필요)
|
// 선택 완료 버튼 비활성화 (작업장 선택 필요)
|
||||||
@@ -1638,22 +1691,18 @@ function confirmWorkplaceSelection() {
|
|||||||
}
|
}
|
||||||
window.confirmWorkplaceSelection = confirmWorkplaceSelection;
|
window.confirmWorkplaceSelection = confirmWorkplaceSelection;
|
||||||
|
|
||||||
|
// 리스트 토글 함수 (레거시 호환)
|
||||||
// 리스트 토글 함수
|
// 리스트 토글 함수
|
||||||
function toggleWorkplaceList() {
|
function toggleWorkplaceList() {
|
||||||
const list = document.getElementById('workplaceList');
|
const listSection = document.getElementById('workplaceListSection');
|
||||||
const icon = document.getElementById('toggleListIcon');
|
|
||||||
const btn = document.getElementById('toggleListBtn');
|
const btn = document.getElementById('toggleListBtn');
|
||||||
|
if (listSection.style.display === 'none') {
|
||||||
if (list.style.display === 'none' || list.style.display === '') {
|
listSection.style.display = 'block';
|
||||||
list.style.display = 'flex';
|
document.getElementById('workplaceList').style.display = 'flex';
|
||||||
icon.textContent = '▲';
|
btn.textContent = '리스트 숨기기';
|
||||||
btn.textContent = ' 리스트 닫기';
|
|
||||||
btn.insertBefore(icon, btn.firstChild);
|
|
||||||
} else {
|
} else {
|
||||||
list.style.display = 'none';
|
listSection.style.display = 'none';
|
||||||
icon.textContent = '▼';
|
btn.textContent = '리스트로 선택';
|
||||||
btn.textContent = ' 리스트 보기';
|
|
||||||
btn.insertBefore(icon, btn.firstChild);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.toggleWorkplaceList = toggleWorkplaceList;
|
window.toggleWorkplaceList = toggleWorkplaceList;
|
||||||
@@ -1693,8 +1742,10 @@ async function loadWorkplaceMap(categoryId, layoutImagePath) {
|
|||||||
mapImage.crossOrigin = 'anonymous';
|
mapImage.crossOrigin = 'anonymous';
|
||||||
|
|
||||||
mapImage.onload = function() {
|
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;
|
const scale = mapImage.width > maxWidth ? maxWidth / mapImage.width : 1;
|
||||||
|
|
||||||
mapCanvas.width = mapImage.width * scale;
|
mapCanvas.width = mapImage.width * scale;
|
||||||
@@ -1712,6 +1763,7 @@ async function loadWorkplaceMap(categoryId, layoutImagePath) {
|
|||||||
mapImage.onerror = function() {
|
mapImage.onerror = function() {
|
||||||
console.error('❌ 지도 이미지 로드 실패');
|
console.error('❌ 지도 이미지 로드 실패');
|
||||||
document.getElementById('layoutMapArea').style.display = 'none';
|
document.getElementById('layoutMapArea').style.display = 'none';
|
||||||
|
document.getElementById('workplaceListSection').style.display = 'block';
|
||||||
document.getElementById('workplaceList').style.display = 'flex';
|
document.getElementById('workplaceList').style.display = 'flex';
|
||||||
document.getElementById('toggleListBtn').style.display = 'none';
|
document.getElementById('toggleListBtn').style.display = 'none';
|
||||||
showToast('지도를 불러올 수 없어 리스트로 표시합니다.', 'warning');
|
showToast('지도를 불러올 수 없어 리스트로 표시합니다.', 'warning');
|
||||||
@@ -1779,8 +1831,12 @@ function handleMapClick(event) {
|
|||||||
if (!mapCanvas || mapRegions.length === 0) return;
|
if (!mapCanvas || mapRegions.length === 0) return;
|
||||||
|
|
||||||
const rect = mapCanvas.getBoundingClientRect();
|
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--) {
|
for (let i = mapRegions.length - 1; i >= 0; i--) {
|
||||||
@@ -1894,7 +1950,7 @@ async function openTeamCompositionModal(sessionId) {
|
|||||||
renderWorkerTaskList();
|
renderWorkerTaskList();
|
||||||
|
|
||||||
document.getElementById('tbmModal').style.display = 'flex';
|
document.getElementById('tbmModal').style.display = 'flex';
|
||||||
document.body.style.overflow = 'hidden';
|
lockBodyScroll();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ 팀 구성 로드 오류:', error);
|
console.error('❌ 팀 구성 로드 오류:', error);
|
||||||
@@ -1964,7 +2020,7 @@ window.deselectAllWorkers = deselectAllWorkers;
|
|||||||
// 팀 구성 모달 닫기
|
// 팀 구성 모달 닫기
|
||||||
function closeTeamModal() {
|
function closeTeamModal() {
|
||||||
document.getElementById('teamModal').style.display = 'none';
|
document.getElementById('teamModal').style.display = 'none';
|
||||||
document.body.style.overflow = 'auto';
|
unlockBodyScroll();
|
||||||
}
|
}
|
||||||
window.closeTeamModal = closeTeamModal;
|
window.closeTeamModal = closeTeamModal;
|
||||||
|
|
||||||
@@ -2094,7 +2150,7 @@ async function openSafetyCheckModal(sessionId) {
|
|||||||
|
|
||||||
container.innerHTML = html;
|
container.innerHTML = html;
|
||||||
document.getElementById('safetyModal').style.display = 'flex';
|
document.getElementById('safetyModal').style.display = 'flex';
|
||||||
document.body.style.overflow = 'hidden';
|
lockBodyScroll();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ 안전 체크 조회 오류:', error);
|
console.error('❌ 안전 체크 조회 오류:', error);
|
||||||
@@ -2183,7 +2239,7 @@ function renderCheckItems(items) {
|
|||||||
// 안전 체크 모달 닫기
|
// 안전 체크 모달 닫기
|
||||||
function closeSafetyModal() {
|
function closeSafetyModal() {
|
||||||
document.getElementById('safetyModal').style.display = 'none';
|
document.getElementById('safetyModal').style.display = 'none';
|
||||||
document.body.style.overflow = 'auto';
|
unlockBodyScroll();
|
||||||
}
|
}
|
||||||
window.closeSafetyModal = closeSafetyModal;
|
window.closeSafetyModal = closeSafetyModal;
|
||||||
|
|
||||||
@@ -2226,14 +2282,14 @@ function openCompleteTbmModal(sessionId) {
|
|||||||
document.getElementById('endTime').value = timeString;
|
document.getElementById('endTime').value = timeString;
|
||||||
|
|
||||||
document.getElementById('completeModal').style.display = 'flex';
|
document.getElementById('completeModal').style.display = 'flex';
|
||||||
document.body.style.overflow = 'hidden';
|
lockBodyScroll();
|
||||||
}
|
}
|
||||||
window.openCompleteTbmModal = openCompleteTbmModal;
|
window.openCompleteTbmModal = openCompleteTbmModal;
|
||||||
|
|
||||||
// 완료 모달 닫기
|
// 완료 모달 닫기
|
||||||
function closeCompleteModal() {
|
function closeCompleteModal() {
|
||||||
document.getElementById('completeModal').style.display = 'none';
|
document.getElementById('completeModal').style.display = 'none';
|
||||||
document.body.style.overflow = 'auto';
|
unlockBodyScroll();
|
||||||
}
|
}
|
||||||
window.closeCompleteModal = closeCompleteModal;
|
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');
|
const basicInfo = document.getElementById('detailBasicInfo');
|
||||||
basicInfo.innerHTML = `
|
basicInfo.innerHTML = `
|
||||||
<div style="padding: 0.75rem; background: #f9fafb; border-radius: 0.375rem;">
|
<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-size: 0.75rem; color: #6b7280; margin-bottom: 0.25rem;">입력자</div>
|
||||||
<div style="font-weight: 600; color: #111827;">${session.leader_name}</div>
|
<div style="font-weight: 600; color: #111827;">${escapeHtml(leaderDisplay)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="padding: 0.75rem; background: #f9fafb; border-radius: 0.375rem;">
|
<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-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>
|
||||||
<div style="padding: 0.75rem; background: #f9fafb; border-radius: 0.375rem;">
|
<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-size: 0.75rem; color: #6b7280; margin-bottom: 0.25rem;">상태</div>
|
||||||
<div style="font-weight: 600; color: #111827;">${session.project_name || '-'}</div>
|
<div style="font-weight: 600; color: #111827;">${escapeHtml(statusText)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="padding: 0.75rem; background: #f9fafb; border-radius: 0.375rem;">
|
<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-size: 0.75rem; color: #6b7280; margin-bottom: 0.25rem;">팀원 수</div>
|
||||||
<div style="font-weight: 600; color: #111827;">${session.work_location || '-'}</div>
|
<div style="font-weight: 600; color: #111827;">${parseInt(session.team_member_count) || team.length}명</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="padding: 0.75rem; background: #f9fafb; border-radius: 0.375rem; grid-column: span 2;">
|
${session.project_name ? `
|
||||||
<div style="font-size: 0.75rem; color: #6b7280; margin-bottom: 0.25rem;">작업 내용</div>
|
<div style="padding: 0.75rem; background: #f9fafb; border-radius: 0.375rem;">
|
||||||
<div style="color: #111827;">${session.work_description || '-'}</div>
|
<div style="font-size: 0.75rem; color: #6b7280; margin-bottom: 0.25rem;">프로젝트</div>
|
||||||
</div>
|
<div style="font-weight: 600; color: #111827;">${escapeHtml(session.project_name)}</div>
|
||||||
${session.safety_notes ? `
|
</div>
|
||||||
<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>
|
${session.work_location ? `
|
||||||
<div style="color: #78350f;">${session.safety_notes}</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;">${escapeHtml(session.work_location)}</div>
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// 팀 구성 표시
|
// 팀 구성 표시 (작업자별 작업 정보 포함)
|
||||||
const teamMembers = document.getElementById('detailTeamMembers');
|
const teamContainer = document.getElementById('detailTeamMembers');
|
||||||
if (team.length === 0) {
|
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 {
|
} else {
|
||||||
teamMembers.innerHTML = team.map(member => `
|
// 작업자별로 그룹화
|
||||||
<div style="padding: 0.75rem; background: #f9fafb; border-radius: 0.375rem; border: 1px solid #e5e7eb;">
|
const workerMap = new Map();
|
||||||
<div style="font-weight: 600; color: #111827; margin-bottom: 0.25rem;">${member.worker_name}</div>
|
team.forEach(member => {
|
||||||
<div style="font-size: 0.75rem; color: #6b7280;">${member.job_type || ''}</div>
|
if (!workerMap.has(member.worker_id)) {
|
||||||
${member.is_present ? '' : '<div style="font-size: 0.75rem; color: #ef4444; margin-top: 0.25rem;">결석</div>'}
|
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>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
}
|
}
|
||||||
@@ -2371,8 +2467,28 @@ async function viewTbmSession(sessionId) {
|
|||||||
`).join('');
|
`).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.getElementById('detailModal').style.display = 'flex';
|
||||||
document.body.style.overflow = 'hidden';
|
lockBodyScroll();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ TBM 상세 조회 오류:', error);
|
console.error('❌ TBM 상세 조회 오류:', error);
|
||||||
@@ -2381,10 +2497,42 @@ async function viewTbmSession(sessionId) {
|
|||||||
}
|
}
|
||||||
window.viewTbmSession = viewTbmSession;
|
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() {
|
function closeDetailModal() {
|
||||||
document.getElementById('detailModal').style.display = 'none';
|
document.getElementById('detailModal').style.display = 'none';
|
||||||
document.body.style.overflow = 'auto';
|
unlockBodyScroll();
|
||||||
}
|
}
|
||||||
window.closeDetailModal = closeDetailModal;
|
window.closeDetailModal = closeDetailModal;
|
||||||
|
|
||||||
@@ -2448,7 +2596,7 @@ async function openHandoverModal(sessionId) {
|
|||||||
document.getElementById('handoverNotes').value = '';
|
document.getElementById('handoverNotes').value = '';
|
||||||
|
|
||||||
document.getElementById('handoverModal').style.display = 'flex';
|
document.getElementById('handoverModal').style.display = 'flex';
|
||||||
document.body.style.overflow = 'hidden';
|
lockBodyScroll();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ 인계 모달 열기 오류:', error);
|
console.error('❌ 인계 모달 열기 오류:', error);
|
||||||
@@ -2460,7 +2608,7 @@ window.openHandoverModal = openHandoverModal;
|
|||||||
// 인계 모달 닫기
|
// 인계 모달 닫기
|
||||||
function closeHandoverModal() {
|
function closeHandoverModal() {
|
||||||
document.getElementById('handoverModal').style.display = 'none';
|
document.getElementById('handoverModal').style.display = 'none';
|
||||||
document.body.style.overflow = 'auto';
|
unlockBodyScroll();
|
||||||
}
|
}
|
||||||
window.closeHandoverModal = closeHandoverModal;
|
window.closeHandoverModal = closeHandoverModal;
|
||||||
|
|
||||||
|
|||||||
24
system1-factory/web/manifest.json
Normal file
24
system1-factory/web/manifest.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -279,7 +279,7 @@
|
|||||||
|
|
||||||
<!-- JavaScript -->
|
<!-- JavaScript -->
|
||||||
<script src="/js/api-base.js"></script>
|
<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://instant.page/5.2.0" type="module"></script>
|
||||||
<script src="/js/admin-settings.js?v=9"></script>
|
<script src="/js/admin-settings.js?v=9"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<link rel="stylesheet" href="/css/admin-pages.css?v=8">
|
<link rel="stylesheet" href="/css/admin-pages.css?v=8">
|
||||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||||
<script src="/js/api-base.js"></script>
|
<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://instant.page/5.2.0" type="module"></script>
|
||||||
<style>
|
<style>
|
||||||
.comparison-grid {
|
.comparison-grid {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<link rel="stylesheet" href="/css/admin-pages.css?v=8">
|
<link rel="stylesheet" href="/css/admin-pages.css?v=8">
|
||||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||||
<script src="/js/api-base.js"></script>
|
<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://instant.page/5.2.0" type="module"></script>
|
||||||
<style>
|
<style>
|
||||||
.department-grid {
|
.department-grid {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<link rel="stylesheet" href="/css/equipment-detail.css?v=1">
|
<link rel="stylesheet" href="/css/equipment-detail.css?v=1">
|
||||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||||
<script src="/js/api-base.js"></script>
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- 네비게이션 바 -->
|
<!-- 네비게이션 바 -->
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<link rel="stylesheet" href="/css/equipment-management.css?v=1">
|
<link rel="stylesheet" href="/css/equipment-management.css?v=1">
|
||||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||||
<script src="/js/api-base.js"></script>
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- 네비게이션 바 -->
|
<!-- 네비게이션 바 -->
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<link rel="stylesheet" href="/css/admin-pages.css?v=8">
|
<link rel="stylesheet" href="/css/admin-pages.css?v=8">
|
||||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||||
<script src="/js/api-base.js"></script>
|
<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://instant.page/5.2.0" type="module"></script>
|
||||||
<style>
|
<style>
|
||||||
.type-tabs {
|
.type-tabs {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<link rel="stylesheet" href="/css/admin-pages.css?v=8">
|
<link rel="stylesheet" href="/css/admin-pages.css?v=8">
|
||||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||||
<script src="/js/api-base.js"></script>
|
<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://instant.page/5.2.0" type="module"></script>
|
||||||
<style>
|
<style>
|
||||||
.notification-page-container {
|
.notification-page-container {
|
||||||
|
|||||||
@@ -367,7 +367,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/api-base.js"></script>
|
<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>
|
<script>
|
||||||
let allProjects = [];
|
let allProjects = [];
|
||||||
let filteredProjects = [];
|
let filteredProjects = [];
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<link rel="stylesheet" href="/css/admin-pages.css?v=8">
|
<link rel="stylesheet" href="/css/admin-pages.css?v=8">
|
||||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||||
<script src="/js/api-base.js"></script>
|
<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>
|
<style>
|
||||||
.page-wrapper { padding: 1rem 1.5rem; max-width: 1400px; }
|
.page-wrapper { padding: 1rem 1.5rem; max-width: 1400px; }
|
||||||
.page-header {
|
.page-header {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||||
<!-- 최적화된 로딩: API 설정 → 앱 초기화 (병렬 컴포넌트 로딩) -->
|
<!-- 최적화된 로딩: API 설정 → 앱 초기화 (병렬 컴포넌트 로딩) -->
|
||||||
<script src="/js/api-base.js"></script>
|
<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: 링크 호버 시 페이지 프리로딩 -->
|
<!-- instant.page: 링크 호버 시 페이지 프리로딩 -->
|
||||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<link rel="stylesheet" href="/css/workplace-management.css?v=7">
|
<link rel="stylesheet" href="/css/workplace-management.css?v=7">
|
||||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||||
<script src="/js/api-base.js"></script>
|
<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://instant.page/5.2.0" type="module"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
|
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
|
||||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||||
<script src="/js/api-base.js"></script>
|
<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>
|
<style>
|
||||||
.page-wrapper {
|
.page-wrapper {
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<link rel="stylesheet" href="/css/mobile.css?v=1">
|
<link rel="stylesheet" href="/css/mobile.css?v=1">
|
||||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||||
<script src="/js/api-base.js"></script>
|
<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://instant.page/5.2.0" type="module"></script>
|
||||||
<style>
|
<style>
|
||||||
.page-wrapper {
|
.page-wrapper {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
|
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
|
||||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||||
<script src="/js/api-base.js"></script>
|
<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://instant.page/5.2.0" type="module"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
|
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
|
||||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||||
<script src="/js/api-base.js"></script>
|
<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://instant.page/5.2.0" type="module"></script>
|
||||||
<style>
|
<style>
|
||||||
/* 테이블 컨테이너 */
|
/* 테이블 컨테이너 */
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
|
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
|
||||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||||
<script src="/js/api-base.js"></script>
|
<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>
|
<style>
|
||||||
.page-wrapper {
|
.page-wrapper {
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
<!-- 스크립트 -->
|
<!-- 스크립트 -->
|
||||||
<script src="/js/api-base.js"></script>
|
<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://instant.page/5.2.0" type="module"></script>
|
||||||
<script type="module" src="/js/vacation-allocation.js" defer></script>
|
<script type="module" src="/js/vacation-allocation.js" defer></script>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
|
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
|
||||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||||
<script src="/js/api-base.js"></script>
|
<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://instant.page/5.2.0" type="module"></script>
|
||||||
<style>
|
<style>
|
||||||
.tabs {
|
.tabs {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
|
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
|
||||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||||
<script src="/js/api-base.js"></script>
|
<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://instant.page/5.2.0" type="module"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
|
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
|
||||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||||
<script src="/js/api-base.js"></script>
|
<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://instant.page/5.2.0" type="module"></script>
|
||||||
<style>
|
<style>
|
||||||
.tabs {
|
.tabs {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
|
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
|
||||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||||
<script src="/js/api-base.js"></script>
|
<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://instant.page/5.2.0" type="module"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<link rel="stylesheet" href="/css/mobile.css?v=1">
|
<link rel="stylesheet" href="/css/mobile.css?v=1">
|
||||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||||
<script src="/js/api-base.js"></script>
|
<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>
|
<style>
|
||||||
.page-wrapper {
|
.page-wrapper {
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
|
|||||||
@@ -10,17 +10,17 @@
|
|||||||
<!-- preconnect는 Gateway 프록시 사용 시 불필요 -->
|
<!-- preconnect는 Gateway 프록시 사용 시 불필요 -->
|
||||||
<link rel="preload" href="/css/design-system.css" as="style">
|
<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/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/design-system.css">
|
||||||
<link rel="stylesheet" href="/css/modern-dashboard.css?v=2">
|
<link rel="stylesheet" href="/css/modern-dashboard.css?v=3">
|
||||||
<link rel="stylesheet" href="/css/mobile.css?v=1">
|
<link rel="stylesheet" href="/css/mobile.css?v=2">
|
||||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||||
|
|
||||||
<!-- 최적화된 로딩: API 설정 → 앱 초기화 (병렬 컴포넌트 로딩) -->
|
<!-- 최적화된 로딩: API 설정 → 앱 초기화 (병렬 컴포넌트 로딩) -->
|
||||||
<script src="/js/api-base.js"></script>
|
<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/modern-dashboard.js?v=10" defer></script>
|
||||||
<script type="module" src="/js/group-leader-dashboard.js?v=1" defer></script>
|
<script type="module" src="/js/group-leader-dashboard.js?v=1" defer></script>
|
||||||
<script src="/js/workplace-status.js" defer></script>
|
<script src="/js/workplace-status.js" defer></script>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<link rel="stylesheet" href="/css/daily-patrol.css?v=4">
|
<link rel="stylesheet" href="/css/daily-patrol.css?v=4">
|
||||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||||
<script src="/js/api-base.js"></script>
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- 네비게이션 바 -->
|
<!-- 네비게이션 바 -->
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<link rel="stylesheet" href="/css/zone-detail.css?v=3">
|
<link rel="stylesheet" href="/css/zone-detail.css?v=3">
|
||||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||||
<script src="/js/api-base.js"></script>
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- 네비게이션 바 -->
|
<!-- 네비게이션 바 -->
|
||||||
|
|||||||
@@ -29,23 +29,6 @@
|
|||||||
margin: 0;
|
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 {
|
.tab-menu {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -401,6 +384,65 @@
|
|||||||
margin-bottom: 1rem;
|
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 {
|
.weather-icon {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -437,9 +479,6 @@
|
|||||||
<div class="page-container">
|
<div class="page-container">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h1 class="page-title">안전 체크리스트 관리</h1>
|
<h1 class="page-title">안전 체크리스트 관리</h1>
|
||||||
<button class="btn-add" onclick="openAddModal()">
|
|
||||||
<span>+</span> 항목 추가
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 탭 메뉴 -->
|
<!-- 탭 메뉴 -->
|
||||||
@@ -589,7 +628,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/api-base.js"></script>
|
<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://instant.page/5.2.0" type="module"></script>
|
||||||
<script type="module" src="/js/safety-checklist-manage.js"></script>
|
<script type="module" src="/js/safety-checklist-manage.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<link rel="stylesheet" href="/css/project-management.css?v=3">
|
<link rel="stylesheet" href="/css/project-management.css?v=3">
|
||||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||||
<script src="/js/api-base.js"></script>
|
<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://instant.page/5.2.0" type="module"></script>
|
||||||
<style>
|
<style>
|
||||||
.status-tabs {
|
.status-tabs {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<link rel="stylesheet" href="/css/project-management.css?v=3">
|
<link rel="stylesheet" href="/css/project-management.css?v=3">
|
||||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||||
<script src="/js/api-base.js"></script>
|
<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://instant.page/5.2.0" type="module"></script>
|
||||||
<style>
|
<style>
|
||||||
/* 스텝 인디케이터 */
|
/* 스텝 인디케이터 */
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<link rel="stylesheet" href="/css/project-management.css?v=3">
|
<link rel="stylesheet" href="/css/project-management.css?v=3">
|
||||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||||
<script src="/js/api-base.js"></script>
|
<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://instant.page/5.2.0" type="module"></script>
|
||||||
<style>
|
<style>
|
||||||
.training-container {
|
.training-container {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<link rel="stylesheet" href="/css/project-management.css?v=3">
|
<link rel="stylesheet" href="/css/project-management.css?v=3">
|
||||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||||
<script src="/js/api-base.js"></script>
|
<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://instant.page/5.2.0" type="module"></script>
|
||||||
<style>
|
<style>
|
||||||
.visit-form-container {
|
.visit-form-container {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
<link rel="stylesheet" href="/css/work-analysis.css?v=41">
|
<link rel="stylesheet" href="/css/work-analysis.css?v=41">
|
||||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||||
<script src="/js/api-base.js"></script>
|
<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://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>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.js"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<link rel="stylesheet" href="/css/project-management.css?v=3">
|
<link rel="stylesheet" href="/css/project-management.css?v=3">
|
||||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||||
<script src="/js/api-base.js"></script>
|
<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://instant.page/5.2.0" type="module"></script>
|
||||||
<style>
|
<style>
|
||||||
/* 통계 카드 */
|
/* 통계 카드 */
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||||
<!-- 최적화된 로딩 -->
|
<!-- 최적화된 로딩 -->
|
||||||
<script src="/js/api-base.js"></script>
|
<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://instant.page/5.2.0" type="module"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -169,7 +169,7 @@
|
|||||||
|
|
||||||
<!-- 스크립트 -->
|
<!-- 스크립트 -->
|
||||||
<script src="/js/api-base.js"></script>
|
<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://instant.page/5.2.0" type="module"></script>
|
||||||
|
|
||||||
<!-- 작업보고서 모듈 (리팩토링된 구조) -->
|
<!-- 작업보고서 모듈 (리팩토링된 구조) -->
|
||||||
|
|||||||
@@ -6,12 +6,12 @@
|
|||||||
<title>TBM 관리 | (주)테크니컬코리아</title>
|
<title>TBM 관리 | (주)테크니컬코리아</title>
|
||||||
<link rel="stylesheet" href="/css/design-system.css">
|
<link rel="stylesheet" href="/css/design-system.css">
|
||||||
<link rel="stylesheet" href="/css/common.css?v=2">
|
<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="stylesheet" href="/css/mobile.css?v=1">
|
||||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||||
<!-- 최적화된 로딩: API 설정 → 앱 초기화 (병렬 컴포넌트 로딩) -->
|
<!-- 최적화된 로딩: API 설정 → 앱 초기화 (병렬 컴포넌트 로딩) -->
|
||||||
<script src="/js/api-base.js"></script>
|
<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: 링크 호버 시 페이지 프리로딩 -->
|
<!-- instant.page: 링크 호버 시 페이지 프리로딩 -->
|
||||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||||
</head>
|
</head>
|
||||||
@@ -220,7 +220,7 @@
|
|||||||
</div>
|
</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" style="max-width: 700px;">
|
||||||
<div class="tbm-modal-header">
|
<div class="tbm-modal-header">
|
||||||
<h2 class="tbm-modal-title">
|
<h2 class="tbm-modal-title">
|
||||||
@@ -310,7 +310,7 @@
|
|||||||
</div>
|
</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" style="max-width: 800px;">
|
||||||
<div class="tbm-modal-header">
|
<div class="tbm-modal-header">
|
||||||
<h2 class="tbm-modal-title">
|
<h2 class="tbm-modal-title">
|
||||||
@@ -342,7 +342,7 @@
|
|||||||
</div>
|
</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" style="max-width: 600px;">
|
||||||
<div class="tbm-modal-header">
|
<div class="tbm-modal-header">
|
||||||
<h2 class="tbm-modal-title" id="itemSelectModalTitle">항목 선택</h2>
|
<h2 class="tbm-modal-title" id="itemSelectModalTitle">항목 선택</h2>
|
||||||
@@ -362,7 +362,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 작업장 선택 모달 (2단계: 공장 → 작업장) -->
|
<!-- 작업장 선택 모달 (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" style="max-width: 1000px;">
|
||||||
<div class="tbm-modal-header">
|
<div class="tbm-modal-header">
|
||||||
<h2 class="tbm-modal-title">
|
<h2 class="tbm-modal-title">
|
||||||
@@ -384,7 +384,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 2단계: 작업장 선택 (지도 + 리스트) -->
|
<!-- 2단계: 작업장 선택 (지도 기본 + 리스트 토글) -->
|
||||||
<div id="workplaceSelectionArea" style="display: none;">
|
<div id="workplaceSelectionArea" style="display: none;">
|
||||||
<div class="tbm-form-section">
|
<div class="tbm-form-section">
|
||||||
<h3 class="tbm-form-section-title">
|
<h3 class="tbm-form-section-title">
|
||||||
@@ -392,8 +392,8 @@
|
|||||||
작업장 선택
|
작업장 선택
|
||||||
</h3>
|
</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 style="font-size: 0.875rem; color: #64748b; margin-bottom: 0.75rem;">
|
||||||
지도에서 작업장을 클릭하여 선택하세요
|
지도에서 작업장을 클릭하여 선택하세요
|
||||||
</div>
|
</div>
|
||||||
@@ -402,18 +402,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 리스트 기반 선택 (오류 대비용) -->
|
<!-- 리스트 기반 선택 (모바일에서 토글) -->
|
||||||
<div>
|
<div style="margin-top: 0.75rem;">
|
||||||
<div style="font-size: 0.875rem; color: #64748b; margin-bottom: 0.75rem; display: flex; align-items: center; justify-content: space-between;">
|
<button type="button" class="tbm-btn tbm-btn-secondary tbm-btn-sm"
|
||||||
<span>리스트에서 선택 (지도 오류 시)</span>
|
onclick="toggleWorkplaceList()" id="toggleListBtn" style="display: none;">
|
||||||
<button type="button" class="tbm-btn tbm-btn-secondary tbm-btn-sm" onclick="toggleWorkplaceList()" id="toggleListBtn">
|
리스트로 선택
|
||||||
<span id="toggleListIcon">▼</span>
|
</button>
|
||||||
리스트 보기
|
<div id="workplaceListSection">
|
||||||
</button>
|
<div id="workplaceList" class="tbm-item-list">
|
||||||
</div>
|
<div style="color: #94a3b8; text-align: center; padding: 2rem;">
|
||||||
<div id="workplaceList" class="tbm-item-list" style="display: none; max-height: 250px;">
|
공장을 먼저 선택해주세요
|
||||||
<div style="color: #94a3b8; text-align: center; padding: 2rem;">
|
</div>
|
||||||
공장을 먼저 선택해주세요
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -652,7 +651,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<button type="button" class="tbm-btn tbm-btn-secondary" onclick="closeDetailModal()">닫기</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -668,7 +667,7 @@
|
|||||||
<script src="/js/tbm/api.js?v=1"></script>
|
<script src="/js/tbm/api.js?v=1"></script>
|
||||||
|
|
||||||
<!-- 기존 UI 로직 (점진적 마이그레이션) -->
|
<!-- 기존 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>
|
<div id="mobile-nav-container"></div>
|
||||||
|
|||||||
73
system1-factory/web/sw.js
Normal file
73
system1-factory/web/sw.js
Normal 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);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
<link rel="preload" href="https://cdn.tailwindcss.com" as="script">
|
<link rel="preload" href="https://cdn.tailwindcss.com" as="script">
|
||||||
<script src="https://cdn.tailwindcss.com"></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="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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
@@ -43,6 +43,9 @@
|
|||||||
<button class="tab-btn px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap" onclick="switchTab('workplaces')">
|
<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>작업장
|
<i class="fas fa-building mr-2"></i>작업장
|
||||||
</button>
|
</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')">
|
<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>부서
|
<i class="fas fa-sitemap mr-2"></i>부서
|
||||||
</button>
|
</button>
|
||||||
@@ -315,6 +318,56 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 id="tab-departments" class="hidden">
|
||||||
<div class="grid lg:grid-cols-5 gap-6">
|
<div class="grid lg:grid-cols-5 gap-6">
|
||||||
@@ -869,6 +922,71 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 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">
|
<div class="bg-white rounded-xl max-w-md w-full p-6">
|
||||||
@@ -1270,18 +1388,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- JS: Core (config, token, api, toast, helpers, init) -->
|
<!-- 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 -->
|
<!-- 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 -->
|
<!-- JS: Individual modules -->
|
||||||
<script src="/static/js/tkuser-users.js?v=20260223"></script>
|
<script src="/static/js/tkuser-users.js?v=20260224"></script>
|
||||||
<script src="/static/js/tkuser-projects.js?v=20260223"></script>
|
<script src="/static/js/tkuser-projects.js?v=20260224"></script>
|
||||||
<script src="/static/js/tkuser-departments.js?v=20260223"></script>
|
<script src="/static/js/tkuser-departments.js?v=20260224"></script>
|
||||||
<script src="/static/js/tkuser-issue-types.js?v=20260223"></script>
|
<script src="/static/js/tkuser-issue-types.js?v=20260224"></script>
|
||||||
<script src="/static/js/tkuser-workplaces.js?v=20260223"></script>
|
<script src="/static/js/tkuser-workplaces.js?v=20260224"></script>
|
||||||
<script src="/static/js/tkuser-tasks.js?v=20260223"></script>
|
<script src="/static/js/tkuser-tasks.js?v=20260224"></script>
|
||||||
<script src="/static/js/tkuser-vacations.js?v=20260223"></script>
|
<script src="/static/js/tkuser-vacations.js?v=20260224"></script>
|
||||||
<script src="/static/js/tkuser-layout-map.js?v=20260223"></script>
|
<script src="/static/js/tkuser-layout-map.js?v=20260224"></script>
|
||||||
<!-- Boot -->
|
<!-- Boot -->
|
||||||
<script>init();</script>
|
<script>init();</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,5 +1,208 @@
|
|||||||
/* ===== Issue Types ===== */
|
/* ===== Issue Types CRUD ===== */
|
||||||
/* Placeholder module for issue type CRUD operations.
|
let issueCategories = [], issueItems = [], issueTypesLoaded = false;
|
||||||
This file is reserved for future issue category management functionality.
|
let currentIssueType = 'nonconformity';
|
||||||
Currently, issue types are managed through System 3 permissions in tkuser-users.js.
|
|
||||||
*/
|
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'); }
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,5 +21,6 @@ function switchTab(name) {
|
|||||||
if (name === 'workplaces' && !workplacesLoaded) loadWorkplaces();
|
if (name === 'workplaces' && !workplacesLoaded) loadWorkplaces();
|
||||||
if (name === 'tasks' && !tasksLoaded) loadTasksTab();
|
if (name === 'tasks' && !tasksLoaded) loadTasksTab();
|
||||||
if (name === 'vacations' && !vacationsLoaded) loadVacationsTab();
|
if (name === 'vacations' && !vacationsLoaded) loadVacationsTab();
|
||||||
|
if (name === 'issueTypes' && !issueTypesLoaded) loadIssueTypes();
|
||||||
if (name === 'permissions' && !permissionsTabLoaded) loadPermissionsTab();
|
if (name === 'permissions' && !permissionsTabLoaded) loadPermissionsTab();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user