feat: tkuser 통합 관리 서비스 + 전체 시스템 SSO 쿠키 인증 통합

- tkuser 서비스 신규 추가 (API + Web)
  - 사용자/권한/프로젝트/부서/작업자/작업장/설비/작업/휴가 통합 관리
  - 작업장 탭: 공장→작업장 드릴다운 네비게이션 + 구역지도 클릭 연동
  - 작업 탭: 공정(work_types)→작업(tasks) 계층 관리
  - 휴가 탭: 유형 관리 + 연차 배정(근로기준법 자동계산)
- 전 시스템 SSO 쿠키 인증으로 통합 (.technicalkorea.net 공유)
- System 2: 작업 이슈 리포트 기능 강화
- System 3: tkuser API 연동, 페이지 권한 체계 적용
- docker-compose에 tkuser-api, tkuser-web 서비스 추가
- ARCHITECTURE.md, DEPLOYMENT.md 문서 작성

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-02-12 13:45:52 +09:00
parent 6495b8af32
commit 733bb0cb35
96 changed files with 9721 additions and 825 deletions

View File

@@ -0,0 +1,18 @@
FROM node:18-alpine
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install --omit=dev
COPY . .
RUN chown -R node:node /usr/src/app
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1); })"
CMD ["node", "index.js"]

View File

@@ -0,0 +1,66 @@
/**
* Department Controller
*
* 부서 CRUD
*/
const departmentModel = require('../models/departmentModel');
async function getAll(req, res, next) {
try {
const departments = await departmentModel.getAll();
res.json({ success: true, data: departments });
} catch (err) {
next(err);
}
}
async function getById(req, res, next) {
try {
const dept = await departmentModel.getById(parseInt(req.params.id));
if (!dept) {
return res.status(404).json({ success: false, error: '부서를 찾을 수 없습니다' });
}
res.json({ success: true, data: dept });
} catch (err) {
next(err);
}
}
async function create(req, res, next) {
try {
const { department_name } = req.body;
if (!department_name) {
return res.status(400).json({ success: false, error: '부서명은 필수입니다' });
}
const dept = await departmentModel.create(req.body);
res.status(201).json({ success: true, data: dept });
} catch (err) {
next(err);
}
}
async function update(req, res, next) {
try {
const id = parseInt(req.params.id);
const dept = await departmentModel.update(id, req.body);
if (!dept) {
return res.status(404).json({ success: false, error: '부서를 찾을 수 없습니다' });
}
res.json({ success: true, data: dept });
} catch (err) {
next(err);
}
}
async function remove(req, res, next) {
try {
const id = parseInt(req.params.id);
await departmentModel.deactivate(id);
res.json({ success: true, message: '부서가 비활성화되었습니다' });
} catch (err) {
next(err);
}
}
module.exports = { getAll, getById, create, update, remove };

View File

@@ -0,0 +1,141 @@
/**
* Equipment Controller
*
* 설비 CRUD + 지도위치 + 사진
*/
const equipmentModel = require('../models/equipmentModel');
const path = require('path');
const fs = require('fs');
// ==================== 기본 CRUD ====================
async function getAll(req, res, next) {
try {
const filters = {};
if (req.query.workplace_id) filters.workplace_id = parseInt(req.query.workplace_id);
if (req.query.equipment_type) filters.equipment_type = req.query.equipment_type;
if (req.query.status) filters.status = req.query.status;
if (req.query.search) filters.search = req.query.search;
const equipments = await equipmentModel.getAll(filters);
res.json({ success: true, data: equipments });
} catch (err) { next(err); }
}
async function getById(req, res, next) {
try {
const eq = await equipmentModel.getById(parseInt(req.params.id));
if (!eq) return res.status(404).json({ success: false, error: '설비를 찾을 수 없습니다' });
res.json({ success: true, data: eq });
} catch (err) { next(err); }
}
async function getByWorkplace(req, res, next) {
try {
const equipments = await equipmentModel.getByWorkplace(parseInt(req.params.workplaceId));
res.json({ success: true, data: equipments });
} catch (err) { next(err); }
}
async function create(req, res, next) {
try {
const { equipment_code, equipment_name } = req.body;
if (!equipment_code || !equipment_name) return res.status(400).json({ success: false, error: '관리번호와 설비명은 필수입니다' });
const dup = await equipmentModel.checkDuplicateCode(equipment_code);
if (dup) return res.status(409).json({ success: false, error: '이미 존재하는 관리번호입니다' });
const eq = await equipmentModel.create(req.body);
res.status(201).json({ success: true, data: eq });
} catch (err) { next(err); }
}
async function update(req, res, next) {
try {
const id = parseInt(req.params.id);
if (req.body.equipment_code) {
const dup = await equipmentModel.checkDuplicateCode(req.body.equipment_code, id);
if (dup) return res.status(409).json({ success: false, error: '이미 존재하는 관리번호입니다' });
}
const eq = await equipmentModel.update(id, req.body);
if (!eq) return res.status(404).json({ success: false, error: '설비를 찾을 수 없습니다' });
res.json({ success: true, data: eq });
} catch (err) { next(err); }
}
async function remove(req, res, next) {
try {
await equipmentModel.remove(parseInt(req.params.id));
res.json({ success: true, message: '설비가 삭제되었습니다' });
} catch (err) { next(err); }
}
async function getTypes(req, res, next) {
try {
const types = await equipmentModel.getEquipmentTypes();
res.json({ success: true, data: types });
} catch (err) { next(err); }
}
async function getNextCode(req, res, next) {
try {
const code = await equipmentModel.getNextCode(req.query.prefix || 'TKP');
res.json({ success: true, data: code });
} catch (err) { next(err); }
}
// ==================== 지도 위치 ====================
async function updateMapPosition(req, res, next) {
try {
const id = parseInt(req.params.id);
const positionData = {
map_x_percent: req.body.map_x_percent,
map_y_percent: req.body.map_y_percent,
map_width_percent: req.body.map_width_percent,
map_height_percent: req.body.map_height_percent
};
if (req.body.workplace_id !== undefined) positionData.workplace_id = req.body.workplace_id;
const eq = await equipmentModel.updateMapPosition(id, positionData);
res.json({ success: true, data: eq });
} catch (err) { next(err); }
}
// ==================== 사진 ====================
async function addPhoto(req, res, next) {
try {
const equipmentId = parseInt(req.params.id);
if (!req.file) return res.status(400).json({ success: false, error: '사진 파일이 필요합니다' });
const photoData = {
photo_path: `/uploads/${req.file.filename}`,
description: req.body.description || null,
display_order: parseInt(req.body.display_order) || 0,
uploaded_by: req.user?.user_id || null
};
const result = await equipmentModel.addPhoto(equipmentId, photoData);
res.status(201).json({ success: true, data: result });
} catch (err) { next(err); }
}
async function getPhotos(req, res, next) {
try {
const results = await equipmentModel.getPhotos(parseInt(req.params.id));
res.json({ success: true, data: results });
} catch (err) { next(err); }
}
async function deletePhoto(req, res, next) {
try {
const result = await equipmentModel.deletePhoto(parseInt(req.params.photoId));
if (result.photo_path) {
const filePath = path.join(__dirname, '..', result.photo_path);
fs.unlink(filePath, () => {});
}
res.json({ success: true, message: '사진이 삭제되었습니다' });
} catch (err) { next(err); }
}
module.exports = {
getAll, getById, getByWorkplace, create, update, remove, getTypes, getNextCode,
updateMapPosition,
addPhoto, getPhotos, deletePhoto
};

View File

@@ -0,0 +1,159 @@
/**
* Permission Controller
*
* 페이지 권한 관리 (system3 page_permissions.py 포팅)
*/
const permissionModel = require('../models/permissionModel');
const userModel = require('../models/userModel');
/**
* GET /api/users/:id/page-permissions - 사용자 권한 조회
*/
async function getUserPermissions(req, res, next) {
try {
const userId = parseInt(req.params.id);
const requesterId = req.user.user_id || req.user.id;
// 관리자이거나 본인만 조회 가능
if (req.user.role !== 'admin' && requesterId !== userId) {
return res.status(403).json({ success: false, error: '권한이 없습니다' });
}
const user = await userModel.findById(userId);
if (!user) {
return res.status(404).json({ success: false, error: '사용자를 찾을 수 없습니다' });
}
const permissions = await permissionModel.getUserPermissions(userId);
res.json(permissions);
} catch (err) {
next(err);
}
}
/**
* POST /api/permissions/grant - 단건 권한 부여
*/
async function grantPermission(req, res, next) {
try {
const { user_id, page_name, can_access, notes } = req.body;
const grantedById = req.user.user_id || req.user.id;
// 대상 사용자 확인
const targetUser = await userModel.findById(user_id);
if (!targetUser) {
return res.status(404).json({ success: false, error: '사용자를 찾을 수 없습니다' });
}
// 유효한 페이지명 확인
if (!permissionModel.DEFAULT_PAGES[page_name]) {
return res.status(400).json({ success: false, error: '유효하지 않은 페이지명입니다' });
}
const result = await permissionModel.grantPermission({
user_id,
page_name,
can_access,
granted_by_id: grantedById,
notes
});
res.json({ success: true, message: '권한이 설정되었습니다', data: result });
} catch (err) {
next(err);
}
}
/**
* POST /api/permissions/bulk-grant - 일괄 권한 부여
*/
async function bulkGrant(req, res, next) {
try {
const { user_id, permissions } = req.body;
const grantedById = req.user.user_id || req.user.id;
// 대상 사용자 확인
const targetUser = await userModel.findById(user_id);
if (!targetUser) {
return res.status(404).json({ success: false, error: '사용자를 찾을 수 없습니다' });
}
const result = await permissionModel.bulkGrant({
user_id,
permissions,
granted_by_id: grantedById
});
res.json({
success: true,
message: `${result.updated_count}개의 권한이 설정되었습니다`,
updated_count: result.updated_count
});
} catch (err) {
next(err);
}
}
/**
* GET /api/permissions/check/:uid/:page - 접근 권한 확인
*/
async function checkAccess(req, res, next) {
try {
const userId = parseInt(req.params.uid);
const pageName = req.params.page;
// 사용자 확인
const user = await userModel.findById(userId);
if (!user) {
return res.status(404).json({ success: false, error: '사용자를 찾을 수 없습니다' });
}
// admin은 모든 페이지 접근 가능
if (user.role === 'admin') {
return res.json({ can_access: true, reason: 'admin_role' });
}
const result = await permissionModel.checkAccess(userId, pageName);
res.json(result);
} catch (err) {
next(err);
}
}
/**
* GET /api/permissions/available-pages - 설정 가능 페이지 목록
*/
async function getAvailablePages(req, res) {
res.json({
pages: permissionModel.DEFAULT_PAGES,
total_count: Object.keys(permissionModel.DEFAULT_PAGES).length
});
}
/**
* DELETE /api/permissions/:id - 권한 삭제
*/
async function deletePermission(req, res, next) {
try {
const permissionId = parseInt(req.params.id);
const deleted = await permissionModel.deletePermission(permissionId);
if (!deleted) {
return res.status(404).json({ success: false, error: '권한을 찾을 수 없습니다' });
}
res.json({ success: true, message: '권한이 삭제되었습니다. 기본값이 적용됩니다.' });
} catch (err) {
next(err);
}
}
module.exports = {
getUserPermissions,
grantPermission,
bulkGrant,
checkAccess,
getAvailablePages,
deletePermission
};

View File

@@ -0,0 +1,83 @@
/**
* Project Controller
*
* 프로젝트 CRUD
*/
const projectModel = require('../models/projectModel');
async function getAll(req, res, next) {
try {
const projects = await projectModel.getAll();
res.json({ success: true, data: projects });
} catch (err) {
next(err);
}
}
async function getActive(req, res, next) {
try {
const projects = await projectModel.getActive();
res.json({ success: true, data: projects });
} catch (err) {
next(err);
}
}
async function getById(req, res, next) {
try {
const project = await projectModel.getById(parseInt(req.params.id));
if (!project) {
return res.status(404).json({ success: false, error: '프로젝트를 찾을 수 없습니다' });
}
res.json({ success: true, data: project });
} catch (err) {
next(err);
}
}
async function create(req, res, next) {
try {
const { job_no, project_name } = req.body;
if (!job_no || !project_name) {
return res.status(400).json({ success: false, error: 'Job No와 프로젝트명은 필수입니다' });
}
const project = await projectModel.create(req.body);
res.status(201).json({ success: true, data: project });
} catch (err) {
if (err.code === 'ER_DUP_ENTRY') {
return res.status(409).json({ success: false, error: '이미 존재하는 Job No입니다' });
}
next(err);
}
}
async function update(req, res, next) {
try {
const id = parseInt(req.params.id);
const project = await projectModel.update(id, req.body);
if (!project) {
return res.status(404).json({ success: false, error: '프로젝트를 찾을 수 없습니다' });
}
res.json({ success: true, data: project });
} catch (err) {
if (err.code === 'ER_DUP_ENTRY') {
return res.status(409).json({ success: false, error: '이미 존재하는 Job No입니다' });
}
next(err);
}
}
async function remove(req, res, next) {
try {
const id = parseInt(req.params.id);
await projectModel.deactivate(id);
res.json({ success: true, message: '프로젝트가 비활성화되었습니다' });
} catch (err) {
next(err);
}
}
module.exports = { getAll, getActive, getById, create, update, remove };

View File

@@ -0,0 +1,94 @@
/**
* Task Controller
*
* 공정(work_types) + 작업(tasks) CRUD
*/
const taskModel = require('../models/taskModel');
/* ===== Work Types ===== */
async function getWorkTypes(req, res, next) {
try {
const data = await taskModel.getWorkTypes();
res.json({ success: true, data });
} catch (err) { next(err); }
}
async function createWorkType(req, res, next) {
try {
const { name } = req.body;
if (!name) return res.status(400).json({ success: false, error: '공정명은 필수입니다' });
const data = await taskModel.createWorkType(req.body);
res.status(201).json({ success: true, data });
} catch (err) { next(err); }
}
async function updateWorkType(req, res, next) {
try {
const data = await taskModel.updateWorkType(parseInt(req.params.id), req.body);
if (!data) return res.status(404).json({ success: false, error: '공정을 찾을 수 없습니다' });
res.json({ success: true, data });
} catch (err) { next(err); }
}
async function deleteWorkType(req, res, next) {
try {
await taskModel.deleteWorkType(parseInt(req.params.id));
res.json({ success: true, message: '공정이 삭제되었습니다' });
} catch (err) { next(err); }
}
/* ===== Tasks ===== */
async function getTasks(req, res, next) {
try {
const workTypeId = req.query.work_type_id ? parseInt(req.query.work_type_id) : null;
const data = await taskModel.getTasks(workTypeId);
res.json({ success: true, data });
} catch (err) { next(err); }
}
async function getActiveTasks(req, res, next) {
try {
const data = await taskModel.getActiveTasks();
res.json({ success: true, data });
} catch (err) { next(err); }
}
async function getTaskById(req, res, next) {
try {
const data = await taskModel.getTaskById(parseInt(req.params.id));
if (!data) return res.status(404).json({ success: false, error: '작업을 찾을 수 없습니다' });
res.json({ success: true, data });
} catch (err) { next(err); }
}
async function createTask(req, res, next) {
try {
const { task_name } = req.body;
if (!task_name) return res.status(400).json({ success: false, error: '작업명은 필수입니다' });
const data = await taskModel.createTask(req.body);
res.status(201).json({ success: true, data });
} catch (err) { next(err); }
}
async function updateTask(req, res, next) {
try {
const data = await taskModel.updateTask(parseInt(req.params.id), req.body);
if (!data) return res.status(404).json({ success: false, error: '작업을 찾을 수 없습니다' });
res.json({ success: true, data });
} catch (err) { next(err); }
}
async function deleteTask(req, res, next) {
try {
await taskModel.deleteTask(parseInt(req.params.id));
res.json({ success: true, message: '작업이 삭제되었습니다' });
} catch (err) { next(err); }
}
module.exports = {
getWorkTypes, createWorkType, updateWorkType, deleteWorkType,
getTasks, getActiveTasks, getTaskById, createTask, updateTask, deleteTask
};

View File

@@ -0,0 +1,146 @@
/**
* User Controller
*
* 사용자 CRUD + 비밀번호 관리
*/
const userModel = require('../models/userModel');
/**
* GET /api/users - 전체 사용자 목록
*/
async function getUsers(req, res, next) {
try {
const users = await userModel.findAll();
res.json({ success: true, data: users });
} catch (err) {
next(err);
}
}
/**
* POST /api/users - 사용자 생성
*/
async function createUser(req, res, next) {
try {
const { username, password, name, full_name, department, role } = req.body;
if (!username || !password) {
return res.status(400).json({ success: false, error: '사용자명과 비밀번호는 필수입니다' });
}
const existing = await userModel.findByUsername(username);
if (existing) {
return res.status(409).json({ success: false, error: '이미 존재하는 사용자명입니다' });
}
const user = await userModel.create({
username,
password,
name: name || full_name,
department,
role
});
res.status(201).json({ success: true, data: user });
} catch (err) {
next(err);
}
}
/**
* PUT /api/users/:id - 사용자 수정
*/
async function updateUser(req, res, next) {
try {
const userId = parseInt(req.params.id);
const data = { ...req.body };
// full_name → name 매핑
if (data.full_name !== undefined && data.name === undefined) {
data.name = data.full_name;
delete data.full_name;
}
const user = await userModel.update(userId, data);
if (!user) {
return res.status(404).json({ success: false, error: '사용자를 찾을 수 없습니다' });
}
res.json({ success: true, data: user });
} catch (err) {
next(err);
}
}
/**
* DELETE /api/users/:id - 사용자 비활성화
*/
async function deleteUser(req, res, next) {
try {
const userId = parseInt(req.params.id);
await userModel.deleteUser(userId);
res.json({ success: true, message: '사용자가 비활성화되었습니다' });
} catch (err) {
next(err);
}
}
/**
* POST /api/users/:id/reset-password - 비밀번호 초기화 (admin)
*/
async function resetPassword(req, res, next) {
try {
const userId = parseInt(req.params.id);
const { new_password } = req.body;
const password = new_password || '000000';
const user = await userModel.update(userId, { password });
if (!user) {
return res.status(404).json({ success: false, error: '사용자를 찾을 수 없습니다' });
}
res.json({ success: true, message: '비밀번호가 초기화되었습니다' });
} catch (err) {
next(err);
}
}
/**
* POST /api/users/change-password - 본인 비밀번호 변경
*/
async function changePassword(req, res, next) {
try {
const { current_password, new_password } = req.body;
const userId = req.user.user_id || req.user.id;
if (!current_password || !new_password) {
return res.status(400).json({ success: false, error: '현재 비밀번호와 새 비밀번호를 입력하세요' });
}
if (new_password.length < 6) {
return res.status(400).json({ success: false, error: '새 비밀번호는 최소 6자 이상이어야 합니다' });
}
const user = await userModel.findById(userId);
if (!user) {
return res.status(404).json({ success: false, error: '사용자를 찾을 수 없습니다' });
}
const valid = await userModel.verifyPassword(current_password, user.password_hash);
if (!valid) {
return res.status(401).json({ success: false, error: '현재 비밀번호가 올바르지 않습니다' });
}
await userModel.update(userId, { password: new_password });
res.json({ success: true, message: '비밀번호가 변경되었습니다' });
} catch (err) {
next(err);
}
}
module.exports = {
getUsers,
createUser,
updateUser,
deleteUser,
resetPassword,
changePassword
};

View File

@@ -0,0 +1,126 @@
/**
* Vacation Controller
*
* 휴가 유형 + 연차 배정 관리
*/
const vacationModel = require('../models/vacationModel');
/* ===== Vacation Types ===== */
async function getVacationTypes(req, res, next) {
try {
const all = req.query.all === 'true';
const data = all ? await vacationModel.getAllVacationTypes() : await vacationModel.getVacationTypes();
res.json({ success: true, data });
} catch (err) { next(err); }
}
async function createVacationType(req, res, next) {
try {
const { type_code, type_name } = req.body;
if (!type_code || !type_name) return res.status(400).json({ success: false, error: '유형 코드와 이름은 필수입니다' });
const data = await vacationModel.createVacationType(req.body);
res.status(201).json({ success: true, data });
} catch (err) {
if (err.code === 'ER_DUP_ENTRY') return res.status(409).json({ success: false, error: '이미 존재하는 유형 코드입니다' });
next(err);
}
}
async function updateVacationType(req, res, next) {
try {
const data = await vacationModel.updateVacationType(parseInt(req.params.id), req.body);
if (!data) return res.status(404).json({ success: false, error: '휴가 유형을 찾을 수 없습니다' });
res.json({ success: true, data });
} catch (err) {
if (err.code === 'ER_DUP_ENTRY') return res.status(409).json({ success: false, error: '이미 존재하는 유형 코드입니다' });
next(err);
}
}
async function deleteVacationType(req, res, next) {
try {
await vacationModel.deleteVacationType(parseInt(req.params.id));
res.json({ success: true, message: '휴가 유형이 비활성화되었습니다' });
} catch (err) { next(err); }
}
async function updatePriorities(req, res, next) {
try {
const { items } = req.body;
if (!items || !Array.isArray(items)) return res.status(400).json({ success: false, error: 'items 배열이 필요합니다' });
await vacationModel.updatePriorities(items);
res.json({ success: true, message: '우선순위가 업데이트되었습니다' });
} catch (err) { next(err); }
}
/* ===== Vacation Balances ===== */
async function getBalancesByYear(req, res, next) {
try {
const year = parseInt(req.params.year);
const data = await vacationModel.getBalancesByYear(year);
res.json({ success: true, data });
} catch (err) { next(err); }
}
async function getBalancesByWorkerYear(req, res, next) {
try {
const workerId = parseInt(req.params.workerId);
const year = parseInt(req.params.year);
const data = await vacationModel.getBalancesByWorkerYear(workerId, year);
res.json({ success: true, data });
} catch (err) { next(err); }
}
async function createBalance(req, res, next) {
try {
const { worker_id, vacation_type_id, year } = req.body;
if (!worker_id || !vacation_type_id || !year) {
return res.status(400).json({ success: false, error: '작업자, 휴가유형, 연도는 필수입니다' });
}
const data = await vacationModel.createBalance({ ...req.body, created_by: req.user.user_id });
res.status(201).json({ success: true, data });
} catch (err) { next(err); }
}
async function updateBalance(req, res, next) {
try {
const data = await vacationModel.updateBalance(parseInt(req.params.id), req.body);
if (!data) return res.status(404).json({ success: false, error: '배정 정보를 찾을 수 없습니다' });
res.json({ success: true, data });
} catch (err) { next(err); }
}
async function deleteBalance(req, res, next) {
try {
await vacationModel.deleteBalance(parseInt(req.params.id));
res.json({ success: true, message: '삭제되었습니다' });
} catch (err) { next(err); }
}
async function bulkUpsertBalances(req, res, next) {
try {
const { balances } = req.body;
if (!balances || !Array.isArray(balances)) return res.status(400).json({ success: false, error: 'balances 배열이 필요합니다' });
const items = balances.map(b => ({ ...b, created_by: req.user.user_id }));
const count = await vacationModel.bulkUpsertBalances(items);
res.json({ success: true, data: { count }, message: `${count}건 처리되었습니다` });
} catch (err) { next(err); }
}
async function autoCalculate(req, res, next) {
try {
const { year } = req.body;
if (!year) return res.status(400).json({ success: false, error: '연도는 필수입니다' });
const result = await vacationModel.autoCalculateForAllWorkers(year, req.user.user_id);
res.json({ success: true, data: result, message: `${result.count}명 자동 배정 완료` });
} catch (err) { next(err); }
}
module.exports = {
getVacationTypes, createVacationType, updateVacationType, deleteVacationType, updatePriorities,
getBalancesByYear, getBalancesByWorkerYear, createBalance, updateBalance, deleteBalance,
bulkUpsertBalances, autoCalculate
};

View File

@@ -0,0 +1,66 @@
/**
* Worker Controller
*
* 작업자 CRUD
*/
const workerModel = require('../models/workerModel');
async function getAll(req, res, next) {
try {
const workers = await workerModel.getAll();
res.json({ success: true, data: workers });
} catch (err) {
next(err);
}
}
async function getById(req, res, next) {
try {
const worker = await workerModel.getById(parseInt(req.params.id));
if (!worker) {
return res.status(404).json({ success: false, error: '작업자를 찾을 수 없습니다' });
}
res.json({ success: true, data: worker });
} catch (err) {
next(err);
}
}
async function create(req, res, next) {
try {
const { worker_name } = req.body;
if (!worker_name) {
return res.status(400).json({ success: false, error: '작업자 이름은 필수입니다' });
}
const worker = await workerModel.create(req.body);
res.status(201).json({ success: true, data: worker });
} catch (err) {
next(err);
}
}
async function update(req, res, next) {
try {
const id = parseInt(req.params.id);
const worker = await workerModel.update(id, req.body);
if (!worker) {
return res.status(404).json({ success: false, error: '작업자를 찾을 수 없습니다' });
}
res.json({ success: true, data: worker });
} catch (err) {
next(err);
}
}
async function remove(req, res, next) {
try {
const id = parseInt(req.params.id);
await workerModel.deactivate(id);
res.json({ success: true, message: '작업자가 비활성화되었습니다' });
} catch (err) {
next(err);
}
}
module.exports = { getAll, getById, create, update, remove };

View File

@@ -0,0 +1,155 @@
/**
* Workplace Controller
*
* 작업장 CRUD + 카테고리 조회
*/
const workplaceModel = require('../models/workplaceModel');
async function getAll(req, res, next) {
try {
const workplaces = await workplaceModel.getAll();
res.json({ success: true, data: workplaces });
} catch (err) {
next(err);
}
}
async function getById(req, res, next) {
try {
const wp = await workplaceModel.getById(parseInt(req.params.id));
if (!wp) {
return res.status(404).json({ success: false, error: '작업장을 찾을 수 없습니다' });
}
res.json({ success: true, data: wp });
} catch (err) {
next(err);
}
}
async function getCategories(req, res, next) {
try {
const categories = await workplaceModel.getCategories();
res.json({ success: true, data: categories });
} catch (err) {
next(err);
}
}
async function create(req, res, next) {
try {
const { workplace_name } = req.body;
if (!workplace_name) {
return res.status(400).json({ success: false, error: '작업장명은 필수입니다' });
}
const wp = await workplaceModel.create(req.body);
res.status(201).json({ success: true, data: wp });
} catch (err) {
next(err);
}
}
async function update(req, res, next) {
try {
const id = parseInt(req.params.id);
const wp = await workplaceModel.update(id, req.body);
if (!wp) {
return res.status(404).json({ success: false, error: '작업장을 찾을 수 없습니다' });
}
res.json({ success: true, data: wp });
} catch (err) {
next(err);
}
}
async function remove(req, res, next) {
try {
const id = parseInt(req.params.id);
await workplaceModel.deactivate(id);
res.json({ success: true, message: '작업장이 비활성화되었습니다' });
} catch (err) {
next(err);
}
}
// ==================== 구역지도 ====================
async function uploadCategoryLayoutImage(req, res, next) {
try {
if (!req.file) {
return res.status(400).json({ success: false, error: '이미지 파일이 필요합니다' });
}
const id = parseInt(req.params.id);
const imagePath = `/uploads/${req.file.filename}`;
const category = await workplaceModel.updateCategoryLayoutImage(id, imagePath);
if (!category) {
return res.status(404).json({ success: false, error: '카테고리를 찾을 수 없습니다' });
}
res.json({ success: true, data: { image_path: imagePath, category } });
} catch (err) {
next(err);
}
}
async function createMapRegion(req, res, next) {
try {
const { workplace_id, category_id } = req.body;
if (!workplace_id || !category_id) {
return res.status(400).json({ success: false, error: 'workplace_id와 category_id는 필수입니다' });
}
const region = await workplaceModel.createMapRegion(req.body);
res.status(201).json({ success: true, data: region });
} catch (err) {
next(err);
}
}
async function getMapRegionsByCategory(req, res, next) {
try {
const categoryId = parseInt(req.params.categoryId);
const regions = await workplaceModel.getMapRegionsByCategory(categoryId);
res.json({ success: true, data: regions });
} catch (err) {
next(err);
}
}
async function updateMapRegion(req, res, next) {
try {
const regionId = parseInt(req.params.id);
const region = await workplaceModel.updateMapRegion(regionId, req.body);
if (!region) {
return res.status(404).json({ success: false, error: '영역을 찾을 수 없습니다' });
}
res.json({ success: true, data: region });
} catch (err) {
next(err);
}
}
async function deleteMapRegion(req, res, next) {
try {
const regionId = parseInt(req.params.id);
await workplaceModel.deleteMapRegion(regionId);
res.json({ success: true, message: '영역이 삭제되었습니다' });
} catch (err) {
next(err);
}
}
async function uploadWorkplaceLayoutImage(req, res, next) {
try {
if (!req.file) return res.status(400).json({ success: false, error: '이미지 파일이 필요합니다' });
const id = parseInt(req.params.id);
const imagePath = `/uploads/${req.file.filename}`;
const wp = await workplaceModel.updateWorkplaceLayoutImage(id, imagePath);
if (!wp) return res.status(404).json({ success: false, error: '작업장을 찾을 수 없습니다' });
res.json({ success: true, data: { image_path: imagePath, workplace: wp } });
} catch (err) { next(err); }
}
module.exports = {
getAll, getById, getCategories, create, update, remove,
uploadCategoryLayoutImage, uploadWorkplaceLayoutImage,
createMapRegion, getMapRegionsByCategory, updateMapRegion, deleteMapRegion
};

View File

@@ -0,0 +1,65 @@
/**
* tkuser-api - 사용자 관리 서비스
*
* 사용자 CRUD + 페이지 권한 관리 통합 API
* MariaDB (sso_users + user_page_permissions) 직접 연결
*/
const express = require('express');
const path = require('path');
const cors = require('cors');
const userRoutes = require('./routes/userRoutes');
const permissionRoutes = require('./routes/permissionRoutes');
const projectRoutes = require('./routes/projectRoutes');
const workerRoutes = require('./routes/workerRoutes');
const departmentRoutes = require('./routes/departmentRoutes');
const workplaceRoutes = require('./routes/workplaceRoutes');
const equipmentRoutes = require('./routes/equipmentRoutes');
const taskRoutes = require('./routes/taskRoutes');
const vacationRoutes = require('./routes/vacationRoutes');
const app = express();
const PORT = process.env.PORT || 3000;
app.use(cors({
origin: true,
credentials: true
}));
app.use(express.json());
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok', service: 'tkuser-api', timestamp: new Date().toISOString() });
});
// Routes
app.use('/api/users', userRoutes);
app.use('/api/permissions', permissionRoutes);
app.use('/api/projects', projectRoutes);
app.use('/api/workers', workerRoutes);
app.use('/api/departments', departmentRoutes);
app.use('/api/workplaces', workplaceRoutes);
app.use('/api/equipments', equipmentRoutes);
app.use('/api/tasks', taskRoutes);
app.use('/api/vacations', vacationRoutes);
// 404
app.use((req, res) => {
res.status(404).json({ success: false, error: 'Not Found' });
});
// Error handler
app.use((err, req, res, next) => {
console.error('tkuser-api Error:', err.message);
res.status(err.status || 500).json({
success: false,
error: err.message || 'Internal Server Error'
});
});
app.listen(PORT, () => {
console.log(`tkuser-api running on port ${PORT}`);
});
module.exports = app;

View File

@@ -0,0 +1,61 @@
/**
* 인증 미들웨어
* JWT 검증 + admin 체크
*/
const jwt = require('jsonwebtoken');
const JWT_SECRET = process.env.SSO_JWT_SECRET;
/**
* Bearer 토큰 또는 쿠키에서 토큰 추출
*/
function extractToken(req) {
const authHeader = req.headers['authorization'];
if (authHeader && authHeader.startsWith('Bearer ')) {
return authHeader.split(' ')[1];
}
if (req.cookies && req.cookies.sso_token) {
return req.cookies.sso_token;
}
return null;
}
/**
* 인증 필수 미들웨어
*/
function requireAuth(req, res, next) {
const token = extractToken(req);
if (!token) {
return res.status(401).json({ success: false, error: '인증이 필요합니다' });
}
try {
const decoded = jwt.verify(token, JWT_SECRET);
req.user = decoded;
next();
} catch {
return res.status(401).json({ success: false, error: '유효하지 않은 토큰입니다' });
}
}
/**
* 관리자 권한 미들웨어
*/
function requireAdmin(req, res, next) {
const token = extractToken(req);
if (!token) {
return res.status(401).json({ success: false, error: '인증이 필요합니다' });
}
try {
const decoded = jwt.verify(token, JWT_SECRET);
if (!['admin', 'system'].includes(decoded.role)) {
return res.status(403).json({ success: false, error: '관리자 권한이 필요합니다' });
}
req.user = decoded;
next();
} catch {
return res.status(401).json({ success: false, error: '유효하지 않은 토큰입니다' });
}
}
module.exports = { extractToken, requireAuth, requireAdmin };

View File

@@ -0,0 +1,35 @@
/**
* 파일 업로드 미들웨어 (multer)
*/
const multer = require('multer');
const path = require('path');
const crypto = require('crypto');
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, path.join(__dirname, '..', 'uploads'));
},
filename: (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
const uniqueName = `workplace-layout-${Date.now()}-${crypto.randomInt(100000000, 999999999)}${ext}`;
cb(null, uniqueName);
}
});
const fileFilter = (req, file, cb) => {
const allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if (allowed.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('허용되지 않는 파일 형식입니다. (JPEG, PNG, GIF, WebP만 가능)'), false);
}
};
const upload = multer({
storage,
fileFilter,
limits: { fileSize: 5 * 1024 * 1024 }
});
module.exports = upload;

View File

@@ -0,0 +1,71 @@
/**
* Department Model
*
* departments 테이블 CRUD (MariaDB)
*/
const { getPool } = require('./userModel');
async function getAll() {
const db = getPool();
const [rows] = await db.query(
`SELECT d.*, p.department_name AS parent_name
FROM departments d
LEFT JOIN departments p ON d.parent_id = p.department_id
ORDER BY d.display_order ASC, d.department_id ASC`
);
return rows;
}
async function getById(id) {
const db = getPool();
const [rows] = await db.query(
`SELECT d.*, p.department_name AS parent_name
FROM departments d
LEFT JOIN departments p ON d.parent_id = p.department_id
WHERE d.department_id = ?`,
[id]
);
return rows[0] || null;
}
async function create({ department_name, parent_id, description, display_order }) {
const db = getPool();
const [result] = await db.query(
`INSERT INTO departments (department_name, parent_id, description, display_order)
VALUES (?, ?, ?, ?)`,
[department_name, parent_id || null, description || null, display_order || 0]
);
return getById(result.insertId);
}
async function update(id, data) {
const db = getPool();
const fields = [];
const values = [];
if (data.department_name !== undefined) { fields.push('department_name = ?'); values.push(data.department_name); }
if (data.parent_id !== undefined) { fields.push('parent_id = ?'); values.push(data.parent_id || null); }
if (data.description !== undefined) { fields.push('description = ?'); values.push(data.description || null); }
if (data.is_active !== undefined) { fields.push('is_active = ?'); values.push(data.is_active); }
if (data.display_order !== undefined) { fields.push('display_order = ?'); values.push(data.display_order); }
if (fields.length === 0) return getById(id);
values.push(id);
await db.query(
`UPDATE departments SET ${fields.join(', ')} WHERE department_id = ?`,
values
);
return getById(id);
}
async function deactivate(id) {
const db = getPool();
await db.query(
'UPDATE departments SET is_active = FALSE WHERE department_id = ?',
[id]
);
}
module.exports = { getAll, getById, create, update, deactivate };

View File

@@ -0,0 +1,192 @@
/**
* Equipment Model
*
* equipments + equipment_photos CRUD (MariaDB)
*/
const { getPool } = require('./userModel');
// ==================== 기본 CRUD ====================
async function getAll(filters = {}) {
const db = getPool();
let sql = `SELECT e.*, w.workplace_name, c.category_name
FROM equipments e
LEFT JOIN workplaces w ON e.workplace_id = w.workplace_id
LEFT JOIN workplace_categories c ON w.category_id = c.category_id`;
const conditions = [];
const values = [];
if (filters.workplace_id) { conditions.push('e.workplace_id = ?'); values.push(filters.workplace_id); }
if (filters.equipment_type) { conditions.push('e.equipment_type = ?'); values.push(filters.equipment_type); }
if (filters.status) { conditions.push('e.status = ?'); values.push(filters.status); }
if (filters.search) {
conditions.push('(e.equipment_name LIKE ? OR e.equipment_code LIKE ?)');
const term = `%${filters.search}%`;
values.push(term, term);
}
if (conditions.length) sql += ' WHERE ' + conditions.join(' AND ');
sql += ' ORDER BY e.equipment_code ASC';
const [rows] = await db.query(sql, values);
return rows;
}
async function getById(id) {
const db = getPool();
const [rows] = await db.query(
`SELECT e.*, w.workplace_name, c.category_name
FROM equipments e
LEFT JOIN workplaces w ON e.workplace_id = w.workplace_id
LEFT JOIN workplace_categories c ON w.category_id = c.category_id
WHERE e.equipment_id = ?`,
[id]
);
return rows[0] || null;
}
async function getByWorkplace(workplaceId) {
const db = getPool();
const [rows] = await db.query(
`SELECT e.*, w.workplace_name
FROM equipments e
LEFT JOIN workplaces w ON e.workplace_id = w.workplace_id
WHERE e.workplace_id = ?
ORDER BY e.equipment_code ASC`,
[workplaceId]
);
return rows;
}
async function create(data) {
const db = getPool();
const [result] = await db.query(
`INSERT INTO equipments (equipment_code, equipment_name, equipment_type, model_name, manufacturer, supplier, purchase_price, installation_date, serial_number, specifications, status, notes, workplace_id, map_x_percent, map_y_percent, map_width_percent, map_height_percent)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
data.equipment_code, data.equipment_name,
data.equipment_type || null, data.model_name || null,
data.manufacturer || null, data.supplier || null,
data.purchase_price || null, data.installation_date || null,
data.serial_number || null, data.specifications || null,
data.status || 'active', data.notes || null,
data.workplace_id || null,
data.map_x_percent || null, data.map_y_percent || null,
data.map_width_percent || null, data.map_height_percent || null
]
);
return getById(result.insertId);
}
async function update(id, data) {
const db = getPool();
const fields = [];
const values = [];
if (data.equipment_code !== undefined) { fields.push('equipment_code = ?'); values.push(data.equipment_code); }
if (data.equipment_name !== undefined) { fields.push('equipment_name = ?'); values.push(data.equipment_name); }
if (data.equipment_type !== undefined) { fields.push('equipment_type = ?'); values.push(data.equipment_type || null); }
if (data.model_name !== undefined) { fields.push('model_name = ?'); values.push(data.model_name || null); }
if (data.manufacturer !== undefined) { fields.push('manufacturer = ?'); values.push(data.manufacturer || null); }
if (data.supplier !== undefined) { fields.push('supplier = ?'); values.push(data.supplier || null); }
if (data.purchase_price !== undefined) { fields.push('purchase_price = ?'); values.push(data.purchase_price || null); }
if (data.installation_date !== undefined) { fields.push('installation_date = ?'); values.push(data.installation_date || null); }
if (data.serial_number !== undefined) { fields.push('serial_number = ?'); values.push(data.serial_number || null); }
if (data.specifications !== undefined) { fields.push('specifications = ?'); values.push(data.specifications || null); }
if (data.status !== undefined) { fields.push('status = ?'); values.push(data.status); }
if (data.notes !== undefined) { fields.push('notes = ?'); values.push(data.notes || null); }
if (data.workplace_id !== undefined) { fields.push('workplace_id = ?'); values.push(data.workplace_id || null); }
if (data.map_x_percent !== undefined) { fields.push('map_x_percent = ?'); values.push(data.map_x_percent); }
if (data.map_y_percent !== undefined) { fields.push('map_y_percent = ?'); values.push(data.map_y_percent); }
if (data.map_width_percent !== undefined) { fields.push('map_width_percent = ?'); values.push(data.map_width_percent); }
if (data.map_height_percent !== undefined) { fields.push('map_height_percent = ?'); values.push(data.map_height_percent); }
if (fields.length === 0) return getById(id);
values.push(id);
await db.query(`UPDATE equipments SET ${fields.join(', ')} WHERE equipment_id = ?`, values);
return getById(id);
}
async function remove(id) {
const db = getPool();
await db.query('DELETE FROM equipments WHERE equipment_id = ?', [id]);
}
async function getEquipmentTypes() {
const db = getPool();
const [rows] = await db.query(
'SELECT DISTINCT equipment_type FROM equipments WHERE equipment_type IS NOT NULL AND equipment_type != "" ORDER BY equipment_type ASC'
);
return rows.map(r => r.equipment_type);
}
async function getNextCode(prefix = 'TKP') {
const db = getPool();
const [rows] = await db.query(
'SELECT equipment_code FROM equipments WHERE equipment_code LIKE ? ORDER BY equipment_code DESC LIMIT 1',
[`${prefix}-%`]
);
if (!rows.length) return `${prefix}-001`;
const lastNum = parseInt(rows[0].equipment_code.replace(`${prefix}-`, ''), 10) || 0;
return `${prefix}-${String(lastNum + 1).padStart(3, '0')}`;
}
async function checkDuplicateCode(code, excludeId) {
const db = getPool();
let sql = 'SELECT equipment_id FROM equipments WHERE equipment_code = ?';
const values = [code];
if (excludeId) { sql += ' AND equipment_id != ?'; values.push(excludeId); }
const [rows] = await db.query(sql, values);
return rows.length > 0;
}
// ==================== 지도 위치 ====================
async function updateMapPosition(id, positionData) {
const db = getPool();
const fields = ['map_x_percent = ?', 'map_y_percent = ?', 'map_width_percent = ?', 'map_height_percent = ?'];
const values = [positionData.map_x_percent, positionData.map_y_percent, positionData.map_width_percent, positionData.map_height_percent];
if (positionData.workplace_id !== undefined) {
fields.push('workplace_id = ?');
values.push(positionData.workplace_id);
}
values.push(id);
await db.query(`UPDATE equipments SET ${fields.join(', ')} WHERE equipment_id = ?`, values);
return getById(id);
}
// ==================== 사진 ====================
async function addPhoto(equipmentId, photoData) {
const db = getPool();
const [result] = await db.query(
`INSERT INTO equipment_photos (equipment_id, photo_path, description, display_order, uploaded_by) VALUES (?, ?, ?, ?, ?)`,
[equipmentId, photoData.photo_path, photoData.description || null, photoData.display_order || 0, photoData.uploaded_by || null]
);
return { photo_id: result.insertId, equipment_id: equipmentId, ...photoData };
}
async function getPhotos(equipmentId) {
const db = getPool();
const [rows] = await db.query(
'SELECT * FROM equipment_photos WHERE equipment_id = ? ORDER BY display_order ASC, created_at ASC',
[equipmentId]
);
return rows;
}
async function deletePhoto(photoId) {
const db = getPool();
const [photo] = await db.query('SELECT photo_path FROM equipment_photos WHERE photo_id = ?', [photoId]);
await db.query('DELETE FROM equipment_photos WHERE photo_id = ?', [photoId]);
return { photo_id: photoId, photo_path: photo[0]?.photo_path };
}
module.exports = {
getAll, getById, getByWorkplace, create, update, remove,
getEquipmentTypes, getNextCode, checkDuplicateCode,
updateMapPosition,
addPhoto, getPhotos, deletePhoto
};

View File

@@ -0,0 +1,150 @@
/**
* Permission Model
*
* MariaDB user_page_permissions 테이블 CRUD
*/
const { getPool } = require('./userModel');
// 기본 페이지 목록 (시스템별 구분)
const DEFAULT_PAGES = {
// ===== System 1 - 공장관리 =====
// 작업 관리
's1.dashboard': { title: '대시보드', system: 'system1', group: '작업 관리', default_access: true },
's1.work.tbm': { title: 'TBM 관리', system: 'system1', group: '작업 관리', default_access: true },
's1.work.report_create': { title: '작업보고서 작성', system: 'system1', group: '작업 관리', default_access: true },
's1.work.analysis': { title: '작업 분석', system: 'system1', group: '작업 관리', default_access: false },
's1.work.nonconformity': { title: '부적합 현황', system: 'system1', group: '작업 관리', default_access: true },
// 공장 관리
's1.factory.repair_management':{ title: '시설설비 관리', system: 'system1', group: '공장 관리', default_access: false },
's1.inspection.daily_patrol': { title: '일일순회점검', system: 'system1', group: '공장 관리', default_access: false },
's1.inspection.checkin': { title: '출근 체크', system: 'system1', group: '공장 관리', default_access: true },
's1.inspection.work_status': { title: '근무 현황', system: 'system1', group: '공장 관리', default_access: false },
// 안전 관리
's1.safety.visit_request': { title: '출입 신청', system: 'system1', group: '안전 관리', default_access: true },
's1.safety.management': { title: '안전 관리', system: 'system1', group: '안전 관리', default_access: false },
's1.safety.checklist_manage': { title: '체크리스트 관리', system: 'system1', group: '안전 관리', default_access: false },
// 근태 관리
's1.attendance.my_vacation_info': { title: '내 연차 정보', system: 'system1', group: '근태 관리', default_access: true },
's1.attendance.monthly': { title: '월간 근태', system: 'system1', group: '근태 관리', default_access: true },
's1.attendance.vacation_request': { title: '휴가 신청', system: 'system1', group: '근태 관리', default_access: true },
's1.attendance.vacation_management': { title: '휴가 관리', system: 'system1', group: '근태 관리', default_access: false },
's1.attendance.vacation_allocation': { title: '휴가 발생 입력', system: 'system1', group: '근태 관리', default_access: false },
's1.attendance.annual_overview': { title: '연간 휴가 현황', system: 'system1', group: '근태 관리', default_access: false },
// 시스템 관리
's1.admin.workers': { title: '작업자 관리', system: 'system1', group: '시스템 관리', default_access: false },
's1.admin.projects': { title: '프로젝트 관리', system: 'system1', group: '시스템 관리', default_access: false },
's1.admin.tasks': { title: '작업 관리', system: 'system1', group: '시스템 관리', default_access: false },
's1.admin.workplaces': { title: '작업장 관리', system: 'system1', group: '시스템 관리', default_access: false },
's1.admin.equipments': { title: '설비 관리', system: 'system1', group: '시스템 관리', default_access: false },
's1.admin.issue_categories': { title: '신고 카테고리 관리', system: 'system1', group: '시스템 관리', default_access: false },
's1.admin.attendance_report': { title: '출퇴근-보고서 대조', system: 'system1', group: '시스템 관리', default_access: false },
// ===== System 3 - 부적합관리 =====
// 메인
'issues_dashboard': { title: '현황판', system: 'system3', group: '메인', default_access: true },
'issues_inbox': { title: '수신함', system: 'system3', group: '메인', default_access: true },
'issues_management': { title: '관리함', system: 'system3', group: '메인', default_access: false },
'issues_archive': { title: '폐기함', system: 'system3', group: '메인', default_access: false },
// 업무
'daily_work': { title: '일일 공수', system: 'system3', group: '업무', default_access: false },
'projects_manage': { title: '프로젝트 관리', system: 'system3', group: '업무', default_access: false },
// 보고서
'reports': { title: '보고서', system: 'system3', group: '보고서', default_access: false },
'reports_daily': { title: '일일보고서', system: 'system3', group: '보고서', default_access: false },
'reports_weekly': { title: '주간보고서', system: 'system3', group: '보고서', default_access: false },
'reports_monthly': { title: '월간보고서', system: 'system3', group: '보고서', default_access: false }
};
/**
* 사용자의 페이지 권한 목록 조회
*/
async function getUserPermissions(userId) {
const db = getPool();
const [rows] = await db.query(
'SELECT * FROM user_page_permissions WHERE user_id = ? ORDER BY page_name',
[userId]
);
return rows;
}
/**
* 단건 권한 부여/업데이트 (UPSERT)
*/
async function grantPermission({ user_id, page_name, can_access, granted_by_id, notes }) {
const db = getPool();
const [result] = await db.query(
`INSERT INTO user_page_permissions (user_id, page_name, can_access, granted_by_id, notes)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE can_access = VALUES(can_access), granted_by_id = VALUES(granted_by_id), notes = VALUES(notes), granted_at = CURRENT_TIMESTAMP`,
[user_id, page_name, can_access, granted_by_id, notes || null]
);
return { id: result.insertId, user_id, page_name, can_access };
}
/**
* 일괄 권한 부여
*/
async function bulkGrant({ user_id, permissions, granted_by_id }) {
const db = getPool();
let count = 0;
for (const perm of permissions) {
if (!DEFAULT_PAGES[perm.page_name]) continue;
await db.query(
`INSERT INTO user_page_permissions (user_id, page_name, can_access, granted_by_id)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE can_access = VALUES(can_access), granted_by_id = VALUES(granted_by_id), granted_at = CURRENT_TIMESTAMP`,
[user_id, perm.page_name, perm.can_access, granted_by_id]
);
count++;
}
return { updated_count: count };
}
/**
* 접근 권한 확인
*/
async function checkAccess(userId, pageName) {
const db = getPool();
const [rows] = await db.query(
'SELECT can_access FROM user_page_permissions WHERE user_id = ? AND page_name = ?',
[userId, pageName]
);
if (rows.length > 0) {
return { can_access: rows[0].can_access, reason: 'explicit_permission' };
}
// 기본 권한
const pageConfig = DEFAULT_PAGES[pageName];
if (!pageConfig) {
return { can_access: false, reason: 'invalid_page' };
}
return { can_access: pageConfig.default_access, reason: 'default_permission' };
}
/**
* 권한 삭제 (기본값으로 되돌림)
*/
async function deletePermission(permissionId) {
const db = getPool();
const [rows] = await db.query(
'SELECT * FROM user_page_permissions WHERE id = ?',
[permissionId]
);
if (rows.length === 0) return null;
await db.query('DELETE FROM user_page_permissions WHERE id = ?', [permissionId]);
return rows[0];
}
module.exports = {
DEFAULT_PAGES,
getUserPermissions,
grantPermission,
bulkGrant,
checkAccess,
deletePermission
};

View File

@@ -0,0 +1,79 @@
/**
* Project Model
*
* projects 테이블 CRUD (MariaDB)
* System 1과 같은 DB를 공유
*/
const { getPool } = require('./userModel');
async function getAll() {
const db = getPool();
const [rows] = await db.query(
'SELECT * FROM projects ORDER BY project_id DESC'
);
return rows;
}
async function getActive() {
const db = getPool();
const [rows] = await db.query(
'SELECT * FROM projects WHERE is_active = TRUE ORDER BY project_name ASC'
);
return rows;
}
async function getById(id) {
const db = getPool();
const [rows] = await db.query(
'SELECT * FROM projects WHERE project_id = ?',
[id]
);
return rows[0] || null;
}
async function create({ job_no, project_name, contract_date, due_date, delivery_method, site, pm }) {
const db = getPool();
const [result] = await db.query(
`INSERT INTO projects (job_no, project_name, contract_date, due_date, delivery_method, site, pm)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[job_no, project_name, contract_date || null, due_date || null, delivery_method || null, site || null, pm || null]
);
return getById(result.insertId);
}
async function update(id, data) {
const db = getPool();
const fields = [];
const values = [];
if (data.job_no !== undefined) { fields.push('job_no = ?'); values.push(data.job_no); }
if (data.project_name !== undefined) { fields.push('project_name = ?'); values.push(data.project_name); }
if (data.contract_date !== undefined) { fields.push('contract_date = ?'); values.push(data.contract_date || null); }
if (data.due_date !== undefined) { fields.push('due_date = ?'); values.push(data.due_date || null); }
if (data.delivery_method !== undefined) { fields.push('delivery_method = ?'); values.push(data.delivery_method); }
if (data.site !== undefined) { fields.push('site = ?'); values.push(data.site); }
if (data.pm !== undefined) { fields.push('pm = ?'); values.push(data.pm); }
if (data.is_active !== undefined) { fields.push('is_active = ?'); values.push(data.is_active); }
if (data.project_status !== undefined) { fields.push('project_status = ?'); values.push(data.project_status); }
if (data.completed_date !== undefined) { fields.push('completed_date = ?'); values.push(data.completed_date || null); }
if (fields.length === 0) return getById(id);
values.push(id);
await db.query(
`UPDATE projects SET ${fields.join(', ')} WHERE project_id = ?`,
values
);
return getById(id);
}
async function deactivate(id) {
const db = getPool();
await db.query(
'UPDATE projects SET is_active = FALSE, project_status = ? WHERE project_id = ?',
['completed', id]
);
}
module.exports = { getAll, getActive, getById, create, update, deactivate };

View File

@@ -0,0 +1,127 @@
/**
* Task Model
*
* work_types + tasks 테이블 CRUD (MariaDB)
* System 1과 같은 DB를 공유
*/
const { getPool } = require('./userModel');
/* ===== Work Types (공정) ===== */
async function getWorkTypes() {
const db = getPool();
const [rows] = await db.query(
'SELECT * FROM work_types ORDER BY category ASC, name ASC'
);
return rows;
}
async function getWorkTypeById(id) {
const db = getPool();
const [rows] = await db.query('SELECT * FROM work_types WHERE id = ?', [id]);
return rows[0] || null;
}
async function createWorkType({ name, category, description }) {
const db = getPool();
const [result] = await db.query(
'INSERT INTO work_types (name, category, description) VALUES (?, ?, ?)',
[name, category || null, description || null]
);
return getWorkTypeById(result.insertId);
}
async function updateWorkType(id, data) {
const db = getPool();
const fields = [];
const values = [];
if (data.name !== undefined) { fields.push('name = ?'); values.push(data.name); }
if (data.category !== undefined) { fields.push('category = ?'); values.push(data.category || null); }
if (data.description !== undefined) { fields.push('description = ?'); values.push(data.description || null); }
if (fields.length === 0) return getWorkTypeById(id);
values.push(id);
await db.query(`UPDATE work_types SET ${fields.join(', ')} WHERE id = ?`, values);
return getWorkTypeById(id);
}
async function deleteWorkType(id) {
const db = getPool();
// 연결된 tasks의 work_type_id를 NULL로 설정 (FK cascade가 처리하지만 명시적으로)
await db.query('UPDATE tasks SET work_type_id = NULL WHERE work_type_id = ?', [id]);
await db.query('DELETE FROM work_types WHERE id = ?', [id]);
}
/* ===== Tasks (작업) ===== */
async function getTasks(workTypeId) {
const db = getPool();
let sql = `SELECT t.*, wt.name AS work_type_name, wt.category AS work_type_category
FROM tasks t
LEFT JOIN work_types wt ON t.work_type_id = wt.id`;
const params = [];
if (workTypeId) {
sql += ' WHERE t.work_type_id = ?';
params.push(workTypeId);
}
sql += ' ORDER BY wt.category ASC, t.task_id DESC';
const [rows] = await db.query(sql, params);
return rows;
}
async function getActiveTasks() {
const db = getPool();
const [rows] = await db.query(
`SELECT t.*, wt.name AS work_type_name, wt.category AS work_type_category
FROM tasks t
LEFT JOIN work_types wt ON t.work_type_id = wt.id
WHERE t.is_active = TRUE
ORDER BY wt.category ASC, t.task_name ASC`
);
return rows;
}
async function getTaskById(id) {
const db = getPool();
const [rows] = await db.query(
`SELECT t.*, wt.name AS work_type_name, wt.category AS work_type_category
FROM tasks t
LEFT JOIN work_types wt ON t.work_type_id = wt.id
WHERE t.task_id = ?`,
[id]
);
return rows[0] || null;
}
async function createTask({ work_type_id, task_name, description, is_active }) {
const db = getPool();
const [result] = await db.query(
'INSERT INTO tasks (work_type_id, task_name, description, is_active) VALUES (?, ?, ?, ?)',
[work_type_id || null, task_name, description || null, is_active !== undefined ? is_active : true]
);
return getTaskById(result.insertId);
}
async function updateTask(id, data) {
const db = getPool();
const fields = [];
const values = [];
if (data.work_type_id !== undefined) { fields.push('work_type_id = ?'); values.push(data.work_type_id || null); }
if (data.task_name !== undefined) { fields.push('task_name = ?'); values.push(data.task_name); }
if (data.description !== undefined) { fields.push('description = ?'); values.push(data.description || null); }
if (data.is_active !== undefined) { fields.push('is_active = ?'); values.push(data.is_active); }
if (fields.length === 0) return getTaskById(id);
values.push(id);
await db.query(`UPDATE tasks SET ${fields.join(', ')} WHERE task_id = ?`, values);
return getTaskById(id);
}
async function deleteTask(id) {
const db = getPool();
await db.query('DELETE FROM tasks WHERE task_id = ?', [id]);
}
module.exports = {
getWorkTypes, getWorkTypeById, createWorkType, updateWorkType, deleteWorkType,
getTasks, getActiveTasks, getTaskById, createTask, updateTask, deleteTask
};

View File

@@ -0,0 +1,158 @@
/**
* User Model
*
* sso_users 테이블 CRUD 및 비밀번호 관리
* sso-auth-service/models/userModel.js 기반
*/
const mysql = require('mysql2/promise');
const bcrypt = require('bcrypt');
const crypto = require('crypto');
let pool;
function getPool() {
if (!pool) {
pool = mysql.createPool({
host: process.env.DB_HOST || 'mariadb',
port: parseInt(process.env.DB_PORT) || 3306,
user: process.env.DB_USER || 'hyungi_user',
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME || 'hyungi',
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0
});
}
return pool;
}
/**
* pbkdf2_sha256 해시 검증 (passlib 호환)
*/
function verifyPbkdf2(password, storedHash) {
try {
const parts = storedHash.split('$');
if (parts.length < 5) return false;
const rounds = parseInt(parts[2]);
const salt = parts[3].replace(/\./g, '+');
const hash = parts[4].replace(/\./g, '+');
const padded = (s) => s + '='.repeat((4 - s.length % 4) % 4);
const saltBuffer = Buffer.from(padded(salt), 'base64');
const expectedHash = Buffer.from(padded(hash), 'base64');
const derivedKey = crypto.pbkdf2Sync(password, saltBuffer, rounds, expectedHash.length, 'sha256');
return crypto.timingSafeEqual(derivedKey, expectedHash);
} catch (err) {
console.error('pbkdf2 verify error:', err.message);
return false;
}
}
/**
* 비밀번호 검증 (bcrypt 또는 pbkdf2_sha256 자동 감지)
*/
async function verifyPassword(password, storedHash) {
if (!password || !storedHash) return false;
if (storedHash.startsWith('$pbkdf2-sha256$')) {
return verifyPbkdf2(password, storedHash);
}
if (storedHash.startsWith('$2b$') || storedHash.startsWith('$2a$')) {
return bcrypt.compare(password, storedHash);
}
return false;
}
/**
* bcrypt로 비밀번호 해시 생성
*/
async function hashPassword(password) {
return bcrypt.hash(password, 10);
}
async function findByUsername(username) {
const db = getPool();
const [rows] = await db.query(
'SELECT * FROM sso_users WHERE username = ? AND is_active = TRUE',
[username]
);
return rows[0] || null;
}
async function findById(userId) {
const db = getPool();
const [rows] = await db.query(
'SELECT * FROM sso_users WHERE user_id = ?',
[userId]
);
return rows[0] || null;
}
async function findAll() {
const db = getPool();
const [rows] = await db.query(
'SELECT user_id, username, name, department, role, system1_access, system2_access, system3_access, is_active, last_login, created_at FROM sso_users ORDER BY user_id'
);
return rows;
}
async function create({ username, password, name, department, role }) {
const db = getPool();
const password_hash = await hashPassword(password);
const [result] = await db.query(
`INSERT INTO sso_users (username, password_hash, name, department, role)
VALUES (?, ?, ?, ?, ?)`,
[username, password_hash, name || null, department || null, role || 'user']
);
return findById(result.insertId);
}
async function update(userId, data) {
const db = getPool();
const fields = [];
const values = [];
if (data.name !== undefined) { fields.push('name = ?'); values.push(data.name); }
if (data.department !== undefined) { fields.push('department = ?'); values.push(data.department); }
if (data.role !== undefined) { fields.push('role = ?'); values.push(data.role); }
if (data.system1_access !== undefined) { fields.push('system1_access = ?'); values.push(data.system1_access); }
if (data.system2_access !== undefined) { fields.push('system2_access = ?'); values.push(data.system2_access); }
if (data.system3_access !== undefined) { fields.push('system3_access = ?'); values.push(data.system3_access); }
if (data.is_active !== undefined) { fields.push('is_active = ?'); values.push(data.is_active); }
if (data.password) {
fields.push('password_hash = ?');
values.push(await hashPassword(data.password));
}
if (fields.length === 0) return findById(userId);
values.push(userId);
await db.query(
`UPDATE sso_users SET ${fields.join(', ')} WHERE user_id = ?`,
values
);
return findById(userId);
}
async function deleteUser(userId) {
const db = getPool();
await db.query('UPDATE sso_users SET is_active = FALSE WHERE user_id = ?', [userId]);
}
module.exports = {
verifyPassword,
hashPassword,
findByUsername,
findById,
findAll,
create,
update,
deleteUser,
getPool
};

View File

@@ -0,0 +1,219 @@
/**
* Vacation Model
*
* vacation_types + vacation_balance_details CRUD (MariaDB)
* System 1과 같은 DB를 공유
*/
const { getPool } = require('./userModel');
/* ===== Vacation Types (휴가 유형) ===== */
async function getVacationTypes() {
const db = getPool();
const [rows] = await db.query(
'SELECT * FROM vacation_types WHERE is_active = TRUE ORDER BY priority ASC, id ASC'
);
return rows;
}
async function getAllVacationTypes() {
const db = getPool();
const [rows] = await db.query(
'SELECT * FROM vacation_types ORDER BY priority ASC, id ASC'
);
return rows;
}
async function getVacationTypeById(id) {
const db = getPool();
const [rows] = await db.query('SELECT * FROM vacation_types WHERE id = ?', [id]);
return rows[0] || null;
}
async function createVacationType({ type_code, type_name, deduct_days, is_special, priority, description }) {
const db = getPool();
const [result] = await db.query(
`INSERT INTO vacation_types (type_code, type_name, deduct_days, is_special, priority, description, is_system)
VALUES (?, ?, ?, ?, ?, ?, FALSE)`,
[type_code, type_name, deduct_days ?? 1.0, is_special ? 1 : 0, priority ?? 99, description || null]
);
return getVacationTypeById(result.insertId);
}
async function updateVacationType(id, data) {
const db = getPool();
const fields = [];
const values = [];
if (data.type_code !== undefined) { fields.push('type_code = ?'); values.push(data.type_code); }
if (data.type_name !== undefined) { fields.push('type_name = ?'); values.push(data.type_name); }
if (data.deduct_days !== undefined) { fields.push('deduct_days = ?'); values.push(data.deduct_days); }
if (data.is_special !== undefined) { fields.push('is_special = ?'); values.push(data.is_special ? 1 : 0); }
if (data.priority !== undefined) { fields.push('priority = ?'); values.push(data.priority); }
if (data.description !== undefined) { fields.push('description = ?'); values.push(data.description || null); }
if (data.is_active !== undefined) { fields.push('is_active = ?'); values.push(data.is_active ? 1 : 0); }
if (fields.length === 0) return getVacationTypeById(id);
values.push(id);
await db.query(`UPDATE vacation_types SET ${fields.join(', ')} WHERE id = ?`, values);
return getVacationTypeById(id);
}
async function deleteVacationType(id) {
const db = getPool();
// 시스템 유형은 비활성화만
const type = await getVacationTypeById(id);
if (type && type.is_system) {
await db.query('UPDATE vacation_types SET is_active = FALSE WHERE id = ?', [id]);
} else {
await db.query('UPDATE vacation_types SET is_active = FALSE WHERE id = ?', [id]);
}
}
async function updatePriorities(items) {
const db = getPool();
for (const { id, priority } of items) {
await db.query('UPDATE vacation_types SET priority = ? WHERE id = ?', [priority, id]);
}
}
/* ===== Vacation Balances (연차 배정) ===== */
async function getBalancesByYear(year) {
const db = getPool();
const [rows] = await db.query(
`SELECT vbd.*, vt.type_code, vt.type_name, vt.deduct_days, vt.priority,
w.worker_name, w.hire_date
FROM vacation_balance_details vbd
JOIN vacation_types vt ON vbd.vacation_type_id = vt.id
JOIN workers w ON vbd.worker_id = w.worker_id
WHERE vbd.year = ?
ORDER BY w.worker_name ASC, vt.priority ASC`,
[year]
);
return rows;
}
async function getBalancesByWorkerYear(workerId, year) {
const db = getPool();
const [rows] = await db.query(
`SELECT vbd.*, vt.type_code, vt.type_name, vt.deduct_days, vt.priority
FROM vacation_balance_details vbd
JOIN vacation_types vt ON vbd.vacation_type_id = vt.id
WHERE vbd.worker_id = ? AND vbd.year = ?
ORDER BY vt.priority ASC`,
[workerId, year]
);
return rows;
}
async function getBalanceById(id) {
const db = getPool();
const [rows] = await db.query(
`SELECT vbd.*, vt.type_code, vt.type_name
FROM vacation_balance_details vbd
JOIN vacation_types vt ON vbd.vacation_type_id = vt.id
WHERE vbd.id = ?`,
[id]
);
return rows[0] || null;
}
async function createBalance({ worker_id, vacation_type_id, year, total_days, used_days, notes, created_by }) {
const db = getPool();
const [result] = await db.query(
`INSERT INTO vacation_balance_details (worker_id, vacation_type_id, year, total_days, used_days, notes, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE total_days = VALUES(total_days), used_days = VALUES(used_days), notes = VALUES(notes)`,
[worker_id, vacation_type_id, year, total_days ?? 0, used_days ?? 0, notes || null, created_by]
);
return result.insertId ? getBalanceById(result.insertId) : null;
}
async function updateBalance(id, data) {
const db = getPool();
const fields = [];
const values = [];
if (data.total_days !== undefined) { fields.push('total_days = ?'); values.push(data.total_days); }
if (data.used_days !== undefined) { fields.push('used_days = ?'); values.push(data.used_days); }
if (data.notes !== undefined) { fields.push('notes = ?'); values.push(data.notes || null); }
if (fields.length === 0) return getBalanceById(id);
values.push(id);
await db.query(`UPDATE vacation_balance_details SET ${fields.join(', ')} WHERE id = ?`, values);
return getBalanceById(id);
}
async function deleteBalance(id) {
const db = getPool();
await db.query('DELETE FROM vacation_balance_details WHERE id = ?', [id]);
}
async function bulkUpsertBalances(balances) {
const db = getPool();
let count = 0;
for (const b of balances) {
await db.query(
`INSERT INTO vacation_balance_details (worker_id, vacation_type_id, year, total_days, used_days, notes, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE total_days = VALUES(total_days), notes = VALUES(notes)`,
[b.worker_id, b.vacation_type_id, b.year, b.total_days ?? 0, b.used_days ?? 0, b.notes || null, b.created_by]
);
count++;
}
return count;
}
/* ===== 연차 자동 계산 (근로기준법) ===== */
function calculateAnnualDays(hireDate, targetYear) {
if (!hireDate) return 0;
const hire = new Date(hireDate);
const yearStart = new Date(targetYear, 0, 1);
const monthsDiff = (yearStart.getFullYear() - hire.getFullYear()) * 12 + (yearStart.getMonth() - hire.getMonth());
if (monthsDiff < 0) return 0;
if (monthsDiff < 12) {
// 1년 미만: 근무 개월 수
return Math.max(0, Math.floor(monthsDiff));
}
// 1년 이상: 15일 + 2년마다 1일 추가 (최대 25일)
const yearsWorked = Math.floor(monthsDiff / 12);
const additional = Math.floor((yearsWorked - 1) / 2);
return Math.min(15 + additional, 25);
}
async function autoCalculateForAllWorkers(year, createdBy) {
const db = getPool();
const [workers] = await db.query(
'SELECT worker_id, worker_name, hire_date FROM workers WHERE status != ? ORDER BY worker_name',
['inactive']
);
// 연차 유형 (ANNUAL_FULL) 찾기
const [types] = await db.query(
"SELECT id FROM vacation_types WHERE type_code = 'ANNUAL_FULL' AND is_active = TRUE LIMIT 1"
);
if (!types.length) return { count: 0, results: [] };
const annualTypeId = types[0].id;
const results = [];
for (const w of workers) {
const days = calculateAnnualDays(w.hire_date, year);
if (days > 0) {
await db.query(
`INSERT INTO vacation_balance_details (worker_id, vacation_type_id, year, total_days, used_days, notes, created_by)
VALUES (?, ?, ?, ?, 0, ?, ?)
ON DUPLICATE KEY UPDATE total_days = VALUES(total_days), notes = VALUES(notes)`,
[w.worker_id, annualTypeId, year, days, `자동계산 (입사: ${w.hire_date ? w.hire_date.toISOString().substring(0,10) : ''})`, createdBy]
);
results.push({ worker_id: w.worker_id, worker_name: w.worker_name, days, hire_date: w.hire_date });
}
}
return { count: results.length, results };
}
module.exports = {
getVacationTypes, getAllVacationTypes, getVacationTypeById,
createVacationType, updateVacationType, deleteVacationType, updatePriorities,
getBalancesByYear, getBalancesByWorkerYear, getBalanceById,
createBalance, updateBalance, deleteBalance, bulkUpsertBalances,
calculateAnnualDays, autoCalculateForAllWorkers
};

View File

@@ -0,0 +1,74 @@
/**
* Worker Model
*
* workers 테이블 CRUD (MariaDB)
*/
const { getPool } = require('./userModel');
async function getAll() {
const db = getPool();
const [rows] = await db.query(
`SELECT w.*, d.department_name
FROM workers w
LEFT JOIN departments d ON w.department_id = d.department_id
ORDER BY w.worker_id DESC`
);
return rows;
}
async function getById(id) {
const db = getPool();
const [rows] = await db.query(
`SELECT w.*, d.department_name
FROM workers w
LEFT JOIN departments d ON w.department_id = d.department_id
WHERE w.worker_id = ?`,
[id]
);
return rows[0] || null;
}
async function create({ worker_name, job_type, department_id, phone_number, hire_date, notes }) {
const db = getPool();
const [result] = await db.query(
`INSERT INTO workers (worker_name, job_type, department_id, phone_number, hire_date, notes)
VALUES (?, ?, ?, ?, ?, ?)`,
[worker_name, job_type || null, department_id || null, phone_number || null, hire_date || null, notes || null]
);
return getById(result.insertId);
}
async function update(id, data) {
const db = getPool();
const fields = [];
const values = [];
if (data.worker_name !== undefined) { fields.push('worker_name = ?'); values.push(data.worker_name); }
if (data.job_type !== undefined) { fields.push('job_type = ?'); values.push(data.job_type); }
if (data.status !== undefined) { fields.push('status = ?'); values.push(data.status); }
if (data.department_id !== undefined) { fields.push('department_id = ?'); values.push(data.department_id || null); }
if (data.employment_status !== undefined) { fields.push('employment_status = ?'); values.push(data.employment_status); }
if (data.phone_number !== undefined) { fields.push('phone_number = ?'); values.push(data.phone_number || null); }
if (data.hire_date !== undefined) { fields.push('hire_date = ?'); values.push(data.hire_date || null); }
if (data.notes !== undefined) { fields.push('notes = ?'); values.push(data.notes || null); }
if (fields.length === 0) return getById(id);
values.push(id);
await db.query(
`UPDATE workers SET ${fields.join(', ')} WHERE worker_id = ?`,
values
);
return getById(id);
}
async function deactivate(id) {
const db = getPool();
await db.query(
'UPDATE workers SET status = ? WHERE worker_id = ?',
['inactive', id]
);
}
module.exports = { getAll, getById, create, update, deactivate };

View File

@@ -0,0 +1,158 @@
/**
* Workplace Model
*
* workplaces + workplace_categories 테이블 CRUD (MariaDB)
*/
const { getPool } = require('./userModel');
async function getAll() {
const db = getPool();
const [rows] = await db.query(
`SELECT w.*, c.category_name
FROM workplaces w
LEFT JOIN workplace_categories c ON w.category_id = c.category_id
ORDER BY w.display_priority ASC, w.workplace_id DESC`
);
return rows;
}
async function getById(id) {
const db = getPool();
const [rows] = await db.query(
`SELECT w.*, c.category_name
FROM workplaces w
LEFT JOIN workplace_categories c ON w.category_id = c.category_id
WHERE w.workplace_id = ?`,
[id]
);
return rows[0] || null;
}
async function getCategories() {
const db = getPool();
const [rows] = await db.query(
'SELECT * FROM workplace_categories WHERE is_active = TRUE ORDER BY display_order ASC'
);
return rows;
}
async function create({ workplace_name, category_id, workplace_purpose, description, display_priority }) {
const db = getPool();
const [result] = await db.query(
`INSERT INTO workplaces (workplace_name, category_id, workplace_purpose, description, display_priority)
VALUES (?, ?, ?, ?, ?)`,
[workplace_name, category_id || null, workplace_purpose || null, description || null, display_priority || 0]
);
return getById(result.insertId);
}
async function update(id, data) {
const db = getPool();
const fields = [];
const values = [];
if (data.workplace_name !== undefined) { fields.push('workplace_name = ?'); values.push(data.workplace_name); }
if (data.category_id !== undefined) { fields.push('category_id = ?'); values.push(data.category_id || null); }
if (data.workplace_purpose !== undefined) { fields.push('workplace_purpose = ?'); values.push(data.workplace_purpose); }
if (data.description !== undefined) { fields.push('description = ?'); values.push(data.description || null); }
if (data.is_active !== undefined) { fields.push('is_active = ?'); values.push(data.is_active); }
if (data.display_priority !== undefined) { fields.push('display_priority = ?'); values.push(data.display_priority); }
if (fields.length === 0) return getById(id);
values.push(id);
await db.query(
`UPDATE workplaces SET ${fields.join(', ')} WHERE workplace_id = ?`,
values
);
return getById(id);
}
async function deactivate(id) {
const db = getPool();
await db.query(
'UPDATE workplaces SET is_active = FALSE WHERE workplace_id = ?',
[id]
);
}
// ==================== 구역지도 ====================
async function updateCategoryLayoutImage(id, imagePath) {
const db = getPool();
await db.query(
'UPDATE workplace_categories SET layout_image = ? WHERE category_id = ?',
[imagePath, id]
);
const [rows] = await db.query('SELECT * FROM workplace_categories WHERE category_id = ?', [id]);
return rows[0] || null;
}
async function createMapRegion({ workplace_id, category_id, x_start, y_start, x_end, y_end, shape, polygon_points }) {
const db = getPool();
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 || 'rect', polygon_points || null]
);
const [rows] = await db.query('SELECT * FROM workplace_map_regions WHERE region_id = ?', [result.insertId]);
return rows[0];
}
async function getMapRegionsByCategory(categoryId) {
const db = getPool();
const [rows] = await db.query(
`SELECT r.*, w.workplace_name
FROM workplace_map_regions r
LEFT JOIN workplaces w ON r.workplace_id = w.workplace_id
WHERE r.category_id = ?
ORDER BY r.region_id ASC`,
[categoryId]
);
return rows;
}
async function updateMapRegion(regionId, { x_start, y_start, x_end, y_end, workplace_id, shape, polygon_points }) {
const db = getPool();
const fields = [];
const values = [];
if (x_start !== undefined) { fields.push('x_start = ?'); values.push(x_start); }
if (y_start !== undefined) { fields.push('y_start = ?'); values.push(y_start); }
if (x_end !== undefined) { fields.push('x_end = ?'); values.push(x_end); }
if (y_end !== undefined) { fields.push('y_end = ?'); values.push(y_end); }
if (workplace_id !== undefined) { fields.push('workplace_id = ?'); values.push(workplace_id); }
if (shape !== undefined) { fields.push('shape = ?'); values.push(shape); }
if (polygon_points !== undefined) { fields.push('polygon_points = ?'); values.push(polygon_points); }
if (fields.length === 0) return null;
values.push(regionId);
await db.query(`UPDATE workplace_map_regions SET ${fields.join(', ')} WHERE region_id = ?`, values);
const [rows] = await db.query(
`SELECT r.*, w.workplace_name
FROM workplace_map_regions r
LEFT JOIN workplaces w ON r.workplace_id = w.workplace_id
WHERE r.region_id = ?`,
[regionId]
);
return rows[0] || null;
}
async function deleteMapRegion(regionId) {
const db = getPool();
await db.query('DELETE FROM workplace_map_regions WHERE region_id = ?', [regionId]);
}
async function updateWorkplaceLayoutImage(id, imagePath) {
const db = getPool();
await db.query('UPDATE workplaces SET layout_image = ? WHERE workplace_id = ?', [imagePath, id]);
return getById(id);
}
module.exports = {
getAll, getById, getCategories, create, update, deactivate,
updateCategoryLayoutImage, createMapRegion, getMapRegionsByCategory, updateMapRegion, deleteMapRegion,
updateWorkplaceLayoutImage
};

View File

@@ -0,0 +1,18 @@
{
"name": "tkuser-api",
"version": "1.0.0",
"description": "TK Factory Services - 사용자 관리 서비스",
"main": "index.js",
"scripts": {
"start": "node index.js",
"dev": "node --watch index.js"
},
"dependencies": {
"bcrypt": "^6.0.0",
"cors": "^2.8.5",
"express": "^4.18.2",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"mysql2": "^3.14.1"
}
}

View File

@@ -0,0 +1,16 @@
/**
* Department Routes
*/
const express = require('express');
const router = express.Router();
const departmentController = require('../controllers/departmentController');
const { requireAuth, requireAdmin } = require('../middleware/auth');
router.get('/', requireAuth, departmentController.getAll);
router.get('/:id', requireAuth, departmentController.getById);
router.post('/', requireAdmin, departmentController.create);
router.put('/:id', requireAdmin, departmentController.update);
router.delete('/:id', requireAdmin, departmentController.remove);
module.exports = router;

View File

@@ -0,0 +1,32 @@
/**
* Equipment Routes
*/
const express = require('express');
const router = express.Router();
const equipmentController = require('../controllers/equipmentController');
const { requireAuth, requireAdmin } = require('../middleware/auth');
const upload = require('../middleware/upload');
// 고정 경로를 /:id 보다 먼저 등록
router.get('/types', requireAuth, equipmentController.getTypes);
router.get('/next-code', requireAuth, equipmentController.getNextCode);
router.get('/workplace/:workplaceId', requireAuth, equipmentController.getByWorkplace);
// 사진 삭제 (photo_id만으로)
router.delete('/photos/:photoId', requireAdmin, equipmentController.deletePhoto);
// 기본 CRUD
router.get('/', requireAuth, equipmentController.getAll);
router.get('/:id', requireAuth, equipmentController.getById);
router.post('/', requireAdmin, equipmentController.create);
router.put('/:id', requireAdmin, equipmentController.update);
router.delete('/:id', requireAdmin, equipmentController.remove);
// 지도 위치
router.patch('/:id/map-position', requireAdmin, equipmentController.updateMapPosition);
// 사진
router.post('/:id/photos', requireAdmin, upload.single('photo'), equipmentController.addPhoto);
router.get('/:id/photos', requireAuth, equipmentController.getPhotos);
module.exports = router;

View File

@@ -0,0 +1,23 @@
/**
* Permission Routes
*/
const express = require('express');
const router = express.Router();
const permissionController = require('../controllers/permissionController');
const { requireAuth, requireAdmin } = require('../middleware/auth');
// 권한 부여 (admin)
router.post('/grant', requireAdmin, permissionController.grantPermission);
router.post('/bulk-grant', requireAdmin, permissionController.bulkGrant);
// 접근 권한 확인 (auth)
router.get('/check/:uid/:page', requireAuth, permissionController.checkAccess);
// 설정 가능 페이지 목록 (auth)
router.get('/available-pages', requireAuth, permissionController.getAvailablePages);
// 권한 삭제 (admin)
router.delete('/:id', requireAdmin, permissionController.deletePermission);
module.exports = router;

View File

@@ -0,0 +1,17 @@
/**
* Project Routes
*/
const express = require('express');
const router = express.Router();
const projectController = require('../controllers/projectController');
const { requireAuth, requireAdmin } = require('../middleware/auth');
router.get('/', requireAuth, projectController.getAll);
router.get('/active', requireAuth, projectController.getActive);
router.get('/:id', requireAuth, projectController.getById);
router.post('/', requireAdmin, projectController.create);
router.put('/:id', requireAdmin, projectController.update);
router.delete('/:id', requireAdmin, projectController.remove);
module.exports = router;

View File

@@ -0,0 +1,26 @@
/**
* Task Routes
*
* 공정(work-types) + 작업(tasks) 라우팅
*/
const express = require('express');
const router = express.Router();
const taskController = require('../controllers/taskController');
const { requireAuth, requireAdmin } = require('../middleware/auth');
// Work Types (공정)
router.get('/work-types', requireAuth, taskController.getWorkTypes);
router.post('/work-types', requireAdmin, taskController.createWorkType);
router.put('/work-types/:id', requireAdmin, taskController.updateWorkType);
router.delete('/work-types/:id', requireAdmin, taskController.deleteWorkType);
// Tasks (작업)
router.get('/', requireAuth, taskController.getTasks);
router.get('/active', requireAuth, taskController.getActiveTasks);
router.get('/:id', requireAuth, taskController.getTaskById);
router.post('/', requireAdmin, taskController.createTask);
router.put('/:id', requireAdmin, taskController.updateTask);
router.delete('/:id', requireAdmin, taskController.deleteTask);
module.exports = router;

View File

@@ -0,0 +1,24 @@
/**
* User Routes
*/
const express = require('express');
const router = express.Router();
const userController = require('../controllers/userController');
const permissionController = require('../controllers/permissionController');
const { requireAuth, requireAdmin } = require('../middleware/auth');
// 사용자 CRUD (admin)
router.get('/', requireAdmin, userController.getUsers);
router.post('/', requireAdmin, userController.createUser);
router.put('/:id', requireAdmin, userController.updateUser);
router.delete('/:id', requireAdmin, userController.deleteUser);
// 비밀번호 관리
router.post('/:id/reset-password', requireAdmin, userController.resetPassword);
router.post('/change-password', requireAuth, userController.changePassword);
// 사용자별 페이지 권한 조회 (auth - /api/users/:id/page-permissions)
router.get('/:id/page-permissions', requireAuth, permissionController.getUserPermissions);
module.exports = router;

View File

@@ -0,0 +1,28 @@
/**
* Vacation Routes
*
* 휴가 유형 + 연차 배정 라우팅
*/
const express = require('express');
const router = express.Router();
const vc = require('../controllers/vacationController');
const { requireAuth, requireAdmin } = require('../middleware/auth');
// Vacation Types (휴가 유형)
router.get('/types', requireAuth, vc.getVacationTypes);
router.post('/types', requireAdmin, vc.createVacationType);
router.put('/types/priorities', requireAdmin, vc.updatePriorities);
router.put('/types/:id', requireAdmin, vc.updateVacationType);
router.delete('/types/:id', requireAdmin, vc.deleteVacationType);
// Vacation Balances (연차 배정)
router.get('/balances/year/:year', requireAdmin, vc.getBalancesByYear);
router.get('/balances/worker/:workerId/year/:year', requireAuth, vc.getBalancesByWorkerYear);
router.post('/balances', requireAdmin, vc.createBalance);
router.post('/balances/bulk-upsert', requireAdmin, vc.bulkUpsertBalances);
router.post('/balances/auto-calculate', requireAdmin, vc.autoCalculate);
router.put('/balances/:id', requireAdmin, vc.updateBalance);
router.delete('/balances/:id', requireAdmin, vc.deleteBalance);
module.exports = router;

View File

@@ -0,0 +1,16 @@
/**
* Worker Routes
*/
const express = require('express');
const router = express.Router();
const workerController = require('../controllers/workerController');
const { requireAuth, requireAdmin } = require('../middleware/auth');
router.get('/', requireAuth, workerController.getAll);
router.get('/:id', requireAuth, workerController.getById);
router.post('/', requireAdmin, workerController.create);
router.put('/:id', requireAdmin, workerController.update);
router.delete('/:id', requireAdmin, workerController.remove);
module.exports = router;

View File

@@ -0,0 +1,28 @@
/**
* Workplace Routes
*/
const express = require('express');
const router = express.Router();
const workplaceController = require('../controllers/workplaceController');
const { requireAuth, requireAdmin } = require('../middleware/auth');
const upload = require('../middleware/upload');
router.get('/categories', requireAuth, workplaceController.getCategories);
// 구역지도 (/:id 보다 먼저 등록)
router.post('/categories/:id/layout-image', requireAdmin, upload.single('image'), workplaceController.uploadCategoryLayoutImage);
router.get('/categories/:categoryId/map-regions', requireAuth, workplaceController.getMapRegionsByCategory);
router.post('/map-regions', requireAdmin, workplaceController.createMapRegion);
router.put('/map-regions/:id', requireAdmin, workplaceController.updateMapRegion);
router.delete('/map-regions/:id', requireAdmin, workplaceController.deleteMapRegion);
router.post('/:id/layout-image', requireAdmin, upload.single('image'), workplaceController.uploadWorkplaceLayoutImage);
router.get('/', requireAuth, workplaceController.getAll);
router.get('/:id', requireAuth, workplaceController.getById);
router.post('/', requireAdmin, workplaceController.create);
router.put('/:id', requireAdmin, workplaceController.update);
router.delete('/:id', requireAdmin, workplaceController.remove);
module.exports = router;