From d7cc568c016fb6f36c41cea9cc6bfab5c622d2ca Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Tue, 17 Mar 2026 08:05:18 +0900 Subject: [PATCH] =?UTF-8?q?feat(tkfb):=20=EA=B3=B5=EC=A0=95=ED=91=9C=20+?= =?UTF-8?q?=20=EC=83=9D=EC=82=B0=ED=9A=8C=EC=9D=98=EB=A1=9D=20=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- system1-factory/api/config/routes.js | 4 + .../api/controllers/meetingController.js | 182 +++++ .../api/controllers/scheduleController.js | 228 ++++++ ...17100000_create_schedule_meeting_system.js | 182 +++++ system1-factory/api/models/meetingModel.js | 223 ++++++ system1-factory/api/models/scheduleModel.js | 265 +++++++ system1-factory/api/routes/meetingRoutes.js | 24 + system1-factory/api/routes/scheduleRoutes.js | 36 + system1-factory/web/js/meeting-detail.js | 358 +++++++++ system1-factory/web/js/meetings.js | 106 +++ system1-factory/web/js/schedule.js | 699 ++++++++++++++++++ .../web/pages/work/meeting-detail.html | 199 +++++ system1-factory/web/pages/work/meetings.html | 86 +++ system1-factory/web/pages/work/schedule.html | 321 ++++++++ system1-factory/web/static/js/tkfb-core.js | 3 + 15 files changed, 2916 insertions(+) create mode 100644 system1-factory/api/controllers/meetingController.js create mode 100644 system1-factory/api/controllers/scheduleController.js create mode 100644 system1-factory/api/db/migrations/20260317100000_create_schedule_meeting_system.js create mode 100644 system1-factory/api/models/meetingModel.js create mode 100644 system1-factory/api/models/scheduleModel.js create mode 100644 system1-factory/api/routes/meetingRoutes.js create mode 100644 system1-factory/api/routes/scheduleRoutes.js create mode 100644 system1-factory/web/js/meeting-detail.js create mode 100644 system1-factory/web/js/meetings.js create mode 100644 system1-factory/web/js/schedule.js create mode 100644 system1-factory/web/pages/work/meeting-detail.html create mode 100644 system1-factory/web/pages/work/meetings.html create mode 100644 system1-factory/web/pages/work/schedule.html 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(u.name)} (${escapeHtml(u.username)})
` + ).join(''); + resultsDiv.classList.remove('hidden'); + }, 200)); + searchInput.addEventListener('blur', () => setTimeout(() => resultsDiv.classList.add('hidden'), 200)); + + if (meetingId) { + await loadMeeting(); + } else { + // New meeting + document.getElementById('meetingDate').value = new Date().toISOString().split('T')[0]; + updateUI(); + } +}); + +async function loadMeeting() { + try { + const res = await api(`/meetings/${meetingId}`); + meetingData = res.data; + isPublished = meetingData.status === 'published'; + + document.getElementById('pageTitle').textContent = meetingData.title; + document.getElementById('meetingDate').value = formatDate(meetingData.meeting_date); + document.getElementById('meetingTime').value = meetingData.meeting_time || ''; + document.getElementById('meetingTitle').value = meetingData.title; + document.getElementById('meetingLocation').value = meetingData.location || ''; + document.getElementById('meetingSummary').value = meetingData.summary || ''; + + // Status badge + const badge = document.getElementById('statusBadge'); + badge.classList.remove('hidden'); + if (isPublished) { + badge.className = 'badge badge-green'; + badge.textContent = '발행'; + } else { + badge.className = 'badge badge-gray'; + badge.textContent = '초안'; + } + + // Attendees + selectedAttendees = (meetingData.attendees || []).map(a => ({ + user_id: a.user_id, name: a.name, username: a.username + })); + renderAttendees(); + + // Agenda items + renderAgendaItems(meetingData.items || []); + updateUI(); + } catch (err) { + showToast('회의록 로드 실패: ' + err.message, 'error'); + } +} + +function updateUI() { + const editable = canEdit && (!isPublished || isAdmin); + // Fields + ['meetingDate', 'meetingTime', 'meetingTitle', 'meetingLocation', 'meetingSummary', 'attendeeSearch'].forEach(id => { + const el = document.getElementById(id); + if (el) { el.disabled = !editable; if (!editable) el.classList.add('bg-gray-100'); } + }); + + // Buttons + document.getElementById('btnSave').classList.toggle('hidden', !editable); + document.getElementById('btnAddItem').classList.toggle('hidden', !editable); + document.getElementById('btnPublish').classList.toggle('hidden', !canEdit || isPublished || !meetingId); + document.getElementById('btnUnpublish').classList.toggle('hidden', !isAdmin || !isPublished); + document.getElementById('btnDelete').classList.toggle('hidden', !isAdmin || !meetingId); +} + +/* ===== Attendees ===== */ +function addAttendee(userId, name, username) { + if (selectedAttendees.some(a => a.user_id === userId)) return; + selectedAttendees.push({ user_id: userId, name, username }); + renderAttendees(); + document.getElementById('attendeeSearch').value = ''; + document.getElementById('attendeeResults').classList.add('hidden'); +} + +function removeAttendee(userId) { + selectedAttendees = selectedAttendees.filter(a => a.user_id !== userId); + renderAttendees(); +} + +function renderAttendees() { + const container = document.getElementById('attendeeTags'); + const editable = canEdit && (!isPublished || isAdmin); + container.innerHTML = selectedAttendees.map(a => + `${escapeHtml(a.name)}${editable ? ` ×` : ''}` + ).join(''); +} + +/* ===== Agenda Items ===== */ +function renderAgendaItems(items) { + const list = document.getElementById('agendaList'); + const empty = document.getElementById('agendaEmpty'); + + if (items.length === 0) { + list.innerHTML = ''; + empty.classList.remove('hidden'); + return; + } + empty.classList.add('hidden'); + + const typeLabels = { schedule_update: '공정현황', issue: '이슈', decision: '결정사항', action_item: '조치사항', other: '기타' }; + const typeColors = { schedule_update: 'badge-blue', issue: 'badge-red', decision: 'badge-green', action_item: 'badge-amber', other: 'badge-gray' }; + const statusLabels = { open: '미처리', in_progress: '진행중', completed: '완료', cancelled: '취소' }; + const statusColors = { open: 'badge-amber', in_progress: 'badge-blue', completed: 'badge-green', cancelled: 'badge-gray' }; + + const editable = canEdit && (!isPublished || isAdmin); + const canUpdateStatus = ['group_leader', 'support_team', 'admin', 'system', 'system admin'].includes(currentUser?.role || ''); + + list.innerHTML = items.map(item => ` +
+
+
+ ${typeLabels[item.item_type] || item.item_type} + ${statusLabels[item.status] || item.status} + ${item.project_code ? `${escapeHtml(item.project_code)}` : ''} + ${item.milestone_name ? `◆ ${escapeHtml(item.milestone_name)}` : ''} +
+
+ ${canUpdateStatus && item.status !== 'completed' ? `` : ''} + ${editable ? ` + ` : ''} +
+
+

${escapeHtml(item.content)}

+ ${item.decision ? `

결정: ${escapeHtml(item.decision)}

` : ''} + ${item.action_required ? `

조치: ${escapeHtml(item.action_required)}

` : ''} +
+ ${item.responsible_name ? `${escapeHtml(item.responsible_name)}` : ''} + ${item.due_date ? `${formatDate(item.due_date)}` : ''} + ${item.milestone_name ? `공정표 보기` : ''} +
+
+ `).join(''); +} + +/* ===== Save Meeting ===== */ +async function saveMeeting() { + const title = document.getElementById('meetingTitle').value.trim(); + const meetingDate = document.getElementById('meetingDate').value; + if (!title || !meetingDate) { showToast('날짜와 제목은 필수입니다.', 'error'); return; } + + const data = { + meeting_date: meetingDate, + meeting_time: document.getElementById('meetingTime').value || null, + title, + location: document.getElementById('meetingLocation').value || null, + summary: document.getElementById('meetingSummary').value || null, + attendees: selectedAttendees.map(a => a.user_id) + }; + + try { + if (meetingId) { + await api(`/meetings/${meetingId}`, { method: 'PUT', body: JSON.stringify(data) }); + showToast('회의록이 저장되었습니다.'); + } else { + const res = await api('/meetings', { method: 'POST', body: JSON.stringify(data) }); + meetingId = res.data.meeting_id; + history.replaceState(null, '', `?id=${meetingId}`); + showToast('회의록이 생성되었습니다.'); + } + await loadMeeting(); + } catch (err) { showToast(err.message, 'error'); } +} + +async function publishMeeting() { + if (!confirm('회의록을 발행하시겠습니까? 발행 후 일반 사용자는 수정할 수 없습니다.')) return; + try { + await api(`/meetings/${meetingId}/publish`, { method: 'PUT' }); + showToast('회의록이 발행되었습니다.'); + await loadMeeting(); + } catch (err) { showToast(err.message, 'error'); } +} + +async function unpublishMeeting() { + if (!confirm('발행을 취소하시겠습니까?')) return; + try { + await api(`/meetings/${meetingId}/unpublish`, { method: 'PUT' }); + showToast('발행이 취소되었습니다.'); + await loadMeeting(); + } catch (err) { showToast(err.message, 'error'); } +} + +async function deleteMeeting() { + if (!confirm('회의록을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.')) return; + try { + await api(`/meetings/${meetingId}`, { method: 'DELETE' }); + showToast('회의록이 삭제되었습니다.'); + location.href = '/pages/work/meetings.html'; + } catch (err) { showToast(err.message, 'error'); } +} + +/* ===== Agenda Item Modal ===== */ +function openItemModal(editItemId) { + const modal = document.getElementById('itemModal'); + const isEdit = !!editItemId; + document.getElementById('itemModalTitle').textContent = isEdit ? '안건 수정' : '안건 추가'; + + if (isEdit && meetingData) { + const item = meetingData.items.find(i => i.item_id === editItemId); + if (!item) return; + document.getElementById('itemId').value = editItemId; + document.getElementById('itemType').value = item.item_type; + document.getElementById('itemProject').value = item.project_id || ''; + loadItemMilestones(item.milestone_id); + document.getElementById('itemContent').value = item.content; + document.getElementById('itemDecision').value = item.decision || ''; + document.getElementById('itemAction').value = item.action_required || ''; + document.getElementById('itemResponsible').value = item.responsible_user_id || ''; + document.getElementById('itemDueDate').value = item.due_date ? formatDate(item.due_date) : ''; + document.getElementById('itemStatus').value = item.status; + } else { + document.getElementById('itemId').value = ''; + document.getElementById('itemType').value = 'schedule_update'; + document.getElementById('itemProject').value = ''; + document.getElementById('itemMilestone').innerHTML = ''; + document.getElementById('itemContent').value = ''; + document.getElementById('itemDecision').value = ''; + document.getElementById('itemAction').value = ''; + document.getElementById('itemResponsible').value = ''; + document.getElementById('itemDueDate').value = ''; + document.getElementById('itemStatus').value = 'open'; + } + + modal.classList.remove('hidden'); +} + +function closeItemModal() { document.getElementById('itemModal').classList.add('hidden'); } + +async function loadItemMilestones(selectedId) { + const projectId = document.getElementById('itemProject').value; + const sel = document.getElementById('itemMilestone'); + sel.innerHTML = ''; + if (!projectId) return; + try { + const res = await api(`/schedule/milestones?project_id=${projectId}`); + (res.data || []).forEach(m => { + const opt = document.createElement('option'); + opt.value = m.milestone_id; + opt.textContent = `${m.milestone_name} (${formatDate(m.milestone_date)})`; + if (selectedId && m.milestone_id === selectedId) opt.selected = true; + sel.appendChild(opt); + }); + } catch {} +} + +async function saveItem() { + const content = document.getElementById('itemContent').value.trim(); + if (!content) { showToast('안건 내용을 입력해주세요.', 'error'); return; } + if (!meetingId) { showToast('회의록을 먼저 저장해주세요.', 'error'); return; } + + const itemId = document.getElementById('itemId').value; + const data = { + item_type: document.getElementById('itemType').value, + project_id: document.getElementById('itemProject').value || null, + milestone_id: document.getElementById('itemMilestone').value || null, + content, + decision: document.getElementById('itemDecision').value || null, + action_required: document.getElementById('itemAction').value || null, + responsible_user_id: document.getElementById('itemResponsible').value || null, + due_date: document.getElementById('itemDueDate').value || null, + status: document.getElementById('itemStatus').value + }; + + try { + if (itemId) { + await api(`/meetings/${meetingId}/items/${itemId}`, { method: 'PUT', body: JSON.stringify(data) }); + showToast('안건이 수정되었습니다.'); + } else { + await api(`/meetings/${meetingId}/items`, { method: 'POST', body: JSON.stringify(data) }); + showToast('안건이 추가되었습니다.'); + } + closeItemModal(); + await loadMeeting(); + } catch (err) { showToast(err.message, 'error'); } +} + +async function deleteItem(itemId) { + if (!confirm('안건을 삭제하시겠습니까?')) return; + try { + await api(`/meetings/${meetingId}/items/${itemId}`, { method: 'DELETE' }); + showToast('안건이 삭제되었습니다.'); + await loadMeeting(); + } catch (err) { showToast(err.message, 'error'); } +} + +async function updateItemStatus(itemId, status) { + if (!status) return; + try { + await api(`/meetings/items/${itemId}/status`, { method: 'PUT', body: JSON.stringify({ status }) }); + showToast('상태가 업데이트되었습니다.'); + await loadMeeting(); + } catch (err) { showToast(err.message, 'error'); } +} diff --git a/system1-factory/web/js/meetings.js b/system1-factory/web/js/meetings.js new file mode 100644 index 0000000..2a41b0b --- /dev/null +++ b/system1-factory/web/js/meetings.js @@ -0,0 +1,106 @@ +/* meetings.js — 생산회의록 목록 */ + +let canEdit = 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); + if (canEdit) document.getElementById('btnNewMeeting').classList.remove('hidden'); + + // Year filter + const yearSel = document.getElementById('yearFilter'); + const now = new Date(); + for (let y = now.getFullYear() - 2; y <= now.getFullYear() + 1; y++) { + const opt = document.createElement('option'); + opt.value = y; opt.textContent = y + '년'; + if (y === now.getFullYear()) opt.selected = true; + yearSel.appendChild(opt); + } + document.getElementById('monthFilter').value = String(now.getMonth() + 1); + + yearSel.addEventListener('change', loadMeetings); + document.getElementById('monthFilter').addEventListener('change', loadMeetings); + document.getElementById('searchInput').addEventListener('input', debounce(loadMeetings, 300)); + document.getElementById('btnNewMeeting').addEventListener('click', () => { + location.href = '/pages/work/meeting-detail.html'; + }); + + await Promise.all([loadMeetings(), loadActionItems()]); +}); + +async function loadMeetings() { + try { + const year = document.getElementById('yearFilter').value; + const month = document.getElementById('monthFilter').value; + const search = document.getElementById('searchInput').value.trim(); + let url = `/meetings?year=${year}`; + if (month) url += `&month=${month}`; + if (search) url += `&search=${encodeURIComponent(search)}`; + const res = await api(url); + renderMeetings(res.data || []); + } catch (err) { + showToast('회의록 목록 로드 실패: ' + err.message, 'error'); + } +} + +function renderMeetings(meetings) { + const list = document.getElementById('meetingList'); + const empty = document.getElementById('emptyState'); + + if (meetings.length === 0) { + list.innerHTML = ''; + empty.classList.remove('hidden'); + return; + } + empty.classList.add('hidden'); + + list.innerHTML = meetings.map(m => { + const statusBadge = m.status === 'published' + ? '발행' + : '초안'; + return ` + +
+
+
+ ${formatDate(m.meeting_date)} + ${statusBadge} +
+

${escapeHtml(m.title)}

+
+ ${escapeHtml(m.created_by_name || '-')} + 참석 ${m.attendee_count || 0}명 + 안건 ${m.agenda_count || 0}건 + ${m.open_action_count > 0 ? `미완료 ${m.open_action_count}건` : ''} +
+
+ +
+
+ `; + }).join(''); +} + +async function loadActionItems() { + try { + const res = await api('/meetings/action-items?status=open'); + const items = res.data || []; + if (items.length === 0) return; + + document.getElementById('actionSummary').classList.remove('hidden'); + document.getElementById('actionCount').textContent = items.length; + + document.getElementById('actionList').innerHTML = items.slice(0, 5).map(item => ` +
+ + ${escapeHtml(item.content)} + ${item.responsible_name ? `${escapeHtml(item.responsible_name)}` : ''} + ${item.due_date ? `${formatDate(item.due_date)}` : ''} +
+ `).join('') + (items.length > 5 ? `
외 ${items.length - 5}건
` : ''); + } catch {} +} diff --git a/system1-factory/web/js/schedule.js b/system1-factory/web/js/schedule.js new file mode 100644 index 0000000..4103837 --- /dev/null +++ b/system1-factory/web/js/schedule.js @@ -0,0 +1,699 @@ +/* schedule.js — Gantt chart with row virtualization */ + +let ganttData = { entries: [], dependencies: [], milestones: [] }; +let projects = []; +let phases = []; +let templates = []; +let allRows = []; // flat row data for virtualization +let collapseState = {}; // { projectCode: bool } +let ncCache = {}; // { projectId: [issues] } + +const ROW_HEIGHT = 32; +const BUFFER_ROWS = 5; +const DAY_WIDTHS = { month: 24, quarter: 12, year: 3 }; +let currentZoom = 'quarter'; +let currentYear = new Date().getFullYear(); +let canEdit = false; + +/* ===== Init ===== */ +document.addEventListener('DOMContentLoaded', async () => { + const ok = await initAuth(); + if (!ok) return; + document.querySelector('.fade-in').classList.add('visible'); + + // Check edit permission (support_team+) + const role = currentUser?.role || ''; + canEdit = ['support_team', 'admin', 'system', 'system admin'].includes(role); + if (canEdit) { + document.getElementById('btnAddEntry').classList.remove('hidden'); + document.getElementById('btnBatchAdd').classList.remove('hidden'); + document.getElementById('btnAddMilestone').classList.remove('hidden'); + } + + // Load collapse state + try { + const saved = localStorage.getItem('gantt_collapse'); + if (saved) collapseState = JSON.parse(saved); + } catch {} + + // Year select + const sel = document.getElementById('yearSelect'); + for (let y = currentYear - 2; y <= currentYear + 2; y++) { + const opt = document.createElement('option'); + opt.value = y; opt.textContent = y; + if (y === currentYear) opt.selected = true; + sel.appendChild(opt); + } + sel.addEventListener('change', () => { currentYear = parseInt(sel.value); loadGantt(); }); + + // Zoom buttons + document.querySelectorAll('.zoom-btn').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('.zoom-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + currentZoom = btn.dataset.zoom; + renderGantt(); + }); + }); + + // Toolbar buttons + document.getElementById('btnAddEntry').addEventListener('click', () => openEntryModal()); + document.getElementById('btnBatchAdd').addEventListener('click', () => openBatchModal()); + document.getElementById('btnAddMilestone').addEventListener('click', () => openMilestoneModal()); + + await loadMasterData(); + await loadGantt(); +}); + +async function loadMasterData() { + try { + const [projRes, phaseRes, tmplRes] = await Promise.all([ + api('/projects'), api('/schedule/phases'), api('/schedule/templates') + ]); + projects = projRes.data || []; + phases = phaseRes.data || []; + templates = tmplRes.data || []; + } catch (err) { showToast('마스터 데이터 로드 실패', 'error'); } +} + +async function loadGantt() { + try { + const res = await api(`/schedule/entries/gantt?year=${currentYear}`); + ganttData = res.data; + // Preload NC data + const projectIds = [...new Set(ganttData.entries.map(e => e.project_id))]; + await Promise.all(projectIds.map(async pid => { + try { + const ncRes = await api(`/schedule/nonconformance?project_id=${pid}`); + ncCache[pid] = ncRes.data || []; + } catch { ncCache[pid] = []; } + })); + renderGantt(); + } catch (err) { showToast('공정표 데이터 로드 실패: ' + err.message, 'error'); } +} + +/* ===== Build flat row array ===== */ +function buildRows() { + allRows = []; + // Group entries by project, then by phase + const byProject = {}; + ganttData.entries.forEach(e => { + if (!byProject[e.project_code]) byProject[e.project_code] = { project_id: e.project_id, project_name: e.project_name, code: e.project_code, phases: {} }; + const p = byProject[e.project_code]; + if (!p.phases[e.phase_name]) p.phases[e.phase_name] = { phase_id: e.phase_id, color: e.phase_color, order: e.phase_order, entries: [] }; + p.phases[e.phase_name].entries.push(e); + }); + // Also add milestones-only projects + ganttData.milestones.forEach(m => { + if (!byProject[m.project_code]) byProject[m.project_code] = { project_id: m.project_id, project_name: m.project_name, code: m.project_code, phases: {} }; + }); + + const sortedProjects = Object.values(byProject).sort((a, b) => a.code.localeCompare(b.code)); + + for (const proj of sortedProjects) { + const collapsed = collapseState[proj.code] === true; + allRows.push({ type: 'project', code: proj.code, label: `${proj.code} ${proj.project_name}`, project_id: proj.project_id, collapsed }); + + if (!collapsed) { + const sortedPhases = Object.entries(proj.phases).sort((a, b) => a[1].order - b[1].order); + for (const [phaseName, phaseData] of sortedPhases) { + allRows.push({ type: 'phase', label: phaseName, color: phaseData.color }); + for (const entry of phaseData.entries) { + allRows.push({ type: 'task', entry, color: phaseData.color }); + } + } + // Milestones for this project + const projMilestones = ganttData.milestones.filter(m => m.project_id === proj.project_id); + if (projMilestones.length > 0) { + allRows.push({ type: 'milestone-header', label: '◆ 마일스톤', milestones: projMilestones }); + } + // NC row + const ncList = ncCache[proj.project_id] || []; + if (ncList.length > 0) { + allRows.push({ type: 'nc', label: `⚠ 부적합 (${ncList.length})`, project_id: proj.project_id, count: ncList.length }); + } + } + } +} + +/* ===== Render ===== */ +function renderGantt() { + buildRows(); + const container = document.getElementById('ganttContainer'); + const wrapper = document.getElementById('ganttWrapper'); + const dayWidth = DAY_WIDTHS[currentZoom]; + + // Calculate total days in year + const yearStart = new Date(currentYear, 0, 1); + const yearEnd = new Date(currentYear, 11, 31); + const totalDays = Math.ceil((yearEnd - yearStart) / 86400000) + 1; + const timelineWidth = totalDays * dayWidth; + + container.style.setProperty('--day-width', dayWidth + 'px'); + container.style.width = (250 + timelineWidth) + 'px'; + + // Build month header + let headerHtml = '
프로젝트 / 단계 / 작업
'; + for (let m = 0; m < 12; m++) { + const daysInMonth = new Date(currentYear, m + 1, 0).getDate(); + const monthWidth = daysInMonth * dayWidth; + const monthNames = ['1월','2월','3월','4월','5월','6월','7월','8월','9월','10월','11월','12월']; + headerHtml += `
${monthNames[m]}
`; + } + headerHtml += '
'; + + // Virtual scroll container + const totalHeight = allRows.length * ROW_HEIGHT; + let rowsHtml = `
`; + + container.innerHTML = headerHtml + rowsHtml; + + // Today marker + const today = new Date(); + if (today.getFullYear() === currentYear) { + const todayOffset = dayOfYear(today) - 1; + const markerLeft = 250 + todayOffset * dayWidth; + const marker = document.createElement('div'); + marker.className = 'today-marker'; + marker.style.left = markerLeft + 'px'; + container.appendChild(marker); + } + + // Setup virtual scroll + const virtualBody = document.getElementById('ganttVirtualBody'); + const onScroll = () => renderVisibleRows(wrapper, virtualBody, dayWidth, totalDays); + wrapper.addEventListener('scroll', onScroll); + renderVisibleRows(wrapper, virtualBody, dayWidth, totalDays); + + // Scroll to today + if (today.getFullYear() === currentYear) { + const todayOffset = dayOfYear(today) - 1; + const scrollTo = Math.max(0, todayOffset * dayWidth - wrapper.clientWidth / 2 + 250); + wrapper.scrollLeft = scrollTo; + } +} + +function renderVisibleRows(wrapper, virtualBody, dayWidth, totalDays) { + const scrollTop = wrapper.scrollTop - 30; // account for header + const viewHeight = wrapper.clientHeight; + const startIdx = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - BUFFER_ROWS); + const endIdx = Math.min(allRows.length, Math.ceil((scrollTop + viewHeight) / ROW_HEIGHT) + BUFFER_ROWS); + + let html = ''; + for (let i = startIdx; i < endIdx; i++) { + const row = allRows[i]; + const top = i * ROW_HEIGHT; + html += renderRow(row, top, dayWidth, totalDays); + } + virtualBody.innerHTML = html; +} + +function renderRow(row, top, dayWidth, totalDays) { + const style = `position:absolute;top:${top}px;width:100%;height:${ROW_HEIGHT}px;`; + + if (row.type === 'project') { + const arrowClass = row.collapsed ? 'collapsed' : ''; + return `
+
+ ${escapeHtml(row.label)} +
+
+
`; + } + + if (row.type === 'phase') { + return `
+
${escapeHtml(row.label)}
+
+
`; + } + + if (row.type === 'task') { + const e = row.entry; + const bar = calcBar(e.start_date, e.end_date, dayWidth); + const statusColors = { planned: '0.6', in_progress: '0.85', completed: '1', delayed: '0.9' }; + const opacity = statusColors[e.status] || '0.7'; + const barHtml = bar ? `
+
+ ${bar.width > 50 ? `
${escapeHtml(e.task_name)}
` : ''} +
` : ''; + + return `
+
${escapeHtml(e.task_name)}${e.assignee ? ` (${escapeHtml(e.assignee)})` : ''}
+
${barHtml}
+
`; + } + + if (row.type === 'milestone-header') { + let markers = ''; + for (const m of row.milestones) { + const pos = calcPos(m.milestone_date, dayWidth); + if (pos !== null) { + const mColor = m.status === 'completed' ? '#10B981' : m.status === 'missed' ? '#EF4444' : '#7C3AED'; + markers += `
`; + } + } + return `
+
${escapeHtml(row.label)}
+
${markers}
+
`; + } + + if (row.type === 'nc') { + // Place NC badges by date + const ncList = ncCache[row.project_id] || []; + let badges = ''; + const byMonth = {}; + ncList.forEach(nc => { + const d = nc.report_date ? new Date(nc.report_date) : null; + if (d && d.getFullYear() === currentYear) { + const m = d.getMonth(); + byMonth[m] = (byMonth[m] || 0) + 1; + } + }); + for (const [m, cnt] of Object.entries(byMonth)) { + const monthStart = new Date(currentYear, parseInt(m), 15); + const pos = calcPos(monthStart, dayWidth); + if (pos !== null) { + badges += `
${cnt}
`; + } + } + return `
+
${escapeHtml(row.label)}
+
${badges}
+
`; + } + + return ''; +} + +/* ===== Helpers ===== */ +function dayOfYear(d) { + const start = new Date(d.getFullYear(), 0, 1); + return Math.ceil((d - start) / 86400000) + 1; +} + +function calcBar(startStr, endStr, dayWidth) { + const s = new Date(startStr); + const e = new Date(endStr); + if (s.getFullYear() > currentYear || e.getFullYear() < currentYear) return null; + const yearStart = new Date(currentYear, 0, 1); + const yearEnd = new Date(currentYear, 11, 31); + const clampStart = s < yearStart ? yearStart : s; + const clampEnd = e > yearEnd ? yearEnd : e; + const startDay = Math.ceil((clampStart - yearStart) / 86400000); + const endDay = Math.ceil((clampEnd - yearStart) / 86400000) + 1; + return { left: startDay * dayWidth, width: Math.max((endDay - startDay) * dayWidth, 4) }; +} + +function calcPos(dateStr, dayWidth) { + const d = new Date(dateStr); + if (d.getFullYear() !== currentYear) return null; + const yearStart = new Date(currentYear, 0, 1); + const offset = Math.ceil((d - yearStart) / 86400000); + return offset * dayWidth; +} + +/* ===== Interactions ===== */ +function toggleProject(code) { + collapseState[code] = !collapseState[code]; + localStorage.setItem('gantt_collapse', JSON.stringify(collapseState)); + renderGantt(); +} + +function showBarDetail(entryId) { + const entry = ganttData.entries.find(e => e.entry_id === entryId); + if (!entry) return; + const popup = document.getElementById('barDetailPopup'); + document.getElementById('barDetailTitle').textContent = entry.task_name; + const statusLabels = { planned: '계획', in_progress: '진행중', completed: '완료', delayed: '지연', cancelled: '취소' }; + document.getElementById('barDetailContent').innerHTML = ` +
+
프로젝트${escapeHtml(entry.project_code)} ${escapeHtml(entry.project_name)}
+
공정 단계${escapeHtml(entry.phase_name)}
+
기간${formatDate(entry.start_date)} ~ ${formatDate(entry.end_date)}
+
진행률${entry.progress}%
+
상태${statusLabels[entry.status] || entry.status}
+ ${entry.assignee ? `
담당자${escapeHtml(entry.assignee)}
` : ''} + ${entry.notes ? `
메모: ${escapeHtml(entry.notes)}
` : ''} +
+ `; + let actions = ''; + if (canEdit) { + actions = ``; + } + document.getElementById('barDetailActions').innerHTML = actions; + popup.classList.remove('hidden'); +} + +function showMilestoneDetail(milestoneId) { + const m = ganttData.milestones.find(ms => ms.milestone_id === milestoneId); + if (!m) return; + const popup = document.getElementById('barDetailPopup'); + const typeLabels = { deadline: '납기', review: '검토', inspection: '검사', delivery: '출하', meeting: '회의', other: '기타' }; + const statusLabels = { upcoming: '예정', completed: '완료', missed: '미달성' }; + document.getElementById('barDetailTitle').textContent = '◆ ' + m.milestone_name; + document.getElementById('barDetailContent').innerHTML = ` +
+
프로젝트${escapeHtml(m.project_code)} ${escapeHtml(m.project_name)}
+
날짜${formatDate(m.milestone_date)}
+
유형${typeLabels[m.milestone_type] || m.milestone_type}
+
상태${statusLabels[m.status] || m.status}
+ ${m.notes ? `
메모: ${escapeHtml(m.notes)}
` : ''} +
+ `; + let actions = ''; + if (canEdit) { + actions = ``; + } + document.getElementById('barDetailActions').innerHTML = actions; + popup.classList.remove('hidden'); +} + +function showNcPopup(projectId) { + const list = ncCache[projectId] || []; + const proj = projects.find(p => p.project_id === projectId); + document.getElementById('ncPopupTitle').textContent = `부적합 현황 - ${proj ? proj.project_code : ''}`; + const statusLabels = { reported: '신고', received: '접수', reviewing: '검토중', in_progress: '처리중', completed: '완료' }; + let html = ''; + if (list.length === 0) { + html = '

부적합 내역이 없습니다.

'; + } else { + html = ''; + for (const nc of list) { + html += ` + + + + + `; + } + html += '
일자분류내용상태
${formatDate(nc.report_date)}${escapeHtml(nc.category || '-')}${escapeHtml(nc.description || '-')}${statusLabels[nc.review_status] || nc.review_status}
'; + } + document.getElementById('ncPopupContent').innerHTML = html; + document.getElementById('ncPopup').classList.remove('hidden'); +} + +/* ===== Entry Modal ===== */ +function openEntryModal(editId) { + const modal = document.getElementById('entryModal'); + const isEdit = !!editId; + document.getElementById('entryModalTitle').textContent = isEdit ? '공정표 항목 수정' : '공정표 항목 추가'; + + // Populate dropdowns + populateSelect('entryProject', projects, 'project_id', p => `${p.project_code} ${p.project_name}`); + populateSelect('entryPhase', phases, 'phase_id', p => p.phase_name); + + // Template select (populated on phase change) + const phaseSelect = document.getElementById('entryPhase'); + phaseSelect.addEventListener('change', () => loadTemplateOptions('entryTemplate', phaseSelect.value)); + if (phases.length > 0) loadTemplateOptions('entryTemplate', phaseSelect.value); + + // Template → task name + document.getElementById('entryTemplate').addEventListener('change', function() { + if (this.value) { + const tmpl = templates.find(t => t.template_id === parseInt(this.value)); + if (tmpl) { + document.getElementById('entryTaskName').value = tmpl.task_name; + // Auto-fill duration + const startDate = document.getElementById('entryStartDate').value; + if (startDate && tmpl.default_duration_days) { + const end = new Date(startDate); + end.setDate(end.getDate() + tmpl.default_duration_days); + document.getElementById('entryEndDate').value = end.toISOString().split('T')[0]; + } + } + } + }); + + // Dependencies (all entries for the selected project) + const depSelect = document.getElementById('entryDependencies'); + depSelect.innerHTML = ''; + + if (isEdit) { + const entry = ganttData.entries.find(e => e.entry_id === editId); + if (!entry) return; + document.getElementById('entryId').value = editId; + document.getElementById('entryProject').value = entry.project_id; + document.getElementById('entryPhase').value = entry.phase_id; + document.getElementById('entryTaskName').value = entry.task_name; + document.getElementById('entryStartDate').value = formatDate(entry.start_date); + document.getElementById('entryEndDate').value = formatDate(entry.end_date); + document.getElementById('entryAssignee').value = entry.assignee || ''; + document.getElementById('entryProgress').value = entry.progress; + document.getElementById('entryStatus').value = entry.status; + document.getElementById('entryNotes').value = entry.notes || ''; + + // Load dependencies + const projEntries = ganttData.entries.filter(e => e.project_id === entry.project_id && e.entry_id !== editId); + const deps = ganttData.dependencies.filter(d => d.entry_id === editId).map(d => d.depends_on_entry_id); + projEntries.forEach(e => { + const opt = document.createElement('option'); + opt.value = e.entry_id; + opt.textContent = `${e.phase_name} > ${e.task_name}`; + opt.selected = deps.includes(e.entry_id); + depSelect.appendChild(opt); + }); + } else { + document.getElementById('entryId').value = ''; + document.getElementById('entryTaskName').value = ''; + document.getElementById('entryStartDate').value = ''; + document.getElementById('entryEndDate').value = ''; + document.getElementById('entryAssignee').value = ''; + document.getElementById('entryProgress').value = '0'; + document.getElementById('entryStatus').value = 'planned'; + document.getElementById('entryNotes').value = ''; + } + + modal.classList.remove('hidden'); +} + +function closeEntryModal() { document.getElementById('entryModal').classList.add('hidden'); } + +async function saveEntry() { + const entryId = document.getElementById('entryId').value; + const taskName = document.getElementById('entryTaskName').value.trim(); + if (!taskName) { showToast('작업명을 입력해주세요.', 'error'); return; } + + const data = { + project_id: document.getElementById('entryProject').value, + phase_id: document.getElementById('entryPhase').value, + task_name: taskName, + start_date: document.getElementById('entryStartDate').value, + end_date: document.getElementById('entryEndDate').value, + assignee: document.getElementById('entryAssignee').value || null, + progress: parseInt(document.getElementById('entryProgress').value) || 0, + status: document.getElementById('entryStatus').value, + notes: document.getElementById('entryNotes').value || null + }; + + try { + if (entryId) { + await api(`/schedule/entries/${entryId}`, { method: 'PUT', body: JSON.stringify(data) }); + + // Update dependencies + const depSelect = document.getElementById('entryDependencies'); + const selectedDeps = Array.from(depSelect.selectedOptions).map(o => parseInt(o.value)); + const existingDeps = ganttData.dependencies.filter(d => d.entry_id === parseInt(entryId)).map(d => d.depends_on_entry_id); + + // Add new + for (const depId of selectedDeps) { + if (!existingDeps.includes(depId)) { + await api(`/schedule/entries/${entryId}/dependencies`, { method: 'POST', body: JSON.stringify({ depends_on_entry_id: depId }) }); + } + } + // Remove old + for (const depId of existingDeps) { + if (!selectedDeps.includes(depId)) { + await api(`/schedule/entries/${entryId}/dependencies/${depId}`, { method: 'DELETE' }); + } + } + + showToast('공정표 항목이 수정되었습니다.'); + } else { + const res = await api('/schedule/entries', { method: 'POST', body: JSON.stringify(data) }); + + // Add dependencies for new entry + const depSelect = document.getElementById('entryDependencies'); + const selectedDeps = Array.from(depSelect.selectedOptions).map(o => parseInt(o.value)); + for (const depId of selectedDeps) { + await api(`/schedule/entries/${res.data.entry_id}/dependencies`, { method: 'POST', body: JSON.stringify({ depends_on_entry_id: depId }) }); + } + + showToast('공정표 항목이 추가되었습니다.'); + } + closeEntryModal(); + await loadGantt(); + } catch (err) { showToast(err.message, 'error'); } +} + +/* ===== Batch Modal ===== */ +function openBatchModal() { + populateSelect('batchProject', projects, 'project_id', p => `${p.project_code} ${p.project_name}`); + populateSelect('batchPhase', phases, 'phase_id', p => p.phase_name); + document.getElementById('batchStartDate').value = ''; + document.getElementById('batchTemplateList').innerHTML = ''; + loadBatchTemplates(); + document.getElementById('batchModal').classList.remove('hidden'); +} + +function closeBatchModal() { document.getElementById('batchModal').classList.add('hidden'); } + +function loadBatchTemplates() { + const phaseId = parseInt(document.getElementById('batchPhase').value); + const filtered = templates.filter(t => t.phase_id === phaseId); + const list = document.getElementById('batchTemplateList'); + if (filtered.length === 0) { list.innerHTML = '

해당 단계에 템플릿이 없습니다.

'; return; } + + list.innerHTML = filtered.map((t, i) => ` +
+ + ${escapeHtml(t.task_name)} + ${t.default_duration_days}일 + + ~ + +
+ `).join(''); + + recalcBatchDates(); +} + +function recalcBatchDates() { + const baseStart = document.getElementById('batchStartDate').value; + if (!baseStart) return; + const phaseId = parseInt(document.getElementById('batchPhase').value); + const filtered = templates.filter(t => t.phase_id === phaseId); + let cursor = new Date(baseStart); + for (const t of filtered) { + const startEl = document.getElementById(`btmpl_start_${t.template_id}`); + const endEl = document.getElementById(`btmpl_end_${t.template_id}`); + if (startEl && endEl) { + startEl.value = cursor.toISOString().split('T')[0]; + const endDate = new Date(cursor); + endDate.setDate(endDate.getDate() + t.default_duration_days); + endEl.value = endDate.toISOString().split('T')[0]; + cursor = new Date(endDate); + } + } +} + +async function saveBatchEntries() { + const projectId = document.getElementById('batchProject').value; + const phaseId = document.getElementById('batchPhase').value; + const filtered = templates.filter(t => t.phase_id === parseInt(phaseId)); + + const entries = []; + for (const t of filtered) { + const cb = document.getElementById(`btmpl_${t.template_id}`); + if (!cb || !cb.checked) continue; + const startDate = document.getElementById(`btmpl_start_${t.template_id}`)?.value; + const endDate = document.getElementById(`btmpl_end_${t.template_id}`)?.value; + if (!startDate || !endDate) { showToast(`${t.task_name}의 날짜를 입력해주세요.`, 'error'); return; } + entries.push({ task_name: t.task_name, start_date: startDate, end_date: endDate, display_order: t.display_order }); + } + + if (entries.length === 0) { showToast('추가할 항목이 없습니다.', 'error'); return; } + + try { + await api('/schedule/entries/batch', { method: 'POST', body: JSON.stringify({ project_id: projectId, phase_id: phaseId, entries }) }); + showToast(`${entries.length}개 항목이 일괄 추가되었습니다.`); + closeBatchModal(); + await loadGantt(); + } catch (err) { showToast(err.message, 'error'); } +} + +/* ===== Milestone Modal ===== */ +function openMilestoneModal(editId) { + const modal = document.getElementById('milestoneModal'); + const isEdit = !!editId; + document.getElementById('milestoneModalTitle').textContent = isEdit ? '마일스톤 수정' : '마일스톤 추가'; + + populateSelect('milestoneProject', projects, 'project_id', p => `${p.project_code} ${p.project_name}`); + + if (isEdit) { + const m = ganttData.milestones.find(ms => ms.milestone_id === editId); + if (!m) return; + document.getElementById('milestoneId').value = editId; + document.getElementById('milestoneProject').value = m.project_id; + document.getElementById('milestoneName').value = m.milestone_name; + document.getElementById('milestoneDate').value = formatDate(m.milestone_date); + document.getElementById('milestoneType').value = m.milestone_type; + document.getElementById('milestoneStatus').value = m.status; + document.getElementById('milestoneNotes').value = m.notes || ''; + // Load entry options for project + loadMilestoneEntries(m.project_id, m.entry_id); + } else { + document.getElementById('milestoneId').value = ''; + document.getElementById('milestoneName').value = ''; + document.getElementById('milestoneDate').value = ''; + document.getElementById('milestoneType').value = 'deadline'; + document.getElementById('milestoneStatus').value = 'upcoming'; + document.getElementById('milestoneNotes').value = ''; + document.getElementById('milestoneEntry').innerHTML = ''; + } + + // Update entry list on project change + document.getElementById('milestoneProject').onchange = function() { loadMilestoneEntries(this.value); }; + + modal.classList.remove('hidden'); +} + +function loadMilestoneEntries(projectId, selectedEntryId) { + const sel = document.getElementById('milestoneEntry'); + sel.innerHTML = ''; + const projEntries = ganttData.entries.filter(e => e.project_id === parseInt(projectId)); + projEntries.forEach(e => { + const opt = document.createElement('option'); + opt.value = e.entry_id; + opt.textContent = `${e.phase_name} > ${e.task_name}`; + if (selectedEntryId && e.entry_id === selectedEntryId) opt.selected = true; + sel.appendChild(opt); + }); +} + +function closeMilestoneModal() { document.getElementById('milestoneModal').classList.add('hidden'); } + +async function saveMilestone() { + const milestoneId = document.getElementById('milestoneId').value; + const data = { + project_id: document.getElementById('milestoneProject').value, + milestone_name: document.getElementById('milestoneName').value.trim(), + milestone_date: document.getElementById('milestoneDate').value, + milestone_type: document.getElementById('milestoneType').value, + status: document.getElementById('milestoneStatus').value, + entry_id: document.getElementById('milestoneEntry').value || null, + notes: document.getElementById('milestoneNotes').value || null + }; + + if (!data.milestone_name || !data.milestone_date) { showToast('마일스톤명과 날짜를 입력해주세요.', 'error'); return; } + + try { + if (milestoneId) { + await api(`/schedule/milestones/${milestoneId}`, { method: 'PUT', body: JSON.stringify(data) }); + showToast('마일스톤이 수정되었습니다.'); + } else { + await api('/schedule/milestones', { method: 'POST', body: JSON.stringify(data) }); + showToast('마일스톤이 추가되었습니다.'); + } + closeMilestoneModal(); + await loadGantt(); + } catch (err) { showToast(err.message, 'error'); } +} + +/* ===== Utility ===== */ +function populateSelect(selectId, items, valueField, labelFn) { + const sel = document.getElementById(selectId); + const oldVal = sel.value; + sel.innerHTML = items.map(item => ``).join(''); + if (oldVal && sel.querySelector(`option[value="${oldVal}"]`)) sel.value = oldVal; +} + +function loadTemplateOptions(selectId, phaseId) { + const sel = document.getElementById(selectId); + sel.innerHTML = ''; + templates.filter(t => t.phase_id === parseInt(phaseId)).forEach(t => { + sel.innerHTML += ``; + }); +} diff --git a/system1-factory/web/pages/work/meeting-detail.html b/system1-factory/web/pages/work/meeting-detail.html new file mode 100644 index 0000000..2906579 --- /dev/null +++ b/system1-factory/web/pages/work/meeting-detail.html @@ -0,0 +1,199 @@ + + + + + + 회의록 상세 - TK 공장관리 + + + + + + +
+
+
+
+ + +

TK 공장관리

+
+
+ +
-
+ +
+
+
+
+ + + +
+
+ + +
+ +
+ +

새 회의록

+ +
+ + +
+

기본 정보

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+

참석자

+
+ + +
+
+
+ + +
+
+

안건

+ +
+
+
+ +

안건이 없습니다.

+
+
+ + +
+ + + + +
+
+
+
+ + + + + + + + diff --git a/system1-factory/web/pages/work/meetings.html b/system1-factory/web/pages/work/meetings.html new file mode 100644 index 0000000..cb6bf19 --- /dev/null +++ b/system1-factory/web/pages/work/meetings.html @@ -0,0 +1,86 @@ + + + + + + 생산회의록 - TK 공장관리 + + + + + +
+
+
+
+ + +

TK 공장관리

+
+
+ +
-
+ +
+
+
+
+ + + +
+
+ + +
+
+

생산회의록

+

생산회의 기록을 관리합니다

+
+ + +
+
+ + +
+ + +
+ + + + + +
+ +
+
+
+ + + + + diff --git a/system1-factory/web/pages/work/schedule.html b/system1-factory/web/pages/work/schedule.html new file mode 100644 index 0000000..c4cf5ed --- /dev/null +++ b/system1-factory/web/pages/work/schedule.html @@ -0,0 +1,321 @@ + + + + + + 공정표 - TK 공장관리 + + + + + + +
+
+
+
+ + +

TK 공장관리

+
+
+ +
-
+ +
+
+
+
+ + + +
+
+ + +
+
+

공정표

+

프로젝트별 공정 일정을 Gantt 차트로 관리합니다

+
+ + +
+
+ + +
+
+ + + +
+
+ + + +
+
+ + +
+
+ +
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + diff --git a/system1-factory/web/static/js/tkfb-core.js b/system1-factory/web/static/js/tkfb-core.js index 1ef40fd..cd6d0e7 100644 --- a/system1-factory/web/static/js/tkfb-core.js +++ b/system1-factory/web/static/js/tkfb-core.js @@ -112,6 +112,8 @@ const NAV_MENU = [ { href: '/pages/work/report-create.html', icon: 'fa-file-alt', label: '작업보고서 작성', key: 'work.report_create' }, { href: '/pages/work/analysis.html', icon: 'fa-chart-bar', label: '작업 분석', key: 'work.analysis', admin: true }, { href: '/pages/work/nonconformity.html', icon: 'fa-exclamation-triangle', label: '부적합 현황', key: 'work.nonconformity' }, + { href: '/pages/work/schedule.html', icon: 'fa-calendar-alt', label: '공정표', key: 'work.schedule' }, + { href: '/pages/work/meetings.html', icon: 'fa-users', label: '생산회의록', key: 'work.meetings' }, ]}, { cat: '공장 관리', items: [ { href: '/pages/admin/repair-management.html', icon: 'fa-tools', label: '시설설비 관리', key: 'factory.repair_management' }, @@ -153,6 +155,7 @@ const PAGE_KEY_ALIASES = { 'admin.repair_management': 'factory.repair_management', 'attendance.checkin': 'inspection.checkin', 'attendance.work_status': 'inspection.work_status', + 'work.meeting_detail': 'work.meetings', }; function _getCurrentPageKey() {