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 = '데이터를 불러올 수 없습니다
네트워크 오류. 다시 시도해주세요.
데이터를 불러올 수 없습니다
네트워크 오류