From 160b78e70f1a2fae20739b4c4e12d97162bea1ae Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 19 Mar 2026 15:08:31 +0900 Subject: [PATCH] feat: add shipping order and design management features - Introduced new routes and controllers for managing shipping orders, including listing, saving, and previewing next order numbers. - Added design management routes and controller for handling design requests, projects, tasks, and work logs. - Implemented company code filtering for multi-tenancy support in both shipping order and design request functionalities. - Enhanced the shipping plan routes to include listing and updating plans, improving overall shipping management capabilities. These changes aim to provide comprehensive management features for shipping orders and design processes, facilitating better organization and tracking within the application. --- backend-node/src/app.ts | 4 + .../src/controllers/designController.ts | 946 ++++++++ .../controllers/shippingOrderController.ts | 482 +++++ .../src/controllers/shippingPlanController.ts | 212 ++ backend-node/src/routes/designRoutes.ts | 67 + .../src/routes/shippingOrderRoutes.ts | 21 + backend-node/src/routes/shippingPlanRoutes.ts | 6 + .../(main)/design/change-management/page.tsx | 1655 ++++++++++++++ frontend/app/(main)/design/my-work/page.tsx | 1897 +++++++++++++++++ frontend/app/(main)/design/project/page.tsx | 1512 +++++++++++++ .../(main)/design/task-management/page.tsx | 1096 ++++++++++ .../(main)/logistics/material-status/page.tsx | 609 ++++++ frontend/app/(main)/sales/claim/page.tsx | 893 ++++++++ .../app/(main)/sales/shipping-order/page.tsx | 826 +++++++ .../app/(main)/sales/shipping-plan/page.tsx | 530 +++++ .../components/layout/AdminPageRenderer.tsx | 14 + frontend/components/layout/AppLayout.tsx | 8 + frontend/lib/api/design.ts | 330 +++ frontend/lib/api/shipping.ts | 99 + frontend/package-lock.json | 42 +- 20 files changed, 11212 insertions(+), 37 deletions(-) create mode 100644 backend-node/src/controllers/designController.ts create mode 100644 backend-node/src/controllers/shippingOrderController.ts create mode 100644 backend-node/src/routes/designRoutes.ts create mode 100644 backend-node/src/routes/shippingOrderRoutes.ts create mode 100644 frontend/app/(main)/design/change-management/page.tsx create mode 100644 frontend/app/(main)/design/my-work/page.tsx create mode 100644 frontend/app/(main)/design/project/page.tsx create mode 100644 frontend/app/(main)/design/task-management/page.tsx create mode 100644 frontend/app/(main)/logistics/material-status/page.tsx create mode 100644 frontend/app/(main)/sales/claim/page.tsx create mode 100644 frontend/app/(main)/sales/shipping-order/page.tsx create mode 100644 frontend/app/(main)/sales/shipping-plan/page.tsx create mode 100644 frontend/lib/api/design.ts 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" + /> +
+
+
+ + {/* 우측: 변경 내용 */} +
+
+

변경 내용

+ +
+ +