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 = ` -
-
👥
-

등록된 작업자가 없습니다

-

시스템에 작업자가 등록되어 있지 않습니다.

-
+ tableBody.innerHTML = ` + + +

등록된 작업자가 없습니다

+ + `; return; } - + // 작업자별 상황 분석 const workerStatusList = allWorkers.map(worker => { const todayWork = workData.filter(w => w.worker_id === worker.worker_id); const totalHours = todayWork.reduce((sum, w) => sum + parseFloat(w.work_hours || 0), 0); - + // 휴가/연차 제외한 실제 작업시간 계산 const actualWorkHours = todayWork .filter(w => w.project_id !== 13) // 연차/휴무 프로젝트 제외 .reduce((sum, w) => sum + parseFloat(w.work_hours || 0), 0); - + const hasError = todayWork.some(w => w.work_status_id === 2); - + // 정규 작업과 에러 작업 건수 분리 const regularWorkCount = todayWork.filter(w => w.project_id !== 13 && w.work_status_id !== 2).length; const errorWorkCount = todayWork.filter(w => w.project_id !== 13 && w.work_status_id === 2).length; - + // 상태 판단 로직 (개선된 버전) let status = 'incomplete'; let statusText = '미입력'; let statusBadge = '미입력'; + let statusClass = 'incomplete'; let vacationType = null; - + // 휴가 처리된 경우 확인 (프로젝트 ID 13 = "연차/휴무" 또는 설명에 휴가 키워드) - const hasVacationRecord = todayWork.some(w => + const hasVacationRecord = todayWork.some(w => w.project_id === 13 || // 연차/휴무 프로젝트 (w.description && ( - w.description.includes('연차') || - w.description.includes('반차') || + w.description.includes('연차') || + w.description.includes('반차') || w.description.includes('휴가') )) ); - + // 연차/휴무 프로젝트의 시간 계산 const vacationHours = todayWork .filter(w => w.project_id === 13) .reduce((sum, w) => sum + parseFloat(w.work_hours || 0), 0); - + if (totalHours > 12) { status = 'overtime-warning'; statusText = '초과근무 확인필요'; statusBadge = '확인필요'; + statusClass = 'warning'; } else if (hasVacationRecord && vacationHours > 0) { // 연차/휴무 시간에 따른 상태 결정 if (vacationHours === 8) { status = 'vacation-full'; statusText = '연차'; statusBadge = '연차'; + statusClass = 'vacation'; } else if (vacationHours === 6) { status = 'vacation-half-half'; statusText = '조퇴'; statusBadge = '조퇴'; + statusClass = 'vacation'; } else if (vacationHours === 4) { status = 'vacation-half'; statusText = '반차'; statusBadge = '반차'; + statusClass = 'vacation'; } else if (vacationHours === 2) { status = 'vacation-quarter'; statusText = '반반차'; statusBadge = '반반차'; + statusClass = 'vacation'; } } else if (totalHours > 8) { // 8시간 초과 - 연장근로 status = 'overtime'; statusText = '연장근로'; statusBadge = '연장근로'; + statusClass = 'overtime'; } else if (totalHours === 8) { // 정확히 8시간 - 정시근로 status = 'complete'; statusText = '정시근로'; statusBadge = '정시근로'; + statusClass = 'success'; } else if (totalHours > 0) { // 0시간 초과 8시간 미만 - 부분 입력 status = 'partial'; statusText = '부분 입력'; statusBadge = '부분입력'; - + statusClass = 'info'; + // 휴가 처리 필요 여부 판단 if (totalHours === 0) { vacationType = 'full'; @@ -394,9 +437,10 @@ function displayWorkStatus() { status = 'incomplete'; statusText = '미입력'; statusBadge = '미입력'; + statusClass = 'incomplete'; vacationType = 'full'; } - + return { ...worker, todayWork, @@ -408,79 +452,92 @@ function displayWorkStatus() { status, statusText, statusBadge, + statusClass, vacationType }; }); - - elements.workStatusContainer.innerHTML = ` -
-
-
-

작업자별 현황

- ${selectedDate} -
-
- 정시근로 - 연장근로 - 휴가 - 부분입력 - 미입력 -
-
-
- ${workerStatusList.map(worker => ` -
-
-
- ${worker.worker_name.charAt(0)} -
-
-

${worker.worker_name}

-

${worker.job_type || '작업자'}

-
-
- -
- ${worker.statusBadge} -
- -
-
- 작업시간 - ${worker.actualWorkHours.toFixed(1)}h -
-
- 정규 - ${worker.regularWorkCount}건 -
- ${worker.errorWorkCount > 0 ? ` -
- 에러 - ${worker.errorWorkCount}건 -
- ` : ''} -
- -
- - ${worker.vacationType ? ` - - ` : ''} - ${worker.status === 'overtime-warning' ? ` - - ` : ''} -
+ + // 테이블 행 렌더링 + tableBody.innerHTML = workerStatusList.map(worker => { + // 상태에 따른 SVG 아이콘 선택 + let iconKey = 'incomplete'; + if (worker.status === 'overtime-warning') iconKey = 'warning'; + else if (worker.status.startsWith('vacation')) iconKey = 'vacation'; + else if (worker.status === 'overtime') iconKey = 'overtime'; + else if (worker.status === 'complete') iconKey = 'complete'; + else if (worker.status === 'partial') iconKey = 'partial'; + + return ` + + +
+ ${worker.worker_name.charAt(0)}
- `).join('')} -
-
- `; +
+
${worker.worker_name}
+
${worker.job_type || '작업자'}
+
+ + + + + ${SVG_ICONS[iconKey]} + ${worker.statusBadge} + + + + + ${worker.actualWorkHours.toFixed(1)}h + + + +
+ + 정규: + ${worker.regularWorkCount}건 + + ${worker.errorWorkCount > 0 ? ` + + 에러: + ${worker.errorWorkCount}건 + + ` : ''} +
+ + + +
+ + ${worker.vacationType ? ` + + ` : ''} + ${worker.status === 'overtime-warning' ? ` + + ` : ''} +
+ + + `; + }).join(''); } function groupWorkDataByProject() { diff --git a/web-ui/js/page-access-management.js b/web-ui/js/page-access-management.js new file mode 100644 index 0000000..fb308c5 --- /dev/null +++ b/web-ui/js/page-access-management.js @@ -0,0 +1,338 @@ +// page-access-management.js - 페이지 권한 관리 + +// 전역 변수 +let allUsers = []; +let allPages = []; +let currentUserId = null; +let currentFilter = 'all'; + +// DOM이 로드되면 초기화 +document.addEventListener('DOMContentLoaded', async () => { + console.log('🚀 페이지 권한 관리 시스템 초기화'); + + // API 함수가 로드될 때까지 대기 + let retryCount = 0; + while (!window.apiCall && retryCount < 50) { + await new Promise(resolve => setTimeout(resolve, 100)); + retryCount++; + } + + if (!window.apiCall) { + showToast('시스템을 초기화할 수 없습니다. 페이지를 새로고침해주세요.', 'error'); + return; + } + + // 이벤트 리스너 설정 + setupEventListeners(); + + // 데이터 로드 + await loadInitialData(); +}); + +// 이벤트 리스너 설정 +function setupEventListeners() { + // 필터 버튼 + document.querySelectorAll('.filter-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active')); + e.target.classList.add('active'); + currentFilter = e.target.dataset.filter; + filterUsers(); + }); + }); + + // 저장 버튼 + const saveBtn = document.getElementById('savePageAccessBtn'); + if (saveBtn) { + saveBtn.addEventListener('click', savePageAccess); + } +} + +// 초기 데이터 로드 +async function loadInitialData() { + try { + // 페이지 목록 로드 + const pagesResponse = await window.apiCall('/pages'); + if (pagesResponse && pagesResponse.success) { + allPages = pagesResponse.data; + console.log('✅ 페이지 목록 로드:', allPages.length + '개'); + } + + // 사용자 목록 로드 - 계정이 있는 작업자만 + const workersResponse = await window.apiCall('/workers?limit=1000'); + if (workersResponse) { + const workers = Array.isArray(workersResponse) ? workersResponse : (workersResponse.data || []); + + // user_id가 있고 활성 상태인 작업자만 필터링 + const usersWithAccounts = workers.filter(w => w.user_id && w.is_active); + + // 각 사용자의 페이지 권한 수 조회 + allUsers = await Promise.all(usersWithAccounts.map(async (worker) => { + try { + const accessResponse = await window.apiCall(`/users/${worker.user_id}/page-access`); + const grantedPagesCount = accessResponse && accessResponse.success + ? accessResponse.data.pageAccess.filter(p => p.can_access).length + : 0; + + return { + user_id: worker.user_id, + username: worker.username || 'N/A', + name: worker.name || worker.worker_name, + role_name: worker.role_name || 'User', + worker_name: worker.worker_name, + worker_id: worker.worker_id, + granted_pages_count: grantedPagesCount + }; + } catch (error) { + console.error(`권한 조회 오류 (user_id: ${worker.user_id}):`, error); + return { + ...worker, + granted_pages_count: 0 + }; + } + })); + + console.log('✅ 사용자 목록 로드:', allUsers.length + '명'); + displayUsers(); + } + + } catch (error) { + console.error('❌ 데이터 로드 오류:', error); + showToast('데이터를 불러오는 중 오류가 발생했습니다.', 'error'); + } +} + +// 사용자 목록 표시 +function displayUsers() { + const tbody = document.getElementById('usersTableBody'); + const emptyState = document.getElementById('emptyState'); + + if (allUsers.length === 0) { + tbody.innerHTML = ''; + emptyState.style.display = 'block'; + return; + } + + emptyState.style.display = 'none'; + + const filteredUsers = filterUsersByStatus(); + + if (filteredUsers.length === 0) { + tbody.innerHTML = ` + + +

필터 조건에 맞는 사용자가 없습니다.

+ + + `; + return; + } + + tbody.innerHTML = filteredUsers.map(user => ` + + +
+
+ ${(user.name || user.username).charAt(0)} +
+ ${user.name || user.username} +
+ + ${user.username} + + + ${user.role_name} + + + ${user.worker_name || '-'} + + + ${user.granted_pages_count}개 + + / ${allPages.length}개 + + + + + + `).join(''); +} + +// 사용자 필터링 +function filterUsersByStatus() { + if (currentFilter === 'all') { + return allUsers; + } else if (currentFilter === 'with-access') { + return allUsers.filter(u => u.granted_pages_count > 0); + } else if (currentFilter === 'no-access') { + return allUsers.filter(u => u.granted_pages_count === 0); + } + return allUsers; +} + +function filterUsers() { + displayUsers(); +} + +// 페이지 권한 설정 모달 열기 +async function openPageAccessModal(userId) { + currentUserId = userId; + const user = allUsers.find(u => u.user_id === userId); + + if (!user) { + showToast('사용자 정보를 찾을 수 없습니다.', 'error'); + return; + } + + // 모달 열기 + document.getElementById('pageAccessModal').style.display = 'flex'; + document.body.style.overflow = 'hidden'; + + // 사용자 정보 표시 + document.getElementById('modalUserInitial').textContent = (user.name || user.username).charAt(0); + document.getElementById('modalUserName').textContent = user.name || user.username; + document.getElementById('modalUsername').textContent = user.username; + document.getElementById('modalWorkerName').textContent = user.worker_name || '작업자 정보 없음'; + + // 페이지 목록 로드 + try { + const response = await window.apiCall(`/users/${userId}/page-access`); + + if (response && response.success) { + const pageAccess = response.data.pageAccess; + renderPageList(pageAccess); + } else { + showToast('페이지 권한 정보를 불러올 수 없습니다.', 'error'); + } + } catch (error) { + console.error('페이지 권한 조회 오류:', error); + showToast('페이지 권한 정보를 불러오는 중 오류가 발생했습니다.', 'error'); + } +} + +// 페이지 목록 렌더링 +function renderPageList(pageAccess) { + const container = document.getElementById('pageListContainer'); + + // 카테고리별로 그룹화 + const grouped = {}; + pageAccess.forEach(page => { + const category = page.category || 'common'; + if (!grouped[category]) { + grouped[category] = []; + } + grouped[category].push(page); + }); + + const categoryNames = { + 'dashboard': '대시보드', + 'management': '관리', + 'common': '공통', + 'admin': '관리자', + 'work': '작업', + 'guest': '게스트' + }; + + container.innerHTML = Object.keys(grouped).map(category => ` +
+
+ ${categoryNames[category] || category} +
+ ${grouped[category].map(page => ` +
+ + ${page.is_default ? '기본 권한' : ''} +
+ `).join('')} +
+ `).join(''); +} + +// 페이지 권한 저장 +async function savePageAccess() { + if (!currentUserId) return; + + const checkboxes = document.querySelectorAll('.page-checkbox:not([disabled]):checked'); + const pageIds = Array.from(checkboxes).map(cb => parseInt(cb.dataset.pageId)); + + try { + document.getElementById('savePageAccessBtn').disabled = true; + document.getElementById('savePageAccessBtn').textContent = '저장 중...'; + + const response = await window.apiCall( + `/users/${currentUserId}/page-access`, + 'POST', + { pageIds, canAccess: true } + ); + + if (response && response.success) { + showToast('페이지 권한이 저장되었습니다.', 'success'); + closePageAccessModal(); + await loadInitialData(); // 목록 새로고침 + } else { + throw new Error(response.error || '저장에 실패했습니다.'); + } + } catch (error) { + console.error('페이지 권한 저장 오류:', error); + showToast('페이지 권한 저장 중 오류가 발생했습니다.', 'error'); + } finally { + document.getElementById('savePageAccessBtn').disabled = false; + document.getElementById('savePageAccessBtn').textContent = '저장'; + } +} + +// 모달 닫기 +function closePageAccessModal() { + document.getElementById('pageAccessModal').style.display = 'none'; + document.body.style.overflow = 'auto'; + currentUserId = null; +} + +// 토스트 알림 +function showToast(message, type = 'info', duration = 3000) { + const container = document.getElementById('toastContainer'); + if (!container) return; + + const toast = document.createElement('div'); + toast.className = `toast ${type}`; + + const iconMap = { + success: '✅', + error: '❌', + warning: '⚠️', + info: 'ℹ️' + }; + + toast.innerHTML = ` +
${iconMap[type] || 'ℹ️'}
+
${message}
+ + `; + + container.appendChild(toast); + + setTimeout(() => { + if (toast.parentElement) { + toast.remove(); + } + }, duration); +} + +// 전역 함수로 export +window.openPageAccessModal = openPageAccessModal; +window.closePageAccessModal = closePageAccessModal; diff --git a/web-ui/pages/admin/page-access.html b/web-ui/pages/admin/page-access.html new file mode 100644 index 0000000..15430ed --- /dev/null +++ b/web-ui/pages/admin/page-access.html @@ -0,0 +1,140 @@ + + + + + + 페이지 권한 관리 | (주)테크니컬코리아 + + + + + + + +
+ + + + +
+
+ + + +
+
+

+ 👥 + 사용자 목록 +

+
+ + + +
+
+ +
+
+ + + + + + + + + + + + + + + + +
사용자명아이디역할작업자접근 가능 페이지관리
+
+

사용자 목록을 불러오는 중...

+
+
+ + +
+
+
+
+
+ + + + + +
+ + + + + + + diff --git a/web-ui/pages/admin/workers.html b/web-ui/pages/admin/workers.html index 6a49566..cadc0e2 100644 --- a/web-ui/pages/admin/workers.html +++ b/web-ui/pages/admin/workers.html @@ -109,6 +109,7 @@
+ @@ -211,8 +212,7 @@ - - + diff --git a/web-ui/pages/dashboard.html b/web-ui/pages/dashboard.html index fce1fd5..9db6fc1 100644 --- a/web-ui/pages/dashboard.html +++ b/web-ui/pages/dashboard.html @@ -33,12 +33,11 @@
-

⚡ 빠른 작업

+

빠른 작업

-
📝

작업 보고서 작성

오늘의 작업 내용을 입력하고 관리합니다

@@ -47,7 +46,6 @@
-
📋

작업 현황 확인

팀원들의 작업 현황을 실시간으로 조회합니다

@@ -56,7 +54,6 @@
-
📈

작업 분석

작업 효율성 및 통계를 분석합니다

@@ -65,7 +62,6 @@
-
🔧

작업 관리

작업자 및 프로젝트를 관리합니다

@@ -82,22 +78,36 @@
-

📊 오늘의 작업 현황

+

오늘의 작업 현황

-
-
-
-

작업 현황을 불러오는 중...

-
+
+ + + + + + + + + + + + + + + +
작업자상태작업시간작업건수액션
+
+

작업 현황을 불러오는 중...

+
diff --git a/web-ui/pages/work/tbm.html b/web-ui/pages/work/tbm.html new file mode 100644 index 0000000..97ae32d --- /dev/null +++ b/web-ui/pages/work/tbm.html @@ -0,0 +1,236 @@ + + + + + + TBM 관리 | (주)테크니컬코리아 + + + + + + + + +
+ + + + +
+
+ + + +
+
+

TBM 세션 목록

+
+ + 📋 + 총 0개 + + + + 완료 0개 + +
+
+ +
+ +
+ + + +
+
+
+ + + + + + + + + + + + + + +
+
+ + + + +