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:
@@ -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 문서
|
||||
|
||||
200
api.hyungi.net/controllers/pageAccessController.js
Normal file
200
api.hyungi.net/controllers/pageAccessController.js
Normal 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;
|
||||
575
api.hyungi.net/controllers/tbmController.js
Normal file
575
api.hyungi.net/controllers/tbmController.js
Normal 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;
|
||||
158
api.hyungi.net/db/migrations/20260120000000_create_tbm_system.js
Normal file
158
api.hyungi.net/db/migrations/20260120000000_create_tbm_system.js
Normal 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 시스템 테이블 제거 완료');
|
||||
};
|
||||
33
api.hyungi.net/db/migrations/20260120000001_add_tbm_page.js
Normal file
33
api.hyungi.net/db/migrations/20260120000001_add_tbm_page.js
Normal 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 페이지 제거 완료');
|
||||
};
|
||||
160
api.hyungi.net/models/pageAccessModel.js
Normal file
160
api.hyungi.net/models/pageAccessModel.js
Normal 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;
|
||||
453
api.hyungi.net/models/tbmModel.js
Normal file
453
api.hyungi.net/models/tbmModel.js
Normal 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;
|
||||
71
api.hyungi.net/routes/tbmRoutes.js
Normal file
71
api.hyungi.net/routes/tbmRoutes.js
Normal 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;
|
||||
393
docs/TBM_DEPLOYMENT_GUIDE.md
Normal file
393
docs/TBM_DEPLOYMENT_GUIDE.md
Normal file
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -287,100 +287,143 @@ function updateSummaryCard(element, value, unit) {
|
||||
}
|
||||
}
|
||||
|
||||
// ========== SVG 아이콘 정의 ========== //
|
||||
const SVG_ICONS = {
|
||||
complete: `<svg class="status-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="20 6 9 17 4 12"></polyline>
|
||||
</svg>`,
|
||||
|
||||
overtime: `<svg class="status-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<polyline points="12 6 12 12 16 14"></polyline>
|
||||
</svg>`,
|
||||
|
||||
vacation: `<svg class="status-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path>
|
||||
<circle cx="12" cy="10" r="3"></circle>
|
||||
</svg>`,
|
||||
|
||||
partial: `<svg class="status-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"></path>
|
||||
</svg>`,
|
||||
|
||||
incomplete: `<svg class="status-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="15" y1="9" x2="9" y2="15"></line>
|
||||
<line x1="9" y1="9" x2="15" y2="15"></line>
|
||||
</svg>`,
|
||||
|
||||
warning: `<svg class="status-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
|
||||
<line x1="12" y1="9" x2="12" y2="13"></line>
|
||||
<line x1="12" y1="17" x2="12.01" y2="17"></line>
|
||||
</svg>`
|
||||
};
|
||||
|
||||
// ========== 작업 현황 표시 (작업자 중심) ========== //
|
||||
function displayWorkStatus() {
|
||||
if (!elements.workStatusContainer) return;
|
||||
|
||||
const tableBody = document.getElementById('workStatusTableBody');
|
||||
if (!tableBody) return;
|
||||
|
||||
// 모든 작업자 데이터 가져오기 (작업이 없는 작업자도 포함)
|
||||
const allWorkers = workersData || [];
|
||||
|
||||
|
||||
if (allWorkers.length === 0) {
|
||||
elements.workStatusContainer.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">👥</div>
|
||||
<h3>등록된 작업자가 없습니다</h3>
|
||||
<p>시스템에 작업자가 등록되어 있지 않습니다.</p>
|
||||
</div>
|
||||
tableBody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="5" class="empty-state">
|
||||
<p>등록된 작업자가 없습니다</p>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
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 = `
|
||||
<div class="worker-status-list">
|
||||
<div class="worker-status-header">
|
||||
<div class="header-title">
|
||||
<h3>작업자별 현황</h3>
|
||||
<span class="header-date">${selectedDate}</span>
|
||||
</div>
|
||||
<div class="status-legend">
|
||||
<span class="legend-item legend-complete">정시근로</span>
|
||||
<span class="legend-item legend-overtime">연장근로</span>
|
||||
<span class="legend-item legend-vacation">휴가</span>
|
||||
<span class="legend-item legend-partial">부분입력</span>
|
||||
<span class="legend-item legend-incomplete">미입력</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="worker-status-rows">
|
||||
${workerStatusList.map(worker => `
|
||||
<div class="worker-status-row ${worker.status}" data-worker-id="${worker.worker_id}">
|
||||
<div class="worker-basic-info">
|
||||
<div class="worker-avatar">
|
||||
<span>${worker.worker_name.charAt(0)}</span>
|
||||
</div>
|
||||
<div class="worker-details">
|
||||
<h4 class="worker-name">${worker.worker_name}</h4>
|
||||
<p class="worker-job">${worker.job_type || '작업자'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="worker-status-indicator">
|
||||
<span class="status-badge status-${worker.status}">${worker.statusBadge}</span>
|
||||
</div>
|
||||
|
||||
<div class="worker-stats-inline">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">작업시간</span>
|
||||
<span class="stat-value ${worker.actualWorkHours > 12 ? 'warning' : ''}">${worker.actualWorkHours.toFixed(1)}h</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">정규</span>
|
||||
<span class="stat-value">${worker.regularWorkCount}건</span>
|
||||
</div>
|
||||
${worker.errorWorkCount > 0 ? `
|
||||
<div class="stat-item error">
|
||||
<span class="stat-label">에러</span>
|
||||
<span class="stat-value">${worker.errorWorkCount}건</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
<div class="worker-actions-inline">
|
||||
<button class="btn btn-sm btn-primary worker-edit-btn" onclick="openWorkerModal(${worker.worker_id}, '${worker.worker_name}')">
|
||||
작업입력
|
||||
</button>
|
||||
${worker.vacationType ? `
|
||||
<button class="btn btn-sm btn-secondary vacation-btn" onclick="handleVacation(${worker.worker_id}, '${worker.vacationType}')">
|
||||
${worker.vacationType === 'full' ? '연차처리' : worker.vacationType === 'half' ? '반차처리' : '반반차처리'}
|
||||
</button>
|
||||
` : ''}
|
||||
${worker.status === 'overtime-warning' ? `
|
||||
<button class="btn btn-sm btn-warning confirm-overtime-btn" onclick="confirmOvertime(${worker.worker_id})">
|
||||
정상확인
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
// 테이블 행 렌더링
|
||||
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 `
|
||||
<tr data-worker-id="${worker.worker_id}">
|
||||
<td data-label="작업자" class="worker-info">
|
||||
<div class="worker-avatar">
|
||||
<span>${worker.worker_name.charAt(0)}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
<div class="worker-details">
|
||||
<div class="worker-name">${worker.worker_name}</div>
|
||||
<div class="worker-job">${worker.job_type || '작업자'}</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td data-label="상태" class="worker-status">
|
||||
<span class="status-badge status-${worker.statusClass}">
|
||||
${SVG_ICONS[iconKey]}
|
||||
<span class="status-text">${worker.statusBadge}</span>
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td data-label="작업시간" class="worker-hours">
|
||||
<span class="hours-value ${worker.actualWorkHours > 12 ? 'hours-warning' : ''}">${worker.actualWorkHours.toFixed(1)}h</span>
|
||||
</td>
|
||||
|
||||
<td data-label="작업건수" class="worker-tasks">
|
||||
<div class="tasks-summary">
|
||||
<span class="task-count">
|
||||
<span class="task-label">정규:</span>
|
||||
<span class="task-value">${worker.regularWorkCount}건</span>
|
||||
</span>
|
||||
${worker.errorWorkCount > 0 ? `
|
||||
<span class="task-count task-error">
|
||||
<span class="task-label">에러:</span>
|
||||
<span class="task-value">${worker.errorWorkCount}건</span>
|
||||
</span>
|
||||
` : ''}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td data-label="액션" class="worker-actions">
|
||||
<div class="action-buttons">
|
||||
<button class="action-btn btn-edit" onclick="openWorkerModal(${worker.worker_id}, '${worker.worker_name}')" title="작업입력">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
|
||||
</svg>
|
||||
<span class="action-text">작업입력</span>
|
||||
</button>
|
||||
${worker.vacationType ? `
|
||||
<button class="action-btn btn-vacation" onclick="handleVacation(${worker.worker_id}, '${worker.vacationType}')" title="${worker.vacationType === 'full' ? '연차처리' : worker.vacationType === 'half' ? '반차처리' : '반반차처리'}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
|
||||
<line x1="16" y1="2" x2="16" y2="6"></line>
|
||||
<line x1="8" y1="2" x2="8" y2="6"></line>
|
||||
<line x1="3" y1="10" x2="21" y2="10"></line>
|
||||
</svg>
|
||||
<span class="action-text">${worker.vacationType === 'full' ? '연차' : worker.vacationType === 'half' ? '반차' : '반반차'}</span>
|
||||
</button>
|
||||
` : ''}
|
||||
${worker.status === 'overtime-warning' ? `
|
||||
<button class="action-btn btn-confirm" onclick="confirmOvertime(${worker.worker_id})" title="정상확인">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="20 6 9 17 4 12"></polyline>
|
||||
</svg>
|
||||
<span class="action-text">정상확인</span>
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function groupWorkDataByProject() {
|
||||
|
||||
338
web-ui/js/page-access-management.js
Normal file
338
web-ui/js/page-access-management.js
Normal file
@@ -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 = `
|
||||
<tr>
|
||||
<td colspan="6" style="text-align: center; padding: 2rem; color: #6b7280;">
|
||||
<p>필터 조건에 맞는 사용자가 없습니다.</p>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = filteredUsers.map(user => `
|
||||
<tr>
|
||||
<td>
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<div style="width: 32px; height: 32px; border-radius: 50%; background: linear-gradient(135deg, #3b82f6, #2563eb); color: white; display: flex; align-items: center; justify-content: center; font-weight: 600; font-size: 0.875rem;">
|
||||
${(user.name || user.username).charAt(0)}
|
||||
</div>
|
||||
<span style="font-weight: 600;">${user.name || user.username}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>${user.username}</td>
|
||||
<td>
|
||||
<span class="badge ${user.role_name === 'Admin' ? 'badge-warning' : 'badge-info'}">
|
||||
${user.role_name}
|
||||
</span>
|
||||
</td>
|
||||
<td>${user.worker_name || '-'}</td>
|
||||
<td>
|
||||
<span style="font-weight: 600; color: ${user.granted_pages_count > 0 ? '#16a34a' : '#6b7280'};">
|
||||
${user.granted_pages_count}개
|
||||
</span>
|
||||
<span style="color: #9ca3af;"> / ${allPages.length}개</span>
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-primary" onclick="openPageAccessModal(${user.user_id})">
|
||||
권한 설정
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).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 => `
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<div style="font-weight: 600; font-size: 0.875rem; color: #6b7280; padding: 0.5rem; background: #f9fafb; border-radius: 0.375rem; margin-bottom: 0.5rem;">
|
||||
${categoryNames[category] || category}
|
||||
</div>
|
||||
${grouped[category].map(page => `
|
||||
<div style="padding: 0.75rem; border-bottom: 1px solid #f3f4f6; display: flex; align-items: center; justify-content: space-between;">
|
||||
<label style="display: flex; align-items: center; gap: 0.75rem; cursor: pointer; flex: 1;">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="page-checkbox"
|
||||
data-page-id="${page.page_id}"
|
||||
${page.can_access || page.is_default ? 'checked' : ''}
|
||||
${page.is_default ? 'disabled' : ''}
|
||||
style="width: 18px; height: 18px; cursor: pointer;"
|
||||
/>
|
||||
<div style="flex: 1;">
|
||||
<div style="font-weight: 500; color: #111827;">${page.page_name}</div>
|
||||
<div style="font-size: 0.75rem; color: #9ca3af;">${page.page_path}</div>
|
||||
</div>
|
||||
</label>
|
||||
${page.is_default ? '<span style="font-size: 0.75rem; color: #16a34a; font-weight: 600;">기본 권한</span>' : ''}
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`).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 = `
|
||||
<div class="toast-icon">${iconMap[type] || 'ℹ️'}</div>
|
||||
<div class="toast-message">${message}</div>
|
||||
<button class="toast-close" onclick="this.parentElement.remove()">×</button>
|
||||
`;
|
||||
|
||||
container.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
if (toast.parentElement) {
|
||||
toast.remove();
|
||||
}
|
||||
}, duration);
|
||||
}
|
||||
|
||||
// 전역 함수로 export
|
||||
window.openPageAccessModal = openPageAccessModal;
|
||||
window.closePageAccessModal = closePageAccessModal;
|
||||
140
web-ui/pages/admin/page-access.html
Normal file
140
web-ui/pages/admin/page-access.html
Normal file
@@ -0,0 +1,140 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>페이지 권한 관리 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/common.css?v=1">
|
||||
<link rel="stylesheet" href="/css/admin-settings.css?v=1">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script type="module" src="/js/auth-check.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="work-report-container">
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<main class="work-report-main">
|
||||
<div class="dashboard-main">
|
||||
<div class="page-header">
|
||||
<div class="page-title-section">
|
||||
<h1 class="page-title">
|
||||
<span class="title-icon">🔐</span>
|
||||
페이지 접근 권한 관리
|
||||
</h1>
|
||||
<p class="page-description">작업자에게 특정 페이지 접근 권한을 부여하거나 회수합니다</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 사용자 목록 섹션 -->
|
||||
<div class="settings-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">
|
||||
<span class="section-icon">👥</span>
|
||||
사용자 목록
|
||||
</h2>
|
||||
<div class="filter-buttons">
|
||||
<button class="filter-btn active" data-filter="all">전체</button>
|
||||
<button class="filter-btn" data-filter="with-access">권한 있음</button>
|
||||
<button class="filter-btn" data-filter="no-access">권한 없음</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="users-container">
|
||||
<div class="users-table-container">
|
||||
<table class="users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>사용자명</th>
|
||||
<th>아이디</th>
|
||||
<th>역할</th>
|
||||
<th>작업자</th>
|
||||
<th>접근 가능 페이지</th>
|
||||
<th>관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="usersTableBody">
|
||||
<tr>
|
||||
<td colspan="6" style="text-align: center; padding: 2rem;">
|
||||
<div class="spinner"></div>
|
||||
<p>사용자 목록을 불러오는 중...</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="empty-state" id="emptyState" style="display: none;">
|
||||
<div class="empty-icon">👥</div>
|
||||
<h3>등록된 사용자가 없습니다</h3>
|
||||
<p>권한을 부여할 사용자 계정이 없습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 페이지 권한 설정 모달 -->
|
||||
<div id="pageAccessModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container" style="max-width: 700px;">
|
||||
<div class="modal-header">
|
||||
<h2 id="modalTitle">페이지 권한 설정</h2>
|
||||
<button class="modal-close-btn" onclick="closePageAccessModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="user-info-section" style="background: #f9fafb; padding: 1rem; border-radius: 0.5rem; margin-bottom: 1.5rem;">
|
||||
<div style="display: flex; align-items: center; gap: 0.75rem;">
|
||||
<div style="width: 40px; height: 40px; border-radius: 50%; background: linear-gradient(135deg, #3b82f6, #2563eb); color: white; display: flex; align-items: center; justify-content: center; font-weight: 600;">
|
||||
<span id="modalUserInitial">-</span>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-weight: 600; font-size: 1rem; color: #111827;" id="modalUserName">사용자명</div>
|
||||
<div style="font-size: 0.875rem; color: #6b7280;">
|
||||
<span id="modalUsername">username</span>
|
||||
<span style="margin: 0 0.5rem;">•</span>
|
||||
<span id="modalWorkerName">작업자</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page-access-list">
|
||||
<h3 style="font-size: 1rem; font-weight: 600; color: #374151; margin-bottom: 1rem;">
|
||||
접근 가능 페이지 선택
|
||||
</h3>
|
||||
|
||||
<div id="pageListContainer" style="max-height: 400px; overflow-y: auto; border: 1px solid #e5e7eb; border-radius: 0.5rem; padding: 0.5rem;">
|
||||
<div style="text-align: center; padding: 2rem; color: #6b7280;">
|
||||
<div class="spinner" style="margin: 0 auto 0.5rem;"></div>
|
||||
페이지 목록을 불러오는 중...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 1.5rem; padding: 1rem; background: #fffbeb; border: 1px solid #fde047; border-radius: 0.5rem;">
|
||||
<p style="margin: 0; font-size: 0.875rem; color: #92400e;">
|
||||
<strong>💡 참고:</strong> Admin 및 System Admin은 모든 페이지에 자동으로 접근할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closePageAccessModal()">취소</button>
|
||||
<button type="button" class="btn btn-primary" id="savePageAccessBtn">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 토스트 알림 -->
|
||||
<div class="toast-container" id="toastContainer"></div>
|
||||
|
||||
<!-- JavaScript -->
|
||||
<script type="module" src="/js/api-config.js?v=13"></script>
|
||||
<script type="module" src="/js/load-navbar.js?v=4"></script>
|
||||
<script src="/js/page-access-management.js?v=1"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -109,6 +109,7 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 작업자 추가/수정 모달 -->
|
||||
@@ -211,8 +212,7 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/load-navbar.js?v=5"></script>
|
||||
|
||||
@@ -33,12 +33,11 @@
|
||||
<section class="quick-actions-section">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">⚡ 빠른 작업</h2>
|
||||
<h2 class="card-title">빠른 작업</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="quick-actions-grid-full">
|
||||
<a href="/pages/work/report-create.html" class="quick-action-card">
|
||||
<div class="action-icon-large">📝</div>
|
||||
<div class="action-content">
|
||||
<h3>작업 보고서 작성</h3>
|
||||
<p>오늘의 작업 내용을 입력하고 관리합니다</p>
|
||||
@@ -47,7 +46,6 @@
|
||||
</a>
|
||||
|
||||
<a href="/pages/work/report-view.html" class="quick-action-card">
|
||||
<div class="action-icon-large">📋</div>
|
||||
<div class="action-content">
|
||||
<h3>작업 현황 확인</h3>
|
||||
<p>팀원들의 작업 현황을 실시간으로 조회합니다</p>
|
||||
@@ -56,7 +54,6 @@
|
||||
</a>
|
||||
|
||||
<a href="/pages/work/analysis.html" class="quick-action-card admin-only">
|
||||
<div class="action-icon-large">📈</div>
|
||||
<div class="action-content">
|
||||
<h3>작업 분석</h3>
|
||||
<p>작업 효율성 및 통계를 분석합니다</p>
|
||||
@@ -65,7 +62,6 @@
|
||||
</a>
|
||||
|
||||
<a href="/pages/admin/index.html" class="quick-action-card admin-only">
|
||||
<div class="action-icon-large">🔧</div>
|
||||
<div class="action-content">
|
||||
<h3>작업 관리</h3>
|
||||
<p>작업자 및 프로젝트를 관리합니다</p>
|
||||
@@ -82,22 +78,36 @@
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="card-title">📊 오늘의 작업 현황</h2>
|
||||
<h2 class="card-title">오늘의 작업 현황</h2>
|
||||
<div class="date-selector">
|
||||
<input type="date" id="selectedDate" class="date-input">
|
||||
<button class="btn btn-primary btn-sm" id="refreshBtn">
|
||||
<span>🔄</span>
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="work-status-container-enhanced" id="workStatusContainer">
|
||||
<div class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
<p>작업 현황을 불러오는 중...</p>
|
||||
</div>
|
||||
<div class="work-status-table-container">
|
||||
<table class="work-status-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>작업자</th>
|
||||
<th>상태</th>
|
||||
<th>작업시간</th>
|
||||
<th>작업건수</th>
|
||||
<th>액션</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="workStatusTableBody">
|
||||
<tr>
|
||||
<td colspan="5" class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
<p>작업 현황을 불러오는 중...</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
236
web-ui/pages/work/tbm.html
Normal file
236
web-ui/pages/work/tbm.html
Normal file
@@ -0,0 +1,236 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>TBM 관리 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/common.css?v=2">
|
||||
<link rel="stylesheet" href="/css/project-management.css?v=3">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js?v=1" defer></script>
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="work-report-container">
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<main class="work-report-main">
|
||||
<div class="dashboard-main">
|
||||
<div class="page-header">
|
||||
<div class="page-title-section">
|
||||
<h1 class="page-title">
|
||||
<span class="title-icon">🛠️</span>
|
||||
TBM (Tool Box Meeting) 관리
|
||||
</h1>
|
||||
<p class="page-description">아침 안전 회의 및 팀 구성 관리</p>
|
||||
</div>
|
||||
|
||||
<div class="page-actions">
|
||||
<input type="date" id="tbmDate" class="form-control" style="display: inline-block; width: auto;">
|
||||
<button class="btn btn-secondary" onclick="loadTodayTbm()">
|
||||
<span class="btn-icon">📅</span>
|
||||
오늘
|
||||
</button>
|
||||
<button class="btn btn-primary" onclick="openNewTbmModal()">
|
||||
<span class="btn-icon">➕</span>
|
||||
새 TBM 시작
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TBM 세션 목록 -->
|
||||
<div class="projects-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">TBM 세션 목록</h2>
|
||||
<div class="project-stats">
|
||||
<span class="stat-item">
|
||||
<span class="stat-icon">📋</span>
|
||||
총 <span id="totalSessions">0</span>개
|
||||
</span>
|
||||
<span class="stat-item" style="color: #16a34a;">
|
||||
<span class="stat-icon">✅</span>
|
||||
완료 <span id="completedSessions">0</span>개
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="projects-grid" id="tbmSessionsGrid">
|
||||
<!-- TBM 세션 카드들이 여기에 동적으로 생성됩니다 -->
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div class="empty-state" id="emptyState" style="display: none;">
|
||||
<div class="empty-icon">🛠️</div>
|
||||
<h3>등록된 TBM 세션이 없습니다.</h3>
|
||||
<p>\"새 TBM 시작\" 버튼을 눌러 오늘의 TBM을 시작해보세요.</p>
|
||||
<button class="btn btn-primary" onclick="openNewTbmModal()">
|
||||
➕ 첫 TBM 시작하기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- TBM 생성/수정 모달 -->
|
||||
<div id="tbmModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container" style="max-width: 800px;">
|
||||
<div class="modal-header">
|
||||
<h2 id="modalTitle">새 TBM 시작</h2>
|
||||
<button class="modal-close-btn" onclick="closeTbmModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<form id="tbmForm" onsubmit="event.preventDefault(); saveTbmSession();">
|
||||
<input type="hidden" id="sessionId">
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">TBM 날짜 *</label>
|
||||
<input type="date" id="sessionDate" class="form-control" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">팀장 *</label>
|
||||
<select id="leaderId" class="form-control" required>
|
||||
<option value="">팀장 선택...</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">프로젝트</label>
|
||||
<select id="projectId" class="form-control">
|
||||
<option value="">프로젝트 선택...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">작업 장소</label>
|
||||
<input type="text" id="workLocation" class="form-control" placeholder="작업 현장 위치">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">작업 내용</label>
|
||||
<textarea id="workDescription" class="form-control" rows="3" placeholder="오늘 진행할 작업 내용을 입력하세요"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">안전 관련 특이사항</label>
|
||||
<textarea id="safetyNotes" class="form-control" rows="2" placeholder="안전 주의사항이나 특이사항"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">시작 시간</label>
|
||||
<input type="time" id="startTime" class="form-control">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeTbmModal()">취소</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveTbmSession()">
|
||||
💾 저장 및 팀 구성하기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 팀 구성 모달 -->
|
||||
<div id="teamModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container" style="max-width: 900px;">
|
||||
<div class="modal-header">
|
||||
<h2>팀 구성</h2>
|
||||
<button class="modal-close-btn" onclick="closeTeamModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="section-header" style="margin-bottom: 1rem;">
|
||||
<h3 style="font-size: 1rem; font-weight: 600;">작업자 선택</h3>
|
||||
<div>
|
||||
<button class="btn btn-sm btn-secondary" onclick="selectAllWorkers()">전체 선택</button>
|
||||
<button class="btn btn-sm btn-secondary" onclick="deselectAllWorkers()">전체 해제</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="workerSelectionGrid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 0.75rem; max-height: 400px; overflow-y: auto; padding: 0.5rem; border: 1px solid #e5e7eb; border-radius: 0.5rem;">
|
||||
<!-- 작업자 체크박스 목록이 여기에 생성됩니다 -->
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 1.5rem;">
|
||||
<h3 style="font-size: 1rem; font-weight: 600; margin-bottom: 0.75rem;">선택된 팀원 <span id="selectedCount">0</span>명</h3>
|
||||
<div id="selectedWorkersList" style="display: flex; flex-wrap: wrap; gap: 0.5rem; min-height: 50px; padding: 0.75rem; background: #f9fafb; border-radius: 0.5rem;">
|
||||
<p style="margin: 0; color: #9ca3af; font-size: 0.875rem;">작업자를 선택해주세요</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeTeamModal()">취소</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveTeamComposition()">
|
||||
👥 팀 구성 완료
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 안전 체크리스트 모달 -->
|
||||
<div id="safetyModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container" style="max-width: 700px;">
|
||||
<div class="modal-header">
|
||||
<h2>안전 체크리스트</h2>
|
||||
<button class="modal-close-btn" onclick="closeSafetyModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div id="safetyChecklistContainer" style="max-height: 500px; overflow-y: auto;">
|
||||
<!-- 안전 체크리스트가 여기에 생성됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeSafetyModal()">취소</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveSafetyChecklist()">
|
||||
✅ 안전 체크 완료
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TBM 완료 모달 -->
|
||||
<div id="completeModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container" style="max-width: 500px;">
|
||||
<div class="modal-header">
|
||||
<h2>TBM 완료</h2>
|
||||
<button class="modal-close-btn" onclick="closeCompleteModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<p style="margin-bottom: 1rem;">이 TBM 세션을 완료 처리하시겠습니까?</p>
|
||||
<p style="color: #6b7280; font-size: 0.875rem;">완료 후에는 수정할 수 없습니다.</p>
|
||||
|
||||
<div class="form-group" style="margin-top: 1.5rem;">
|
||||
<label class="form-label">종료 시간</label>
|
||||
<input type="time" id="endTime" class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeCompleteModal()">취소</button>
|
||||
<button type="button" class="btn btn-primary" onclick="completeTbmSession()">
|
||||
✅ 완료
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 토스트 알림 -->
|
||||
<div class="toast-container" id="toastContainer"></div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/load-navbar.js?v=5"></script>
|
||||
<script type="module" src="/js/tbm.js?v=1"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user