feat(tkfb): 공정표 + 생산회의록 시스템 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-03-17 08:05:18 +09:00
parent b5dc9c2f20
commit d7cc568c01
15 changed files with 2916 additions and 0 deletions

View File

@@ -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 문서

View File

@@ -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;

View File

@@ -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;

View File

@@ -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')`);
};

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 += `<option value="${p.project_id}">${escapeHtml(p.project_code)} ${escapeHtml(p.project_name)}</option>`;
});
// Populate responsible user select
const respSel = document.getElementById('itemResponsible');
users.forEach(u => {
respSel.innerHTML += `<option value="${u.user_id}">${escapeHtml(u.name)} (${escapeHtml(u.username)})</option>`;
});
// 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 =>
`<div class="user-search-item" onclick="addAttendee(${u.user_id}, '${escapeHtml(u.name)}', '${escapeHtml(u.username)}')">${escapeHtml(u.name)} <span class="text-gray-400">(${escapeHtml(u.username)})</span></div>`
).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 =>
`<span class="attendee-tag">${escapeHtml(a.name)}${editable ? ` <span class="remove-btn" onclick="removeAttendee(${a.user_id})">×</span>` : ''}</span>`
).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 => `
<div class="border rounded-lg p-4">
<div class="flex items-start justify-between gap-2 mb-2">
<div class="flex items-center gap-2 flex-wrap">
<span class="badge ${typeColors[item.item_type] || 'badge-gray'}">${typeLabels[item.item_type] || item.item_type}</span>
<span class="badge ${statusColors[item.status] || 'badge-gray'}">${statusLabels[item.status] || item.status}</span>
${item.project_code ? `<span class="text-xs text-gray-400">${escapeHtml(item.project_code)}</span>` : ''}
${item.milestone_name ? `<span class="text-xs text-purple-500">◆ ${escapeHtml(item.milestone_name)}</span>` : ''}
</div>
<div class="flex items-center gap-1 flex-shrink-0">
${canUpdateStatus && item.status !== 'completed' ? `<select class="text-xs border rounded px-1 py-0.5" onchange="updateItemStatus(${item.item_id}, this.value)">
<option value="">상태변경</option>
<option value="in_progress">진행중</option>
<option value="completed">완료</option>
</select>` : ''}
${editable ? `<button onclick="openItemModal(${item.item_id})" class="text-gray-400 hover:text-orange-600 text-xs px-1"><i class="fas fa-edit"></i></button>
<button onclick="deleteItem(${item.item_id})" class="text-gray-400 hover:text-red-600 text-xs px-1"><i class="fas fa-trash"></i></button>` : ''}
</div>
</div>
<p class="text-sm text-gray-800 mb-1">${escapeHtml(item.content)}</p>
${item.decision ? `<p class="text-sm text-green-700 bg-green-50 rounded p-2 mb-1"><strong>결정:</strong> ${escapeHtml(item.decision)}</p>` : ''}
${item.action_required ? `<p class="text-sm text-amber-700 bg-amber-50 rounded p-2 mb-1"><strong>조치:</strong> ${escapeHtml(item.action_required)}</p>` : ''}
<div class="flex items-center gap-4 text-xs text-gray-400 mt-2">
${item.responsible_name ? `<span><i class="fas fa-user mr-1"></i>${escapeHtml(item.responsible_name)}</span>` : ''}
${item.due_date ? `<span class="${new Date(item.due_date) < new Date() && item.status !== 'completed' ? 'text-red-500 font-semibold' : ''}"><i class="fas fa-clock mr-1"></i>${formatDate(item.due_date)}</span>` : ''}
${item.milestone_name ? `<a href="/pages/work/schedule.html?highlight=${item.milestone_id}" class="text-purple-500 hover:text-purple-700"><i class="fas fa-calendar-alt mr-1"></i>공정표 보기</a>` : ''}
</div>
</div>
`).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 = '<option value="">선택안함</option>';
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 = '<option value="">선택안함</option>';
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'); }
}

View File

@@ -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'
? '<span class="badge badge-green">발행</span>'
: '<span class="badge badge-gray">초안</span>';
return `
<a href="/pages/work/meeting-detail.html?id=${m.meeting_id}" class="block bg-white rounded-xl shadow-sm p-4 hover:shadow-md transition-shadow">
<div class="flex items-start justify-between gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="text-sm text-gray-500">${formatDate(m.meeting_date)}</span>
${statusBadge}
</div>
<h3 class="font-semibold text-gray-800 truncate">${escapeHtml(m.title)}</h3>
<div class="flex items-center gap-4 mt-2 text-xs text-gray-500">
<span><i class="fas fa-user mr-1"></i>${escapeHtml(m.created_by_name || '-')}</span>
<span><i class="fas fa-users mr-1"></i>참석 ${m.attendee_count || 0}명</span>
<span><i class="fas fa-list mr-1"></i>안건 ${m.agenda_count || 0}건</span>
${m.open_action_count > 0 ? `<span class="text-amber-600 font-semibold"><i class="fas fa-exclamation-circle mr-1"></i>미완료 ${m.open_action_count}건</span>` : ''}
</div>
</div>
<i class="fas fa-chevron-right text-gray-300 mt-2"></i>
</div>
</a>
`;
}).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 => `
<div class="flex items-center gap-2 p-1.5 bg-white rounded">
<span class="text-amber-600"><i class="fas fa-circle text-[6px]"></i></span>
<span class="flex-1 truncate">${escapeHtml(item.content)}</span>
${item.responsible_name ? `<span class="text-gray-400 text-xs">${escapeHtml(item.responsible_name)}</span>` : ''}
${item.due_date ? `<span class="text-xs ${new Date(item.due_date) < new Date() ? 'text-red-500 font-semibold' : 'text-gray-400'}">${formatDate(item.due_date)}</span>` : ''}
</div>
`).join('') + (items.length > 5 ? `<div class="text-xs text-gray-400 text-center mt-1">외 ${items.length - 5}건</div>` : '');
} catch {}
}

View File

@@ -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 = '<div class="gantt-month-header"><div class="gantt-label"><div class="label-content font-semibold text-sm text-gray-600">프로젝트 / 단계 / 작업</div></div><div class="gantt-timeline">';
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 += `<div class="gantt-day month-label" style="flex: 0 0 ${monthWidth}px;">${monthNames[m]}</div>`;
}
headerHtml += '</div></div>';
// Virtual scroll container
const totalHeight = allRows.length * ROW_HEIGHT;
let rowsHtml = `<div style="height:${totalHeight}px;position:relative;" id="ganttVirtualBody"></div>`;
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 `<div class="gantt-row project-row" style="${style}">
<div class="gantt-label"><div class="label-content collapse-toggle ${arrowClass}" onclick="toggleProject('${row.code}')">
<span class="arrow">▼</span>${escapeHtml(row.label)}
</div></div>
<div class="gantt-timeline" style="width:${totalDays * dayWidth}px;position:relative;"></div>
</div>`;
}
if (row.type === 'phase') {
return `<div class="gantt-row phase-row" style="${style}">
<div class="gantt-label"><div class="label-content"><span style="display:inline-block;width:10px;height:10px;border-radius:2px;background:${row.color};margin-right:6px;"></span>${escapeHtml(row.label)}</div></div>
<div class="gantt-timeline" style="width:${totalDays * dayWidth}px;"></div>
</div>`;
}
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 ? `<div class="gantt-bar" style="left:${bar.left}px;width:${bar.width}px;background:${row.color};opacity:${opacity};"
onclick="showBarDetail(${e.entry_id})" title="${escapeHtml(e.task_name)}&#10;${formatDate(e.start_date)}~${formatDate(e.end_date)}&#10;진행률: ${e.progress}%">
<div class="gantt-bar-progress" style="width:${e.progress}%;background:#fff;"></div>
${bar.width > 50 ? `<div class="gantt-bar-label">${escapeHtml(e.task_name)}</div>` : ''}
</div>` : '';
return `<div class="gantt-row task-row" style="${style}">
<div class="gantt-label"><div class="label-content" title="${escapeHtml(e.task_name)}">${escapeHtml(e.task_name)}${e.assignee ? ` <span class="text-gray-400 text-xs">(${escapeHtml(e.assignee)})</span>` : ''}</div></div>
<div class="gantt-timeline" style="width:${totalDays * dayWidth}px;position:relative;">${barHtml}</div>
</div>`;
}
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 += `<div class="milestone-marker" style="left:${pos - 7}px;background:${mColor};" title="${escapeHtml(m.milestone_name)}&#10;${formatDate(m.milestone_date)}" onclick="showMilestoneDetail(${m.milestone_id})"></div>`;
}
}
return `<div class="gantt-row milestone-row" style="${style}">
<div class="gantt-label"><div class="label-content">${escapeHtml(row.label)}</div></div>
<div class="gantt-timeline" style="width:${totalDays * dayWidth}px;position:relative;">${markers}</div>
</div>`;
}
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 += `<div class="nc-badge" style="left:${pos - 10}px;" onclick="showNcPopup(${row.project_id})">${cnt}</div>`;
}
}
return `<div class="gantt-row nc-row" style="${style}">
<div class="gantt-label"><div class="label-content" style="cursor:pointer;" onclick="showNcPopup(${row.project_id})">${escapeHtml(row.label)}</div></div>
<div class="gantt-timeline" style="width:${totalDays * dayWidth}px;position:relative;">${badges}</div>
</div>`;
}
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 = `
<div class="space-y-2 text-sm">
<div class="flex justify-between"><span class="text-gray-500">프로젝트</span><span>${escapeHtml(entry.project_code)} ${escapeHtml(entry.project_name)}</span></div>
<div class="flex justify-between"><span class="text-gray-500">공정 단계</span><span>${escapeHtml(entry.phase_name)}</span></div>
<div class="flex justify-between"><span class="text-gray-500">기간</span><span>${formatDate(entry.start_date)} ~ ${formatDate(entry.end_date)}</span></div>
<div class="flex justify-between"><span class="text-gray-500">진행률</span><span>${entry.progress}%</span></div>
<div class="flex justify-between"><span class="text-gray-500">상태</span><span>${statusLabels[entry.status] || entry.status}</span></div>
${entry.assignee ? `<div class="flex justify-between"><span class="text-gray-500">담당자</span><span>${escapeHtml(entry.assignee)}</span></div>` : ''}
${entry.notes ? `<div><span class="text-gray-500">메모:</span> ${escapeHtml(entry.notes)}</div>` : ''}
</div>
`;
let actions = '';
if (canEdit) {
actions = `<button onclick="document.getElementById('barDetailPopup').classList.add('hidden');openEntryModal(${entryId})" class="px-3 py-1.5 text-sm bg-orange-600 text-white rounded-lg hover:bg-orange-700">수정</button>`;
}
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 = `
<div class="space-y-2 text-sm">
<div class="flex justify-between"><span class="text-gray-500">프로젝트</span><span>${escapeHtml(m.project_code)} ${escapeHtml(m.project_name)}</span></div>
<div class="flex justify-between"><span class="text-gray-500">날짜</span><span>${formatDate(m.milestone_date)}</span></div>
<div class="flex justify-between"><span class="text-gray-500">유형</span><span>${typeLabels[m.milestone_type] || m.milestone_type}</span></div>
<div class="flex justify-between"><span class="text-gray-500">상태</span><span>${statusLabels[m.status] || m.status}</span></div>
${m.notes ? `<div><span class="text-gray-500">메모:</span> ${escapeHtml(m.notes)}</div>` : ''}
</div>
`;
let actions = '';
if (canEdit) {
actions = `<button onclick="document.getElementById('barDetailPopup').classList.add('hidden');openMilestoneModal(${milestoneId})" class="px-3 py-1.5 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700">수정</button>`;
}
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 = '<p class="text-gray-500 text-sm">부적합 내역이 없습니다.</p>';
} else {
html = '<table class="data-table"><thead><tr><th>일자</th><th>분류</th><th>내용</th><th>상태</th></tr></thead><tbody>';
for (const nc of list) {
html += `<tr>
<td>${formatDate(nc.report_date)}</td>
<td>${escapeHtml(nc.category || '-')}</td>
<td class="max-w-[200px] truncate">${escapeHtml(nc.description || '-')}</td>
<td><span class="badge ${nc.review_status === 'completed' ? 'badge-green' : 'badge-amber'}">${statusLabels[nc.review_status] || nc.review_status}</span></td>
</tr>`;
}
html += '</tbody></table>';
}
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 = '<p class="text-gray-500 text-sm">해당 단계에 템플릿이 없습니다.</p>'; return; }
list.innerHTML = filtered.map((t, i) => `
<div class="flex items-center gap-3 p-2 bg-gray-50 rounded-lg">
<input type="checkbox" id="btmpl_${t.template_id}" checked class="w-4 h-4">
<span class="flex-1 text-sm">${escapeHtml(t.task_name)}</span>
<span class="text-xs text-gray-400">${t.default_duration_days}일</span>
<input type="date" id="btmpl_start_${t.template_id}" class="input-field rounded px-2 py-1 text-xs w-32">
<span class="text-xs text-gray-400">~</span>
<input type="date" id="btmpl_end_${t.template_id}" class="input-field rounded px-2 py-1 text-xs w-32">
</div>
`).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 = '<option value="">없음</option>';
}
// 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 = '<option value="">없음</option>';
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 => `<option value="${item[valueField]}">${escapeHtml(labelFn(item))}</option>`).join('');
if (oldVal && sel.querySelector(`option[value="${oldVal}"]`)) sel.value = oldVal;
}
function loadTemplateOptions(selectId, phaseId) {
const sel = document.getElementById(selectId);
sel.innerHTML = '<option value="">직접 입력</option>';
templates.filter(t => t.phase_id === parseInt(phaseId)).forEach(t => {
sel.innerHTML += `<option value="${t.template_id}">${escapeHtml(t.task_name)} (${t.default_duration_days}일)</option>`;
});
}

View File

@@ -0,0 +1,199 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>회의록 상세 - TK 공장관리</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026031701">
<style>
.attendee-tag { display: inline-flex; align-items: center; gap: 4px; background: #eff6ff; color: #2563eb; padding: 2px 8px; border-radius: 9999px; font-size: 0.75rem; }
.attendee-tag .remove-btn { cursor: pointer; color: #93c5fd; }
.attendee-tag .remove-btn:hover { color: #dc2626; }
.user-search-results { position: absolute; top: 100%; left: 0; right: 0; background: white; border: 1px solid #e2e8f0; border-radius: 0.5rem; max-height: 200px; overflow-y: auto; z-index: 50; box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
.user-search-item { padding: 0.5rem 0.75rem; cursor: pointer; font-size: 0.875rem; }
.user-search-item:hover { background: #f1f5f9; }
</style>
</head>
<body class="bg-gray-50">
<header class="bg-orange-700 text-white sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-14">
<div class="flex items-center gap-3">
<button id="mobileMenuBtn" class="lg:hidden text-orange-200 hover:text-white">
<i class="fas fa-bars text-xl"></i>
</button>
<i class="fas fa-industry text-xl text-orange-200"></i>
<h1 class="text-lg font-semibold">TK 공장관리</h1>
</div>
<div class="flex items-center gap-4">
<span id="headerUserName" class="text-sm hidden sm:block">-</span>
<div id="headerUserAvatar" class="w-8 h-8 bg-orange-600 rounded-full flex items-center justify-center text-sm font-bold">-</div>
<button onclick="doLogout()" class="text-orange-200 hover:text-white" title="로그아웃">
<i class="fas fa-sign-out-alt"></i>
</button>
</div>
</div>
</div>
</header>
<div id="mobileOverlay" class="hidden fixed inset-0 bg-black/50 z-30 lg:hidden"></div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 fade-in">
<div class="flex gap-6">
<nav id="sideNav" class="hidden lg:flex flex-col gap-1 w-52 flex-shrink-0 pt-2 fixed lg:static z-40 bg-white lg:bg-transparent p-4 lg:p-0 rounded-lg lg:rounded-none shadow-lg lg:shadow-none top-14 left-0 bottom-0 overflow-y-auto"></nav>
<div class="flex-1 min-w-0">
<!-- 상단 -->
<div class="flex items-center gap-3 mb-5">
<a href="/pages/work/meetings.html" class="text-gray-400 hover:text-gray-600"><i class="fas fa-arrow-left"></i></a>
<h2 id="pageTitle" class="text-xl font-bold text-gray-800">새 회의록</h2>
<span id="statusBadge" class="badge badge-gray hidden">초안</span>
</div>
<!-- 기본 정보 -->
<div class="bg-white rounded-xl shadow-sm p-5 mb-4">
<h3 class="font-semibold text-gray-800 mb-3">기본 정보</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">날짜 *</label>
<input type="date" id="meetingDate" class="input-field w-full rounded-lg px-3 py-2 text-sm" required>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">시간</label>
<input type="time" id="meetingTime" class="input-field w-full rounded-lg px-3 py-2 text-sm">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">장소</label>
<input type="text" id="meetingLocation" class="input-field w-full rounded-lg px-3 py-2 text-sm" placeholder="회의 장소">
</div>
<div class="sm:col-span-2 lg:col-span-3">
<label class="block text-sm font-medium text-gray-700 mb-1">제목 *</label>
<input type="text" id="meetingTitle" class="input-field w-full rounded-lg px-3 py-2 text-sm" placeholder="회의 제목" required>
</div>
<div class="sm:col-span-2 lg:col-span-3">
<label class="block text-sm font-medium text-gray-700 mb-1">요약</label>
<textarea id="meetingSummary" class="input-field w-full rounded-lg px-3 py-2 text-sm" rows="3" placeholder="회의 요약"></textarea>
</div>
</div>
</div>
<!-- 참석자 -->
<div class="bg-white rounded-xl shadow-sm p-5 mb-4">
<h3 class="font-semibold text-gray-800 mb-3">참석자</h3>
<div class="relative mb-3">
<input type="text" id="attendeeSearch" class="input-field w-full rounded-lg px-3 py-2 text-sm" placeholder="이름 또는 아이디로 검색" autocomplete="off">
<div id="attendeeResults" class="user-search-results hidden"></div>
</div>
<div id="attendeeTags" class="flex flex-wrap gap-2"></div>
</div>
<!-- 안건 목록 -->
<div class="bg-white rounded-xl shadow-sm p-5 mb-4">
<div class="flex items-center justify-between mb-3">
<h3 class="font-semibold text-gray-800">안건</h3>
<button id="btnAddItem" class="hidden text-sm text-orange-600 hover:text-orange-700 font-medium" onclick="openItemModal()">
<i class="fas fa-plus mr-1"></i>안건 추가
</button>
</div>
<div id="agendaList" class="space-y-3"></div>
<div id="agendaEmpty" class="text-center py-6 text-gray-400 text-sm">
<i class="fas fa-clipboard-list text-2xl mb-2"></i>
<p>안건이 없습니다.</p>
</div>
</div>
<!-- 하단 버튼 -->
<div class="flex flex-wrap gap-2 justify-end mb-8" id="bottomActions">
<button id="btnSave" class="hidden bg-orange-600 text-white px-5 py-2 rounded-lg text-sm hover:bg-orange-700" onclick="saveMeeting()">
<i class="fas fa-save mr-1"></i>저장
</button>
<button id="btnPublish" class="hidden bg-green-600 text-white px-5 py-2 rounded-lg text-sm hover:bg-green-700" onclick="publishMeeting()">
<i class="fas fa-paper-plane mr-1"></i>발행
</button>
<button id="btnUnpublish" class="hidden bg-gray-500 text-white px-5 py-2 rounded-lg text-sm hover:bg-gray-600" onclick="unpublishMeeting()">
<i class="fas fa-undo mr-1"></i>발행 취소
</button>
<button id="btnDelete" class="hidden bg-red-500 text-white px-5 py-2 rounded-lg text-sm hover:bg-red-600" onclick="deleteMeeting()">
<i class="fas fa-trash mr-1"></i>삭제
</button>
</div>
</div>
</div>
</div>
<!-- 안건 모달 -->
<div id="itemModal" class="modal-overlay hidden">
<div class="modal-content p-6">
<div class="flex justify-between items-center mb-4">
<h3 id="itemModalTitle" class="text-lg font-bold">안건 추가</h3>
<button onclick="closeItemModal()" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times text-xl"></i></button>
</div>
<input type="hidden" id="itemId">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">유형</label>
<select id="itemType" class="input-field w-full rounded-lg px-3 py-2 text-sm">
<option value="schedule_update">공정현황</option>
<option value="issue">이슈</option>
<option value="decision">결정사항</option>
<option value="action_item">조치사항</option>
<option value="other">기타</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">관련 프로젝트</label>
<select id="itemProject" class="input-field w-full rounded-lg px-3 py-2 text-sm" onchange="loadItemMilestones()">
<option value="">선택안함</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">관련 마일스톤</label>
<select id="itemMilestone" class="input-field w-full rounded-lg px-3 py-2 text-sm">
<option value="">선택안함</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">상태</label>
<select id="itemStatus" class="input-field w-full rounded-lg px-3 py-2 text-sm">
<option value="open">미처리</option>
<option value="in_progress">진행중</option>
<option value="completed">완료</option>
<option value="cancelled">취소</option>
</select>
</div>
<div class="sm:col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-1">내용 *</label>
<textarea id="itemContent" class="input-field w-full rounded-lg px-3 py-2 text-sm" rows="3" required></textarea>
</div>
<div class="sm:col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-1">결정사항</label>
<textarea id="itemDecision" class="input-field w-full rounded-lg px-3 py-2 text-sm" rows="2"></textarea>
</div>
<div class="sm:col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-1">필요조치</label>
<textarea id="itemAction" class="input-field w-full rounded-lg px-3 py-2 text-sm" rows="2"></textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">담당자</label>
<select id="itemResponsible" class="input-field w-full rounded-lg px-3 py-2 text-sm">
<option value="">선택안함</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">기한</label>
<input type="date" id="itemDueDate" class="input-field w-full rounded-lg px-3 py-2 text-sm">
</div>
</div>
<div class="flex justify-end gap-2 mt-4">
<button type="button" onclick="closeItemModal()" class="px-4 py-2 text-sm border rounded-lg hover:bg-gray-50">취소</button>
<button type="button" onclick="saveItem()" class="px-4 py-2 text-sm bg-orange-600 text-white rounded-lg hover:bg-orange-700">저장</button>
</div>
</div>
</div>
<script src="/static/js/tkfb-core.js?v=2026031701"></script>
<script src="/js/meeting-detail.js?v=2026031701"></script>
</body>
</html>

View File

@@ -0,0 +1,86 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>생산회의록 - TK 공장관리</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026031701">
</head>
<body class="bg-gray-50">
<header class="bg-orange-700 text-white sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-14">
<div class="flex items-center gap-3">
<button id="mobileMenuBtn" class="lg:hidden text-orange-200 hover:text-white">
<i class="fas fa-bars text-xl"></i>
</button>
<i class="fas fa-industry text-xl text-orange-200"></i>
<h1 class="text-lg font-semibold">TK 공장관리</h1>
</div>
<div class="flex items-center gap-4">
<span id="headerUserName" class="text-sm hidden sm:block">-</span>
<div id="headerUserAvatar" class="w-8 h-8 bg-orange-600 rounded-full flex items-center justify-center text-sm font-bold">-</div>
<button onclick="doLogout()" class="text-orange-200 hover:text-white" title="로그아웃">
<i class="fas fa-sign-out-alt"></i>
</button>
</div>
</div>
</div>
</header>
<div id="mobileOverlay" class="hidden fixed inset-0 bg-black/50 z-30 lg:hidden"></div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 fade-in">
<div class="flex gap-6">
<nav id="sideNav" class="hidden lg:flex flex-col gap-1 w-52 flex-shrink-0 pt-2 fixed lg:static z-40 bg-white lg:bg-transparent p-4 lg:p-0 rounded-lg lg:rounded-none shadow-lg lg:shadow-none top-14 left-0 bottom-0 overflow-y-auto"></nav>
<div class="flex-1 min-w-0">
<div class="mb-5">
<h2 class="text-xl font-bold text-gray-800">생산회의록</h2>
<p class="text-sm text-gray-500 mt-0.5">생산회의 기록을 관리합니다</p>
</div>
<!-- 필터 + 버튼 -->
<div class="bg-white rounded-xl shadow-sm p-4 mb-4 flex flex-wrap items-center gap-3">
<div class="flex items-center gap-2">
<select id="yearFilter" class="input-field rounded-lg px-3 py-1.5 text-sm w-24"></select>
<select id="monthFilter" class="input-field rounded-lg px-3 py-1.5 text-sm w-20">
<option value="">전체</option>
<option value="1">1월</option><option value="2">2월</option><option value="3">3월</option>
<option value="4">4월</option><option value="5">5월</option><option value="6">6월</option>
<option value="7">7월</option><option value="8">8월</option><option value="9">9월</option>
<option value="10">10월</option><option value="11">11월</option><option value="12">12월</option>
</select>
</div>
<input type="text" id="searchInput" class="input-field rounded-lg px-3 py-1.5 text-sm w-48" placeholder="제목/내용 검색">
<button id="btnNewMeeting" class="hidden ml-auto bg-orange-600 text-white px-4 py-1.5 rounded-lg text-sm hover:bg-orange-700">
<i class="fas fa-plus mr-1"></i>새 회의록
</button>
</div>
<!-- 미완료 조치사항 요약 -->
<div id="actionSummary" class="hidden bg-amber-50 border border-amber-200 rounded-xl p-4 mb-4">
<div class="flex items-center gap-2 mb-2">
<i class="fas fa-exclamation-triangle text-amber-600"></i>
<span class="font-semibold text-amber-800 text-sm">미완료 조치사항</span>
<span id="actionCount" class="badge badge-amber">0</span>
</div>
<div id="actionList" class="space-y-1 text-sm max-h-40 overflow-y-auto"></div>
</div>
<!-- 회의록 목록 -->
<div id="meetingList" class="space-y-3"></div>
<div id="emptyState" class="hidden text-center py-12 text-gray-400">
<i class="fas fa-clipboard text-4xl mb-3"></i>
<p>회의록이 없습니다.</p>
</div>
</div>
</div>
</div>
<script src="/static/js/tkfb-core.js?v=2026031701"></script>
<script src="/js/meetings.js?v=2026031701"></script>
</body>
</html>

View File

@@ -0,0 +1,321 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>공정표 - TK 공장관리</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026031701">
<style>
/* Gantt container */
.gantt-wrapper { position: relative; overflow: auto; border: 1px solid #e2e8f0; border-radius: 0.5rem; background: #fff; }
.gantt-container { position: relative; min-width: 100%; }
/* Left column sticky */
.gantt-label { position: sticky; left: 0; z-index: 20; background: #fff; border-right: 2px solid #e2e8f0; min-width: 250px; max-width: 250px; }
.gantt-header .gantt-label { background: #f1f5f9; z-index: 25; }
/* Row styles */
.gantt-row { display: flex; border-bottom: 1px solid #f1f5f9; min-height: 32px; align-items: stretch; }
.gantt-row:hover { background: #fafbfc; }
.gantt-row.project-row { background: #f8fafc; font-weight: 600; }
.gantt-row.project-row .gantt-label { background: #f8fafc; }
.gantt-row.phase-row .gantt-label { padding-left: 1.25rem; color: #6b7280; font-size: 0.8rem; }
.gantt-row.task-row .gantt-label { padding-left: 2.25rem; font-size: 0.8rem; }
.gantt-row.milestone-row .gantt-label { padding-left: 1.25rem; font-size: 0.8rem; color: #7c3aed; }
.gantt-row.nc-row .gantt-label { padding-left: 1.25rem; font-size: 0.8rem; color: #dc2626; }
/* Header */
.gantt-header { display: flex; border-bottom: 2px solid #e2e8f0; background: #f1f5f9; position: sticky; top: 0; z-index: 22; }
.gantt-month-header { display: flex; border-bottom: 1px solid #e2e8f0; background: #f8fafc; position: sticky; top: 0; z-index: 22; }
/* Timeline cells */
.gantt-timeline { display: flex; flex: 1; position: relative; }
.gantt-day { flex: 0 0 var(--day-width); border-right: 1px solid #f1f5f9; display: flex; align-items: center; justify-content: center; font-size: 0.65rem; color: #9ca3af; }
.gantt-day.weekend { background: #fafafa; }
.gantt-day.month-label { font-weight: 600; color: #475569; font-size: 0.75rem; justify-content: flex-start; padding-left: 4px; border-right: 1px solid #cbd5e1; }
/* Bars */
.gantt-bar { position: absolute; height: 20px; top: 6px; border-radius: 3px; cursor: pointer; transition: opacity 0.15s; min-width: 4px; z-index: 5; }
.gantt-bar:hover { opacity: 0.85; filter: brightness(1.1); }
.gantt-bar-progress { height: 100%; border-radius: 3px; opacity: 0.4; }
.gantt-bar-label { position: absolute; left: 4px; top: 1px; font-size: 0.65rem; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: calc(100% - 8px); line-height: 18px; }
/* Today marker */
.today-marker { position: absolute; top: 0; bottom: 0; width: 2px; background: #ef4444; z-index: 10; pointer-events: none; }
/* Milestone diamond */
.milestone-marker { position: absolute; top: 6px; width: 14px; height: 14px; background: #7c3aed; transform: rotate(45deg); z-index: 5; cursor: pointer; border: 1px solid #6d28d9; }
.milestone-marker:hover { filter: brightness(1.2); }
/* NC badge */
.nc-badge { display: inline-flex; align-items: center; justify-content: center; background: #fef2f2; color: #dc2626; border-radius: 9999px; padding: 0 0.5rem; font-size: 0.7rem; font-weight: 600; height: 20px; cursor: pointer; position: absolute; top: 6px; z-index: 5; }
/* Collapse toggle */
.collapse-toggle { cursor: pointer; user-select: none; }
.collapse-toggle .arrow { display: inline-block; transition: transform 0.2s; font-size: 0.6rem; margin-right: 4px; }
.collapse-toggle.collapsed .arrow { transform: rotate(-90deg); }
/* Zoom controls */
.zoom-btn.active { background: #ea580c; color: #fff; }
/* Label content */
.label-content { display: flex; align-items: center; height: 100%; padding: 0 0.75rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
</style>
</head>
<body class="bg-gray-50">
<header class="bg-orange-700 text-white sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-14">
<div class="flex items-center gap-3">
<button id="mobileMenuBtn" class="lg:hidden text-orange-200 hover:text-white">
<i class="fas fa-bars text-xl"></i>
</button>
<i class="fas fa-industry text-xl text-orange-200"></i>
<h1 class="text-lg font-semibold">TK 공장관리</h1>
</div>
<div class="flex items-center gap-4">
<span id="headerUserName" class="text-sm hidden sm:block">-</span>
<div id="headerUserAvatar" class="w-8 h-8 bg-orange-600 rounded-full flex items-center justify-center text-sm font-bold">-</div>
<button onclick="doLogout()" class="text-orange-200 hover:text-white" title="로그아웃">
<i class="fas fa-sign-out-alt"></i>
</button>
</div>
</div>
</div>
</header>
<div id="mobileOverlay" class="hidden fixed inset-0 bg-black/50 z-30 lg:hidden"></div>
<div class="max-w-full mx-auto px-4 sm:px-6 lg:px-8 py-4 fade-in">
<div class="flex gap-6">
<nav id="sideNav" class="hidden lg:flex flex-col gap-1 w-52 flex-shrink-0 pt-2 fixed lg:static z-40 bg-white lg:bg-transparent p-4 lg:p-0 rounded-lg lg:rounded-none shadow-lg lg:shadow-none top-14 left-0 bottom-0 overflow-y-auto"></nav>
<div class="flex-1 min-w-0">
<div class="mb-4">
<h2 class="text-xl font-bold text-gray-800">공정표</h2>
<p class="text-sm text-gray-500 mt-0.5">프로젝트별 공정 일정을 Gantt 차트로 관리합니다</p>
</div>
<!-- 툴바 -->
<div class="bg-white rounded-xl shadow-sm p-4 mb-4 flex flex-wrap items-center gap-3">
<div class="flex items-center gap-2">
<label class="text-sm font-medium text-gray-600">연도:</label>
<select id="yearSelect" class="input-field rounded-lg px-3 py-1.5 text-sm w-24"></select>
</div>
<div class="flex items-center gap-1">
<button class="zoom-btn px-3 py-1.5 rounded-lg text-sm border" data-zoom="month">월간</button>
<button class="zoom-btn px-3 py-1.5 rounded-lg text-sm border active" data-zoom="quarter">분기</button>
<button class="zoom-btn px-3 py-1.5 rounded-lg text-sm border" data-zoom="year">연간</button>
</div>
<div class="flex items-center gap-2 ml-auto">
<button id="btnAddEntry" class="hidden bg-orange-600 text-white px-3 py-1.5 rounded-lg text-sm hover:bg-orange-700">
<i class="fas fa-plus mr-1"></i>항목 추가
</button>
<button id="btnBatchAdd" class="hidden bg-blue-600 text-white px-3 py-1.5 rounded-lg text-sm hover:bg-blue-700">
<i class="fas fa-layer-group mr-1"></i>일괄 생성
</button>
<button id="btnAddMilestone" class="hidden bg-purple-600 text-white px-3 py-1.5 rounded-lg text-sm hover:bg-purple-700">
<i class="fas fa-diamond mr-1"></i>마일스톤
</button>
</div>
</div>
<!-- Gantt Chart -->
<div class="gantt-wrapper" id="ganttWrapper" style="max-height: calc(100vh - 220px);">
<div class="gantt-container" id="ganttContainer">
<!-- Rendered by JS -->
</div>
</div>
</div>
</div>
</div>
<!-- 항목 추가/수정 모달 -->
<div id="entryModal" class="modal-overlay hidden">
<div class="modal-content p-6">
<div class="flex justify-between items-center mb-4">
<h3 id="entryModalTitle" class="text-lg font-bold">공정표 항목 추가</h3>
<button onclick="closeEntryModal()" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times text-xl"></i></button>
</div>
<form id="entryForm" onsubmit="return false;">
<input type="hidden" id="entryId">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">프로젝트 *</label>
<select id="entryProject" class="input-field w-full rounded-lg px-3 py-2 text-sm" required></select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">공정 단계 *</label>
<select id="entryPhase" class="input-field w-full rounded-lg px-3 py-2 text-sm" required></select>
</div>
<div class="sm:col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-1">작업명 *</label>
<div class="flex gap-2">
<select id="entryTemplate" class="input-field flex-1 rounded-lg px-3 py-2 text-sm">
<option value="">직접 입력</option>
</select>
<input type="text" id="entryTaskName" class="input-field flex-1 rounded-lg px-3 py-2 text-sm" placeholder="작업명 입력">
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">시작일 *</label>
<input type="date" id="entryStartDate" class="input-field w-full rounded-lg px-3 py-2 text-sm" required>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">종료일 *</label>
<input type="date" id="entryEndDate" class="input-field w-full rounded-lg px-3 py-2 text-sm" required>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">담당자</label>
<input type="text" id="entryAssignee" class="input-field w-full rounded-lg px-3 py-2 text-sm" placeholder="담당자">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">진행률 (%)</label>
<input type="number" id="entryProgress" class="input-field w-full rounded-lg px-3 py-2 text-sm" min="0" max="100" value="0">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">상태</label>
<select id="entryStatus" class="input-field w-full rounded-lg px-3 py-2 text-sm">
<option value="planned">계획</option>
<option value="in_progress">진행중</option>
<option value="completed">완료</option>
<option value="delayed">지연</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">선행작업 (의존관계)</label>
<select id="entryDependencies" class="input-field w-full rounded-lg px-3 py-2 text-sm" multiple style="min-height: 60px;"></select>
</div>
<div class="sm:col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-1">메모</label>
<textarea id="entryNotes" class="input-field w-full rounded-lg px-3 py-2 text-sm" rows="2"></textarea>
</div>
</div>
<div class="flex justify-end gap-2 mt-4">
<button type="button" onclick="closeEntryModal()" class="px-4 py-2 text-sm border rounded-lg hover:bg-gray-50">취소</button>
<button type="button" onclick="saveEntry()" class="px-4 py-2 text-sm bg-orange-600 text-white rounded-lg hover:bg-orange-700">저장</button>
</div>
</form>
</div>
</div>
<!-- 일괄 생성 모달 -->
<div id="batchModal" class="modal-overlay hidden">
<div class="modal-content p-6">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-bold">템플릿 기반 일괄 생성</h3>
<button onclick="closeBatchModal()" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times text-xl"></i></button>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">프로젝트 *</label>
<select id="batchProject" class="input-field w-full rounded-lg px-3 py-2 text-sm"></select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">공정 단계 *</label>
<select id="batchPhase" class="input-field w-full rounded-lg px-3 py-2 text-sm" onchange="loadBatchTemplates()"></select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">기준 시작일 *</label>
<input type="date" id="batchStartDate" class="input-field w-full rounded-lg px-3 py-2 text-sm" onchange="recalcBatchDates()">
</div>
</div>
<div id="batchTemplateList" class="space-y-2 mb-4 max-h-60 overflow-y-auto">
<!-- 템플릿 목록 동적 생성 -->
</div>
<div class="flex justify-end gap-2">
<button type="button" onclick="closeBatchModal()" class="px-4 py-2 text-sm border rounded-lg hover:bg-gray-50">취소</button>
<button type="button" onclick="saveBatchEntries()" class="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700">일괄 생성</button>
</div>
</div>
</div>
<!-- 마일스톤 모달 -->
<div id="milestoneModal" class="modal-overlay hidden">
<div class="modal-content p-6">
<div class="flex justify-between items-center mb-4">
<h3 id="milestoneModalTitle" class="text-lg font-bold">마일스톤 추가</h3>
<button onclick="closeMilestoneModal()" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times text-xl"></i></button>
</div>
<form id="milestoneForm" onsubmit="return false;">
<input type="hidden" id="milestoneId">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">프로젝트 *</label>
<select id="milestoneProject" class="input-field w-full rounded-lg px-3 py-2 text-sm" required></select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">마일스톤명 *</label>
<input type="text" id="milestoneName" class="input-field w-full rounded-lg px-3 py-2 text-sm" required>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">날짜 *</label>
<input type="date" id="milestoneDate" class="input-field w-full rounded-lg px-3 py-2 text-sm" required>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">유형</label>
<select id="milestoneType" class="input-field w-full rounded-lg px-3 py-2 text-sm">
<option value="deadline">납기</option>
<option value="review">검토</option>
<option value="inspection">검사</option>
<option value="delivery">출하</option>
<option value="meeting">회의</option>
<option value="other">기타</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">상태</label>
<select id="milestoneStatus" class="input-field w-full rounded-lg px-3 py-2 text-sm">
<option value="upcoming">예정</option>
<option value="completed">완료</option>
<option value="missed">미달성</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">연결 작업</label>
<select id="milestoneEntry" class="input-field w-full rounded-lg px-3 py-2 text-sm">
<option value="">없음</option>
</select>
</div>
<div class="sm:col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-1">메모</label>
<textarea id="milestoneNotes" class="input-field w-full rounded-lg px-3 py-2 text-sm" rows="2"></textarea>
</div>
</div>
<div class="flex justify-end gap-2 mt-4">
<button type="button" onclick="closeMilestoneModal()" class="px-4 py-2 text-sm border rounded-lg hover:bg-gray-50">취소</button>
<button type="button" onclick="saveMilestone()" class="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700">저장</button>
</div>
</form>
</div>
</div>
<!-- 부적합 팝업 -->
<div id="ncPopup" class="modal-overlay hidden">
<div class="modal-content p-6" style="max-width: 600px;">
<div class="flex justify-between items-center mb-4">
<h3 id="ncPopupTitle" class="text-lg font-bold">부적합 현황</h3>
<button onclick="document.getElementById('ncPopup').classList.add('hidden')" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times text-xl"></i></button>
</div>
<div id="ncPopupContent" class="space-y-2 max-h-80 overflow-y-auto"></div>
</div>
</div>
<!-- 바 상세 팝업 -->
<div id="barDetailPopup" class="modal-overlay hidden">
<div class="modal-content p-6" style="max-width: 500px;">
<div class="flex justify-between items-center mb-4">
<h3 id="barDetailTitle" class="text-lg font-bold">작업 상세</h3>
<button onclick="document.getElementById('barDetailPopup').classList.add('hidden')" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times text-xl"></i></button>
</div>
<div id="barDetailContent"></div>
<div id="barDetailActions" class="flex justify-end gap-2 mt-4"></div>
</div>
</div>
<script src="/static/js/tkfb-core.js?v=2026031701"></script>
<script src="/js/schedule.js?v=2026031701"></script>
</body>
</html>

View File

@@ -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() {