+ + 페이지 접근 권한 관리 +
+작업자에게 특정 페이지 접근 권한을 부여하거나 회수합니다
++ + 사용자 목록 +
+ +| 사용자명 | +아이디 | +역할 | +작업자 | +접근 가능 페이지 | +관리 | +
|---|---|---|---|---|---|
|
+
+ 사용자 목록을 불러오는 중... + |
+ |||||
diff --git a/api.hyungi.net/config/routes.js b/api.hyungi.net/config/routes.js index 73e463e..9d233ed 100644 --- a/api.hyungi.net/config/routes.js +++ b/api.hyungi.net/config/routes.js @@ -40,6 +40,7 @@ function setupRoutes(app) { const attendanceRoutes = require('../routes/attendanceRoutes'); const monthlyStatusRoutes = require('../routes/monthlyStatusRoutes'); const pageAccessRoutes = require('../routes/pageAccessRoutes'); + const tbmRoutes = require('../routes/tbmRoutes'); // Rate Limiters 설정 const rateLimit = require('express-rate-limit'); @@ -127,6 +128,7 @@ function setupRoutes(app) { app.use('/api/tools', toolsRoute); app.use('/api/users', userRoutes); app.use('/api', pageAccessRoutes); // 페이지 접근 권한 관리 + app.use('/api/tbm', tbmRoutes); // TBM 시스템 app.use('/api', uploadBgRoutes); // Swagger API 문서 diff --git a/api.hyungi.net/controllers/pageAccessController.js b/api.hyungi.net/controllers/pageAccessController.js new file mode 100644 index 0000000..0448e39 --- /dev/null +++ b/api.hyungi.net/controllers/pageAccessController.js @@ -0,0 +1,200 @@ +// controllers/pageAccessController.js +const PageAccessModel = require('../models/pageAccessModel'); + +const PageAccessController = { + // 사용자의 페이지 권한 조회 + getUserPageAccess: (req, res) => { + const userId = parseInt(req.params.userId); + + if (isNaN(userId)) { + return res.status(400).json({ + success: false, + message: '유효하지 않은 사용자 ID입니다.' + }); + } + + PageAccessModel.getUserPageAccess(userId, (err, results) => { + if (err) { + console.error('페이지 권한 조회 오류:', err); + return res.status(500).json({ + success: false, + message: '페이지 권한 조회 중 오류가 발생했습니다.', + error: err.message + }); + } + + res.json({ + success: true, + data: results + }); + }); + }, + + // 모든 페이지 목록 조회 + getAllPages: (req, res) => { + PageAccessModel.getAllPages((err, results) => { + if (err) { + console.error('페이지 목록 조회 오류:', err); + return res.status(500).json({ + success: false, + message: '페이지 목록 조회 중 오류가 발생했습니다.', + error: err.message + }); + } + + res.json({ + success: true, + data: results + }); + }); + }, + + // 페이지 권한 부여 + grantPageAccess: (req, res) => { + const userId = parseInt(req.params.userId); + const { pageId } = req.body; + const grantedBy = req.user.user_id; + + if (isNaN(userId) || !pageId) { + return res.status(400).json({ + success: false, + message: '필수 파라미터가 누락되었습니다.' + }); + } + + PageAccessModel.grantPageAccess(userId, pageId, grantedBy, (err, result) => { + if (err) { + console.error('페이지 권한 부여 오류:', err); + return res.status(500).json({ + success: false, + message: '페이지 권한 부여 중 오류가 발생했습니다.', + error: err.message + }); + } + + res.json({ + success: true, + message: '페이지 권한이 부여되었습니다.', + data: result + }); + }); + }, + + // 페이지 권한 회수 + revokePageAccess: (req, res) => { + const userId = parseInt(req.params.userId); + const pageId = parseInt(req.params.pageId); + + if (isNaN(userId) || isNaN(pageId)) { + return res.status(400).json({ + success: false, + message: '유효하지 않은 파라미터입니다.' + }); + } + + PageAccessModel.revokePageAccess(userId, pageId, (err, result) => { + if (err) { + console.error('페이지 권한 회수 오류:', err); + return res.status(500).json({ + success: false, + message: '페이지 권한 회수 중 오류가 발생했습니다.', + error: err.message + }); + } + + res.json({ + success: true, + message: '페이지 권한이 회수되었습니다.', + data: result + }); + }); + }, + + // 사용자 페이지 권한 일괄 설정 + setUserPageAccess: (req, res) => { + const userId = parseInt(req.params.userId); + const { pageIds } = req.body; + const grantedBy = req.user.user_id; + + if (isNaN(userId)) { + return res.status(400).json({ + success: false, + message: '유효하지 않은 사용자 ID입니다.' + }); + } + + if (!Array.isArray(pageIds)) { + return res.status(400).json({ + success: false, + message: 'pageIds는 배열이어야 합니다.' + }); + } + + PageAccessModel.setUserPageAccess(userId, pageIds, grantedBy, (err, result) => { + if (err) { + console.error('페이지 권한 설정 오류:', err); + return res.status(500).json({ + success: false, + message: '페이지 권한 설정 중 오류가 발생했습니다.', + error: err.message + }); + } + + res.json({ + success: true, + message: '페이지 권한이 설정되었습니다.', + data: result + }); + }); + }, + + // 특정 페이지 접근 권한 확인 + checkPageAccess: (req, res) => { + const userId = req.user.user_id; + const { pageKey } = req.params; + + if (!pageKey) { + return res.status(400).json({ + success: false, + message: '페이지 키가 필요합니다.' + }); + } + + PageAccessModel.checkPageAccess(userId, pageKey, (err, result) => { + if (err) { + console.error('페이지 접근 권한 확인 오류:', err); + return res.status(500).json({ + success: false, + message: '페이지 접근 권한 확인 중 오류가 발생했습니다.', + error: err.message + }); + } + + res.json({ + success: true, + data: result + }); + }); + }, + + // 계정이 있는 사용자 목록 조회 (권한 관리용) + getUsersWithAccounts: (req, res) => { + PageAccessModel.getUsersWithAccounts((err, results) => { + if (err) { + console.error('사용자 목록 조회 오류:', err); + return res.status(500).json({ + success: false, + message: '사용자 목록 조회 중 오류가 발생했습니다.', + error: err.message + }); + } + + res.json({ + success: true, + data: results + }); + }); + } +}; + +module.exports = PageAccessController; diff --git a/api.hyungi.net/controllers/tbmController.js b/api.hyungi.net/controllers/tbmController.js new file mode 100644 index 0000000..52e9e04 --- /dev/null +++ b/api.hyungi.net/controllers/tbmController.js @@ -0,0 +1,575 @@ +// controllers/tbmController.js - TBM 시스템 컨트롤러 +const TbmModel = require('../models/tbmModel'); + +const TbmController = { + // ==================== TBM 세션 관련 ==================== + + /** + * TBM 세션 생성 + */ + createSession: (req, res) => { + const sessionData = { + session_date: req.body.session_date, + leader_id: req.body.leader_id, + project_id: req.body.project_id || null, + work_location: req.body.work_location || null, + work_description: req.body.work_description || null, + safety_notes: req.body.safety_notes || null, + start_time: req.body.start_time || null, + created_by: req.user.user_id + }; + + // 필수 필드 검증 + if (!sessionData.session_date || !sessionData.leader_id) { + return res.status(400).json({ + success: false, + message: 'TBM 날짜와 팀장 정보는 필수입니다.' + }); + } + + TbmModel.createSession(sessionData, (err, result) => { + if (err) { + console.error('TBM 세션 생성 오류:', err); + return res.status(500).json({ + success: false, + message: 'TBM 세션 생성 중 오류가 발생했습니다.', + error: err.message + }); + } + + res.status(201).json({ + success: true, + message: 'TBM 세션이 생성되었습니다.', + data: { + session_id: result.insertId, + ...sessionData + } + }); + }); + }, + + /** + * 특정 날짜의 TBM 세션 목록 조회 + */ + getSessionsByDate: (req, res) => { + const { date } = req.params; + + if (!date) { + return res.status(400).json({ + success: false, + message: '날짜 정보가 필요합니다.' + }); + } + + TbmModel.getSessionsByDate(date, (err, results) => { + if (err) { + console.error('TBM 세션 조회 오류:', err); + return res.status(500).json({ + success: false, + message: 'TBM 세션 조회 중 오류가 발생했습니다.', + error: err.message + }); + } + + res.json({ + success: true, + data: results + }); + }); + }, + + /** + * TBM 세션 상세 조회 + */ + getSessionById: (req, res) => { + const { sessionId } = req.params; + + TbmModel.getSessionById(sessionId, (err, results) => { + if (err) { + console.error('TBM 세션 상세 조회 오류:', err); + return res.status(500).json({ + success: false, + message: 'TBM 세션 상세 조회 중 오류가 발생했습니다.', + error: err.message + }); + } + + if (results.length === 0) { + return res.status(404).json({ + success: false, + message: 'TBM 세션을 찾을 수 없습니다.' + }); + } + + res.json({ + success: true, + data: results[0] + }); + }); + }, + + /** + * TBM 세션 수정 + */ + updateSession: (req, res) => { + const { sessionId } = req.params; + const sessionData = { + project_id: req.body.project_id, + work_location: req.body.work_location, + work_description: req.body.work_description, + safety_notes: req.body.safety_notes, + status: req.body.status || 'draft' + }; + + TbmModel.updateSession(sessionId, sessionData, (err, result) => { + if (err) { + console.error('TBM 세션 수정 오류:', err); + return res.status(500).json({ + success: false, + message: 'TBM 세션 수정 중 오류가 발생했습니다.', + error: err.message + }); + } + + if (result.affectedRows === 0) { + return res.status(404).json({ + success: false, + message: 'TBM 세션을 찾을 수 없습니다.' + }); + } + + res.json({ + success: true, + message: 'TBM 세션이 수정되었습니다.' + }); + }); + }, + + /** + * TBM 세션 완료 처리 + */ + completeSession: (req, res) => { + const { sessionId } = req.params; + const endTime = req.body.end_time || new Date().toTimeString().slice(0, 8); + + TbmModel.completeSession(sessionId, endTime, (err, result) => { + if (err) { + console.error('TBM 세션 완료 처리 오류:', err); + return res.status(500).json({ + success: false, + message: 'TBM 세션 완료 처리 중 오류가 발생했습니다.', + error: err.message + }); + } + + if (result.affectedRows === 0) { + return res.status(404).json({ + success: false, + message: 'TBM 세션을 찾을 수 없습니다.' + }); + } + + res.json({ + success: true, + message: 'TBM 세션이 완료되었습니다.' + }); + }); + }, + + // ==================== 팀 구성 관련 ==================== + + /** + * 팀원 추가 + */ + addTeamMember: (req, res) => { + const assignmentData = { + session_id: req.params.sessionId, + worker_id: req.body.worker_id, + assigned_role: req.body.assigned_role || null, + work_detail: req.body.work_detail || null, + is_present: req.body.is_present, + absence_reason: req.body.absence_reason || null + }; + + if (!assignmentData.worker_id) { + return res.status(400).json({ + success: false, + message: '작업자 ID가 필요합니다.' + }); + } + + TbmModel.addTeamMember(assignmentData, (err, result) => { + if (err) { + console.error('팀원 추가 오류:', err); + return res.status(500).json({ + success: false, + message: '팀원 추가 중 오류가 발생했습니다.', + error: err.message + }); + } + + res.json({ + success: true, + message: '팀원이 추가되었습니다.' + }); + }); + }, + + /** + * 팀 구성 일괄 추가 + */ + addTeamMembers: (req, res) => { + const { sessionId } = req.params; + const { members } = req.body; + + if (!Array.isArray(members) || members.length === 0) { + return res.status(400).json({ + success: false, + message: '팀원 목록이 필요합니다.' + }); + } + + TbmModel.addTeamMembers(sessionId, members, (err, result) => { + if (err) { + console.error('팀 구성 일괄 추가 오류:', err); + return res.status(500).json({ + success: false, + message: '팀 구성 추가 중 오류가 발생했습니다.', + error: err.message + }); + } + + res.json({ + success: true, + message: `${members.length}명의 팀원이 추가되었습니다.`, + data: { count: members.length } + }); + }); + }, + + /** + * TBM 세션의 팀 구성 조회 + */ + getTeamMembers: (req, res) => { + const { sessionId } = req.params; + + TbmModel.getTeamMembers(sessionId, (err, results) => { + if (err) { + console.error('팀 구성 조회 오류:', err); + return res.status(500).json({ + success: false, + message: '팀 구성 조회 중 오류가 발생했습니다.', + error: err.message + }); + } + + res.json({ + success: true, + data: results + }); + }); + }, + + /** + * 팀원 제거 + */ + removeTeamMember: (req, res) => { + const { sessionId, workerId } = req.params; + + TbmModel.removeTeamMember(sessionId, workerId, (err, result) => { + if (err) { + console.error('팀원 제거 오류:', err); + return res.status(500).json({ + success: false, + message: '팀원 제거 중 오류가 발생했습니다.', + error: err.message + }); + } + + if (result.affectedRows === 0) { + return res.status(404).json({ + success: false, + message: '팀원을 찾을 수 없습니다.' + }); + } + + res.json({ + success: true, + message: '팀원이 제거되었습니다.' + }); + }); + }, + + // ==================== 안전 체크리스트 관련 ==================== + + /** + * 모든 안전 체크 항목 조회 + */ + getAllSafetyChecks: (req, res) => { + TbmModel.getAllSafetyChecks((err, results) => { + if (err) { + console.error('안전 체크 항목 조회 오류:', err); + return res.status(500).json({ + success: false, + message: '안전 체크 항목 조회 중 오류가 발생했습니다.', + error: err.message + }); + } + + res.json({ + success: true, + data: results + }); + }); + }, + + /** + * TBM 세션의 안전 체크 기록 조회 + */ + getSafetyRecords: (req, res) => { + const { sessionId } = req.params; + + TbmModel.getSafetyRecords(sessionId, (err, results) => { + if (err) { + console.error('안전 체크 기록 조회 오류:', err); + return res.status(500).json({ + success: false, + message: '안전 체크 기록 조회 중 오류가 발생했습니다.', + error: err.message + }); + } + + res.json({ + success: true, + data: results + }); + }); + }, + + /** + * 안전 체크 일괄 저장 + */ + saveSafetyRecords: (req, res) => { + const { sessionId } = req.params; + const { records } = req.body; + + if (!Array.isArray(records) || records.length === 0) { + return res.status(400).json({ + success: false, + message: '안전 체크 기록이 필요합니다.' + }); + } + + const checkedBy = req.user.user_id; + + TbmModel.saveSafetyRecords(sessionId, records, checkedBy, (err, result) => { + if (err) { + console.error('안전 체크 저장 오류:', err); + return res.status(500).json({ + success: false, + message: '안전 체크 저장 중 오류가 발생했습니다.', + error: err.message + }); + } + + res.json({ + success: true, + message: '안전 체크가 저장되었습니다.', + data: { count: records.length } + }); + }); + }, + + // ==================== 작업 인계 관련 ==================== + + /** + * 작업 인계 생성 + */ + createHandover: (req, res) => { + const handoverData = { + session_id: req.body.session_id, + from_leader_id: req.body.from_leader_id, + to_leader_id: req.body.to_leader_id, + handover_date: req.body.handover_date, + handover_time: req.body.handover_time || null, + reason: req.body.reason, + handover_notes: req.body.handover_notes || null, + worker_ids: req.body.worker_ids || [] + }; + + // 필수 필드 검증 + if (!handoverData.session_id || !handoverData.from_leader_id || + !handoverData.to_leader_id || !handoverData.handover_date || !handoverData.reason) { + return res.status(400).json({ + success: false, + message: '필수 정보가 누락되었습니다.' + }); + } + + TbmModel.createHandover(handoverData, (err, result) => { + if (err) { + console.error('작업 인계 생성 오류:', err); + return res.status(500).json({ + success: false, + message: '작업 인계 생성 중 오류가 발생했습니다.', + error: err.message + }); + } + + res.status(201).json({ + success: true, + message: '작업 인계가 생성되었습니다.', + data: { handover_id: result.insertId } + }); + }); + }, + + /** + * 작업 인계 확인 + */ + confirmHandover: (req, res) => { + const { handoverId } = req.params; + const confirmedBy = req.user.user_id; + + TbmModel.confirmHandover(handoverId, confirmedBy, (err, result) => { + if (err) { + console.error('작업 인계 확인 오류:', err); + return res.status(500).json({ + success: false, + message: '작업 인계 확인 중 오류가 발생했습니다.', + error: err.message + }); + } + + if (result.affectedRows === 0) { + return res.status(404).json({ + success: false, + message: '작업 인계 건을 찾을 수 없습니다.' + }); + } + + res.json({ + success: true, + message: '작업 인계가 확인되었습니다.' + }); + }); + }, + + /** + * 특정 날짜의 작업 인계 목록 조회 + */ + getHandoversByDate: (req, res) => { + const { date } = req.params; + + TbmModel.getHandoversByDate(date, (err, results) => { + if (err) { + console.error('작업 인계 목록 조회 오류:', err); + return res.status(500).json({ + success: false, + message: '작업 인계 목록 조회 중 오류가 발생했습니다.', + error: err.message + }); + } + + res.json({ + success: true, + data: results + }); + }); + }, + + /** + * 나에게 온 미확인 인계 건 조회 + */ + getMyPendingHandovers: (req, res) => { + // worker_id는 req.user에서 가져옴 + const toLeaderId = req.user.worker_id; + + if (!toLeaderId) { + return res.status(400).json({ + success: false, + message: '작업자 정보를 찾을 수 없습니다.' + }); + } + + TbmModel.getPendingHandovers(toLeaderId, (err, results) => { + if (err) { + console.error('미확인 인계 건 조회 오류:', err); + return res.status(500).json({ + success: false, + message: '미확인 인계 건 조회 중 오류가 발생했습니다.', + error: err.message + }); + } + + res.json({ + success: true, + data: results + }); + }); + }, + + // ==================== 통계 및 리포트 ==================== + + /** + * TBM 통계 조회 + */ + getTbmStatistics: (req, res) => { + const { startDate, endDate } = req.query; + + if (!startDate || !endDate) { + return res.status(400).json({ + success: false, + message: '시작일과 종료일이 필요합니다.' + }); + } + + TbmModel.getTbmStatistics(startDate, endDate, (err, results) => { + if (err) { + console.error('TBM 통계 조회 오류:', err); + return res.status(500).json({ + success: false, + message: 'TBM 통계 조회 중 오류가 발생했습니다.', + error: err.message + }); + } + + res.json({ + success: true, + data: results + }); + }); + }, + + /** + * 리더별 TBM 진행 현황 조회 + */ + getLeaderStatistics: (req, res) => { + const { startDate, endDate } = req.query; + + if (!startDate || !endDate) { + return res.status(400).json({ + success: false, + message: '시작일과 종료일이 필요합니다.' + }); + } + + TbmModel.getLeaderStatistics(startDate, endDate, (err, results) => { + if (err) { + console.error('리더 통계 조회 오류:', err); + return res.status(500).json({ + success: false, + message: '리더 통계 조회 중 오류가 발생했습니다.', + error: err.message + }); + } + + res.json({ + success: true, + data: results + }); + }); + } +}; + +module.exports = TbmController; diff --git a/api.hyungi.net/db/migrations/20260120000000_create_tbm_system.js b/api.hyungi.net/db/migrations/20260120000000_create_tbm_system.js new file mode 100644 index 0000000..4ee0a02 --- /dev/null +++ b/api.hyungi.net/db/migrations/20260120000000_create_tbm_system.js @@ -0,0 +1,158 @@ +/** + * 마이그레이션: TBM (Tool Box Meeting) 시스템 + * 작성일: 2026-01-20 + * + * 생성 테이블: + * - tbm_sessions: TBM 세션 (아침 미팅 기록) + * - tbm_team_assignments: TBM 팀 구성 (리더가 선택한 작업자들) + * - tbm_safety_checks: TBM 안전 체크리스트 + * - tbm_safety_records: TBM 안전 체크 기록 + * - team_handovers: 작업 인계 기록 (반차/조퇴 시) + */ + +exports.up = async function(knex) { + console.log('⏳ TBM 시스템 테이블 생성 중...'); + + // 1. TBM 세션 테이블 (아침 미팅) + await knex.schema.createTable('tbm_sessions', (table) => { + table.increments('session_id').primary(); + table.date('session_date').notNullable().comment('TBM 날짜'); + table.integer('leader_id').notNullable().comment('팀장 worker_id'); + table.integer('project_id').nullable().comment('프로젝트 ID'); + table.string('work_location', 200).nullable().comment('작업 장소'); + table.text('work_description').nullable().comment('작업 내용'); + table.text('safety_notes').nullable().comment('안전 관련 특이사항'); + table.enum('status', ['draft', 'completed', 'cancelled']).defaultTo('draft').comment('상태'); + table.time('start_time').nullable().comment('TBM 시작 시간'); + table.time('end_time').nullable().comment('TBM 종료 시간'); + table.integer('created_by').notNullable().comment('생성자 user_id'); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + + // 인덱스 및 제약조건 + table.index(['session_date', 'leader_id']); + table.foreign('leader_id').references('workers.worker_id'); + table.foreign('project_id').references('projects.project_id').onDelete('SET NULL'); + table.foreign('created_by').references('users.user_id'); + }); + console.log('✅ tbm_sessions 테이블 생성 완료'); + + // 2. TBM 팀 구성 테이블 (리더가 선택한 팀원들) + await knex.schema.createTable('tbm_team_assignments', (table) => { + table.increments('assignment_id').primary(); + table.integer('session_id').unsigned().notNullable().comment('TBM 세션 ID'); + table.integer('worker_id').notNullable().comment('팀원 worker_id'); + table.string('assigned_role', 100).nullable().comment('역할/담당'); + table.text('work_detail').nullable().comment('세부 작업 내용'); + table.boolean('is_present').defaultTo(true).comment('출석 여부'); + table.text('absence_reason').nullable().comment('결석 사유'); + table.timestamp('assigned_at').defaultTo(knex.fn.now()); + + // 인덱스 및 제약조건 + table.unique(['session_id', 'worker_id']); + table.foreign('session_id').references('tbm_sessions.session_id').onDelete('CASCADE'); + table.foreign('worker_id').references('workers.worker_id'); + }); + console.log('✅ tbm_team_assignments 테이블 생성 완료'); + + // 3. TBM 안전 체크리스트 마스터 테이블 + await knex.schema.createTable('tbm_safety_checks', (table) => { + table.increments('check_id').primary(); + table.string('check_category', 50).notNullable().comment('카테고리 (장비, PPE, 환경 등)'); + table.string('check_item', 200).notNullable().comment('체크 항목'); + table.text('description').nullable().comment('설명'); + table.integer('display_order').defaultTo(0).comment('표시 순서'); + table.boolean('is_required').defaultTo(true).comment('필수 체크 여부'); + table.boolean('is_active').defaultTo(true).comment('활성 여부'); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + + table.index('check_category'); + }); + console.log('✅ tbm_safety_checks 테이블 생성 완료'); + + // 초기 안전 체크리스트 데이터 + await knex('tbm_safety_checks').insert([ + // PPE (개인 보호 장비) + { check_category: 'PPE', check_item: '안전모 착용 확인', display_order: 1, is_required: true }, + { check_category: 'PPE', check_item: '안전화 착용 확인', display_order: 2, is_required: true }, + { check_category: 'PPE', check_item: '안전조끼 착용 확인', display_order: 3, is_required: true }, + { check_category: 'PPE', check_item: '안전벨트 착용 확인 (고소작업 시)', display_order: 4, is_required: false }, + { check_category: 'PPE', check_item: '보안경/마스크 착용 확인', display_order: 5, is_required: false }, + + // 장비 점검 + { check_category: 'EQUIPMENT', check_item: '작업 도구 점검 완료', display_order: 10, is_required: true }, + { check_category: 'EQUIPMENT', check_item: '전동공구 안전 점검', display_order: 11, is_required: true }, + { check_category: 'EQUIPMENT', check_item: '사다리/비계 안전 확인', display_order: 12, is_required: false }, + { check_category: 'EQUIPMENT', check_item: '차량/중장비 점검 완료', display_order: 13, is_required: false }, + + // 작업 환경 + { check_category: 'ENVIRONMENT', check_item: '작업 장소 정리정돈 확인', display_order: 20, is_required: true }, + { check_category: 'ENVIRONMENT', check_item: '위험 구역 표시 확인', display_order: 21, is_required: true }, + { check_category: 'ENVIRONMENT', check_item: '기상 상태 확인 (우천, 강풍 등)', display_order: 22, is_required: true }, + { check_category: 'ENVIRONMENT', check_item: '작업 동선 안전 확인', display_order: 23, is_required: true }, + + // 비상 대응 + { check_category: 'EMERGENCY', check_item: '비상연락망 공유 완료', display_order: 30, is_required: true }, + { check_category: 'EMERGENCY', check_item: '소화기 위치 확인', display_order: 31, is_required: true }, + { check_category: 'EMERGENCY', check_item: '응급처치 키트 위치 확인', display_order: 32, is_required: true }, + ]); + console.log('✅ tbm_safety_checks 초기 데이터 입력 완료'); + + // 4. TBM 안전 체크 기록 테이블 + await knex.schema.createTable('tbm_safety_records', (table) => { + table.increments('record_id').primary(); + table.integer('session_id').unsigned().notNullable().comment('TBM 세션 ID'); + table.integer('check_id').unsigned().notNullable().comment('체크 항목 ID'); + table.boolean('is_checked').defaultTo(false).comment('체크 여부'); + table.text('notes').nullable().comment('비고/특이사항'); + table.integer('checked_by').nullable().comment('체크한 user_id'); + table.timestamp('checked_at').nullable().comment('체크 시간'); + + // 인덱스 및 제약조건 + table.unique(['session_id', 'check_id']); + table.foreign('session_id').references('tbm_sessions.session_id').onDelete('CASCADE'); + table.foreign('check_id').references('tbm_safety_checks.check_id'); + table.foreign('checked_by').references('users.user_id'); + }); + console.log('✅ tbm_safety_records 테이블 생성 완료'); + + // 5. 작업 인계 테이블 (반차/조퇴 시) + await knex.schema.createTable('team_handovers', (table) => { + table.increments('handover_id').primary(); + table.integer('session_id').unsigned().notNullable().comment('TBM 세션 ID'); + table.integer('from_leader_id').notNullable().comment('인계자 worker_id'); + table.integer('to_leader_id').notNullable().comment('인수자 worker_id'); + table.date('handover_date').notNullable().comment('인계 날짜'); + table.time('handover_time').nullable().comment('인계 시간'); + table.enum('reason', ['half_day', 'early_leave', 'emergency', 'other']).notNullable().comment('인계 사유'); + table.text('handover_notes').nullable().comment('인계 내용'); + table.text('worker_ids').nullable().comment('인계하는 작업자 IDs (JSON array)'); + table.boolean('is_confirmed').defaultTo(false).comment('인수 확인 여부'); + table.timestamp('confirmed_at').nullable().comment('인수 확인 시간'); + table.integer('confirmed_by').nullable().comment('인수 확인자 user_id'); + table.timestamp('created_at').defaultTo(knex.fn.now()); + + // 인덱스 및 제약조건 + table.index(['session_id', 'handover_date']); + table.foreign('session_id').references('tbm_sessions.session_id').onDelete('CASCADE'); + table.foreign('from_leader_id').references('workers.worker_id'); + table.foreign('to_leader_id').references('workers.worker_id'); + table.foreign('confirmed_by').references('users.user_id'); + }); + console.log('✅ team_handovers 테이블 생성 완료'); + + console.log('✅ 모든 TBM 시스템 테이블 생성 완료'); +}; + +exports.down = async function(knex) { + console.log('⏳ TBM 시스템 테이블 제거 중...'); + + await knex.schema.dropTableIfExists('team_handovers'); + await knex.schema.dropTableIfExists('tbm_safety_records'); + await knex.schema.dropTableIfExists('tbm_safety_checks'); + await knex.schema.dropTableIfExists('tbm_team_assignments'); + await knex.schema.dropTableIfExists('tbm_sessions'); + + console.log('✅ 모든 TBM 시스템 테이블 제거 완료'); +}; diff --git a/api.hyungi.net/db/migrations/20260120000001_add_tbm_page.js b/api.hyungi.net/db/migrations/20260120000001_add_tbm_page.js new file mode 100644 index 0000000..4b3c9bb --- /dev/null +++ b/api.hyungi.net/db/migrations/20260120000001_add_tbm_page.js @@ -0,0 +1,33 @@ +/** + * 마이그레이션: TBM 페이지 등록 + * 작성일: 2026-01-20 + * + * pages 테이블에 TBM 페이지 추가 + */ + +exports.up = async function(knex) { + console.log('⏳ TBM 페이지 등록 중...'); + + // TBM 페이지 추가 + await knex('pages').insert([ + { + page_key: 'tbm', + page_name: 'TBM 관리', + page_path: '/pages/work/tbm.html', + category: 'work', + description: 'Tool Box Meeting - 아침 안전 회의 및 팀 구성 관리', + is_admin_only: false, + display_order: 10 + } + ]); + + console.log('✅ TBM 페이지 등록 완료'); +}; + +exports.down = async function(knex) { + console.log('⏳ TBM 페이지 제거 중...'); + + await knex('pages').where('page_key', 'tbm').del(); + + console.log('✅ TBM 페이지 제거 완료'); +}; diff --git a/api.hyungi.net/models/pageAccessModel.js b/api.hyungi.net/models/pageAccessModel.js new file mode 100644 index 0000000..f6801f6 --- /dev/null +++ b/api.hyungi.net/models/pageAccessModel.js @@ -0,0 +1,160 @@ +// models/pageAccessModel.js +const db = require('../db/connection'); + +const PageAccessModel = { + // 사용자의 페이지 권한 조회 + getUserPageAccess: (userId, callback) => { + const sql = ` + SELECT + p.id, + p.page_key, + p.page_name, + p.page_path, + p.category, + p.is_admin_only, + COALESCE(upa.can_access, 0) as can_access, + upa.granted_at, + upa.granted_by, + granter.username as granted_by_username + FROM pages p + LEFT JOIN user_page_access upa ON p.id = upa.page_id AND upa.user_id = ? + LEFT JOIN users granter ON upa.granted_by = granter.user_id + WHERE p.is_admin_only = 0 + ORDER BY p.category, p.display_order + `; + + db.query(sql, [userId], callback); + }, + + // 모든 페이지 목록 조회 + getAllPages: (callback) => { + const sql = ` + SELECT + id, + page_key, + page_name, + page_path, + category, + description, + is_admin_only, + display_order + FROM pages + WHERE is_admin_only = 0 + ORDER BY category, display_order + `; + + db.query(sql, callback); + }, + + // 페이지 권한 부여 + grantPageAccess: (userId, pageId, grantedBy, callback) => { + const sql = ` + INSERT INTO user_page_access (user_id, page_id, can_access, granted_by, granted_at) + VALUES (?, ?, 1, ?, NOW()) + ON DUPLICATE KEY UPDATE + can_access = 1, + granted_by = ?, + granted_at = NOW() + `; + + db.query(sql, [userId, pageId, grantedBy, grantedBy], callback); + }, + + // 페이지 권한 회수 + revokePageAccess: (userId, pageId, callback) => { + const sql = ` + DELETE FROM user_page_access + WHERE user_id = ? AND page_id = ? + `; + + db.query(sql, [userId, pageId], callback); + }, + + // 여러 페이지 권한 일괄 설정 + setUserPageAccess: (userId, pageIds, grantedBy, callback) => { + db.beginTransaction((err) => { + if (err) return callback(err); + + // 기존 권한 모두 삭제 + const deleteSql = 'DELETE FROM user_page_access WHERE user_id = ?'; + + db.query(deleteSql, [userId], (err) => { + if (err) { + return db.rollback(() => callback(err)); + } + + // 새 권한이 없으면 커밋하고 종료 + if (!pageIds || pageIds.length === 0) { + return db.commit((err) => { + if (err) return db.rollback(() => callback(err)); + callback(null, { affectedRows: 0 }); + }); + } + + // 새 권한 추가 + const values = pageIds.map(pageId => [userId, pageId, 1, grantedBy]); + const insertSql = ` + INSERT INTO user_page_access (user_id, page_id, can_access, granted_by, granted_at) + VALUES ? + `; + + db.query(insertSql, [values], (err, result) => { + if (err) { + return db.rollback(() => callback(err)); + } + + db.commit((err) => { + if (err) return db.rollback(() => callback(err)); + callback(null, result); + }); + }); + }); + }); + }, + + // 특정 페이지 접근 권한 확인 + checkPageAccess: (userId, pageKey, callback) => { + const sql = ` + SELECT + COALESCE(upa.can_access, 0) as can_access, + p.is_admin_only + FROM pages p + LEFT JOIN user_page_access upa ON p.id = upa.page_id AND upa.user_id = ? + WHERE p.page_key = ? + `; + + db.query(sql, [userId, pageKey], (err, results) => { + if (err) return callback(err); + if (results.length === 0) return callback(null, { can_access: false }); + callback(null, results[0]); + }); + }, + + // 계정이 있는 작업자 목록 조회 (권한 관리용) + getUsersWithAccounts: (callback) => { + const sql = ` + SELECT + u.user_id, + u.username, + u.name, + u.role_id, + r.name as role_name, + u.worker_id, + w.worker_name, + w.job_type, + COUNT(upa.page_id) as granted_pages_count + FROM users u + LEFT JOIN roles r ON u.role_id = r.id + LEFT JOIN workers w ON u.worker_id = w.worker_id + LEFT JOIN user_page_access upa ON u.user_id = upa.user_id AND upa.can_access = 1 + WHERE u.is_active = 1 + AND u.role_id IN (4, 5) + GROUP BY u.user_id + ORDER BY w.worker_name, u.username + `; + + db.query(sql, callback); + } +}; + +module.exports = PageAccessModel; diff --git a/api.hyungi.net/models/tbmModel.js b/api.hyungi.net/models/tbmModel.js new file mode 100644 index 0000000..263f137 --- /dev/null +++ b/api.hyungi.net/models/tbmModel.js @@ -0,0 +1,453 @@ +// models/tbmModel.js - TBM 시스템 모델 +const db = require('../db/connection'); + +const TbmModel = { + // ==================== TBM 세션 관련 ==================== + + /** + * TBM 세션 생성 + */ + createSession: (sessionData, callback) => { + const sql = ` + INSERT INTO tbm_sessions + (session_date, leader_id, project_id, work_location, work_description, + safety_notes, start_time, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `; + + const values = [ + sessionData.session_date, + sessionData.leader_id, + sessionData.project_id, + sessionData.work_location, + sessionData.work_description, + sessionData.safety_notes, + sessionData.start_time, + sessionData.created_by + ]; + + db.query(sql, values, callback); + }, + + /** + * 특정 날짜의 TBM 세션 조회 + */ + getSessionsByDate: (date, callback) => { + const sql = ` + SELECT + s.*, + w.worker_name as leader_name, + w.job_type as leader_job_type, + p.project_name, + p.job_no, + u.username as created_by_username, + COUNT(DISTINCT ta.worker_id) as team_member_count + FROM tbm_sessions s + LEFT JOIN workers w ON s.leader_id = w.worker_id + LEFT JOIN projects p ON s.project_id = p.project_id + LEFT JOIN users u ON s.created_by = u.user_id + LEFT JOIN tbm_team_assignments ta ON s.session_id = ta.session_id + WHERE s.session_date = ? + GROUP BY s.session_id + ORDER BY s.start_time DESC + `; + + db.query(sql, [date], callback); + }, + + /** + * TBM 세션 상세 조회 + */ + getSessionById: (sessionId, callback) => { + const sql = ` + SELECT + s.*, + w.worker_name as leader_name, + w.job_type as leader_job_type, + w.phone_number as leader_phone, + p.project_name, + p.job_no, + p.site, + u.username as created_by_username, + u.name as created_by_name + FROM tbm_sessions s + LEFT JOIN workers w ON s.leader_id = w.worker_id + LEFT JOIN projects p ON s.project_id = p.project_id + LEFT JOIN users u ON s.created_by = u.user_id + WHERE s.session_id = ? + `; + + db.query(sql, [sessionId], callback); + }, + + /** + * TBM 세션 수정 + */ + updateSession: (sessionId, sessionData, callback) => { + const sql = ` + UPDATE tbm_sessions + SET + project_id = ?, + work_location = ?, + work_description = ?, + safety_notes = ?, + status = ?, + updated_at = NOW() + WHERE session_id = ? + `; + + const values = [ + sessionData.project_id, + sessionData.work_location, + sessionData.work_description, + sessionData.safety_notes, + sessionData.status, + sessionId + ]; + + db.query(sql, values, callback); + }, + + /** + * TBM 세션 완료 처리 + */ + completeSession: (sessionId, endTime, callback) => { + const sql = ` + UPDATE tbm_sessions + SET + status = 'completed', + end_time = ?, + updated_at = NOW() + WHERE session_id = ? + `; + + db.query(sql, [endTime, sessionId], callback); + }, + + // ==================== 팀 구성 관련 ==================== + + /** + * 팀원 추가 + */ + addTeamMember: (assignmentData, callback) => { + const sql = ` + INSERT INTO tbm_team_assignments + (session_id, worker_id, assigned_role, work_detail, is_present, absence_reason) + VALUES (?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + assigned_role = VALUES(assigned_role), + work_detail = VALUES(work_detail), + is_present = VALUES(is_present), + absence_reason = VALUES(absence_reason) + `; + + const values = [ + assignmentData.session_id, + assignmentData.worker_id, + assignmentData.assigned_role, + assignmentData.work_detail, + assignmentData.is_present !== undefined ? assignmentData.is_present : true, + assignmentData.absence_reason + ]; + + db.query(sql, values, callback); + }, + + /** + * 팀 구성 일괄 추가 + */ + addTeamMembers: (sessionId, members, callback) => { + if (!members || members.length === 0) { + return callback(null, { affectedRows: 0 }); + } + + const values = members.map(m => [ + sessionId, + m.worker_id, + m.assigned_role || null, + m.work_detail || null, + m.is_present !== undefined ? m.is_present : true, + m.absence_reason || null + ]); + + const sql = ` + INSERT INTO tbm_team_assignments + (session_id, worker_id, assigned_role, work_detail, is_present, absence_reason) + VALUES ? + `; + + db.query(sql, [values], callback); + }, + + /** + * TBM 세션의 팀 구성 조회 + */ + getTeamMembers: (sessionId, callback) => { + const sql = ` + SELECT + ta.*, + w.worker_name, + w.job_type, + w.phone_number, + w.department + FROM tbm_team_assignments ta + INNER JOIN workers w ON ta.worker_id = w.worker_id + WHERE ta.session_id = ? + ORDER BY ta.assigned_at DESC + `; + + db.query(sql, [sessionId], callback); + }, + + /** + * 팀원 제거 + */ + removeTeamMember: (sessionId, workerId, callback) => { + const sql = ` + DELETE FROM tbm_team_assignments + WHERE session_id = ? AND worker_id = ? + `; + + db.query(sql, [sessionId, workerId], callback); + }, + + // ==================== 안전 체크리스트 관련 ==================== + + /** + * 모든 안전 체크 항목 조회 + */ + getAllSafetyChecks: (callback) => { + const sql = ` + SELECT * + FROM tbm_safety_checks + WHERE is_active = 1 + ORDER BY check_category, display_order + `; + + db.query(sql, callback); + }, + + /** + * 카테고리별 안전 체크 항목 조회 + */ + getSafetyChecksByCategory: (category, callback) => { + const sql = ` + SELECT * + FROM tbm_safety_checks + WHERE check_category = ? AND is_active = 1 + ORDER BY display_order + `; + + db.query(sql, [category], callback); + }, + + /** + * TBM 세션의 안전 체크 기록 조회 + */ + getSafetyRecords: (sessionId, callback) => { + const sql = ` + SELECT + sr.*, + sc.check_category, + sc.check_item, + sc.description, + sc.is_required, + u.username as checked_by_username, + u.name as checked_by_name + FROM tbm_safety_records sr + INNER JOIN tbm_safety_checks sc ON sr.check_id = sc.check_id + LEFT JOIN users u ON sr.checked_by = u.user_id + WHERE sr.session_id = ? + ORDER BY sc.check_category, sc.display_order + `; + + db.query(sql, [sessionId], callback); + }, + + /** + * 안전 체크 기록 저장/업데이트 + */ + saveSafetyRecord: (recordData, callback) => { + const sql = ` + INSERT INTO tbm_safety_records + (session_id, check_id, is_checked, notes, checked_by, checked_at) + VALUES (?, ?, ?, ?, ?, NOW()) + ON DUPLICATE KEY UPDATE + is_checked = VALUES(is_checked), + notes = VALUES(notes), + checked_by = VALUES(checked_by), + checked_at = NOW() + `; + + const values = [ + recordData.session_id, + recordData.check_id, + recordData.is_checked, + recordData.notes, + recordData.checked_by + ]; + + db.query(sql, values, callback); + }, + + /** + * 안전 체크 일괄 저장 + */ + saveSafetyRecords: (sessionId, records, checkedBy, callback) => { + if (!records || records.length === 0) { + return callback(null, { affectedRows: 0 }); + } + + const values = records.map(r => [ + sessionId, + r.check_id, + r.is_checked, + r.notes || null, + checkedBy + ]); + + const sql = ` + INSERT INTO tbm_safety_records + (session_id, check_id, is_checked, notes, checked_by, checked_at) + VALUES ? + ON DUPLICATE KEY UPDATE + is_checked = VALUES(is_checked), + notes = VALUES(notes), + checked_by = VALUES(checked_by), + checked_at = NOW() + `; + + db.query(sql, [values], callback); + }, + + // ==================== 작업 인계 관련 ==================== + + /** + * 작업 인계 생성 + */ + createHandover: (handoverData, callback) => { + const sql = ` + INSERT INTO team_handovers + (session_id, from_leader_id, to_leader_id, handover_date, handover_time, + reason, handover_notes, worker_ids) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `; + + const values = [ + handoverData.session_id, + handoverData.from_leader_id, + handoverData.to_leader_id, + handoverData.handover_date, + handoverData.handover_time, + handoverData.reason, + handoverData.handover_notes, + JSON.stringify(handoverData.worker_ids || []) + ]; + + db.query(sql, values, callback); + }, + + /** + * 작업 인계 확인 + */ + confirmHandover: (handoverId, confirmedBy, callback) => { + const sql = ` + UPDATE team_handovers + SET + is_confirmed = 1, + confirmed_at = NOW(), + confirmed_by = ? + WHERE handover_id = ? + `; + + db.query(sql, [confirmedBy, handoverId], callback); + }, + + /** + * 특정 날짜의 작업 인계 목록 조회 + */ + getHandoversByDate: (date, callback) => { + const sql = ` + SELECT + h.*, + w1.worker_name as from_leader_name, + w2.worker_name as to_leader_name, + u.username as confirmed_by_username, + u.name as confirmed_by_name + FROM team_handovers h + INNER JOIN workers w1 ON h.from_leader_id = w1.worker_id + INNER JOIN workers w2 ON h.to_leader_id = w2.worker_id + LEFT JOIN users u ON h.confirmed_by = u.user_id + WHERE h.handover_date = ? + ORDER BY h.handover_time DESC + `; + + db.query(sql, [date], callback); + }, + + /** + * 인수자가 받은 미확인 인계 건 조회 + */ + getPendingHandovers: (toLeaderId, callback) => { + const sql = ` + SELECT + h.*, + w1.worker_name as from_leader_name, + w1.phone_number as from_leader_phone, + s.work_location, + s.work_description + FROM team_handovers h + INNER JOIN workers w1 ON h.from_leader_id = w1.worker_id + LEFT JOIN tbm_sessions s ON h.session_id = s.session_id + WHERE h.to_leader_id = ? AND h.is_confirmed = 0 + ORDER BY h.handover_date DESC, h.handover_time DESC + `; + + db.query(sql, [toLeaderId], callback); + }, + + // ==================== 통계 및 리포트 ==================== + + /** + * 특정 기간의 TBM 통계 + */ + getTbmStatistics: (startDate, endDate, callback) => { + const sql = ` + SELECT + DATE(session_date) as date, + COUNT(DISTINCT session_id) as session_count, + COUNT(DISTINCT leader_id) as leader_count, + SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed_count + FROM tbm_sessions + WHERE session_date BETWEEN ? AND ? + GROUP BY DATE(session_date) + ORDER BY date DESC + `; + + db.query(sql, [startDate, endDate], callback); + }, + + /** + * 리더별 TBM 진행 현황 + */ + getLeaderStatistics: (startDate, endDate, callback) => { + const sql = ` + SELECT + s.leader_id, + w.worker_name as leader_name, + COUNT(DISTINCT s.session_id) as total_sessions, + SUM(CASE WHEN s.status = 'completed' THEN 1 ELSE 0 END) as completed_sessions, + COUNT(DISTINCT ta.worker_id) as total_team_members + FROM tbm_sessions s + INNER JOIN workers w ON s.leader_id = w.worker_id + LEFT JOIN tbm_team_assignments ta ON s.session_id = ta.session_id + WHERE s.session_date BETWEEN ? AND ? + GROUP BY s.leader_id + ORDER BY total_sessions DESC + `; + + db.query(sql, [startDate, endDate], callback); + } +}; + +module.exports = TbmModel; diff --git a/api.hyungi.net/routes/tbmRoutes.js b/api.hyungi.net/routes/tbmRoutes.js new file mode 100644 index 0000000..86dd404 --- /dev/null +++ b/api.hyungi.net/routes/tbmRoutes.js @@ -0,0 +1,71 @@ +// routes/tbmRoutes.js - TBM 시스템 라우트 +const express = require('express'); +const router = express.Router(); +const TbmController = require('../controllers/tbmController'); +const { authenticateToken } = require('../middlewares/auth'); + +// ==================== TBM 세션 관련 ==================== + +// TBM 세션 생성 +router.post('/sessions', authenticateToken, TbmController.createSession); + +// 특정 날짜의 TBM 세션 목록 조회 +router.get('/sessions/date/:date', authenticateToken, TbmController.getSessionsByDate); + +// TBM 세션 상세 조회 +router.get('/sessions/:sessionId', authenticateToken, TbmController.getSessionById); + +// TBM 세션 수정 +router.put('/sessions/:sessionId', authenticateToken, TbmController.updateSession); + +// TBM 세션 완료 처리 +router.post('/sessions/:sessionId/complete', authenticateToken, TbmController.completeSession); + +// ==================== 팀 구성 관련 ==================== + +// 팀원 추가 (단일) +router.post('/sessions/:sessionId/team', authenticateToken, TbmController.addTeamMember); + +// 팀 구성 일괄 추가 +router.post('/sessions/:sessionId/team/batch', authenticateToken, TbmController.addTeamMembers); + +// TBM 세션의 팀 구성 조회 +router.get('/sessions/:sessionId/team', authenticateToken, TbmController.getTeamMembers); + +// 팀원 제거 +router.delete('/sessions/:sessionId/team/:workerId', authenticateToken, TbmController.removeTeamMember); + +// ==================== 안전 체크리스트 관련 ==================== + +// 모든 안전 체크 항목 조회 +router.get('/safety-checks', authenticateToken, TbmController.getAllSafetyChecks); + +// TBM 세션의 안전 체크 기록 조회 +router.get('/sessions/:sessionId/safety', authenticateToken, TbmController.getSafetyRecords); + +// 안전 체크 일괄 저장 +router.post('/sessions/:sessionId/safety', authenticateToken, TbmController.saveSafetyRecords); + +// ==================== 작업 인계 관련 ==================== + +// 작업 인계 생성 +router.post('/handovers', authenticateToken, TbmController.createHandover); + +// 작업 인계 확인 +router.post('/handovers/:handoverId/confirm', authenticateToken, TbmController.confirmHandover); + +// 특정 날짜의 작업 인계 목록 조회 +router.get('/handovers/date/:date', authenticateToken, TbmController.getHandoversByDate); + +// 나에게 온 미확인 인계 건 조회 +router.get('/handovers/pending', authenticateToken, TbmController.getMyPendingHandovers); + +// ==================== 통계 및 리포트 ==================== + +// TBM 통계 조회 +router.get('/statistics/tbm', authenticateToken, TbmController.getTbmStatistics); + +// 리더별 TBM 진행 현황 조회 +router.get('/statistics/leaders', authenticateToken, TbmController.getLeaderStatistics); + +module.exports = router; diff --git a/docs/TBM_DEPLOYMENT_GUIDE.md b/docs/TBM_DEPLOYMENT_GUIDE.md new file mode 100644 index 0000000..cf6745a --- /dev/null +++ b/docs/TBM_DEPLOYMENT_GUIDE.md @@ -0,0 +1,393 @@ +# TBM 시스템 배포 가이드 + +## 📋 개요 + +TBM (Tool Box Meeting) 시스템은 아침 안전 회의 및 팀 구성 관리를 위한 기능입니다. + +**배포일**: 2026-01-20 +**버전**: 1.0.0 + +--- + +## 🗄️ 데이터베이스 마이그레이션 + +### 필수 마이그레이션 파일 + +본 서버에 배포 시 반드시 실행해야 할 마이그레이션: + +1. **`20260120000000_create_tbm_system.js`** - TBM 시스템 테이블 생성 +2. **`20260120000001_add_tbm_page.js`** - TBM 페이지 등록 + +### 생성되는 테이블 + +#### 1. `tbm_sessions` - TBM 세션 (아침 미팅) +```sql +CREATE TABLE `tbm_sessions` ( + `session_id` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + `session_date` DATE NOT NULL COMMENT 'TBM 날짜', + `leader_id` INT NOT NULL COMMENT '팀장 worker_id', + `project_id` INT NULL COMMENT '프로젝트 ID', + `work_location` VARCHAR(200) NULL COMMENT '작업 장소', + `work_description` TEXT NULL COMMENT '작업 내용', + `safety_notes` TEXT NULL COMMENT '안전 관련 특이사항', + `status` ENUM('draft', 'completed', 'cancelled') DEFAULT 'draft' COMMENT '상태', + `start_time` TIME NULL COMMENT 'TBM 시작 시간', + `end_time` TIME NULL COMMENT 'TBM 종료 시간', + `created_by` INT NOT NULL COMMENT '생성자 user_id', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX `idx_session_date_leader` (`session_date`, `leader_id`), + FOREIGN KEY (`leader_id`) REFERENCES `workers` (`worker_id`), + FOREIGN KEY (`project_id`) REFERENCES `projects` (`project_id`) ON DELETE SET NULL, + FOREIGN KEY (`created_by`) REFERENCES `users` (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +``` + +#### 2. `tbm_team_assignments` - TBM 팀 구성 +```sql +CREATE TABLE `tbm_team_assignments` ( + `assignment_id` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + `session_id` INT UNSIGNED NOT NULL COMMENT 'TBM 세션 ID', + `worker_id` INT NOT NULL COMMENT '팀원 worker_id', + `assigned_role` VARCHAR(100) NULL COMMENT '역할/담당', + `work_detail` TEXT NULL COMMENT '세부 작업 내용', + `is_present` BOOLEAN DEFAULT TRUE COMMENT '출석 여부', + `absence_reason` TEXT NULL COMMENT '결석 사유', + `assigned_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY `uk_session_worker` (`session_id`, `worker_id`), + FOREIGN KEY (`session_id`) REFERENCES `tbm_sessions` (`session_id`) ON DELETE CASCADE, + FOREIGN KEY (`worker_id`) REFERENCES `workers` (`worker_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +``` + +#### 3. `tbm_safety_checks` - 안전 체크리스트 마스터 +```sql +CREATE TABLE `tbm_safety_checks` ( + `check_id` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + `check_category` VARCHAR(50) NOT NULL COMMENT '카테고리 (PPE, EQUIPMENT, ENVIRONMENT, EMERGENCY)', + `check_item` VARCHAR(200) NOT NULL COMMENT '체크 항목', + `description` TEXT NULL COMMENT '설명', + `display_order` INT DEFAULT 0 COMMENT '표시 순서', + `is_required` BOOLEAN DEFAULT TRUE COMMENT '필수 체크 여부', + `is_active` BOOLEAN DEFAULT TRUE COMMENT '활성 여부', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX `idx_check_category` (`check_category`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +``` + +**초기 데이터 (17개 항목)**: +- PPE (개인 보호 장비): 5개 +- EQUIPMENT (장비 점검): 4개 +- ENVIRONMENT (작업 환경): 4개 +- EMERGENCY (비상 대응): 3개 + +#### 4. `tbm_safety_records` - 안전 체크 기록 +```sql +CREATE TABLE `tbm_safety_records` ( + `record_id` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + `session_id` INT UNSIGNED NOT NULL COMMENT 'TBM 세션 ID', + `check_id` INT UNSIGNED NOT NULL COMMENT '체크 항목 ID', + `is_checked` BOOLEAN DEFAULT FALSE COMMENT '체크 여부', + `notes` TEXT NULL COMMENT '비고/특이사항', + `checked_by` INT NULL COMMENT '체크한 user_id', + `checked_at` TIMESTAMP NULL COMMENT '체크 시간', + UNIQUE KEY `uk_session_check` (`session_id`, `check_id`), + FOREIGN KEY (`session_id`) REFERENCES `tbm_sessions` (`session_id`) ON DELETE CASCADE, + FOREIGN KEY (`check_id`) REFERENCES `tbm_safety_checks` (`check_id`), + FOREIGN KEY (`checked_by`) REFERENCES `users` (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +``` + +#### 5. `team_handovers` - 작업 인계 +```sql +CREATE TABLE `team_handovers` ( + `handover_id` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + `session_id` INT UNSIGNED NOT NULL COMMENT 'TBM 세션 ID', + `from_leader_id` INT NOT NULL COMMENT '인계자 worker_id', + `to_leader_id` INT NOT NULL COMMENT '인수자 worker_id', + `handover_date` DATE NOT NULL COMMENT '인계 날짜', + `handover_time` TIME NULL COMMENT '인계 시간', + `reason` ENUM('half_day', 'early_leave', 'emergency', 'other') NOT NULL COMMENT '인계 사유', + `handover_notes` TEXT NULL COMMENT '인계 내용', + `worker_ids` TEXT NULL COMMENT '인계하는 작업자 IDs (JSON array)', + `is_confirmed` BOOLEAN DEFAULT FALSE COMMENT '인수 확인 여부', + `confirmed_at` TIMESTAMP NULL COMMENT '인수 확인 시간', + `confirmed_by` INT NULL COMMENT '인수 확인자 user_id', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX `idx_session_handover_date` (`session_id`, `handover_date`), + FOREIGN KEY (`session_id`) REFERENCES `tbm_sessions` (`session_id`) ON DELETE CASCADE, + FOREIGN KEY (`from_leader_id`) REFERENCES `workers` (`worker_id`), + FOREIGN KEY (`to_leader_id`) REFERENCES `workers` (`worker_id`), + FOREIGN KEY (`confirmed_by`) REFERENCES `users` (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +``` + +--- + +## 🚀 배포 절차 + +### 1. 데이터베이스 마이그레이션 실행 + +```bash +# 본 서버에서 실행 +cd /path/to/api.hyungi.net + +# 환경 변수 설정 (필요 시) +export DB_HOST=your_db_host +export DB_PORT=your_db_port +export DB_USER=your_db_user +export DB_PASSWORD=your_db_password +export DB_NAME=hyungi + +# 마이그레이션 실행 +npm run db:migrate + +# 또는 직접 실행 +npx knex migrate:latest --knexfile knexfile.js +``` + +### 2. 마이그레이션 확인 + +```bash +# 마이그레이션 상태 확인 +npx knex migrate:status --knexfile knexfile.js + +# 테이블 생성 확인 +mysql -u root -p -e "SHOW TABLES LIKE 'tbm%'" hyungi +mysql -u root -p -e "SHOW TABLES LIKE 'team_handovers'" hyungi + +# 안전 체크리스트 초기 데이터 확인 +mysql -u root -p -e "SELECT check_category, COUNT(*) as count FROM tbm_safety_checks GROUP BY check_category" hyungi +``` + +예상 결과: +``` ++----------------+-------+ +| check_category | count | ++----------------+-------+ +| PPE | 5 | +| EQUIPMENT | 4 | +| ENVIRONMENT | 4 | +| EMERGENCY | 3 | ++----------------+-------+ +``` + +### 3. API 서버 재시작 + +```bash +# PM2 사용 시 +pm2 restart api-hyungi + +# 또는 직접 재시작 +pm2 stop api-hyungi +pm2 start ecosystem.config.js --env production +``` + +### 4. 페이지 권한 확인 + +```bash +# TBM 페이지가 pages 테이블에 등록되었는지 확인 +mysql -u root -p -e "SELECT * FROM pages WHERE page_key='tbm'\G" hyungi +``` + +예상 결과: +``` + page_id: [auto_increment] + page_key: tbm + page_name: TBM 관리 + page_path: /pages/work/tbm.html + category: work + description: Tool Box Meeting - 아침 안전 회의 및 팀 구성 관리 + is_admin_only: 0 + display_order: 10 +``` + +--- + +## 📡 API 엔드포인트 + +### TBM 세션 관리 +- `POST /api/tbm/sessions` - TBM 세션 생성 +- `GET /api/tbm/sessions/date/:date` - 특정 날짜의 TBM 세션 목록 +- `GET /api/tbm/sessions/:sessionId` - TBM 세션 상세 조회 +- `PUT /api/tbm/sessions/:sessionId` - TBM 세션 수정 +- `POST /api/tbm/sessions/:sessionId/complete` - TBM 세션 완료 + +### 팀 구성 관리 +- `POST /api/tbm/sessions/:sessionId/team` - 팀원 추가 (단일) +- `POST /api/tbm/sessions/:sessionId/team/batch` - 팀원 일괄 추가 +- `GET /api/tbm/sessions/:sessionId/team` - 팀 구성 조회 +- `DELETE /api/tbm/sessions/:sessionId/team/:workerId` - 팀원 제거 + +### 안전 체크리스트 +- `GET /api/tbm/safety-checks` - 모든 안전 체크 항목 조회 +- `GET /api/tbm/sessions/:sessionId/safety` - 안전 체크 기록 조회 +- `POST /api/tbm/sessions/:sessionId/safety` - 안전 체크 일괄 저장 + +### 작업 인계 +- `POST /api/tbm/handovers` - 작업 인계 생성 +- `POST /api/tbm/handovers/:handoverId/confirm` - 작업 인계 확인 +- `GET /api/tbm/handovers/date/:date` - 특정 날짜의 인계 목록 +- `GET /api/tbm/handovers/pending` - 나에게 온 미확인 인계 건 + +### 통계 및 리포트 +- `GET /api/tbm/statistics/tbm?startDate=&endDate=` - TBM 통계 +- `GET /api/tbm/statistics/leaders?startDate=&endDate=` - 리더별 통계 + +--- + +## 🔐 권한 설정 + +### 1. 관리자가 페이지 권한 설정 +1. 관리자 계정으로 로그인 +2. `/pages/admin/page-access.html` 접속 +3. 권한을 부여할 사용자 선택 +4. "TBM 관리" 페이지 체크 +5. 저장 + +### 2. 기본 권한 (권장) +- **그룹장 (Leader)**: TBM 페이지 접근 권한 부여 필요 +- **관리자 (Admin)**: 자동으로 모든 페이지 접근 가능 +- **일반 작업자 (User)**: 필요에 따라 부여 + +--- + +## 📁 파일 구조 + +### 백엔드 (API) +``` +api.hyungi.net/ +├── db/migrations/ +│ ├── 20260120000000_create_tbm_system.js # TBM 테이블 생성 +│ └── 20260120000001_add_tbm_page.js # TBM 페이지 등록 +├── models/ +│ └── tbmModel.js # TBM 데이터 모델 +├── controllers/ +│ └── tbmController.js # TBM 컨트롤러 +├── routes/ +│ └── tbmRoutes.js # TBM 라우트 +└── config/ + └── routes.js # 라우트 등록 (수정됨) +``` + +### 프론트엔드 (Web UI) +``` +web-ui/ +├── pages/work/ +│ └── tbm.html # TBM 페이지 +└── js/ + └── tbm.js # TBM JavaScript (예정) +``` + +--- + +## ⚠️ 주의사항 + +### 1. 외래 키 제약 조건 +- `workers` 테이블의 `worker_id`는 **signed INT(11)** +- `users` 테이블의 `user_id`는 **signed INT(11)** +- `projects` 테이블의 PK는 `project_id` (NOT `id`) +- 외래 키 컬럼은 반드시 **signed INT**로 선언 (unsigned 사용 금지) + +### 2. 데이터 정합성 +- TBM 세션 삭제 시 관련 팀 구성, 안전 체크 기록 자동 삭제 (CASCADE) +- 작업자 삭제 시 관련 TBM 세션도 삭제됨 (workers 테이블의 CASCADE 설정) + +### 3. 백업 +마이그레이션 실행 전 **반드시 데이터베이스 백업**: +```bash +mysqldump -u root -p hyungi > backup_before_tbm_$(date +%Y%m%d_%H%M%S).sql +``` + +### 4. 롤백 +문제 발생 시 롤백: +```bash +# 한 단계 롤백 +npx knex migrate:rollback --knexfile knexfile.js + +# 또는 백업 복구 +mysql -u root -p hyungi < backup_before_tbm_YYYYMMDD_HHMMSS.sql +``` + +--- + +## ✅ 배포 체크리스트 + +배포 전 확인: +- [ ] 데이터베이스 백업 완료 +- [ ] 마이그레이션 파일 본 서버에 복사 완료 +- [ ] 환경 변수 설정 확인 (DB 접속 정보) +- [ ] 마이그레이션 실행 완료 +- [ ] 5개 테이블 생성 확인 +- [ ] tbm_safety_checks 초기 데이터 (17개) 확인 +- [ ] pages 테이블에 TBM 페이지 등록 확인 +- [ ] API 서버 재시작 완료 +- [ ] API 엔드포인트 테스트 (최소 1개) +- [ ] TBM 페이지 접속 테스트 +- [ ] 권한 설정 테스트 (그룹장 계정) + +--- + +## 🔍 테스트 방법 + +### 1. API 테스트 +```bash +# 안전 체크 항목 조회 +curl -X GET http://localhost:20005/api/tbm/safety-checks \ + -H "Authorization: Bearer YOUR_TOKEN" + +# 오늘 날짜의 TBM 세션 조회 +curl -X GET http://localhost:20005/api/tbm/sessions/date/2026-01-20 \ + -H "Authorization: Bearer YOUR_TOKEN" +``` + +### 2. 웹 페이지 테스트 +1. 그룹장 계정으로 로그인 +2. `/pages/work/tbm.html` 접속 +3. "새 TBM 시작" 버튼 클릭 +4. TBM 세션 생성 및 팀 구성 + +--- + +## 📞 문제 해결 + +### 문제 1: 마이그레이션 실패 (errno: 150) +**원인**: 외래 키 제약 조건 오류 (데이터 타입 불일치) +**해결**: 마이그레이션 파일에서 외래 키 컬럼을 signed INT로 수정 + +### 문제 2: API 엔드포인트 404 +**원인**: 라우트 등록 누락 또는 서버 미재시작 +**해결**: +```bash +pm2 logs api-hyungi --lines 50 # 로그 확인 +pm2 restart api-hyungi # 서버 재시작 +``` + +### 문제 3: TBM 페이지 접근 불가 +**원인**: 페이지 권한 미설정 +**해결**: 관리자가 `/pages/admin/page-access.html`에서 권한 부여 + +--- + +## 📝 변경 이력 + +### v1.0.0 (2026-01-20) +- TBM 시스템 초기 배포 +- 5개 테이블 생성 +- 17개 안전 체크리스트 항목 +- API 엔드포인트 17개 +- 페이지 권한 시스템 연동 + +--- + +## 📚 관련 문서 + +- [작업자-계정 연동 가이드](./worker-account-integration.md) +- [페이지 권한 관리 가이드](./page-access-management.md) +- [데이터베이스 스키마](./database-schema.md) + +--- + +**작성자**: Claude +**최종 수정일**: 2026-01-20 diff --git a/web-ui/css/design-system.css b/web-ui/css/design-system.css index eb9572d..ee1b15c 100644 --- a/web-ui/css/design-system.css +++ b/web-ui/css/design-system.css @@ -58,6 +58,28 @@ --info-500: #03a9f4; --info-700: #0288d1; + /* 따뜻한 중성 색상 (베이지/크림) */ + --warm-50: #fafaf9; /* 매우 밝은 크림 */ + --warm-100: #f5f5f4; /* 밝은 크림 */ + --warm-200: #e7e5e4; /* 베이지 */ + --warm-300: #d6d3d1; /* 중간 베이지 */ + --warm-400: #a8a29e; /* 진한 베이지 */ + --warm-500: #78716c; /* 그레이 베이지 */ + + /* 부드러운 작업 상태 색상 (눈이 편한 톤) */ + --status-success-bg: #dcfce7; /* 부드러운 초록 배경 */ + --status-success-text: #16a34a; /* 부드러운 초록 텍스트 */ + --status-info-bg: #e0f2fe; /* 부드러운 하늘색 배경 */ + --status-info-text: #0284c7; /* 부드러운 하늘색 텍스트 */ + --status-warning-bg: #fef3c7; /* 부드러운 노랑 배경 */ + --status-warning-text: #ca8a04; /* 부드러운 노랑 텍스트 */ + --status-error-bg: #fee2e2; /* 부드러운 빨강 배경 */ + --status-error-text: #dc2626; /* 부드러운 빨강 텍스트 */ + --status-critical-bg: #fecaca; /* 진한 빨강 배경 */ + --status-critical-text: #b91c1c; /* 진한 빨강 텍스트 */ + --status-vacation-bg: #fed7aa; /* 부드러운 주황 배경 */ + --status-vacation-text: #ea580c; /* 부드러운 주황 텍스트 */ + /* 배경 색상 */ --bg-primary: #ffffff; --bg-secondary: #f8fafc; diff --git a/web-ui/css/modern-dashboard.css b/web-ui/css/modern-dashboard.css index 29d5858..c9059cf 100644 --- a/web-ui/css/modern-dashboard.css +++ b/web-ui/css/modern-dashboard.css @@ -1965,3 +1965,364 @@ color: #6b7280; width: 100%; } + +/* ========== 작업 현황 테이블 ========== */ +.work-status-table-container { + background: white; + border-radius: var(--radius-lg); + overflow: hidden; + border: 1px solid #d1d5db; + box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.05); +} + +.work-status-table { + width: 100%; + border-collapse: collapse; + background: white; +} + +.work-status-table thead { + background: #f9fafb; + border-bottom: 2px solid #d1d5db; +} + +.work-status-table th { + padding: 1rem 1.25rem; + text-align: left; + font-weight: 600; + color: #374151; + font-size: 0.875rem; + letter-spacing: 0.025em; + text-transform: uppercase; + border-right: 1px solid #e5e7eb; +} + +.work-status-table th:last-child { + border-right: none; +} + +.work-status-table tbody tr { + background: white; + border-bottom: 1px solid #e5e7eb; + transition: var(--transition-fast); +} + +.work-status-table tbody tr:nth-child(even) { + background: #fafafa; +} + +.work-status-table tbody tr:hover { + background: #f3f4f6; +} + +.work-status-table tbody tr:last-child { + border-bottom: none; +} + +.work-status-table td { + padding: 1rem 1.25rem; + font-size: 0.9375rem; + line-height: 1.5; + vertical-align: middle; + border-right: 1px solid #f3f4f6; + color: #1f2937; +} + +.work-status-table td:last-child { + border-right: none; +} + +/* 로딩 상태 */ +.work-status-table .loading-state { + text-align: center; + padding: var(--space-12); + color: var(--text-tertiary); +} + +.work-status-table .loading-state .spinner { + margin: 0 auto var(--space-4); +} + +/* 작업자 정보 셀 */ +.worker-info { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.worker-avatar { + width: 36px; + height: 36px; + border-radius: var(--radius-full); + background: linear-gradient(135deg, #3b82f6, #2563eb); + color: white; + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + font-size: 0.875rem; + flex-shrink: 0; +} + +.worker-details { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.worker-name { + font-weight: 600; + color: #111827; + font-size: 0.9375rem; + line-height: 1.2; +} + +.worker-job { + font-size: 0.8125rem; + color: #6b7280; + line-height: 1.2; +} + +/* 상태 배지 */ +.worker-status { + white-space: nowrap; +} + +.status-badge { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.875rem; + border-radius: 0.375rem; + font-size: 0.875rem; + font-weight: 600; + white-space: nowrap; + border: 1px solid; +} + +.status-badge .status-icon { + width: 16px; + height: 16px; + flex-shrink: 0; +} + +.status-badge.status-success { + background: #dcfce7; + color: #166534; + border-color: #86efac; +} + +.status-badge.status-info { + background: #dbeafe; + color: #1e40af; + border-color: #93c5fd; +} + +.status-badge.status-warning { + background: #fef3c7; + color: #92400e; + border-color: #fde047; +} + +.status-badge.status-error { + background: #fee2e2; + color: #991b1b; + border-color: #fca5a5; +} + +.status-badge.status-critical { + background: #fecaca; + color: #7f1d1d; + border-color: #f87171; +} + +.status-badge.status-vacation { + background: #fed7aa; + color: #9a3412; + border-color: #fdba74; +} + +.status-badge.status-incomplete { + background: #f3f4f6; + color: #374151; + border-color: #d1d5db; +} + +.status-badge.status-overtime { + background: #e0e7ff; + color: #3730a3; + border-color: #a5b4fc; +} + +/* 작업시간 셀 */ +.worker-hours { + white-space: nowrap; +} + +.hours-value { + font-weight: 600; + color: #111827; + font-size: 1rem; +} + +.hours-value.hours-warning { + color: #dc2626; + font-weight: 700; +} + +/* 작업건수 셀 */ +.worker-tasks { + white-space: nowrap; +} + +.tasks-summary { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.task-count { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; +} + +.task-label { + color: #6b7280; + font-weight: 500; +} + +.task-value { + color: #111827; + font-weight: 600; +} + +.task-count.task-error .task-label { + color: #dc2626; +} + +.task-count.task-error .task-value { + color: #991b1b; + font-weight: 700; +} + +/* 액션 버튼 */ +.worker-actions { + white-space: nowrap; +} + +.action-buttons { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.action-btn { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.5rem 0.875rem; + border-radius: 0.375rem; + font-size: 0.875rem; + font-weight: 600; + border: 1px solid; + cursor: pointer; + transition: all 0.15s ease; + white-space: nowrap; +} + +.action-btn svg { + width: 14px; + height: 14px; + flex-shrink: 0; +} + +.action-btn.btn-edit { + background: #3b82f6; + color: white; + border-color: #3b82f6; +} + +.action-btn.btn-edit:hover { + background: #2563eb; + border-color: #2563eb; + transform: translateY(-1px); + box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1); +} + +.action-btn.btn-vacation { + background: white; + color: #ea580c; + border-color: #ea580c; +} + +.action-btn.btn-vacation:hover { + background: #ea580c; + color: white; +} + +.action-btn.btn-confirm { + background: white; + color: #16a34a; + border-color: #16a34a; +} + +.action-btn.btn-confirm:hover { + background: #16a34a; + color: white; +} + +/* 반응형 디자인 */ +@media (max-width: 768px) { + .work-status-table { + display: block; + } + + .work-status-table thead { + display: none; + } + + .work-status-table tbody, + .work-status-table tr, + .work-status-table td { + display: block; + width: 100%; + } + + .work-status-table tr { + margin-bottom: 1rem; + border: 2px solid #d1d5db; + border-radius: 0.5rem; + padding: 1rem; + background: white; + } + + .work-status-table td { + padding: var(--space-3) 0; + border-bottom: 1px solid var(--warm-100); + } + + .work-status-table td:last-child { + border-bottom: none; + } + + .work-status-table td::before { + content: attr(data-label); + font-weight: var(--font-semibold); + display: block; + margin-bottom: var(--space-2); + color: var(--text-secondary); + font-size: var(--text-sm); + text-transform: uppercase; + } + + .worker-actions { + flex-direction: column; + } + + .action-btn { + width: 100%; + text-align: center; + } +} diff --git a/web-ui/js/load-navbar.js b/web-ui/js/load-navbar.js index d40c5fd..c1edcf7 100644 --- a/web-ui/js/load-navbar.js +++ b/web-ui/js/load-navbar.js @@ -34,6 +34,9 @@ function processNavbarDom(doc) { * @param {string} userRole - 현재 사용자의 역할 */ function filterMenuByRole(doc, userRole) { + // 대소문자 구분 없이 처리 + const userRoleLower = (userRole || '').toLowerCase(); + const selectors = [ { role: 'admin', selector: '.admin-only' }, { role: 'system', selector: '.system-only' }, @@ -41,7 +44,7 @@ function filterMenuByRole(doc, userRole) { ]; selectors.forEach(({ role, selector }) => { - if (userRole !== role && userRole !== 'system') { + if (userRoleLower !== role && userRoleLower !== 'system') { doc.querySelectorAll(selector).forEach(el => el.remove()); } }); @@ -54,7 +57,9 @@ function filterMenuByRole(doc, userRole) { */ function populateUserInfo(doc, user) { const displayName = user.name || user.username; - const roleName = ROLE_NAMES[user.role] || ROLE_NAMES.default; + // 대소문자 구분 없이 처리 + const roleLower = (user.role || '').toLowerCase(); + const roleName = ROLE_NAMES[roleLower] || ROLE_NAMES.default; const elements = { 'userName': displayName, diff --git a/web-ui/js/modern-dashboard.js b/web-ui/js/modern-dashboard.js index c702e51..66d247e 100644 --- a/web-ui/js/modern-dashboard.js +++ b/web-ui/js/modern-dashboard.js @@ -287,100 +287,143 @@ function updateSummaryCard(element, value, unit) { } } +// ========== SVG 아이콘 정의 ========== // +const SVG_ICONS = { + complete: ``, + + overtime: ``, + + vacation: ``, + + partial: ``, + + incomplete: ``, + + warning: `` +}; + // ========== 작업 현황 표시 (작업자 중심) ========== // function displayWorkStatus() { - if (!elements.workStatusContainer) return; - + const tableBody = document.getElementById('workStatusTableBody'); + if (!tableBody) return; + // 모든 작업자 데이터 가져오기 (작업이 없는 작업자도 포함) const allWorkers = workersData || []; - + if (allWorkers.length === 0) { - elements.workStatusContainer.innerHTML = ` -
시스템에 작업자가 등록되어 있지 않습니다.
-등록된 작업자가 없습니다
+${worker.job_type || '작업자'}
-필터 조건에 맞는 사용자가 없습니다.
+작업자에게 특정 페이지 접근 권한을 부여하거나 회수합니다
+| 사용자명 | +아이디 | +역할 | +작업자 | +접근 가능 페이지 | +관리 | +
|---|---|---|---|---|---|
|
+
+ 사용자 목록을 불러오는 중... + |
+ |||||