feat: 대시보드 작업장 현황 지도 구현

- 실시간 작업장 현황을 지도로 시각화
- 작업장 관리 페이지에서 정의한 구역 정보 활용
- TBM 작업자 및 방문자 현황 표시

주요 변경사항:
- dashboard.html: 작업장 현황 섹션 추가 (기존 작업 현황 테이블 제거)
- workplace-status.js: 지도 렌더링 및 데이터 통합 로직 구현
- modern-dashboard.js: 삭제된 DOM 요소 조건부 체크 추가

시각화 방식:
- 인원 없음: 회색 테두리 + 작업장 이름
- 내부 작업자: 파란색 영역 + 인원 수
- 외부 방문자: 보라색 영역 + 인원 수
- 둘 다: 초록색 영역 + 총 인원 수

기술 구현:
- Canvas API 기반 사각형 영역 렌더링
- map-regions API를 통한 데이터 일관성 보장
- 클릭 이벤트로 상세 정보 모달 표시

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-01-29 15:46:47 +09:00
parent e1227a69fe
commit b6485e3140
87 changed files with 17509 additions and 698 deletions

View File

@@ -44,6 +44,10 @@ function setupRoutes(app) {
const equipmentRoutes = require('../routes/equipmentRoutes');
const taskRoutes = require('../routes/taskRoutes');
const tbmRoutes = require('../routes/tbmRoutes');
const vacationRequestRoutes = require('../routes/vacationRequestRoutes');
const vacationTypeRoutes = require('../routes/vacationTypeRoutes');
const vacationBalanceRoutes = require('../routes/vacationBalanceRoutes');
const visitRequestRoutes = require('../routes/visitRequestRoutes');
// Rate Limiters 설정
const rateLimit = require('express-rate-limit');
@@ -141,6 +145,10 @@ function setupRoutes(app) {
app.use('/api/workplaces', workplaceRoutes);
app.use('/api/equipments', equipmentRoutes);
app.use('/api/tasks', taskRoutes);
app.use('/api/vacation-requests', vacationRequestRoutes); // 휴가 신청 관리
app.use('/api/vacation-types', vacationTypeRoutes); // 휴가 유형 관리
app.use('/api/vacation-balances', vacationBalanceRoutes); // 휴가 잔액 관리
app.use('/api/workplace-visits', visitRequestRoutes); // 출입 신청 및 안전교육 관리
app.use('/api', pageAccessRoutes); // 페이지 접근 권한 관리
app.use('/api/tbm', tbmRoutes); // TBM 시스템
app.use('/api', uploadBgRoutes);

View File

@@ -86,6 +86,15 @@ const helmetOptions = {
*/
permittedCrossDomainPolicies: {
permittedPolicies: 'none'
},
/**
* Cross-Origin-Resource-Policy
* 크로스 오리진 리소스 공유 설정
* 이미지 등 정적 파일을 다른 포트에서 로드할 수 있도록 허용
*/
crossOriginResourcePolicy: {
policy: 'cross-origin'
}
};

View File

@@ -154,6 +154,34 @@ const getMonthlyAttendanceStats = asyncHandler(async (req, res) => {
});
});
/**
* 출근 체크 목록 조회 (아침용, 휴가 정보 포함)
*/
const getCheckinList = asyncHandler(async (req, res) => {
const { date } = req.query;
const data = await attendanceService.getCheckinListService(date);
res.json({
success: true,
data,
message: '출근 체크 목록을 성공적으로 조회했습니다'
});
});
/**
* 출근 체크 저장 (일괄 처리)
*/
const saveCheckins = asyncHandler(async (req, res) => {
const { date, checkins } = req.body; // checkins: [{worker_id, is_present}, ...]
const result = await attendanceService.saveCheckinsService(date, checkins);
res.json({
success: true,
data: result,
message: '출근 체크가 성공적으로 저장되었습니다'
});
});
module.exports = {
getDailyAttendanceStatus,
getDailyAttendanceRecords,
@@ -163,5 +191,7 @@ module.exports = {
getAttendanceTypes,
getVacationTypes,
getWorkerVacationBalance,
getMonthlyAttendanceStats
getMonthlyAttendanceStats,
getCheckinList,
saveCheckins
};

View File

@@ -10,7 +10,7 @@ const TbmController = {
createSession: (req, res) => {
const sessionData = {
session_date: req.body.session_date,
leader_id: req.body.leader_id,
leader_id: req.body.leader_id || null,
project_id: req.body.project_id || null,
work_location: req.body.work_location || null,
work_description: req.body.work_description || null,
@@ -19,11 +19,11 @@ const TbmController = {
created_by: req.user.user_id
};
// 필수 필드 검증
if (!sessionData.session_date || !sessionData.leader_id) {
// 필수 필드 검증 (날짜만 필수, leader_id는 관리자의 경우 null 허용)
if (!sessionData.session_date) {
return res.status(400).json({
success: false,
message: 'TBM 날짜와 팀장 정보는 필수입니다.'
message: 'TBM 날짜는 필수입니다.'
});
}
@@ -179,7 +179,7 @@ const TbmController = {
// ==================== 팀 구성 관련 ====================
/**
* 팀원 추가
* 팀원 추가 (작업자별 상세 정보 포함)
*/
addTeamMember: (req, res) => {
const assignmentData = {
@@ -188,7 +188,12 @@ const TbmController = {
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
absence_reason: req.body.absence_reason || null,
project_id: req.body.project_id || null,
work_type_id: req.body.work_type_id || null,
task_id: req.body.task_id || null,
workplace_category_id: req.body.workplace_category_id || null,
workplace_id: req.body.workplace_id || null
};
if (!assignmentData.worker_id) {
@@ -300,6 +305,30 @@ const TbmController = {
});
},
/**
* 세션의 모든 팀원 삭제 (수정 시 사용)
*/
clearAllTeamMembers: (req, res) => {
const { sessionId } = req.params;
TbmModel.clearAllTeamMembers(sessionId, (err, result) => {
if (err) {
console.error('팀원 전체 삭제 오류:', err);
return res.status(500).json({
success: false,
message: '팀원 전체 삭제 중 오류가 발생했습니다.',
error: err.message
});
}
res.json({
success: true,
message: '모든 팀원이 삭제되었습니다.',
data: { deletedCount: result.affectedRows }
});
});
},
// ==================== 안전 체크리스트 관련 ====================
/**
@@ -564,6 +593,33 @@ const TbmController = {
});
}
res.json({
success: true,
data: results
});
});
},
/**
* 작업보고서가 작성되지 않은 TBM 팀 배정 조회
*/
getIncompleteWorkReports: (req, res) => {
const userId = req.user.user_id;
const accessLevel = req.user.access_level;
// 관리자는 모든 TBM 조회, 일반 사용자는 본인이 작성한 것만 조회
const filterUserId = (accessLevel === 'system' || accessLevel === 'admin') ? null : userId;
TbmModel.getIncompleteWorkReports(filterUserId, (err, results) => {
if (err) {
console.error('미완료 작업보고서 조회 오류:', err);
return res.status(500).json({
success: false,
message: '미완료 작업보고서 조회 중 오류가 발생했습니다.',
error: err.message
});
}
res.json({
success: true,
data: results

View File

@@ -218,16 +218,16 @@ const updateUser = asyncHandler(async (req, res) => {
checkAdminPermission(req.user);
const { id } = req.params;
const { username, name, email, phone, role, password } = req.body;
const { username, name, email, phone, role, role_id, password } = req.body;
if (!id || isNaN(id)) {
throw new ValidationError('유효하지 않은 사용자 ID입니다');
}
logger.info('사용자 수정 요청', { userId: id });
logger.info('사용자 수정 요청', { userId: id, body: req.body });
// 최소 하나의 수정 필드가 필요
if (!username && !name && email === undefined && phone === undefined && !role && !password) {
if (!username && !name && email === undefined && phone === undefined && !role && !role_id && !password) {
throw new ValidationError('수정할 필드가 없습니다');
}
@@ -283,13 +283,35 @@ const updateUser = asyncHandler(async (req, res) => {
values.push(phone || null);
}
if (role) {
const validRoles = ['admin', 'group_leader', 'worker'];
if (!validRoles.includes(role)) {
throw new ValidationError('유효하지 않은 권한입니다');
// role_id 또는 role 문자열 처리
if (role_id) {
// role_id가 유효한지 확인
const [roleCheck] = await db.execute('SELECT id, name FROM roles WHERE id = ?', [role_id]);
if (roleCheck.length === 0) {
throw new ValidationError('유효하지 않은 역할 ID입니다');
}
updates.push('role = ?, access_level = ?');
values.push(role, role);
updates.push('role_id = ?');
values.push(role_id);
logger.info('role_id로 역할 변경', { userId: id, role_id, role_name: roleCheck[0].name });
} else if (role) {
// role 문자열을 role_id로 변환 (하위 호환성)
const roleNameMap = {
'admin': 'Admin',
'system': 'System Admin',
'user': 'User',
'guest': 'Guest',
'group_leader': 'User', // 임시 매핑
'worker': 'User' // 임시 매핑
};
const roleName = roleNameMap[role.toLowerCase()] || role;
const [roleCheck] = await db.execute('SELECT id FROM roles WHERE name = ?', [roleName]);
if (roleCheck.length === 0) {
throw new ValidationError(`유효하지 않은 권한입니다: ${role}`);
}
updates.push('role_id = ?');
values.push(roleCheck[0].id);
logger.info('role 문자열로 역할 변경', { userId: id, role, role_id: roleCheck[0].id });
}
if (password) {
@@ -297,7 +319,7 @@ const updateUser = asyncHandler(async (req, res) => {
throw new ValidationError('비밀번호는 최소 6자 이상이어야 합니다');
}
const hashedPassword = await bcrypt.hash(password, 10);
updates.push('password_hash = ?');
updates.push('password = ?');
values.push(hashedPassword);
}
@@ -306,6 +328,7 @@ const updateUser = asyncHandler(async (req, res) => {
const updateQuery = `UPDATE users SET ${updates.join(', ')} WHERE user_id = ?`;
logger.info('실행할 UPDATE 쿼리', { query: updateQuery, values });
await db.execute(updateQuery, values);
logger.info('사용자 수정 성공', {
@@ -324,7 +347,7 @@ const updateUser = asyncHandler(async (req, res) => {
if (error instanceof NotFoundError || error instanceof ValidationError || error instanceof ConflictError) {
throw error;
}
logger.error('사용자 수정 실패', { userId: id, error: error.message });
logger.error('사용자 수정 실패', { userId: id, error: error.message, stack: error.stack });
throw new DatabaseError('사용자 정보를 수정하는데 실패했습니다');
}
});
@@ -458,11 +481,127 @@ const deleteUser = asyncHandler(async (req, res) => {
}
});
/**
* 사용자의 페이지 접근 권한 조회
*/
const getUserPageAccess = asyncHandler(async (req, res) => {
const { id } = req.params;
if (!id || isNaN(id)) {
throw new ValidationError('유효하지 않은 사용자 ID입니다');
}
logger.info('사용자 페이지 권한 조회 요청', { userId: id });
const { getDb } = require('../dbPool');
const db = await getDb();
try {
const query = `
SELECT
p.id as page_id,
p.page_key,
p.page_name,
p.page_path,
p.category,
COALESCE(upa.can_access, 0) as can_access
FROM pages p
LEFT JOIN user_page_access upa ON p.id = upa.page_id AND upa.user_id = ?
WHERE p.is_active = 1
ORDER BY p.category, p.display_order
`;
const [pageAccess] = await db.execute(query, [id]);
logger.info('사용자 페이지 권한 조회 성공', { userId: id, pageCount: pageAccess.length });
res.json({
success: true,
data: {
pageAccess
},
message: '페이지 권한 조회 성공'
});
} catch (error) {
logger.error('사용자 페이지 권한 조회 실패', { userId: id, error: error.message });
throw new DatabaseError('페이지 권한을 조회하는데 실패했습니다');
}
});
/**
* 사용자의 페이지 접근 권한 업데이트
*/
const updateUserPageAccess = asyncHandler(async (req, res) => {
checkAdminPermission(req.user);
const { id } = req.params;
const { pageAccess } = req.body;
if (!id || isNaN(id)) {
throw new ValidationError('유효하지 않은 사용자 ID입니다');
}
if (!Array.isArray(pageAccess)) {
throw new ValidationError('pageAccess는 배열이어야 합니다');
}
logger.info('사용자 페이지 권한 업데이트 요청', {
userId: id,
pageCount: pageAccess.length,
updatedBy: req.user.username
});
const { getDb } = require('../dbPool');
const db = await getDb();
try {
// 트랜잭션 시작
await db.query('START TRANSACTION');
// 기존 권한 삭제
await db.execute('DELETE FROM user_page_access WHERE user_id = ?', [id]);
// 새 권한 삽입
if (pageAccess.length > 0) {
const values = pageAccess.map(p => [id, p.page_id, p.can_access]);
const placeholders = values.map(() => '(?, ?, ?)').join(', ');
const flatValues = values.flat();
await db.execute(
`INSERT INTO user_page_access (user_id, page_id, can_access) VALUES ${placeholders}`,
flatValues
);
}
// 커밋
await db.query('COMMIT');
logger.info('사용자 페이지 권한 업데이트 성공', {
userId: id,
pageCount: pageAccess.length,
updatedBy: req.user.username
});
res.json({
success: true,
data: { user_id: id },
message: '페이지 권한이 성공적으로 업데이트되었습니다'
});
} catch (error) {
// 롤백
await db.query('ROLLBACK');
logger.error('사용자 페이지 권한 업데이트 실패', { userId: id, error: error.message });
throw new DatabaseError('페이지 권한을 업데이트하는데 실패했습니다');
}
});
module.exports = {
getAllUsers,
getUserById,
createUser,
updateUser,
updateUserStatus,
deleteUser
deleteUser,
getUserPageAccess,
updateUserPageAccess
};

View File

@@ -0,0 +1,357 @@
/**
* vacationBalanceController.js
* 휴가 잔액 관련 컨트롤러
*/
const vacationBalanceModel = require('../models/vacationBalanceModel');
const vacationTypeModel = require('../models/vacationTypeModel');
const vacationBalanceController = {
/**
* 특정 작업자의 휴가 잔액 조회 (특정 연도)
* GET /api/vacation-balances/worker/:workerId/year/:year
*/
async getByWorkerAndYear(req, res) {
try {
const { workerId, year } = req.params;
vacationBalanceModel.getByWorkerAndYear(workerId, year, (err, results) => {
if (err) {
console.error('휴가 잔액 조회 오류:', err);
return res.status(500).json({
success: false,
message: '휴가 잔액을 조회하는 중 오류가 발생했습니다'
});
}
res.json({
success: true,
data: results
});
});
} catch (error) {
console.error('getByWorkerAndYear 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다'
});
}
},
/**
* 모든 작업자의 휴가 잔액 조회 (특정 연도)
* GET /api/vacation-balances/year/:year
*/
async getAllByYear(req, res) {
try {
const { year } = req.params;
vacationBalanceModel.getAllByYear(year, (err, results) => {
if (err) {
console.error('전체 휴가 잔액 조회 오류:', err);
return res.status(500).json({
success: false,
message: '전체 휴가 잔액을 조회하는 중 오류가 발생했습니다'
});
}
res.json({
success: true,
data: results
});
});
} catch (error) {
console.error('getAllByYear 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다'
});
}
},
/**
* 휴가 잔액 생성
* POST /api/vacation-balances
*/
async createBalance(req, res) {
try {
const {
worker_id,
vacation_type_id,
year,
total_days,
used_days,
notes
} = req.body;
const created_by = req.user.user_id;
// 필수 필드 검증
if (!worker_id || !vacation_type_id || !year || total_days === undefined) {
return res.status(400).json({
success: false,
message: '필수 필드가 누락되었습니다 (worker_id, vacation_type_id, year, total_days)'
});
}
// 중복 체크
vacationBalanceModel.getByWorkerTypeYear(worker_id, vacation_type_id, year, (err, existing) => {
if (err) {
console.error('중복 체크 오류:', err);
return res.status(500).json({
success: false,
message: '중복 체크 중 오류가 발생했습니다'
});
}
if (existing && existing.length > 0) {
return res.status(400).json({
success: false,
message: '이미 해당 작업자의 해당 연도 휴가 잔액이 존재합니다'
});
}
const balanceData = {
worker_id,
vacation_type_id,
year,
total_days,
used_days: used_days || 0,
notes: notes || null,
created_by
};
vacationBalanceModel.create(balanceData, (err, result) => {
if (err) {
console.error('휴가 잔액 생성 오류:', err);
return res.status(500).json({
success: false,
message: '휴가 잔액을 생성하는 중 오류가 발생했습니다'
});
}
res.status(201).json({
success: true,
message: '휴가 잔액이 생성되었습니다',
data: { id: result.insertId }
});
});
});
} catch (error) {
console.error('createBalance 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다'
});
}
},
/**
* 휴가 잔액 수정
* PUT /api/vacation-balances/:id
*/
async updateBalance(req, res) {
try {
const { id } = req.params;
const { total_days, used_days, notes } = req.body;
const updateData = {};
if (total_days !== undefined) updateData.total_days = total_days;
if (used_days !== undefined) updateData.used_days = used_days;
if (notes !== undefined) updateData.notes = notes;
updateData.updated_at = new Date();
if (Object.keys(updateData).length === 1) {
return res.status(400).json({
success: false,
message: '수정할 데이터가 없습니다'
});
}
vacationBalanceModel.update(id, updateData, (err, result) => {
if (err) {
console.error('휴가 잔액 수정 오류:', err);
return res.status(500).json({
success: false,
message: '휴가 잔액을 수정하는 중 오류가 발생했습니다'
});
}
if (result.affectedRows === 0) {
return res.status(404).json({
success: false,
message: '휴가 잔액을 찾을 수 없습니다'
});
}
res.json({
success: true,
message: '휴가 잔액이 수정되었습니다'
});
});
} catch (error) {
console.error('updateBalance 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다'
});
}
},
/**
* 휴가 잔액 삭제
* DELETE /api/vacation-balances/:id
*/
async deleteBalance(req, res) {
try {
const { id } = req.params;
vacationBalanceModel.delete(id, (err, result) => {
if (err) {
console.error('휴가 잔액 삭제 오류:', err);
return res.status(500).json({
success: false,
message: '휴가 잔액을 삭제하는 중 오류가 발생했습니다'
});
}
if (result.affectedRows === 0) {
return res.status(404).json({
success: false,
message: '휴가 잔액을 찾을 수 없습니다'
});
}
res.json({
success: true,
message: '휴가 잔액이 삭제되었습니다'
});
});
} catch (error) {
console.error('deleteBalance 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다'
});
}
},
/**
* 근속년수 기반 연차 자동 계산 및 생성
* POST /api/vacation-balances/auto-calculate
*/
async autoCalculateAndCreate(req, res) {
try {
const { worker_id, hire_date, year } = req.body;
const created_by = req.user.user_id;
if (!worker_id || !hire_date || !year) {
return res.status(400).json({
success: false,
message: '필수 필드가 누락되었습니다 (worker_id, hire_date, year)'
});
}
// 연차 일수 계산
const annualDays = vacationBalanceModel.calculateAnnualLeaveDays(hire_date, year);
// ANNUAL 휴가 유형 ID 조회
vacationTypeModel.getByCode('ANNUAL', (err, types) => {
if (err || !types || types.length === 0) {
console.error('ANNUAL 휴가 유형 조회 오류:', err);
return res.status(500).json({
success: false,
message: 'ANNUAL 휴가 유형을 찾을 수 없습니다'
});
}
const annualTypeId = types[0].id;
// 중복 체크
vacationBalanceModel.getByWorkerTypeYear(worker_id, annualTypeId, year, (err, existing) => {
if (err) {
console.error('중복 체크 오류:', err);
return res.status(500).json({
success: false,
message: '중복 체크 중 오류가 발생했습니다'
});
}
if (existing && existing.length > 0) {
return res.status(400).json({
success: false,
message: '이미 해당 작업자의 해당 연도 연차가 존재합니다'
});
}
const balanceData = {
worker_id,
vacation_type_id: annualTypeId,
year,
total_days: annualDays,
used_days: 0,
notes: `근속년수 기반 자동 계산 (입사일: ${hire_date})`,
created_by
};
vacationBalanceModel.create(balanceData, (err, result) => {
if (err) {
console.error('휴가 잔액 생성 오류:', err);
return res.status(500).json({
success: false,
message: '휴가 잔액을 생성하는 중 오류가 발생했습니다'
});
}
res.status(201).json({
success: true,
message: `${annualDays}일의 연차가 자동으로 생성되었습니다`,
data: {
id: result.insertId,
calculated_days: annualDays
}
});
});
});
});
} catch (error) {
console.error('autoCalculateAndCreate 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다'
});
}
},
/**
* 작업자의 사용 가능한 휴가 일수 조회
* GET /api/vacation-balances/worker/:workerId/year/:year/available
*/
async getAvailableDays(req, res) {
try {
const { workerId, year } = req.params;
vacationBalanceModel.getAvailableVacationDays(workerId, year, (err, results) => {
if (err) {
console.error('사용 가능 휴가 조회 오류:', err);
return res.status(500).json({
success: false,
message: '사용 가능 휴가를 조회하는 중 오류가 발생했습니다'
});
}
res.json({
success: true,
data: results
});
});
} catch (error) {
console.error('getAvailableDays 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다'
});
}
}
};
module.exports = vacationBalanceController;

View File

@@ -0,0 +1,565 @@
/**
* vacationRequestController.js
* 휴가 신청 관련 컨트롤러
*/
const vacationRequestModel = require('../models/vacationRequestModel');
// TODO: workerVacationBalanceModel 구현 필요
// const workerVacationBalanceModel = require('../models/workerVacationBalanceModel');
const vacationRequestController = {
/**
* 휴가 신청 생성
*/
async createRequest(req, res) {
try {
const { worker_id, vacation_type_id, start_date, end_date, days_used, reason } = req.body;
const requested_by = req.user.user_id;
// 필수 필드 검증
if (!worker_id || !vacation_type_id || !start_date || !end_date || !days_used) {
return res.status(400).json({
success: false,
message: '필수 필드가 누락되었습니다'
});
}
// 날짜 유효성 검증
const startDate = new Date(start_date);
const endDate = new Date(end_date);
if (endDate < startDate) {
return res.status(400).json({
success: false,
message: '종료일은 시작일보다 이후여야 합니다'
});
}
// 기간 중복 체크
vacationRequestModel.checkOverlap(worker_id, start_date, end_date, null, (err, results) => {
if (err) {
console.error('기간 중복 체크 오류:', err);
return res.status(500).json({
success: false,
message: '기간 중복 체크 중 오류가 발생했습니다'
});
}
if (results[0].count > 0) {
return res.status(400).json({
success: false,
message: '해당 기간에 이미 신청된 휴가가 있습니다'
});
}
// TODO: 잔여 연차 확인 로직 구현 필요
// 현재는 잔여 연차 확인 없이 신청 가능
// 휴가 신청 생성
const requestData = {
worker_id,
vacation_type_id,
start_date,
end_date,
days_used,
reason: reason || null,
status: 'pending',
requested_by
};
vacationRequestModel.create(requestData, (err, result) => {
if (err) {
console.error('휴가 신청 생성 오류:', err);
return res.status(500).json({
success: false,
message: '휴가 신청 생성 중 오류가 발생했습니다'
});
}
res.status(201).json({
success: true,
message: '휴가 신청이 완료되었습니다',
data: {
request_id: result.insertId
}
});
});
});
} catch (error) {
console.error('휴가 신청 생성 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다'
});
}
},
/**
* 휴가 신청 목록 조회
*/
async getAllRequests(req, res) {
try {
const filters = {
worker_id: req.query.worker_id,
status: req.query.status,
start_date: req.query.start_date,
end_date: req.query.end_date,
vacation_type_id: req.query.vacation_type_id
};
// 일반 사용자는 자신의 신청만 조회 가능
if (req.user.access_level !== 'system') {
if (req.user.worker_id) {
filters.worker_id = req.user.worker_id;
} else {
return res.status(403).json({
success: false,
message: '권한이 없습니다'
});
}
}
vacationRequestModel.getAll(filters, (err, results) => {
if (err) {
console.error('휴가 신청 목록 조회 오류:', err);
return res.status(500).json({
success: false,
message: '휴가 신청 목록 조회 중 오류가 발생했습니다'
});
}
res.json({
success: true,
data: results
});
});
} catch (error) {
console.error('휴가 신청 목록 조회 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다'
});
}
},
/**
* 특정 휴가 신청 조회
*/
async getRequestById(req, res) {
try {
const { id } = req.params;
vacationRequestModel.getById(id, (err, results) => {
if (err) {
console.error('휴가 신청 조회 오류:', err);
return res.status(500).json({
success: false,
message: '휴가 신청 조회 중 오류가 발생했습니다'
});
}
if (results.length === 0) {
return res.status(404).json({
success: false,
message: '해당 휴가 신청을 찾을 수 없습니다'
});
}
const request = results[0];
// 권한 검증: 관리자 또는 본인만 조회 가능
if (req.user.access_level !== 'system' && req.user.worker_id !== request.worker_id) {
return res.status(403).json({
success: false,
message: '권한이 없습니다'
});
}
res.json({
success: true,
data: request
});
});
} catch (error) {
console.error('휴가 신청 조회 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다'
});
}
},
/**
* 휴가 신청 수정 (대기 중인 신청만)
*/
async updateRequest(req, res) {
try {
const { id } = req.params;
const { start_date, end_date, days_used, reason } = req.body;
// 기존 신청 조회
vacationRequestModel.getById(id, (err, results) => {
if (err) {
console.error('휴가 신청 조회 오류:', err);
return res.status(500).json({
success: false,
message: '휴가 신청 조회 중 오류가 발생했습니다'
});
}
if (results.length === 0) {
return res.status(404).json({
success: false,
message: '해당 휴가 신청을 찾을 수 없습니다'
});
}
const existingRequest = results[0];
// 권한 검증
if (req.user.access_level !== 'system' && req.user.worker_id !== existingRequest.worker_id) {
return res.status(403).json({
success: false,
message: '권한이 없습니다'
});
}
// 대기 중인 신청만 수정 가능
if (existingRequest.status !== 'pending') {
return res.status(400).json({
success: false,
message: '승인/거부된 신청은 수정할 수 없습니다'
});
}
const updateData = {};
if (start_date) updateData.start_date = start_date;
if (end_date) updateData.end_date = end_date;
if (days_used) updateData.days_used = days_used;
if (reason !== undefined) updateData.reason = reason;
// 날짜가 변경된 경우 중복 체크
if (start_date || end_date) {
const newStartDate = start_date || existingRequest.start_date;
const newEndDate = end_date || existingRequest.end_date;
vacationRequestModel.checkOverlap(
existingRequest.worker_id,
newStartDate,
newEndDate,
id,
(err, overlapResults) => {
if (err) {
console.error('기간 중복 체크 오류:', err);
return res.status(500).json({
success: false,
message: '기간 중복 체크 중 오류가 발생했습니다'
});
}
if (overlapResults[0].count > 0) {
return res.status(400).json({
success: false,
message: '해당 기간에 이미 신청된 휴가가 있습니다'
});
}
// 수정 실행
vacationRequestModel.update(id, updateData, (err, result) => {
if (err) {
console.error('휴가 신청 수정 오류:', err);
return res.status(500).json({
success: false,
message: '휴가 신청 수정 중 오류가 발생했습니다'
});
}
res.json({
success: true,
message: '휴가 신청이 수정되었습니다'
});
});
}
);
} else {
// 날짜 변경 없이 바로 수정
vacationRequestModel.update(id, updateData, (err, result) => {
if (err) {
console.error('휴가 신청 수정 오류:', err);
return res.status(500).json({
success: false,
message: '휴가 신청 수정 중 오류가 발생했습니다'
});
}
res.json({
success: true,
message: '휴가 신청이 수정되었습니다'
});
});
}
});
} catch (error) {
console.error('휴가 신청 수정 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다'
});
}
},
/**
* 휴가 신청 삭제 (대기 중인 신청만)
*/
async deleteRequest(req, res) {
try {
const { id } = req.params;
// 기존 신청 조회
vacationRequestModel.getById(id, (err, results) => {
if (err) {
console.error('휴가 신청 조회 오류:', err);
return res.status(500).json({
success: false,
message: '휴가 신청 조회 중 오류가 발생했습니다'
});
}
if (results.length === 0) {
return res.status(404).json({
success: false,
message: '해당 휴가 신청을 찾을 수 없습니다'
});
}
const existingRequest = results[0];
// 권한 검증
if (req.user.access_level !== 'system' && req.user.worker_id !== existingRequest.worker_id) {
return res.status(403).json({
success: false,
message: '권한이 없습니다'
});
}
// 대기 중인 신청만 삭제 가능
if (existingRequest.status !== 'pending') {
return res.status(400).json({
success: false,
message: '승인/거부된 신청은 삭제할 수 없습니다'
});
}
vacationRequestModel.delete(id, (err, result) => {
if (err) {
console.error('휴가 신청 삭제 오류:', err);
return res.status(500).json({
success: false,
message: '휴가 신청 삭제 중 오류가 발생했습니다'
});
}
res.json({
success: true,
message: '휴가 신청이 삭제되었습니다'
});
});
});
} catch (error) {
console.error('휴가 신청 삭제 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다'
});
}
},
/**
* 휴가 신청 승인 (관리자만)
*/
async approveRequest(req, res) {
try {
const { id } = req.params;
const { review_note } = req.body;
const reviewed_by = req.user.user_id;
// 관리자 권한 확인
if (req.user.access_level !== 'system') {
return res.status(403).json({
success: false,
message: '관리자만 승인할 수 있습니다'
});
}
// 기존 신청 조회
vacationRequestModel.getById(id, (err, results) => {
if (err) {
console.error('휴가 신청 조회 오류:', err);
return res.status(500).json({
success: false,
message: '휴가 신청 조회 중 오류가 발생했습니다'
});
}
if (results.length === 0) {
return res.status(404).json({
success: false,
message: '해당 휴가 신청을 찾을 수 없습니다'
});
}
const request = results[0];
if (request.status !== 'pending') {
return res.status(400).json({
success: false,
message: '이미 처리된 신청입니다'
});
}
// 상태 업데이트
const statusData = {
status: 'approved',
reviewed_by,
review_note
};
vacationRequestModel.updateStatus(id, statusData, (err, result) => {
if (err) {
console.error('휴가 승인 오류:', err);
return res.status(500).json({
success: false,
message: '휴가 승인 중 오류가 발생했습니다'
});
}
// TODO: 잔여 연차에서 차감 로직 구현 필요
// 현재는 연차 차감 없이 승인만 처리
res.json({
success: true,
message: '휴가 신청이 승인되었습니다'
});
});
});
} catch (error) {
console.error('휴가 승인 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다'
});
}
},
/**
* 휴가 신청 거부 (관리자만)
*/
async rejectRequest(req, res) {
try {
const { id } = req.params;
const { review_note } = req.body;
const reviewed_by = req.user.user_id;
// 관리자 권한 확인
if (req.user.access_level !== 'system') {
return res.status(403).json({
success: false,
message: '관리자만 거부할 수 있습니다'
});
}
// 기존 신청 조회
vacationRequestModel.getById(id, (err, results) => {
if (err) {
console.error('휴가 신청 조회 오류:', err);
return res.status(500).json({
success: false,
message: '휴가 신청 조회 중 오류가 발생했습니다'
});
}
if (results.length === 0) {
return res.status(404).json({
success: false,
message: '해당 휴가 신청을 찾을 수 없습니다'
});
}
const request = results[0];
if (request.status !== 'pending') {
return res.status(400).json({
success: false,
message: '이미 처리된 신청입니다'
});
}
// 상태 업데이트
const statusData = {
status: 'rejected',
reviewed_by,
review_note
};
vacationRequestModel.updateStatus(id, statusData, (err, result) => {
if (err) {
console.error('휴가 거부 오류:', err);
return res.status(500).json({
success: false,
message: '휴가 거부 중 오류가 발생했습니다'
});
}
res.json({
success: true,
message: '휴가 신청이 거부되었습니다'
});
});
});
} catch (error) {
console.error('휴가 거부 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다'
});
}
},
/**
* 대기 중인 휴가 신청 목록 (관리자용)
*/
async getPendingRequests(req, res) {
try {
// 관리자 권한 확인
if (req.user.access_level !== 'system') {
return res.status(403).json({
success: false,
message: '관리자만 조회할 수 있습니다'
});
}
vacationRequestModel.getAllPending((err, results) => {
if (err) {
console.error('대기 중인 휴가 신청 조회 오류:', err);
return res.status(500).json({
success: false,
message: '대기 중인 휴가 신청 조회 중 오류가 발생했습니다'
});
}
res.json({
success: true,
data: results
});
});
} catch (error) {
console.error('대기 중인 휴가 신청 조회 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다'
});
}
}
};
module.exports = vacationRequestController;

View File

@@ -0,0 +1,333 @@
/**
* vacationTypeController.js
* 휴가 유형 관련 컨트롤러
*/
const vacationTypeModel = require('../models/vacationTypeModel');
const vacationTypeController = {
/**
* 모든 활성 휴가 유형 조회
* GET /api/vacation-types
*/
async getAllTypes(req, res) {
try {
vacationTypeModel.getAll((err, results) => {
if (err) {
console.error('휴가 유형 조회 오류:', err);
return res.status(500).json({
success: false,
message: '휴가 유형을 조회하는 중 오류가 발생했습니다'
});
}
res.json({
success: true,
data: results
});
});
} catch (error) {
console.error('getAllTypes 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다'
});
}
},
/**
* 시스템 기본 휴가 유형 조회
* GET /api/vacation-types/system
*/
async getSystemTypes(req, res) {
try {
vacationTypeModel.getSystemTypes((err, results) => {
if (err) {
console.error('시스템 휴가 유형 조회 오류:', err);
return res.status(500).json({
success: false,
message: '시스템 휴가 유형을 조회하는 중 오류가 발생했습니다'
});
}
res.json({
success: true,
data: results
});
});
} catch (error) {
console.error('getSystemTypes 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다'
});
}
},
/**
* 특별 휴가 유형 조회
* GET /api/vacation-types/special
*/
async getSpecialTypes(req, res) {
try {
vacationTypeModel.getSpecialTypes((err, results) => {
if (err) {
console.error('특별 휴가 유형 조회 오류:', err);
return res.status(500).json({
success: false,
message: '특별 휴가 유형을 조회하는 중 오류가 발생했습니다'
});
}
res.json({
success: true,
data: results
});
});
} catch (error) {
console.error('getSpecialTypes 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다'
});
}
},
/**
* 특별 휴가 유형 생성 (관리자만)
* POST /api/vacation-types
*/
async createType(req, res) {
try {
const {
type_code,
type_name,
deduct_days,
priority,
description
} = req.body;
// 필수 필드 검증
if (!type_code || !type_name || !deduct_days) {
return res.status(400).json({
success: false,
message: '필수 필드가 누락되었습니다 (type_code, type_name, deduct_days)'
});
}
// type_code 중복 체크
vacationTypeModel.getByCode(type_code, (err, existingTypes) => {
if (err) {
console.error('type_code 중복 체크 오류:', err);
return res.status(500).json({
success: false,
message: 'type_code 중복 체크 중 오류가 발생했습니다'
});
}
if (existingTypes && existingTypes.length > 0) {
return res.status(400).json({
success: false,
message: '이미 존재하는 type_code입니다'
});
}
// 특별 휴가 유형으로 생성
const typeData = {
type_code,
type_name,
deduct_days,
priority: priority || 50,
description: description || null,
is_special: true,
is_system: false,
is_active: true
};
vacationTypeModel.create(typeData, (err, result) => {
if (err) {
console.error('휴가 유형 생성 오류:', err);
return res.status(500).json({
success: false,
message: '휴가 유형을 생성하는 중 오류가 발생했습니다'
});
}
res.status(201).json({
success: true,
message: '특별 휴가 유형이 생성되었습니다',
data: { id: result.insertId }
});
});
});
} catch (error) {
console.error('createType 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다'
});
}
},
/**
* 휴가 유형 수정 (관리자만)
* PUT /api/vacation-types/:id
*/
async updateType(req, res) {
try {
const { id } = req.params;
const {
type_name,
deduct_days,
priority,
description,
is_active
} = req.body;
// 먼저 해당 유형 조회
vacationTypeModel.getById(id, (err, types) => {
if (err) {
console.error('휴가 유형 조회 오류:', err);
return res.status(500).json({
success: false,
message: '휴가 유형을 조회하는 중 오류가 발생했습니다'
});
}
if (!types || types.length === 0) {
return res.status(404).json({
success: false,
message: '휴가 유형을 찾을 수 없습니다'
});
}
const type = types[0];
// 시스템 기본 휴가의 경우 제한적으로만 수정 가능
const updateData = {};
if (type.is_system) {
// 시스템 휴가는 priority와 description만 수정 가능
if (priority !== undefined) updateData.priority = priority;
if (description !== undefined) updateData.description = description;
} else {
// 특별 휴가는 모든 필드 수정 가능
if (type_name) updateData.type_name = type_name;
if (deduct_days !== undefined) updateData.deduct_days = deduct_days;
if (priority !== undefined) updateData.priority = priority;
if (description !== undefined) updateData.description = description;
if (is_active !== undefined) updateData.is_active = is_active;
}
if (Object.keys(updateData).length === 0) {
return res.status(400).json({
success: false,
message: '수정할 데이터가 없습니다'
});
}
updateData.updated_at = new Date();
vacationTypeModel.update(id, updateData, (err, result) => {
if (err) {
console.error('휴가 유형 수정 오류:', err);
return res.status(500).json({
success: false,
message: '휴가 유형을 수정하는 중 오류가 발생했습니다'
});
}
res.json({
success: true,
message: '휴가 유형이 수정되었습니다'
});
});
});
} catch (error) {
console.error('updateType 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다'
});
}
},
/**
* 특별 휴가 유형 삭제 (관리자만, 시스템 기본 휴가는 삭제 불가)
* DELETE /api/vacation-types/:id
*/
async deleteType(req, res) {
try {
const { id } = req.params;
vacationTypeModel.delete(id, (err, result) => {
if (err) {
console.error('휴가 유형 삭제 오류:', err);
return res.status(500).json({
success: false,
message: '휴가 유형을 삭제하는 중 오류가 발생했습니다'
});
}
if (result.affectedRows === 0) {
return res.status(400).json({
success: false,
message: '삭제할 수 없습니다. 시스템 기본 휴가이거나 존재하지 않는 휴가 유형입니다'
});
}
res.json({
success: true,
message: '휴가 유형이 삭제되었습니다'
});
});
} catch (error) {
console.error('deleteType 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다'
});
}
},
/**
* 휴가 유형 우선순위 일괄 업데이트 (관리자만)
* PUT /api/vacation-types/priorities
*/
async updatePriorities(req, res) {
try {
const { priorities } = req.body;
// priorities = [{ id: 1, priority: 10 }, { id: 2, priority: 20 }, ...]
if (!priorities || !Array.isArray(priorities)) {
return res.status(400).json({
success: false,
message: 'priorities 배열이 필요합니다'
});
}
vacationTypeModel.updatePriorities(priorities, (err, result) => {
if (err) {
console.error('우선순위 업데이트 오류:', err);
return res.status(500).json({
success: false,
message: '우선순위를 업데이트하는 중 오류가 발생했습니다'
});
}
res.json({
success: true,
message: '우선순위가 업데이트되었습니다',
data: { updated: result.affectedRows }
});
});
} catch (error) {
console.error('updatePriorities 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다'
});
}
}
};
module.exports = vacationTypeController;

View File

@@ -0,0 +1,555 @@
const visitRequestModel = require('../models/visitRequestModel');
// ==================== 출입 신청 관리 ====================
/**
* 출입 신청 생성
*/
exports.createVisitRequest = (req, res) => {
const requester_id = req.user.user_id;
const requestData = {
requester_id,
...req.body
};
// 필수 필드 검증
const requiredFields = ['visitor_company', 'category_id', 'workplace_id', 'visit_date', 'visit_time', 'purpose_id'];
for (const field of requiredFields) {
if (!requestData[field]) {
return res.status(400).json({
success: false,
message: `${field}는 필수 입력 항목입니다.`
});
}
}
visitRequestModel.createVisitRequest(requestData, (err, requestId) => {
if (err) {
console.error('출입 신청 생성 오류:', err);
return res.status(500).json({
success: false,
message: '출입 신청 생성 중 오류가 발생했습니다.',
error: err.message
});
}
res.status(201).json({
success: true,
message: '출입 신청이 성공적으로 생성되었습니다.',
data: { request_id: requestId }
});
});
};
/**
* 출입 신청 목록 조회
*/
exports.getAllVisitRequests = (req, res) => {
const filters = {
status: req.query.status,
visit_date: req.query.visit_date,
start_date: req.query.start_date,
end_date: req.query.end_date,
requester_id: req.query.requester_id,
category_id: req.query.category_id
};
visitRequestModel.getAllVisitRequests(filters, (err, requests) => {
if (err) {
console.error('출입 신청 목록 조회 오류:', err);
return res.status(500).json({
success: false,
message: '출입 신청 목록 조회 중 오류가 발생했습니다.',
error: err.message
});
}
res.json({
success: true,
data: requests
});
});
};
/**
* 출입 신청 상세 조회
*/
exports.getVisitRequestById = (req, res) => {
const requestId = req.params.id;
visitRequestModel.getVisitRequestById(requestId, (err, request) => {
if (err) {
console.error('출입 신청 조회 오류:', err);
return res.status(500).json({
success: false,
message: '출입 신청 조회 중 오류가 발생했습니다.',
error: err.message
});
}
if (!request) {
return res.status(404).json({
success: false,
message: '출입 신청을 찾을 수 없습니다.'
});
}
res.json({
success: true,
data: request
});
});
};
/**
* 출입 신청 수정
*/
exports.updateVisitRequest = (req, res) => {
const requestId = req.params.id;
const requestData = req.body;
visitRequestModel.updateVisitRequest(requestId, requestData, (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: '출입 신청이 수정되었습니다.'
});
});
};
/**
* 출입 신청 삭제
*/
exports.deleteVisitRequest = (req, res) => {
const requestId = req.params.id;
visitRequestModel.deleteVisitRequest(requestId, (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: '출입 신청이 삭제되었습니다.'
});
});
};
/**
* 출입 신청 승인
*/
exports.approveVisitRequest = (req, res) => {
const requestId = req.params.id;
const approvedBy = req.user.user_id;
visitRequestModel.approveVisitRequest(requestId, approvedBy, (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: '출입 신청이 승인되었습니다.'
});
});
};
/**
* 출입 신청 반려
*/
exports.rejectVisitRequest = (req, res) => {
const requestId = req.params.id;
const approvedBy = req.user.user_id;
const rejectionReason = req.body.rejection_reason || '사유 없음';
const rejectionData = {
approved_by: approvedBy,
rejection_reason: rejectionReason
};
visitRequestModel.rejectVisitRequest(requestId, rejectionData, (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: '출입 신청이 반려되었습니다.'
});
});
};
// ==================== 방문 목적 관리 ====================
/**
* 모든 방문 목적 조회
*/
exports.getAllVisitPurposes = (req, res) => {
visitRequestModel.getAllVisitPurposes((err, purposes) => {
if (err) {
console.error('방문 목적 조회 오류:', err);
return res.status(500).json({
success: false,
message: '방문 목적 조회 중 오류가 발생했습니다.',
error: err.message
});
}
res.json({
success: true,
data: purposes
});
});
};
/**
* 활성 방문 목적만 조회
*/
exports.getActiveVisitPurposes = (req, res) => {
visitRequestModel.getActiveVisitPurposes((err, purposes) => {
if (err) {
console.error('활성 방문 목적 조회 오류:', err);
return res.status(500).json({
success: false,
message: '활성 방문 목적 조회 중 오류가 발생했습니다.',
error: err.message
});
}
res.json({
success: true,
data: purposes
});
});
};
/**
* 방문 목적 추가
*/
exports.createVisitPurpose = (req, res) => {
const purposeData = req.body;
if (!purposeData.purpose_name) {
return res.status(400).json({
success: false,
message: 'purpose_name은 필수 입력 항목입니다.'
});
}
visitRequestModel.createVisitPurpose(purposeData, (err, purposeId) => {
if (err) {
console.error('방문 목적 추가 오류:', err);
return res.status(500).json({
success: false,
message: '방문 목적 추가 중 오류가 발생했습니다.',
error: err.message
});
}
res.status(201).json({
success: true,
message: '방문 목적이 추가되었습니다.',
data: { purpose_id: purposeId }
});
});
};
/**
* 방문 목적 수정
*/
exports.updateVisitPurpose = (req, res) => {
const purposeId = req.params.id;
const purposeData = req.body;
visitRequestModel.updateVisitPurpose(purposeId, purposeData, (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: '방문 목적이 수정되었습니다.'
});
});
};
/**
* 방문 목적 삭제
*/
exports.deleteVisitPurpose = (req, res) => {
const purposeId = req.params.id;
visitRequestModel.deleteVisitPurpose(purposeId, (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: '방문 목적이 삭제되었습니다.'
});
});
};
// ==================== 안전교육 기록 관리 ====================
/**
* 안전교육 기록 생성
*/
exports.createTrainingRecord = (req, res) => {
const trainerId = req.user.user_id;
const trainingData = {
trainer_id: trainerId,
...req.body
};
// 필수 필드 검증
const requiredFields = ['request_id', 'training_date', 'training_start_time'];
for (const field of requiredFields) {
if (!trainingData[field]) {
return res.status(400).json({
success: false,
message: `${field}는 필수 입력 항목입니다.`
});
}
}
visitRequestModel.createTrainingRecord(trainingData, (err, trainingId) => {
if (err) {
console.error('안전교육 기록 생성 오류:', err);
return res.status(500).json({
success: false,
message: '안전교육 기록 생성 중 오류가 발생했습니다.',
error: err.message
});
}
// 안전교육 기록이 생성되면 출입 신청 상태를 training_completed로 변경
console.log(`[교육 완료] request_id=${trainingData.request_id} 상태를 training_completed로 변경 중...`);
visitRequestModel.updateVisitRequestStatus(trainingData.request_id, 'training_completed', (statusErr) => {
if (statusErr) {
console.error('출입 신청 상태 업데이트 오류:', statusErr);
// 에러가 발생해도 교육 기록은 생성되었으므로 성공 응답
} else {
console.log(`[교육 완료] request_id=${trainingData.request_id} 상태 변경 성공`);
}
res.status(201).json({
success: true,
message: '안전교육 기록이 생성되었습니다.',
data: { training_id: trainingId }
});
});
});
};
/**
* 특정 출입 신청의 안전교육 기록 조회
*/
exports.getTrainingRecordByRequestId = (req, res) => {
const requestId = req.params.requestId;
visitRequestModel.getTrainingRecordByRequestId(requestId, (err, record) => {
if (err) {
console.error('안전교육 기록 조회 오류:', err);
return res.status(500).json({
success: false,
message: '안전교육 기록 조회 중 오류가 발생했습니다.',
error: err.message
});
}
res.json({
success: true,
data: record || null
});
});
};
/**
* 안전교육 기록 수정
*/
exports.updateTrainingRecord = (req, res) => {
const trainingId = req.params.id;
const trainingData = req.body;
visitRequestModel.updateTrainingRecord(trainingId, trainingData, (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: '안전교육 기록이 수정되었습니다.'
});
});
};
/**
* 안전교육 완료 (서명 포함)
*/
exports.completeTraining = (req, res) => {
const trainingId = req.params.id;
const signatureData = req.body.signature_data;
if (!signatureData) {
return res.status(400).json({
success: false,
message: '서명 데이터가 필요합니다.'
});
}
visitRequestModel.completeTraining(trainingId, signatureData, (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: '안전교육 기록을 찾을 수 없습니다.'
});
}
// 교육 완료 후 출입 신청 상태를 'training_completed'로 변경
visitRequestModel.getTrainingRecordByRequestId(trainingId, (err, record) => {
if (err || !record) {
return res.json({
success: true,
message: '안전교육이 완료되었습니다.'
});
}
visitRequestModel.updateVisitRequestStatus(record.request_id, 'training_completed', (err) => {
if (err) {
console.error('출입 신청 상태 업데이트 오류:', err);
}
res.json({
success: true,
message: '안전교육이 완료되었습니다.'
});
});
});
});
};
/**
* 안전교육 기록 목록 조회
*/
exports.getTrainingRecords = (req, res) => {
const filters = {
training_date: req.query.training_date,
start_date: req.query.start_date,
end_date: req.query.end_date,
trainer_id: req.query.trainer_id
};
visitRequestModel.getTrainingRecords(filters, (err, records) => {
if (err) {
console.error('안전교육 기록 목록 조회 오류:', err);
return res.status(500).json({
success: false,
message: '안전교육 기록 목록 조회 중 오류가 발생했습니다.',
error: err.message
});
}
res.json({
success: true,
data: records
});
});
};

View File

@@ -178,36 +178,68 @@ exports.updateWorker = asyncHandler(async (req, res) => {
// 계정 생성/해제 처리
const db = await getDb();
const hasAccount = currentWorker.user_id !== null && currentWorker.user_id !== undefined;
let accountAction = null;
let accountUsername = null;
console.log('🔍 계정 생성 체크:', {
createAccount,
hasAccount,
currentWorker_user_id: currentWorker.user_id,
worker_name: workerData.worker_name
});
if (createAccount && !hasAccount && workerData.worker_name) {
// 계정 생성
console.log('✅ 계정 생성 로직 시작');
try {
console.log('🔑 사용자명 생성 중...');
const username = await generateUniqueUsername(workerData.worker_name, db);
console.log('🔑 생성된 사용자명:', username);
const hashedPassword = await bcrypt.hash('1234', 10);
console.log('🔒 비밀번호 해싱 완료');
// User 역할 조회
console.log('👤 User 역할 조회 중...');
const [userRole] = await db.query('SELECT id FROM roles WHERE name = ?', ['User']);
console.log('👤 User 역할 조회 결과:', userRole);
if (userRole && userRole.length > 0) {
console.log('💾 계정 DB 삽입 시작...');
await db.query(
`INSERT INTO users (username, password, name, worker_id, role_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, NOW(), NOW())`,
[username, hashedPassword, workerData.worker_name, id, userRole[0].id]
);
console.log('✅ 계정 DB 삽입 완료');
accountAction = 'created';
accountUsername = username;
logger.info('작업자 계정 생성 성공', { worker_id: id, username });
} else {
console.log('❌ User 역할을 찾을 수 없음');
}
} catch (accountError) {
console.error('❌ 계정 생성 오류:', accountError);
logger.error('계정 생성 실패', { worker_id: id, error: accountError.message });
accountAction = 'failed';
}
} else if (!createAccount && hasAccount) {
} else {
console.log('⏭️ 계정 생성 조건 불만족:', { createAccount, hasAccount, hasWorkerName: !!workerData.worker_name });
}
if (!createAccount && hasAccount) {
// 계정 연동 해제 (users.worker_id = NULL)
try {
await db.query('UPDATE users SET worker_id = NULL WHERE worker_id = ?', [id]);
accountAction = 'unlinked';
logger.info('작업자 계정 연동 해제 성공', { worker_id: id });
} catch (unlinkError) {
logger.error('계정 연동 해제 실패', { worker_id: id, error: unlinkError.message });
accountAction = 'unlink_failed';
}
} else if (createAccount && hasAccount) {
accountAction = 'already_exists';
}
// 작업자 관련 캐시 무효화
@@ -216,10 +248,26 @@ exports.updateWorker = asyncHandler(async (req, res) => {
logger.info('작업자 수정 성공', { worker_id: id });
// 응답 메시지 구성
let message = '작업자 정보가 성공적으로 수정되었습니다';
if (accountAction === 'created') {
message += ` (계정 생성 완료: ${accountUsername}, 초기 비밀번호: 1234)`;
} else if (accountAction === 'unlinked') {
message += ' (계정 연동 해제 완료)';
} else if (accountAction === 'already_exists') {
message += ' (이미 계정이 존재합니다)';
} else if (accountAction === 'failed') {
message += ' (계정 생성 실패)';
}
res.json({
success: true,
data: { changes },
message: '작업자 정보가 성공적으로 수정되었습니다'
data: {
changes,
account_action: accountAction,
account_username: accountUsername
},
message
});
});

View File

@@ -0,0 +1,37 @@
/**
* 마이그레이션: tbm_team_assignments 테이블 확장
* 작업자별 프로젝트/공정/작업/작업장 정보 저장 가능하도록 컬럼 추가 및 외래키 설정
*/
exports.up = async function(knex) {
// 1. workplace_category_id와 workplace_id를 UNSIGNED로 변경
await knex.raw(`
ALTER TABLE tbm_team_assignments
MODIFY COLUMN workplace_category_id INT UNSIGNED NULL COMMENT '작업자별 작업장 대분류 (공장)',
MODIFY COLUMN workplace_id INT UNSIGNED NULL COMMENT '작업자별 작업장 ID'
`);
// 2. 외래키 제약조건 추가
return knex.schema.alterTable('tbm_team_assignments', function(table) {
// 외래키 제약조건 추가
table.foreign('workplace_category_id')
.references('category_id')
.inTable('workplace_categories')
.onDelete('SET NULL')
.onUpdate('CASCADE');
table.foreign('workplace_id')
.references('workplace_id')
.inTable('workplaces')
.onDelete('SET NULL')
.onUpdate('CASCADE');
});
};
exports.down = function(knex) {
return knex.schema.alterTable('tbm_team_assignments', function(table) {
// 외래키 제약조건 제거
table.dropForeign('workplace_category_id');
table.dropForeign('workplace_id');
});
};

View File

@@ -0,0 +1,20 @@
/**
* 마이그레이션: tbm_sessions 테이블에서 불필요한 컬럼 제거
* work_description, safety_notes, start_time 컬럼 제거
*/
exports.up = function(knex) {
return knex.schema.alterTable('tbm_sessions', function(table) {
table.dropColumn('work_description');
table.dropColumn('safety_notes');
table.dropColumn('start_time');
});
};
exports.down = function(knex) {
return knex.schema.alterTable('tbm_sessions', function(table) {
table.text('work_description').nullable().comment('작업 내용');
table.text('safety_notes').nullable().comment('안전 관련 특이사항');
table.time('start_time').nullable().comment('시작 시간');
});
};

View File

@@ -0,0 +1,53 @@
/**
* 마이그레이션: 작업장 지도 이미지 기능 추가
* - workplace_categories에 layout_image 필드 추가
* - workplace_map_regions 테이블 생성 (클릭 가능한 영역 정의)
*/
exports.up = async function(knex) {
// 1. workplace_categories 테이블에 layout_image 필드 추가
await knex.schema.alterTable('workplace_categories', function(table) {
table.string('layout_image', 500).nullable().comment('공장 배치도 이미지 경로');
});
// 2. 작업장 지도 클릭 영역 정의 테이블 생성
await knex.schema.createTable('workplace_map_regions', function(table) {
table.increments('region_id').primary().comment('영역 ID');
table.integer('workplace_id').unsigned().notNullable().comment('작업장 ID');
table.integer('category_id').unsigned().notNullable().comment('공장 카테고리 ID');
// 좌표 정보 (비율 기반: 0~100%)
table.decimal('x_start', 5, 2).notNullable().comment('시작 X 좌표 (%)');
table.decimal('y_start', 5, 2).notNullable().comment('시작 Y 좌표 (%)');
table.decimal('x_end', 5, 2).notNullable().comment('끝 X 좌표 (%)');
table.decimal('y_end', 5, 2).notNullable().comment('끝 Y 좌표 (%)');
table.string('shape', 20).defaultTo('rect').comment('영역 모양 (rect, circle, polygon)');
table.text('polygon_points').nullable().comment('다각형인 경우 좌표 배열 (JSON)');
table.timestamps(true, true);
// 외래키
table.foreign('workplace_id')
.references('workplace_id')
.inTable('workplaces')
.onDelete('CASCADE')
.onUpdate('CASCADE');
table.foreign('category_id')
.references('category_id')
.inTable('workplace_categories')
.onDelete('CASCADE')
.onUpdate('CASCADE');
});
};
exports.down = async function(knex) {
// 테이블 삭제
await knex.schema.dropTableIfExists('workplace_map_regions');
// 필드 제거
await knex.schema.alterTable('workplace_categories', function(table) {
table.dropColumn('layout_image');
});
};

View File

@@ -0,0 +1,17 @@
/**
* 마이그레이션: 작업장 용도 및 표시 순서 필드 추가
*/
exports.up = function(knex) {
return knex.schema.alterTable('workplaces', function(table) {
table.string('workplace_purpose', 50).nullable().comment('작업장 용도 (작업구역, 설비, 휴게시설, 회의실 등)');
table.integer('display_priority').defaultTo(0).comment('표시 우선순위 (숫자가 작을수록 먼저 표시)');
});
};
exports.down = function(knex) {
return knex.schema.alterTable('workplaces', function(table) {
table.dropColumn('workplace_purpose');
table.dropColumn('display_priority');
});
};

View File

@@ -0,0 +1,30 @@
/**
* leader_id를 nullable로 변경
* 관리자가 TBM을 입력할 때 leader_id를 NULL로 설정하고 created_by를 사용
*/
exports.up = async function(knex) {
// 1. 외래 키 제약조건 삭제 (존재하는 경우에만)
try {
await knex.raw('ALTER TABLE tbm_sessions DROP FOREIGN KEY tbm_sessions_leader_id_foreign');
} catch (err) {
console.log('외래 키가 이미 존재하지 않음 (정상)');
}
// 2. leader_id를 nullable로 변경 (UNSIGNED 제거하여 workers.worker_id와 타입 일치)
await knex.raw('ALTER TABLE tbm_sessions MODIFY leader_id INT(11) NULL');
// 3. 외래 키 제약조건 다시 추가 (nullable 허용)
await knex.raw('ALTER TABLE tbm_sessions ADD CONSTRAINT tbm_sessions_leader_id_foreign FOREIGN KEY (leader_id) REFERENCES workers(worker_id) ON DELETE SET NULL');
};
exports.down = async function(knex) {
// 1. 외래 키 제약조건 삭제
await knex.raw('ALTER TABLE tbm_sessions DROP FOREIGN KEY tbm_sessions_leader_id_foreign');
// 2. leader_id를 NOT NULL로 되돌림
await knex.raw('ALTER TABLE tbm_sessions MODIFY leader_id INT(11) NOT NULL');
// 3. 외래 키 제약조건 다시 추가
await knex.raw('ALTER TABLE tbm_sessions ADD CONSTRAINT tbm_sessions_leader_id_foreign FOREIGN KEY (leader_id) REFERENCES workers(worker_id) ON DELETE CASCADE');
};

View File

@@ -0,0 +1,55 @@
/**
* daily_work_reports 테이블에 TBM 연동 필드 추가
* - TBM 세션 및 팀 배정과 연결
* - 작업 시간 및 오류 시간 추적
*/
exports.up = async function(knex) {
await knex.schema.table('daily_work_reports', (table) => {
// TBM 연동 필드
table.integer('tbm_session_id').unsigned().nullable()
.comment('연결된 TBM 세션 ID');
table.integer('tbm_assignment_id').unsigned().nullable()
.comment('연결된 TBM 팀 배정 ID');
// 작업 시간 추적
table.time('start_time').nullable()
.comment('작업 시작 시간');
table.time('end_time').nullable()
.comment('작업 종료 시간');
table.decimal('total_hours', 5, 2).nullable()
.comment('총 작업 시간');
table.decimal('regular_hours', 5, 2).nullable()
.comment('정규 작업 시간 (총 시간 - 오류 시간)');
table.decimal('error_hours', 5, 2).nullable()
.comment('부적합 사항 처리 시간');
// 외래 키 제약조건
table.foreign('tbm_session_id')
.references('session_id')
.inTable('tbm_sessions')
.onDelete('SET NULL');
table.foreign('tbm_assignment_id')
.references('assignment_id')
.inTable('tbm_team_assignments')
.onDelete('SET NULL');
});
};
exports.down = async function(knex) {
await knex.schema.table('daily_work_reports', (table) => {
// 외래 키 제약조건 삭제
table.dropForeign('tbm_session_id');
table.dropForeign('tbm_assignment_id');
// 컬럼 삭제
table.dropColumn('tbm_session_id');
table.dropColumn('tbm_assignment_id');
table.dropColumn('start_time');
table.dropColumn('end_time');
table.dropColumn('total_hours');
table.dropColumn('regular_hours');
table.dropColumn('error_hours');
});
};

View File

@@ -0,0 +1,152 @@
/**
* 현재 사용 중인 페이지를 pages 테이블에 업데이트
*/
exports.up = async function(knex) {
// 기존 페이지 모두 삭제
await knex('pages').del();
// 현재 사용 중인 페이지들을 등록
await knex('pages').insert([
// 공통 페이지
{
page_key: 'dashboard',
page_name: '대시보드',
page_path: '/pages/dashboard.html',
category: 'common',
description: '전체 현황 대시보드',
is_admin_only: 0,
display_order: 1
},
// 작업 관련 페이지
{
page_key: 'work.tbm',
page_name: 'TBM',
page_path: '/pages/work/tbm.html',
category: 'work',
description: 'TBM (Tool Box Meeting) 관리',
is_admin_only: 0,
display_order: 10
},
{
page_key: 'work.report_create',
page_name: '작업보고서 작성',
page_path: '/pages/work/report-create.html',
category: 'work',
description: '일일 작업보고서 작성',
is_admin_only: 0,
display_order: 11
},
{
page_key: 'work.report_view',
page_name: '작업보고서 조회',
page_path: '/pages/work/report-view.html',
category: 'work',
description: '작업보고서 조회 및 검색',
is_admin_only: 0,
display_order: 12
},
{
page_key: 'work.analysis',
page_name: '작업 분석',
page_path: '/pages/work/analysis.html',
category: 'work',
description: '작업 통계 및 분석',
is_admin_only: 0,
display_order: 13
},
// Admin 페이지
{
page_key: 'admin.accounts',
page_name: '계정 관리',
page_path: '/pages/admin/accounts.html',
category: 'admin',
description: '사용자 계정 관리',
is_admin_only: 1,
display_order: 20
},
{
page_key: 'admin.page_access',
page_name: '페이지 권한 관리',
page_path: '/pages/admin/page-access.html',
category: 'admin',
description: '사용자별 페이지 접근 권한 관리',
is_admin_only: 1,
display_order: 21
},
{
page_key: 'admin.workers',
page_name: '작업자 관리',
page_path: '/pages/admin/workers.html',
category: 'admin',
description: '작업자 정보 관리',
is_admin_only: 1,
display_order: 22
},
{
page_key: 'admin.projects',
page_name: '프로젝트 관리',
page_path: '/pages/admin/projects.html',
category: 'admin',
description: '프로젝트 관리',
is_admin_only: 1,
display_order: 23
},
{
page_key: 'admin.workplaces',
page_name: '작업장 관리',
page_path: '/pages/admin/workplaces.html',
category: 'admin',
description: '작업장소 관리',
is_admin_only: 1,
display_order: 24
},
{
page_key: 'admin.codes',
page_name: '코드 관리',
page_path: '/pages/admin/codes.html',
category: 'admin',
description: '시스템 코드 관리',
is_admin_only: 1,
display_order: 25
},
{
page_key: 'admin.tasks',
page_name: '작업 관리',
page_path: '/pages/admin/tasks.html',
category: 'admin',
description: '작업 유형 관리',
is_admin_only: 1,
display_order: 26
},
// 프로필 페이지
{
page_key: 'profile.info',
page_name: '내 정보',
page_path: '/pages/profile/info.html',
category: 'profile',
description: '내 프로필 정보',
is_admin_only: 0,
display_order: 30
},
{
page_key: 'profile.password',
page_name: '비밀번호 변경',
page_path: '/pages/profile/password.html',
category: 'profile',
description: '비밀번호 변경',
is_admin_only: 0,
display_order: 31
}
]);
console.log('✅ 현재 사용 중인 페이지 목록 업데이트 완료');
};
exports.down = async function(knex) {
await knex('pages').del();
console.log('✅ 페이지 목록 삭제 완료');
};

View File

@@ -0,0 +1,57 @@
/**
* Migration: Create vacation_requests table
* Purpose: Track vacation request workflow (request, approval/rejection)
* Date: 2026-01-29
*/
exports.up = async function(knex) {
// Create vacation_requests table
await knex.schema.createTable('vacation_requests', (table) => {
table.increments('request_id').primary().comment('휴가 신청 ID');
// 작업자 정보
table.integer('worker_id').notNullable().comment('작업자 ID');
table.foreign('worker_id').references('worker_id').inTable('workers').onDelete('CASCADE');
// 휴가 정보
table.integer('vacation_type_id').unsigned().notNullable().comment('휴가 유형 ID');
table.foreign('vacation_type_id').references('id').inTable('vacation_types').onDelete('RESTRICT');
table.date('start_date').notNullable().comment('휴가 시작일');
table.date('end_date').notNullable().comment('휴가 종료일');
table.decimal('days_used', 4, 1).notNullable().comment('사용 일수 (0.5일 단위)');
table.text('reason').nullable().comment('휴가 사유');
// 신청 및 승인 정보
table.enum('status', ['pending', 'approved', 'rejected'])
.notNullable()
.defaultTo('pending')
.comment('승인 상태: pending(대기), approved(승인), rejected(거부)');
table.integer('requested_by').notNullable().comment('신청자 user_id');
table.foreign('requested_by').references('user_id').inTable('users').onDelete('RESTRICT');
table.integer('reviewed_by').nullable().comment('승인/거부자 user_id');
table.foreign('reviewed_by').references('user_id').inTable('users').onDelete('SET NULL');
table.timestamp('reviewed_at').nullable().comment('승인/거부 일시');
table.text('review_note').nullable().comment('승인/거부 메모');
// 타임스탬프
table.timestamp('created_at').defaultTo(knex.fn.now()).comment('신청 일시');
table.timestamp('updated_at').defaultTo(knex.fn.now()).comment('수정 일시');
// 인덱스
table.index('worker_id', 'idx_vacation_requests_worker');
table.index('status', 'idx_vacation_requests_status');
table.index(['start_date', 'end_date'], 'idx_vacation_requests_dates');
});
console.log('✅ vacation_requests 테이블 생성 완료');
};
exports.down = async function(knex) {
await knex.schema.dropTableIfExists('vacation_requests');
console.log('✅ vacation_requests 테이블 삭제 완료');
};

View File

@@ -0,0 +1,84 @@
/**
* Migration: Register attendance management pages
* Purpose: Add 4 new pages to pages table for attendance management system
* Date: 2026-01-29
*/
exports.up = async function(knex) {
// 페이지 등록 (실제 pages 테이블 컬럼에 맞춤)
await knex('pages').insert([
{
page_key: 'daily-attendance',
page_name: '일일 출퇴근 입력',
page_path: '/pages/common/daily-attendance.html',
description: '일일 출퇴근 기록 입력 페이지 (관리자/조장)',
category: 'common',
is_admin_only: false,
display_order: 50
},
{
page_key: 'monthly-attendance',
page_name: '월별 출퇴근 현황',
page_path: '/pages/common/monthly-attendance.html',
description: '월별 출퇴근 현황 조회 페이지',
category: 'common',
is_admin_only: false,
display_order: 51
},
{
page_key: 'vacation-management',
page_name: '휴가 관리',
page_path: '/pages/common/vacation-management.html',
description: '휴가 신청 및 승인 관리 페이지',
category: 'common',
is_admin_only: false,
display_order: 52
},
{
page_key: 'attendance-report-comparison',
page_name: '출퇴근-작업보고서 대조',
page_path: '/pages/admin/attendance-report-comparison.html',
description: '출퇴근 기록과 작업보고서 대조 페이지 (관리자)',
category: 'admin',
is_admin_only: true,
display_order: 120
}
]);
console.log('✅ 출퇴근 관리 페이지 4개 등록 완료');
// Admin 사용자(user_id=1)에게 페이지 접근 권한 부여
const adminUserId = 1;
const pages = await knex('pages')
.whereIn('page_key', [
'daily-attendance',
'monthly-attendance',
'vacation-management',
'attendance-report-comparison'
])
.select('id');
const accessRecords = pages.map(page => ({
user_id: adminUserId,
page_id: page.id,
can_access: true,
granted_by: adminUserId
}));
await knex('user_page_access').insert(accessRecords);
console.log('✅ Admin 사용자에게 출퇴근 관리 페이지 접근 권한 부여 완료');
};
exports.down = async function(knex) {
// 페이지 삭제 (user_page_access는 FK CASCADE로 자동 삭제됨)
await knex('pages')
.whereIn('page_key', [
'daily-attendance',
'monthly-attendance',
'vacation-management',
'attendance-report-comparison'
])
.delete();
console.log('✅ 출퇴근 관리 페이지 삭제 완료');
};

View File

@@ -0,0 +1,35 @@
/**
* 출퇴근 출근 여부 필드 추가
* 아침 출근 확인용 간단한 필드
*/
exports.up = async function(knex) {
// 컬럼 존재 여부 확인
const hasColumn = await knex.schema.hasColumn('daily_attendance_records', 'is_present');
if (!hasColumn) {
await knex.schema.table('daily_attendance_records', (table) => {
// 출근 여부 (아침에 체크)
table.boolean('is_present').defaultTo(true).comment('출근 여부');
});
// 기존 데이터는 모두 출근으로 처리
await knex('daily_attendance_records')
.whereNotNull('id')
.update({ is_present: true });
console.log('✅ is_present 컬럼 추가 완료');
} else {
console.log('⏭️ is_present 컬럼이 이미 존재합니다');
}
};
exports.down = async function(knex) {
const hasColumn = await knex.schema.hasColumn('daily_attendance_records', 'is_present');
if (hasColumn) {
await knex.schema.table('daily_attendance_records', (table) => {
table.dropColumn('is_present');
});
}
};

View File

@@ -0,0 +1,57 @@
/**
* 휴가 관리 페이지 분리 및 등록
* - 기존 vacation-management.html을 2개 페이지로 분리
* - vacation-request.html: 작업자 휴가 신청 및 본인 내역 확인
* - vacation-management.html: 관리자 휴가 승인/직접입력/전체내역 (3개 탭)
*/
exports.up = async function(knex) {
// 기존 vacation-management 페이지 삭제
await knex('pages')
.where('page_key', 'vacation-management')
.del();
// 새로운 휴가 관리 페이지 2개 등록
await knex('pages').insert([
{
page_key: 'vacation-request',
page_name: '휴가 신청',
page_path: '/pages/common/vacation-request.html',
category: 'common',
description: '작업자가 휴가를 신청하고 본인의 신청 내역을 확인하는 페이지',
is_admin_only: 0,
display_order: 51
},
{
page_key: 'vacation-management',
page_name: '휴가 관리',
page_path: '/pages/common/vacation-management.html',
category: 'common',
description: '관리자가 휴가 승인, 직접 입력, 전체 내역을 관리하는 페이지',
is_admin_only: 1,
display_order: 52
}
]);
console.log('✅ 휴가 관리 페이지 분리 완료 (기존 1개 → 신규 2개)');
};
exports.down = async function(knex) {
// 새로운 페이지 삭제
await knex('pages')
.whereIn('page_key', ['vacation-request', 'vacation-management'])
.del();
// 기존 vacation-management 페이지 복원
await knex('pages').insert({
page_key: 'vacation-management',
page_name: '휴가 관리',
page_path: '/pages/common/vacation-management.html.old',
category: 'common',
description: '휴가 신청 및 승인 관리',
is_admin_only: 0,
display_order: 50
});
console.log('✅ 휴가 관리 페이지 롤백 완료');
};

View File

@@ -0,0 +1,55 @@
/**
* vacation_types 테이블 확장
* - 특별 휴가 유형 추가 기능
* - 차감 우선순위 관리
* - 시스템 기본 휴가 보호
*/
exports.up = async function(knex) {
// vacation_types 테이블 확장
await knex.schema.table('vacation_types', (table) => {
table.boolean('is_special').defaultTo(false).comment('특별 휴가 여부 (장기근속, 출산 등)');
table.integer('priority').defaultTo(99).comment('차감 우선순위 (낮을수록 먼저 차감)');
table.text('description').nullable().comment('휴가 설명');
table.boolean('is_system').defaultTo(true).comment('시스템 기본 휴가 (삭제 불가)');
});
// 기존 휴가 유형에 우선순위 설정
await knex('vacation_types').where('type_code', 'ANNUAL').update({
priority: 10,
is_system: true,
description: '근로기준법에 따른 연차 유급휴가'
});
await knex('vacation_types').where('type_code', 'HALF_ANNUAL').update({
priority: 10,
is_system: true,
description: '반일 연차 (0.5일)'
});
await knex('vacation_types').where('type_code', 'SICK').update({
priority: 20,
is_system: true,
description: '병가'
});
await knex('vacation_types').where('type_code', 'SPECIAL').update({
priority: 0,
is_system: true,
description: '경조사 휴가 (무급)'
});
console.log('✅ vacation_types 테이블 확장 완료');
};
exports.down = async function(knex) {
// 컬럼 삭제
await knex.schema.table('vacation_types', (table) => {
table.dropColumn('is_special');
table.dropColumn('priority');
table.dropColumn('description');
table.dropColumn('is_system');
});
console.log('✅ vacation_types 테이블 롤백 완료');
};

View File

@@ -0,0 +1,87 @@
/**
* vacation_balance_details 테이블 생성 및 데이터 마이그레이션
* - 작업자별, 휴가 유형별, 연도별 휴가 잔액 관리
* - 기존 worker_vacation_balance 데이터 이관
*/
exports.up = async function(knex) {
// vacation_balance_details 테이블 생성
await knex.schema.createTable('vacation_balance_details', (table) => {
table.increments('id').primary();
table.integer('worker_id').notNullable().comment('작업자 ID');
table.integer('vacation_type_id').unsigned().notNullable().comment('휴가 유형 ID');
table.integer('year').notNullable().comment('연도');
table.decimal('total_days', 4, 1).defaultTo(0).comment('총 발생 일수');
table.decimal('used_days', 4, 1).defaultTo(0).comment('사용 일수');
table.text('notes').nullable().comment('비고');
table.integer('created_by').notNullable().comment('생성자 ID');
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
// 인덱스
table.unique(['worker_id', 'vacation_type_id', 'year'], 'unique_worker_vacation_year');
table.index(['worker_id', 'year'], 'idx_worker_year');
table.index('vacation_type_id', 'idx_vacation_type');
// 외래키
table.foreign('worker_id').references('worker_id').inTable('workers').onDelete('CASCADE');
table.foreign('vacation_type_id').references('id').inTable('vacation_types').onDelete('RESTRICT');
table.foreign('created_by').references('user_id').inTable('users');
});
// remaining_days를 generated column으로 추가 (Raw SQL)
await knex.raw(`
ALTER TABLE vacation_balance_details
ADD COLUMN remaining_days DECIMAL(4,1)
GENERATED ALWAYS AS (total_days - used_days) STORED
COMMENT '잔여 일수'
`);
console.log('✅ vacation_balance_details 테이블 생성 완료');
// 기존 worker_vacation_balance 데이터를 vacation_balance_details로 마이그레이션
const existingBalances = await knex('worker_vacation_balance').select('*');
if (existingBalances.length > 0) {
// ANNUAL 휴가 유형 ID 조회
const annualType = await knex('vacation_types')
.where('type_code', 'ANNUAL')
.first();
if (!annualType) {
throw new Error('ANNUAL 휴가 유형을 찾을 수 없습니다');
}
// 관리자 사용자 ID 조회 (created_by 용)
// role_id 1 = System Admin, 2 = Admin
const adminUser = await knex('users')
.whereIn('role_id', [1, 2])
.first();
const createdById = adminUser ? adminUser.user_id : 1;
// 데이터 변환 및 삽입
const balanceDetails = existingBalances.map(balance => ({
worker_id: balance.worker_id,
vacation_type_id: annualType.id,
year: balance.year,
total_days: balance.total_annual_leave || 0,
used_days: balance.used_annual_leave || 0,
notes: 'Migrated from worker_vacation_balance',
created_by: createdById,
created_at: balance.created_at,
updated_at: balance.updated_at
}));
await knex('vacation_balance_details').insert(balanceDetails);
console.log(`${balanceDetails.length}건의 기존 휴가 데이터 마이그레이션 완료`);
}
};
exports.down = async function(knex) {
// vacation_balance_details 테이블 삭제
await knex.schema.dropTableIfExists('vacation_balance_details');
console.log('✅ vacation_balance_details 테이블 롤백 완료');
};

View File

@@ -0,0 +1,38 @@
/**
* 새로운 휴가 관리 페이지 등록
* - annual-vacation-overview: 연간 연차 현황 (차트)
* - vacation-allocation: 휴가 발생 입력 및 관리
*/
exports.up = async function(knex) {
await knex('pages').insert([
{
page_key: 'annual-vacation-overview',
page_name: '연간 연차 현황',
page_path: '/pages/common/annual-vacation-overview.html',
category: 'common',
description: '모든 작업자의 연간 연차 현황을 차트로 시각화',
is_admin_only: 1,
display_order: 54
},
{
page_key: 'vacation-allocation',
page_name: '휴가 발생 입력',
page_path: '/pages/common/vacation-allocation.html',
category: 'common',
description: '작업자별 휴가 발생 입력 및 특별 휴가 관리',
is_admin_only: 1,
display_order: 55
}
]);
console.log('✅ 휴가 관리 신규 페이지 2개 등록 완료');
};
exports.down = async function(knex) {
await knex('pages')
.whereIn('page_key', ['annual-vacation-overview', 'vacation-allocation'])
.del();
console.log('✅ 휴가 관리 페이지 롤백 완료');
};

View File

@@ -0,0 +1,153 @@
/**
* 마이그레이션: 출입 신청 및 안전교육 시스템
* - 방문 목적 타입 테이블
* - 출입 신청 테이블
* - 안전교육 기록 테이블
*/
exports.up = async function(knex) {
// 1. 방문 목적 타입 테이블 생성
await knex.schema.createTable('visit_purpose_types', function(table) {
table.increments('purpose_id').primary().comment('방문 목적 ID');
table.string('purpose_name', 100).notNullable().comment('방문 목적명');
table.integer('display_order').defaultTo(0).comment('표시 순서');
table.boolean('is_active').defaultTo(true).comment('활성 여부');
table.timestamp('created_at').defaultTo(knex.fn.now());
});
// 초기 데이터 삽입
await knex('visit_purpose_types').insert([
{ purpose_name: '외주작업', display_order: 1, is_active: true },
{ purpose_name: '검사', display_order: 2, is_active: true },
{ purpose_name: '견학', display_order: 3, is_active: true },
{ purpose_name: '기타', display_order: 99, is_active: true }
]);
// 2. 출입 신청 테이블 생성
await knex.schema.createTable('workplace_visit_requests', function(table) {
table.increments('request_id').primary().comment('신청 ID');
// 신청자 정보
table.integer('requester_id').notNullable().comment('신청자 user_id');
// 방문자 정보
table.string('visitor_company', 200).notNullable().comment('방문자 소속 (회사명 또는 "일용직")');
table.integer('visitor_count').defaultTo(1).comment('방문 인원');
// 방문 장소
table.integer('category_id').unsigned().notNullable().comment('방문 구역 (공장)');
table.integer('workplace_id').unsigned().notNullable().comment('방문 작업장');
// 방문 일시
table.date('visit_date').notNullable().comment('방문 날짜');
table.time('visit_time').notNullable().comment('방문 시간');
// 방문 목적
table.integer('purpose_id').unsigned().notNullable().comment('방문 목적 ID');
table.text('notes').nullable().comment('비고');
// 상태 관리
table.enum('status', ['pending', 'approved', 'rejected', 'training_completed'])
.defaultTo('pending')
.comment('신청 상태');
// 승인 정보
table.integer('approved_by').nullable().comment('승인자 user_id');
table.timestamp('approved_at').nullable().comment('승인 시간');
table.text('rejection_reason').nullable().comment('반려 사유');
// 타임스탬프
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
// 외래키
table.foreign('requester_id')
.references('user_id')
.inTable('users')
.onDelete('RESTRICT')
.onUpdate('CASCADE');
table.foreign('category_id')
.references('category_id')
.inTable('workplace_categories')
.onDelete('RESTRICT')
.onUpdate('CASCADE');
table.foreign('workplace_id')
.references('workplace_id')
.inTable('workplaces')
.onDelete('RESTRICT')
.onUpdate('CASCADE');
table.foreign('purpose_id')
.references('purpose_id')
.inTable('visit_purpose_types')
.onDelete('RESTRICT')
.onUpdate('CASCADE');
table.foreign('approved_by')
.references('user_id')
.inTable('users')
.onDelete('SET NULL')
.onUpdate('CASCADE');
// 인덱스
table.index('visit_date', 'idx_visit_date');
table.index('status', 'idx_status');
table.index(['visit_date', 'status'], 'idx_visit_date_status');
});
// 3. 안전교육 기록 테이블 생성
await knex.schema.createTable('safety_training_records', function(table) {
table.increments('training_id').primary().comment('교육 기록 ID');
table.integer('request_id').unsigned().notNullable().comment('출입 신청 ID');
// 교육 진행 정보
table.integer('trainer_id').notNullable().comment('교육 진행자 user_id');
table.date('training_date').notNullable().comment('교육 날짜');
table.time('training_start_time').notNullable().comment('교육 시작 시간');
table.time('training_end_time').nullable().comment('교육 종료 시간');
// 교육 내용
table.text('training_topics').nullable().comment('교육 내용 (JSON 배열)');
// 서명 데이터 (Base64 이미지)
table.text('signature_data', 'longtext').nullable().comment('교육 이수자 서명 (Base64 PNG)');
// 완료 정보
table.timestamp('completed_at').nullable().comment('교육 완료 시간');
// 타임스탬프
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
// 외래키
table.foreign('request_id')
.references('request_id')
.inTable('workplace_visit_requests')
.onDelete('CASCADE')
.onUpdate('CASCADE');
table.foreign('trainer_id')
.references('user_id')
.inTable('users')
.onDelete('RESTRICT')
.onUpdate('CASCADE');
// 인덱스
table.index('training_date', 'idx_training_date');
table.index('request_id', 'idx_request_id');
});
console.log('✅ 출입 신청 및 안전교육 시스템 테이블 생성 완료');
};
exports.down = async function(knex) {
// 역순으로 테이블 삭제
await knex.schema.dropTableIfExists('safety_training_records');
await knex.schema.dropTableIfExists('workplace_visit_requests');
await knex.schema.dropTableIfExists('visit_purpose_types');
console.log('✅ 출입 신청 및 안전교육 시스템 테이블 삭제 완료');
};

View File

@@ -0,0 +1,50 @@
/**
* 마이그레이션: 출입 신청 및 안전관리 페이지 등록
*/
exports.up = async function(knex) {
// 1. 출입 신청 페이지 등록
await knex('pages').insert({
page_key: 'visit-request',
page_name: '출입 신청',
page_path: '/pages/work/visit-request.html',
category: 'work',
description: '작업장 출입 신청 및 안전교육 신청',
is_admin_only: 0,
display_order: 15
});
// 2. 안전관리 대시보드 페이지 등록
await knex('pages').insert({
page_key: 'safety-management',
page_name: '안전관리',
page_path: '/pages/admin/safety-management.html',
category: 'admin',
description: '출입 신청 승인 및 안전교육 관리',
is_admin_only: 0,
display_order: 60
});
// 3. 안전교육 진행 페이지 등록
await knex('pages').insert({
page_key: 'safety-training-conduct',
page_name: '안전교육 진행',
page_path: '/pages/admin/safety-training-conduct.html',
category: 'admin',
description: '안전교육 실시 및 서명 관리',
is_admin_only: 0,
display_order: 61
});
console.log('✅ 출입 신청 및 안전관리 페이지 등록 완료');
};
exports.down = async function(knex) {
await knex('pages').whereIn('page_key', [
'visit-request',
'safety-management',
'safety-training-conduct'
]).delete();
console.log('✅ 출입 신청 및 안전관리 페이지 삭제 완료');
};

View File

@@ -297,7 +297,7 @@ class AttendanceModel {
// 휴가 유형 정보 조회
const [vacationTypes] = await db.execute(
'SELECT id, type_code, type_name, hours_deduction, description, is_active, created_at, updated_at FROM vacation_types WHERE type_code = ?',
'SELECT id, type_code, type_name, deduct_days, is_active, created_at, updated_at FROM vacation_types WHERE type_code = ?',
[vacationType]
);
@@ -391,7 +391,7 @@ class AttendanceModel {
static async getVacationTypes() {
const db = await getDb();
const [rows] = await db.execute(
'SELECT id, type_code, type_name, hours_deduction, description, is_active, created_at, updated_at FROM vacation_types WHERE is_active = TRUE ORDER BY hours_deduction DESC'
'SELECT id, type_code, type_name, deduct_days, is_active, created_at, updated_at FROM vacation_types WHERE is_active = TRUE ORDER BY deduct_days DESC'
);
return rows;
}
@@ -458,6 +458,68 @@ class AttendanceModel {
const [rows] = await db.execute(query, params);
return rows;
}
// 출근 체크 기록 생성 또는 업데이트
static async upsertCheckin(checkinData) {
const db = await getDb();
const { worker_id, record_date, is_present } = checkinData;
// 해당 날짜에 기록이 있는지 확인
const [existing] = await db.execute(
'SELECT id FROM daily_attendance_records WHERE worker_id = ? AND record_date = ?',
[worker_id, record_date]
);
if (existing.length > 0) {
// 업데이트
await db.execute(
'UPDATE daily_attendance_records SET is_present = ? WHERE id = ?',
[is_present, existing[0].id]
);
return existing[0].id;
} else {
// 새로 생성 (기본값으로)
const [result] = await db.execute(
`INSERT INTO daily_attendance_records
(worker_id, record_date, is_present, attendance_type_id, created_by)
VALUES (?, ?, ?, 1, 1)`,
[worker_id, record_date, is_present]
);
return result.insertId;
}
}
// 특정 날짜의 출근 체크 목록 조회 (휴가 정보 포함)
static async getCheckinList(date) {
const db = await getDb();
const query = `
SELECT
w.worker_id,
w.worker_name,
w.job_type,
w.employment_status,
COALESCE(dar.is_present, TRUE) as is_present,
dar.id as record_id,
vr.request_id as vacation_request_id,
vr.status as vacation_status,
vt.type_name as vacation_type_name,
vt.type_code as vacation_type_code,
vr.days_used as vacation_days
FROM workers w
LEFT JOIN daily_attendance_records dar
ON w.worker_id = dar.worker_id AND dar.record_date = ?
LEFT JOIN vacation_requests vr
ON w.worker_id = vr.worker_id
AND ? BETWEEN vr.start_date AND vr.end_date
AND vr.status = 'approved'
LEFT JOIN vacation_types vt ON vr.vacation_type_id = vt.id
WHERE w.employment_status = 'employed'
ORDER BY w.worker_name
`;
const [rows] = await db.execute(query, [date, date]);
return rows;
}
}
module.exports = AttendanceModel;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,288 @@
/**
* vacationBalanceModel.js
* 휴가 잔액 관련 데이터베이스 쿼리 모델
*/
const { getDb } = require('../dbPool');
const vacationBalanceModel = {
/**
* 특정 작업자의 모든 휴가 잔액 조회 (특정 연도)
*/
async getByWorkerAndYear(workerId, year, callback) {
try {
const db = await getDb();
const query = `
SELECT
vbd.*,
vt.type_name,
vt.type_code,
vt.priority,
vt.is_special
FROM vacation_balance_details vbd
INNER JOIN vacation_types vt ON vbd.vacation_type_id = vt.id
WHERE vbd.worker_id = ? AND vbd.year = ?
ORDER BY vt.priority ASC, vt.type_name ASC
`;
const [rows] = await db.query(query, [workerId, year]);
callback(null, rows);
} catch (error) {
callback(error);
}
},
/**
* 특정 작업자의 특정 휴가 유형 잔액 조회
*/
async getByWorkerTypeYear(workerId, vacationTypeId, year, callback) {
try {
const db = await getDb();
const query = `
SELECT
vbd.*,
vt.type_name,
vt.type_code
FROM vacation_balance_details vbd
INNER JOIN vacation_types vt ON vbd.vacation_type_id = vt.id
WHERE vbd.worker_id = ?
AND vbd.vacation_type_id = ?
AND vbd.year = ?
`;
const [rows] = await db.query(query, [workerId, vacationTypeId, year]);
callback(null, rows);
} catch (error) {
callback(error);
}
},
/**
* 모든 작업자의 휴가 잔액 조회 (특정 연도)
* - 연간 연차 현황 차트용
*/
async getAllByYear(year, callback) {
try {
const db = await getDb();
const query = `
SELECT
vbd.*,
w.worker_name,
w.employment_status,
vt.type_name,
vt.type_code,
vt.priority
FROM vacation_balance_details vbd
INNER JOIN workers w ON vbd.worker_id = w.worker_id
INNER JOIN vacation_types vt ON vbd.vacation_type_id = vt.id
WHERE vbd.year = ?
AND w.employment_status = 'employed'
ORDER BY w.worker_name ASC, vt.priority ASC
`;
const [rows] = await db.query(query, [year]);
callback(null, rows);
} catch (error) {
callback(error);
}
},
/**
* 휴가 잔액 생성
*/
async create(balanceData, callback) {
try {
const db = await getDb();
const query = `INSERT INTO vacation_balance_details SET ?`;
const [rows] = await db.query(query, balanceData);
callback(null, rows);
} catch (error) {
callback(error);
}
},
/**
* 휴가 잔액 수정
*/
async update(id, updateData, callback) {
try {
const db = await getDb();
const query = `UPDATE vacation_balance_details SET ? WHERE id = ?`;
const [rows] = await db.query(query, [updateData, id]);
callback(null, rows);
} catch (error) {
callback(error);
}
},
/**
* 휴가 잔액 삭제
*/
async delete(id, callback) {
try {
const db = await getDb();
const query = `DELETE FROM vacation_balance_details WHERE id = ?`;
const [rows] = await db.query(query, [id]);
callback(null, rows);
} catch (error) {
callback(error);
}
},
/**
* 작업자의 휴가 사용 일수 업데이트 (차감)
* - 휴가 신청 승인 시 호출
*/
async deductDays(workerId, vacationTypeId, year, daysToDeduct, callback) {
try {
const db = await getDb();
const query = `
UPDATE vacation_balance_details
SET used_days = used_days + ?,
updated_at = NOW()
WHERE worker_id = ?
AND vacation_type_id = ?
AND year = ?
`;
const [rows] = await db.query(query, [daysToDeduct, workerId, vacationTypeId, year]);
callback(null, rows);
} catch (error) {
callback(error);
}
},
/**
* 작업자의 휴가 사용 일수 복구 (취소)
* - 휴가 신청 취소/거부 시 호출
*/
async restoreDays(workerId, vacationTypeId, year, daysToRestore, callback) {
try {
const db = await getDb();
const query = `
UPDATE vacation_balance_details
SET used_days = GREATEST(0, used_days - ?),
updated_at = NOW()
WHERE worker_id = ?
AND vacation_type_id = ?
AND year = ?
`;
const [rows] = await db.query(query, [daysToRestore, workerId, vacationTypeId, year]);
callback(null, rows);
} catch (error) {
callback(error);
}
},
/**
* 특정 작업자의 사용 가능한 휴가 일수 확인
* - 우선순위가 높은 순서대로 차감 가능 여부 확인
*/
async getAvailableVacationDays(workerId, year, callback) {
try {
const db = await getDb();
const query = `
SELECT
vbd.id,
vbd.vacation_type_id,
vt.type_name,
vt.type_code,
vt.priority,
vbd.total_days,
vbd.used_days,
vbd.remaining_days
FROM vacation_balance_details vbd
INNER JOIN vacation_types vt ON vbd.vacation_type_id = vt.id
WHERE vbd.worker_id = ?
AND vbd.year = ?
AND vbd.remaining_days > 0
ORDER BY vt.priority ASC
`;
const [rows] = await db.query(query, [workerId, year]);
callback(null, rows);
} catch (error) {
callback(error);
}
},
/**
* 작업자별 휴가 잔액 일괄 생성 (연도별)
* - 매년 초 또는 입사 시 사용
*/
async bulkCreate(balances, callback) {
try {
const db = await getDb();
if (!balances || balances.length === 0) {
return callback(new Error('생성할 휴가 잔액 데이터가 없습니다'));
}
const query = `INSERT INTO vacation_balance_details
(worker_id, vacation_type_id, year, total_days, used_days, notes, created_by)
VALUES ?`;
const values = balances.map(b => [
b.worker_id,
b.vacation_type_id,
b.year,
b.total_days || 0,
b.used_days || 0,
b.notes || null,
b.created_by
]);
const [rows] = await db.query(query, [values]);
callback(null, rows);
} catch (error) {
callback(error);
}
},
/**
* 근속년수 기반 연차 일수 계산 (한국 근로기준법)
* @param {Date} hireDate - 입사일
* @param {number} targetYear - 대상 연도
* @returns {number} - 부여받을 연차 일수
*/
calculateAnnualLeaveDays(hireDate, targetYear) {
const hire = new Date(hireDate);
const targetDate = new Date(targetYear, 0, 1);
// 근속 월수 계산
const monthsDiff = (targetDate.getFullYear() - hire.getFullYear()) * 12
+ (targetDate.getMonth() - hire.getMonth());
// 1년 미만: 월 1일
if (monthsDiff < 12) {
return Math.floor(monthsDiff);
}
// 1년 이상: 15일 기본 + 2년마다 1일 추가 (최대 25일)
const yearsWorked = Math.floor(monthsDiff / 12);
const additionalDays = Math.floor((yearsWorked - 1) / 2);
return Math.min(15 + additionalDays, 25);
},
/**
* 특정 ID로 휴가 잔액 조회
*/
async getById(id, callback) {
try {
const db = await getDb();
const query = `
SELECT
vbd.*,
w.worker_name,
vt.type_name,
vt.type_code
FROM vacation_balance_details vbd
INNER JOIN workers w ON vbd.worker_id = w.worker_id
INNER JOIN vacation_types vt ON vbd.vacation_type_id = vt.id
WHERE vbd.id = ?
`;
const [rows] = await db.query(query, [id]);
callback(null, rows);
} catch (error) {
callback(error);
}
}
};
module.exports = vacationBalanceModel;

View File

@@ -0,0 +1,271 @@
/**
* vacationRequestModel.js
* 휴가 신청 관련 데이터베이스 쿼리 모델
*/
const { getDb } = require('../dbPool');
const vacationRequestModel = {
/**
* 휴가 신청 생성
*/
async create(requestData, callback) {
try {
const db = await getDb();
const query = `INSERT INTO vacation_requests SET ?`;
const [result] = await db.query(query, requestData);
callback(null, result);
} catch (error) {
callback(error);
}
},
/**
* 휴가 신청 목록 조회 (필터링 지원)
*/
async getAll(filters = {}, callback) {
try {
const db = await getDb();
let query = `
SELECT
vr.*,
w.worker_name,
vt.type_name as vacation_type_name,
vt.deduct_days as vacation_deduct_days,
requester.name as requester_name,
reviewer.name as reviewer_name
FROM vacation_requests vr
INNER JOIN workers w ON vr.worker_id = w.worker_id
INNER JOIN vacation_types vt ON vr.vacation_type_id = vt.id
LEFT JOIN users requester ON vr.requested_by = requester.user_id
LEFT JOIN users reviewer ON vr.reviewed_by = reviewer.user_id
WHERE 1=1
`;
const params = [];
// 작업자 필터
if (filters.worker_id) {
query += ` AND vr.worker_id = ?`;
params.push(filters.worker_id);
}
// 상태 필터
if (filters.status) {
query += ` AND vr.status = ?`;
params.push(filters.status);
}
// 기간 필터
if (filters.start_date) {
query += ` AND vr.start_date >= ?`;
params.push(filters.start_date);
}
if (filters.end_date) {
query += ` AND vr.end_date <= ?`;
params.push(filters.end_date);
}
// 휴가 유형 필터
if (filters.vacation_type_id) {
query += ` AND vr.vacation_type_id = ?`;
params.push(filters.vacation_type_id);
}
query += ` ORDER BY vr.created_at DESC`;
const [rows] = await db.query(query, params);
callback(null, rows);
} catch (error) {
callback(error);
}
},
/**
* 특정 휴가 신청 조회
*/
async getById(requestId, callback) {
try {
const db = await getDb();
const query = `
SELECT
vr.*,
w.worker_name,
w.phone_number as worker_phone,
w.email as worker_email,
vt.type_name as vacation_type_name,
vt.type_code as vacation_type_code,
vt.deduct_days as vacation_deduct_days,
requester.name as requester_name,
requester.username as requester_username,
reviewer.name as reviewer_name,
reviewer.username as reviewer_username
FROM vacation_requests vr
INNER JOIN workers w ON vr.worker_id = w.worker_id
INNER JOIN vacation_types vt ON vr.vacation_type_id = vt.id
LEFT JOIN users requester ON vr.requested_by = requester.user_id
LEFT JOIN users reviewer ON vr.reviewed_by = reviewer.user_id
WHERE vr.request_id = ?
`;
const [rows] = await db.query(query, [requestId]);
callback(null, rows);
} catch (error) {
callback(error);
}
},
/**
* 휴가 신청 수정
*/
async update(requestId, updateData, callback) {
try {
const db = await getDb();
const query = `UPDATE vacation_requests SET ? WHERE request_id = ?`;
const [result] = await db.query(query, [updateData, requestId]);
callback(null, result);
} catch (error) {
callback(error);
}
},
/**
* 휴가 신청 삭제
*/
async delete(requestId, callback) {
try {
const db = await getDb();
const query = `DELETE FROM vacation_requests WHERE request_id = ?`;
const [result] = await db.query(query, [requestId]);
callback(null, result);
} catch (error) {
callback(error);
}
},
/**
* 휴가 신청 승인/거부
*/
async updateStatus(requestId, statusData, callback) {
try {
const db = await getDb();
const query = `
UPDATE vacation_requests
SET
status = ?,
reviewed_by = ?,
reviewed_at = NOW(),
review_note = ?
WHERE request_id = ?
`;
const [result] = await db.query(query, [
statusData.status,
statusData.reviewed_by,
statusData.review_note || null,
requestId
]);
callback(null, result);
} catch (error) {
callback(error);
}
},
/**
* 특정 작업자의 대기 중인 휴가 신청 수
*/
async getPendingCount(workerId, callback) {
try {
const db = await getDb();
const query = `
SELECT COUNT(*) as count
FROM vacation_requests
WHERE worker_id = ? AND status = 'pending'
`;
const [rows] = await db.query(query, [workerId]);
callback(null, rows);
} catch (error) {
callback(error);
}
},
/**
* 특정 작업자의 승인된 휴가 일수 합계 (특정 기간)
*/
async getApprovedDaysInPeriod(workerId, startDate, endDate, callback) {
try {
const db = await getDb();
const query = `
SELECT COALESCE(SUM(days_used), 0) as total_days
FROM vacation_requests
WHERE worker_id = ?
AND status = 'approved'
AND start_date >= ?
AND end_date <= ?
`;
const [rows] = await db.query(query, [workerId, startDate, endDate]);
callback(null, rows);
} catch (error) {
callback(error);
}
},
/**
* 휴가 기간 중복 체크
*/
async checkOverlap(workerId, startDate, endDate, excludeRequestId = null, callback) {
try {
const db = await getDb();
let query = `
SELECT COUNT(*) as count
FROM vacation_requests
WHERE worker_id = ?
AND status IN ('pending', 'approved')
AND (
(start_date <= ? AND end_date >= ?) OR
(start_date <= ? AND end_date >= ?) OR
(start_date >= ? AND end_date <= ?)
)
`;
const params = [workerId, startDate, startDate, endDate, endDate, startDate, endDate];
if (excludeRequestId) {
query += ` AND request_id != ?`;
params.push(excludeRequestId);
}
const [rows] = await db.query(query, params);
callback(null, rows);
} catch (error) {
callback(error);
}
},
/**
* 모든 대기 중인 휴가 신청 (관리자용)
*/
async getAllPending(callback) {
try {
const db = await getDb();
const query = `
SELECT
vr.*,
w.worker_name,
vt.type_name as vacation_type_name,
requester.name as requester_name
FROM vacation_requests vr
INNER JOIN workers w ON vr.worker_id = w.worker_id
INNER JOIN vacation_types vt ON vr.vacation_type_id = vt.id
LEFT JOIN users requester ON vr.requested_by = requester.user_id
WHERE vr.status = 'pending'
ORDER BY vr.created_at ASC
`;
const [rows] = await db.query(query);
callback(null, rows);
} catch (error) {
callback(error);
}
}
};
module.exports = vacationRequestModel;

View File

@@ -0,0 +1,132 @@
/**
* vacationTypeModel.js
* 휴가 유형 관련 데이터베이스 쿼리 모델
*/
const { getDb } = require('../dbPool');
const vacationTypeModel = {
/**
* 모든 활성 휴가 유형 조회 (우선순위 순서대로)
*/
async getAll(callback) {
try {
const db = await getDb();
const query = `
SELECT *
FROM vacation_types
WHERE is_active = 1
ORDER BY priority ASC, id ASC
`;
const [rows] = await db.query(query);
callback(null, rows);
} catch (error) {
callback(error);
}
},
/**
* 시스템 기본 휴가 유형만 조회
*/
async getSystemTypes(callback) {
try {
const db = await getDb();
const query = `
SELECT *
FROM vacation_types
WHERE is_system = 1 AND is_active = 1
ORDER BY priority ASC
`;
const [rows] = await db.query(query);
callback(null, rows);
} catch (error) {
callback(error);
}
},
/**
* 특정 ID로 휴가 유형 조회
*/
async getById(id, callback) {
try {
const db = await getDb();
const query = `SELECT * FROM vacation_types WHERE id = ?`;
const [rows] = await db.query(query, [id]);
callback(null, rows);
} catch (error) {
callback(error);
}
},
/**
* 휴가 유형 코드로 조회
*/
async getByCode(code, callback) {
try {
const db = await getDb();
const query = `SELECT * FROM vacation_types WHERE type_code = ?`;
const [rows] = await db.query(query, [code]);
callback(null, rows);
} catch (error) {
callback(error);
}
},
/**
* 휴가 유형 생성
*/
async create(typeData, callback) {
try {
const db = await getDb();
const query = `INSERT INTO vacation_types SET ?`;
const [result] = await db.query(query, typeData);
callback(null, result);
} catch (error) {
callback(error);
}
},
/**
* 휴가 유형 수정
*/
async update(id, updateData, callback) {
try {
const db = await getDb();
const query = `UPDATE vacation_types SET ? WHERE id = ?`;
const [result] = await db.query(query, [updateData, id]);
callback(null, result);
} catch (error) {
callback(error);
}
},
/**
* 휴가 유형 삭제 (논리적 삭제 - is_active = 0)
*/
async delete(id, callback) {
try {
const db = await getDb();
const query = `UPDATE vacation_types SET is_active = 0, updated_at = NOW() WHERE id = ?`;
const [result] = await db.query(query, [id]);
callback(null, result);
} catch (error) {
callback(error);
}
},
/**
* 우선순위 업데이트
*/
async updatePriority(id, priority, callback) {
try {
const db = await getDb();
const query = `UPDATE vacation_types SET priority = ?, updated_at = NOW() WHERE id = ?`;
const [result] = await db.query(query, [priority, id]);
callback(null, result);
} catch (error) {
callback(error);
}
}
};
module.exports = vacationTypeModel;

View File

@@ -0,0 +1,505 @@
const { getDb } = require('../dbPool');
// ==================== 출입 신청 관리 ====================
/**
* 출입 신청 생성
*/
const createVisitRequest = async (requestData, callback) => {
try {
const db = await getDb();
const {
requester_id,
visitor_company,
visitor_count = 1,
category_id,
workplace_id,
visit_date,
visit_time,
purpose_id,
notes = null
} = requestData;
const [result] = await db.query(
`INSERT INTO workplace_visit_requests
(requester_id, visitor_company, visitor_count, category_id, workplace_id,
visit_date, visit_time, purpose_id, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[requester_id, visitor_company, visitor_count, category_id, workplace_id,
visit_date, visit_time, purpose_id, notes]
);
callback(null, result.insertId);
} catch (err) {
callback(err);
}
};
/**
* 출입 신청 목록 조회 (필터 옵션 포함)
*/
const getAllVisitRequests = async (filters = {}, callback) => {
try {
const db = await getDb();
let query = `
SELECT
vr.request_id, vr.requester_id, vr.visitor_company, vr.visitor_count,
vr.category_id, vr.workplace_id, vr.visit_date, vr.visit_time,
vr.purpose_id, vr.notes, vr.status,
vr.approved_by, vr.approved_at, vr.rejection_reason,
vr.created_at, vr.updated_at,
u.username as requester_name, u.name as requester_full_name,
wc.category_name, w.workplace_name,
vpt.purpose_name,
approver.username as approver_name
FROM workplace_visit_requests vr
INNER JOIN users u ON vr.requester_id = u.user_id
INNER JOIN workplace_categories wc ON vr.category_id = wc.category_id
INNER JOIN workplaces w ON vr.workplace_id = w.workplace_id
INNER JOIN visit_purpose_types vpt ON vr.purpose_id = vpt.purpose_id
LEFT JOIN users approver ON vr.approved_by = approver.user_id
WHERE 1=1
`;
const params = [];
// 필터 적용
if (filters.status) {
query += ` AND vr.status = ?`;
params.push(filters.status);
}
if (filters.visit_date) {
query += ` AND vr.visit_date = ?`;
params.push(filters.visit_date);
}
if (filters.start_date && filters.end_date) {
query += ` AND vr.visit_date BETWEEN ? AND ?`;
params.push(filters.start_date, filters.end_date);
}
if (filters.requester_id) {
query += ` AND vr.requester_id = ?`;
params.push(filters.requester_id);
}
if (filters.category_id) {
query += ` AND vr.category_id = ?`;
params.push(filters.category_id);
}
query += ` ORDER BY vr.visit_date DESC, vr.visit_time DESC, vr.created_at DESC`;
const [rows] = await db.query(query, params);
callback(null, rows);
} catch (err) {
callback(err);
}
};
/**
* 출입 신청 상세 조회
*/
const getVisitRequestById = async (requestId, callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT
vr.request_id, vr.requester_id, vr.visitor_company, vr.visitor_count,
vr.category_id, vr.workplace_id, vr.visit_date, vr.visit_time,
vr.purpose_id, vr.notes, vr.status,
vr.approved_by, vr.approved_at, vr.rejection_reason,
vr.created_at, vr.updated_at,
u.username as requester_name, u.name as requester_full_name,
wc.category_name, w.workplace_name,
vpt.purpose_name,
approver.username as approver_name
FROM workplace_visit_requests vr
INNER JOIN users u ON vr.requester_id = u.user_id
INNER JOIN workplace_categories wc ON vr.category_id = wc.category_id
INNER JOIN workplaces w ON vr.workplace_id = w.workplace_id
INNER JOIN visit_purpose_types vpt ON vr.purpose_id = vpt.purpose_id
LEFT JOIN users approver ON vr.approved_by = approver.user_id
WHERE vr.request_id = ?`,
[requestId]
);
callback(null, rows[0]);
} catch (err) {
callback(err);
}
};
/**
* 출입 신청 수정
*/
const updateVisitRequest = async (requestId, requestData, callback) => {
try {
const db = await getDb();
const {
visitor_company,
visitor_count,
category_id,
workplace_id,
visit_date,
visit_time,
purpose_id,
notes
} = requestData;
const [result] = await db.query(
`UPDATE workplace_visit_requests
SET visitor_company = ?, visitor_count = ?, category_id = ?, workplace_id = ?,
visit_date = ?, visit_time = ?, purpose_id = ?, notes = ?, updated_at = NOW()
WHERE request_id = ?`,
[visitor_company, visitor_count, category_id, workplace_id,
visit_date, visit_time, purpose_id, notes, requestId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 출입 신청 삭제
*/
const deleteVisitRequest = async (requestId, callback) => {
try {
const db = await getDb();
const [result] = await db.query(
`DELETE FROM workplace_visit_requests WHERE request_id = ?`,
[requestId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 출입 신청 승인
*/
const approveVisitRequest = async (requestId, approvedBy, callback) => {
try {
const db = await getDb();
const [result] = await db.query(
`UPDATE workplace_visit_requests
SET status = 'approved', approved_by = ?, approved_at = NOW(), updated_at = NOW()
WHERE request_id = ?`,
[approvedBy, requestId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 출입 신청 반려
*/
const rejectVisitRequest = async (requestId, rejectionData, callback) => {
try {
const db = await getDb();
const { approved_by, rejection_reason } = rejectionData;
const [result] = await db.query(
`UPDATE workplace_visit_requests
SET status = 'rejected', approved_by = ?, approved_at = NOW(),
rejection_reason = ?, updated_at = NOW()
WHERE request_id = ?`,
[approved_by, rejection_reason, requestId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 출입 신청 상태 변경
*/
const updateVisitRequestStatus = async (requestId, status, callback) => {
try {
const db = await getDb();
const [result] = await db.query(
`UPDATE workplace_visit_requests
SET status = ?, updated_at = NOW()
WHERE request_id = ?`,
[status, requestId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
// ==================== 방문 목적 관리 ====================
/**
* 모든 방문 목적 조회
*/
const getAllVisitPurposes = async (callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT purpose_id, purpose_name, display_order, is_active, created_at
FROM visit_purpose_types
ORDER BY display_order ASC, purpose_id ASC`
);
callback(null, rows);
} catch (err) {
callback(err);
}
};
/**
* 활성 방문 목적만 조회
*/
const getActiveVisitPurposes = async (callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT purpose_id, purpose_name, display_order, is_active, created_at
FROM visit_purpose_types
WHERE is_active = TRUE
ORDER BY display_order ASC, purpose_id ASC`
);
callback(null, rows);
} catch (err) {
callback(err);
}
};
/**
* 방문 목적 추가
*/
const createVisitPurpose = async (purposeData, callback) => {
try {
const db = await getDb();
const { purpose_name, display_order = 0, is_active = true } = purposeData;
const [result] = await db.query(
`INSERT INTO visit_purpose_types (purpose_name, display_order, is_active)
VALUES (?, ?, ?)`,
[purpose_name, display_order, is_active]
);
callback(null, result.insertId);
} catch (err) {
callback(err);
}
};
/**
* 방문 목적 수정
*/
const updateVisitPurpose = async (purposeId, purposeData, callback) => {
try {
const db = await getDb();
const { purpose_name, display_order, is_active } = purposeData;
const [result] = await db.query(
`UPDATE visit_purpose_types
SET purpose_name = ?, display_order = ?, is_active = ?
WHERE purpose_id = ?`,
[purpose_name, display_order, is_active, purposeId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 방문 목적 삭제
*/
const deleteVisitPurpose = async (purposeId, callback) => {
try {
const db = await getDb();
const [result] = await db.query(
`DELETE FROM visit_purpose_types WHERE purpose_id = ?`,
[purposeId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
// ==================== 안전교육 기록 관리 ====================
/**
* 안전교육 기록 생성
*/
const createTrainingRecord = async (trainingData, callback) => {
try {
const db = await getDb();
const {
request_id,
trainer_id,
training_date,
training_start_time,
training_end_time = null,
training_topics = null
} = trainingData;
const [result] = await db.query(
`INSERT INTO safety_training_records
(request_id, trainer_id, training_date, training_start_time, training_end_time, training_topics)
VALUES (?, ?, ?, ?, ?, ?)`,
[request_id, trainer_id, training_date, training_start_time, training_end_time, training_topics]
);
callback(null, result.insertId);
} catch (err) {
callback(err);
}
};
/**
* 특정 출입 신청의 안전교육 기록 조회
*/
const getTrainingRecordByRequestId = async (requestId, callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT
str.training_id, str.request_id, str.trainer_id, str.training_date,
str.training_start_time, str.training_end_time, str.training_topics,
str.signature_data, str.completed_at, str.created_at, str.updated_at,
u.username as trainer_name, u.name as trainer_full_name
FROM safety_training_records str
INNER JOIN users u ON str.trainer_id = u.user_id
WHERE str.request_id = ?`,
[requestId]
);
callback(null, rows[0]);
} catch (err) {
callback(err);
}
};
/**
* 안전교육 기록 수정
*/
const updateTrainingRecord = async (trainingId, trainingData, callback) => {
try {
const db = await getDb();
const {
training_date,
training_start_time,
training_end_time,
training_topics
} = trainingData;
const [result] = await db.query(
`UPDATE safety_training_records
SET training_date = ?, training_start_time = ?, training_end_time = ?,
training_topics = ?, updated_at = NOW()
WHERE training_id = ?`,
[training_date, training_start_time, training_end_time, training_topics, trainingId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 안전교육 완료 (서명 포함)
*/
const completeTraining = async (trainingId, signatureData, callback) => {
try {
const db = await getDb();
const [result] = await db.query(
`UPDATE safety_training_records
SET signature_data = ?, completed_at = NOW(), updated_at = NOW()
WHERE training_id = ?`,
[signatureData, trainingId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 안전교육 목록 조회 (날짜별 필터)
*/
const getTrainingRecords = async (filters = {}, callback) => {
try {
const db = await getDb();
let query = `
SELECT
str.training_id, str.request_id, str.trainer_id, str.training_date,
str.training_start_time, str.training_end_time, str.training_topics,
str.completed_at, str.created_at, str.updated_at,
u.username as trainer_name, u.name as trainer_full_name,
vr.visitor_company, vr.visitor_count, vr.visit_date
FROM safety_training_records str
INNER JOIN users u ON str.trainer_id = u.user_id
INNER JOIN workplace_visit_requests vr ON str.request_id = vr.request_id
WHERE 1=1
`;
const params = [];
if (filters.training_date) {
query += ` AND str.training_date = ?`;
params.push(filters.training_date);
}
if (filters.start_date && filters.end_date) {
query += ` AND str.training_date BETWEEN ? AND ?`;
params.push(filters.start_date, filters.end_date);
}
if (filters.trainer_id) {
query += ` AND str.trainer_id = ?`;
params.push(filters.trainer_id);
}
query += ` ORDER BY str.training_date DESC, str.training_start_time DESC`;
const [rows] = await db.query(query, params);
callback(null, rows);
} catch (err) {
callback(err);
}
};
module.exports = {
// 출입 신청
createVisitRequest,
getAllVisitRequests,
getVisitRequestById,
updateVisitRequest,
deleteVisitRequest,
approveVisitRequest,
rejectVisitRequest,
updateVisitRequestStatus,
// 방문 목적
getAllVisitPurposes,
getActiveVisitPurposes,
createVisitPurpose,
updateVisitPurpose,
deleteVisitPurpose,
// 안전교육
createTrainingRecord,
getTrainingRecordByRequestId,
updateTrainingRecord,
completeTraining,
getTrainingRecords
};

View File

@@ -35,7 +35,7 @@ const getAllCategories = async (callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT category_id, category_name, description, display_order, is_active, created_at, updated_at
`SELECT category_id, category_name, description, display_order, is_active, layout_image, created_at, updated_at
FROM workplace_categories
ORDER BY display_order ASC, category_id ASC`
);
@@ -52,7 +52,7 @@ const getActiveCategories = async (callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT category_id, category_name, description, display_order, is_active, created_at, updated_at
`SELECT category_id, category_name, description, display_order, is_active, layout_image, created_at, updated_at
FROM workplace_categories
WHERE is_active = TRUE
ORDER BY display_order ASC, category_id ASC`
@@ -70,7 +70,7 @@ const getCategoryById = async (categoryId, callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT category_id, category_name, description, display_order, is_active, created_at, updated_at
`SELECT category_id, category_name, description, display_order, is_active, layout_image, created_at, updated_at
FROM workplace_categories
WHERE category_id = ?`,
[categoryId]
@@ -91,14 +91,15 @@ const updateCategory = async (categoryId, category, callback) => {
category_name,
description,
display_order,
is_active
is_active,
layout_image
} = category;
const [result] = await db.query(
`UPDATE workplace_categories
SET category_name = ?, description = ?, display_order = ?, is_active = ?, updated_at = NOW()
SET category_name = ?, description = ?, display_order = ?, is_active = ?, layout_image = ?, updated_at = NOW()
WHERE category_id = ?`,
[category_name, description, display_order, is_active, categoryId]
[category_name, description, display_order, is_active, layout_image, categoryId]
);
callback(null, result);
@@ -135,14 +136,16 @@ const createWorkplace = async (workplace, callback) => {
category_id = null,
workplace_name,
description = null,
is_active = true
is_active = true,
workplace_purpose = null,
display_priority = 0
} = workplace;
const [result] = await db.query(
`INSERT INTO workplaces
(category_id, workplace_name, description, is_active)
VALUES (?, ?, ?, ?)`,
[category_id, workplace_name, description, is_active]
(category_id, workplace_name, description, is_active, workplace_purpose, display_priority)
VALUES (?, ?, ?, ?, ?, ?)`,
[category_id, workplace_name, description, is_active, workplace_purpose, display_priority]
);
callback(null, result.insertId);
@@ -158,12 +161,12 @@ const getAllWorkplaces = async (callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT w.workplace_id, w.category_id, w.workplace_name, w.description, w.is_active,
w.created_at, w.updated_at,
`SELECT w.workplace_id, w.category_id, w.workplace_name, w.description, w.is_active, w.workplace_purpose, w.display_priority,
w.workplace_purpose, w.display_priority, w.created_at, w.updated_at,
wc.category_name
FROM workplaces w
LEFT JOIN workplace_categories wc ON w.category_id = wc.category_id
ORDER BY wc.display_order ASC, w.workplace_id DESC`
ORDER BY wc.display_order ASC, w.display_priority ASC, w.workplace_id DESC`
);
callback(null, rows);
} catch (err) {
@@ -178,7 +181,7 @@ const getActiveWorkplaces = async (callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT w.workplace_id, w.category_id, w.workplace_name, w.description, w.is_active,
`SELECT w.workplace_id, w.category_id, w.workplace_name, w.description, w.is_active, w.workplace_purpose, w.display_priority,
w.created_at, w.updated_at,
wc.category_name
FROM workplaces w
@@ -199,7 +202,7 @@ const getWorkplacesByCategory = async (categoryId, callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT w.workplace_id, w.category_id, w.workplace_name, w.description, w.is_active,
`SELECT w.workplace_id, w.category_id, w.workplace_name, w.description, w.is_active, w.workplace_purpose, w.display_priority,
w.created_at, w.updated_at,
wc.category_name
FROM workplaces w
@@ -221,7 +224,7 @@ const getWorkplaceById = async (workplaceId, callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT w.workplace_id, w.category_id, w.workplace_name, w.description, w.is_active,
`SELECT w.workplace_id, w.category_id, w.workplace_name, w.description, w.is_active, w.workplace_purpose, w.display_priority,
w.created_at, w.updated_at,
wc.category_name
FROM workplaces w
@@ -245,14 +248,17 @@ const updateWorkplace = async (workplaceId, workplace, callback) => {
category_id,
workplace_name,
description,
is_active
is_active,
workplace_purpose,
display_priority
} = workplace;
const [result] = await db.query(
`UPDATE workplaces
SET category_id = ?, workplace_name = ?, description = ?, is_active = ?, updated_at = NOW()
SET category_id = ?, workplace_name = ?, description = ?, is_active = ?,
workplace_purpose = ?, display_priority = ?, updated_at = NOW()
WHERE workplace_id = ?`,
[category_id, workplace_name, description, is_active, workplaceId]
[category_id, workplace_name, description, is_active, workplace_purpose, display_priority, workplaceId]
);
callback(null, result);
@@ -277,6 +283,134 @@ const deleteWorkplace = async (workplaceId, callback) => {
}
};
// ==================== 작업장 지도 영역 관련 ====================
/**
* 작업장 지도 영역 생성
*/
const createMapRegion = async (region, callback) => {
try {
const db = await getDb();
const {
workplace_id,
category_id,
x_start,
y_start,
x_end,
y_end,
shape = 'rect',
polygon_points = null
} = region;
const [result] = await db.query(
`INSERT INTO workplace_map_regions
(workplace_id, category_id, x_start, y_start, x_end, y_end, shape, polygon_points)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[workplace_id, category_id, x_start, y_start, x_end, y_end, shape, polygon_points]
);
callback(null, result.insertId);
} catch (err) {
callback(err);
}
};
/**
* 카테고리(공장)별 지도 영역 조회
*/
const getMapRegionsByCategory = async (categoryId, callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT mr.*, w.workplace_name, w.description
FROM workplace_map_regions mr
INNER JOIN workplaces w ON mr.workplace_id = w.workplace_id
WHERE mr.category_id = ? AND w.is_active = TRUE
ORDER BY mr.region_id ASC`,
[categoryId]
);
callback(null, rows);
} catch (err) {
callback(err);
}
};
/**
* 작업장별 지도 영역 조회
*/
const getMapRegionByWorkplace = async (workplaceId, callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT * FROM workplace_map_regions WHERE workplace_id = ?`,
[workplaceId]
);
callback(null, rows[0]);
} catch (err) {
callback(err);
}
};
/**
* 지도 영역 수정
*/
const updateMapRegion = async (regionId, region, callback) => {
try {
const db = await getDb();
const {
x_start,
y_start,
x_end,
y_end,
shape,
polygon_points
} = region;
const [result] = await db.query(
`UPDATE workplace_map_regions
SET x_start = ?, y_start = ?, x_end = ?, y_end = ?, shape = ?, polygon_points = ?, updated_at = NOW()
WHERE region_id = ?`,
[x_start, y_start, x_end, y_end, shape, polygon_points, regionId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 지도 영역 삭제
*/
const deleteMapRegion = async (regionId, callback) => {
try {
const db = await getDb();
const [result] = await db.query(
`DELETE FROM workplace_map_regions WHERE region_id = ?`,
[regionId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 작업장 영역 일괄 삭제 (카테고리별)
*/
const deleteMapRegionsByCategory = async (categoryId, callback) => {
try {
const db = await getDb();
const [result] = await db.query(
`DELETE FROM workplace_map_regions WHERE category_id = ?`,
[categoryId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
module.exports = {
// 카테고리
createCategory,
@@ -293,5 +427,13 @@ module.exports = {
getWorkplacesByCategory,
getWorkplaceById,
updateWorkplace,
deleteWorkplace
deleteWorkplace,
// 지도 영역
createMapRegion,
getMapRegionsByCategory,
getMapRegionByWorkplace,
updateMapRegion,
deleteMapRegion,
deleteMapRegionsByCategory
};

View File

@@ -34,4 +34,10 @@ router.get('/vacation-balance/:worker_id', AttendanceController.getWorkerVacatio
// 월별 근태 통계
router.get('/monthly-stats', AttendanceController.getMonthlyAttendanceStats);
// 출근 체크 목록 조회 (아침용, 휴가 정보 포함)
router.get('/checkin-list', AttendanceController.getCheckinList);
// 출근 체크 일괄 저장
router.post('/checkins', AttendanceController.saveCheckins);
module.exports = router;

View File

@@ -70,6 +70,9 @@ router.get('/stats', dailyWorkReportController.getWorkReportStats);
// 📝 일일 작업보고서 생성 (누적 방식 - 덮어쓰기 없음!)
router.post('/', dailyWorkReportController.createDailyWorkReport);
// 📝 TBM 기반 작업보고서 생성
router.post('/from-tbm', dailyWorkReportController.createFromTbm);
// 📊 일일 작업보고서 조회 (날짜별 - 경로 파라미터)
router.get('/date/:date', dailyWorkReportController.getDailyWorkReportsByDate);

View File

@@ -2,70 +2,76 @@
const express = require('express');
const router = express.Router();
const TbmController = require('../controllers/tbmController');
const { authenticateToken } = require('../middlewares/auth');
const { requireAuth } = require('../middlewares/auth');
// ==================== TBM 세션 관련 ====================
// TBM 세션 생성
router.post('/sessions', authenticateToken, TbmController.createSession);
router.post('/sessions', requireAuth, TbmController.createSession);
// 작업보고서가 작성되지 않은 TBM 팀 배정 조회 (구체적인 경로이므로 먼저 정의)
router.get('/sessions/incomplete-reports', requireAuth, TbmController.getIncompleteWorkReports);
// 특정 날짜의 TBM 세션 목록 조회
router.get('/sessions/date/:date', authenticateToken, TbmController.getSessionsByDate);
router.get('/sessions/date/:date', requireAuth, TbmController.getSessionsByDate);
// TBM 세션 상세 조회
router.get('/sessions/:sessionId', authenticateToken, TbmController.getSessionById);
router.get('/sessions/:sessionId', requireAuth, TbmController.getSessionById);
// TBM 세션 수정
router.put('/sessions/:sessionId', authenticateToken, TbmController.updateSession);
router.put('/sessions/:sessionId', requireAuth, TbmController.updateSession);
// TBM 세션 완료 처리
router.post('/sessions/:sessionId/complete', authenticateToken, TbmController.completeSession);
router.post('/sessions/:sessionId/complete', requireAuth, TbmController.completeSession);
// ==================== 팀 구성 관련 ====================
// 팀원 추가 (단일)
router.post('/sessions/:sessionId/team', authenticateToken, TbmController.addTeamMember);
router.post('/sessions/:sessionId/team', requireAuth, TbmController.addTeamMember);
// 팀 구성 일괄 추가
router.post('/sessions/:sessionId/team/batch', authenticateToken, TbmController.addTeamMembers);
router.post('/sessions/:sessionId/team/batch', requireAuth, TbmController.addTeamMembers);
// TBM 세션의 팀 구성 조회
router.get('/sessions/:sessionId/team', authenticateToken, TbmController.getTeamMembers);
router.get('/sessions/:sessionId/team', requireAuth, TbmController.getTeamMembers);
// 팀원 전체 삭제 (수정 시 사용) - 더 구체적인 경로이므로 먼저 정의
router.delete('/sessions/:sessionId/team/clear', requireAuth, TbmController.clearAllTeamMembers);
// 팀원 제거
router.delete('/sessions/:sessionId/team/:workerId', authenticateToken, TbmController.removeTeamMember);
router.delete('/sessions/:sessionId/team/:workerId', requireAuth, TbmController.removeTeamMember);
// ==================== 안전 체크리스트 관련 ====================
// 모든 안전 체크 항목 조회
router.get('/safety-checks', authenticateToken, TbmController.getAllSafetyChecks);
router.get('/safety-checks', requireAuth, TbmController.getAllSafetyChecks);
// TBM 세션의 안전 체크 기록 조회
router.get('/sessions/:sessionId/safety', authenticateToken, TbmController.getSafetyRecords);
router.get('/sessions/:sessionId/safety', requireAuth, TbmController.getSafetyRecords);
// 안전 체크 일괄 저장
router.post('/sessions/:sessionId/safety', authenticateToken, TbmController.saveSafetyRecords);
router.post('/sessions/:sessionId/safety', requireAuth, TbmController.saveSafetyRecords);
// ==================== 작업 인계 관련 ====================
// 작업 인계 생성
router.post('/handovers', authenticateToken, TbmController.createHandover);
router.post('/handovers', requireAuth, TbmController.createHandover);
// 작업 인계 확인
router.post('/handovers/:handoverId/confirm', authenticateToken, TbmController.confirmHandover);
router.post('/handovers/:handoverId/confirm', requireAuth, TbmController.confirmHandover);
// 특정 날짜의 작업 인계 목록 조회
router.get('/handovers/date/:date', authenticateToken, TbmController.getHandoversByDate);
router.get('/handovers/date/:date', requireAuth, TbmController.getHandoversByDate);
// 나에게 온 미확인 인계 건 조회
router.get('/handovers/pending', authenticateToken, TbmController.getMyPendingHandovers);
router.get('/handovers/pending', requireAuth, TbmController.getMyPendingHandovers);
// ==================== 통계 및 리포트 ====================
// TBM 통계 조회
router.get('/statistics/tbm', authenticateToken, TbmController.getTbmStatistics);
router.get('/statistics/tbm', requireAuth, TbmController.getTbmStatistics);
// 리더별 TBM 진행 현황 조회
router.get('/statistics/leaders', authenticateToken, TbmController.getLeaderStatistics);
router.get('/statistics/leaders', requireAuth, TbmController.getLeaderStatistics);
module.exports = router;

View File

@@ -128,4 +128,10 @@ router.put('/:id/status', userController.updateUserStatus);
// 🗑️ 사용자 삭제
router.delete('/:id', userController.deleteUser);
// 📄 사용자 페이지 접근 권한 조회
router.get('/:id/page-access', userController.getUserPageAccess);
// 🔐 사용자 페이지 접근 권한 업데이트
router.put('/:id/page-access', userController.updateUserPageAccess);
module.exports = router;

View File

@@ -0,0 +1,31 @@
/**
* vacationBalanceRoutes.js
* 휴가 잔액 관련 라우트
*/
const express = require('express');
const router = express.Router();
const vacationBalanceController = require('../controllers/vacationBalanceController');
// 모든 작업자의 휴가 잔액 조회 (특정 연도)
router.get('/year/:year', vacationBalanceController.getAllByYear);
// 특정 작업자의 휴가 잔액 조회 (특정 연도)
router.get('/worker/:workerId/year/:year', vacationBalanceController.getByWorkerAndYear);
// 작업자의 사용 가능한 휴가 일수 조회
router.get('/worker/:workerId/year/:year/available', vacationBalanceController.getAvailableDays);
// 근속년수 기반 연차 자동 계산 및 생성 (관리자만)
router.post('/auto-calculate', vacationBalanceController.autoCalculateAndCreate);
// 휴가 잔액 생성 (관리자만)
router.post('/', vacationBalanceController.createBalance);
// 휴가 잔액 수정 (관리자만)
router.put('/:id', vacationBalanceController.updateBalance);
// 휴가 잔액 삭제 (관리자만)
router.delete('/:id', vacationBalanceController.deleteBalance);
module.exports = router;

View File

@@ -0,0 +1,34 @@
/**
* vacationRequestRoutes.js
* 휴가 신청 관련 라우트
*/
const express = require('express');
const router = express.Router();
const vacationRequestController = require('../controllers/vacationRequestController');
// 휴가 신청 생성
router.post('/', vacationRequestController.createRequest);
// 휴가 신청 목록 조회
router.get('/', vacationRequestController.getAllRequests);
// 대기 중인 휴가 신청 목록 (관리자용)
router.get('/pending', vacationRequestController.getPendingRequests);
// 특정 휴가 신청 조회
router.get('/:id', vacationRequestController.getRequestById);
// 휴가 신청 수정
router.put('/:id', vacationRequestController.updateRequest);
// 휴가 신청 삭제
router.delete('/:id', vacationRequestController.deleteRequest);
// 휴가 신청 승인
router.patch('/:id/approve', vacationRequestController.approveRequest);
// 휴가 신청 거부
router.patch('/:id/reject', vacationRequestController.rejectRequest);
module.exports = router;

View File

@@ -0,0 +1,31 @@
/**
* vacationTypeRoutes.js
* 휴가 유형 관련 라우트
*/
const express = require('express');
const router = express.Router();
const vacationTypeController = require('../controllers/vacationTypeController');
// 모든 활성 휴가 유형 조회
router.get('/', vacationTypeController.getAllTypes);
// 시스템 기본 휴가 유형 조회
router.get('/system', vacationTypeController.getSystemTypes);
// 특별 휴가 유형 조회
router.get('/special', vacationTypeController.getSpecialTypes);
// 휴가 유형 우선순위 일괄 업데이트 (관리자만)
router.put('/priorities', vacationTypeController.updatePriorities);
// 특별 휴가 유형 생성 (관리자만)
router.post('/', vacationTypeController.createType);
// 휴가 유형 수정 (관리자만)
router.put('/:id', vacationTypeController.updateType);
// 특별 휴가 유형 삭제 (관리자만, 시스템 기본 휴가는 삭제 불가)
router.delete('/:id', vacationTypeController.deleteType);
module.exports = router;

View File

@@ -0,0 +1,66 @@
const express = require('express');
const router = express.Router();
const visitRequestController = require('../controllers/visitRequestController');
const { verifyToken } = require('../middlewares/authMiddleware');
// 모든 라우트에 인증 미들웨어 적용
router.use(verifyToken);
// ==================== 출입 신청 관리 ====================
// 출입 신청 생성
router.post('/requests', visitRequestController.createVisitRequest);
// 출입 신청 목록 조회 (필터: status, visit_date, start_date, end_date, requester_id, category_id)
router.get('/requests', visitRequestController.getAllVisitRequests);
// 출입 신청 상세 조회
router.get('/requests/:id', visitRequestController.getVisitRequestById);
// 출입 신청 수정
router.put('/requests/:id', visitRequestController.updateVisitRequest);
// 출입 신청 삭제
router.delete('/requests/:id', visitRequestController.deleteVisitRequest);
// 출입 신청 승인
router.put('/requests/:id/approve', visitRequestController.approveVisitRequest);
// 출입 신청 반려
router.put('/requests/:id/reject', visitRequestController.rejectVisitRequest);
// ==================== 방문 목적 관리 ====================
// 모든 방문 목적 조회
router.get('/purposes', visitRequestController.getAllVisitPurposes);
// 활성 방문 목적만 조회
router.get('/purposes/active', visitRequestController.getActiveVisitPurposes);
// 방문 목적 추가
router.post('/purposes', visitRequestController.createVisitPurpose);
// 방문 목적 수정
router.put('/purposes/:id', visitRequestController.updateVisitPurpose);
// 방문 목적 삭제
router.delete('/purposes/:id', visitRequestController.deleteVisitPurpose);
// ==================== 안전교육 기록 관리 ====================
// 안전교육 기록 생성
router.post('/training', visitRequestController.createTrainingRecord);
// 안전교육 기록 목록 조회 (필터: training_date, start_date, end_date, trainer_id)
router.get('/training', visitRequestController.getTrainingRecords);
// 특정 출입 신청의 안전교육 기록 조회
router.get('/training/request/:requestId', visitRequestController.getTrainingRecordByRequestId);
// 안전교육 기록 수정
router.put('/training/:id', visitRequestController.updateTrainingRecord);
// 안전교육 완료 (서명 포함)
router.post('/training/:id/complete', visitRequestController.completeTraining);
module.exports = router;

View File

@@ -248,6 +248,77 @@ const getMonthlyAttendanceStatsService = async (year, month, workerId = null) =>
}
};
/**
* 출근 체크 목록 조회 (휴가 정보 포함)
*/
const getCheckinListService = async (date) => {
if (!date) {
throw new ValidationError('날짜가 필요합니다', {
required: ['date'],
received: { date }
});
}
logger.info('출근 체크 목록 조회 요청', { date });
try {
const checkinList = await AttendanceModel.getCheckinList(date);
logger.info('출근 체크 목록 조회 성공', { date, count: checkinList.length });
return checkinList;
} catch (error) {
logger.error('출근 체크 목록 조회 실패', { date, error: error.message });
throw new DatabaseError('출근 체크 목록 조회 중 데이터베이스 오류가 발생했습니다');
}
};
/**
* 출근 체크 일괄 저장
*/
const saveCheckinsService = async (date, checkins) => {
if (!date || !checkins || !Array.isArray(checkins)) {
throw new ValidationError('날짜와 출근 체크 목록이 필요합니다', {
required: ['date', 'checkins'],
received: { date, checkins: checkins ? `Array[${checkins.length}]` : null }
});
}
logger.info('출근 체크 일괄 저장 요청', { date, count: checkins.length });
try {
const results = [];
for (const checkin of checkins) {
const { worker_id, is_present } = checkin;
if (!worker_id || is_present === undefined) {
logger.warn('출근 체크 데이터 누락', { checkin });
continue;
}
const result = await AttendanceModel.upsertCheckin({
worker_id,
record_date: date,
is_present
});
results.push({
worker_id,
record_id: result,
is_present
});
}
logger.info('출근 체크 일괄 저장 성공', { date, saved: results.length });
return {
saved_count: results.length,
results
};
} catch (error) {
logger.error('출근 체크 일괄 저장 실패', { date, error: error.message });
throw new DatabaseError('출근 체크 저장 중 데이터베이스 오류가 발생했습니다');
}
};
module.exports = {
getDailyAttendanceStatusService,
getDailyAttendanceRecordsService,
@@ -257,5 +328,7 @@ module.exports = {
getAttendanceTypesService,
getVacationTypesService,
getWorkerVacationBalanceService,
getMonthlyAttendanceStatsService
getMonthlyAttendanceStatsService,
getCheckinListService,
saveCheckinsService
};

View File

@@ -122,19 +122,29 @@ function convertToRoman(text) {
/**
* 사용자명 생성 (중복 확인 및 처리)
* @param {string} koreanName - 한글 이름
* @param {object} knex - Knex 인스턴스
* @param {object} db - Database connection (mysql2 pool or knex)
* @returns {Promise<string>} 고유한 username
*/
async function generateUniqueUsername(koreanName, knex) {
async function generateUniqueUsername(koreanName, db) {
const baseUsername = hangulToRoman(koreanName);
let username = baseUsername;
let counter = 1;
// 중복 확인
while (true) {
const existing = await knex('users')
.where('username', username)
.first();
let existing;
// mysql2 pool 또는 knex 모두 지원
if (typeof db === 'function') {
// Knex
existing = await db('users')
.where('username', username)
.first();
} else {
// mysql2 pool
const [rows] = await db.query('SELECT username FROM users WHERE username = ?', [username]);
existing = rows[0];
}
if (!existing) {
break; // 중복 없음