diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 9de5f66c..9ca42a1e 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -144,6 +144,8 @@ import aiAssistantProxy from "./routes/aiAssistantProxy"; // AI 어시스턴트 import auditLogRoutes from "./routes/auditLogRoutes"; // 통합 변경 이력 import moldRoutes from "./routes/moldRoutes"; // 금형 관리 import shippingPlanRoutes from "./routes/shippingPlanRoutes"; // 출하계획 관리 +import shippingOrderRoutes from "./routes/shippingOrderRoutes"; // 출하지시 관리 +import designRoutes from "./routes/designRoutes"; // 설계 모듈 (DR/ECR/프로젝트/ECN) import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석 @@ -337,6 +339,8 @@ app.use("/api/process-work-standard", processWorkStandardRoutes); // 공정 작 app.use("/api/audit-log", auditLogRoutes); // 통합 변경 이력 app.use("/api/mold", moldRoutes); // 금형 관리 app.use("/api/shipping-plan", shippingPlanRoutes); // 출하계획 관리 +app.use("/api/shipping-order", shippingOrderRoutes); // 출하지시 관리 +app.use("/api/design", designRoutes); // 설계 모듈 app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달 app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트) app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리 diff --git a/backend-node/src/controllers/designController.ts b/backend-node/src/controllers/designController.ts new file mode 100644 index 00000000..320ce9d9 --- /dev/null +++ b/backend-node/src/controllers/designController.ts @@ -0,0 +1,946 @@ +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import { query, getPool } from "../database/db"; +import { logger } from "../utils/logger"; + +// 회사코드 필터 조건 생성 헬퍼 +function companyFilter(companyCode: string, paramIndex: number, alias?: string): { condition: string; param: string; nextIndex: number } { + const col = alias ? `${alias}.company_code` : "company_code"; + if (companyCode === "*") { + return { condition: "", param: "", nextIndex: paramIndex }; + } + return { condition: `${col} = $${paramIndex}`, param: companyCode, nextIndex: paramIndex + 1 }; +} + +// ============================================ +// 설계의뢰/설변요청 (DR/ECR) CRUD +// ============================================ + +export async function getDesignRequestList(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode!; + const { source_type, status, priority, search } = req.query; + + const conditions: string[] = []; + const params: any[] = []; + let pi = 1; + + if (companyCode !== "*") { + conditions.push(`r.company_code = $${pi}`); + params.push(companyCode); + pi++; + } + if (source_type) { conditions.push(`r.source_type = $${pi}`); params.push(source_type); pi++; } + if (status) { conditions.push(`r.status = $${pi}`); params.push(status); pi++; } + if (priority) { conditions.push(`r.priority = $${pi}`); params.push(priority); pi++; } + if (search) { + conditions.push(`(r.target_name ILIKE $${pi} OR r.request_no ILIKE $${pi} OR r.requester ILIKE $${pi})`); + params.push(`%${search}%`); + pi++; + } + + const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : ""; + const sql = ` + SELECT r.*, + COALESCE(json_agg(json_build_object('id', h.id, 'step', h.step, 'history_date', h.history_date, 'user_name', h.user_name, 'description', h.description)) FILTER (WHERE h.id IS NOT NULL), '[]') AS history, + COALESCE((SELECT json_agg(i.impact_type) FROM dsn_request_impact i WHERE i.request_id = r.id), '[]') AS impact + FROM dsn_design_request r + LEFT JOIN dsn_request_history h ON h.request_id = r.id + ${where} + GROUP BY r.id + ORDER BY r.created_date DESC + `; + const result = await query(sql, params); + res.json({ success: true, data: result }); + } catch (error: any) { + logger.error("설계의뢰 목록 조회 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function getDesignRequestDetail(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode!; + const { id } = req.params; + + const conditions = [`r.id = $1`]; + const params: any[] = [id]; + if (companyCode !== "*") { conditions.push(`r.company_code = $2`); params.push(companyCode); } + + const sql = ` + SELECT r.*, + COALESCE((SELECT json_agg(json_build_object('id', h.id, 'step', h.step, 'history_date', h.history_date, 'user_name', h.user_name, 'description', h.description) ORDER BY h.created_date) FROM dsn_request_history h WHERE h.request_id = r.id), '[]') AS history, + COALESCE((SELECT json_agg(i.impact_type) FROM dsn_request_impact i WHERE i.request_id = r.id), '[]') AS impact + FROM dsn_design_request r + WHERE ${conditions.join(" AND ")} + `; + const result = await query(sql, params); + if (!result.length) { res.status(404).json({ success: false, message: "의뢰를 찾을 수 없습니다." }); return; } + res.json({ success: true, data: result[0] }); + } catch (error: any) { + logger.error("설계의뢰 상세 조회 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function createDesignRequest(req: AuthenticatedRequest, res: Response): Promise { + const pool = getPool(); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + const companyCode = req.user!.companyCode!; + const userId = req.user!.userId; + const { + request_no, source_type, request_date, due_date, priority, status, + target_name, customer, req_dept, requester, designer, order_no, + design_type, spec, change_type, drawing_no, urgency, reason, + content, apply_timing, review_memo, project_id, ecn_no, + impact, history, + } = req.body; + + const sql = ` + INSERT INTO dsn_design_request ( + request_no, source_type, request_date, due_date, priority, status, + target_name, customer, req_dept, requester, designer, order_no, + design_type, spec, change_type, drawing_no, urgency, reason, + content, apply_timing, review_memo, project_id, ecn_no, + writer, company_code + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25) + RETURNING * + `; + const result = await client.query(sql, [ + request_no, source_type || "dr", request_date, due_date, priority || "보통", status || "신규접수", + target_name, customer, req_dept, requester, designer, order_no, + design_type, spec, change_type, drawing_no, urgency || "보통", reason, + content, apply_timing, review_memo, project_id, ecn_no, + userId, companyCode, + ]); + + const requestId = result.rows[0].id; + + if (impact?.length) { + for (const imp of impact) { + await client.query( + `INSERT INTO dsn_request_impact (request_id, impact_type, writer, company_code) VALUES ($1,$2,$3,$4)`, + [requestId, imp, userId, companyCode] + ); + } + } + + if (history?.length) { + for (const h of history) { + await client.query( + `INSERT INTO dsn_request_history (request_id, step, history_date, user_name, description, writer, company_code) VALUES ($1,$2,$3,$4,$5,$6,$7)`, + [requestId, h.step, h.history_date, h.user_name, h.description, userId, companyCode] + ); + } + } + + await client.query("COMMIT"); + res.json({ success: true, data: result.rows[0] }); + } catch (error: any) { + await client.query("ROLLBACK"); + logger.error("설계의뢰 생성 오류", error); + res.status(500).json({ success: false, message: error.message }); + } finally { + client.release(); + } +} + +export async function updateDesignRequest(req: AuthenticatedRequest, res: Response): Promise { + const pool = getPool(); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + const companyCode = req.user!.companyCode!; + const userId = req.user!.userId; + const { id } = req.params; + const { + request_no, source_type, request_date, due_date, priority, status, approval_step, + target_name, customer, req_dept, requester, designer, order_no, + design_type, spec, change_type, drawing_no, urgency, reason, + content, apply_timing, review_memo, project_id, ecn_no, + impact, history, + } = req.body; + + const conditions = [`id = $1`]; + const params: any[] = [id]; + let pi = 2; + if (companyCode !== "*") { conditions.push(`company_code = $${pi}`); params.push(companyCode); pi++; } + + const setClauses = []; + const setParams: any[] = []; + const fields: Record = { + request_no, source_type, request_date, due_date, priority, status, approval_step, + target_name, customer, req_dept, requester, designer, order_no, + design_type, spec, change_type, drawing_no, urgency, reason, + content, apply_timing, review_memo, project_id, ecn_no, + }; + for (const [key, val] of Object.entries(fields)) { + if (val !== undefined) { + setClauses.push(`${key} = $${pi}`); + setParams.push(val); + pi++; + } + } + setClauses.push(`updated_date = now()`); + + const sql = `UPDATE dsn_design_request SET ${setClauses.join(", ")} WHERE ${conditions.join(" AND ")} RETURNING *`; + const result = await client.query(sql, [...params, ...setParams]); + if (!result.rowCount) { await client.query("ROLLBACK"); res.status(404).json({ success: false, message: "의뢰를 찾을 수 없습니다." }); return; } + + if (impact !== undefined) { + await client.query(`DELETE FROM dsn_request_impact WHERE request_id = $1`, [id]); + for (const imp of impact) { + await client.query( + `INSERT INTO dsn_request_impact (request_id, impact_type, writer, company_code) VALUES ($1,$2,$3,$4)`, + [id, imp, userId, companyCode] + ); + } + } + + if (history !== undefined) { + await client.query(`DELETE FROM dsn_request_history WHERE request_id = $1`, [id]); + for (const h of history) { + await client.query( + `INSERT INTO dsn_request_history (request_id, step, history_date, user_name, description, writer, company_code) VALUES ($1,$2,$3,$4,$5,$6,$7)`, + [id, h.step, h.history_date, h.user_name, h.description, userId, companyCode] + ); + } + } + + await client.query("COMMIT"); + res.json({ success: true, data: result.rows[0] }); + } catch (error: any) { + await client.query("ROLLBACK"); + logger.error("설계의뢰 수정 오류", error); + res.status(500).json({ success: false, message: error.message }); + } finally { + client.release(); + } +} + +export async function deleteDesignRequest(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode!; + const { id } = req.params; + + const conditions = [`id = $1`]; + const params: any[] = [id]; + if (companyCode !== "*") { conditions.push(`company_code = $2`); params.push(companyCode); } + + const sql = `DELETE FROM dsn_design_request WHERE ${conditions.join(" AND ")} RETURNING id`; + const result = await query(sql, params); + if (!result.length) { res.status(404).json({ success: false, message: "의뢰를 찾을 수 없습니다." }); return; } + res.json({ success: true }); + } catch (error: any) { + logger.error("설계의뢰 삭제 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +// 이력 추가 (단건) +export async function addRequestHistory(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode!; + const userId = req.user!.userId; + const { id } = req.params; + const { step, history_date, user_name, description } = req.body; + + const sql = `INSERT INTO dsn_request_history (request_id, step, history_date, user_name, description, writer, company_code) VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`; + const result = await query(sql, [id, step, history_date, user_name, description, userId, companyCode]); + res.json({ success: true, data: result[0] }); + } catch (error: any) { + logger.error("의뢰 이력 추가 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +// ============================================ +// 설계 프로젝트 CRUD +// ============================================ + +export async function getProjectList(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode!; + const { status, search } = req.query; + + const conditions: string[] = []; + const params: any[] = []; + let pi = 1; + + if (companyCode !== "*") { conditions.push(`p.company_code = $${pi}`); params.push(companyCode); pi++; } + if (status) { conditions.push(`p.status = $${pi}`); params.push(status); pi++; } + if (search) { + conditions.push(`(p.name ILIKE $${pi} OR p.project_no ILIKE $${pi} OR p.customer ILIKE $${pi})`); + params.push(`%${search}%`); + pi++; + } + + const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : ""; + const sql = ` + SELECT p.*, + COALESCE( + (SELECT json_agg(json_build_object( + 'id', t.id, 'name', t.name, 'category', t.category, 'assignee', t.assignee, + 'start_date', t.start_date, 'end_date', t.end_date, 'status', t.status, + 'progress', t.progress, 'priority', t.priority, 'remark', t.remark, 'sort_order', t.sort_order + ) ORDER BY t.sort_order, t.start_date) + FROM dsn_project_task t WHERE t.project_id = p.id), '[]' + ) AS tasks + FROM dsn_project p + ${where} + ORDER BY p.created_date DESC + `; + const result = await query(sql, params); + res.json({ success: true, data: result }); + } catch (error: any) { + logger.error("프로젝트 목록 조회 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function getProjectDetail(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode!; + const { id } = req.params; + + const conditions = [`p.id = $1`]; + const params: any[] = [id]; + if (companyCode !== "*") { conditions.push(`p.company_code = $2`); params.push(companyCode); } + + const sql = ` + SELECT p.*, + COALESCE( + (SELECT json_agg(json_build_object( + 'id', t.id, 'name', t.name, 'category', t.category, 'assignee', t.assignee, + 'start_date', t.start_date, 'end_date', t.end_date, 'status', t.status, + 'progress', t.progress, 'priority', t.priority, 'remark', t.remark, 'sort_order', t.sort_order + ) ORDER BY t.sort_order, t.start_date) + FROM dsn_project_task t WHERE t.project_id = p.id), '[]' + ) AS tasks + FROM dsn_project p + WHERE ${conditions.join(" AND ")} + `; + const result = await query(sql, params); + if (!result.length) { res.status(404).json({ success: false, message: "프로젝트를 찾을 수 없습니다." }); return; } + res.json({ success: true, data: result[0] }); + } catch (error: any) { + logger.error("프로젝트 상세 조회 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function createProject(req: AuthenticatedRequest, res: Response): Promise { + const pool = getPool(); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + const companyCode = req.user!.companyCode!; + const userId = req.user!.userId; + const { project_no, name, status: pStatus, pm, customer, start_date, end_date, source_no, description, progress, parent_id, relation_type, tasks } = req.body; + + const result = await client.query( + `INSERT INTO dsn_project (project_no, name, status, pm, customer, start_date, end_date, source_no, description, progress, parent_id, relation_type, writer, company_code) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) RETURNING *`, + [project_no, name, pStatus || "계획", pm, customer, start_date, end_date, source_no, description, progress || "0", parent_id, relation_type, userId, companyCode] + ); + + const projectId = result.rows[0].id; + if (tasks?.length) { + for (let i = 0; i < tasks.length; i++) { + const t = tasks[i]; + await client.query( + `INSERT INTO dsn_project_task (project_id, name, category, assignee, start_date, end_date, status, progress, priority, remark, sort_order, writer, company_code) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`, + [projectId, t.name, t.category, t.assignee, t.start_date, t.end_date, t.status || "대기", t.progress || "0", t.priority || "보통", t.remark, String(i), userId, companyCode] + ); + } + } + + await client.query("COMMIT"); + res.json({ success: true, data: result.rows[0] }); + } catch (error: any) { + await client.query("ROLLBACK"); + logger.error("프로젝트 생성 오류", error); + res.status(500).json({ success: false, message: error.message }); + } finally { + client.release(); + } +} + +export async function updateProject(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode!; + const { id } = req.params; + const { project_no, name, status: pStatus, pm, customer, start_date, end_date, source_no, description, progress, parent_id, relation_type } = req.body; + + const conditions = [`id = $1`]; + const params: any[] = [id]; + let pi = 2; + if (companyCode !== "*") { conditions.push(`company_code = $${pi}`); params.push(companyCode); pi++; } + + const sets: string[] = []; + const fields: Record = { project_no, name, status: pStatus, pm, customer, start_date, end_date, source_no, description, progress, parent_id, relation_type }; + for (const [key, val] of Object.entries(fields)) { + if (val !== undefined) { sets.push(`${key} = $${pi}`); params.push(val); pi++; } + } + sets.push(`updated_date = now()`); + + const result = await query(`UPDATE dsn_project SET ${sets.join(", ")} WHERE ${conditions.join(" AND ")} RETURNING *`, params); + if (!result.length) { res.status(404).json({ success: false, message: "프로젝트를 찾을 수 없습니다." }); return; } + res.json({ success: true, data: result[0] }); + } catch (error: any) { + logger.error("프로젝트 수정 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function deleteProject(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode!; + const { id } = req.params; + const conditions = [`id = $1`]; + const params: any[] = [id]; + if (companyCode !== "*") { conditions.push(`company_code = $2`); params.push(companyCode); } + + const result = await query(`DELETE FROM dsn_project WHERE ${conditions.join(" AND ")} RETURNING id`, params); + if (!result.length) { res.status(404).json({ success: false, message: "프로젝트를 찾을 수 없습니다." }); return; } + res.json({ success: true }); + } catch (error: any) { + logger.error("프로젝트 삭제 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +// ============================================ +// 프로젝트 태스크 CRUD +// ============================================ + +export async function getTasksByProject(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode!; + const { projectId } = req.params; + + const conditions = [`t.project_id = $1`]; + const params: any[] = [projectId]; + if (companyCode !== "*") { conditions.push(`t.company_code = $2`); params.push(companyCode); } + + const sql = ` + SELECT t.*, + COALESCE((SELECT json_agg(json_build_object('id', w.id, 'start_dt', w.start_dt, 'end_dt', w.end_dt, 'hours', w.hours, 'description', w.description, 'progress_before', w.progress_before, 'progress_after', w.progress_after, 'author', w.author, 'sub_item_id', w.sub_item_id) ORDER BY w.start_dt) FROM dsn_work_log w WHERE w.task_id = t.id), '[]') AS work_logs, + COALESCE((SELECT json_agg(json_build_object('id', i.id, 'title', i.title, 'status', i.status, 'priority', i.priority, 'description', i.description, 'registered_by', i.registered_by, 'registered_date', i.registered_date, 'resolved_date', i.resolved_date)) FROM dsn_task_issue i WHERE i.task_id = t.id), '[]') AS issues, + COALESCE((SELECT json_agg(json_build_object('id', s.id, 'name', s.name, 'weight', s.weight, 'progress', s.progress, 'status', s.status) ORDER BY s.created_date) FROM dsn_task_sub_item s WHERE s.task_id = t.id), '[]') AS sub_items + FROM dsn_project_task t + WHERE ${conditions.join(" AND ")} + ORDER BY t.sort_order, t.start_date + `; + const result = await query(sql, params); + res.json({ success: true, data: result }); + } catch (error: any) { + logger.error("태스크 목록 조회 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function createTask(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode!; + const userId = req.user!.userId; + const { projectId } = req.params; + const { name, category, assignee, start_date, end_date, status, progress, priority, remark, sort_order } = req.body; + + const result = await query( + `INSERT INTO dsn_project_task (project_id, name, category, assignee, start_date, end_date, status, progress, priority, remark, sort_order, writer, company_code) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) RETURNING *`, + [projectId, name, category, assignee, start_date, end_date, status || "대기", progress || "0", priority || "보통", remark, sort_order || "0", userId, companyCode] + ); + res.json({ success: true, data: result[0] }); + } catch (error: any) { + logger.error("태스크 생성 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function updateTask(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode!; + const { taskId } = req.params; + const { name, category, assignee, start_date, end_date, status, progress, priority, remark, sort_order } = req.body; + + const conditions = [`id = $1`]; + const params: any[] = [taskId]; + let pi = 2; + if (companyCode !== "*") { conditions.push(`company_code = $${pi}`); params.push(companyCode); pi++; } + + const sets: string[] = []; + const fields: Record = { name, category, assignee, start_date, end_date, status, progress, priority, remark, sort_order }; + for (const [key, val] of Object.entries(fields)) { + if (val !== undefined) { sets.push(`${key} = $${pi}`); params.push(val); pi++; } + } + sets.push(`updated_date = now()`); + + const result = await query(`UPDATE dsn_project_task SET ${sets.join(", ")} WHERE ${conditions.join(" AND ")} RETURNING *`, params); + if (!result.length) { res.status(404).json({ success: false, message: "태스크를 찾을 수 없습니다." }); return; } + res.json({ success: true, data: result[0] }); + } catch (error: any) { + logger.error("태스크 수정 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function deleteTask(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode!; + const { taskId } = req.params; + const conditions = [`id = $1`]; + const params: any[] = [taskId]; + if (companyCode !== "*") { conditions.push(`company_code = $2`); params.push(companyCode); } + + const result = await query(`DELETE FROM dsn_project_task WHERE ${conditions.join(" AND ")} RETURNING id`, params); + if (!result.length) { res.status(404).json({ success: false, message: "태스크를 찾을 수 없습니다." }); return; } + res.json({ success: true }); + } catch (error: any) { + logger.error("태스크 삭제 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +// ============================================ +// 작업일지 CRUD +// ============================================ + +export async function getWorkLogsByTask(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode!; + const { taskId } = req.params; + + const conditions = [`w.task_id = $1`]; + const params: any[] = [taskId]; + if (companyCode !== "*") { conditions.push(`w.company_code = $2`); params.push(companyCode); } + + const sql = ` + SELECT w.*, + COALESCE((SELECT json_agg(json_build_object('id', a.id, 'file_name', a.file_name, 'file_type', a.file_type, 'file_size', a.file_size)) FROM dsn_work_attachment a WHERE a.work_log_id = w.id), '[]') AS attachments, + COALESCE((SELECT json_agg(json_build_object('id', p.id, 'item', p.item, 'qty', p.qty, 'unit', p.unit, 'reason', p.reason, 'status', p.status)) FROM dsn_purchase_req p WHERE p.work_log_id = w.id), '[]') AS purchase_reqs, + COALESCE((SELECT json_agg(json_build_object( + 'id', c.id, 'to_user', c.to_user, 'to_dept', c.to_dept, 'title', c.title, 'description', c.description, 'status', c.status, 'due_date', c.due_date, + 'responses', COALESCE((SELECT json_agg(json_build_object('id', cr.id, 'response_date', cr.response_date, 'user_name', cr.user_name, 'content', cr.content)) FROM dsn_coop_response cr WHERE cr.coop_req_id = c.id), '[]') + )) FROM dsn_coop_req c WHERE c.work_log_id = w.id), '[]') AS coop_reqs + FROM dsn_work_log w + WHERE ${conditions.join(" AND ")} + ORDER BY w.start_dt DESC + `; + const result = await query(sql, params); + res.json({ success: true, data: result }); + } catch (error: any) { + logger.error("작업일지 조회 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function createWorkLog(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode!; + const userId = req.user!.userId; + const { taskId } = req.params; + const { start_dt, end_dt, hours, description, progress_before, progress_after, author, sub_item_id } = req.body; + + const result = await query( + `INSERT INTO dsn_work_log (task_id, start_dt, end_dt, hours, description, progress_before, progress_after, author, sub_item_id, writer, company_code) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING *`, + [taskId, start_dt, end_dt, hours || "0", description, progress_before || "0", progress_after || "0", author, sub_item_id, userId, companyCode] + ); + res.json({ success: true, data: result[0] }); + } catch (error: any) { + logger.error("작업일지 생성 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function deleteWorkLog(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode!; + const { workLogId } = req.params; + const conditions = [`id = $1`]; + const params: any[] = [workLogId]; + if (companyCode !== "*") { conditions.push(`company_code = $2`); params.push(companyCode); } + + const result = await query(`DELETE FROM dsn_work_log WHERE ${conditions.join(" AND ")} RETURNING id`, params); + if (!result.length) { res.status(404).json({ success: false, message: "작업일지를 찾을 수 없습니다." }); return; } + res.json({ success: true }); + } catch (error: any) { + logger.error("작업일지 삭제 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +// ============================================ +// 태스크 하위항목 CRUD +// ============================================ + +export async function createSubItem(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode!; + const userId = req.user!.userId; + const { taskId } = req.params; + const { name, weight, progress, status } = req.body; + + const result = await query( + `INSERT INTO dsn_task_sub_item (task_id, name, weight, progress, status, writer, company_code) VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`, + [taskId, name, weight || "0", progress || "0", status || "대기", userId, companyCode] + ); + res.json({ success: true, data: result[0] }); + } catch (error: any) { + logger.error("하위항목 생성 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function updateSubItem(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode!; + const { subItemId } = req.params; + const { name, weight, progress, status } = req.body; + + const conditions = [`id = $1`]; + const params: any[] = [subItemId]; + let pi = 2; + if (companyCode !== "*") { conditions.push(`company_code = $${pi}`); params.push(companyCode); pi++; } + + const sets: string[] = []; + const fields: Record = { name, weight, progress, status }; + for (const [key, val] of Object.entries(fields)) { + if (val !== undefined) { sets.push(`${key} = $${pi}`); params.push(val); pi++; } + } + sets.push(`updated_date = now()`); + + const result = await query(`UPDATE dsn_task_sub_item SET ${sets.join(", ")} WHERE ${conditions.join(" AND ")} RETURNING *`, params); + if (!result.length) { res.status(404).json({ success: false, message: "하위항목을 찾을 수 없습니다." }); return; } + res.json({ success: true, data: result[0] }); + } catch (error: any) { + logger.error("하위항목 수정 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function deleteSubItem(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode!; + const { subItemId } = req.params; + const conditions = [`id = $1`]; + const params: any[] = [subItemId]; + if (companyCode !== "*") { conditions.push(`company_code = $2`); params.push(companyCode); } + + const result = await query(`DELETE FROM dsn_task_sub_item WHERE ${conditions.join(" AND ")} RETURNING id`, params); + if (!result.length) { res.status(404).json({ success: false, message: "하위항목을 찾을 수 없습니다." }); return; } + res.json({ success: true }); + } catch (error: any) { + logger.error("하위항목 삭제 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +// ============================================ +// 태스크 이슈 CRUD +// ============================================ + +export async function createIssue(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode!; + const userId = req.user!.userId; + const { taskId } = req.params; + const { title, status, priority, description, registered_by, registered_date } = req.body; + + const result = await query( + `INSERT INTO dsn_task_issue (task_id, title, status, priority, description, registered_by, registered_date, writer, company_code) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING *`, + [taskId, title, status || "등록", priority || "보통", description, registered_by, registered_date, userId, companyCode] + ); + res.json({ success: true, data: result[0] }); + } catch (error: any) { + logger.error("이슈 생성 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function updateIssue(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode!; + const { issueId } = req.params; + const { title, status, priority, description, resolved_date } = req.body; + + const conditions = [`id = $1`]; + const params: any[] = [issueId]; + let pi = 2; + if (companyCode !== "*") { conditions.push(`company_code = $${pi}`); params.push(companyCode); pi++; } + + const sets: string[] = []; + const fields: Record = { title, status, priority, description, resolved_date }; + for (const [key, val] of Object.entries(fields)) { + if (val !== undefined) { sets.push(`${key} = $${pi}`); params.push(val); pi++; } + } + sets.push(`updated_date = now()`); + + const result = await query(`UPDATE dsn_task_issue SET ${sets.join(", ")} WHERE ${conditions.join(" AND ")} RETURNING *`, params); + if (!result.length) { res.status(404).json({ success: false, message: "이슈를 찾을 수 없습니다." }); return; } + res.json({ success: true, data: result[0] }); + } catch (error: any) { + logger.error("이슈 수정 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +// ============================================ +// ECN (설변통보) CRUD +// ============================================ + +export async function getEcnList(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode!; + const { status, search } = req.query; + + const conditions: string[] = []; + const params: any[] = []; + let pi = 1; + + if (companyCode !== "*") { conditions.push(`e.company_code = $${pi}`); params.push(companyCode); pi++; } + if (status) { conditions.push(`e.status = $${pi}`); params.push(status); pi++; } + if (search) { + conditions.push(`(e.ecn_no ILIKE $${pi} OR e.target ILIKE $${pi})`); + params.push(`%${search}%`); + pi++; + } + + const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : ""; + const sql = ` + SELECT e.*, + COALESCE((SELECT json_agg(json_build_object('id', h.id, 'status', h.status, 'history_date', h.history_date, 'user_name', h.user_name, 'description', h.description) ORDER BY h.created_date) FROM dsn_ecn_history h WHERE h.ecn_id = e.id), '[]') AS history, + COALESCE((SELECT json_agg(nd.dept_name) FROM dsn_ecn_notify_dept nd WHERE nd.ecn_id = e.id), '[]') AS notify_depts + FROM dsn_ecn e + ${where} + ORDER BY e.created_date DESC + `; + const result = await query(sql, params); + res.json({ success: true, data: result }); + } catch (error: any) { + logger.error("ECN 목록 조회 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function createEcn(req: AuthenticatedRequest, res: Response): Promise { + const pool = getPool(); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + const companyCode = req.user!.companyCode!; + const userId = req.user!.userId; + const { ecn_no, ecr_id, ecn_date, apply_date, status, target, drawing_before, drawing_after, designer, before_content, after_content, reason, remark, notify_depts, history } = req.body; + + const result = await client.query( + `INSERT INTO dsn_ecn (ecn_no, ecr_id, ecn_date, apply_date, status, target, drawing_before, drawing_after, designer, before_content, after_content, reason, remark, writer, company_code) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) RETURNING *`, + [ecn_no, ecr_id, ecn_date, apply_date, status || "ECN발행", target, drawing_before, drawing_after, designer, before_content, after_content, reason, remark, userId, companyCode] + ); + + const ecnId = result.rows[0].id; + + if (notify_depts?.length) { + for (const dept of notify_depts) { + await client.query(`INSERT INTO dsn_ecn_notify_dept (ecn_id, dept_name, writer, company_code) VALUES ($1,$2,$3,$4)`, [ecnId, dept, userId, companyCode]); + } + } + + if (history?.length) { + for (const h of history) { + await client.query( + `INSERT INTO dsn_ecn_history (ecn_id, status, history_date, user_name, description, writer, company_code) VALUES ($1,$2,$3,$4,$5,$6,$7)`, + [ecnId, h.status, h.history_date, h.user_name, h.description, userId, companyCode] + ); + } + } + + await client.query("COMMIT"); + res.json({ success: true, data: result.rows[0] }); + } catch (error: any) { + await client.query("ROLLBACK"); + logger.error("ECN 생성 오류", error); + res.status(500).json({ success: false, message: error.message }); + } finally { + client.release(); + } +} + +export async function updateEcn(req: AuthenticatedRequest, res: Response): Promise { + const pool = getPool(); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + const companyCode = req.user!.companyCode!; + const userId = req.user!.userId; + const { id } = req.params; + const { ecn_no, ecn_date, apply_date, status, target, drawing_before, drawing_after, designer, before_content, after_content, reason, remark, notify_depts, history } = req.body; + + const conditions = [`id = $1`]; + const params: any[] = [id]; + let pi = 2; + if (companyCode !== "*") { conditions.push(`company_code = $${pi}`); params.push(companyCode); pi++; } + + const sets: string[] = []; + const fields: Record = { ecn_no, ecn_date, apply_date, status, target, drawing_before, drawing_after, designer, before_content, after_content, reason, remark }; + for (const [key, val] of Object.entries(fields)) { + if (val !== undefined) { sets.push(`${key} = $${pi}`); params.push(val); pi++; } + } + sets.push(`updated_date = now()`); + + const result = await client.query(`UPDATE dsn_ecn SET ${sets.join(", ")} WHERE ${conditions.join(" AND ")} RETURNING *`, params); + if (!result.rowCount) { await client.query("ROLLBACK"); res.status(404).json({ success: false, message: "ECN을 찾을 수 없습니다." }); return; } + + if (notify_depts !== undefined) { + await client.query(`DELETE FROM dsn_ecn_notify_dept WHERE ecn_id = $1`, [id]); + for (const dept of notify_depts) { + await client.query(`INSERT INTO dsn_ecn_notify_dept (ecn_id, dept_name, writer, company_code) VALUES ($1,$2,$3,$4)`, [id, dept, userId, companyCode]); + } + } + if (history !== undefined) { + await client.query(`DELETE FROM dsn_ecn_history WHERE ecn_id = $1`, [id]); + for (const h of history) { + await client.query( + `INSERT INTO dsn_ecn_history (ecn_id, status, history_date, user_name, description, writer, company_code) VALUES ($1,$2,$3,$4,$5,$6,$7)`, + [id, h.status, h.history_date, h.user_name, h.description, userId, companyCode] + ); + } + } + + await client.query("COMMIT"); + res.json({ success: true, data: result.rows[0] }); + } catch (error: any) { + await client.query("ROLLBACK"); + logger.error("ECN 수정 오류", error); + res.status(500).json({ success: false, message: error.message }); + } finally { + client.release(); + } +} + +export async function deleteEcn(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode!; + const { id } = req.params; + const conditions = [`id = $1`]; + const params: any[] = [id]; + if (companyCode !== "*") { conditions.push(`company_code = $2`); params.push(companyCode); } + + const result = await query(`DELETE FROM dsn_ecn WHERE ${conditions.join(" AND ")} RETURNING id`, params); + if (!result.length) { res.status(404).json({ success: false, message: "ECN을 찾을 수 없습니다." }); return; } + res.json({ success: true }); + } catch (error: any) { + logger.error("ECN 삭제 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +// ============================================ +// 나의 업무 (My Work) - 로그인 사용자 기준 +// ============================================ + +export async function getMyWork(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode!; + const userName = req.user!.userName; + const { status, project_id } = req.query; + + const conditions = [`t.assignee = $1`]; + const params: any[] = [userName]; + let pi = 2; + + if (companyCode !== "*") { conditions.push(`t.company_code = $${pi}`); params.push(companyCode); pi++; } + if (status) { conditions.push(`t.status = $${pi}`); params.push(status); pi++; } + if (project_id) { conditions.push(`t.project_id = $${pi}`); params.push(project_id); pi++; } + + const sql = ` + SELECT t.*, + p.project_no, p.name AS project_name, p.customer AS project_customer, p.status AS project_status, + COALESCE((SELECT json_agg(json_build_object('id', s.id, 'name', s.name, 'weight', s.weight, 'progress', s.progress, 'status', s.status) ORDER BY s.created_date) FROM dsn_task_sub_item s WHERE s.task_id = t.id), '[]') AS sub_items, + COALESCE((SELECT json_agg(json_build_object( + 'id', w.id, 'start_dt', w.start_dt, 'end_dt', w.end_dt, 'hours', w.hours, 'description', w.description, 'sub_item_id', w.sub_item_id, + 'attachments', COALESCE((SELECT json_agg(json_build_object('id', a.id, 'file_name', a.file_name, 'file_type', a.file_type, 'file_size', a.file_size)) FROM dsn_work_attachment a WHERE a.work_log_id = w.id), '[]'), + 'purchase_reqs', COALESCE((SELECT json_agg(json_build_object('id', pr.id, 'item', pr.item, 'qty', pr.qty, 'unit', pr.unit, 'reason', pr.reason, 'status', pr.status)) FROM dsn_purchase_req pr WHERE pr.work_log_id = w.id), '[]'), + 'coop_reqs', COALESCE((SELECT json_agg(json_build_object( + 'id', c.id, 'to_user', c.to_user, 'to_dept', c.to_dept, 'title', c.title, 'description', c.description, 'status', c.status, 'due_date', c.due_date, + 'responses', COALESCE((SELECT json_agg(json_build_object('id', cr.id, 'response_date', cr.response_date, 'user_name', cr.user_name, 'content', cr.content)) FROM dsn_coop_response cr WHERE cr.coop_req_id = c.id), '[]') + )) FROM dsn_coop_req c WHERE c.work_log_id = w.id), '[]') + ) ORDER BY w.start_dt DESC) FROM dsn_work_log w WHERE w.task_id = t.id), '[]') AS work_logs + FROM dsn_project_task t + JOIN dsn_project p ON p.id = t.project_id + WHERE ${conditions.join(" AND ")} + ORDER BY + CASE t.status WHEN '진행중' THEN 1 WHEN '대기' THEN 2 WHEN '검토중' THEN 3 ELSE 4 END, + t.end_date ASC + `; + const result = await query(sql, params); + res.json({ success: true, data: result }); + } catch (error: any) { + logger.error("나의 업무 조회 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +// ============================================ +// 구매요청 / 협업요청 CRUD (my-work에서 사용) +// ============================================ + +export async function createPurchaseReq(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode!; + const userId = req.user!.userId; + const { workLogId } = req.params; + const { item, qty, unit, reason, status } = req.body; + + const result = await query( + `INSERT INTO dsn_purchase_req (work_log_id, item, qty, unit, reason, status, writer, company_code) VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`, + [workLogId, item, qty, unit, reason, status || "요청", userId, companyCode] + ); + res.json({ success: true, data: result[0] }); + } catch (error: any) { + logger.error("구매요청 생성 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function createCoopReq(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode!; + const userId = req.user!.userId; + const { workLogId } = req.params; + const { to_user, to_dept, title, description, due_date } = req.body; + + const result = await query( + `INSERT INTO dsn_coop_req (work_log_id, to_user, to_dept, title, description, status, due_date, writer, company_code) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING *`, + [workLogId, to_user, to_dept, title, description, "요청", due_date, userId, companyCode] + ); + res.json({ success: true, data: result[0] }); + } catch (error: any) { + logger.error("협업요청 생성 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function addCoopResponse(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode!; + const userId = req.user!.userId; + const { coopReqId } = req.params; + const { response_date, user_name, content } = req.body; + + const result = await query( + `INSERT INTO dsn_coop_response (coop_req_id, response_date, user_name, content, writer, company_code) VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`, + [coopReqId, response_date, user_name, content, userId, companyCode] + ); + res.json({ success: true, data: result[0] }); + } catch (error: any) { + logger.error("협업응답 추가 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} diff --git a/backend-node/src/controllers/shippingOrderController.ts b/backend-node/src/controllers/shippingOrderController.ts new file mode 100644 index 00000000..d7795fcf --- /dev/null +++ b/backend-node/src/controllers/shippingOrderController.ts @@ -0,0 +1,482 @@ +/** + * 출하지시 컨트롤러 (shipment_instruction + shipment_instruction_detail) + */ +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; +import { numberingRuleService } from "../services/numberingRuleService"; + +// ─── 출하지시 목록 조회 ─── +export async function getList(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { dateFrom, dateTo, status, customer, keyword } = req.query; + + const conditions: string[] = []; + const params: any[] = []; + let idx = 1; + + if (companyCode !== "*") { + conditions.push(`si.company_code = $${idx}`); + params.push(companyCode); + idx++; + } + if (dateFrom) { + conditions.push(`si.instruction_date >= $${idx}::date`); + params.push(dateFrom); + idx++; + } + if (dateTo) { + conditions.push(`si.instruction_date <= $${idx}::date`); + params.push(dateTo); + idx++; + } + if (status) { + conditions.push(`si.status = $${idx}`); + params.push(status); + idx++; + } + if (customer) { + conditions.push(`(c.customer_name ILIKE $${idx} OR si.partner_id ILIKE $${idx})`); + params.push(`%${customer}%`); + idx++; + } + if (keyword) { + conditions.push(`(si.instruction_no ILIKE $${idx} OR si.memo ILIKE $${idx})`); + params.push(`%${keyword}%`); + idx++; + } + + const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + + const query = ` + SELECT + si.*, + COALESCE(c.customer_name, si.partner_id, '') AS customer_name, + COALESCE( + json_agg( + json_build_object( + 'id', sid.id, + 'item_code', sid.item_code, + 'item_name', COALESCE(i.item_name, sid.item_name, sid.item_code), + 'spec', sid.spec, + 'material', sid.material, + 'order_qty', sid.order_qty, + 'plan_qty', sid.plan_qty, + 'ship_qty', sid.ship_qty, + 'source_type', sid.source_type, + 'shipment_plan_id', sid.shipment_plan_id, + 'sales_order_id', sid.sales_order_id, + 'detail_id', sid.detail_id + ) + ) FILTER (WHERE sid.id IS NOT NULL), + '[]' + ) AS items + FROM shipment_instruction si + LEFT JOIN customer_mng c + ON si.partner_id = c.customer_code AND si.company_code = c.company_code + LEFT JOIN shipment_instruction_detail sid + ON si.id = sid.instruction_id AND si.company_code = sid.company_code + LEFT JOIN LATERAL ( + SELECT item_name FROM item_info + WHERE item_number = sid.item_code AND company_code = si.company_code + LIMIT 1 + ) i ON true + ${where} + GROUP BY si.id, c.customer_name + ORDER BY si.created_date DESC + `; + + const pool = getPool(); + const result = await pool.query(query, params); + + logger.info("출하지시 목록 조회", { companyCode, count: result.rowCount }); + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("출하지시 목록 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// ─── 다음 출하지시번호 미리보기 ─── +export async function previewNextNo(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + let instructionNo: string; + + try { + const rule = await numberingRuleService.getNumberingRuleByColumn( + companyCode, "shipment_instruction", "instruction_no" + ); + if (rule) { + instructionNo = await numberingRuleService.previewCode( + rule.ruleId, companyCode, {} + ); + } else { + throw new Error("채번 규칙 없음"); + } + } catch { + const pool = getPool(); + const today = new Date().toISOString().split("T")[0].replace(/-/g, ""); + const seqRes = await pool.query( + `SELECT COUNT(*) + 1 AS seq FROM shipment_instruction WHERE company_code = $1 AND instruction_no LIKE $2`, + [companyCode, `SI-${today}-%`] + ); + const seq = String(seqRes.rows[0].seq).padStart(3, "0"); + instructionNo = `SI-${today}-${seq}`; + } + + return res.json({ success: true, instructionNo }); + } catch (error: any) { + logger.error("출하지시번호 미리보기 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// ─── 출하지시 저장 (신규/수정) ─── +export async function save(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { + id: editId, + instructionDate, + partnerId, + status: orderStatus, + memo, + carrierName, + vehicleNo, + driverName, + driverContact, + arrivalTime, + deliveryAddress, + items, + } = req.body; + + if (!instructionDate) { + return res.status(400).json({ success: false, message: "출하지시일은 필수입니다" }); + } + if (!items || items.length === 0) { + return res.status(400).json({ success: false, message: "품목을 선택해주세요" }); + } + + const pool = getPool(); + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + let instructionId: number; + let instructionNo: string; + + if (editId) { + // 수정 + const check = await client.query( + `SELECT id, instruction_no FROM shipment_instruction WHERE id = $1 AND company_code = $2`, + [editId, companyCode] + ); + if (check.rowCount === 0) { + throw new Error("출하지시를 찾을 수 없습니다"); + } + instructionId = editId; + instructionNo = check.rows[0].instruction_no; + + await client.query( + `UPDATE shipment_instruction SET + instruction_date = $1::date, partner_id = $2, status = $3, memo = $4, + carrier_name = $5, vehicle_no = $6, driver_name = $7, driver_contact = $8, + arrival_time = $9, delivery_address = $10, + updated_date = NOW(), updated_by = $11 + WHERE id = $12 AND company_code = $13`, + [ + instructionDate, partnerId, orderStatus || "READY", memo, + carrierName, vehicleNo, driverName, driverContact, + arrivalTime || null, deliveryAddress, + userId, editId, companyCode, + ] + ); + + // 기존 디테일 삭제 후 재삽입 + await client.query( + `DELETE FROM shipment_instruction_detail WHERE instruction_id = $1 AND company_code = $2`, + [editId, companyCode] + ); + } else { + // 신규 - 채번 규칙이 있으면 사용, 없으면 자체 생성 + try { + const rule = await numberingRuleService.getNumberingRuleByColumn( + companyCode, "shipment_instruction", "instruction_no" + ); + if (rule) { + instructionNo = await numberingRuleService.allocateCode( + rule.ruleId, companyCode, { instruction_date: instructionDate } + ); + logger.info("채번 규칙으로 출하지시번호 생성", { ruleId: rule.ruleId, instructionNo }); + } else { + throw new Error("채번 규칙 없음 - 폴백"); + } + } catch { + const today = new Date().toISOString().split("T")[0].replace(/-/g, ""); + const seqRes = await client.query( + `SELECT COUNT(*) + 1 AS seq FROM shipment_instruction WHERE company_code = $1 AND instruction_no LIKE $2`, + [companyCode, `SI-${today}-%`] + ); + const seq = String(seqRes.rows[0].seq).padStart(3, "0"); + instructionNo = `SI-${today}-${seq}`; + logger.info("폴백으로 출하지시번호 생성", { instructionNo }); + } + + const insertRes = await client.query( + `INSERT INTO shipment_instruction + (company_code, instruction_no, instruction_date, partner_id, status, memo, + carrier_name, vehicle_no, driver_name, driver_contact, arrival_time, delivery_address, + created_date, created_by) + VALUES ($1, $2, $3::date, $4, $5, $6, $7, $8, $9, $10, $11, $12, NOW(), $13) + RETURNING id`, + [ + companyCode, instructionNo, instructionDate, partnerId, + orderStatus || "READY", memo, + carrierName, vehicleNo, driverName, driverContact, + arrivalTime || null, deliveryAddress, userId, + ] + ); + instructionId = insertRes.rows[0].id; + } + + // 디테일 삽입 + for (const item of items) { + await client.query( + `INSERT INTO shipment_instruction_detail + (company_code, instruction_id, shipment_plan_id, sales_order_id, detail_id, + item_code, item_name, spec, material, order_qty, plan_qty, ship_qty, + source_type, created_date, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW(), $14)`, + [ + companyCode, instructionId, + item.shipmentPlanId || null, item.salesOrderId || null, item.detailId || null, + item.itemCode, item.itemName, item.spec, item.material, + item.orderQty || 0, item.planQty || 0, item.shipQty || 0, + item.sourceType || "shipmentPlan", userId, + ] + ); + } + + await client.query("COMMIT"); + + logger.info("출하지시 저장 완료", { companyCode, instructionId, instructionNo, itemCount: items.length }); + return res.json({ success: true, data: { id: instructionId, instructionNo } }); + } catch (txErr) { + await client.query("ROLLBACK"); + throw txErr; + } finally { + client.release(); + } + } catch (error: any) { + logger.error("출하지시 저장 실패", { error: error.message, stack: error.stack }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// ─── 출하지시 삭제 ─── +export async function remove(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { ids } = req.body; + + if (!ids || !Array.isArray(ids) || ids.length === 0) { + return res.status(400).json({ success: false, message: "삭제할 ID가 필요합니다" }); + } + + const pool = getPool(); + // CASCADE로 디테일도 자동 삭제 + const result = await pool.query( + `DELETE FROM shipment_instruction WHERE id = ANY($1::int[]) AND company_code = $2 RETURNING id`, + [ids, companyCode] + ); + + logger.info("출하지시 삭제", { companyCode, deletedCount: result.rowCount }); + return res.json({ success: true, deletedCount: result.rowCount }); + } catch (error: any) { + logger.error("출하지시 삭제 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// ─── 출하계획 목록 (모달 왼쪽 패널용) ─── +export async function getShipmentPlanSource(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { keyword, customer, page: pageStr, pageSize: pageSizeStr } = req.query; + const page = Math.max(1, parseInt(pageStr as string) || 1); + const pageSize = Math.min(100, Math.max(1, parseInt(pageSizeStr as string) || 20)); + const offset = (page - 1) * pageSize; + + const conditions = ["sp.company_code = $1", "sp.status = 'READY'"]; + const params: any[] = [companyCode]; + let idx = 2; + + if (keyword) { + conditions.push(`(COALESCE(d.part_code, m.part_code, '') ILIKE $${idx} OR COALESCE(i.item_name, d.part_name, m.part_name, '') ILIKE $${idx})`); + params.push(`%${keyword}%`); + idx++; + } + if (customer) { + conditions.push(`(c.customer_name ILIKE $${idx} OR COALESCE(m.partner_id, d.delivery_partner_code, '') ILIKE $${idx})`); + params.push(`%${customer}%`); + idx++; + } + + const whereClause = conditions.join(" AND "); + const fromClause = ` + FROM shipment_plan sp + LEFT JOIN sales_order_detail d ON sp.detail_id = d.id AND sp.company_code = d.company_code + LEFT JOIN sales_order_mng m ON sp.sales_order_id = m.id AND sp.company_code = m.company_code + LEFT JOIN LATERAL ( + SELECT item_name FROM item_info + WHERE item_number = COALESCE(d.part_code, m.part_code) AND company_code = sp.company_code + LIMIT 1 + ) i ON true + LEFT JOIN customer_mng c + ON COALESCE(m.partner_id, d.delivery_partner_code) = c.customer_code AND sp.company_code = c.company_code + WHERE ${whereClause} + `; + + const pool = getPool(); + const countResult = await pool.query(`SELECT COUNT(*) AS total ${fromClause}`, params); + const totalCount = parseInt(countResult.rows[0].total); + + const query = ` + SELECT + sp.id, sp.plan_qty, sp.plan_date, sp.status, sp.shipment_plan_no, + COALESCE(m.order_no, d.order_no, '') AS order_no, + COALESCE(d.part_code, m.part_code, '') AS item_code, + COALESCE(i.item_name, d.part_name, m.part_name, COALESCE(d.part_code, m.part_code, '')) AS item_name, + COALESCE(d.spec, m.spec, '') AS spec, + COALESCE(m.material, '') AS material, + COALESCE(c.customer_name, m.partner_id, d.delivery_partner_code, '') AS customer_name, + COALESCE(m.partner_id, d.delivery_partner_code, '') AS partner_code, + sp.detail_id, sp.sales_order_id + ${fromClause} + ORDER BY sp.created_date DESC + LIMIT $${idx} OFFSET $${idx + 1} + `; + params.push(pageSize, offset); + + const result = await pool.query(query, params); + return res.json({ success: true, data: result.rows, totalCount, page, pageSize }); + } catch (error: any) { + logger.error("출하계획 소스 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// ─── 수주 목록 (모달 왼쪽 패널용) ─── +export async function getSalesOrderSource(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { keyword, customer, page: pageStr, pageSize: pageSizeStr } = req.query; + const page = Math.max(1, parseInt(pageStr as string) || 1); + const pageSize = Math.min(100, Math.max(1, parseInt(pageSizeStr as string) || 20)); + const offset = (page - 1) * pageSize; + + const conditions = ["d.company_code = $1"]; + const params: any[] = [companyCode]; + let idx = 2; + + if (keyword) { + conditions.push(`(d.part_code ILIKE $${idx} OR COALESCE(i.item_name, d.part_name, d.part_code) ILIKE $${idx} OR d.order_no ILIKE $${idx})`); + params.push(`%${keyword}%`); + idx++; + } + if (customer) { + conditions.push(`(c.customer_name ILIKE $${idx} OR COALESCE(d.delivery_partner_code, m.partner_id, '') ILIKE $${idx})`); + params.push(`%${customer}%`); + idx++; + } + + const whereClause = conditions.join(" AND "); + const fromClause = ` + FROM sales_order_detail d + LEFT JOIN sales_order_mng m ON d.order_no = m.order_no AND d.company_code = m.company_code + LEFT JOIN LATERAL ( + SELECT item_name FROM item_info + WHERE item_number = d.part_code AND company_code = d.company_code + LIMIT 1 + ) i ON true + LEFT JOIN customer_mng c + ON COALESCE(d.delivery_partner_code, m.partner_id) = c.customer_code AND d.company_code = c.company_code + WHERE ${whereClause} + `; + + const pool = getPool(); + const countResult = await pool.query(`SELECT COUNT(*) AS total ${fromClause}`, params); + const totalCount = parseInt(countResult.rows[0].total); + + const query = ` + SELECT + d.id, d.order_no, d.part_code AS item_code, + COALESCE(i.item_name, d.part_name, d.part_code) AS item_name, + COALESCE(d.spec, '') AS spec, COALESCE(m.material, '') AS material, + COALESCE(NULLIF(d.qty,'')::numeric, 0) AS qty, + COALESCE(NULLIF(d.balance_qty,'')::numeric, 0) AS balance_qty, + COALESCE(c.customer_name, COALESCE(d.delivery_partner_code, m.partner_id, '')) AS customer_name, + COALESCE(d.delivery_partner_code, m.partner_id, '') AS partner_code, + m.id AS master_id + ${fromClause} + ORDER BY d.created_date DESC + LIMIT $${idx} OFFSET $${idx + 1} + `; + params.push(pageSize, offset); + + const result = await pool.query(query, params); + return res.json({ success: true, data: result.rows, totalCount, page, pageSize }); + } catch (error: any) { + logger.error("수주 소스 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// ─── 품목 목록 (모달 왼쪽 패널용) ─── +export async function getItemSource(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { keyword, page: pageStr, pageSize: pageSizeStr } = req.query; + const page = Math.max(1, parseInt(pageStr as string) || 1); + const pageSize = Math.min(100, Math.max(1, parseInt(pageSizeStr as string) || 20)); + const offset = (page - 1) * pageSize; + + const conditions = ["company_code = $1"]; + const params: any[] = [companyCode]; + let idx = 2; + + if (keyword) { + conditions.push(`(item_number ILIKE $${idx} OR item_name ILIKE $${idx})`); + params.push(`%${keyword}%`); + idx++; + } + + const whereClause = conditions.join(" AND "); + + const pool = getPool(); + const countResult = await pool.query(`SELECT COUNT(*) AS total FROM item_info WHERE ${whereClause}`, params); + const totalCount = parseInt(countResult.rows[0].total); + + const query = ` + SELECT + item_number AS item_code, item_name, + COALESCE(size, '') AS spec, COALESCE(material, '') AS material + FROM item_info + WHERE ${whereClause} + ORDER BY item_name + LIMIT $${idx} OFFSET $${idx + 1} + `; + params.push(pageSize, offset); + + const result = await pool.query(query, params); + return res.json({ success: true, data: result.rows, totalCount, page, pageSize }); + } catch (error: any) { + logger.error("품목 소스 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} diff --git a/backend-node/src/controllers/shippingPlanController.ts b/backend-node/src/controllers/shippingPlanController.ts index e89e14c2..b56c3617 100644 --- a/backend-node/src/controllers/shippingPlanController.ts +++ b/backend-node/src/controllers/shippingPlanController.ts @@ -144,6 +144,218 @@ async function getNormalizedOrders( } } +// ─── 출하계획 목록 조회 (관리 화면용) ─── + +export async function getList(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { dateFrom, dateTo, status, customer, keyword } = req.query; + + const conditions: string[] = []; + const params: any[] = []; + let paramIndex = 1; + + // 멀티테넌시 + if (companyCode === "*") { + // 최고 관리자: 전체 조회 + } else { + conditions.push(`sp.company_code = $${paramIndex}`); + params.push(companyCode); + paramIndex++; + } + + if (dateFrom) { + conditions.push(`sp.plan_date >= $${paramIndex}::date`); + params.push(dateFrom); + paramIndex++; + } + if (dateTo) { + conditions.push(`sp.plan_date <= $${paramIndex}::date`); + params.push(dateTo); + paramIndex++; + } + if (status) { + conditions.push(`sp.status = $${paramIndex}`); + params.push(status); + paramIndex++; + } + if (customer) { + conditions.push(`(c.customer_name ILIKE $${paramIndex} OR COALESCE(m.partner_id, d.delivery_partner_code, '') ILIKE $${paramIndex})`); + params.push(`%${customer}%`); + paramIndex++; + } + if (keyword) { + conditions.push(`( + COALESCE(m.order_no, d.order_no, '') ILIKE $${paramIndex} + OR COALESCE(d.part_code, m.part_code, '') ILIKE $${paramIndex} + OR COALESCE(i.item_name, d.part_name, m.part_name, '') ILIKE $${paramIndex} + OR sp.shipment_plan_no ILIKE $${paramIndex} + )`); + params.push(`%${keyword}%`); + paramIndex++; + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + + const query = ` + SELECT + sp.id, + sp.plan_date, + sp.plan_qty, + sp.status, + sp.memo, + sp.shipment_plan_no, + sp.created_date, + sp.created_by, + sp.detail_id, + sp.sales_order_id, + sp.remain_qty, + COALESCE(m.order_no, d.order_no, '') AS order_no, + COALESCE(d.part_code, m.part_code, '') AS part_code, + COALESCE(i.item_name, d.part_name, m.part_name, COALESCE(d.part_code, m.part_code, '')) AS part_name, + COALESCE(d.spec, m.spec, '') AS spec, + COALESCE(m.material, '') AS material, + COALESCE(c.customer_name, m.partner_id, d.delivery_partner_code, '') AS customer_name, + COALESCE(m.partner_id, d.delivery_partner_code, '') AS partner_code, + COALESCE(d.due_date, m.due_date::text, '') AS due_date, + COALESCE(NULLIF(d.qty,'')::numeric, m.order_qty, 0) AS order_qty, + COALESCE(NULLIF(d.ship_qty,'')::numeric, m.ship_qty, 0) AS shipped_qty + FROM shipment_plan sp + LEFT JOIN sales_order_detail d + ON sp.detail_id = d.id AND sp.company_code = d.company_code + LEFT JOIN sales_order_mng m + ON sp.sales_order_id = m.id AND sp.company_code = m.company_code + LEFT JOIN LATERAL ( + SELECT item_name FROM item_info + WHERE item_number = COALESCE(d.part_code, m.part_code) + AND company_code = sp.company_code + LIMIT 1 + ) i ON true + LEFT JOIN customer_mng c + ON COALESCE(m.partner_id, d.delivery_partner_code) = c.customer_code + AND sp.company_code = c.company_code + ${whereClause} + ORDER BY sp.created_date DESC + `; + + const pool = getPool(); + const result = await pool.query(query, params); + + logger.info("출하계획 목록 조회", { + companyCode, + rowCount: result.rowCount, + }); + + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("출하계획 목록 조회 실패", { + error: error.message, + stack: error.stack, + }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// ─── 출하계획 단건 수정 ─── + +export async function updatePlan(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { id } = req.params; + const { planQty, planDate, memo } = req.body; + + const pool = getPool(); + + const check = await pool.query( + `SELECT id, status FROM shipment_plan WHERE id = $1 AND company_code = $2`, + [id, companyCode] + ); + + if (check.rowCount === 0) { + return res.status(404).json({ success: false, message: "출하계획을 찾을 수 없습니다" }); + } + + const setClauses: string[] = []; + const updateParams: any[] = []; + let idx = 1; + + if (planQty !== undefined) { + setClauses.push(`plan_qty = $${idx}`); + updateParams.push(planQty); + idx++; + } + if (planDate !== undefined) { + setClauses.push(`plan_date = $${idx}::date`); + updateParams.push(planDate); + idx++; + } + if (memo !== undefined) { + setClauses.push(`memo = $${idx}`); + updateParams.push(memo); + idx++; + } + + setClauses.push(`updated_date = NOW()`); + setClauses.push(`updated_by = $${idx}`); + updateParams.push(userId); + idx++; + + updateParams.push(id); + updateParams.push(companyCode); + + const updateQuery = ` + UPDATE shipment_plan + SET ${setClauses.join(", ")} + WHERE id = $${idx - 1} AND company_code = $${idx} + RETURNING * + `; + + // 파라미터 인덱스 수정 + const finalParams: any[] = []; + let pIdx = 1; + const setClausesFinal: string[] = []; + + if (planQty !== undefined) { + setClausesFinal.push(`plan_qty = $${pIdx}`); + finalParams.push(planQty); + pIdx++; + } + if (planDate !== undefined) { + setClausesFinal.push(`plan_date = $${pIdx}::date`); + finalParams.push(planDate); + pIdx++; + } + if (memo !== undefined) { + setClausesFinal.push(`memo = $${pIdx}`); + finalParams.push(memo); + pIdx++; + } + setClausesFinal.push(`updated_date = NOW()`); + setClausesFinal.push(`updated_by = $${pIdx}`); + finalParams.push(userId); + pIdx++; + + finalParams.push(id); + finalParams.push(companyCode); + + const result = await pool.query( + `UPDATE shipment_plan + SET ${setClausesFinal.join(", ")} + WHERE id = $${pIdx} AND company_code = $${pIdx + 1} + RETURNING *`, + finalParams + ); + + logger.info("출하계획 수정", { companyCode, planId: id, userId }); + + return res.json({ success: true, data: result.rows[0] }); + } catch (error: any) { + logger.error("출하계획 수정 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + // ─── 품목별 집계 + 기존 출하계획 조회 ─── export async function getAggregate(req: AuthenticatedRequest, res: Response) { diff --git a/backend-node/src/routes/designRoutes.ts b/backend-node/src/routes/designRoutes.ts new file mode 100644 index 00000000..fcbcc6c7 --- /dev/null +++ b/backend-node/src/routes/designRoutes.ts @@ -0,0 +1,67 @@ +import express from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import { + getDesignRequestList, getDesignRequestDetail, createDesignRequest, updateDesignRequest, deleteDesignRequest, addRequestHistory, + getProjectList, getProjectDetail, createProject, updateProject, deleteProject, + getTasksByProject, createTask, updateTask, deleteTask, + getWorkLogsByTask, createWorkLog, deleteWorkLog, + createSubItem, updateSubItem, deleteSubItem, + createIssue, updateIssue, + getEcnList, createEcn, updateEcn, deleteEcn, + getMyWork, + createPurchaseReq, createCoopReq, addCoopResponse, +} from "../controllers/designController"; + +const router = express.Router(); +router.use(authenticateToken); + +// 설계의뢰/설변요청 (DR/ECR) +router.get("/requests", getDesignRequestList); +router.get("/requests/:id", getDesignRequestDetail); +router.post("/requests", createDesignRequest); +router.put("/requests/:id", updateDesignRequest); +router.delete("/requests/:id", deleteDesignRequest); +router.post("/requests/:id/history", addRequestHistory); + +// 설계 프로젝트 +router.get("/projects", getProjectList); +router.get("/projects/:id", getProjectDetail); +router.post("/projects", createProject); +router.put("/projects/:id", updateProject); +router.delete("/projects/:id", deleteProject); + +// 프로젝트 태스크 +router.get("/projects/:projectId/tasks", getTasksByProject); +router.post("/projects/:projectId/tasks", createTask); +router.put("/tasks/:taskId", updateTask); +router.delete("/tasks/:taskId", deleteTask); + +// 작업일지 +router.get("/tasks/:taskId/work-logs", getWorkLogsByTask); +router.post("/tasks/:taskId/work-logs", createWorkLog); +router.delete("/work-logs/:workLogId", deleteWorkLog); + +// 태스크 하위항목 +router.post("/tasks/:taskId/sub-items", createSubItem); +router.put("/sub-items/:subItemId", updateSubItem); +router.delete("/sub-items/:subItemId", deleteSubItem); + +// 태스크 이슈 +router.post("/tasks/:taskId/issues", createIssue); +router.put("/issues/:issueId", updateIssue); + +// ECN (설변통보) +router.get("/ecn", getEcnList); +router.post("/ecn", createEcn); +router.put("/ecn/:id", updateEcn); +router.delete("/ecn/:id", deleteEcn); + +// 나의 업무 +router.get("/my-work", getMyWork); + +// 구매요청 / 협업요청 +router.post("/work-logs/:workLogId/purchase-reqs", createPurchaseReq); +router.post("/work-logs/:workLogId/coop-reqs", createCoopReq); +router.post("/coop-reqs/:coopReqId/responses", addCoopResponse); + +export default router; diff --git a/backend-node/src/routes/shippingOrderRoutes.ts b/backend-node/src/routes/shippingOrderRoutes.ts new file mode 100644 index 00000000..d22ee8be --- /dev/null +++ b/backend-node/src/routes/shippingOrderRoutes.ts @@ -0,0 +1,21 @@ +/** + * 출하지시 라우트 + */ +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import * as shippingOrderController from "../controllers/shippingOrderController"; + +const router = Router(); +router.use(authenticateToken); + +router.get("/list", shippingOrderController.getList); +router.get("/preview-no", shippingOrderController.previewNextNo); +router.post("/save", shippingOrderController.save); +router.post("/delete", shippingOrderController.remove); + +// 모달 왼쪽 패널 데이터 소스 +router.get("/source/shipment-plan", shippingOrderController.getShipmentPlanSource); +router.get("/source/sales-order", shippingOrderController.getSalesOrderSource); +router.get("/source/item", shippingOrderController.getItemSource); + +export default router; diff --git a/backend-node/src/routes/shippingPlanRoutes.ts b/backend-node/src/routes/shippingPlanRoutes.ts index 16ff0050..2bd8e822 100644 --- a/backend-node/src/routes/shippingPlanRoutes.ts +++ b/backend-node/src/routes/shippingPlanRoutes.ts @@ -10,10 +10,16 @@ const router = Router(); router.use(authenticateToken); +// 출하계획 목록 조회 (관리 화면용) +router.get("/list", shippingPlanController.getList); + // 품목별 집계 + 기존 출하계획 조회 router.get("/aggregate", shippingPlanController.getAggregate); // 출하계획 일괄 저장 router.post("/batch", shippingPlanController.batchSave); +// 출하계획 단건 수정 +router.put("/:id", shippingPlanController.updatePlan); + export default router; diff --git a/frontend/app/(main)/design/change-management/page.tsx b/frontend/app/(main)/design/change-management/page.tsx new file mode 100644 index 00000000..8879ba8a --- /dev/null +++ b/frontend/app/(main)/design/change-management/page.tsx @@ -0,0 +1,1655 @@ +"use client"; + +import React, { useState, useMemo, useEffect, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogDescription, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "@/components/ui/resizable"; +import { + Search, + RotateCcw, + Plus, + Save, + ClipboardList, + Inbox, + Pencil, + FileText, + XCircle, + ArrowRight, + Paperclip, + Upload, + Loader2, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { toast } from "sonner"; +import { + getDesignRequestList, + createDesignRequest, + updateDesignRequest, + addRequestHistory, + getEcnList, + createEcn, + updateEcn, +} from "@/lib/api/design"; + +// --- Types --- +type ChangeType = "설계오류" | "원가절감" | "고객요청" | "공정개선" | "법규대응"; +type EcrStatus = "요청접수" | "영향도분석" | "ECN발행" | "기각"; +type EcnStatus = "ECN발행" | "도면변경" | "통보완료" | "적용완료"; +type TabType = "ecr" | "ecn"; + +interface EcrHistory { + status: string; + date: string; + user: string; + desc: string; +} + +interface EcrItem { + id: string; + _id?: string; + date: string; + changeType: ChangeType; + urgency: "보통" | "긴급"; + status: EcrStatus; + target: string; + drawingNo: string; + reqDept: string; + requester: string; + reason: string; + content: string; + impact: string[]; + applyTiming: string; + ecnNo: string; + history: EcrHistory[]; +} + +interface EcnItem { + id: string; + _id?: string; + ecrNo: string; + ecrId?: string; + date: string; + applyDate: string; + status: EcnStatus; + target: string; + drawingBefore: string; + drawingAfter: string; + designer: string; + before: string; + after: string; + reason: string; + notifyDepts: string[]; + remark: string; + history: EcrHistory[]; +} + +// --- Style Helpers --- +const getChangeTypeStyle = (type: ChangeType) => { + switch (type) { + case "설계오류": + return "bg-rose-100 text-rose-800 border-rose-200"; + case "원가절감": + return "bg-emerald-100 text-emerald-800 border-emerald-200"; + case "고객요청": + return "bg-blue-100 text-blue-800 border-blue-200"; + case "공정개선": + return "bg-amber-100 text-amber-800 border-amber-200"; + case "법규대응": + return "bg-purple-100 text-purple-800 border-purple-200"; + default: + return "bg-gray-100 text-gray-800 border-gray-200"; + } +}; + +const getEcrStatusStyle = (status: EcrStatus) => { + switch (status) { + case "요청접수": + return "bg-blue-100 text-blue-800 border-blue-200"; + case "영향도분석": + return "bg-amber-100 text-amber-800 border-amber-200"; + case "ECN발행": + return "bg-emerald-100 text-emerald-800 border-emerald-200"; + case "기각": + return "bg-slate-100 text-slate-800 border-slate-200"; + default: + return "bg-gray-100 text-gray-800 border-gray-200"; + } +}; + +const getEcnStatusStyle = (status: EcnStatus) => { + switch (status) { + case "ECN발행": + return "bg-blue-100 text-blue-800 border-blue-200"; + case "도면변경": + return "bg-purple-100 text-purple-800 border-purple-200"; + case "통보완료": + return "bg-teal-100 text-teal-800 border-teal-200"; + case "적용완료": + return "bg-emerald-100 text-emerald-800 border-emerald-200"; + default: + return "bg-gray-100 text-gray-800 border-gray-200"; + } +}; + +const getImpactBadgeStyle = (impact: string) => { + switch (impact) { + case "BOM": + return "bg-blue-100 text-blue-800 border-blue-200"; + case "공정": + return "bg-amber-100 text-amber-800 border-amber-200"; + case "금형": + return "bg-rose-100 text-rose-800 border-rose-200"; + case "검사기준": + return "bg-purple-100 text-purple-800 border-purple-200"; + case "구매": + case "원가": + return "bg-emerald-100 text-emerald-800 border-emerald-200"; + default: + return "bg-gray-100 text-gray-800 border-gray-200"; + } +}; + +// --- Constants --- +const CHANGE_TYPES: ChangeType[] = ["설계오류", "원가절감", "고객요청", "공정개선", "법규대응"]; +const ECR_STATUSES: EcrStatus[] = ["요청접수", "영향도분석", "ECN발행", "기각"]; +const ECN_STATUSES: EcnStatus[] = ["ECN발행", "도면변경", "통보완료", "적용완료"]; +const DEPARTMENTS = ["품질팀", "생산팀", "영업팀", "구매팀", "설계팀"]; +const DESIGNERS = ["이설계", "박도면", "최기구", "김전장"]; +const IMPACT_OPTIONS = [ + { key: "BOM", label: "BOM 변경" }, + { key: "공정", label: "공정 변경" }, + { key: "금형", label: "금형 변경" }, + { key: "검사기준", label: "검사기준 변경" }, + { key: "구매", label: "구매 변경" }, + { key: "원가", label: "원가 영향" }, +]; +const NOTIFY_DEPTS = [ + { key: "생산팀", label: "생산팀" }, + { key: "품질팀", label: "품질팀" }, + { key: "구매팀", label: "구매팀" }, + { key: "영업팀", label: "영업팀" }, + { key: "물류팀", label: "물류팀" }, + { key: "금형팀", label: "금형팀" }, +]; + +// --- API Response Mapping --- +function mapEcrFromApi(raw: any): EcrItem { + const history = (raw.history || []).map((h: any) => ({ + status: h.step || h.status || "", + date: h.history_date || "", + user: h.user_name || "", + desc: h.description || "", + })); + return { + id: raw.request_no || raw.id || "", + _id: raw.id, + date: raw.request_date || "", + changeType: (raw.change_type as ChangeType) || "설계오류", + urgency: (raw.urgency as "보통" | "긴급") || "보통", + status: (raw.status as EcrStatus) || "요청접수", + target: raw.target_name || "", + drawingNo: raw.drawing_no || "", + reqDept: raw.req_dept || "", + requester: raw.requester || "", + reason: raw.reason || "", + content: raw.content || "", + impact: Array.isArray(raw.impact) ? raw.impact : [], + applyTiming: raw.apply_timing || "", + ecnNo: raw.ecn_no || "", + history, + }; +} + +function mapEcnFromApi(raw: any, ecrData: EcrItem[]): EcnItem { + const history = (raw.history || []).map((h: any) => ({ + status: h.status || "", + date: h.history_date || "", + user: h.user_name || "", + desc: h.description || "", + })); + const ecrNo = raw.ecr_id + ? ecrData.find((e) => e._id === raw.ecr_id)?.id ?? raw.ecr_id + : ""; + return { + id: raw.ecn_no || raw.id || "", + _id: raw.id, + ecrNo, + ecrId: raw.ecr_id, + date: raw.ecn_date || "", + applyDate: raw.apply_date || "", + status: (raw.status as EcnStatus) || "ECN발행", + target: raw.target || "", + drawingBefore: raw.drawing_before || "", + drawingAfter: raw.drawing_after || "", + designer: raw.designer || "", + before: raw.before_content || "", + after: raw.after_content || "", + reason: raw.reason || "", + notifyDepts: Array.isArray(raw.notify_depts) ? raw.notify_depts : [], + remark: raw.remark || "", + history, + }; +} + +// --- Timeline Component --- +function Timeline({ history }: { history: EcrHistory[] }) { + return ( +
+ {history.map((h, idx) => { + const isLast = idx === history.length - 1; + const isRejected = h.status === "기각"; + const isCompleted = h.status === "적용완료"; + return ( +
+
+
+ {!isLast && ( +
+ )} +
+
+
+ + {h.status} + +
+

{h.desc}

+

+ {h.date} · {h.user} +

+
+
+ ); + })} +
+ ); +} + +// --- Main Component --- +export default function DesignChangeManagementPage() { + const [currentTab, setCurrentTab] = useState("ecr"); + const [ecrData, setEcrData] = useState([]); + const [ecnData, setEcnData] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedId, setSelectedId] = useState(null); + + // 검색 상태 + const [searchDateFrom, setSearchDateFrom] = useState(""); + const [searchDateTo, setSearchDateTo] = useState(""); + const [searchStatus, setSearchStatus] = useState("all"); + const [searchChangeType, setSearchChangeType] = useState("all"); + const [searchKeyword, setSearchKeyword] = useState(""); + + // ECR 모달 + const [isEcrModalOpen, setIsEcrModalOpen] = useState(false); + const [isEcrEditMode, setIsEcrEditMode] = useState(false); + const [ecrForm, setEcrForm] = useState>({}); + const [ecrImpactChecks, setEcrImpactChecks] = useState>({}); + + // ECN 모달 + const [isEcnModalOpen, setIsEcnModalOpen] = useState(false); + const [ecnForm, setEcnForm] = useState>({}); + const [ecnNotifyChecks, setEcnNotifyChecks] = useState>({}); + + // 기각 모달 + const [isRejectModalOpen, setIsRejectModalOpen] = useState(false); + const [rejectReason, setRejectReason] = useState(""); + const [rejectTargetId, setRejectTargetId] = useState(""); + + useEffect(() => { + const today = new Date(); + const threeMonthsAgo = new Date(today); + threeMonthsAgo.setMonth(today.getMonth() - 3); + setSearchDateFrom(threeMonthsAgo.toISOString().split("T")[0]); + setSearchDateTo(today.toISOString().split("T")[0]); + }, []); + + const fetchData = useCallback(async () => { + setLoading(true); + try { + const [ecrRes, ecnRes] = await Promise.all([ + getDesignRequestList({ source_type: "ecr" }), + getEcnList(), + ]); + if (ecrRes.success && ecrRes.data) { + setEcrData((ecrRes.data as any[]).map(mapEcrFromApi)); + } + if (ecnRes.success && ecnRes.data) { + const ecrList = ecrRes.success && ecrRes.data ? (ecrRes.data as any[]).map(mapEcrFromApi) : []; + setEcnData((ecnRes.data as any[]).map((r) => mapEcnFromApi(r, ecrList))); + } + } catch { + toast.error("데이터를 불러오는데 실패했습니다."); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + // --- Filtered Data --- + const filteredEcr = useMemo(() => { + return ecrData + .filter((item) => { + if (searchDateFrom && item.date < searchDateFrom) return false; + if (searchDateTo && item.date > searchDateTo) return false; + if (searchStatus !== "all" && item.status !== searchStatus) return false; + if (searchChangeType !== "all" && item.changeType !== searchChangeType) return false; + if (searchKeyword) { + const kw = searchKeyword.toLowerCase(); + const str = [item.id, item.target, item.requester, item.drawingNo].join(" ").toLowerCase(); + if (!str.includes(kw)) return false; + } + return true; + }) + .sort((a, b) => b.date.localeCompare(a.date)); + }, [ecrData, searchDateFrom, searchDateTo, searchStatus, searchChangeType, searchKeyword]); + + const filteredEcn = useMemo(() => { + return ecnData + .filter((item) => { + if (searchDateFrom && item.date < searchDateFrom) return false; + if (searchDateTo && item.date > searchDateTo) return false; + if (searchStatus !== "all" && item.status !== searchStatus) return false; + if (searchKeyword) { + const kw = searchKeyword.toLowerCase(); + const str = [item.id, item.target, item.designer, item.ecrNo].join(" ").toLowerCase(); + if (!str.includes(kw)) return false; + } + return true; + }) + .sort((a, b) => b.date.localeCompare(a.date)); + }, [ecnData, searchDateFrom, searchDateTo, searchStatus, searchKeyword]); + + // --- Status Counts --- + const ecrStatusCounts = useMemo(() => { + const counts: Record = {}; + ECR_STATUSES.forEach((s) => (counts[s] = ecrData.filter((r) => r.status === s).length)); + return counts; + }, [ecrData]); + + const ecnStatusCounts = useMemo(() => { + const counts: Record = {}; + ECN_STATUSES.forEach((s) => (counts[s] = ecnData.filter((r) => r.status === s).length)); + return counts; + }, [ecnData]); + + // --- Selected Items --- + const selectedEcr = useMemo( + () => (currentTab === "ecr" ? ecrData.find((r) => r.id === selectedId) : null), + [ecrData, selectedId, currentTab] + ); + const selectedEcn = useMemo( + () => (currentTab === "ecn" ? ecnData.find((r) => r.id === selectedId) : null), + [ecnData, selectedId, currentTab] + ); + + // --- Tab Switch --- + const handleTabSwitch = (tab: TabType) => { + setCurrentTab(tab); + setSelectedId(null); + setSearchStatus("all"); + }; + + // --- Search --- + const handleResetSearch = () => { + const today = new Date(); + const threeMonthsAgo = new Date(today); + threeMonthsAgo.setMonth(today.getMonth() - 3); + setSearchDateFrom(threeMonthsAgo.toISOString().split("T")[0]); + setSearchDateTo(today.toISOString().split("T")[0]); + setSearchStatus("all"); + setSearchChangeType("all"); + setSearchKeyword(""); + }; + + const handleFilterByStatus = (status: string) => { + setSearchStatus(status); + }; + + // --- ECR/ECN Navigation --- + const navigateToLink = (targetId: string) => { + if (targetId.startsWith("ECN")) { + setCurrentTab("ecn"); + setSelectedId(targetId); + setSearchStatus("all"); + } else if (targetId.startsWith("ECR")) { + setCurrentTab("ecr"); + setSelectedId(targetId); + setSearchStatus("all"); + } + }; + + // --- ECR Number Generator --- + const generateEcrNo = useCallback(() => { + const year = new Date().getFullYear(); + const prefix = `ECR-${year}-`; + const existing = ecrData.filter((r) => r.id.startsWith(prefix)); + const maxNum = existing.reduce((max, r) => { + const num = parseInt(r.id.split("-")[2]); + return num > max ? num : max; + }, 0); + return `${prefix}${String(maxNum + 1).padStart(4, "0")}`; + }, [ecrData]); + + const generateEcnNo = useCallback(() => { + const year = new Date().getFullYear(); + const prefix = `ECN-${year}-`; + const existing = ecnData.filter((r) => r.id.startsWith(prefix)); + const maxNum = existing.reduce((max, r) => { + const num = parseInt(r.id.split("-")[2]); + return num > max ? num : max; + }, 0); + return `${prefix}${String(maxNum + 1).padStart(4, "0")}`; + }, [ecnData]); + + // --- ECR Modal --- + const openEcrRegisterModal = () => { + setIsEcrEditMode(false); + setEcrForm({ + id: generateEcrNo(), + date: new Date().toISOString().split("T")[0], + changeType: undefined, + urgency: "보통", + target: "", + drawingNo: "", + reqDept: "", + requester: "", + reason: "", + content: "", + applyTiming: "즉시", + }); + setEcrImpactChecks({}); + setIsEcrModalOpen(true); + }; + + const openEcrEditModal = (id: string) => { + const item = ecrData.find((r) => r.id === id); + if (!item) return; + setIsEcrEditMode(true); + setEcrForm({ ...item }); + const checks: Record = {}; + IMPACT_OPTIONS.forEach((opt) => { + checks[opt.key] = item.impact.includes(opt.key); + }); + setEcrImpactChecks(checks); + setIsEcrModalOpen(true); + }; + + const handleSaveEcr = async () => { + if (!ecrForm.changeType) { + toast.error("변경 유형을 선택하세요."); + return; + } + if (!ecrForm.target?.trim()) { + toast.error("대상 품목/설비를 입력하세요."); + return; + } + if (!ecrForm.reason?.trim()) { + toast.error("변경 사유를 입력하세요."); + return; + } + if (!ecrForm.content?.trim()) { + toast.error("변경 요구 내용을 입력하세요."); + return; + } + + const impact = IMPACT_OPTIONS.filter((opt) => ecrImpactChecks[opt.key]).map((opt) => opt.key); + const reqDate = ecrForm.date || new Date().toISOString().split("T")[0]; + const historyEntry = { + step: "요청접수", + history_date: reqDate, + user_name: ecrForm.requester || "시스템", + description: `${ecrForm.reqDept || ""}에서 ECR 등록`, + }; + + if (isEcrEditMode && ecrForm._id) { + const res = await updateDesignRequest(ecrForm._id, { + request_no: ecrForm.id, + request_date: reqDate, + change_type: ecrForm.changeType, + urgency: ecrForm.urgency || "보통", + target_name: ecrForm.target, + drawing_no: ecrForm.drawingNo || "", + req_dept: ecrForm.reqDept || "", + requester: ecrForm.requester || "", + reason: ecrForm.reason, + content: ecrForm.content, + impact, + apply_timing: ecrForm.applyTiming || "즉시", + }); + if (res.success) { + toast.success("ECR이 수정되었습니다."); + setIsEcrModalOpen(false); + fetchData(); + } else { + toast.error(res.message || "ECR 수정에 실패했습니다."); + } + } else { + const res = await createDesignRequest({ + request_no: ecrForm.id || generateEcrNo(), + source_type: "ecr", + request_date: reqDate, + change_type: ecrForm.changeType, + urgency: ecrForm.urgency || "보통", + status: "요청접수", + target_name: ecrForm.target, + drawing_no: ecrForm.drawingNo || "", + req_dept: ecrForm.reqDept || "", + requester: ecrForm.requester || "", + reason: ecrForm.reason, + content: ecrForm.content, + impact, + apply_timing: ecrForm.applyTiming || "즉시", + history: [historyEntry], + }); + if (res.success) { + toast.success("ECR이 등록되었습니다."); + setIsEcrModalOpen(false); + fetchData(); + } else { + toast.error(res.message || "ECR 등록에 실패했습니다."); + } + } + }; + + // --- ECN Modal --- + const openEcnIssueModal = (ecrId: string) => { + const ecr = ecrData.find((r) => r.id === ecrId); + if (!ecr) return; + + setEcnForm({ + id: generateEcnNo(), + ecrNo: ecrId, + ecrId: ecr._id, + date: new Date().toISOString().split("T")[0], + target: ecr.target, + reason: ecr.reason, + drawingBefore: ecr.drawingNo, + drawingAfter: "", + designer: "", + before: "", + after: "", + applyDate: "", + remark: "", + }); + setEcnNotifyChecks({}); + setIsEcnModalOpen(true); + }; + + const handleSaveEcn = async () => { + if (!ecnForm.after?.trim()) { + toast.error("변경 후(TO-BE) 내용을 입력하세요."); + return; + } + if (!ecnForm.applyDate) { + toast.error("적용일자를 입력하세요."); + return; + } + if (!ecnForm.ecrId) { + toast.error("관련 ECR 정보가 없습니다."); + return; + } + + const notifyDepts = NOTIFY_DEPTS.filter((d) => ecnNotifyChecks[d.key]).map((d) => d.key); + const ecnDate = ecnForm.date || new Date().toISOString().split("T")[0]; + const historyEntry = { + status: "ECN발행", + history_date: ecnDate, + user_name: ecnForm.designer || "시스템", + description: "ECN 발행", + }; + + const ecnNo = ecnForm.id || generateEcnNo(); + const res = await createEcn({ + ecn_no: ecnNo, + ecr_id: ecnForm.ecrId, + ecn_date: ecnDate, + apply_date: ecnForm.applyDate, + status: "ECN발행", + target: ecnForm.target || "", + drawing_before: ecnForm.drawingBefore || "", + drawing_after: ecnForm.drawingAfter || "(미정)", + designer: ecnForm.designer || "", + before_content: ecnForm.before || "", + after_content: ecnForm.after || "", + reason: ecnForm.reason || "", + remark: ecnForm.remark || "", + notify_depts: notifyDepts, + history: [historyEntry], + }); + + if (res.success) { + await updateDesignRequest(ecnForm.ecrId!, { + status: "ECN발행", + ecn_no: ecnNo, + }); + await addRequestHistory(ecnForm.ecrId!, { + step: "ECN발행", + history_date: ecnDate, + user_name: ecnForm.designer || "시스템", + description: `${ecnNo} 발행`, + }); + toast.success("ECN이 발행되었습니다."); + setIsEcnModalOpen(false); + fetchData(); + } else { + toast.error(res.message || "ECN 발행에 실패했습니다."); + } + }; + + // --- ECR Reject --- + const openRejectModal = (id: string) => { + setRejectTargetId(id); + setRejectReason(""); + setIsRejectModalOpen(true); + }; + + const handleRejectSubmit = async () => { + if (!rejectReason.trim()) { + toast.error("기각 사유를 입력하세요."); + return; + } + + const ecr = ecrData.find((r) => r.id === rejectTargetId); + if (!ecr?._id) { + toast.error("ECR 정보를 찾을 수 없습니다."); + return; + } + + const updateRes = await updateDesignRequest(ecr._id, { status: "기각", review_memo: rejectReason }); + if (!updateRes.success) { + toast.error(updateRes.message || "ECR 기각에 실패했습니다."); + return; + } + await addRequestHistory(ecr._id, { + step: "기각", + history_date: new Date().toISOString().split("T")[0], + user_name: "설계팀", + description: rejectReason, + }); + toast.success("ECR이 기각되었습니다."); + setIsRejectModalOpen(false); + fetchData(); + }; + + // --- Stat Cards --- + const ecrStatCards = [ + { label: "요청접수", value: ecrStatusCounts["요청접수"] || 0, gradient: "from-indigo-500 to-blue-600", textColor: "text-white" }, + { label: "영향도분석", value: ecrStatusCounts["영향도분석"] || 0, gradient: "from-amber-400 to-orange-500", textColor: "text-white" }, + { label: "ECN발행", value: ecrStatusCounts["ECN발행"] || 0, gradient: "from-emerald-400 to-green-600", textColor: "text-white" }, + ]; + + const ecnStatCards = [ + { label: "도면변경", value: ecnStatusCounts["도면변경"] || 0, gradient: "from-purple-400 to-violet-600", textColor: "text-white" }, + { label: "통보완료", value: ecnStatusCounts["통보완료"] || 0, gradient: "from-teal-400 to-cyan-600", textColor: "text-white" }, + { label: "적용완료", value: ecnStatusCounts["적용완료"] || 0, gradient: "from-emerald-400 to-green-600", textColor: "text-white" }, + ]; + + const currentStatCards = currentTab === "ecr" ? ecrStatCards : ecnStatCards; + const currentList = currentTab === "ecr" ? filteredEcr : filteredEcn; + const currentStatuses = currentTab === "ecr" ? ECR_STATUSES : ECN_STATUSES; + + return ( +
+ {loading && ( +
+ +
+ )} + {/* 검색 섹션 */} + + +
+ +
+ setSearchDateFrom(e.target.value)} + /> + ~ + setSearchDateTo(e.target.value)} + /> +
+
+ +
+ + +
+ +
+ + +
+ + {currentTab === "ecr" && ( +
+ + +
+ )} + +
+ + setSearchKeyword(e.target.value)} + /> +
+ +
+ +
+ +
+ + + + {/* 메인 분할 레이아웃 */} +
+ + {/* 왼쪽: 목록 */} + +
+
+
+ + {currentTab === "ecr" ? "설계변경요청(ECR) 목록" : "설계변경통지(ECN) 목록"} + + {currentList.length}건 + +
+ {currentTab === "ecr" && ( + + )} +
+ +
+ {currentTab === "ecr" ? ( + + + + No + ECR번호 + 변경유형 + 상태 + 긴급 + 대상 품목/설비 + 도면번호 + 요청부서 + 요청자 + 요청일자 + 관련 ECN + + + + {filteredEcr.length === 0 ? ( + + +
+ + 조건에 맞는 ECR이 없습니다 +
+
+
+ ) : ( + filteredEcr.map((item, idx) => ( + setSelectedId(item.id)} + > + {idx + 1} + {item.id} + + + {item.changeType} + + + + + {item.status} + + + + {item.urgency === "긴급" ? ( + + 긴급 + + ) : ( + "-" + )} + + {item.target} + {item.drawingNo} + {item.reqDept} + {item.requester} + {item.date} + + {item.ecnNo ? ( + + ) : ( + "-" + )} + + + )) + )} +
+
+ ) : ( + + + + No + ECN번호 + 상태 + 대상 품목/설비 + 도면 (변경 후) + 설계담당 + 발행일자 + 적용일자 + 통보 부서 + 관련 ECR + + + + {filteredEcn.length === 0 ? ( + + +
+ + 조건에 맞는 ECN이 없습니다 +
+
+
+ ) : ( + filteredEcn.map((item, idx) => ( + setSelectedId(item.id)} + > + {idx + 1} + {item.id} + + + {item.status} + + + {item.target} + {item.drawingAfter} + {item.designer} + {item.date} + {item.applyDate} + {item.notifyDepts.join(", ")} + + + + + )) + )} +
+
+ )} +
+
+
+ + + + {/* 오른쪽: 상세 */} + +
+
+ + + 상세 정보 + + {selectedEcr && ( +
+ + {selectedEcr.status === "영향도분석" && ( + <> + + + + )} +
+ )} +
+ +
+ {/* 현황 카드 */} +
+ {currentStatCards.map((card) => ( + + ))} +
+ + {/* ECR 상세 */} + {selectedEcr ? ( +
+
+

+ 기본 정보 +

+
+
+ ECR번호 + {selectedEcr.id} +
+
+ 상태 + + {selectedEcr.status} + +
+
+ 변경 유형 + + {selectedEcr.changeType} + +
+
+ 긴급도 + + {selectedEcr.urgency === "긴급" ? ( + 긴급 + ) : ( + "보통" + )} + +
+
+ 대상 품목/설비 + {selectedEcr.target} +
+
+ 도면번호 + {selectedEcr.drawingNo} +
+
+ 요청부서 / 요청자 + {selectedEcr.reqDept} / {selectedEcr.requester} +
+
+ 요청일자 + {selectedEcr.date} +
+
+ 희망 적용시점 + {selectedEcr.applyTiming} +
+
+ 관련 ECN + {selectedEcr.ecnNo ? ( + + ) : ( + 미발행 + )} +
+
+
+ +
+

변경 사유

+
+ {selectedEcr.reason} +
+
+ +
+

변경 요구 내용

+
+ {selectedEcr.content} +
+
+ +
+

영향 범위

+
+ {selectedEcr.impact.map((imp) => ( + + {imp} + + ))} +
+
+ +
+

처리 이력

+ +
+
+ ) : selectedEcn ? ( +
+
+

+ ECN 기본 정보 +

+
+
+ ECN번호 + {selectedEcn.id} +
+
+ 상태 + + {selectedEcn.status} + +
+
+ 대상 품목/설비 + {selectedEcn.target} +
+
+ 설계담당 + {selectedEcn.designer} +
+
+ 발행일자 + {selectedEcn.date} +
+
+ 적용일자 + {selectedEcn.applyDate} +
+
+ 관련 ECR + +
+
+ 통보 부서 + {selectedEcn.notifyDepts.join(", ")} +
+
+
+ +
+

변경 전/후 비교

+
+
+
+ 변경 전 ({selectedEcn.drawingBefore}) +
+
{selectedEcn.before}
+
+
+
+ 변경 후 ({selectedEcn.drawingAfter}) +
+
{selectedEcn.after}
+
+
+
+ +
+

변경 사유

+
+ {selectedEcn.reason} +
+ {selectedEcn.remark && ( +

비고: {selectedEcn.remark}

+ )} +
+ +
+

처리 이력

+ +
+
+ ) : ( +
+
+ +
+

좌측 목록에서 항목을 선택하세요

+
+ )} +
+
+
+
+
+ + {/* ECR 등록/수정 모달 */} + + + + + {isEcrEditMode ? "설계변경요청(ECR) 수정" : "설계변경요청(ECR) 등록"} + + + {isEcrEditMode ? "ECR 정보를 수정합니다." : "새로운 설계변경요청을 등록합니다."} + + + +
+
+ {/* 좌측: 요청 정보 */} +
+

변경 요청 정보

+ +
+ + +
+ +
+
+ + setEcrForm((p) => ({ ...p, date: e.target.value }))} + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + setEcrForm((p) => ({ ...p, target: e.target.value }))} + placeholder="품목코드 / 설비명" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ +
+ + setEcrForm((p) => ({ ...p, drawingNo: e.target.value }))} + placeholder="DWG-XXX-XXX" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ +
+
+ + +
+
+ + setEcrForm((p) => ({ ...p, requester: e.target.value }))} + placeholder="요청자명" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+
+ + {/* 우측: 변경 내용 */} +
+
+

변경 내용

+ +
+ +