From 6411eab210b7d7bbe179084b139828cd7edf1624 Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Mon, 30 Mar 2026 07:40:56 +0900 Subject: [PATCH] =?UTF-8?q?feat(sprint-002):=20=EB=8C=80=EB=A6=AC=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=20+=20=EC=9D=BC=EB=B3=84=20=ED=98=84=ED=99=A9=20?= =?UTF-8?q?=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20(Section=20A+B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Section A (Backend): - POST /api/proxy-input: TBM 세션+팀배정+작업보고서 일괄 생성 (트랜잭션) - GET /api/proxy-input/daily-status: 일별 TBM/보고서 입력 현황 - GET /api/proxy-input/daily-status/detail: 작업자별 상세 - tbm_sessions에 is_proxy_input, proxy_input_by 컬럼 추가 - system1/system2/tkuser requireMinLevel → shared requirePage 전환 - permissionModel에 factory_proxy_input, factory_daily_status 키 등록 Section B (Frontend): - daily-status.html: 날짜 네비 + 요약 카드 + 필터 탭 + 작업자 리스트 + 바텀시트 - proxy-input.html: 미입력자 카드 + 확장 폼 + 일괄 설정 + 저장 - tkfb-core.js NAV_MENU에 입력 현황/대리입력 추가 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../api/controllers/proxyInputController.js | 187 ++++++++ .../20260330_add_proxy_input_fields.sql | 3 + system1-factory/api/models/proxyInputModel.js | 211 +++++++++ system1-factory/api/routes.js | 1 + .../api/routes/departmentRoutes.js | 15 +- system1-factory/api/routes/meetingRoutes.js | 22 +- system1-factory/api/routes/projectRoutes.js | 11 +- .../api/routes/proxyInputRoutes.js | 20 + .../api/routes/purchaseRequestRoutes.js | 8 +- system1-factory/api/routes/purchaseRoutes.js | 6 +- system1-factory/api/routes/scheduleRoutes.js | 30 +- .../api/routes/settlementRoutes.js | 8 +- system1-factory/api/routes/tbmRoutes.js | 11 +- system1-factory/api/routes/toolsRoute.js | 11 +- system1-factory/api/routes/uploadBgRoutes.js | 7 +- system1-factory/api/routes/workIssueRoutes.js | 28 +- .../api/routes/workReportAnalysisRoutes.js | 7 +- system1-factory/web/css/daily-status.css | 292 +++++++++++++ system1-factory/web/css/proxy-input.css | 232 ++++++++++ system1-factory/web/js/daily-status.js | 300 +++++++++++++ system1-factory/web/js/proxy-input.js | 404 ++++++++++++++++++ .../web/pages/work/daily-status.html | 170 ++++++++ .../web/pages/work/proxy-input.html | 120 ++++++ system1-factory/web/static/js/tkfb-core.js | 3 + system2-report/api/routes/workIssueRoutes.js | 52 +-- user-management/api/models/permissionModel.js | 20 + .../api/routes/notificationRoutes.js | 7 +- user-management/api/routes/vacationRoutes.js | 7 +- 28 files changed, 2097 insertions(+), 96 deletions(-) create mode 100644 system1-factory/api/controllers/proxyInputController.js create mode 100644 system1-factory/api/db/migrations/20260330_add_proxy_input_fields.sql create mode 100644 system1-factory/api/models/proxyInputModel.js create mode 100644 system1-factory/api/routes/proxyInputRoutes.js create mode 100644 system1-factory/web/css/daily-status.css create mode 100644 system1-factory/web/css/proxy-input.css create mode 100644 system1-factory/web/js/daily-status.js create mode 100644 system1-factory/web/js/proxy-input.js create mode 100644 system1-factory/web/pages/work/daily-status.html create mode 100644 system1-factory/web/pages/work/proxy-input.html diff --git a/system1-factory/api/controllers/proxyInputController.js b/system1-factory/api/controllers/proxyInputController.js new file mode 100644 index 0000000..2ee1ff8 --- /dev/null +++ b/system1-factory/api/controllers/proxyInputController.js @@ -0,0 +1,187 @@ +/** + * 대리입력 + 일별 현황 컨트롤러 + */ +const ProxyInputModel = require('../models/proxyInputModel'); +const { getDb } = require('../dbPool'); +const logger = require('../../shared/utils/logger'); + +const ProxyInputController = { + /** + * POST /api/proxy-input — 대리입력 (단일 트랜잭션) + */ + proxyInput: async (req, res) => { + const { session_date, leader_id, entries, safety_notes, work_location } = req.body; + const userId = req.user.user_id || req.user.id; + + // 유효성 검사 + if (!session_date) { + return res.status(400).json({ success: false, message: '날짜는 필수입니다.' }); + } + if (!entries || !Array.isArray(entries) || entries.length === 0) { + return res.status(400).json({ success: false, message: '작업자 정보는 최소 1명 필요합니다.' }); + } + if (entries.length > 30) { + return res.status(400).json({ success: false, message: '한 번에 30명까지 입력 가능합니다.' }); + } + + // 날짜 유효성 (과거 30일 ~ 오늘) + const today = new Date(); + today.setHours(0, 0, 0, 0); + const inputDate = new Date(session_date); + const diffDays = Math.floor((today - inputDate) / (1000 * 60 * 60 * 24)); + if (diffDays < 0) { + return res.status(400).json({ success: false, message: '미래 날짜는 입력할 수 없습니다.' }); + } + if (diffDays > 30) { + return res.status(400).json({ success: false, message: '30일 이내 날짜만 입력 가능합니다.' }); + } + + // entries 필수 필드 검사 + for (const entry of entries) { + if (!entry.user_id || !entry.project_id || !entry.work_type_id || !entry.work_hours) { + return res.status(400).json({ success: false, message: '각 작업자의 user_id, project_id, work_type_id, work_hours는 필수입니다.' }); + } + if (entry.work_hours <= 0 || entry.work_hours > 24) { + return res.status(400).json({ success: false, message: '근무 시간은 0 초과 24 이하여야 합니다.' }); + } + } + + const db = await getDb(); + const conn = await db.getConnection(); + + try { + await conn.beginTransaction(); + + const userIds = entries.map(e => e.user_id); + + // 1. 중복 체크 + const duplicates = await ProxyInputModel.checkDuplicateAssignments(conn, session_date, userIds); + if (duplicates.length > 0) { + await conn.rollback(); + return res.status(409).json({ + success: false, + message: `다음 작업자가 이미 해당 날짜에 TBM 배정되어 있습니다: ${duplicates.map(d => d.worker_name).join(', ')}`, + data: { duplicate_workers: duplicates } + }); + } + + // 2. 작업자 존재 체크 + const validWorkerIds = await ProxyInputModel.validateWorkers(conn, userIds); + const invalidIds = userIds.filter(id => !validWorkerIds.includes(id)); + if (invalidIds.length > 0) { + await conn.rollback(); + return res.status(400).json({ + success: false, + message: `존재하지 않거나 비활성 작업자: ${invalidIds.join(', ')}` + }); + } + + // 3. TBM 세션 생성 + const sessionResult = await ProxyInputModel.createProxySession(conn, { + session_date, + leader_id: leader_id || userId, + proxy_input_by: userId, + created_by: userId, + safety_notes: safety_notes || '', + work_location: work_location || '' + }); + const sessionId = sessionResult.insertId; + + // 4. 각 entry 처리 + const createdWorkers = []; + for (const entry of entries) { + // 팀 배정 + const assignResult = await ProxyInputModel.createTeamAssignment(conn, { + session_id: sessionId, + user_id: entry.user_id, + project_id: entry.project_id, + work_type_id: entry.work_type_id, + task_id: entry.task_id || null, + workplace_id: entry.workplace_id || null, + work_hours: entry.work_hours + }); + const assignmentId = assignResult.insertId; + + // 작업보고서 + const reportResult = await ProxyInputModel.createWorkReport(conn, { + report_date: session_date, + user_id: entry.user_id, + project_id: entry.project_id, + work_type_id: entry.work_type_id, + task_id: entry.task_id || null, + work_status_id: entry.work_status_id || 1, + work_hours: entry.work_hours, + start_time: entry.start_time || null, + end_time: entry.end_time || null, + note: entry.note || '', + tbm_session_id: sessionId, + tbm_assignment_id: assignmentId, + created_by: userId + }); + + createdWorkers.push({ + user_id: entry.user_id, + report_id: reportResult.insertId + }); + } + + await conn.commit(); + + res.status(201).json({ + success: true, + message: `${entries.length}명의 대리입력이 완료되었습니다.`, + data: { + session_id: sessionId, + is_proxy_input: true, + created_reports: entries.length, + workers: createdWorkers + } + }); + } catch (err) { + try { await conn.rollback(); } catch (e) {} + logger.error('대리입력 오류:', err); + res.status(500).json({ success: false, message: '대리입력 처리 중 오류가 발생했습니다.', error: err.message }); + } finally { + conn.release(); + } + }, + + /** + * GET /api/proxy-input/daily-status — 일별 현황 + */ + getDailyStatus: async (req, res) => { + try { + const { date } = req.query; + if (!date) { + return res.status(400).json({ success: false, message: '날짜(date) 파라미터는 필수입니다.' }); + } + const data = await ProxyInputModel.getDailyStatus(date); + res.json({ success: true, data }); + } catch (err) { + logger.error('일별 현황 조회 오류:', err); + res.status(500).json({ success: false, message: '조회 중 오류가 발생했습니다.', error: err.message }); + } + }, + + /** + * GET /api/proxy-input/daily-status/detail — 작업자별 상세 + */ + getDailyStatusDetail: async (req, res) => { + try { + const { date, user_id } = req.query; + if (!date || !user_id) { + return res.status(400).json({ success: false, message: 'date와 user_id 파라미터는 필수입니다.' }); + } + const data = await ProxyInputModel.getDailyStatusDetail(date, parseInt(user_id)); + if (!data.worker) { + return res.status(404).json({ success: false, message: '작업자를 찾을 수 없습니다.' }); + } + res.json({ success: true, data }); + } catch (err) { + logger.error('일별 상세 조회 오류:', err); + res.status(500).json({ success: false, message: '조회 중 오류가 발생했습니다.', error: err.message }); + } + } +}; + +module.exports = ProxyInputController; diff --git a/system1-factory/api/db/migrations/20260330_add_proxy_input_fields.sql b/system1-factory/api/db/migrations/20260330_add_proxy_input_fields.sql new file mode 100644 index 0000000..26d5a52 --- /dev/null +++ b/system1-factory/api/db/migrations/20260330_add_proxy_input_fields.sql @@ -0,0 +1,3 @@ +-- 대리입력 식별 컬럼 추가 +ALTER TABLE tbm_sessions ADD COLUMN IF NOT EXISTS is_proxy_input TINYINT(1) DEFAULT 0 COMMENT '대리입력 여부'; +ALTER TABLE tbm_sessions ADD COLUMN IF NOT EXISTS proxy_input_by INT NULL COMMENT '대리입력자 sso_users.user_id (앱 레벨 참조)'; diff --git a/system1-factory/api/models/proxyInputModel.js b/system1-factory/api/models/proxyInputModel.js new file mode 100644 index 0000000..9e8b507 --- /dev/null +++ b/system1-factory/api/models/proxyInputModel.js @@ -0,0 +1,211 @@ +/** + * 대리입력 + 일별 현황 모델 + */ +const { getDb } = require('../dbPool'); + +const ProxyInputModel = { + /** + * 중복 배정 체크 (같은 날짜 + 같은 작업자) + */ + checkDuplicateAssignments: async (conn, sessionDate, userIds) => { + if (!userIds.length) return []; + const placeholders = userIds.map(() => '?').join(','); + const [rows] = await conn.query(` + SELECT ta.user_id, w.worker_name, ta.session_id + FROM tbm_team_assignments ta + JOIN tbm_sessions s ON ta.session_id = s.session_id + JOIN workers w ON ta.user_id = w.worker_id + WHERE s.session_date = ? AND ta.user_id IN (${placeholders}) AND s.status != 'cancelled' + `, [sessionDate, ...userIds]); + return rows; + }, + + /** + * 작업자 존재 여부 체크 + */ + validateWorkers: async (conn, userIds) => { + if (!userIds.length) return []; + const placeholders = userIds.map(() => '?').join(','); + const [rows] = await conn.query(` + SELECT worker_id FROM workers WHERE worker_id IN (${placeholders}) AND status = 'active' + `, [...userIds]); + return rows.map(r => r.worker_id); + }, + + /** + * TBM 세션 생성 (대리입력) + */ + createProxySession: async (conn, data) => { + const [result] = await conn.query(` + INSERT INTO tbm_sessions (session_date, leader_user_id, status, is_proxy_input, proxy_input_by, created_by, safety_notes, work_location) + VALUES (?, ?, 'completed', 1, ?, ?, ?, ?) + `, [data.session_date, data.leader_id, data.proxy_input_by, data.created_by, data.safety_notes || '', data.work_location || '']); + return result; + }, + + /** + * 팀 배정 생성 + */ + createTeamAssignment: async (conn, data) => { + const [result] = await conn.query(` + INSERT INTO tbm_team_assignments (session_id, user_id, project_id, work_type_id, task_id, workplace_id, work_hours, is_present) + VALUES (?, ?, ?, ?, ?, ?, ?, 1) + `, [data.session_id, data.user_id, data.project_id, data.work_type_id, data.task_id || null, data.workplace_id || null, data.work_hours]); + return result; + }, + + /** + * 작업보고서 생성 (accumulative) + */ + createWorkReport: async (conn, data) => { + const [result] = await conn.query(` + INSERT INTO daily_work_reports (report_date, user_id, project_id, work_type_id, task_id, work_status_id, work_hours, start_time, end_time, note, tbm_session_id, tbm_assignment_id, created_by, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW()) + `, [data.report_date, data.user_id, data.project_id, data.work_type_id, data.task_id || null, data.work_status_id || 1, data.work_hours, data.start_time || null, data.end_time || null, data.note || '', data.tbm_session_id, data.tbm_assignment_id, data.created_by]); + return result; + }, + + /** + * 일별 현황 조회 + */ + getDailyStatus: async (date) => { + const db = await getDb(); + + // 1. 활성 작업자 + const [workers] = await db.query(` + SELECT w.worker_id AS user_id, w.worker_name, w.job_type, + COALESCE(d.department_name, '미배정') AS department_name + FROM workers w + LEFT JOIN departments d ON w.department_id = d.department_id + WHERE w.status = 'active' + ORDER BY w.worker_name + `); + + // 2. TBM 배정 현황 + const [tbmAssignments] = await db.query(` + SELECT ta.user_id, ta.session_id, s.leader_user_id, + lu.worker_name AS leader_name, s.is_proxy_input + FROM tbm_team_assignments ta + JOIN tbm_sessions s ON ta.session_id = s.session_id + LEFT JOIN workers lu ON s.leader_user_id = lu.worker_id + WHERE s.session_date = ? AND s.status != 'cancelled' + `, [date]); + + // 3. 작업보고서 현황 + const [reports] = await db.query(` + SELECT dwr.user_id, SUM(dwr.work_hours) AS total_hours, COUNT(*) AS entry_count + FROM daily_work_reports dwr + WHERE dwr.report_date = ? + GROUP BY dwr.user_id + `, [date]); + + // 메모리에서 조합 + const tbmMap = {}; + tbmAssignments.forEach(ta => { + if (!tbmMap[ta.user_id]) tbmMap[ta.user_id] = []; + tbmMap[ta.user_id].push(ta); + }); + + const reportMap = {}; + reports.forEach(r => { reportMap[r.user_id] = r; }); + + let tbmCompleted = 0, reportCompleted = 0, bothCompleted = 0, bothMissing = 0; + + const workerList = workers.map(w => { + const hasTbm = !!tbmMap[w.user_id]; + const hasReport = !!reportMap[w.user_id]; + const tbmSessions = (tbmMap[w.user_id] || []).map(ta => ({ + session_id: ta.session_id, + leader_name: ta.leader_name, + is_proxy_input: !!ta.is_proxy_input + })); + const totalReportHours = reportMap[w.user_id]?.total_hours || 0; + + let status = 'both_missing'; + if (hasTbm && hasReport) { status = 'complete'; bothCompleted++; } + else if (hasTbm && !hasReport) { status = 'tbm_only'; } + else if (!hasTbm && hasReport) { status = 'report_only'; } + else { bothMissing++; } + + if (hasTbm) tbmCompleted++; + if (hasReport) reportCompleted++; + + return { + user_id: w.user_id, worker_name: w.worker_name, job_type: w.job_type, + department_name: w.department_name, has_tbm: hasTbm, has_report: hasReport, + tbm_sessions: tbmSessions, total_report_hours: totalReportHours, status + }; + }); + + return { + date, + summary: { + total_active_workers: workers.length, + tbm_completed: tbmCompleted, + tbm_missing: workers.length - tbmCompleted, + report_completed: reportCompleted, + report_missing: workers.length - reportCompleted, + both_completed: bothCompleted, + both_missing: bothMissing + }, + workers: workerList + }; + }, + + /** + * 작업자별 상세 조회 + */ + getDailyStatusDetail: async (date, userId) => { + const db = await getDb(); + + // 작업자 정보 + const [workerRows] = await db.query(` + SELECT w.worker_id AS user_id, w.worker_name, w.job_type, + COALESCE(d.department_name, '미배정') AS department_name + FROM workers w + LEFT JOIN departments d ON w.department_id = d.department_id + WHERE w.worker_id = ? + `, [userId]); + + // TBM 세션 + const [tbmSessions] = await db.query(` + SELECT ta.session_id, s.status, s.is_proxy_input, + lu.worker_name AS leader_name, + pu.name AS proxy_input_by_name, + p.project_name, wt.work_type_name, ta.work_hours + FROM tbm_team_assignments ta + JOIN tbm_sessions s ON ta.session_id = s.session_id + LEFT JOIN workers lu ON s.leader_user_id = lu.worker_id + LEFT JOIN sso_users pu ON s.proxy_input_by = pu.user_id + LEFT JOIN projects p ON ta.project_id = p.project_id + LEFT JOIN work_types wt ON ta.work_type_id = wt.work_type_id + WHERE s.session_date = ? AND ta.user_id = ? AND s.status != 'cancelled' + `, [date, userId]); + + // 작업보고서 + const [workReports] = await db.query(` + SELECT dwr.report_id, dwr.work_hours, dwr.created_at, dwr.created_by, + cu.name AS created_by_name, + p.project_name, wt.work_type_name, t.task_name, + ws.status_name AS work_status, + s.is_proxy_input + FROM daily_work_reports dwr + LEFT JOIN sso_users cu ON dwr.created_by = cu.user_id + LEFT JOIN projects p ON dwr.project_id = p.project_id + LEFT JOIN work_types wt ON dwr.work_type_id = wt.work_type_id + LEFT JOIN tasks t ON dwr.task_id = t.task_id + LEFT JOIN work_statuses ws ON dwr.work_status_id = ws.work_status_id + LEFT JOIN tbm_sessions s ON dwr.tbm_session_id = s.session_id + WHERE dwr.report_date = ? AND dwr.user_id = ? + ORDER BY dwr.created_at + `, [date, userId]); + + return { + worker: workerRows[0] || null, + tbm_sessions: tbmSessions, + work_reports: workReports + }; + } +}; + +module.exports = ProxyInputModel; diff --git a/system1-factory/api/routes.js b/system1-factory/api/routes.js index bbe6d0e..b766976 100644 --- a/system1-factory/api/routes.js +++ b/system1-factory/api/routes.js @@ -153,6 +153,7 @@ function setupRoutes(app) { app.use('/api/vacation-types', vacationTypeRoutes); // 휴가 유형 관리 app.use('/api/vacation-balances', vacationBalanceRoutes); // 휴가 잔액 관리 app.use('/api/tbm', tbmRoutes); // TBM 시스템 + app.use('/api/proxy-input', require('./routes/proxyInputRoutes')); // 대리입력 + 일별현황 app.use('/api/work-issues', workIssueRoutes); // 카테고리/아이템 + 신고 조회 (같은 MariaDB 공유) app.use('/api/departments', departmentRoutes); // 부서 관리 app.use('/api/patrol', patrolRoutes); // 일일순회점검 시스템 diff --git a/system1-factory/api/routes/departmentRoutes.js b/system1-factory/api/routes/departmentRoutes.js index d6e7198..70285c7 100644 --- a/system1-factory/api/routes/departmentRoutes.js +++ b/system1-factory/api/routes/departmentRoutes.js @@ -2,7 +2,10 @@ const express = require('express'); const router = express.Router(); const departmentController = require('../controllers/departmentController'); -const { requireAuth, requireRole } = require('../middlewares/auth'); +const { requireAuth } = require('../middlewares/auth'); +const { createRequirePage } = require('../../../shared/middleware/pagePermission'); +const { getDb } = require('../dbPool'); +const requirePage = createRequirePage(getDb); // 부서 목록 조회 (인증 필요) router.get('/', requireAuth, departmentController.getAll); @@ -14,18 +17,18 @@ router.get('/:id', requireAuth, departmentController.getById); router.get('/:id/workers', requireAuth, departmentController.getWorkers); // 부서 생성 (관리자만) -router.post('/', requireAuth, requireRole(['Admin', 'System Admin']), departmentController.create); +router.post('/', requireAuth, requirePage('factory_departments'), departmentController.create); // 부서 수정 (관리자만) -router.put('/:id', requireAuth, requireRole(['Admin', 'System Admin']), departmentController.update); +router.put('/:id', requireAuth, requirePage('factory_departments'), departmentController.update); // 부서 삭제 (관리자만) -router.delete('/:id', requireAuth, requireRole(['Admin', 'System Admin']), departmentController.delete); +router.delete('/:id', requireAuth, requirePage('factory_departments'), departmentController.delete); // 작업자 부서 이동 (관리자만) -router.post('/move-worker', requireAuth, requireRole(['Admin', 'System Admin']), departmentController.moveWorker); +router.post('/move-worker', requireAuth, requirePage('factory_departments'), departmentController.moveWorker); // 여러 작업자 부서 일괄 이동 (관리자만) -router.post('/move-workers', requireAuth, requireRole(['Admin', 'System Admin']), departmentController.moveWorkers); +router.post('/move-workers', requireAuth, requirePage('factory_departments'), departmentController.moveWorkers); module.exports = router; diff --git a/system1-factory/api/routes/meetingRoutes.js b/system1-factory/api/routes/meetingRoutes.js index 8b72424..e11dfd9 100644 --- a/system1-factory/api/routes/meetingRoutes.js +++ b/system1-factory/api/routes/meetingRoutes.js @@ -1,24 +1,26 @@ const express = require('express'); const router = express.Router(); const ctrl = require('../controllers/meetingController'); -const { requireMinLevel } = require('../middlewares/auth'); +const { createRequirePage } = require('../../../shared/middleware/pagePermission'); +const { getDb } = require('../dbPool'); +const requirePage = createRequirePage(getDb); // 회의록 router.get('/', ctrl.getAll); router.get('/action-items', ctrl.getActionItems); router.get('/:id', ctrl.getById); -router.post('/', requireMinLevel('support_team'), ctrl.create); -router.put('/:id', requireMinLevel('support_team'), ctrl.update); -router.put('/:id/publish', requireMinLevel('support_team'), ctrl.publish); -router.put('/:id/unpublish', requireMinLevel('admin'), ctrl.unpublish); -router.delete('/:id', requireMinLevel('admin'), ctrl.delete); +router.post('/', requirePage('factory_meetings'), ctrl.create); +router.put('/:id', requirePage('factory_meetings'), ctrl.update); +router.put('/:id/publish', requirePage('factory_meetings'), ctrl.publish); +router.put('/:id/unpublish', requirePage('factory_meetings'), ctrl.unpublish); +router.delete('/:id', requirePage('factory_meetings'), ctrl.delete); // 안건 -router.post('/:id/items', requireMinLevel('support_team'), ctrl.addItem); -router.put('/:id/items/:itemId', requireMinLevel('support_team'), ctrl.updateItem); -router.delete('/:id/items/:itemId', requireMinLevel('support_team'), ctrl.deleteItem); +router.post('/:id/items', requirePage('factory_meetings'), ctrl.addItem); +router.put('/:id/items/:itemId', requirePage('factory_meetings'), ctrl.updateItem); +router.delete('/:id/items/:itemId', requirePage('factory_meetings'), ctrl.deleteItem); // 조치상태 업데이트 -router.put('/items/:itemId/status', requireMinLevel('group_leader'), ctrl.updateItemStatus); +router.put('/items/:itemId/status', requirePage('factory_meetings'), ctrl.updateItemStatus); module.exports = router; diff --git a/system1-factory/api/routes/projectRoutes.js b/system1-factory/api/routes/projectRoutes.js index 4fddf5d..176cc2f 100644 --- a/system1-factory/api/routes/projectRoutes.js +++ b/system1-factory/api/routes/projectRoutes.js @@ -2,7 +2,10 @@ const express = require('express'); const router = express.Router(); const projectController = require('../controllers/projectController'); -const { requireAuth, requireMinLevel } = require('../middlewares/auth'); +const { requireAuth } = require('../middlewares/auth'); +const { createRequirePage } = require('../../../shared/middleware/pagePermission'); +const { getDb } = require('../dbPool'); +const requirePage = createRequirePage(getDb); // READ - 인증된 사용자 router.get('/', requireAuth, projectController.getAllProjects); @@ -10,10 +13,10 @@ router.get('/active/list', requireAuth, projectController.getActiveProjects); router.get('/:project_id', requireAuth, projectController.getProjectById); // CREATE/UPDATE - support_team 이상 권한 필요 -router.post('/', requireAuth, requireMinLevel('support_team'), projectController.createProject); -router.put('/:project_id', requireAuth, requireMinLevel('support_team'), projectController.updateProject); +router.post('/', requireAuth, requirePage('factory_projects'), projectController.createProject); +router.put('/:project_id', requireAuth, requirePage('factory_projects'), projectController.updateProject); // DELETE - admin 이상 권한 필요 -router.delete('/:project_id', requireAuth, requireMinLevel('admin'), projectController.removeProject); +router.delete('/:project_id', requireAuth, requirePage('factory_projects'), projectController.removeProject); module.exports = router; \ No newline at end of file diff --git a/system1-factory/api/routes/proxyInputRoutes.js b/system1-factory/api/routes/proxyInputRoutes.js new file mode 100644 index 0000000..2cf285a --- /dev/null +++ b/system1-factory/api/routes/proxyInputRoutes.js @@ -0,0 +1,20 @@ +/** + * 대리입력 + 일별 현황 라우터 + */ +const express = require('express'); +const router = express.Router(); +const proxyInputController = require('../controllers/proxyInputController'); +const { createRequirePage } = require('../../../shared/middleware/pagePermission'); +const { getDb } = require('../dbPool'); +const requirePage = createRequirePage(getDb); + +// 대리입력 +router.post('/', requirePage('factory_proxy_input'), proxyInputController.proxyInput); + +// 일별 현황 +router.get('/daily-status', requirePage('factory_daily_status'), proxyInputController.getDailyStatus); + +// 작업자별 상세 +router.get('/daily-status/detail', requirePage('factory_daily_status'), proxyInputController.getDailyStatusDetail); + +module.exports = router; diff --git a/system1-factory/api/routes/purchaseRequestRoutes.js b/system1-factory/api/routes/purchaseRequestRoutes.js index fce1ba3..e5bf132 100644 --- a/system1-factory/api/routes/purchaseRequestRoutes.js +++ b/system1-factory/api/routes/purchaseRequestRoutes.js @@ -1,7 +1,9 @@ const express = require('express'); const router = express.Router(); const ctrl = require('../controllers/purchaseRequestController'); -const { requireMinLevel } = require('../middlewares/auth'); +const { createRequirePage } = require('../../../shared/middleware/pagePermission'); +const { getDb } = require('../dbPool'); +const requirePage = createRequirePage(getDb); // 보조 데이터 router.get('/consumable-items', ctrl.getConsumableItems); @@ -11,8 +13,8 @@ router.get('/vendors', ctrl.getVendors); router.get('/', ctrl.getAll); router.get('/:id', ctrl.getById); router.post('/', ctrl.create); -router.put('/:id/hold', requireMinLevel('admin'), ctrl.hold); -router.put('/:id/revert', requireMinLevel('admin'), ctrl.revert); +router.put('/:id/hold', requirePage('factory_purchases'), ctrl.hold); +router.put('/:id/revert', requirePage('factory_purchases'), ctrl.revert); router.delete('/:id', ctrl.delete); module.exports = router; diff --git a/system1-factory/api/routes/purchaseRoutes.js b/system1-factory/api/routes/purchaseRoutes.js index 45faed8..8397b59 100644 --- a/system1-factory/api/routes/purchaseRoutes.js +++ b/system1-factory/api/routes/purchaseRoutes.js @@ -1,10 +1,12 @@ const express = require('express'); const router = express.Router(); const ctrl = require('../controllers/purchaseController'); -const { requireMinLevel } = require('../middlewares/auth'); +const { createRequirePage } = require('../../../shared/middleware/pagePermission'); +const { getDb } = require('../dbPool'); +const requirePage = createRequirePage(getDb); router.get('/', ctrl.getAll); -router.post('/', requireMinLevel('admin'), ctrl.create); +router.post('/', requirePage('factory_purchases'), ctrl.create); router.get('/price-history/:itemId', ctrl.getPriceHistory); module.exports = router; diff --git a/system1-factory/api/routes/scheduleRoutes.js b/system1-factory/api/routes/scheduleRoutes.js index cfd6475..19bf02f 100644 --- a/system1-factory/api/routes/scheduleRoutes.js +++ b/system1-factory/api/routes/scheduleRoutes.js @@ -1,18 +1,20 @@ const express = require('express'); const router = express.Router(); const ctrl = require('../controllers/scheduleController'); -const { requireMinLevel } = require('../middlewares/auth'); +const { createRequirePage } = require('../../../shared/middleware/pagePermission'); +const { getDb } = require('../dbPool'); +const requirePage = createRequirePage(getDb); // 제품유형 router.get('/product-types', ctrl.getProductTypes); // 표준공정 자동 생성 -router.post('/generate-from-template', requireMinLevel('support_team'), ctrl.generateFromTemplate); +router.post('/generate-from-template', requirePage('factory_schedules'), ctrl.generateFromTemplate); // 공정 단계 router.get('/phases', ctrl.getPhases); -router.post('/phases', requireMinLevel('admin'), ctrl.createPhase); -router.put('/phases/:id', requireMinLevel('admin'), ctrl.updatePhase); +router.post('/phases', requirePage('factory_schedules'), ctrl.createPhase); +router.put('/phases/:id', requirePage('factory_schedules'), ctrl.updatePhase); // 작업 템플릿 router.get('/templates', ctrl.getTemplates); @@ -20,21 +22,21 @@ router.get('/templates', ctrl.getTemplates); // 공정표 항목 router.get('/entries', ctrl.getEntries); router.get('/entries/gantt', ctrl.getGanttData); -router.post('/entries', requireMinLevel('support_team'), ctrl.createEntry); -router.post('/entries/batch', requireMinLevel('support_team'), ctrl.createBatchEntries); -router.put('/entries/:id', requireMinLevel('support_team'), ctrl.updateEntry); -router.put('/entries/:id/progress', requireMinLevel('group_leader'), ctrl.updateProgress); -router.delete('/entries/:id', requireMinLevel('admin'), ctrl.deleteEntry); +router.post('/entries', requirePage('factory_schedules'), ctrl.createEntry); +router.post('/entries/batch', requirePage('factory_schedules'), ctrl.createBatchEntries); +router.put('/entries/:id', requirePage('factory_schedules'), ctrl.updateEntry); +router.put('/entries/:id/progress', requirePage('factory_schedules'), ctrl.updateProgress); +router.delete('/entries/:id', requirePage('factory_schedules'), ctrl.deleteEntry); // 의존관계 -router.post('/entries/:id/dependencies', requireMinLevel('support_team'), ctrl.addDependency); -router.delete('/entries/:id/dependencies/:depId', requireMinLevel('support_team'), ctrl.removeDependency); +router.post('/entries/:id/dependencies', requirePage('factory_schedules'), ctrl.addDependency); +router.delete('/entries/:id/dependencies/:depId', requirePage('factory_schedules'), ctrl.removeDependency); // 마일스톤 router.get('/milestones', ctrl.getMilestones); -router.post('/milestones', requireMinLevel('support_team'), ctrl.createMilestone); -router.put('/milestones/:id', requireMinLevel('support_team'), ctrl.updateMilestone); -router.delete('/milestones/:id', requireMinLevel('admin'), ctrl.deleteMilestone); +router.post('/milestones', requirePage('factory_schedules'), ctrl.createMilestone); +router.put('/milestones/:id', requirePage('factory_schedules'), ctrl.updateMilestone); +router.delete('/milestones/:id', requirePage('factory_schedules'), ctrl.deleteMilestone); // 부적합 연동 router.get('/nonconformance', ctrl.getNonconformance); diff --git a/system1-factory/api/routes/settlementRoutes.js b/system1-factory/api/routes/settlementRoutes.js index 39ff5a4..29a54aa 100644 --- a/system1-factory/api/routes/settlementRoutes.js +++ b/system1-factory/api/routes/settlementRoutes.js @@ -1,12 +1,14 @@ const express = require('express'); const router = express.Router(); const ctrl = require('../controllers/settlementController'); -const { requireMinLevel } = require('../middlewares/auth'); +const { createRequirePage } = require('../../../shared/middleware/pagePermission'); +const { getDb } = require('../dbPool'); +const requirePage = createRequirePage(getDb); router.get('/summary', ctrl.getMonthlySummary); router.get('/purchases', ctrl.getMonthlyPurchases); router.get('/price-changes', ctrl.getPriceChanges); -router.post('/complete', requireMinLevel('admin'), ctrl.complete); -router.post('/cancel', requireMinLevel('admin'), ctrl.cancel); +router.post('/complete', requirePage('factory_settlements'), ctrl.complete); +router.post('/cancel', requirePage('factory_settlements'), ctrl.cancel); module.exports = router; diff --git a/system1-factory/api/routes/tbmRoutes.js b/system1-factory/api/routes/tbmRoutes.js index 03fd87c..ee4f695 100644 --- a/system1-factory/api/routes/tbmRoutes.js +++ b/system1-factory/api/routes/tbmRoutes.js @@ -2,7 +2,10 @@ const express = require('express'); const router = express.Router(); const TbmController = require('../controllers/tbmController'); -const { requireAuth, requireRole } = require('../middlewares/auth'); +const { requireAuth } = require('../middlewares/auth'); +const { createRequirePage } = require('../../../shared/middleware/pagePermission'); +const { getDb } = require('../dbPool'); +const requirePage = createRequirePage(getDb); // ==================== TBM 세션 관련 ==================== @@ -56,13 +59,13 @@ router.delete('/sessions/:sessionId/team/:userId', requireAuth, TbmController.re router.get('/safety-checks', requireAuth, TbmController.getAllSafetyChecks); // 안전 체크 항목 생성 (관리자용) -router.post('/safety-checks', requireAuth, requireRole('admin', 'system'), TbmController.createSafetyCheck); +router.post('/safety-checks', requireAuth, requirePage('factory_tbm'), TbmController.createSafetyCheck); // 안전 체크 항목 수정 (관리자용) -router.put('/safety-checks/:checkId', requireAuth, requireRole('admin', 'system'), TbmController.updateSafetyCheck); +router.put('/safety-checks/:checkId', requireAuth, requirePage('factory_tbm'), TbmController.updateSafetyCheck); // 안전 체크 항목 삭제 (관리자용) -router.delete('/safety-checks/:checkId', requireAuth, requireRole('admin', 'system'), TbmController.deleteSafetyCheck); +router.delete('/safety-checks/:checkId', requireAuth, requirePage('factory_tbm'), TbmController.deleteSafetyCheck); // TBM 세션의 안전 체크 기록 조회 router.get('/sessions/:sessionId/safety', requireAuth, TbmController.getSafetyRecords); diff --git a/system1-factory/api/routes/toolsRoute.js b/system1-factory/api/routes/toolsRoute.js index c1bdcd5..2115732 100644 --- a/system1-factory/api/routes/toolsRoute.js +++ b/system1-factory/api/routes/toolsRoute.js @@ -2,15 +2,18 @@ const express = require('express'); const router = express.Router(); const controller = require('../controllers/toolsController'); -const { requireAuth, requireMinLevel } = require('../middlewares/auth'); +const { requireAuth } = require('../middlewares/auth'); +const { createRequirePage } = require('../../../shared/middleware/pagePermission'); +const { getDb } = require('../dbPool'); +const requirePage = createRequirePage(getDb); // 읽기 작업: 인증된 사용자 router.get('/', requireAuth, controller.getAll); router.get('/:id', requireAuth, controller.getById); // 쓰기 작업: group_leader 이상 권한 필요 -router.post('/', requireAuth, requireMinLevel('group_leader'), controller.create); -router.put('/:id', requireAuth, requireMinLevel('group_leader'), controller.update); -router.delete('/:id', requireAuth, requireMinLevel('admin'), controller.delete); +router.post('/', requireAuth, requirePage('factory_tools'), controller.create); +router.put('/:id', requireAuth, requirePage('factory_tools'), controller.update); +router.delete('/:id', requireAuth, requirePage('factory_tools'), controller.delete); module.exports = router; diff --git a/system1-factory/api/routes/uploadBgRoutes.js b/system1-factory/api/routes/uploadBgRoutes.js index dd807a9..9199d83 100644 --- a/system1-factory/api/routes/uploadBgRoutes.js +++ b/system1-factory/api/routes/uploadBgRoutes.js @@ -3,8 +3,11 @@ const express = require('express'); const router = express.Router(); const multer = require('multer'); const path = require('path'); -const { requireAuth, requireMinLevel } = require('../middlewares/auth'); +const { requireAuth } = require('../middlewares/auth'); const { createFileFilter, validateUploadedFile } = require('../utils/fileUploadSecurity'); +const { createRequirePage } = require('../../../shared/middleware/pagePermission'); +const { getDb } = require('../dbPool'); +const requirePage = createRequirePage(getDb); const storage = multer.diskStorage({ destination: (req, file, cb) => { @@ -31,7 +34,7 @@ const upload = multer({ }); // 관리자 권한 필요 -router.post('/upload-bg', requireAuth, requireMinLevel('admin'), upload.single('image'), async (req, res) => { +router.post('/upload-bg', requireAuth, requirePage('factory_uploads'), upload.single('image'), async (req, res) => { if (!req.file) { return res.status(400).json({ success: false, message: '파일이 없습니다.' }); } diff --git a/system1-factory/api/routes/workIssueRoutes.js b/system1-factory/api/routes/workIssueRoutes.js index 31f5fda..8a001a1 100644 --- a/system1-factory/api/routes/workIssueRoutes.js +++ b/system1-factory/api/routes/workIssueRoutes.js @@ -5,7 +5,9 @@ const express = require('express'); const router = express.Router(); const workIssueController = require('../controllers/workIssueController'); -const { requireMinLevel } = require('../middlewares/auth'); +const { createRequirePage } = require('../../../shared/middleware/pagePermission'); +const { getDb } = require('../dbPool'); +const requirePage = createRequirePage(getDb); // ==================== 카테고리 관리 ==================== @@ -16,13 +18,13 @@ router.get('/categories', workIssueController.getAllCategories); router.get('/categories/type/:type', workIssueController.getCategoriesByType); // 카테고리 생성 (admin 이상) -router.post('/categories', requireMinLevel('admin'), workIssueController.createCategory); +router.post('/categories', requirePage('factory_work_issues'), workIssueController.createCategory); // 카테고리 수정 (admin 이상) -router.put('/categories/:id', requireMinLevel('admin'), workIssueController.updateCategory); +router.put('/categories/:id', requirePage('factory_work_issues'), workIssueController.updateCategory); // 카테고리 삭제 (admin 이상) -router.delete('/categories/:id', requireMinLevel('admin'), workIssueController.deleteCategory); +router.delete('/categories/:id', requirePage('factory_work_issues'), workIssueController.deleteCategory); // ==================== 사전 정의 항목 관리 ==================== @@ -33,24 +35,24 @@ router.get('/items', workIssueController.getAllItems); router.get('/items/category/:categoryId', workIssueController.getItemsByCategory); // 항목 생성 (admin 이상) -router.post('/items', requireMinLevel('admin'), workIssueController.createItem); +router.post('/items', requirePage('factory_work_issues'), workIssueController.createItem); // 항목 수정 (admin 이상) -router.put('/items/:id', requireMinLevel('admin'), workIssueController.updateItem); +router.put('/items/:id', requirePage('factory_work_issues'), workIssueController.updateItem); // 항목 삭제 (admin 이상) -router.delete('/items/:id', requireMinLevel('admin'), workIssueController.deleteItem); +router.delete('/items/:id', requirePage('factory_work_issues'), workIssueController.deleteItem); // ==================== 통계 ==================== // 통계 요약 (support_team 이상) -router.get('/stats/summary', requireMinLevel('support_team'), workIssueController.getStatsSummary); +router.get('/stats/summary', requirePage('factory_work_issues'), workIssueController.getStatsSummary); // 카테고리별 통계 (support_team 이상) -router.get('/stats/by-category', requireMinLevel('support_team'), workIssueController.getStatsByCategory); +router.get('/stats/by-category', requirePage('factory_work_issues'), workIssueController.getStatsByCategory); // 작업장별 통계 (support_team 이상) -router.get('/stats/by-workplace', requireMinLevel('support_team'), workIssueController.getStatsByWorkplace); +router.get('/stats/by-workplace', requirePage('factory_work_issues'), workIssueController.getStatsByWorkplace); // ==================== 문제 신고 관리 ==================== @@ -72,10 +74,10 @@ router.delete('/:id', workIssueController.deleteReport); // ==================== 상태 관리 ==================== // 신고 접수 (support_team 이상) -router.put('/:id/receive', requireMinLevel('support_team'), workIssueController.receiveReport); +router.put('/:id/receive', requirePage('factory_work_issues'), workIssueController.receiveReport); // 담당자 배정 (support_team 이상) -router.put('/:id/assign', requireMinLevel('support_team'), workIssueController.assignReport); +router.put('/:id/assign', requirePage('factory_work_issues'), workIssueController.assignReport); // 처리 시작 router.put('/:id/start', workIssueController.startProcessing); @@ -84,7 +86,7 @@ router.put('/:id/start', workIssueController.startProcessing); router.put('/:id/complete', workIssueController.completeReport); // 신고 종료 (admin 이상) -router.put('/:id/close', requireMinLevel('admin'), workIssueController.closeReport); +router.put('/:id/close', requirePage('factory_work_issues'), workIssueController.closeReport); // 상태 변경 이력 조회 router.get('/:id/logs', workIssueController.getStatusLogs); diff --git a/system1-factory/api/routes/workReportAnalysisRoutes.js b/system1-factory/api/routes/workReportAnalysisRoutes.js index 6e53a64..4b6dd55 100644 --- a/system1-factory/api/routes/workReportAnalysisRoutes.js +++ b/system1-factory/api/routes/workReportAnalysisRoutes.js @@ -2,11 +2,14 @@ const express = require('express'); const router = express.Router(); const workReportAnalysisController = require('../controllers/workReportAnalysisController'); -const { requireAuth, requireRole } = require('../middlewares/auth'); +const { requireAuth } = require('../middlewares/auth'); +const { createRequirePage } = require('../../../shared/middleware/pagePermission'); +const { getDb } = require('../dbPool'); +const requirePage = createRequirePage(getDb); // 🔒 모든 분석 라우트에 인증 + Admin 권한 필요 router.use(requireAuth); -router.use(requireRole('admin', 'system')); +router.use(requirePage('factory_work_analysis')); // 📋 분석용 필터 데이터 조회 (프로젝트, 작업자, 작업유형 목록) router.get('/filters', workReportAnalysisController.getAnalysisFilters); diff --git a/system1-factory/web/css/daily-status.css b/system1-factory/web/css/daily-status.css new file mode 100644 index 0000000..db274e6 --- /dev/null +++ b/system1-factory/web/css/daily-status.css @@ -0,0 +1,292 @@ +/* daily-status.css — 일별 입력 현황 대시보드 */ + +/* Header */ +.ds-header { + background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); + color: white; + padding: 16px 16px 12px; + border-radius: 0 0 16px 16px; + margin: -16px -16px 0; + position: sticky; + top: 56px; + z-index: 20; +} +.ds-header h1 { font-size: 1.125rem; font-weight: 700; } + +/* Date Navigation */ +.ds-date-nav { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + padding: 12px 0; + background: white; + border-radius: 12px; + margin: 12px 0; + box-shadow: 0 1px 3px rgba(0,0,0,0.06); +} +.ds-date-btn { + width: 36px; height: 36px; + border-radius: 50%; + display: flex; align-items: center; justify-content: center; + color: #6b7280; + background: #f3f4f6; + border: none; cursor: pointer; + transition: all 0.15s; +} +.ds-date-btn:hover { background: #e5e7eb; color: #374151; } +.ds-date-btn:disabled { opacity: 0.3; cursor: not-allowed; } +.ds-date-display { + display: flex; flex-direction: column; align-items: center; + cursor: pointer; user-select: none; +} +.ds-date-display #dateText { font-size: 1rem; font-weight: 700; color: #1f2937; } +.ds-day-label { font-size: 0.75rem; color: #6b7280; } + +/* Summary Cards */ +.ds-summary { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; + margin-bottom: 12px; +} +.ds-card { + background: white; + border-radius: 12px; + padding: 12px 8px; + text-align: center; + box-shadow: 0 1px 3px rgba(0,0,0,0.06); + cursor: pointer; + transition: transform 0.15s, box-shadow 0.15s; + border-top: 3px solid transparent; +} +.ds-card:active { transform: scale(0.97); } +.ds-card-total { border-top-color: #3b82f6; } +.ds-card-done { border-top-color: #16a34a; } +.ds-card-missing { border-top-color: #dc2626; } +.ds-card-num { font-size: 1.5rem; font-weight: 800; color: #1f2937; line-height: 1; } +.ds-card-label { font-size: 0.7rem; color: #6b7280; margin-top: 4px; } +.ds-card-pct { font-size: 0.7rem; font-weight: 600; color: #9ca3af; margin-top: 2px; } +.ds-card-done .ds-card-pct { color: #16a34a; } +.ds-card-missing .ds-card-pct { color: #dc2626; } + +/* Filter Tabs */ +.ds-tabs { + display: flex; + gap: 4px; + padding: 4px; + background: #f3f4f6; + border-radius: 10px; + margin-bottom: 12px; +} +.ds-tab { + flex: 1; + padding: 8px 4px; + font-size: 0.75rem; + font-weight: 600; + color: #6b7280; + background: transparent; + border: none; + border-radius: 8px; + cursor: pointer; + transition: all 0.15s; + text-align: center; +} +.ds-tab.active { background: white; color: #2563eb; box-shadow: 0 1px 3px rgba(0,0,0,0.08); } +.ds-tab-badge { + display: inline-flex; + align-items: center; justify-content: center; + min-width: 18px; height: 18px; + font-size: 0.65rem; font-weight: 700; + background: #e5e7eb; color: #6b7280; + border-radius: 9px; + padding: 0 5px; + margin-left: 2px; +} +.ds-tab.active .ds-tab-badge { background: #dbeafe; color: #2563eb; } + +/* Worker List */ +.ds-list { padding-bottom: 140px; } +.ds-worker-row { + display: flex; + align-items: center; + gap: 10px; + padding: 12px; + background: white; + border-radius: 10px; + margin-bottom: 6px; + box-shadow: 0 1px 2px rgba(0,0,0,0.04); + cursor: pointer; + transition: background 0.15s; +} +.ds-worker-row:active { background: #f9fafb; } + +.ds-status-dot { + width: 10px; height: 10px; + border-radius: 50%; + flex-shrink: 0; +} +.ds-status-dot.complete { background: #16a34a; } +.ds-status-dot.tbm_only, .ds-status-dot.report_only { background: #f59e0b; } +.ds-status-dot.both_missing { background: #dc2626; } + +.ds-worker-info { flex: 1; min-width: 0; } +.ds-worker-name { font-size: 0.875rem; font-weight: 600; color: #1f2937; } +.ds-worker-dept { font-size: 0.7rem; color: #9ca3af; } +.ds-worker-status { text-align: right; flex-shrink: 0; } +.ds-worker-status span { + display: inline-block; + font-size: 0.65rem; + padding: 2px 6px; + border-radius: 4px; + margin-left: 2px; +} +.ds-badge-ok { background: #dcfce7; color: #16a34a; } +.ds-badge-no { background: #fef2f2; color: #dc2626; } +.ds-badge-proxy { background: #ede9fe; color: #7c3aed; font-size: 0.6rem; } + +/* Skeleton */ +.ds-skeleton { + height: 56px; + background: linear-gradient(90deg, #f3f4f6 25%, #e5e7eb 50%, #f3f4f6 75%); + background-size: 200% 100%; + animation: ds-shimmer 1.5s infinite; + border-radius: 10px; + margin-bottom: 6px; +} +@keyframes ds-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } } + +/* Empty / No Permission */ +.ds-empty, .ds-no-perm { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 48px 16px; + color: #9ca3af; + font-size: 0.875rem; +} +.ds-link { color: #2563eb; font-size: 0.8rem; text-decoration: underline; } + +/* Bottom Action */ +.ds-bottom-action { + position: fixed; + bottom: calc(68px + env(safe-area-inset-bottom, 0px)); + left: 0; right: 0; + padding: 10px 16px; + background: white; + border-top: 1px solid #e5e7eb; + z-index: 30; + max-width: 480px; + margin: 0 auto; +} +.ds-proxy-btn { + width: 100%; + padding: 12px; + background: #2563eb; + color: white; + font-size: 0.875rem; + font-weight: 700; + border: none; + border-radius: 10px; + cursor: pointer; + transition: background 0.15s; +} +.ds-proxy-btn:hover { background: #1d4ed8; } +.ds-proxy-btn:disabled { background: #d1d5db; cursor: not-allowed; } + +/* Bottom Sheet */ +.ds-sheet-overlay { + position: fixed; inset: 0; + background: rgba(0,0,0,0.4); + z-index: 40; +} +.ds-sheet { + position: fixed; + bottom: 0; left: 0; right: 0; + background: white; + border-radius: 16px 16px 0 0; + max-height: 70vh; + overflow-y: auto; + z-index: 41; + padding: 0 16px 24px; + transform: translateY(100%); + transition: transform 0.3s ease; + max-width: 480px; + margin: 0 auto; +} +.ds-sheet.open { transform: translateY(0); } +.ds-sheet-handle { + width: 40px; height: 4px; + background: #d1d5db; + border-radius: 2px; + margin: 10px auto 12px; + cursor: pointer; +} +.ds-sheet-header { + display: flex; align-items: baseline; gap: 8px; + padding-bottom: 12px; + border-bottom: 1px solid #f3f4f6; + margin-bottom: 12px; +} +.ds-sheet-header span:first-child { font-size: 1rem; font-weight: 700; color: #1f2937; } +.ds-sheet-sub { font-size: 0.75rem; color: #9ca3af; } +.ds-sheet-body { min-height: 80px; } +.ds-sheet-loading { text-align: center; padding: 24px; color: #9ca3af; font-size: 0.875rem; } + +.ds-sheet-section { margin-bottom: 12px; } +.ds-sheet-section-title { + font-size: 0.75rem; font-weight: 700; color: #6b7280; + margin-bottom: 6px; + display: flex; align-items: center; gap: 6px; +} +.ds-sheet-card { + background: #f9fafb; + border-radius: 8px; + padding: 10px; + font-size: 0.8rem; + color: #374151; +} +.ds-sheet-card.empty { color: #9ca3af; text-align: center; } + +.ds-sheet-actions { + padding-top: 12px; + border-top: 1px solid #f3f4f6; +} +.ds-sheet-btn { + width: 100%; + padding: 10px; + background: #2563eb; + color: white; + font-size: 0.8rem; + font-weight: 600; + border: none; + border-radius: 8px; + cursor: pointer; +} + +/* Bottom Nav (reuse tbm-mobile pattern) */ +.m-bottom-nav { + position: fixed; bottom: 0; left: 0; right: 0; + display: flex; justify-content: space-around; + background: white; + border-top: 1px solid #e5e7eb; + padding: 8px 0 calc(8px + env(safe-area-inset-bottom, 0px)); + z-index: 35; + max-width: 480px; + margin: 0 auto; +} +.m-nav-item { + display: flex; flex-direction: column; align-items: center; + gap: 2px; color: #9ca3af; + text-decoration: none; + font-size: 0.65rem; + padding: 4px 8px; +} +.m-nav-item svg { width: 22px; height: 22px; } +.m-nav-item.active { color: #2563eb; } +.m-nav-label { font-weight: 500; } + +@media (max-width: 480px) { + body { max-width: 480px; margin: 0 auto; } +} diff --git a/system1-factory/web/css/proxy-input.css b/system1-factory/web/css/proxy-input.css new file mode 100644 index 0000000..fb8fdcb --- /dev/null +++ b/system1-factory/web/css/proxy-input.css @@ -0,0 +1,232 @@ +/* proxy-input.css — 대리입력 페이지 */ + +/* Header */ +.pi-header { + background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); + color: white; + padding: 14px 16px; + border-radius: 0 0 16px 16px; + margin: -16px -16px 0; + position: sticky; + top: 56px; + z-index: 20; +} +.pi-header-row { display: flex; align-items: center; gap: 12px; } +.pi-back-btn { + width: 32px; height: 32px; + display: flex; align-items: center; justify-content: center; + background: rgba(255,255,255,0.15); + border-radius: 8px; + border: none; color: white; cursor: pointer; +} +.pi-header h1 { font-size: 1.05rem; font-weight: 700; flex: 1; } +.pi-header-date { font-size: 0.75rem; opacity: 0.8; } + +/* Date Bar */ +.pi-date-bar { + display: flex; align-items: center; justify-content: space-between; + padding: 10px 12px; + background: white; + border-radius: 10px; + margin: 12px 0 8px; + box-shadow: 0 1px 3px rgba(0,0,0,0.06); +} +.pi-date-info { display: flex; align-items: center; gap: 8px; } +.pi-date-input { + border: 1px solid #e5e7eb; + border-radius: 6px; + padding: 4px 8px; + font-size: 0.8rem; + color: #374151; +} +.pi-refresh-btn { + width: 28px; height: 28px; + display: flex; align-items: center; justify-content: center; + background: #f3f4f6; border: none; border-radius: 6px; + color: #6b7280; cursor: pointer; font-size: 0.75rem; +} +.pi-status-badge { + display: flex; align-items: center; gap: 4px; + font-size: 0.8rem; color: #374151; +} + +/* Bulk Actions Bar */ +.pi-bulk { + background: #eff6ff; + border: 1px solid #bfdbfe; + border-radius: 10px; + padding: 8px 12px; + margin-bottom: 8px; + display: flex; align-items: center; justify-content: space-between; + flex-wrap: wrap; gap: 6px; +} +.pi-bulk-label { font-size: 0.75rem; font-weight: 600; color: #1d4ed8; } +.pi-bulk-actions { display: flex; gap: 4px; } +.pi-bulk-btn { + padding: 4px 10px; + font-size: 0.7rem; font-weight: 600; + background: white; color: #2563eb; + border: 1px solid #bfdbfe; + border-radius: 6px; + cursor: pointer; +} +.pi-bulk-btn:active { background: #dbeafe; } + +/* Worker Cards */ +.pi-cards { padding-bottom: 100px; } + +.pi-card { + background: white; + border-radius: 10px; + margin-bottom: 8px; + box-shadow: 0 1px 2px rgba(0,0,0,0.04); + overflow: hidden; + border: 2px solid transparent; + transition: border-color 0.15s; +} +.pi-card.selected { border-color: #2563eb; } + +.pi-card-header { + display: flex; align-items: center; gap: 10px; + padding: 12px; + cursor: pointer; +} +.pi-card-check { + width: 22px; height: 22px; + border: 2px solid #d1d5db; + border-radius: 6px; + display: flex; align-items: center; justify-content: center; + flex-shrink: 0; + transition: all 0.15s; +} +.pi-card.selected .pi-card-check { + background: #2563eb; border-color: #2563eb; color: white; +} +.pi-card-name { font-size: 0.875rem; font-weight: 600; color: #1f2937; } +.pi-card-meta { font-size: 0.7rem; color: #9ca3af; } +.pi-card-status { + margin-left: auto; + font-size: 0.65rem; + padding: 2px 6px; + border-radius: 4px; +} +.pi-card-status.both_missing { background: #fef2f2; color: #dc2626; } +.pi-card-status.tbm_only { background: #fefce8; color: #ca8a04; } +.pi-card-status.report_only { background: #fefce8; color: #ca8a04; } + +/* Expanded Form */ +.pi-card-form { + display: none; + padding: 0 12px 12px; + border-top: 1px solid #f3f4f6; +} +.pi-card.selected .pi-card-form { display: block; } + +.pi-field { margin-top: 8px; } +.pi-field label { + display: block; + font-size: 0.7rem; font-weight: 600; + color: #6b7280; + margin-bottom: 3px; +} +.pi-field select, .pi-field input, .pi-field textarea { + width: 100%; + border: 1px solid #e5e7eb; + border-radius: 6px; + padding: 6px 8px; + font-size: 0.8rem; + color: #374151; + background: white; +} +.pi-field textarea { resize: none; height: 48px; } +.pi-field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } +.pi-field-row3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; } + +/* Bottom Save */ +.pi-bottom { + position: fixed; + bottom: 0; left: 0; right: 0; + padding: 10px 16px calc(10px + env(safe-area-inset-bottom, 0px)); + background: white; + border-top: 1px solid #e5e7eb; + z-index: 30; + max-width: 480px; + margin: 0 auto; +} +.pi-save-btn { + width: 100%; + padding: 12px; + background: #2563eb; + color: white; + font-size: 0.875rem; + font-weight: 700; + border: none; + border-radius: 10px; + cursor: pointer; + transition: background 0.15s; +} +.pi-save-btn:hover { background: #1d4ed8; } +.pi-save-btn:disabled { background: #d1d5db; cursor: not-allowed; } +.pi-save-btn .fa-spinner { display: none; } +.pi-save-btn.loading .fa-spinner { display: inline; } +.pi-save-btn.loading .fa-save { display: none; } + +/* Empty */ +.pi-empty { + display: flex; flex-direction: column; align-items: center; + gap: 8px; padding: 48px 16px; color: #9ca3af; font-size: 0.875rem; +} + +/* Modal */ +.pi-modal-overlay { + position: fixed; inset: 0; + background: rgba(0,0,0,0.4); + z-index: 50; + display: flex; align-items: center; justify-content: center; + padding: 16px; +} +.pi-modal { + background: white; + border-radius: 12px; + width: 100%; max-width: 360px; + overflow: hidden; +} +.pi-modal-header { + display: flex; align-items: center; justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid #f3f4f6; + font-weight: 700; font-size: 0.9rem; +} +.pi-modal-header button { background: none; border: none; color: #9ca3af; cursor: pointer; } +.pi-modal-body { padding: 16px; } +.pi-modal-body select, .pi-modal-body input { + width: 100%; border: 1px solid #e5e7eb; border-radius: 8px; + padding: 8px 10px; font-size: 0.85rem; +} +.pi-modal-footer { + display: flex; gap: 8px; padding: 12px 16px; + border-top: 1px solid #f3f4f6; +} +.pi-modal-cancel { + flex: 1; padding: 8px; border: 1px solid #e5e7eb; + border-radius: 8px; background: white; font-size: 0.8rem; cursor: pointer; +} +.pi-modal-confirm { + flex: 1; padding: 8px; background: #2563eb; color: white; + border: none; border-radius: 8px; font-size: 0.8rem; font-weight: 600; cursor: pointer; +} + +/* Skeleton (reuse) */ +.ds-skeleton { + height: 56px; + background: linear-gradient(90deg, #f3f4f6 25%, #e5e7eb 50%, #f3f4f6 75%); + background-size: 200% 100%; + animation: ds-shimmer 1.5s infinite; + border-radius: 10px; + margin-bottom: 6px; +} +@keyframes ds-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } } +.ds-empty { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 48px 16px; color: #9ca3af; font-size: 0.875rem; } +.ds-link { color: #2563eb; font-size: 0.8rem; text-decoration: underline; } + +@media (max-width: 480px) { body { max-width: 480px; margin: 0 auto; } } diff --git a/system1-factory/web/js/daily-status.js b/system1-factory/web/js/daily-status.js new file mode 100644 index 0000000..e7c5c23 --- /dev/null +++ b/system1-factory/web/js/daily-status.js @@ -0,0 +1,300 @@ +/** + * daily-status.js — 일별 TBM/작업보고서 입력 현황 대시보드 + * Sprint 002 Section B + */ + +// ===== Mock 설정 ===== +const MOCK_ENABLED = false; + +const MOCK_DATA = { + success: true, + data: { + date: '2026-03-30', + summary: { + total_active_workers: 45, tbm_completed: 38, tbm_missing: 7, + report_completed: 35, report_missing: 10, both_completed: 33, both_missing: 5 + }, + workers: [ + { user_id: 15, worker_name: '김철수', job_type: '용접', department_name: '생산1팀', has_tbm: false, has_report: false, tbm_session_id: null, total_report_hours: 0, status: 'both_missing', proxy_history: null }, + { user_id: 22, worker_name: '이영희', job_type: '배관', department_name: '생산2팀', has_tbm: true, has_report: false, tbm_session_id: 140, total_report_hours: 0, status: 'tbm_only', proxy_history: null }, + { user_id: 30, worker_name: '박민수', job_type: '전기', department_name: '생산1팀', has_tbm: true, has_report: true, tbm_session_id: 141, total_report_hours: 8, status: 'complete', proxy_history: { proxy_by: '관리자', proxy_at: '2026-03-30T14:30:00' } }, + { user_id: 35, worker_name: '정대호', job_type: '도장', department_name: '생산2팀', has_tbm: false, has_report: true, tbm_session_id: null, total_report_hours: 8, status: 'report_only', proxy_history: null }, + { user_id: 40, worker_name: '최윤서', job_type: '용접', department_name: '생산1팀', has_tbm: true, has_report: true, tbm_session_id: 142, total_report_hours: 9, status: 'complete', proxy_history: null }, + { user_id: 41, worker_name: '한지민', job_type: '사상', department_name: '생산2팀', has_tbm: false, has_report: false, tbm_session_id: null, total_report_hours: 0, status: 'both_missing', proxy_history: null }, + { user_id: 42, worker_name: '송민호', job_type: '절단', department_name: '생산1팀', has_tbm: true, has_report: true, tbm_session_id: 143, total_report_hours: 8, status: 'complete', proxy_history: null }, + ] + } +}; + +const MOCK_DETAIL = { + success: true, + data: { + worker: { user_id: 15, worker_name: '김철수', job_type: '용접', department_name: '생산1팀' }, + tbm_sessions: [], + work_reports: [], + proxy_history: [] + } +}; + +// ===== State ===== +let currentDate = new Date(); +let workers = []; +let currentFilter = 'all'; +let selectedWorkerId = null; + +const DAYS_KR = ['일요일', '월요일', '화요일', '수요일', '목요일', '금요일', '토요일']; +const ALLOWED_ROLES = ['support_team', 'admin', 'system']; + +// ===== Init ===== +document.addEventListener('DOMContentLoaded', async () => { + // URL 파라미터에서 날짜 가져오기 + const urlDate = new URLSearchParams(location.search).get('date'); + if (urlDate) currentDate = new Date(urlDate + 'T00:00:00'); + + // 권한 체크 (initAuth 완료 후) + setTimeout(() => { + const user = window.currentUser; + if (user && !ALLOWED_ROLES.includes(user.role)) { + document.getElementById('workerList').classList.add('hidden'); + document.getElementById('bottomAction').classList.add('hidden'); + document.getElementById('noPermission').classList.remove('hidden'); + return; + } + loadStatus(); + }, 500); +}); + +// ===== Date Navigation ===== +function formatDateStr(d) { + return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); +} + +function updateDateDisplay() { + const str = formatDateStr(currentDate); + document.getElementById('dateText').textContent = str; + document.getElementById('dayText').textContent = DAYS_KR[currentDate.getDay()]; + + // 미래 날짜 비활성 + const today = new Date(); + today.setHours(0, 0, 0, 0); + const nextBtn = document.getElementById('nextDate'); + nextBtn.disabled = currentDate >= today; +} + +function changeDate(delta) { + currentDate.setDate(currentDate.getDate() + delta); + updateDateDisplay(); + loadStatus(); +} + +function openDatePicker() { + const picker = document.getElementById('datePicker'); + picker.value = formatDateStr(currentDate); + picker.max = formatDateStr(new Date()); + picker.showPicker ? picker.showPicker() : picker.click(); +} + +function onDatePicked(val) { + if (!val) return; + currentDate = new Date(val + 'T00:00:00'); + updateDateDisplay(); + loadStatus(); +} + +// ===== Data Loading ===== +async function loadStatus() { + const listEl = document.getElementById('workerList'); + listEl.innerHTML = '
'; + document.getElementById('emptyState').classList.add('hidden'); + updateDateDisplay(); + + try { + let res; + if (MOCK_ENABLED) { + res = MOCK_DATA; + } else { + res = await window.apiCall('/proxy-input/daily-status?date=' + formatDateStr(currentDate)); + } + + if (!res || !res.success) { + listEl.innerHTML = '

데이터를 불러올 수 없습니다

'; + return; + } + + workers = res.data.workers || []; + updateSummary(res.data.summary || {}); + updateFilterCounts(); + renderWorkerList(); + } catch (e) { + listEl.innerHTML = '

네트워크 오류. 다시 시도해주세요.

'; + } +} + +function updateSummary(s) { + document.getElementById('totalCount').textContent = s.total_active_workers || 0; + document.getElementById('doneCount').textContent = s.both_completed || 0; + document.getElementById('missingCount').textContent = s.both_missing || 0; + + const total = s.total_active_workers || 1; + document.getElementById('donePct').textContent = Math.round((s.both_completed || 0) / total * 100) + '%'; + document.getElementById('missingPct').textContent = Math.round((s.both_missing || 0) / total * 100) + '%'; + + // 하단 버튼 카운트 + const missingWorkers = workers.filter(w => w.status !== 'complete').length; + document.getElementById('proxyCount').textContent = missingWorkers; + document.getElementById('proxyBtn').disabled = missingWorkers === 0; +} + +function updateFilterCounts() { + document.getElementById('filterAll').textContent = workers.length; + document.getElementById('filterComplete').textContent = workers.filter(w => w.status === 'complete').length; + document.getElementById('filterMissing').textContent = workers.filter(w => w.status === 'both_missing').length; + document.getElementById('filterPartial').textContent = workers.filter(w => w.status === 'tbm_only' || w.status === 'report_only').length; +} + +// ===== Filter ===== +function setFilter(f) { + currentFilter = f; + document.querySelectorAll('.ds-tab').forEach(tab => { + tab.classList.toggle('active', tab.dataset.filter === f); + }); + renderWorkerList(); +} + +// ===== Render ===== +function renderWorkerList() { + const listEl = document.getElementById('workerList'); + const emptyEl = document.getElementById('emptyState'); + + let filtered = workers; + if (currentFilter === 'complete') filtered = workers.filter(w => w.status === 'complete'); + else if (currentFilter === 'both_missing') filtered = workers.filter(w => w.status === 'both_missing'); + else if (currentFilter === 'partial') filtered = workers.filter(w => w.status === 'tbm_only' || w.status === 'report_only'); + + if (filtered.length === 0) { + listEl.innerHTML = ''; + emptyEl.classList.remove('hidden'); + return; + } + emptyEl.classList.add('hidden'); + + listEl.innerHTML = filtered.map(w => { + const tbmBadge = w.has_tbm + ? 'TBM ✓' + : 'TBM ✗'; + const reportBadge = w.has_report + ? `보고서 ✓${w.total_report_hours ? ' ' + w.total_report_hours + 'h' : ''}` + : '보고서 ✗'; + const isProxy = w.tbm_sessions?.some(t => t.is_proxy_input) || false; + const proxyBadge = isProxy + ? '대리입력' + : ''; + + return ` +
+
+
+
${escHtml(w.worker_name)}
+
${escHtml(w.job_type)} · ${escHtml(w.department_name)}
+
+
${tbmBadge}${reportBadge}${proxyBadge}
+
`; + }).join(''); +} + +function escHtml(s) { return (s || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } + +// ===== Bottom Sheet ===== +function openSheet(userId) { + selectedWorkerId = userId; + const w = workers.find(x => x.user_id === userId); + if (!w) return; + + document.getElementById('sheetWorkerName').textContent = w.worker_name; + document.getElementById('sheetWorkerInfo').textContent = `${w.job_type} · ${w.department_name}`; + document.getElementById('sheetBody').innerHTML = '
로딩 중...
'; + + document.getElementById('sheetOverlay').classList.remove('hidden'); + document.getElementById('detailSheet').classList.remove('hidden'); + setTimeout(() => document.getElementById('detailSheet').classList.add('open'), 10); + + // 상세 데이터 로드 + loadDetail(userId, w); +} + +async function loadDetail(userId, workerBasic) { + const bodyEl = document.getElementById('sheetBody'); + try { + let res; + if (MOCK_ENABLED) { + res = JSON.parse(JSON.stringify(MOCK_DETAIL)); + res.data.worker = workerBasic; + // mock: complete 상태면 TBM/보고서 데이터 채우기 + if (workerBasic.has_tbm) { + res.data.tbm_sessions = [{ session_id: workerBasic.tbm_session_id, session_date: formatDateStr(currentDate), status: 'completed', leader_name: '반장' }]; + } + if (workerBasic.has_report) { + res.data.work_reports = [{ report_date: formatDateStr(currentDate), project_name: '프로젝트A', work_type_name: workerBasic.job_type, work_hours: workerBasic.total_report_hours }]; + } + if (workerBasic.proxy_history) { + res.data.proxy_history = [workerBasic.proxy_history]; + } + } else { + res = await window.apiCall('/proxy-input/daily-status/detail?date=' + formatDateStr(currentDate) + '&user_id=' + userId); + } + + if (!res || !res.success) { bodyEl.innerHTML = '
상세 정보를 불러올 수 없습니다
'; return; } + + const d = res.data; + let html = ''; + + // TBM 섹션 + html += '
TBM
'; + if (d.tbm_sessions && d.tbm_sessions.length > 0) { + html += d.tbm_sessions.map(s => { + const proxyTag = s.is_proxy_input ? ` · 대리입력(${escHtml(s.proxy_input_by_name || '-')})` : ''; + return `
세션 #${s.session_id} · ${s.status === 'completed' ? '완료' : '진행중'} · 리더: ${escHtml(s.leader_name || '-')}${proxyTag}
`; + }).join(''); + } else { + html += '
세션 없음
'; + } + html += '
'; + + // 작업보고서 섹션 + html += '
작업보고서
'; + if (d.work_reports && d.work_reports.length > 0) { + html += d.work_reports.map(r => `
${escHtml(r.project_name || '-')} · ${escHtml(r.work_type_name || '-')} · ${r.work_hours || 0}시간
`).join(''); + } else { + html += '
보고서 없음
'; + } + html += '
'; + + bodyEl.innerHTML = html; + + // 완료 상태면 대리입력 버튼 숨김 + const btn = document.getElementById('sheetProxyBtn'); + btn.style.display = workerBasic.status === 'complete' ? 'none' : 'block'; + + } catch (e) { + bodyEl.innerHTML = '
네트워크 오류
'; + } +} + +function closeSheet() { + document.getElementById('detailSheet').classList.remove('open'); + setTimeout(() => { + document.getElementById('sheetOverlay').classList.add('hidden'); + document.getElementById('detailSheet').classList.add('hidden'); + }, 300); +} + +// ===== Navigation ===== +function goProxyInput() { + location.href = '/pages/work/proxy-input.html?date=' + formatDateStr(currentDate); +} + +function goProxyInputSingle() { + if (selectedWorkerId) { + location.href = '/pages/work/proxy-input.html?date=' + formatDateStr(currentDate) + '&user_id=' + selectedWorkerId; + } +} diff --git a/system1-factory/web/js/proxy-input.js b/system1-factory/web/js/proxy-input.js new file mode 100644 index 0000000..5b81dfc --- /dev/null +++ b/system1-factory/web/js/proxy-input.js @@ -0,0 +1,404 @@ +/** + * proxy-input.js — 대리입력 (TBM + 작업보고서 동시 생성) + * Sprint 002 Section B + */ + +// ===== Mock ===== +const MOCK_ENABLED = false; + +const MOCK_STATUS = { + success: true, + data: { + date: '2026-03-30', + summary: { total_active_workers: 45, tbm_completed: 38, tbm_missing: 7, report_completed: 35, report_missing: 10, both_completed: 33, both_missing: 5 }, + workers: [ + { user_id: 15, worker_name: '김철수', job_type: '용접', department_name: '생산1팀', has_tbm: false, has_report: false, tbm_session_id: null, total_report_hours: 0, status: 'both_missing', proxy_history: null }, + { user_id: 22, worker_name: '이영희', job_type: '배관', department_name: '생산2팀', has_tbm: true, has_report: false, tbm_session_id: 140, total_report_hours: 0, status: 'tbm_only', proxy_history: null }, + { user_id: 35, worker_name: '정대호', job_type: '도장', department_name: '생산2팀', has_tbm: false, has_report: true, tbm_session_id: null, total_report_hours: 8, status: 'report_only', proxy_history: null }, + { user_id: 41, worker_name: '한지민', job_type: '사상', department_name: '생산2팀', has_tbm: false, has_report: false, tbm_session_id: null, total_report_hours: 0, status: 'both_missing', proxy_history: null }, + ] + } +}; + +const MOCK_PROJECTS = [ + { project_id: 1, project_name: 'JOB-2026-001 용기제작', job_no: 'JOB-2026-001' }, + { project_id: 2, project_name: 'JOB-2026-002 Skid 제작', job_no: 'JOB-2026-002' }, + { project_id: 3, project_name: 'JOB-2026-003 PKG 조립', job_no: 'JOB-2026-003' }, +]; +const MOCK_WORK_TYPES = [ + { id: 1, name: '절단' }, { id: 2, name: '용접' }, { id: 3, name: '배관' }, + { id: 4, name: '도장' }, { id: 5, name: '사상' }, { id: 6, name: '전기' }, +]; +const MOCK_TASKS = [ + { task_id: 1, work_type_id: 2, task_name: '취부&용접' }, + { task_id: 2, work_type_id: 2, task_name: '가우징' }, + { task_id: 3, work_type_id: 1, task_name: '절단작업' }, + { task_id: 4, work_type_id: 3, task_name: '배관설치' }, +]; +const MOCK_WORK_STATUSES = [ + { id: 1, name: '정상' }, { id: 2, name: '잔업' }, { id: 3, name: '특근' }, +]; + +// ===== State ===== +let currentDate = ''; +let missingWorkers = []; +let selectedIds = new Set(); +let projects = [], workTypes = [], tasks = [], workStatuses = []; +let workerFormData = {}; // { userId: { project_id, work_type_id, ... } } +let saving = false; + +const ALLOWED_ROLES = ['support_team', 'admin', 'system']; + +// ===== Init ===== +document.addEventListener('DOMContentLoaded', async () => { + const urlDate = new URLSearchParams(location.search).get('date'); + const urlUserId = new URLSearchParams(location.search).get('user_id'); + currentDate = urlDate || formatDateStr(new Date()); + document.getElementById('dateInput').value = currentDate; + document.getElementById('headerDate').textContent = currentDate; + + setTimeout(async () => { + const user = window.currentUser; + if (user && !ALLOWED_ROLES.includes(user.role)) { + document.getElementById('workerCards').classList.add('hidden'); + document.getElementById('bottomSave').classList.add('hidden'); + document.getElementById('noPermission').classList.remove('hidden'); + return; + } + await loadMasterData(); + await loadWorkers(); + + // URL에서 user_id가 있으면 자동 선택 + if (urlUserId) toggleWorker(parseInt(urlUserId)); + }, 500); +}); + +function formatDateStr(d) { + return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); +} + +function onDateChange(val) { + if (!val) return; + currentDate = val; + document.getElementById('headerDate').textContent = val; + loadWorkers(); +} + +// ===== Master Data ===== +async function loadMasterData() { + if (MOCK_ENABLED) { + projects = MOCK_PROJECTS; + workTypes = MOCK_WORK_TYPES; + tasks = MOCK_TASKS; + workStatuses = MOCK_WORK_STATUSES; + return; + } + try { + const [pRes, wtRes, tRes, wsRes] = await Promise.all([ + window.apiCall('/projects'), + window.apiCall('/daily-work-reports/work-types'), + window.apiCall('/tasks'), + window.apiCall('/daily-work-reports/work-status-types'), + ]); + projects = (pRes && pRes.data) || []; + workTypes = (wtRes && wtRes.data) || []; + tasks = (tRes && tRes.data) || []; + workStatuses = (wsRes && wsRes.data) || []; + } catch (e) { + console.error('마스터 데이터 로드 실패:', e); + } +} + +// ===== Load Workers ===== +async function loadWorkers() { + const cardsEl = document.getElementById('workerCards'); + cardsEl.innerHTML = '
'; + document.getElementById('emptyState').classList.add('hidden'); + selectedIds.clear(); + workerFormData = {}; + updateSaveBtn(); + updateBulkBar(); + + try { + let res; + if (MOCK_ENABLED) { + res = MOCK_STATUS; + } else { + res = await window.apiCall('/proxy-input/daily-status?date=' + currentDate); + } + if (!res || !res.success) { cardsEl.innerHTML = '

데이터를 불러올 수 없습니다

'; return; } + + missingWorkers = (res.data.workers || []).filter(w => w.status !== 'complete'); + document.getElementById('missingNum').textContent = missingWorkers.length; + + if (missingWorkers.length === 0) { + cardsEl.innerHTML = ''; + document.getElementById('emptyState').classList.remove('hidden'); + return; + } + renderCards(); + } catch (e) { + cardsEl.innerHTML = '

네트워크 오류

'; + } +} + +// ===== Render Cards ===== +function renderCards() { + const cardsEl = document.getElementById('workerCards'); + cardsEl.innerHTML = missingWorkers.map(w => { + const statusLabel = { both_missing: 'TBM+보고서 미입력', tbm_only: '보고서만 미입력', report_only: 'TBM만 미입력' }[w.status] || ''; + const fd = workerFormData[w.user_id] || getDefaultFormData(w); + workerFormData[w.user_id] = fd; + const sel = selectedIds.has(w.user_id); + + return ` +
+
+
${sel ? '' : ''}
+
+
${escHtml(w.worker_name)}
+
${escHtml(w.job_type)} · ${escHtml(w.department_name)}
+
+
${statusLabel}
+
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
`; + }).join(''); +} + +function getDefaultFormData(worker) { + return { + project_id: '', work_type_id: '', task_id: '', + work_hours: 8, start_time: '08:00', end_time: '17:00', + work_status_id: 1, note: '' + }; +} + +function escHtml(s) { return (s || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } + +// ===== Worker Toggle ===== +function toggleWorker(userId) { + if (selectedIds.has(userId)) { + selectedIds.delete(userId); + } else { + selectedIds.add(userId); + } + renderCards(); + updateSaveBtn(); + updateBulkBar(); +} + +function updateField(userId, field, value) { + if (!workerFormData[userId]) workerFormData[userId] = getDefaultFormData({}); + workerFormData[userId][field] = value; +} + +function updateTaskOptions(userId) { + const wtId = workerFormData[userId]?.work_type_id; + const sel = document.getElementById('task-' + userId); + if (!sel) return; + const filtered = tasks.filter(t => t.work_type_id == wtId); + sel.innerHTML = '' + filtered.map(t => ``).join(''); + workerFormData[userId].task_id = ''; +} + +// ===== Bulk Actions ===== +function updateBulkBar() { + const bar = document.getElementById('bulkBar'); + if (selectedIds.size > 0) { + bar.classList.remove('hidden'); + document.getElementById('selectedCount').textContent = selectedIds.size; + } else { + bar.classList.add('hidden'); + } +} + +function bulkSet(type) { + const modal = document.getElementById('bulkModal'); + const body = document.getElementById('bulkModalBody'); + const title = document.getElementById('bulkModalTitle'); + const confirm = document.getElementById('bulkConfirmBtn'); + + if (type === 'project') { + title.textContent = '프로젝트 일괄 설정'; + body.innerHTML = ``; + confirm.onclick = () => applyBulk('project_id', document.getElementById('bulkValue').value); + } else if (type === 'workType') { + title.textContent = '공종 일괄 설정'; + body.innerHTML = ``; + confirm.onclick = () => applyBulk('work_type_id', document.getElementById('bulkValue').value); + } else if (type === 'hours') { + title.textContent = '작업시간 일괄 설정'; + body.innerHTML = ''; + confirm.onclick = () => applyBulk('work_hours', document.getElementById('bulkValue').value); + } + modal.classList.remove('hidden'); +} + +function applyBulk(field, value) { + let hasExisting = false; + for (const uid of selectedIds) { + const fd = workerFormData[uid]; + if (fd && fd[field] && fd[field] !== '' && String(fd[field]) !== String(value)) { + hasExisting = true; + break; + } + } + + if (hasExisting) { + if (!confirm('이미 입력된 값이 있습니다. 덮어쓰시겠습니까?')) { + // 빈 필드만 채움 + for (const uid of selectedIds) { + if (!workerFormData[uid][field] || workerFormData[uid][field] === '') { + workerFormData[uid][field] = value; + } + } + closeBulkModal(); + renderCards(); + return; + } + } + + for (const uid of selectedIds) { + workerFormData[uid][field] = value; + if (field === 'work_type_id') workerFormData[uid].task_id = ''; + } + closeBulkModal(); + renderCards(); +} + +function closeBulkModal() { + document.getElementById('bulkModal').classList.add('hidden'); +} + +// ===== Save ===== +function updateSaveBtn() { + const btn = document.getElementById('saveBtn'); + const text = document.getElementById('saveBtnText'); + if (selectedIds.size === 0) { + btn.disabled = true; + text.textContent = '저장할 작업자를 선택하세요'; + } else { + btn.disabled = false; + text.textContent = `${selectedIds.size}명 대리입력 저장`; + } +} + +async function saveProxyInput() { + if (saving || selectedIds.size === 0) return; + + // 유효성 검사 + for (const uid of selectedIds) { + const fd = workerFormData[uid]; + const w = missingWorkers.find(x => x.user_id === uid); + if (!fd.project_id) { showToast(`${w?.worker_name || uid}: 프로젝트를 선택하세요`, 'error'); return; } + if (!fd.work_type_id) { showToast(`${w?.worker_name || uid}: 공종을 선택하세요`, 'error'); return; } + } + + saving = true; + const btn = document.getElementById('saveBtn'); + btn.disabled = true; + btn.classList.add('loading'); + document.getElementById('saveBtnText').textContent = '저장 중...'; + + const entries = [...selectedIds].map(uid => ({ + user_id: uid, + project_id: parseInt(workerFormData[uid].project_id), + work_type_id: parseInt(workerFormData[uid].work_type_id), + task_id: workerFormData[uid].task_id ? parseInt(workerFormData[uid].task_id) : null, + work_hours: parseFloat(workerFormData[uid].work_hours) || 8, + start_time: workerFormData[uid].start_time || '08:00', + end_time: workerFormData[uid].end_time || '17:00', + work_status_id: parseInt(workerFormData[uid].work_status_id) || 1, + note: workerFormData[uid].note || '' + })); + + try { + let res; + if (MOCK_ENABLED) { + await new Promise(r => setTimeout(r, 1000)); // simulate delay + res = { + success: true, + data: { session_id: 999, created_reports: entries.length, workers: entries.map(e => ({ user_id: e.user_id, worker_name: missingWorkers.find(w => w.user_id === e.user_id)?.worker_name || '', report_id: Math.floor(Math.random() * 1000) })) }, + message: `${entries.length}명의 대리입력이 완료되었습니다.` + }; + } else { + res = await window.apiCall('/proxy-input', 'POST', { session_date: currentDate, entries }); + } + + if (res && res.success) { + showToast(res.message || `${entries.length}명 대리입력 완료`, 'success'); + selectedIds.clear(); + await loadWorkers(); + } else { + showToast(res?.message || '저장 실패', 'error'); + } + } catch (e) { + showToast('네트워크 오류. 다시 시도해주세요.', 'error'); + } finally { + saving = false; + btn.classList.remove('loading'); + updateSaveBtn(); + } +} + +// ===== Toast ===== +function showToast(msg, type) { + if (window.showToast) { window.showToast(msg, type); return; } + const c = document.getElementById('toastContainer'); + const t = document.createElement('div'); + t.className = `toast toast-${type || 'info'}`; + t.textContent = msg; + c.appendChild(t); + setTimeout(() => t.remove(), 3000); +} diff --git a/system1-factory/web/pages/work/daily-status.html b/system1-factory/web/pages/work/daily-status.html new file mode 100644 index 0000000..56ebb80 --- /dev/null +++ b/system1-factory/web/pages/work/daily-status.html @@ -0,0 +1,170 @@ + + + + + + 입력 현황 - TK 공장관리 + + + + + + +
+
+
+
+ + +

TK 공장관리

+
+
+ +
-
+ +
+
+
+
+ +
+
+ +
+ + +
+

일별 입력 현황

+
+ + +
+ +
+ 2026-03-30 + 월요일 +
+ + +
+ + +
+
+
-
+
전체 작업자
+
+
+
-
+
완료
+
-
+
+
+
-
+
미입력
+
-
+
+
+ + +
+ + + + +
+ + +
+
+
+
+
+ + + + + + + + +
+ +
+ + + + + + + + + +
+ +
+
+
+ + + + + + diff --git a/system1-factory/web/pages/work/proxy-input.html b/system1-factory/web/pages/work/proxy-input.html new file mode 100644 index 0000000..761a9ff --- /dev/null +++ b/system1-factory/web/pages/work/proxy-input.html @@ -0,0 +1,120 @@ + + + + + + 대리입력 - TK 공장관리 + + + + + + +
+
+
+
+ + +

TK 공장관리

+
+
+ +
-
+ +
+
+
+
+ +
+
+ +
+ + +
+
+ +

대리입력

+
-
+
+
+ + +
+
+ + + +
+
+ + 미입력 0 +
+
+ + + + + +
+
+
+
+ + + + + + + + +
+ +
+ + +
+ + + + +
+
+
+ + + + + + diff --git a/system1-factory/web/static/js/tkfb-core.js b/system1-factory/web/static/js/tkfb-core.js index 3dcc2d1..efee0f8 100644 --- a/system1-factory/web/static/js/tkfb-core.js +++ b/system1-factory/web/static/js/tkfb-core.js @@ -117,6 +117,8 @@ const NAV_MENU = [ { href: '/pages/work/nonconformity.html', icon: 'fa-exclamation-triangle', label: '부적합 현황', key: 'work.nonconformity' }, { href: '/pages/work/schedule.html', icon: 'fa-calendar-alt', label: '공정표', key: 'work.schedule' }, { href: '/pages/work/meetings.html', icon: 'fa-users', label: '생산회의록', key: 'work.meetings' }, + { href: '/pages/work/daily-status.html', icon: 'fa-chart-bar', label: '입력 현황', key: 'work.daily_status' }, + { href: '/pages/work/proxy-input.html', icon: 'fa-user-edit', label: '대리입력', key: 'work.proxy_input' }, ]}, { cat: '공장 관리', items: [ { href: '/pages/admin/repair-management.html', icon: 'fa-tools', label: '시설설비 관리', key: 'factory.repair_management' }, @@ -159,6 +161,7 @@ const PAGE_KEY_ALIASES = { 'attendance.checkin': 'inspection.checkin', 'attendance.work_status': 'inspection.work_status', 'work.meeting_detail': 'work.meetings', + 'work.proxy_input': 'work.daily_status', }; function _getCurrentPageKey() { diff --git a/system2-report/api/routes/workIssueRoutes.js b/system2-report/api/routes/workIssueRoutes.js index 4da7d48..a5182a6 100644 --- a/system2-report/api/routes/workIssueRoutes.js +++ b/system2-report/api/routes/workIssueRoutes.js @@ -5,7 +5,9 @@ const express = require('express'); const router = express.Router(); const workIssueController = require('../controllers/workIssueController'); -const { requireMinLevel } = require('../middlewares/auth'); +const { createRequirePage } = require('../../../shared/middleware/pagePermission'); +const { getDb } = require('../config/database'); +const requirePage = createRequirePage(getDb); // ==================== 카테고리 관리 ==================== @@ -15,14 +17,14 @@ router.get('/categories', workIssueController.getAllCategories); // 타입별 카테고리 조회 (nonconformity/safety) router.get('/categories/type/:type', workIssueController.getCategoriesByType); -// 카테고리 생성 (admin 이상) -router.post('/categories', requireMinLevel('admin'), workIssueController.createCategory); +// 카테고리 생성 +router.post('/categories', requirePage('report_work_issues'), workIssueController.createCategory); -// 카테고리 수정 (admin 이상) -router.put('/categories/:id', requireMinLevel('admin'), workIssueController.updateCategory); +// 카테고리 수정 +router.put('/categories/:id', requirePage('report_work_issues'), workIssueController.updateCategory); -// 카테고리 삭제 (admin 이상) -router.delete('/categories/:id', requireMinLevel('admin'), workIssueController.deleteCategory); +// 카테고리 삭제 +router.delete('/categories/:id', requirePage('report_work_issues'), workIssueController.deleteCategory); // ==================== 사전 정의 항목 관리 ==================== @@ -32,25 +34,25 @@ router.get('/items', workIssueController.getAllItems); // 카테고리별 항목 조회 router.get('/items/category/:categoryId', workIssueController.getItemsByCategory); -// 항목 생성 (admin 이상) -router.post('/items', requireMinLevel('admin'), workIssueController.createItem); +// 항목 생성 +router.post('/items', requirePage('report_work_issues'), workIssueController.createItem); -// 항목 수정 (admin 이상) -router.put('/items/:id', requireMinLevel('admin'), workIssueController.updateItem); +// 항목 수정 +router.put('/items/:id', requirePage('report_work_issues'), workIssueController.updateItem); -// 항목 삭제 (admin 이상) -router.delete('/items/:id', requireMinLevel('admin'), workIssueController.deleteItem); +// 항목 삭제 +router.delete('/items/:id', requirePage('report_work_issues'), workIssueController.deleteItem); // ==================== 통계 ==================== -// 통계 요약 (support_team 이상) -router.get('/stats/summary', requireMinLevel('support_team'), workIssueController.getStatsSummary); +// 통계 요약 +router.get('/stats/summary', requirePage('report_work_issues'), workIssueController.getStatsSummary); -// 카테고리별 통계 (support_team 이상) -router.get('/stats/by-category', requireMinLevel('support_team'), workIssueController.getStatsByCategory); +// 카테고리별 통계 +router.get('/stats/by-category', requirePage('report_work_issues'), workIssueController.getStatsByCategory); -// 작업장별 통계 (support_team 이상) -router.get('/stats/by-workplace', requireMinLevel('support_team'), workIssueController.getStatsByWorkplace); +// 작업장별 통계 +router.get('/stats/by-workplace', requirePage('report_work_issues'), workIssueController.getStatsByWorkplace); // ==================== 문제 신고 관리 ==================== @@ -76,11 +78,11 @@ router.put('/:id/transfer', workIssueController.transferReport); // ==================== 상태 관리 ==================== -// 신고 접수 (support_team 이상) -router.put('/:id/receive', requireMinLevel('support_team'), workIssueController.receiveReport); +// 신고 접수 +router.put('/:id/receive', requirePage('report_work_issues'), workIssueController.receiveReport); -// 담당자 배정 (support_team 이상) -router.put('/:id/assign', requireMinLevel('support_team'), workIssueController.assignReport); +// 담당자 배정 +router.put('/:id/assign', requirePage('report_work_issues'), workIssueController.assignReport); // 처리 시작 router.put('/:id/start', workIssueController.startProcessing); @@ -88,8 +90,8 @@ router.put('/:id/start', workIssueController.startProcessing); // 처리 완료 router.put('/:id/complete', workIssueController.completeReport); -// 신고 종료 (admin 이상) -router.put('/:id/close', requireMinLevel('admin'), workIssueController.closeReport); +// 신고 종료 +router.put('/:id/close', requirePage('report_work_issues'), workIssueController.closeReport); // 상태 변경 이력 조회 router.get('/:id/logs', workIssueController.getStatusLogs); diff --git a/user-management/api/models/permissionModel.js b/user-management/api/models/permissionModel.js index ab591f2..dc63634 100644 --- a/user-management/api/models/permissionModel.js +++ b/user-management/api/models/permissionModel.js @@ -29,6 +29,26 @@ const DEFAULT_PAGES = { 's1.admin.equipments': { title: '설비 관리', system: 'system1', group: '시스템 관리', default_access: false }, 's1.admin.issue_categories': { title: '신고 카테고리 관리', system: 'system1', group: '시스템 관리', default_access: false }, 's1.admin.attendance_report': { title: '출퇴근-보고서 대조', system: 'system1', group: '시스템 관리', default_access: false }, + // 관리 + 'factory_proxy_input': { title: '대리입력', system: 'system1', group: '관리', default_access: false }, + 'factory_daily_status': { title: '일별 현황', system: 'system1', group: '관리', default_access: false }, + 'factory_tbm': { title: 'TBM 관리', system: 'system1', group: '관리', default_access: false }, + 'factory_work_report': { title: '작업보고서', system: 'system1', group: '관리', default_access: false }, + 'factory_projects': { title: '프로젝트 관리', system: 'system1', group: '관리', default_access: false }, + 'factory_work_issues': { title: '업무 이슈', system: 'system1', group: '관리', default_access: false }, + 'factory_purchases': { title: '구매 관리', system: 'system1', group: '관리', default_access: false }, + 'factory_schedules': { title: '일정 관리', system: 'system1', group: '관리', default_access: false }, + 'factory_settlements': { title: '정산 관리', system: 'system1', group: '관리', default_access: false }, + 'factory_meetings': { title: '회의 관리', system: 'system1', group: '관리', default_access: false }, + 'factory_departments': { title: '부서 관리', system: 'system1', group: '관리', default_access: false }, + 'factory_tools': { title: '도구 관리', system: 'system1', group: '관리', default_access: false }, + 'factory_uploads': { title: '업로드 관리', system: 'system1', group: '관리', default_access: false }, + 'factory_work_analysis': { title: '공수 분석', system: 'system1', group: '관리', default_access: false }, + 'factory_system': { title: '시스템 관리', system: 'system1', group: '시스템 관리', default_access: false }, + // system2 report + 'report_work_issues': { title: '업무 이슈', system: 'system2', group: '관리', default_access: false }, + // tkpurchase + 'purchase_schedules': { title: '작업일정', system: 'tkpurchase', group: '관리', default_access: false }, // ===== System 3 - 부적합관리 ===== // 메인 diff --git a/user-management/api/routes/notificationRoutes.js b/user-management/api/routes/notificationRoutes.js index cc8f7f2..21377da 100644 --- a/user-management/api/routes/notificationRoutes.js +++ b/user-management/api/routes/notificationRoutes.js @@ -2,7 +2,10 @@ const express = require('express'); const router = express.Router(); const notificationController = require('../controllers/notificationController'); -const { requireAuth, requireMinLevel } = require('../middleware/auth'); +const { requireAuth } = require('../middleware/auth'); +const { createRequirePage } = require('../../../shared/middleware/pagePermission'); +const { getPool } = require('../../shared/config/database'); +const requirePage = createRequirePage(getPool); // 내부 서비스용 알림 생성 (X-Internal-Service-Key 인증, JWT 불필요) router.post('/internal', notificationController.createInternal); @@ -20,7 +23,7 @@ router.get('/unread/count', notificationController.getUnreadCount); router.get('/', notificationController.getAll); // 알림 생성 (시스템/관리자용) -router.post('/', requireMinLevel('support_team'), notificationController.create); +router.post('/', requirePage('tkuser.notifications'), notificationController.create); // 모든 알림 읽음 처리 (본인 알림만) router.post('/read-all', notificationController.markAllAsRead); diff --git a/user-management/api/routes/vacationRoutes.js b/user-management/api/routes/vacationRoutes.js index 943fe8c..437877e 100644 --- a/user-management/api/routes/vacationRoutes.js +++ b/user-management/api/routes/vacationRoutes.js @@ -8,7 +8,10 @@ const express = require('express'); const router = express.Router(); const vc = require('../controllers/vacationController'); -const { requireAuth, requireAdminOrPermission, requireMinLevel } = require('../middleware/auth'); +const { requireAuth, requireAdminOrPermission } = require('../middleware/auth'); +const { createRequirePage } = require('../../../shared/middleware/pagePermission'); +const { getPool } = require('../../shared/config/database'); +const requirePage = createRequirePage(getPool); const vacPerm = requireAdminOrPermission('tkuser.vacations'); @@ -29,6 +32,6 @@ router.put('/balances/:id', vacPerm, vc.updateBalance); router.delete('/balances/:id', vacPerm, vc.deleteBalance); // 장기근속 제외 설정 -router.put('/long-service-exclusion', requireMinLevel('support_team'), vc.setLongServiceExclusion); +router.put('/long-service-exclusion', requirePage('tkuser.vacations'), vc.setLongServiceExclusion); module.exports = router;