diff --git a/system1-factory/api/config/routes.js b/system1-factory/api/config/routes.js index 59348fc..0f588e8 100644 --- a/system1-factory/api/config/routes.js +++ b/system1-factory/api/config/routes.js @@ -55,6 +55,8 @@ function setupRoutes(app) { const purchaseRequestRoutes = require('../routes/purchaseRequestRoutes'); const purchaseRoutes = require('../routes/purchaseRoutes'); const settlementRoutes = require('../routes/settlementRoutes'); + const scheduleRoutes = require('../routes/scheduleRoutes'); + const meetingRoutes = require('../routes/meetingRoutes'); // Rate Limiters 설정 const rateLimit = require('express-rate-limit'); @@ -167,6 +169,8 @@ function setupRoutes(app) { app.use('/api/purchase-requests', purchaseRequestRoutes); // 구매신청 app.use('/api/purchases', purchaseRoutes); // 구매 내역 app.use('/api/settlements', settlementRoutes); // 월간 정산 + app.use('/api/schedule', scheduleRoutes); // 공정표 + app.use('/api/meetings', meetingRoutes); // 생산회의록 app.use('/api', uploadBgRoutes); // Swagger API 문서 diff --git a/system1-factory/api/controllers/meetingController.js b/system1-factory/api/controllers/meetingController.js new file mode 100644 index 0000000..c566290 --- /dev/null +++ b/system1-factory/api/controllers/meetingController.js @@ -0,0 +1,182 @@ +const MeetingModel = require('../models/meetingModel'); +const logger = require('../utils/logger'); + +const MeetingController = { + // 회의록 목록 + getAll: async (req, res) => { + try { + const { year, month, search } = req.query; + const rows = await MeetingModel.getAll({ year, month, search }); + res.json({ success: true, data: rows }); + } catch (err) { + logger.error('Meeting getAll error:', err); + res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' }); + } + }, + + // 회의록 상세 + getById: async (req, res) => { + try { + const meeting = await MeetingModel.getById(req.params.id); + if (!meeting) return res.status(404).json({ success: false, message: '회의록을 찾을 수 없습니다.' }); + res.json({ success: true, data: meeting }); + } catch (err) { + logger.error('Meeting getById error:', err); + res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' }); + } + }, + + // 회의록 생성 + create: async (req, res) => { + try { + const { meeting_date, title } = req.body; + if (!meeting_date || !title) { + return res.status(400).json({ success: false, message: '날짜와 제목은 필수입니다.' }); + } + const id = await MeetingModel.create({ + ...req.body, + created_by: req.user.user_id || req.user.id + }); + res.status(201).json({ success: true, data: { meeting_id: id }, message: '회의록이 생성되었습니다.' }); + } catch (err) { + logger.error('Meeting create error:', err); + res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' }); + } + }, + + // 회의록 수정 + update: async (req, res) => { + try { + const meetingId = req.params.id; + const status = await MeetingModel.getStatus(meetingId); + if (!status) return res.status(404).json({ success: false, message: '회의록을 찾을 수 없습니다.' }); + + // published 상태면 admin만 수정 가능 + const userLevel = req.user.access_level || req.user.role; + if (status === 'published' && !['admin', 'system'].includes(userLevel)) { + return res.status(403).json({ success: false, message: '발행된 회의록은 관리자만 수정할 수 있습니다.' }); + } + + await MeetingModel.update(meetingId, req.body); + res.json({ success: true, message: '회의록이 수정되었습니다.' }); + } catch (err) { + logger.error('Meeting update error:', err); + res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' }); + } + }, + + // 회의록 발행 + publish: async (req, res) => { + try { + await MeetingModel.publish(req.params.id); + res.json({ success: true, message: '회의록이 발행되었습니다.' }); + } catch (err) { + logger.error('Meeting publish error:', err); + res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' }); + } + }, + + // 발행 취소 (admin only) + unpublish: async (req, res) => { + try { + await MeetingModel.unpublish(req.params.id); + res.json({ success: true, message: '발행이 취소되었습니다.' }); + } catch (err) { + logger.error('Meeting unpublish error:', err); + res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' }); + } + }, + + // 회의록 삭제 + delete: async (req, res) => { + try { + await MeetingModel.delete(req.params.id); + res.json({ success: true, message: '회의록이 삭제되었습니다.' }); + } catch (err) { + logger.error('Meeting delete error:', err); + res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' }); + } + }, + + // === 안건 === + addItem: async (req, res) => { + try { + const { content } = req.body; + if (!content) return res.status(400).json({ success: false, message: '안건 내용을 입력해주세요.' }); + + // published 체크 + const status = await MeetingModel.getStatus(req.params.id); + const userLevel = req.user.access_level || req.user.role; + if (status === 'published' && !['admin', 'system'].includes(userLevel)) { + return res.status(403).json({ success: false, message: '발행된 회의록은 관리자만 수정할 수 있습니다.' }); + } + + const id = await MeetingModel.addItem(req.params.id, req.body); + res.status(201).json({ success: true, data: { item_id: id }, message: '안건이 추가되었습니다.' }); + } catch (err) { + logger.error('Meeting addItem error:', err); + res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' }); + } + }, + + updateItem: async (req, res) => { + try { + // published 체크 + const status = await MeetingModel.getStatus(req.params.id); + const userLevel = req.user.access_level || req.user.role; + if (status === 'published' && !['admin', 'system'].includes(userLevel)) { + return res.status(403).json({ success: false, message: '발행된 회의록은 관리자만 수정할 수 있습니다.' }); + } + + await MeetingModel.updateItem(req.params.itemId, req.body); + res.json({ success: true, message: '안건이 수정되었습니다.' }); + } catch (err) { + logger.error('Meeting updateItem error:', err); + res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' }); + } + }, + + deleteItem: async (req, res) => { + try { + // published 체크 + const status = await MeetingModel.getStatus(req.params.id); + const userLevel = req.user.access_level || req.user.role; + if (status === 'published' && !['admin', 'system'].includes(userLevel)) { + return res.status(403).json({ success: false, message: '발행된 회의록은 관리자만 수정할 수 있습니다.' }); + } + + await MeetingModel.deleteItem(req.params.itemId); + res.json({ success: true, message: '안건이 삭제되었습니다.' }); + } catch (err) { + logger.error('Meeting deleteItem error:', err); + res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' }); + } + }, + + // 조치상태 업데이트 (group_leader+) + updateItemStatus: async (req, res) => { + try { + const { status } = req.body; + if (!status) return res.status(400).json({ success: false, message: '상태를 선택해주세요.' }); + await MeetingModel.updateItemStatus(req.params.itemId, status); + res.json({ success: true, message: '조치상태가 업데이트되었습니다.' }); + } catch (err) { + logger.error('Meeting updateItemStatus error:', err); + res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' }); + } + }, + + // 미완료 조치사항 + getActionItems: async (req, res) => { + try { + const { status, responsible_user_id } = req.query; + const rows = await MeetingModel.getActionItems({ status, responsible_user_id }); + res.json({ success: true, data: rows }); + } catch (err) { + logger.error('Meeting getActionItems error:', err); + res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' }); + } + } +}; + +module.exports = MeetingController; diff --git a/system1-factory/api/controllers/scheduleController.js b/system1-factory/api/controllers/scheduleController.js new file mode 100644 index 0000000..a974d8e --- /dev/null +++ b/system1-factory/api/controllers/scheduleController.js @@ -0,0 +1,228 @@ +const ScheduleModel = require('../models/scheduleModel'); +const logger = require('../utils/logger'); + +const ScheduleController = { + // === 공정 단계 === + getPhases: async (req, res) => { + try { + const rows = await ScheduleModel.getPhases(); + res.json({ success: true, data: rows }); + } catch (err) { + logger.error('Schedule getPhases error:', err); + res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' }); + } + }, + + createPhase: async (req, res) => { + try { + const { phase_name, display_order, color } = req.body; + if (!phase_name) return res.status(400).json({ success: false, message: '단계명을 입력해주세요.' }); + const id = await ScheduleModel.createPhase({ phase_name, display_order, color }); + res.status(201).json({ success: true, data: { phase_id: id }, message: '공정 단계가 추가되었습니다.' }); + } catch (err) { + logger.error('Schedule createPhase error:', err); + res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' }); + } + }, + + updatePhase: async (req, res) => { + try { + await ScheduleModel.updatePhase(req.params.id, req.body); + res.json({ success: true, message: '공정 단계가 수정되었습니다.' }); + } catch (err) { + logger.error('Schedule updatePhase error:', err); + res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' }); + } + }, + + // === 작업 템플릿 === + getTemplates: async (req, res) => { + try { + const rows = await ScheduleModel.getTemplates(req.query.phase_id); + res.json({ success: true, data: rows }); + } catch (err) { + logger.error('Schedule getTemplates error:', err); + res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' }); + } + }, + + // === 공정표 항목 === + getEntries: async (req, res) => { + try { + const { project_id, year, month } = req.query; + const rows = await ScheduleModel.getEntries({ project_id, year, month }); + res.json({ success: true, data: rows }); + } catch (err) { + logger.error('Schedule getEntries error:', err); + res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' }); + } + }, + + getGanttData: async (req, res) => { + try { + const year = req.query.year || new Date().getFullYear(); + const data = await ScheduleModel.getGanttData(year); + res.json({ success: true, data }); + } catch (err) { + logger.error('Schedule getGanttData error:', err); + res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' }); + } + }, + + createEntry: async (req, res) => { + try { + const { project_id, phase_id, task_name, start_date, end_date } = req.body; + if (!project_id || !phase_id || !task_name || !start_date || !end_date) { + return res.status(400).json({ success: false, message: '필수 항목을 모두 입력해주세요.' }); + } + const id = await ScheduleModel.createEntry({ + ...req.body, + created_by: req.user.user_id || req.user.id + }); + res.status(201).json({ success: true, data: { entry_id: id }, message: '공정표 항목이 추가되었습니다.' }); + } catch (err) { + logger.error('Schedule createEntry error:', err); + res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' }); + } + }, + + createBatchEntries: async (req, res) => { + try { + const { project_id, phase_id, entries } = req.body; + if (!project_id || !phase_id || !entries || entries.length === 0) { + return res.status(400).json({ success: false, message: '프로젝트, 단계, 항목 정보를 입력해주세요.' }); + } + const ids = await ScheduleModel.createBatchEntries( + project_id, phase_id, entries, req.user.user_id || req.user.id + ); + res.status(201).json({ success: true, data: { entry_ids: ids }, message: `${ids.length}개 항목이 일괄 추가되었습니다.` }); + } catch (err) { + logger.error('Schedule createBatchEntries error:', err); + res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' }); + } + }, + + updateEntry: async (req, res) => { + try { + await ScheduleModel.updateEntry(req.params.id, req.body); + res.json({ success: true, message: '공정표 항목이 수정되었습니다.' }); + } catch (err) { + logger.error('Schedule updateEntry error:', err); + res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' }); + } + }, + + updateProgress: async (req, res) => { + try { + const { progress } = req.body; + if (progress === undefined || progress < 0 || progress > 100) { + return res.status(400).json({ success: false, message: '진행률은 0~100 사이의 값이어야 합니다.' }); + } + await ScheduleModel.updateProgress(req.params.id, progress); + res.json({ success: true, message: '진행률이 업데이트되었습니다.' }); + } catch (err) { + logger.error('Schedule updateProgress error:', err); + res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' }); + } + }, + + deleteEntry: async (req, res) => { + try { + await ScheduleModel.deleteEntry(req.params.id); + res.json({ success: true, message: '공정표 항목이 삭제되었습니다.' }); + } catch (err) { + logger.error('Schedule deleteEntry error:', err); + res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' }); + } + }, + + // === 의존관계 === + addDependency: async (req, res) => { + try { + const { depends_on_entry_id } = req.body; + if (!depends_on_entry_id) { + return res.status(400).json({ success: false, message: '선행 작업을 선택해주세요.' }); + } + await ScheduleModel.addDependency(req.params.id, depends_on_entry_id); + res.status(201).json({ success: true, message: '의존관계가 추가되었습니다.' }); + } catch (err) { + logger.error('Schedule addDependency error:', err); + res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' }); + } + }, + + removeDependency: async (req, res) => { + try { + await ScheduleModel.removeDependency(req.params.id, req.params.depId); + res.json({ success: true, message: '의존관계가 삭제되었습니다.' }); + } catch (err) { + logger.error('Schedule removeDependency error:', err); + res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' }); + } + }, + + // === 마일스톤 === + getMilestones: async (req, res) => { + try { + const rows = await ScheduleModel.getMilestones({ project_id: req.query.project_id }); + res.json({ success: true, data: rows }); + } catch (err) { + logger.error('Schedule getMilestones error:', err); + res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' }); + } + }, + + createMilestone: async (req, res) => { + try { + const { project_id, milestone_name, milestone_date } = req.body; + if (!project_id || !milestone_name || !milestone_date) { + return res.status(400).json({ success: false, message: '필수 항목을 모두 입력해주세요.' }); + } + const id = await ScheduleModel.createMilestone({ + ...req.body, + created_by: req.user.user_id || req.user.id + }); + res.status(201).json({ success: true, data: { milestone_id: id }, message: '마일스톤이 추가되었습니다.' }); + } catch (err) { + logger.error('Schedule createMilestone error:', err); + res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' }); + } + }, + + updateMilestone: async (req, res) => { + try { + await ScheduleModel.updateMilestone(req.params.id, req.body); + res.json({ success: true, message: '마일스톤이 수정되었습니다.' }); + } catch (err) { + logger.error('Schedule updateMilestone error:', err); + res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' }); + } + }, + + deleteMilestone: async (req, res) => { + try { + await ScheduleModel.deleteMilestone(req.params.id); + res.json({ success: true, message: '마일스톤이 삭제되었습니다.' }); + } catch (err) { + logger.error('Schedule deleteMilestone error:', err); + res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' }); + } + }, + + // === 부적합 연동 === + getNonconformance: async (req, res) => { + try { + const { project_id } = req.query; + if (!project_id) { + return res.status(400).json({ success: false, message: '프로젝트를 선택해주세요.' }); + } + const rows = await ScheduleModel.getNonconformanceByProject(project_id); + res.json({ success: true, data: rows }); + } catch (err) { + logger.error('Schedule getNonconformance error:', err); + res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' }); + } + } +}; + +module.exports = ScheduleController; diff --git a/system1-factory/api/db/migrations/20260317100000_create_schedule_meeting_system.js b/system1-factory/api/db/migrations/20260317100000_create_schedule_meeting_system.js new file mode 100644 index 0000000..f5cde46 --- /dev/null +++ b/system1-factory/api/db/migrations/20260317100000_create_schedule_meeting_system.js @@ -0,0 +1,182 @@ +/** + * 공정표 + 생산회의록 시스템 마이그레이션 + * + * 테이블 8개: schedule_phases, schedule_task_templates, schedule_entries, + * schedule_entry_dependencies, schedule_milestones, meeting_minutes, + * meeting_attendees, meeting_agenda_items + */ + +exports.up = async (knex) => { + // 1. 공정 단계 + await knex.schema.createTable('schedule_phases', (table) => { + table.increments('phase_id').primary(); + table.string('phase_name', 100).notNullable(); + table.integer('display_order').defaultTo(0); + table.string('color', 20).defaultTo('#3B82F6'); + table.boolean('is_active').defaultTo(true); + }); + + // 2. 작업 템플릿 + await knex.schema.createTable('schedule_task_templates', (table) => { + table.increments('template_id').primary(); + table.integer('phase_id').unsigned().notNullable() + .references('phase_id').inTable('schedule_phases').onDelete('CASCADE'); + table.string('task_name', 200).notNullable(); + table.integer('default_duration_days').defaultTo(7); + table.integer('display_order').defaultTo(0); + table.boolean('is_active').defaultTo(true); + }); + + // 3. 공정표 항목 + await knex.schema.createTable('schedule_entries', (table) => { + table.increments('entry_id').primary(); + table.integer('project_id').unsigned().notNullable() + .references('project_id').inTable('projects'); + table.integer('phase_id').unsigned().notNullable() + .references('phase_id').inTable('schedule_phases'); + table.string('task_name', 200).notNullable(); + table.date('start_date').notNullable(); + table.date('end_date').notNullable(); + table.integer('progress').defaultTo(0); + table.string('status', 20).defaultTo('planned'); + table.string('assignee', 100).nullable(); + table.text('notes').nullable(); + table.integer('display_order').defaultTo(0); + table.integer('created_by').unsigned().nullable() + .references('user_id').inTable('sso_users'); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + }); + + // 4. 작업 의존관계 (다대다) + await knex.schema.createTable('schedule_entry_dependencies', (table) => { + table.increments('id').primary(); + table.integer('entry_id').unsigned().notNullable() + .references('entry_id').inTable('schedule_entries').onDelete('CASCADE'); + table.integer('depends_on_entry_id').unsigned().notNullable() + .references('entry_id').inTable('schedule_entries').onDelete('CASCADE'); + table.unique(['entry_id', 'depends_on_entry_id']); + }); + + // 5. 마일스톤 + await knex.schema.createTable('schedule_milestones', (table) => { + table.increments('milestone_id').primary(); + table.integer('project_id').unsigned().notNullable() + .references('project_id').inTable('projects'); + table.integer('entry_id').unsigned().nullable() + .references('entry_id').inTable('schedule_entries').onDelete('SET NULL'); + table.string('milestone_name', 200).notNullable(); + table.date('milestone_date').notNullable(); + table.string('milestone_type', 30).defaultTo('deadline'); + table.string('status', 20).defaultTo('upcoming'); + table.text('notes').nullable(); + table.integer('created_by').unsigned().nullable() + .references('user_id').inTable('sso_users'); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + }); + + // 6. 생산회의록 + await knex.schema.createTable('meeting_minutes', (table) => { + table.increments('meeting_id').primary(); + table.date('meeting_date').notNullable(); + table.string('meeting_time', 10).nullable(); + table.string('title', 300).notNullable(); + table.string('location', 200).nullable(); + table.text('summary').nullable(); + table.string('status', 20).defaultTo('draft'); + table.integer('created_by').unsigned().nullable() + .references('user_id').inTable('sso_users'); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + }); + + // 7. 회의 참석자 (정규화) + await knex.schema.createTable('meeting_attendees', (table) => { + table.increments('id').primary(); + table.integer('meeting_id').unsigned().notNullable() + .references('meeting_id').inTable('meeting_minutes').onDelete('CASCADE'); + table.integer('user_id').unsigned().notNullable() + .references('user_id').inTable('sso_users'); + table.unique(['meeting_id', 'user_id']); + }); + + // 8. 회의 안건 + await knex.schema.createTable('meeting_agenda_items', (table) => { + table.increments('item_id').primary(); + table.integer('meeting_id').unsigned().notNullable() + .references('meeting_id').inTable('meeting_minutes').onDelete('CASCADE'); + table.integer('project_id').unsigned().nullable() + .references('project_id').inTable('projects'); + table.integer('milestone_id').unsigned().nullable() + .references('milestone_id').inTable('schedule_milestones').onDelete('SET NULL'); + table.string('item_type', 30).defaultTo('other'); + table.text('content').notNullable(); + table.text('decision').nullable(); + table.text('action_required').nullable(); + table.integer('responsible_user_id').unsigned().nullable() + .references('user_id').inTable('sso_users'); + table.date('due_date').nullable(); + table.string('status', 20).defaultTo('open'); + table.integer('display_order').defaultTo(0); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + }); + + // Seed: 공정 단계 + await knex('schedule_phases').insert([ + { phase_name: 'Outsourcing', display_order: 1, color: '#3B82F6' }, + { phase_name: 'BASE', display_order: 2, color: '#10B981' }, + { phase_name: 'SHOP', display_order: 3, color: '#F59E0B' }, + { phase_name: 'PV/Heat Exchanger', display_order: 4, color: '#8B5CF6' }, + ]); + + // Seed: 작업 템플릿 + const phases = await knex('schedule_phases').select('phase_id', 'phase_name'); + const phaseMap = {}; + phases.forEach(p => { phaseMap[p.phase_name] = p.phase_id; }); + + const templates = [ + // Outsourcing + { phase_id: phaseMap['Outsourcing'], task_name: '용기입고', default_duration_days: 14, display_order: 1 }, + { phase_id: phaseMap['Outsourcing'], task_name: 'PTK', default_duration_days: 10, display_order: 2 }, + { phase_id: phaseMap['Outsourcing'], task_name: 'Tray and Instrument Wiring', default_duration_days: 7, display_order: 3 }, + { phase_id: phaseMap['Outsourcing'], task_name: 'Painting', default_duration_days: 5, display_order: 4 }, + { phase_id: phaseMap['Outsourcing'], task_name: 'Instrument Wiring', default_duration_days: 7, display_order: 5 }, + { phase_id: phaseMap['Outsourcing'], task_name: 'Packing', default_duration_days: 3, display_order: 6 }, + // BASE + { phase_id: phaseMap['BASE'], task_name: 'Base Fabrication', default_duration_days: 14, display_order: 1 }, + { phase_id: phaseMap['BASE'], task_name: 'Base 제작', default_duration_days: 10, display_order: 2 }, + { phase_id: phaseMap['BASE'], task_name: '용기설치', default_duration_days: 5, display_order: 3 }, + // SHOP + { phase_id: phaseMap['SHOP'], task_name: '배관자재입고', default_duration_days: 7, display_order: 1 }, + { phase_id: phaseMap['SHOP'], task_name: 'Pre-Fabrication for Piping', default_duration_days: 10, display_order: 2 }, + { phase_id: phaseMap['SHOP'], task_name: '1st Piping Assembly', default_duration_days: 14, display_order: 3 }, + { phase_id: phaseMap['SHOP'], task_name: 'Hydro. Test', default_duration_days: 3, display_order: 4 }, + { phase_id: phaseMap['SHOP'], task_name: 'Re-Assembly', default_duration_days: 7, display_order: 5 }, + { phase_id: phaseMap['SHOP'], task_name: 'Tubing', default_duration_days: 5, display_order: 6 }, + { phase_id: phaseMap['SHOP'], task_name: 'FAT', default_duration_days: 2, display_order: 7 }, + ]; + await knex('schedule_task_templates').insert(templates); + + // 페이지 접근 권한 등록 + await knex.raw(` + INSERT IGNORE INTO pages (page_key, page_name, page_path, description, is_active) VALUES + ('work.schedule', '공정표', '/pages/work/schedule.html', '프로젝트 공정표 Gantt 뷰', 1), + ('work.meetings', '생산회의록', '/pages/work/meetings.html', '생산회의록 관리', 1), + ('work.meeting_detail', '회의록 상세', '/pages/work/meeting-detail.html', '회의록 상세/작성', 1) + `); +}; + +exports.down = async (knex) => { + await knex.schema.dropTableIfExists('meeting_agenda_items'); + await knex.schema.dropTableIfExists('meeting_attendees'); + await knex.schema.dropTableIfExists('meeting_minutes'); + await knex.schema.dropTableIfExists('schedule_milestones'); + await knex.schema.dropTableIfExists('schedule_entry_dependencies'); + await knex.schema.dropTableIfExists('schedule_entries'); + await knex.schema.dropTableIfExists('schedule_task_templates'); + await knex.schema.dropTableIfExists('schedule_phases'); + + await knex.raw(`DELETE FROM pages WHERE page_key IN ('work.schedule', 'work.meetings', 'work.meeting_detail')`); +}; diff --git a/system1-factory/api/models/meetingModel.js b/system1-factory/api/models/meetingModel.js new file mode 100644 index 0000000..b9fdafc --- /dev/null +++ b/system1-factory/api/models/meetingModel.js @@ -0,0 +1,223 @@ +// models/meetingModel.js +const { getDb } = require('../dbPool'); + +const MeetingModel = { + // === 회의록 === + async getAll(filters = {}) { + const db = await getDb(); + let sql = ` + SELECT m.*, + su.name AS created_by_name, + (SELECT COUNT(*) FROM meeting_attendees WHERE meeting_id = m.meeting_id) AS attendee_count, + (SELECT COUNT(*) FROM meeting_agenda_items WHERE meeting_id = m.meeting_id) AS agenda_count, + (SELECT COUNT(*) FROM meeting_agenda_items + WHERE meeting_id = m.meeting_id AND status IN ('open','in_progress')) AS open_action_count + FROM meeting_minutes m + LEFT JOIN sso_users su ON m.created_by = su.user_id + WHERE 1=1 + `; + const params = []; + if (filters.year && filters.month) { + sql += ' AND YEAR(m.meeting_date) = ? AND MONTH(m.meeting_date) = ?'; + params.push(filters.year, filters.month); + } else if (filters.year) { + sql += ' AND YEAR(m.meeting_date) = ?'; + params.push(filters.year); + } + if (filters.search) { + sql += ' AND (m.title LIKE ? OR m.summary LIKE ?)'; + params.push(`%${filters.search}%`, `%${filters.search}%`); + } + sql += ' ORDER BY m.meeting_date DESC, m.created_at DESC'; + const [rows] = await db.query(sql, params); + return rows; + }, + + async getById(meetingId) { + const db = await getDb(); + // 회의 기본정보 + const [meetings] = await db.query(` + SELECT m.*, su.name AS created_by_name + FROM meeting_minutes m + LEFT JOIN sso_users su ON m.created_by = su.user_id + WHERE m.meeting_id = ? + `, [meetingId]); + if (meetings.length === 0) return null; + const meeting = meetings[0]; + + // 참석자 + const [attendees] = await db.query(` + SELECT ma.id, ma.user_id, su.name, su.username, su.department + FROM meeting_attendees ma + JOIN sso_users su ON ma.user_id = su.user_id + WHERE ma.meeting_id = ? + ORDER BY su.name + `, [meetingId]); + meeting.attendees = attendees; + + // 안건 + const [items] = await db.query(` + SELECT ai.*, pr.project_name, pr.project_code, + ms.milestone_name, ms.milestone_date, + su.name AS responsible_name + FROM meeting_agenda_items ai + LEFT JOIN projects pr ON ai.project_id = pr.project_id + LEFT JOIN schedule_milestones ms ON ai.milestone_id = ms.milestone_id + LEFT JOIN sso_users su ON ai.responsible_user_id = su.user_id + WHERE ai.meeting_id = ? + ORDER BY ai.display_order, ai.item_id + `, [meetingId]); + meeting.items = items; + + return meeting; + }, + + async create(data) { + const db = await getDb(); + const [result] = await db.query( + `INSERT INTO meeting_minutes (meeting_date, meeting_time, title, location, summary, status, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [data.meeting_date, data.meeting_time || null, data.title, + data.location || null, data.summary || null, 'draft', data.created_by] + ); + const meetingId = result.insertId; + + // 참석자 추가 + if (data.attendees && data.attendees.length > 0) { + const values = data.attendees.map(userId => [meetingId, userId]); + await db.query('INSERT INTO meeting_attendees (meeting_id, user_id) VALUES ?', [values]); + } + + return meetingId; + }, + + async update(meetingId, data) { + const db = await getDb(); + const fields = []; + const params = []; + const allowed = ['meeting_date', 'meeting_time', 'title', 'location', 'summary']; + for (const key of allowed) { + if (data[key] !== undefined) { fields.push(`${key} = ?`); params.push(data[key]); } + } + if (fields.length > 0) { + fields.push('updated_at = NOW()'); + params.push(meetingId); + await db.query(`UPDATE meeting_minutes SET ${fields.join(', ')} WHERE meeting_id = ?`, params); + } + + // 참석자 재설정 + if (data.attendees !== undefined) { + await db.query('DELETE FROM meeting_attendees WHERE meeting_id = ?', [meetingId]); + if (data.attendees.length > 0) { + const values = data.attendees.map(userId => [meetingId, userId]); + await db.query('INSERT INTO meeting_attendees (meeting_id, user_id) VALUES ?', [values]); + } + } + }, + + async publish(meetingId) { + const db = await getDb(); + await db.query( + "UPDATE meeting_minutes SET status = 'published', updated_at = NOW() WHERE meeting_id = ?", + [meetingId] + ); + }, + + async unpublish(meetingId) { + const db = await getDb(); + await db.query( + "UPDATE meeting_minutes SET status = 'draft', updated_at = NOW() WHERE meeting_id = ?", + [meetingId] + ); + }, + + async delete(meetingId) { + const db = await getDb(); + await db.query('DELETE FROM meeting_minutes WHERE meeting_id = ?', [meetingId]); + }, + + // === 안건 === + async addItem(meetingId, data) { + const db = await getDb(); + const [result] = await db.query( + `INSERT INTO meeting_agenda_items + (meeting_id, project_id, milestone_id, item_type, content, decision, action_required, + responsible_user_id, due_date, status, display_order) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [meetingId, data.project_id || null, data.milestone_id || null, + data.item_type || 'other', data.content, data.decision || null, + data.action_required || null, data.responsible_user_id || null, + data.due_date || null, data.status || 'open', data.display_order || 0] + ); + return result.insertId; + }, + + async updateItem(itemId, data) { + const db = await getDb(); + const fields = []; + const params = []; + const allowed = ['project_id', 'milestone_id', 'item_type', 'content', 'decision', + 'action_required', 'responsible_user_id', 'due_date', 'status', 'display_order']; + for (const key of allowed) { + if (data[key] !== undefined) { fields.push(`${key} = ?`); params.push(data[key]); } + } + if (fields.length === 0) return; + fields.push('updated_at = NOW()'); + params.push(itemId); + await db.query(`UPDATE meeting_agenda_items SET ${fields.join(', ')} WHERE item_id = ?`, params); + }, + + async deleteItem(itemId) { + const db = await getDb(); + await db.query('DELETE FROM meeting_agenda_items WHERE item_id = ?', [itemId]); + }, + + async updateItemStatus(itemId, status) { + const db = await getDb(); + await db.query( + 'UPDATE meeting_agenda_items SET status = ?, updated_at = NOW() WHERE item_id = ?', + [status, itemId] + ); + }, + + // === 미완료 조치사항 === + async getActionItems(filters = {}) { + const db = await getDb(); + let sql = ` + SELECT ai.*, m.title AS meeting_title, m.meeting_date, + pr.project_name, pr.project_code, + su.name AS responsible_name + FROM meeting_agenda_items ai + JOIN meeting_minutes m ON ai.meeting_id = m.meeting_id + LEFT JOIN projects pr ON ai.project_id = pr.project_id + LEFT JOIN sso_users su ON ai.responsible_user_id = su.user_id + WHERE ai.item_type IN ('action_item', 'issue', 'decision') + `; + const params = []; + if (filters.status) { + sql += ' AND ai.status = ?'; + params.push(filters.status); + } else { + sql += " AND ai.status IN ('open', 'in_progress')"; + } + if (filters.responsible_user_id) { + sql += ' AND ai.responsible_user_id = ?'; + params.push(filters.responsible_user_id); + } + sql += ' ORDER BY ai.due_date ASC, m.meeting_date DESC'; + const [rows] = await db.query(sql, params); + return rows; + }, + + // 회의록 상태 조회 (published 체크용) + async getStatus(meetingId) { + const db = await getDb(); + const [rows] = await db.query( + 'SELECT status FROM meeting_minutes WHERE meeting_id = ?', + [meetingId] + ); + return rows.length > 0 ? rows[0].status : null; + } +}; + +module.exports = MeetingModel; diff --git a/system1-factory/api/models/scheduleModel.js b/system1-factory/api/models/scheduleModel.js new file mode 100644 index 0000000..94cc1ea --- /dev/null +++ b/system1-factory/api/models/scheduleModel.js @@ -0,0 +1,265 @@ +// models/scheduleModel.js +const { getDb } = require('../dbPool'); + +const ScheduleModel = { + // === 공정 단계 === + async getPhases() { + const db = await getDb(); + const [rows] = await db.query( + 'SELECT * FROM schedule_phases WHERE is_active = 1 ORDER BY display_order' + ); + return rows; + }, + + async createPhase(data) { + const db = await getDb(); + const [result] = await db.query( + 'INSERT INTO schedule_phases (phase_name, display_order, color) VALUES (?, ?, ?)', + [data.phase_name, data.display_order || 0, data.color || '#3B82F6'] + ); + return result.insertId; + }, + + async updatePhase(phaseId, data) { + const db = await getDb(); + const fields = []; + const params = []; + if (data.phase_name !== undefined) { fields.push('phase_name = ?'); params.push(data.phase_name); } + if (data.display_order !== undefined) { fields.push('display_order = ?'); params.push(data.display_order); } + if (data.color !== undefined) { fields.push('color = ?'); params.push(data.color); } + if (data.is_active !== undefined) { fields.push('is_active = ?'); params.push(data.is_active); } + if (fields.length === 0) return; + params.push(phaseId); + await db.query(`UPDATE schedule_phases SET ${fields.join(', ')} WHERE phase_id = ?`, params); + }, + + // === 작업 템플릿 === + async getTemplates(phaseId) { + const db = await getDb(); + let sql = 'SELECT t.*, p.phase_name FROM schedule_task_templates t JOIN schedule_phases p ON t.phase_id = p.phase_id WHERE t.is_active = 1'; + const params = []; + if (phaseId) { sql += ' AND t.phase_id = ?'; params.push(phaseId); } + sql += ' ORDER BY p.display_order, t.display_order'; + const [rows] = await db.query(sql, params); + return rows; + }, + + // === 공정표 항목 === + async getEntries(filters = {}) { + const db = await getDb(); + let sql = ` + SELECT e.*, p.phase_name, p.color AS phase_color, pr.project_name, pr.project_code, + su.name AS created_by_name + FROM schedule_entries e + JOIN schedule_phases p ON e.phase_id = p.phase_id + JOIN projects pr ON e.project_id = pr.project_id + LEFT JOIN sso_users su ON e.created_by = su.user_id + WHERE 1=1 + `; + const params = []; + if (filters.project_id) { sql += ' AND e.project_id = ?'; params.push(filters.project_id); } + if (filters.year) { + sql += ' AND (YEAR(e.start_date) = ? OR YEAR(e.end_date) = ?)'; + params.push(filters.year, filters.year); + } + if (filters.month && filters.year) { + sql += ' AND ((YEAR(e.start_date) = ? AND MONTH(e.start_date) = ?) OR (YEAR(e.end_date) = ? AND MONTH(e.end_date) = ?))'; + params.push(filters.year, filters.month, filters.year, filters.month); + } + sql += ' ORDER BY pr.project_code, p.display_order, e.display_order'; + const [rows] = await db.query(sql, params); + return rows; + }, + + async getGanttData(year) { + const db = await getDb(); + // 해당 연도에 걸치는 모든 항목 + const [entries] = await db.query(` + SELECT e.*, p.phase_name, p.color AS phase_color, p.display_order AS phase_order, + pr.project_name, pr.project_code + FROM schedule_entries e + JOIN schedule_phases p ON e.phase_id = p.phase_id + JOIN projects pr ON e.project_id = pr.project_id + WHERE (YEAR(e.start_date) <= ? AND YEAR(e.end_date) >= ?) + AND e.status != 'cancelled' + ORDER BY pr.project_code, p.display_order, e.display_order + `, [year, year]); + + // 의존관계 + const entryIds = entries.map(e => e.entry_id); + let dependencies = []; + if (entryIds.length > 0) { + const [deps] = await db.query( + `SELECT * FROM schedule_entry_dependencies WHERE entry_id IN (?)`, + [entryIds] + ); + dependencies = deps; + } + + // 마일스톤 + const [milestones] = await db.query(` + SELECT m.*, pr.project_name, pr.project_code + FROM schedule_milestones m + JOIN projects pr ON m.project_id = pr.project_id + WHERE YEAR(m.milestone_date) = ? + ORDER BY m.milestone_date + `, [year]); + + return { entries, dependencies, milestones }; + }, + + async createEntry(data) { + const db = await getDb(); + const [result] = await db.query( + `INSERT INTO schedule_entries + (project_id, phase_id, task_name, start_date, end_date, progress, status, assignee, notes, display_order, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [data.project_id, data.phase_id, data.task_name, data.start_date, data.end_date, + data.progress || 0, data.status || 'planned', data.assignee || null, + data.notes || null, data.display_order || 0, data.created_by || null] + ); + return result.insertId; + }, + + async createBatchEntries(projectId, phaseId, entries, createdBy) { + const db = await getDb(); + const ids = []; + for (const entry of entries) { + const [result] = await db.query( + `INSERT INTO schedule_entries + (project_id, phase_id, task_name, start_date, end_date, display_order, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [projectId, phaseId, entry.task_name, entry.start_date, entry.end_date, + entry.display_order || 0, createdBy] + ); + ids.push(result.insertId); + } + return ids; + }, + + async updateEntry(entryId, data) { + const db = await getDb(); + const fields = []; + const params = []; + const allowed = ['task_name', 'start_date', 'end_date', 'progress', 'status', 'assignee', 'notes', 'display_order', 'phase_id']; + for (const key of allowed) { + if (data[key] !== undefined) { fields.push(`${key} = ?`); params.push(data[key]); } + } + if (fields.length === 0) return; + fields.push('updated_at = NOW()'); + params.push(entryId); + await db.query(`UPDATE schedule_entries SET ${fields.join(', ')} WHERE entry_id = ?`, params); + }, + + async updateProgress(entryId, progress) { + const db = await getDb(); + let status = 'in_progress'; + if (progress === 0) status = 'planned'; + if (progress === 100) status = 'completed'; + await db.query( + 'UPDATE schedule_entries SET progress = ?, status = ?, updated_at = NOW() WHERE entry_id = ?', + [progress, status, entryId] + ); + }, + + async deleteEntry(entryId) { + const db = await getDb(); + await db.query('DELETE FROM schedule_entries WHERE entry_id = ?', [entryId]); + }, + + // === 의존관계 === + async addDependency(entryId, dependsOnEntryId) { + const db = await getDb(); + const [result] = await db.query( + 'INSERT IGNORE INTO schedule_entry_dependencies (entry_id, depends_on_entry_id) VALUES (?, ?)', + [entryId, dependsOnEntryId] + ); + return result.insertId; + }, + + async removeDependency(entryId, dependsOnEntryId) { + const db = await getDb(); + await db.query( + 'DELETE FROM schedule_entry_dependencies WHERE entry_id = ? AND depends_on_entry_id = ?', + [entryId, dependsOnEntryId] + ); + }, + + async getDependencies(entryId) { + const db = await getDb(); + const [rows] = await db.query(` + SELECT d.*, e.task_name AS depends_on_task_name + FROM schedule_entry_dependencies d + JOIN schedule_entries e ON d.depends_on_entry_id = e.entry_id + WHERE d.entry_id = ? + `, [entryId]); + return rows; + }, + + // === 마일스톤 === + async getMilestones(filters = {}) { + const db = await getDb(); + let sql = ` + SELECT m.*, pr.project_name, pr.project_code, e.task_name AS entry_task_name, + su.name AS created_by_name + FROM schedule_milestones m + JOIN projects pr ON m.project_id = pr.project_id + LEFT JOIN schedule_entries e ON m.entry_id = e.entry_id + LEFT JOIN sso_users su ON m.created_by = su.user_id + WHERE 1=1 + `; + const params = []; + if (filters.project_id) { sql += ' AND m.project_id = ?'; params.push(filters.project_id); } + sql += ' ORDER BY m.milestone_date'; + const [rows] = await db.query(sql, params); + return rows; + }, + + async createMilestone(data) { + const db = await getDb(); + const [result] = await db.query( + `INSERT INTO schedule_milestones + (project_id, entry_id, milestone_name, milestone_date, milestone_type, status, notes, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [data.project_id, data.entry_id || null, data.milestone_name, data.milestone_date, + data.milestone_type || 'deadline', data.status || 'upcoming', + data.notes || null, data.created_by || null] + ); + return result.insertId; + }, + + async updateMilestone(milestoneId, data) { + const db = await getDb(); + const fields = []; + const params = []; + const allowed = ['milestone_name', 'milestone_date', 'milestone_type', 'status', 'entry_id', 'notes']; + for (const key of allowed) { + if (data[key] !== undefined) { fields.push(`${key} = ?`); params.push(data[key]); } + } + if (fields.length === 0) return; + fields.push('updated_at = NOW()'); + params.push(milestoneId); + await db.query(`UPDATE schedule_milestones SET ${fields.join(', ')} WHERE milestone_id = ?`, params); + }, + + async deleteMilestone(milestoneId) { + const db = await getDb(); + await db.query('DELETE FROM schedule_milestones WHERE milestone_id = ?', [milestoneId]); + }, + + // === 부적합 연동 (격리 함수) === + // 향후 System3 API 호출로 전환 시 이 함수만 수정 + async getNonconformanceByProject(projectId) { + const db = await getDb(); + const [rows] = await db.query( + `SELECT id, category, description, review_status, responsible_department, + expected_completion_date, actual_completion_date, report_date + FROM qc_issues WHERE project_id = ? AND review_status != 'disposed' + ORDER BY report_date DESC`, + [projectId] + ); + return rows; + } +}; + +module.exports = ScheduleModel; diff --git a/system1-factory/api/routes/meetingRoutes.js b/system1-factory/api/routes/meetingRoutes.js new file mode 100644 index 0000000..8b72424 --- /dev/null +++ b/system1-factory/api/routes/meetingRoutes.js @@ -0,0 +1,24 @@ +const express = require('express'); +const router = express.Router(); +const ctrl = require('../controllers/meetingController'); +const { requireMinLevel } = require('../middlewares/auth'); + +// 회의록 +router.get('/', ctrl.getAll); +router.get('/action-items', ctrl.getActionItems); +router.get('/:id', ctrl.getById); +router.post('/', requireMinLevel('support_team'), ctrl.create); +router.put('/:id', requireMinLevel('support_team'), ctrl.update); +router.put('/:id/publish', requireMinLevel('support_team'), ctrl.publish); +router.put('/:id/unpublish', requireMinLevel('admin'), ctrl.unpublish); +router.delete('/:id', requireMinLevel('admin'), ctrl.delete); + +// 안건 +router.post('/:id/items', requireMinLevel('support_team'), ctrl.addItem); +router.put('/:id/items/:itemId', requireMinLevel('support_team'), ctrl.updateItem); +router.delete('/:id/items/:itemId', requireMinLevel('support_team'), ctrl.deleteItem); + +// 조치상태 업데이트 +router.put('/items/:itemId/status', requireMinLevel('group_leader'), ctrl.updateItemStatus); + +module.exports = router; diff --git a/system1-factory/api/routes/scheduleRoutes.js b/system1-factory/api/routes/scheduleRoutes.js new file mode 100644 index 0000000..2a13de8 --- /dev/null +++ b/system1-factory/api/routes/scheduleRoutes.js @@ -0,0 +1,36 @@ +const express = require('express'); +const router = express.Router(); +const ctrl = require('../controllers/scheduleController'); +const { requireMinLevel } = require('../middlewares/auth'); + +// 공정 단계 +router.get('/phases', ctrl.getPhases); +router.post('/phases', requireMinLevel('admin'), ctrl.createPhase); +router.put('/phases/:id', requireMinLevel('admin'), ctrl.updatePhase); + +// 작업 템플릿 +router.get('/templates', ctrl.getTemplates); + +// 공정표 항목 +router.get('/entries', ctrl.getEntries); +router.get('/entries/gantt', ctrl.getGanttData); +router.post('/entries', requireMinLevel('support_team'), ctrl.createEntry); +router.post('/entries/batch', requireMinLevel('support_team'), ctrl.createBatchEntries); +router.put('/entries/:id', requireMinLevel('support_team'), ctrl.updateEntry); +router.put('/entries/:id/progress', requireMinLevel('group_leader'), ctrl.updateProgress); +router.delete('/entries/:id', requireMinLevel('admin'), ctrl.deleteEntry); + +// 의존관계 +router.post('/entries/:id/dependencies', requireMinLevel('support_team'), ctrl.addDependency); +router.delete('/entries/:id/dependencies/:depId', requireMinLevel('support_team'), ctrl.removeDependency); + +// 마일스톤 +router.get('/milestones', ctrl.getMilestones); +router.post('/milestones', requireMinLevel('support_team'), ctrl.createMilestone); +router.put('/milestones/:id', requireMinLevel('support_team'), ctrl.updateMilestone); +router.delete('/milestones/:id', requireMinLevel('admin'), ctrl.deleteMilestone); + +// 부적합 연동 +router.get('/nonconformance', ctrl.getNonconformance); + +module.exports = router; diff --git a/system1-factory/web/js/meeting-detail.js b/system1-factory/web/js/meeting-detail.js new file mode 100644 index 0000000..621d0f6 --- /dev/null +++ b/system1-factory/web/js/meeting-detail.js @@ -0,0 +1,358 @@ +/* meeting-detail.js — 회의록 상세/작성 */ + +let meetingId = null; +let meetingData = null; +let selectedAttendees = []; // [{user_id, name, username}] +let projects = []; +let users = []; +let canEdit = false; +let isAdmin = false; +let isPublished = false; + +document.addEventListener('DOMContentLoaded', async () => { + const ok = await initAuth(); + if (!ok) return; + document.querySelector('.fade-in').classList.add('visible'); + + const role = currentUser?.role || ''; + canEdit = ['support_team', 'admin', 'system', 'system admin'].includes(role); + isAdmin = ['admin', 'system', 'system admin'].includes(role); + + // Parse URL + const params = new URLSearchParams(location.search); + meetingId = params.get('id'); + + // Load master data + try { + const [projRes, userRes] = await Promise.all([ + api('/projects'), + api('/users') + ]); + projects = projRes.data || []; + users = (userRes.data || []).filter(u => u.is_active !== 0); + } catch {} + + // Populate project select in item modal + const projSel = document.getElementById('itemProject'); + projects.forEach(p => { + projSel.innerHTML += ``; + }); + + // Populate responsible user select + const respSel = document.getElementById('itemResponsible'); + users.forEach(u => { + respSel.innerHTML += ``; + }); + + // Attendee search + const searchInput = document.getElementById('attendeeSearch'); + const resultsDiv = document.getElementById('attendeeResults'); + searchInput.addEventListener('input', debounce(() => { + const q = searchInput.value.trim().toLowerCase(); + if (q.length < 1) { resultsDiv.classList.add('hidden'); return; } + const matches = users.filter(u => + !selectedAttendees.some(a => a.user_id === u.user_id) && + (u.name?.toLowerCase().includes(q) || u.username?.toLowerCase().includes(q)) + ).slice(0, 10); + if (matches.length === 0) { resultsDiv.classList.add('hidden'); return; } + resultsDiv.innerHTML = matches.map(u => + `
${escapeHtml(item.content)}
+ ${item.decision ? `결정: ${escapeHtml(item.decision)}
` : ''} + ${item.action_required ? `조치: ${escapeHtml(item.action_required)}
` : ''} +부적합 내역이 없습니다.
'; + } else { + html = '| 일자 | 분류 | 내용 | 상태 |
|---|---|---|---|
| ${formatDate(nc.report_date)} | +${escapeHtml(nc.category || '-')} | +${escapeHtml(nc.description || '-')} | +${statusLabels[nc.review_status] || nc.review_status} | +
해당 단계에 템플릿이 없습니다.
'; return; } + + list.innerHTML = filtered.map((t, i) => ` +생산회의 기록을 관리합니다
+회의록이 없습니다.
+프로젝트별 공정 일정을 Gantt 차트로 관리합니다
+