+ + 작업 관리 +
+공정별 세부 작업을 등록하고 관리합니다
+diff --git a/api.hyungi.net/config/routes.js b/api.hyungi.net/config/routes.js index 0ffe581..890953b 100644 --- a/api.hyungi.net/config/routes.js +++ b/api.hyungi.net/config/routes.js @@ -41,6 +41,7 @@ function setupRoutes(app) { const monthlyStatusRoutes = require('../routes/monthlyStatusRoutes'); const pageAccessRoutes = require('../routes/pageAccessRoutes'); const workplaceRoutes = require('../routes/workplaceRoutes'); + const taskRoutes = require('../routes/taskRoutes'); // const tbmRoutes = require('../routes/tbmRoutes'); // 임시 비활성화 - db/connection 문제 // Rate Limiters 설정 @@ -129,6 +130,7 @@ function setupRoutes(app) { app.use('/api/tools', toolsRoute); app.use('/api/users', userRoutes); app.use('/api/workplaces', workplaceRoutes); + app.use('/api/tasks', taskRoutes); app.use('/api', pageAccessRoutes); // 페이지 접근 권한 관리 // app.use('/api/tbm', tbmRoutes); // TBM 시스템 - 임시 비활성화 app.use('/api', uploadBgRoutes); diff --git a/api.hyungi.net/controllers/taskController.js b/api.hyungi.net/controllers/taskController.js new file mode 100644 index 0000000..52a9933 --- /dev/null +++ b/api.hyungi.net/controllers/taskController.js @@ -0,0 +1,179 @@ +/** + * 작업 관리 컨트롤러 + * + * 작업 CRUD API 엔드포인트 핸들러 + * (공정=work_types에 속하는 세부 작업) + * + * @author TK-FB-Project + * @since 2026-01-26 + */ + +const taskModel = require('../models/taskModel'); +const { ValidationError, NotFoundError, DatabaseError } = require('../utils/errors'); +const { asyncHandler } = require('../middlewares/errorHandler'); +const logger = require('../utils/logger'); + +// ==================== 작업 CRUD ==================== + +/** + * 작업 생성 + */ +exports.createTask = asyncHandler(async (req, res) => { + const taskData = req.body; + + if (!taskData.task_name) { + throw new ValidationError('작업명은 필수 입력 항목입니다'); + } + + logger.info('작업 생성 요청', { name: taskData.task_name }); + + const id = await new Promise((resolve, reject) => { + taskModel.createTask(taskData, (err, lastID) => { + if (err) reject(new DatabaseError('작업 생성 중 오류가 발생했습니다')); + else resolve(lastID); + }); + }); + + logger.info('작업 생성 성공', { task_id: id }); + + res.status(201).json({ + success: true, + data: { task_id: id }, + message: '작업이 성공적으로 생성되었습니다' + }); +}); + +/** + * 전체 작업 조회 + */ +exports.getAllTasks = asyncHandler(async (req, res) => { + const rows = await new Promise((resolve, reject) => { + taskModel.getAllTasks((err, data) => { + if (err) reject(new DatabaseError('작업 목록 조회 중 오류가 발생했습니다')); + else resolve(data); + }); + }); + + res.json({ + success: true, + data: rows, + message: '작업 목록 조회 성공' + }); +}); + +/** + * 활성 작업만 조회 + */ +exports.getActiveTasks = asyncHandler(async (req, res) => { + const rows = await new Promise((resolve, reject) => { + taskModel.getActiveTasks((err, data) => { + if (err) reject(new DatabaseError('활성 작업 목록 조회 중 오류가 발생했습니다')); + else resolve(data); + }); + }); + + res.json({ + success: true, + data: rows, + message: '활성 작업 목록 조회 성공' + }); +}); + +/** + * 공정별 작업 조회 + */ +exports.getTasksByWorkType = asyncHandler(async (req, res) => { + const workTypeId = req.params.work_type_id || req.query.work_type_id; + + if (!workTypeId) { + throw new ValidationError('공정 ID가 필요합니다'); + } + + const rows = await new Promise((resolve, reject) => { + taskModel.getTasksByWorkType(workTypeId, (err, data) => { + if (err) reject(new DatabaseError('공정별 작업 목록 조회 중 오류가 발생했습니다')); + else resolve(data); + }); + }); + + res.json({ + success: true, + data: rows, + message: '공정별 작업 목록 조회 성공' + }); +}); + +/** + * 단일 작업 조회 + */ +exports.getTaskById = asyncHandler(async (req, res) => { + const taskId = req.params.id; + + const task = await new Promise((resolve, reject) => { + taskModel.getTaskById(taskId, (err, data) => { + if (err) reject(new DatabaseError('작업 조회 중 오류가 발생했습니다')); + else resolve(data); + }); + }); + + if (!task) { + throw new NotFoundError('작업을 찾을 수 없습니다'); + } + + res.json({ + success: true, + data: task, + message: '작업 조회 성공' + }); +}); + +/** + * 작업 수정 + */ +exports.updateTask = asyncHandler(async (req, res) => { + const taskId = req.params.id; + const taskData = req.body; + + if (!taskData.task_name) { + throw new ValidationError('작업명은 필수 입력 항목입니다'); + } + + logger.info('작업 수정 요청', { task_id: taskId }); + + await new Promise((resolve, reject) => { + taskModel.updateTask(taskId, taskData, (err, result) => { + if (err) reject(new DatabaseError('작업 수정 중 오류가 발생했습니다')); + else resolve(result); + }); + }); + + logger.info('작업 수정 성공', { task_id: taskId }); + + res.json({ + success: true, + message: '작업이 성공적으로 수정되었습니다' + }); +}); + +/** + * 작업 삭제 + */ +exports.deleteTask = asyncHandler(async (req, res) => { + const taskId = req.params.id; + + logger.info('작업 삭제 요청', { task_id: taskId }); + + await new Promise((resolve, reject) => { + taskModel.deleteTask(taskId, (err, result) => { + if (err) reject(new DatabaseError('작업 삭제 중 오류가 발생했습니다')); + else resolve(result); + }); + }); + + logger.info('작업 삭제 성공', { task_id: taskId }); + + res.json({ + success: true, + message: '작업이 성공적으로 삭제되었습니다' + }); +}); diff --git a/api.hyungi.net/db/migrations/20260126010002_create_tasks.js b/api.hyungi.net/db/migrations/20260126010002_create_tasks.js new file mode 100644 index 0000000..aeb4bfc --- /dev/null +++ b/api.hyungi.net/db/migrations/20260126010002_create_tasks.js @@ -0,0 +1,36 @@ +/** + * 작업 테이블 생성 (공정=work_types에 속함) + * + * @param {import('knex').Knex} knex + */ +exports.up = function(knex) { + return knex.schema.createTable('tasks', function(table) { + table.increments('task_id').primary().comment('작업 ID'); + table.integer('work_type_id').nullable().comment('공정 ID (work_types 참조)'); + table.string('task_name', 255).notNullable().comment('작업명'); + table.text('description').nullable().comment('작업 설명'); + table.boolean('is_active').defaultTo(true).comment('활성화 여부'); + table.timestamp('created_at').defaultTo(knex.fn.now()).comment('생성일시'); + table.timestamp('updated_at').defaultTo(knex.fn.now()).comment('수정일시'); + + // 외래키 (work_types 테이블 참조) + table.foreign('work_type_id') + .references('id') + .inTable('work_types') + .onDelete('SET NULL') + .onUpdate('CASCADE'); + + // 인덱스 + table.index('work_type_id'); + table.index('is_active'); + }).then(() => { + console.log('✅ tasks 테이블 생성 완료'); + }); +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = function(knex) { + return knex.schema.dropTableIfExists('tasks'); +}; diff --git a/api.hyungi.net/db/migrations/20260126010003_add_work_type_task_to_tbm.js b/api.hyungi.net/db/migrations/20260126010003_add_work_type_task_to_tbm.js new file mode 100644 index 0000000..45d11bd --- /dev/null +++ b/api.hyungi.net/db/migrations/20260126010003_add_work_type_task_to_tbm.js @@ -0,0 +1,42 @@ +/** + * TBM 세션에 공정/작업 컬럼 추가 + * + * @param {import('knex').Knex} knex + */ +exports.up = function(knex) { + return knex.schema.table('tbm_sessions', function(table) { + table.integer('work_type_id').nullable().comment('공정 ID (work_types 참조)'); + table.integer('task_id').unsigned().nullable().comment('작업 ID (tasks 참조)'); + + // 외래키 추가 + table.foreign('work_type_id') + .references('id') + .inTable('work_types') + .onDelete('SET NULL') + .onUpdate('CASCADE'); + + table.foreign('task_id') + .references('task_id') + .inTable('tasks') + .onDelete('SET NULL') + .onUpdate('CASCADE'); + + // 인덱스 추가 + table.index('work_type_id'); + table.index('task_id'); + }).then(() => { + console.log('✅ tbm_sessions 테이블에 work_type_id, task_id 컬럼 추가 완료'); + }); +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = function(knex) { + return knex.schema.table('tbm_sessions', function(table) { + table.dropForeign('work_type_id'); + table.dropForeign('task_id'); + table.dropColumn('work_type_id'); + table.dropColumn('task_id'); + }); +}; diff --git a/api.hyungi.net/models/taskModel.js b/api.hyungi.net/models/taskModel.js new file mode 100644 index 0000000..46e9f01 --- /dev/null +++ b/api.hyungi.net/models/taskModel.js @@ -0,0 +1,170 @@ +/** + * 작업 모델 + * + * @author TK-FB-Project + * @since 2026-01-26 + */ + +const { getDb } = require('../db/connection'); + +// ==================== 작업 CRUD ==================== + +/** + * 작업 생성 + */ +const createTask = async (taskData, callback) => { + try { + const db = await getDb(); + const { work_type_id, task_name, description } = taskData; + + const [result] = await db.query( + `INSERT INTO tasks (work_type_id, task_name, description, is_active) + VALUES (?, ?, ?, 1)`, + [work_type_id || null, task_name, description || null] + ); + + callback(null, result.insertId); + } catch (err) { + callback(err); + } +}; + +/** + * 전체 작업 목록 조회 (공정 정보 포함) + */ +const getAllTasks = async (callback) => { + try { + const db = await getDb(); + const [rows] = await db.query( + `SELECT t.task_id, t.work_type_id, t.task_name, t.description, t.is_active, + t.created_at, t.updated_at, + wt.name as work_type_name, wt.category + FROM tasks t + LEFT JOIN work_types wt ON t.work_type_id = wt.id + ORDER BY wt.category ASC, t.task_id DESC` + ); + callback(null, rows); + } catch (err) { + callback(err); + } +}; + +/** + * 활성 작업만 조회 + */ +const getActiveTasks = async (callback) => { + try { + const db = await getDb(); + const [rows] = await db.query( + `SELECT t.task_id, t.work_type_id, t.task_name, t.description, + wt.name as work_type_name, wt.category + FROM tasks t + LEFT JOIN work_types wt ON t.work_type_id = wt.id + WHERE t.is_active = 1 + ORDER BY wt.category ASC, t.task_name ASC` + ); + callback(null, rows); + } catch (err) { + callback(err); + } +}; + +/** + * 공정별 작업 목록 조회 + */ +const getTasksByWorkType = async (workTypeId, callback) => { + try { + const db = await getDb(); + const [rows] = await db.query( + `SELECT t.task_id, t.work_type_id, t.task_name, t.description, t.is_active, + t.created_at, t.updated_at, + wt.name as work_type_name, wt.category + FROM tasks t + LEFT JOIN work_types wt ON t.work_type_id = wt.id + WHERE t.work_type_id = ? + ORDER BY t.task_id DESC`, + [workTypeId] + ); + callback(null, rows); + } catch (err) { + callback(err); + } +}; + +/** + * 단일 작업 조회 + */ +const getTaskById = async (taskId, callback) => { + try { + const db = await getDb(); + const [rows] = await db.query( + `SELECT t.task_id, t.work_type_id, t.task_name, t.description, t.is_active, + t.created_at, t.updated_at, + wt.name as work_type_name, wt.category + FROM tasks t + LEFT JOIN work_types wt ON t.work_type_id = wt.id + WHERE t.task_id = ?`, + [taskId] + ); + callback(null, rows[0] || null); + } catch (err) { + callback(err); + } +}; + +/** + * 작업 수정 + */ +const updateTask = async (taskId, taskData, callback) => { + try { + const db = await getDb(); + const { work_type_id, task_name, description, is_active } = taskData; + + const [result] = await db.query( + `UPDATE tasks + SET work_type_id = ?, + task_name = ?, + description = ?, + is_active = ?, + updated_at = NOW() + WHERE task_id = ?`, + [ + work_type_id || null, + task_name, + description || null, + is_active !== undefined ? is_active : 1, + taskId + ] + ); + + callback(null, result); + } catch (err) { + callback(err); + } +}; + +/** + * 작업 삭제 + */ +const deleteTask = async (taskId, callback) => { + try { + const db = await getDb(); + const [result] = await db.query( + `DELETE FROM tasks WHERE task_id = ?`, + [taskId] + ); + callback(null, result); + } catch (err) { + callback(err); + } +}; + +module.exports = { + createTask, + getAllTasks, + getActiveTasks, + getTasksByWorkType, + getTaskById, + updateTask, + deleteTask +}; diff --git a/api.hyungi.net/models/tbmModel.js b/api.hyungi.net/models/tbmModel.js index 263f137..81eb201 100644 --- a/api.hyungi.net/models/tbmModel.js +++ b/api.hyungi.net/models/tbmModel.js @@ -10,15 +10,17 @@ const TbmModel = { createSession: (sessionData, callback) => { const sql = ` INSERT INTO tbm_sessions - (session_date, leader_id, project_id, work_location, work_description, - safety_notes, start_time, created_by) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + (session_date, leader_id, project_id, work_type_id, task_id, work_location, + work_description, safety_notes, start_time, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `; const values = [ sessionData.session_date, sessionData.leader_id, sessionData.project_id, + sessionData.work_type_id, + sessionData.task_id, sessionData.work_location, sessionData.work_description, sessionData.safety_notes, @@ -40,11 +42,16 @@ const TbmModel = { w.job_type as leader_job_type, p.project_name, p.job_no, + wt.name as work_type_name, + wt.category as work_type_category, + t.task_name, u.username as created_by_username, COUNT(DISTINCT ta.worker_id) as team_member_count FROM tbm_sessions s LEFT JOIN workers w ON s.leader_id = w.worker_id LEFT JOIN projects p ON s.project_id = p.project_id + LEFT JOIN work_types wt ON s.work_type_id = wt.id + LEFT JOIN tasks t ON s.task_id = t.task_id LEFT JOIN users u ON s.created_by = u.user_id LEFT JOIN tbm_team_assignments ta ON s.session_id = ta.session_id WHERE s.session_date = ? @@ -68,11 +75,17 @@ const TbmModel = { p.project_name, p.job_no, p.site, + wt.name as work_type_name, + wt.category as work_type_category, + t.task_name, + t.description as task_description, u.username as created_by_username, u.name as created_by_name FROM tbm_sessions s LEFT JOIN workers w ON s.leader_id = w.worker_id LEFT JOIN projects p ON s.project_id = p.project_id + LEFT JOIN work_types wt ON s.work_type_id = wt.id + LEFT JOIN tasks t ON s.task_id = t.task_id LEFT JOIN users u ON s.created_by = u.user_id WHERE s.session_id = ? `; diff --git a/api.hyungi.net/routes/taskRoutes.js b/api.hyungi.net/routes/taskRoutes.js new file mode 100644 index 0000000..ba1d242 --- /dev/null +++ b/api.hyungi.net/routes/taskRoutes.js @@ -0,0 +1,27 @@ +// routes/taskRoutes.js +const express = require('express'); +const router = express.Router(); +const taskController = require('../controllers/taskController'); + +// CREATE 작업 +router.post('/', taskController.createTask); + +// READ ALL 작업 +router.get('/', taskController.getAllTasks); + +// READ ACTIVE 작업 +router.get('/active/list', taskController.getActiveTasks); + +// READ BY WORK TYPE (공정별) +router.get('/by-work-type/:work_type_id', taskController.getTasksByWorkType); + +// READ ONE 작업 +router.get('/:id', taskController.getTaskById); + +// UPDATE 작업 +router.put('/:id', taskController.updateTask); + +// DELETE 작업 +router.delete('/:id', taskController.deleteTask); + +module.exports = router; diff --git a/web-ui/js/task-management.js b/web-ui/js/task-management.js new file mode 100644 index 0000000..31abdd5 --- /dev/null +++ b/web-ui/js/task-management.js @@ -0,0 +1,378 @@ +// task-management.js - 작업 관리 페이지 JavaScript + +// 전역 변수 +let workTypes = []; // 공정 목록 +let tasks = []; // 작업 목록 +let currentWorkTypeId = ''; // 현재 선택된 공정 ID +let currentEditingTask = null; + +// 페이지 초기화 +document.addEventListener('DOMContentLoaded', async () => { + console.log('📋 작업 관리 페이지 초기화'); + + // API 함수가 로드될 때까지 대기 + let retryCount = 0; + while (!window.apiCall && retryCount < 50) { + await new Promise(resolve => setTimeout(resolve, 100)); + retryCount++; + } + + if (!window.apiCall) { + showToast('시스템을 초기화할 수 없습니다. 페이지를 새로고침해주세요.', 'error'); + return; + } + + await loadAllData(); +}); + +// 전체 데이터 로드 +async function loadAllData() { + try { + // 공정 목록 로드 (work_types 조회 - 코드 관리 API 사용) + await loadWorkTypes(); + // 작업 목록 로드 + await loadTasks(); + } catch (error) { + console.error('❌ 데이터 로드 오류:', error); + showToast('데이터를 불러오는 중 오류가 발생했습니다.', 'error'); + } +} +window.loadAllData = loadAllData; + +// 공정 목록 로드 +async function loadWorkTypes() { + try { + // 작업 유형(공정) 목록 조회 - codes API 사용 또는 직접 DB 조회 + // 간단하게 하드코딩으로 시작 (나중에 API로 변경 가능) + const response = await window.apiCall('/tools/work-types'); + + if (response && response.success) { + workTypes = response.data || []; + } else { + workTypes = []; + } + + console.log('✅ 공정 목록 로드:', workTypes.length + '개'); + renderWorkTypeTabs(); + populateWorkTypeSelect(); + } catch (error) { + console.error('❌ 공정 목록 조회 오류:', error); + // API 오류 시에도 빈 배열로 처리 + workTypes = []; + renderWorkTypeTabs(); + } +} + +// 작업 목록 로드 +async function loadTasks() { + try { + const response = await window.apiCall('/tasks'); + + if (response && response.success) { + tasks = response.data || []; + console.log('✅ 작업 목록 로드:', tasks.length + '개'); + } else { + tasks = []; + } + + renderTasks(); + updateStatistics(); + } catch (error) { + console.error('❌ 작업 목록 조회 오류:', error); + showToast('작업 목록을 불러오는 중 오류가 발생했습니다.', 'error'); + tasks = []; + renderTasks(); + } +} + +// 공정 탭 렌더링 +function renderWorkTypeTabs() { + const tabsContainer = document.getElementById('workTypeTabs'); + + let tabsHtml = ` + + `; + + workTypes.forEach(workType => { + const count = tasks.filter(t => t.work_type_id === workType.id).length; + const isActive = currentWorkTypeId === workType.id; + + tabsHtml += ` + + `; + }); + + tabsContainer.innerHTML = tabsHtml; +} + +// 공정 전환 +function switchWorkType(workTypeId) { + currentWorkTypeId = workTypeId === '' ? '' : parseInt(workTypeId); + renderWorkTypeTabs(); + renderTasks(); + updateStatistics(); +} +window.switchWorkType = switchWorkType; + +// 작업 목록 렌더링 +function renderTasks() { + const grid = document.getElementById('taskGrid'); + + // 현재 선택된 공정으로 필터링 + let filteredTasks = tasks; + if (currentWorkTypeId !== '') { + filteredTasks = tasks.filter(t => t.work_type_id === currentWorkTypeId); + } + + if (filteredTasks.length === 0) { + grid.innerHTML = ` +
"작업 추가" 버튼을 눌러 새로운 작업을 등록하세요
+공정별 세부 작업을 등록하고 관리합니다
+