feat: TBM 시스템 구축 및 페이지 권한 관리 기능 추가

## 주요 변경사항

### 1. TBM (Tool Box Meeting) 시스템 구축
- **데이터베이스 스키마** (5개 테이블 생성)
  - tbm_sessions: TBM 세션 관리
  - tbm_team_assignments: 팀 구성 관리
  - tbm_safety_checks: 안전 체크리스트 마스터 (17개 항목)
  - tbm_safety_records: 안전 체크 기록
  - team_handovers: 작업 인계 관리

- **API 엔드포인트** (17개)
  - TBM 세션 CRUD
  - 팀 구성 관리
  - 안전 체크리스트
  - 작업 인계
  - 통계 및 리포트

- **프론트엔드**
  - TBM 관리 페이지 (/pages/work/tbm.html)
  - 모달 기반 UI (세션 생성, 팀 구성, 안전 체크)

### 2. 페이지 권한 관리 시스템
- 페이지별 접근 권한 설정 기능
- 관리자 페이지 (/pages/admin/page-access.html)
- 사용자별 페이지 권한 부여/회수
- TBM 페이지 등록 및 권한 연동

### 3. 네비게이션 role 표시 버그 수정
- load-navbar.js: case-insensitive role 매칭 적용
- JWT의 "Admin" role이 "관리자"로 정상 표시
- admin-only 메뉴 항목 정상 표시

### 4. 대시보드 개선
- 작업 현황 테이블 가독성 향상
- 고대비 색상 및 명확한 구분선 적용
- 이모지 제거 및 SVG 아이콘 적용

### 5. 문서화
- TBM 배포 가이드 작성 (docs/TBM_DEPLOYMENT_GUIDE.md)
- 데이터베이스 스키마 상세 기록
- 배포 절차 및 체크리스트 제공

## 기술 스택
- Backend: Node.js, Express, MySQL
- Frontend: Vanilla JavaScript, HTML5, CSS3
- Database: MySQL (InnoDB)

## 파일 변경사항

### 신규 파일
- api.hyungi.net/db/migrations/20260120000000_create_tbm_system.js
- api.hyungi.net/db/migrations/20260120000001_add_tbm_page.js
- api.hyungi.net/models/tbmModel.js
- api.hyungi.net/models/pageAccessModel.js
- api.hyungi.net/controllers/tbmController.js
- api.hyungi.net/controllers/pageAccessController.js
- api.hyungi.net/routes/tbmRoutes.js
- web-ui/pages/work/tbm.html
- web-ui/pages/admin/page-access.html
- web-ui/js/page-access-management.js
- docs/TBM_DEPLOYMENT_GUIDE.md

### 수정 파일
- api.hyungi.net/config/routes.js (TBM 라우트 추가)
- web-ui/js/load-navbar.js (role 매칭 버그 수정)
- web-ui/pages/admin/workers.html (HTML 구조 수정)
- web-ui/pages/dashboard.html (이모지 제거)
- web-ui/css/design-system.css (색상 팔레트 추가)
- web-ui/css/modern-dashboard.css (가독성 개선)
- web-ui/js/modern-dashboard.js (SVG 아이콘 적용)

## 배포 시 주의사항
⚠️ 본 서버 배포 시 반드시 마이그레이션 실행 필요:
```bash
npm run db:migrate
```

상세한 배포 절차는 docs/TBM_DEPLOYMENT_GUIDE.md 참조

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-01-20 15:38:17 +09:00
parent 0ec099b493
commit 4d0c4c0801
18 changed files with 3321 additions and 107 deletions

View File

@@ -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 문서

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 시스템 테이블 제거 완료');
};

View File

@@ -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 페이지 제거 완료');
};

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;