diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index ebf4c081..a8d27e8f 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -120,6 +120,7 @@ import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변 import bomRoutes from "./routes/bomRoutes"; // BOM 이력/버전 관리 import productionRoutes from "./routes/productionRoutes"; // 생산계획 관리 import itemInspectionRoutes from "./routes/itemInspectionRoutes"; // 품목검사정보 +import salesOrderAuditRoutes from "./routes/salesOrderAuditRoutes"; // 수주 변경 이력 import crawlRoutes from "./routes/crawlRoutes"; // 웹 크롤링 import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리 import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리 @@ -360,6 +361,7 @@ app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 app.use("/api/bom", bomRoutes); // BOM 이력/버전 관리 app.use("/api/production", productionRoutes); // 생산계획 관리 app.use("/api/item-inspection", itemInspectionRoutes); // 품목검사정보 (그룹 페이징) +app.use("/api/sales-order", salesOrderAuditRoutes); // 수주 변경 이력 app.use("/api/crawl", crawlRoutes); // 웹 크롤링 app.use("/api/material-status", materialStatusRoutes); // 자재현황 app.use("/api/process-info", processInfoRoutes); // 공정정보관리 diff --git a/backend-node/src/controllers/salesOrderAuditController.ts b/backend-node/src/controllers/salesOrderAuditController.ts new file mode 100644 index 00000000..1ab573a5 --- /dev/null +++ b/backend-node/src/controllers/salesOrderAuditController.ts @@ -0,0 +1,304 @@ +/** + * 수주 변경 이력 통합 조회 컨트롤러 + * - sales_order_mng_log (마스터) + sales_order_detail_log (라인) 통합 타임라인 + * - 컬럼명을 회사별 한글 라벨로 변환 (table_type_columns.displayName) + */ + +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; + +interface TimelineEvent { + log_id: number; + changed_at: string; + changed_by: string | null; + changed_by_name?: string | null; // user_info.user_name (화면 표시용) + action: "INSERT" | "UPDATE" | "DELETE"; + ref_table: "master" | "detail"; + ref_label: string; // 화면 표시용 (예: "수주", "라인 #2") + original_id: string; + changed_column: string | null; + changed_column_label: string | null; + old_value: string | null; + new_value: string | null; +} + +export async function getOrderAuditLog(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const orderNo = (req.params.orderNo || "").trim(); + if (!orderNo) { + return res.status(400).json({ success: false, message: "orderNo가 필요합니다" }); + } + + const pool = getPool(); + + // 1) 마스터 ID와 디테일 ID 목록 (현재 살아있는 행 기준) + const masterRes = await pool.query( + `SELECT id::text AS id FROM sales_order_mng WHERE company_code = $1 AND order_no = $2`, + [companyCode, orderNo] + ); + const masterIds: string[] = masterRes.rows.map((r) => r.id); + + const detailRes = await pool.query( + `SELECT id::text AS id FROM sales_order_detail WHERE company_code = $1 AND order_no = $2`, + [companyCode, orderNo] + ); + const detailIds: string[] = detailRes.rows.map((r) => r.id); + + // 2) 컬럼 한글 라벨 맵 (회사별 column_label 우선, 공통 fallback) + const labelRes = await pool.query( + `SELECT table_name, column_name, company_code, + COALESCE(NULLIF(column_label, ''), column_name) AS label + FROM table_type_columns + WHERE table_name IN ('sales_order_mng', 'sales_order_detail') + AND (company_code = $1 OR company_code IS NULL OR company_code = '')`, + [companyCode] + ); + const labelMap = new Map(); + // 우선 공통 라벨 채움, 그 위에 회사별 라벨로 덮어쓰기 + for (const r of labelRes.rows) { + if (!r.company_code) labelMap.set(`${r.table_name}::${r.column_name}`, r.label); + } + for (const r of labelRes.rows) { + if (r.company_code) labelMap.set(`${r.table_name}::${r.column_name}`, r.label); + } + + // 3) 마스터 로그 조회 (현 마스터 ID + full_row_after/before에서 order_no 매칭으로 삭제 이력까지) + const masterLogQuery = ` + SELECT log_id, operation_type AS action, original_id, + changed_column, old_value, new_value, changed_by, changed_at, + 'master'::text AS ref_table + FROM sales_order_mng_log + WHERE ( + ($1::text[] IS NOT NULL AND original_id = ANY($1::text[])) + OR full_row_after->>'order_no' = $2 + OR full_row_before->>'order_no' = $2 + ) + AND COALESCE(full_row_after->>'company_code', full_row_before->>'company_code') = $3 + `; + const masterLogRes = await pool.query(masterLogQuery, [masterIds, orderNo, companyCode]); + + // 4) 디테일 로그는 5-A 단계에서 full_row_before/after까지 함께 조회 + // 5) 두 결과 병합 + 컬럼 라벨 매핑 + 정렬 + + // 5-A) detail 로그에서 "전체 DELETE → 다시 INSERT" 노이즈를 UPDATE로 합치기 + // - 같은 시각(초 단위) 내에서 part_code 매칭으로 페어링 + // - full_row_before/after를 diff하여 실제 변경된 필드만 UPDATE 이벤트 생성 + // - 변경 필드 없는 페어(완전 동일)는 이벤트 자체 생략 + // - 매칭 안 된 단독 INSERT/DELETE는 그대로 유지 + const detailIgnoredColumns = new Set([ + "id", "created_date", "updated_date", "writer", "seq_no", + ]); + type DetailRaw = { + log_id: number; + action: "INSERT" | "UPDATE" | "DELETE"; + original_id: string; + changed_column: string | null; + old_value: string | null; + new_value: string | null; + changed_by: string | null; + changed_at: string; + seq_no: string | null; + part_code: string | null; + full_row_before?: any; + full_row_after?: any; + }; + // 같은 시각(초 단위)으로 그룹핑 + const detailRawRows: DetailRaw[] = (await pool.query( + `SELECT log_id, operation_type AS action, original_id, + changed_column, old_value, new_value, changed_by, changed_at, + COALESCE(full_row_after->>'seq_no', full_row_before->>'seq_no') AS seq_no, + COALESCE(full_row_after->>'part_code', full_row_before->>'part_code') AS part_code, + full_row_before, full_row_after + FROM sales_order_detail_log + WHERE ( + ($1::text[] IS NOT NULL AND original_id = ANY($1::text[])) + OR full_row_after->>'order_no' = $2 + OR full_row_before->>'order_no' = $2 + ) + AND COALESCE(full_row_after->>'company_code', full_row_before->>'company_code') = $3`, + [detailIds, orderNo, companyCode] + )).rows; + + const detailFinalEvents: TimelineEvent[] = []; + const groupedBySec = new Map(); + for (const r of detailRawRows) { + const sec = new Date(r.changed_at).toISOString().slice(0, 19); + if (!groupedBySec.has(sec)) groupedBySec.set(sec, []); + groupedBySec.get(sec)!.push(r); + } + Array.from(groupedBySec.values()).forEach((group) => { + // UPDATE 직접 발생한 건 그대로 통과 + const directUpdates = group.filter((g) => g.action === "UPDATE"); + const inserts = group.filter((g) => g.action === "INSERT"); + const deletes = group.filter((g) => g.action === "DELETE"); + + // part_code 키로 INSERT/DELETE 매칭 (같은 키는 가장 빠른 것끼리 짝) + const insertsByKey = new Map(); + for (const ins of inserts) { + const k = ins.part_code || "__nokey__"; + if (!insertsByKey.has(k)) insertsByKey.set(k, []); + insertsByKey.get(k)!.push(ins); + } + const matchedInsertIds = new Set(); + const matchedDeleteIds = new Set(); + + for (const del of deletes) { + const k = del.part_code || "__nokey__"; + const candidates = insertsByKey.get(k); + if (!candidates || candidates.length === 0) continue; + const ins = candidates.shift()!; + matchedInsertIds.add(ins.log_id); + matchedDeleteIds.add(del.log_id); + + const before = del.full_row_before || {}; + const after = ins.full_row_after || {}; + const allKeys = Array.from(new Set([...Object.keys(before), ...Object.keys(after)])); + for (const key of allKeys) { + if (detailIgnoredColumns.has(key)) continue; + const oldVal = before[key] ?? null; + const newVal = after[key] ?? null; + if (String(oldVal ?? "") === String(newVal ?? "")) continue; + const seqLabel = (after.seq_no || before.seq_no) ? `라인 #${after.seq_no || before.seq_no}` : "라인"; + const partLabel = (after.part_code || before.part_code) ? ` (${after.part_code || before.part_code})` : ""; + detailFinalEvents.push({ + log_id: ins.log_id, // 정렬 안정성용 + changed_at: ins.changed_at, + changed_by: ins.changed_by, + action: "UPDATE", + ref_table: "detail", + ref_label: `${seqLabel}${partLabel}`, + original_id: ins.original_id, + changed_column: key, + changed_column_label: labelMap.get(`sales_order_detail::${key}`) || key, + old_value: oldVal != null ? String(oldVal) : null, + new_value: newVal != null ? String(newVal) : null, + }); + } + } + + // 매칭 안 된 단독 INSERT (= 새 라인 추가) + for (const ins of inserts) { + if (matchedInsertIds.has(ins.log_id)) continue; + const seqLabel = ins.seq_no ? `라인 #${ins.seq_no}` : "라인"; + const partLabel = ins.part_code ? ` (${ins.part_code})` : ""; + detailFinalEvents.push({ + log_id: ins.log_id, + changed_at: ins.changed_at, + changed_by: ins.changed_by, + action: "INSERT", + ref_table: "detail", + ref_label: `${seqLabel}${partLabel}`, + original_id: ins.original_id, + changed_column: null, + changed_column_label: null, + old_value: null, + new_value: null, + }); + } + // 매칭 안 된 단독 DELETE (= 라인 제거) + for (const del of deletes) { + if (matchedDeleteIds.has(del.log_id)) continue; + const seqLabel = del.seq_no ? `라인 #${del.seq_no}` : "라인"; + const partLabel = del.part_code ? ` (${del.part_code})` : ""; + detailFinalEvents.push({ + log_id: del.log_id, + changed_at: del.changed_at, + changed_by: del.changed_by, + action: "DELETE", + ref_table: "detail", + ref_label: `${seqLabel}${partLabel}`, + original_id: del.original_id, + changed_column: null, + changed_column_label: null, + old_value: null, + new_value: null, + }); + } + // 직접 UPDATE 이벤트 + for (const u of directUpdates) { + const seqLabel = u.seq_no ? `라인 #${u.seq_no}` : "라인"; + const partLabel = u.part_code ? ` (${u.part_code})` : ""; + detailFinalEvents.push({ + log_id: u.log_id, + changed_at: u.changed_at, + changed_by: u.changed_by, + action: "UPDATE", + ref_table: "detail", + ref_label: `${seqLabel}${partLabel}`, + original_id: u.original_id, + changed_column: u.changed_column, + changed_column_label: u.changed_column + ? labelMap.get(`sales_order_detail::${u.changed_column}`) || u.changed_column + : null, + old_value: u.old_value, + new_value: u.new_value, + }); + } + }); + + const timeline: TimelineEvent[] = []; + for (const r of masterLogRes.rows) { + // 마스터의 updated_date 같은 메타 필드는 노이즈 → 숨김 + if (r.changed_column && ["updated_date", "updated_by"].includes(r.changed_column)) continue; + timeline.push({ + log_id: r.log_id, + changed_at: r.changed_at, + changed_by: r.changed_by, + action: r.action, + ref_table: "master", + ref_label: "수주", + original_id: r.original_id, + changed_column: r.changed_column, + changed_column_label: r.changed_column + ? labelMap.get(`sales_order_mng::${r.changed_column}`) || r.changed_column + : null, + old_value: r.old_value, + new_value: r.new_value, + }); + } + timeline.push(...detailFinalEvents); + + // 시간 역순 (최신 먼저), 동일 시각이면 log_id 역순 + timeline.sort((a, b) => { + const ta = new Date(a.changed_at).getTime(); + const tb = new Date(b.changed_at).getTime(); + if (ta !== tb) return tb - ta; + return b.log_id - a.log_id; + }); + + // changed_by(user_id)를 사용자명(user_info.user_name)으로 매핑 + const userIds = Array.from(new Set(timeline.map((t) => t.changed_by).filter((v): v is string => !!v && v.trim() !== ""))); + const userNameMap = new Map(); + if (userIds.length > 0) { + const userRes = await pool.query( + `SELECT user_id, COALESCE(NULLIF(user_name, ''), user_id) AS user_name + FROM user_info WHERE user_id = ANY($1::text[])`, + [userIds] + ); + for (const u of userRes.rows) userNameMap.set(u.user_id, u.user_name); + } + for (const ev of timeline) { + ev.changed_by_name = ev.changed_by ? (userNameMap.get(ev.changed_by) || ev.changed_by) : null; + } + + logger.info("수주 audit log 조회", { + companyCode, + orderNo, + masterEvents: masterLogRes.rowCount, + detailEvents: detailFinalEvents.length, + }); + + return res.json({ + success: true, + orderNo, + total: timeline.length, + timeline, + }); + } catch (error: any) { + logger.error("수주 audit log 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} diff --git a/backend-node/src/controllers/salesOrderPackagingController.ts b/backend-node/src/controllers/salesOrderPackagingController.ts new file mode 100644 index 00000000..285c0f74 --- /dev/null +++ b/backend-node/src/controllers/salesOrderPackagingController.ts @@ -0,0 +1,53 @@ +/** + * 수주 등록/수정 모달 — 품목별 등록 포장재 옵션 조회 + * - pkg_unit_item에서 item_number로 매핑된 포장재 + 입수수량 + * - pkg_unit JOIN으로 포장재 이름·종류 함께 반환 + */ + +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; + +export async function getPackagingOptions(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const itemNumber = (req.params.itemNumber || "").trim(); + if (!itemNumber) { + return res.status(400).json({ success: false, message: "itemNumber가 필요합니다" }); + } + + const pool = getPool(); + const result = await pool.query( + `SELECT + pui.pkg_code, + pui.pkg_qty, + pu.pkg_name, + pu.pkg_type + FROM pkg_unit_item pui + LEFT JOIN pkg_unit pu + ON pu.pkg_code = pui.pkg_code AND pu.company_code = pui.company_code + WHERE pui.company_code = $1 + AND pui.item_number = $2 + ORDER BY pui.created_date ASC`, + [companyCode, itemNumber] + ); + + const options = result.rows.map((r: any) => ({ + pkg_code: r.pkg_code, + pkg_name: r.pkg_name || r.pkg_code, + pkg_type: r.pkg_type || "", + pkg_qty_per_unit: Number(r.pkg_qty) || 0, // 1 포장당 입수 수량 + })); + + return res.json({ + success: true, + itemNumber, + total: options.length, + options, + }); + } 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/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index 12cb7de7..4edb6ec7 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -1215,6 +1215,16 @@ export async function editTableData( } } + // 🆕 updated_by 자동 추가 (테이블에 컬럼이 있고 클라이언트가 명시 안 한 경우) + // audit 트리거가 NEW.updated_by를 fallback으로 사용하므로 수정자 식별에 필요 + const editorUserId = req.user?.userId; + if (editorUserId && !updatedData.updated_by) { + const hasUpdatedByCol = await tableManagementService.hasColumn(tableName, "updated_by"); + if (hasUpdatedByCol) { + updatedData.updated_by = editorUserId; + } + } + // 회사별 NOT NULL 소프트 제약조건 검증 (수정 데이터 대상) const notNullViolations = await tableManagementService.validateNotNullConstraints( tableName, diff --git a/backend-node/src/routes/salesOrderAuditRoutes.ts b/backend-node/src/routes/salesOrderAuditRoutes.ts new file mode 100644 index 00000000..64631d0b --- /dev/null +++ b/backend-node/src/routes/salesOrderAuditRoutes.ts @@ -0,0 +1,20 @@ +/** + * 수주 변경 이력 라우트 + */ + +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import * as auditController from "../controllers/salesOrderAuditController"; +import * as packagingController from "../controllers/salesOrderPackagingController"; + +const router = Router(); + +router.use(authenticateToken); + +// 수주번호 단위 통합 audit 타임라인 (마스터 + 디테일) +router.get("/audit-log/:orderNo", auditController.getOrderAuditLog); + +// 품목별 등록 포장재 옵션 조회 (수주 등록 모달 자동매칭용) +router.get("/packaging-options/:itemNumber", packagingController.getPackagingOptions); + +export default router; diff --git a/backend-node/src/services/productionPlanService.ts b/backend-node/src/services/productionPlanService.ts index 570816f6..850b6f20 100644 --- a/backend-node/src/services/productionPlanService.ts +++ b/backend-node/src/services/productionPlanService.ts @@ -69,7 +69,7 @@ export async function getOrderSummary( COALESCE(sd.qty::numeric, 0) AS order_qty, COALESCE(sd.ship_qty::numeric, 0) AS ship_qty, COALESCE(sd.balance_qty::numeric, sd.qty::numeric - COALESCE(sd.ship_qty::numeric, 0), 0) AS balance_qty, - sd.due_date::date, so.status, so.partner_id, so.manager_name + NULLIF(sd.due_date, '')::date, so.status, so.partner_id, so.manager_name FROM sales_order_detail sd INNER JOIN sales_order_mng so ON sd.order_no = so.order_no AND sd.company_code = so.company_code WHERE sd.company_code = $1 @@ -201,7 +201,7 @@ export async function getOrderSummary( COALESCE(sd.qty::numeric, 0) AS order_qty, COALESCE(sd.ship_qty::numeric, 0) AS ship_qty, COALESCE(sd.balance_qty::numeric, COALESCE(sd.qty::numeric, 0) - COALESCE(sd.ship_qty::numeric, 0), 0) AS balance_qty, - sd.due_date::date, so.status, so.partner_id, so.manager_name + NULLIF(sd.due_date, '')::date, so.status, so.partner_id, so.manager_name FROM sales_order_detail sd INNER JOIN sales_order_mng so ON sd.order_no = so.order_no AND sd.company_code = so.company_code WHERE sd.company_code = $1 @@ -228,7 +228,7 @@ export async function getOrderSummary( COALESCE(sd.qty::numeric, 0) AS order_qty, COALESCE(sd.ship_qty::numeric, 0) AS ship_qty, COALESCE(sd.balance_qty::numeric, COALESCE(sd.qty::numeric, 0) - COALESCE(sd.ship_qty::numeric, 0), 0) AS balance_qty, - sd.due_date::date, so.status, so.partner_id, so.manager_name + NULLIF(sd.due_date, '')::date, so.status, so.partner_id, so.manager_name FROM sales_order_detail sd INNER JOIN sales_order_mng so ON sd.order_no = so.order_no AND sd.company_code = so.company_code WHERE sd.company_code = $1 diff --git a/frontend/app/(main)/COMPANY_10/equipment/inspection-record/page.tsx b/frontend/app/(main)/COMPANY_10/equipment/inspection-record/page.tsx index 066ec9a8..801fc0fb 100644 --- a/frontend/app/(main)/COMPANY_10/equipment/inspection-record/page.tsx +++ b/frontend/app/(main)/COMPANY_10/equipment/inspection-record/page.tsx @@ -58,6 +58,9 @@ export default function EquipmentInspectionRecordPage() { const [loading, setLoading] = useState(false); const [selectedId, setSelectedId] = useState(null); const [filterValues, setFilterValues] = useState([]); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(50); + const [total, setTotal] = useState(0); // ─── 데이터 조회 ──────────────────────────────────────── diff --git a/frontend/app/(main)/COMPANY_10/sales/order/page.tsx b/frontend/app/(main)/COMPANY_10/sales/order/page.tsx index e6ec842b..c1b9a21d 100644 --- a/frontend/app/(main)/COMPANY_10/sales/order/page.tsx +++ b/frontend/app/(main)/COMPANY_10/sales/order/page.tsx @@ -626,9 +626,29 @@ export default function SalesOrderPage() { const detailData = detailRes.data?.data?.data || detailRes.data?.data?.rows || []; setMasterForm(masterData || {}); - setDetailRows(detailData.map((d: any, i: number) => ({ ...d, _id: d.id || `row_${i}` }))); + const initialRows = detailData.map((d: any, i: number) => ({ + ...d, + _id: d.id || `row_${i}`, + pkg_options: [] as any[], + })); + setDetailRows(initialRows); setIsEditMode(true); setIsModalOpen(true); + + void Promise.all( + initialRows.map(async (r: any) => { + if (!r.part_code) return null; + try { + const res = await apiClient.get(`/sales-order/packaging-options/${encodeURIComponent(r.part_code)}`); + return { id: r._id, opts: res.data?.options || [] }; + } catch { return { id: r._id, opts: [] }; } + }) + ).then((results) => { + setDetailRows((prev) => prev.map((row) => { + const found = results.find((rr: any) => rr && rr.id === row._id); + return found ? { ...row, pkg_options: found.opts } : row; + })); + }); } catch (err) { toast.error("수주 정보를 불러오는데 실패했습니다."); } @@ -731,7 +751,11 @@ export default function SalesOrderPage() { await apiClient.post(`/table-management/tables/${MASTER_TABLE}/add`, masterFields); } for (const row of detailRows) { - const { _id, id: rowId, created_date: _cd, updated_date: _ud, writer: _w, company_code: _cc, ...detailFields } = row; + const { + _id, id: rowId, created_date: _cd, updated_date: _ud, writer: _w, company_code: _cc, + pkg_options: _opts, + ...detailFields + } = row; await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/add`, { ...detailFields, id: crypto.randomUUID(), @@ -902,10 +926,12 @@ export default function SalesOrderPage() { part_name: item.item_name, spec: item.size || "", material: getCategoryLabel("item_material", item.material) || item.material || "", - packing_material: "", + pkg_code: "", + pkg_qty_per_unit: "0", + pkg_options: [] as Array<{ pkg_code: string; pkg_name: string; pkg_type: string; pkg_qty_per_unit: number }>, unit: getCategoryLabel("item_inventory_unit", item.inventory_unit) || item.inventory_unit || "", qty: "1", - pack_qty: "0", + pack_count: "0", unit_price: unitPrice, amount: unitPrice ? String(1 * parseFloat(unitPrice)) : "", due_date: "", @@ -916,6 +942,38 @@ export default function SalesOrderPage() { toast.success(`${selected.length}개 품목이 추가되었습니다.`); setItemSelectedMap(new Map()); setItemSelectOpen(false); + + void Promise.all( + newRows.map(async (nr) => { + if (!nr.part_code) return null; + try { + const res = await apiClient.get(`/sales-order/packaging-options/${encodeURIComponent(nr.part_code)}`); + const opts = (res.data?.options || []) as Array<{ pkg_code: string; pkg_name: string; pkg_type: string; pkg_qty_per_unit: number }>; + return { id: nr._id, opts }; + } catch { return { id: nr._id, opts: [] as any[] }; } + }) + ).then((results) => { + setDetailRows((prev) => prev.map((row) => { + const found = results.find((r) => r && r.id === row._id); + if (!found) return row; + const opts = found.opts; + if (opts.length === 0) return { ...row, pkg_options: [] }; + if (opts.length === 1) { + const o = opts[0]; + const qtyN = parseFloat(row.qty) || 0; + const perUnit = Number(o.pkg_qty_per_unit) || 0; + const pack = perUnit > 0 ? Math.ceil(qtyN / perUnit) : 0; + return { + ...row, + pkg_options: opts, + pkg_code: o.pkg_code, + pkg_qty_per_unit: String(perUnit), + pack_count: String(pack), + }; + } + return { ...row, pkg_options: opts }; + })); + }); }; // 단가 재계산: 단가방식/거래처 변경 시 기존 품목 단가 갱신 @@ -986,9 +1044,29 @@ export default function SalesOrderPage() { setDetailRows((prev) => { const next = [...prev]; next[idx] = { ...next[idx], [field]: value }; - if (field === "qty" || field === "unit_price") { - const qty = parseFloat(field === "qty" ? value : next[idx].qty) || 0; - const price = parseFloat(field === "unit_price" ? value : next[idx].unit_price) || 0; + + if (field === "pkg_code") { + const opts = (next[idx].pkg_options || []) as Array<{ pkg_code: string; pkg_qty_per_unit: number }>; + const sel = opts.find((o) => o.pkg_code === value); + const perUnit = sel ? Number(sel.pkg_qty_per_unit) || 0 : 0; + next[idx].pkg_qty_per_unit = String(perUnit); + const qtyN = parseFloat(next[idx].qty) || 0; + next[idx].pack_count = perUnit > 0 ? String(Math.ceil(qtyN / perUnit)) : "0"; + } + if (field === "qty") { + const perUnit = parseFloat(next[idx].pkg_qty_per_unit) || 0; + const qtyN = parseFloat(value) || 0; + if (perUnit > 0) next[idx].pack_count = String(Math.ceil(qtyN / perUnit)); + } + if (field === "pack_count") { + const perUnit = parseFloat(next[idx].pkg_qty_per_unit) || 0; + const packN = parseFloat(value) || 0; + if (perUnit > 0) next[idx].qty = String(packN * perUnit); + } + + if (field === "qty" || field === "unit_price" || field === "pack_count") { + const qty = parseFloat(next[idx].qty) || 0; + const price = parseFloat(next[idx].unit_price) || 0; next[idx].amount = (qty * price).toString(); } return next; @@ -1661,12 +1739,20 @@ export default function SalesOrderPage() { {row.spec} {row.material} - updateDetailRow(idx, "packing_material", e.target.value)} - placeholder="포장재" - className="h-8 text-xs w-full" - /> + {(row.pkg_options && row.pkg_options.length > 0) ? ( + + ) : ( + 등록된 포장재 없음 + )} updateDetailRow(idx, "pack_qty", e.target.value)} + value={row.pack_count || "0"} + onChange={(e) => updateDetailRow(idx, "pack_count", e.target.value)} className="h-8 text-xs text-right font-mono w-full" + disabled={!row.pkg_code} /> diff --git a/frontend/app/(main)/COMPANY_16/equipment/inspection-record/page.tsx b/frontend/app/(main)/COMPANY_16/equipment/inspection-record/page.tsx index 066ec9a8..801fc0fb 100644 --- a/frontend/app/(main)/COMPANY_16/equipment/inspection-record/page.tsx +++ b/frontend/app/(main)/COMPANY_16/equipment/inspection-record/page.tsx @@ -58,6 +58,9 @@ export default function EquipmentInspectionRecordPage() { const [loading, setLoading] = useState(false); const [selectedId, setSelectedId] = useState(null); const [filterValues, setFilterValues] = useState([]); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(50); + const [total, setTotal] = useState(0); // ─── 데이터 조회 ──────────────────────────────────────── diff --git a/frontend/app/(main)/COMPANY_16/sales/order/page.tsx b/frontend/app/(main)/COMPANY_16/sales/order/page.tsx index 766a867c..771abf0d 100644 --- a/frontend/app/(main)/COMPANY_16/sales/order/page.tsx +++ b/frontend/app/(main)/COMPANY_16/sales/order/page.tsx @@ -626,9 +626,29 @@ export default function SalesOrderPage() { const detailData = detailRes.data?.data?.data || detailRes.data?.data?.rows || []; setMasterForm(masterData || {}); - setDetailRows(detailData.map((d: any, i: number) => ({ ...d, _id: d.id || `row_${i}` }))); + const initialRows = detailData.map((d: any, i: number) => ({ + ...d, + _id: d.id || `row_${i}`, + pkg_options: [] as any[], + })); + setDetailRows(initialRows); setIsEditMode(true); setIsModalOpen(true); + + void Promise.all( + initialRows.map(async (r: any) => { + if (!r.part_code) return null; + try { + const res = await apiClient.get(`/sales-order/packaging-options/${encodeURIComponent(r.part_code)}`); + return { id: r._id, opts: res.data?.options || [] }; + } catch { return { id: r._id, opts: [] }; } + }) + ).then((results) => { + setDetailRows((prev) => prev.map((row) => { + const found = results.find((rr: any) => rr && rr.id === row._id); + return found ? { ...row, pkg_options: found.opts } : row; + })); + }); } catch (err) { toast.error("수주 정보를 불러오는데 실패했습니다."); } @@ -731,7 +751,11 @@ export default function SalesOrderPage() { await apiClient.post(`/table-management/tables/${MASTER_TABLE}/add`, masterFields); } for (const row of detailRows) { - const { _id, id: rowId, created_date: _cd, updated_date: _ud, writer: _w, company_code: _cc, ...detailFields } = row; + const { + _id, id: rowId, created_date: _cd, updated_date: _ud, writer: _w, company_code: _cc, + pkg_options: _opts, + ...detailFields + } = row; await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/add`, { ...detailFields, id: crypto.randomUUID(), @@ -902,10 +926,12 @@ export default function SalesOrderPage() { part_name: item.item_name, spec: item.size || "", material: getCategoryLabel("item_material", item.material) || item.material || "", - packing_material: "", + pkg_code: "", + pkg_qty_per_unit: "0", + pkg_options: [] as Array<{ pkg_code: string; pkg_name: string; pkg_type: string; pkg_qty_per_unit: number }>, unit: getCategoryLabel("item_inventory_unit", item.inventory_unit) || item.inventory_unit || "", qty: "1", - pack_qty: "0", + pack_count: "0", unit_price: unitPrice, amount: unitPrice ? String(1 * parseFloat(unitPrice)) : "", due_date: "", @@ -916,6 +942,38 @@ export default function SalesOrderPage() { toast.success(`${selected.length}개 품목이 추가되었습니다.`); setItemSelectedMap(new Map()); setItemSelectOpen(false); + + void Promise.all( + newRows.map(async (nr) => { + if (!nr.part_code) return null; + try { + const res = await apiClient.get(`/sales-order/packaging-options/${encodeURIComponent(nr.part_code)}`); + const opts = (res.data?.options || []) as Array<{ pkg_code: string; pkg_name: string; pkg_type: string; pkg_qty_per_unit: number }>; + return { id: nr._id, opts }; + } catch { return { id: nr._id, opts: [] as any[] }; } + }) + ).then((results) => { + setDetailRows((prev) => prev.map((row) => { + const found = results.find((r) => r && r.id === row._id); + if (!found) return row; + const opts = found.opts; + if (opts.length === 0) return { ...row, pkg_options: [] }; + if (opts.length === 1) { + const o = opts[0]; + const qtyN = parseFloat(row.qty) || 0; + const perUnit = Number(o.pkg_qty_per_unit) || 0; + const pack = perUnit > 0 ? Math.ceil(qtyN / perUnit) : 0; + return { + ...row, + pkg_options: opts, + pkg_code: o.pkg_code, + pkg_qty_per_unit: String(perUnit), + pack_count: String(pack), + }; + } + return { ...row, pkg_options: opts }; + })); + }); }; // 단가 재계산: 단가방식/거래처 변경 시 기존 품목 단가 갱신 @@ -986,9 +1044,29 @@ export default function SalesOrderPage() { setDetailRows((prev) => { const next = [...prev]; next[idx] = { ...next[idx], [field]: value }; - if (field === "qty" || field === "unit_price") { - const qty = parseFloat(field === "qty" ? value : next[idx].qty) || 0; - const price = parseFloat(field === "unit_price" ? value : next[idx].unit_price) || 0; + + if (field === "pkg_code") { + const opts = (next[idx].pkg_options || []) as Array<{ pkg_code: string; pkg_qty_per_unit: number }>; + const sel = opts.find((o) => o.pkg_code === value); + const perUnit = sel ? Number(sel.pkg_qty_per_unit) || 0 : 0; + next[idx].pkg_qty_per_unit = String(perUnit); + const qtyN = parseFloat(next[idx].qty) || 0; + next[idx].pack_count = perUnit > 0 ? String(Math.ceil(qtyN / perUnit)) : "0"; + } + if (field === "qty") { + const perUnit = parseFloat(next[idx].pkg_qty_per_unit) || 0; + const qtyN = parseFloat(value) || 0; + if (perUnit > 0) next[idx].pack_count = String(Math.ceil(qtyN / perUnit)); + } + if (field === "pack_count") { + const perUnit = parseFloat(next[idx].pkg_qty_per_unit) || 0; + const packN = parseFloat(value) || 0; + if (perUnit > 0) next[idx].qty = String(packN * perUnit); + } + + if (field === "qty" || field === "unit_price" || field === "pack_count") { + const qty = parseFloat(next[idx].qty) || 0; + const price = parseFloat(next[idx].unit_price) || 0; next[idx].amount = (qty * price).toString(); } return next; diff --git a/frontend/app/(main)/COMPANY_29/equipment/inspection-record/page.tsx b/frontend/app/(main)/COMPANY_29/equipment/inspection-record/page.tsx index 066ec9a8..801fc0fb 100644 --- a/frontend/app/(main)/COMPANY_29/equipment/inspection-record/page.tsx +++ b/frontend/app/(main)/COMPANY_29/equipment/inspection-record/page.tsx @@ -58,6 +58,9 @@ export default function EquipmentInspectionRecordPage() { const [loading, setLoading] = useState(false); const [selectedId, setSelectedId] = useState(null); const [filterValues, setFilterValues] = useState([]); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(50); + const [total, setTotal] = useState(0); // ─── 데이터 조회 ──────────────────────────────────────── diff --git a/frontend/app/(main)/COMPANY_29/sales/order/page.tsx b/frontend/app/(main)/COMPANY_29/sales/order/page.tsx index e6ec842b..c1b9a21d 100644 --- a/frontend/app/(main)/COMPANY_29/sales/order/page.tsx +++ b/frontend/app/(main)/COMPANY_29/sales/order/page.tsx @@ -626,9 +626,29 @@ export default function SalesOrderPage() { const detailData = detailRes.data?.data?.data || detailRes.data?.data?.rows || []; setMasterForm(masterData || {}); - setDetailRows(detailData.map((d: any, i: number) => ({ ...d, _id: d.id || `row_${i}` }))); + const initialRows = detailData.map((d: any, i: number) => ({ + ...d, + _id: d.id || `row_${i}`, + pkg_options: [] as any[], + })); + setDetailRows(initialRows); setIsEditMode(true); setIsModalOpen(true); + + void Promise.all( + initialRows.map(async (r: any) => { + if (!r.part_code) return null; + try { + const res = await apiClient.get(`/sales-order/packaging-options/${encodeURIComponent(r.part_code)}`); + return { id: r._id, opts: res.data?.options || [] }; + } catch { return { id: r._id, opts: [] }; } + }) + ).then((results) => { + setDetailRows((prev) => prev.map((row) => { + const found = results.find((rr: any) => rr && rr.id === row._id); + return found ? { ...row, pkg_options: found.opts } : row; + })); + }); } catch (err) { toast.error("수주 정보를 불러오는데 실패했습니다."); } @@ -731,7 +751,11 @@ export default function SalesOrderPage() { await apiClient.post(`/table-management/tables/${MASTER_TABLE}/add`, masterFields); } for (const row of detailRows) { - const { _id, id: rowId, created_date: _cd, updated_date: _ud, writer: _w, company_code: _cc, ...detailFields } = row; + const { + _id, id: rowId, created_date: _cd, updated_date: _ud, writer: _w, company_code: _cc, + pkg_options: _opts, + ...detailFields + } = row; await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/add`, { ...detailFields, id: crypto.randomUUID(), @@ -902,10 +926,12 @@ export default function SalesOrderPage() { part_name: item.item_name, spec: item.size || "", material: getCategoryLabel("item_material", item.material) || item.material || "", - packing_material: "", + pkg_code: "", + pkg_qty_per_unit: "0", + pkg_options: [] as Array<{ pkg_code: string; pkg_name: string; pkg_type: string; pkg_qty_per_unit: number }>, unit: getCategoryLabel("item_inventory_unit", item.inventory_unit) || item.inventory_unit || "", qty: "1", - pack_qty: "0", + pack_count: "0", unit_price: unitPrice, amount: unitPrice ? String(1 * parseFloat(unitPrice)) : "", due_date: "", @@ -916,6 +942,38 @@ export default function SalesOrderPage() { toast.success(`${selected.length}개 품목이 추가되었습니다.`); setItemSelectedMap(new Map()); setItemSelectOpen(false); + + void Promise.all( + newRows.map(async (nr) => { + if (!nr.part_code) return null; + try { + const res = await apiClient.get(`/sales-order/packaging-options/${encodeURIComponent(nr.part_code)}`); + const opts = (res.data?.options || []) as Array<{ pkg_code: string; pkg_name: string; pkg_type: string; pkg_qty_per_unit: number }>; + return { id: nr._id, opts }; + } catch { return { id: nr._id, opts: [] as any[] }; } + }) + ).then((results) => { + setDetailRows((prev) => prev.map((row) => { + const found = results.find((r) => r && r.id === row._id); + if (!found) return row; + const opts = found.opts; + if (opts.length === 0) return { ...row, pkg_options: [] }; + if (opts.length === 1) { + const o = opts[0]; + const qtyN = parseFloat(row.qty) || 0; + const perUnit = Number(o.pkg_qty_per_unit) || 0; + const pack = perUnit > 0 ? Math.ceil(qtyN / perUnit) : 0; + return { + ...row, + pkg_options: opts, + pkg_code: o.pkg_code, + pkg_qty_per_unit: String(perUnit), + pack_count: String(pack), + }; + } + return { ...row, pkg_options: opts }; + })); + }); }; // 단가 재계산: 단가방식/거래처 변경 시 기존 품목 단가 갱신 @@ -986,9 +1044,29 @@ export default function SalesOrderPage() { setDetailRows((prev) => { const next = [...prev]; next[idx] = { ...next[idx], [field]: value }; - if (field === "qty" || field === "unit_price") { - const qty = parseFloat(field === "qty" ? value : next[idx].qty) || 0; - const price = parseFloat(field === "unit_price" ? value : next[idx].unit_price) || 0; + + if (field === "pkg_code") { + const opts = (next[idx].pkg_options || []) as Array<{ pkg_code: string; pkg_qty_per_unit: number }>; + const sel = opts.find((o) => o.pkg_code === value); + const perUnit = sel ? Number(sel.pkg_qty_per_unit) || 0 : 0; + next[idx].pkg_qty_per_unit = String(perUnit); + const qtyN = parseFloat(next[idx].qty) || 0; + next[idx].pack_count = perUnit > 0 ? String(Math.ceil(qtyN / perUnit)) : "0"; + } + if (field === "qty") { + const perUnit = parseFloat(next[idx].pkg_qty_per_unit) || 0; + const qtyN = parseFloat(value) || 0; + if (perUnit > 0) next[idx].pack_count = String(Math.ceil(qtyN / perUnit)); + } + if (field === "pack_count") { + const perUnit = parseFloat(next[idx].pkg_qty_per_unit) || 0; + const packN = parseFloat(value) || 0; + if (perUnit > 0) next[idx].qty = String(packN * perUnit); + } + + if (field === "qty" || field === "unit_price" || field === "pack_count") { + const qty = parseFloat(next[idx].qty) || 0; + const price = parseFloat(next[idx].unit_price) || 0; next[idx].amount = (qty * price).toString(); } return next; @@ -1661,12 +1739,20 @@ export default function SalesOrderPage() { {row.spec} {row.material} - updateDetailRow(idx, "packing_material", e.target.value)} - placeholder="포장재" - className="h-8 text-xs w-full" - /> + {(row.pkg_options && row.pkg_options.length > 0) ? ( + + ) : ( + 등록된 포장재 없음 + )} updateDetailRow(idx, "pack_qty", e.target.value)} + value={row.pack_count || "0"} + onChange={(e) => updateDetailRow(idx, "pack_count", e.target.value)} className="h-8 text-xs text-right font-mono w-full" + disabled={!row.pkg_code} /> diff --git a/frontend/app/(main)/COMPANY_30/sales/order/page.tsx b/frontend/app/(main)/COMPANY_30/sales/order/page.tsx index 18c02b12..11ae354c 100644 --- a/frontend/app/(main)/COMPANY_30/sales/order/page.tsx +++ b/frontend/app/(main)/COMPANY_30/sales/order/page.tsx @@ -27,7 +27,7 @@ import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/componen import { Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil, ClipboardList, Package, Search, X, Settings2, GripVertical, - ChevronsLeft, ChevronLeft, ChevronRight, ChevronsRight, + ChevronsLeft, ChevronLeft, ChevronRight, ChevronsRight, History, ChevronDown, } from "lucide-react"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; @@ -102,7 +102,9 @@ const MODAL_DETAIL_COLUMNS: ModalCol[] = [ { key: "thickness", label: "두께", width: 90 }, { key: "area", label: "면적(㎡)", width: 90 }, { key: "unit", label: "단위", width: 100 }, + { key: "pkg_code", label: "포장재", width: 130 }, { key: "qty", label: "수량", width: 90 }, + { key: "pack_count", label: "포장수량", width: 100 }, { key: "unit_price", label: "단가", width: 110 }, { key: "amount", label: "금액", width: 110 }, { key: "due_date", label: "납기일", width: 160 }, @@ -213,6 +215,93 @@ export default function ChunganSalesOrderPage() { const [masterForm, setMasterForm] = useState>({}); const [modalDetailRows, setModalDetailRows] = useState([]); + // 변경 이력 모달 + const [historyOpen, setHistoryOpen] = useState(false); + const [historyOrderNo, setHistoryOrderNo] = useState(""); + const [historyLoading, setHistoryLoading] = useState(false); + const [historyEvents, setHistoryEvents] = useState>([]); + + // 펼쳐진 그룹 키 집합 + const [expandedGroups, setExpandedGroups] = useState>(new Set()); + + const openHistoryModal = useCallback(async (orderNo: string) => { + setHistoryOrderNo(orderNo); + setHistoryEvents([]); + setExpandedGroups(new Set()); + setHistoryOpen(true); + setHistoryLoading(true); + try { + const res = await apiClient.get(`/sales-order/audit-log/${encodeURIComponent(orderNo)}`); + if (res.data?.success) { + setHistoryEvents(res.data.timeline || []); + } else { + toast.error(res.data?.message || "이력 조회에 실패했어요"); + } + } catch { + toast.error("이력 조회 중 오류가 발생했어요"); + } finally { + setHistoryLoading(false); + } + }, []); + + // 같은 시각(초)·같은 작업자·같은 액션·같은 라인을 한 그룹으로 묶기 + const historyGroups = useMemo(() => { + type Group = { + key: string; + changed_at: string; + changed_by: string | null; + changed_by_name?: string | null; + action: "INSERT" | "UPDATE" | "DELETE"; + ref_table: "master" | "detail"; + ref_label: string; + events: typeof historyEvents; + }; + const groups: Group[] = []; + const map = new Map(); + for (const ev of historyEvents) { + const sec = (ev.changed_at || "").slice(0, 19); // 초 단위 + const key = `${sec}::${ev.changed_by || ""}::${ev.action}::${ev.ref_table}::${ev.ref_label}`; + let g = map.get(key); + if (!g) { + g = { + key, + changed_at: ev.changed_at, + changed_by: ev.changed_by, + changed_by_name: ev.changed_by_name, + action: ev.action, + ref_table: ev.ref_table, + ref_label: ev.ref_label, + events: [], + }; + map.set(key, g); + groups.push(g); + } + g.events.push(ev); + } + return groups; + }, [historyEvents]); + + const toggleGroup = useCallback((key: string) => { + setExpandedGroups((prev) => { + const n = new Set(prev); + if (n.has(key)) n.delete(key); + else n.add(key); + return n; + }); + }, []); + // 품목 선택 모달 const [itemSelectOpen, setItemSelectOpen] = useState(false); const [itemSearchKeyword, setItemSearchKeyword] = useState(""); @@ -550,15 +639,32 @@ export default function ChunganSalesOrderPage() { const detailData = await enrichDetailsWithItemInfo(rawDetail); setMasterForm(masterData || {}); - setModalDetailRows(detailData.map((d: any, i: number) => ({ + const initialRows = detailData.map((d: any, i: number) => ({ ...d, _id: d.id || `row_${i}`, _fromItemInfo: !!d.part_code, _divisionLabel: categoryOptions["item_division"]?.find((o: any) => o.code === d.division)?.label || d.division || "", _typeLabel: categoryOptions["item_type"]?.find((o: any) => o.code === d.type)?.label || d.type || "", - }))); + pkg_options: [] as any[], + })); + setModalDetailRows(initialRows); setIsEditMode(true); setIsModalOpen(true); + + void Promise.all( + initialRows.map(async (r: any) => { + if (!r.part_code) return null; + try { + const res = await apiClient.get(`/sales-order/packaging-options/${encodeURIComponent(r.part_code)}`); + return { id: r._id, opts: res.data?.options || [] }; + } catch { return { id: r._id, opts: [] }; } + }) + ).then((results) => { + setModalDetailRows((prev) => prev.map((row) => { + const found = results.find((rr: any) => rr && rr.id === row._id); + return found ? { ...row, pkg_options: found.opts } : row; + })); + }); } catch (err) { console.error("수주 상세 조회 실패:", err); toast.error("수주 정보를 불러오는데 실패했습니다."); @@ -694,7 +800,13 @@ export default function ChunganSalesOrderPage() { for (let i = 0; i < modalDetailRows.length; i++) { const row = modalDetailRows[i]; - const { _id, _fromItemInfo, _divisionLabel, _typeLabel, division: _div, type: _typ, id: rowId, created_date: _cd, updated_date: _ud, writer: _w, company_code: _cc, ...detailFields } = row; + const { + _id, _fromItemInfo, _divisionLabel, _typeLabel, + division: _div, type: _typ, id: rowId, + created_date: _cd, updated_date: _ud, writer: _w, company_code: _cc, + pkg_options: _opts, + ...detailFields + } = row; await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/add`, { id: crypto.randomUUID(), ...detailFields, @@ -790,6 +902,10 @@ export default function ChunganSalesOrderPage() { height: item.height || "", thickness: item.thickness || "", area: item.area || autoArea, + pkg_code: "", + pkg_qty_per_unit: "0", + pkg_options: [] as Array<{ pkg_code: string; pkg_name: string; pkg_type: string; pkg_qty_per_unit: number }>, + pack_count: "0", qty: "", unit_price: item.selling_price || item.standard_price || "", amount: "", due_date: "", memo: "", }; @@ -797,6 +913,38 @@ export default function ChunganSalesOrderPage() { setModalDetailRows((prev) => [...prev, ...newRows]); setItemSelectOpen(false); setItemCheckedIds(new Set()); + + void Promise.all( + newRows.map(async (nr) => { + if (!nr.part_code) return null; + try { + const res = await apiClient.get(`/sales-order/packaging-options/${encodeURIComponent(nr.part_code)}`); + const opts = (res.data?.options || []) as Array<{ pkg_code: string; pkg_name: string; pkg_type: string; pkg_qty_per_unit: number }>; + return { id: nr._id, opts }; + } catch { return { id: nr._id, opts: [] as any[] }; } + }) + ).then((results) => { + setModalDetailRows((prev) => prev.map((row) => { + const found = results.find((r) => r && r.id === row._id); + if (!found) return row; + const opts = found.opts; + if (opts.length === 0) return { ...row, pkg_options: [] }; + if (opts.length === 1) { + const o = opts[0]; + const qtyN = parseFloat(row.qty) || 0; + const perUnit = Number(o.pkg_qty_per_unit) || 0; + const pack = perUnit > 0 ? Math.ceil(qtyN / perUnit) : 0; + return { + ...row, + pkg_options: opts, + pkg_code: o.pkg_code, + pkg_qty_per_unit: String(perUnit), + pack_count: String(pack), + }; + } + return { ...row, pkg_options: opts }; + })); + }); }; // 빈 행 추가 (품명 직접 입력용) — 관리품목=영업관리, 품목구분=제품 고정 @@ -813,6 +961,7 @@ export default function ChunganSalesOrderPage() { type: typeCode, _typeLabel: typeLabel, unit: "㎡", width: "", height: "", thickness: "", area: "", + pkg_code: "", pkg_qty_per_unit: "0", pkg_options: [], pack_count: "0", qty: "", unit_price: "", amount: "", due_date: "", memo: "", }]); @@ -844,10 +993,29 @@ export default function ChunganSalesOrderPage() { if (field === "width" || field === "height" || field === "division") { next[idx].area = calcArea(next[idx]); } + // 포장재 자동매칭 + if (field === "pkg_code") { + const opts = (next[idx].pkg_options || []) as Array<{ pkg_code: string; pkg_qty_per_unit: number }>; + const sel = opts.find((o) => o.pkg_code === value); + const perUnit = sel ? Number(sel.pkg_qty_per_unit) || 0 : 0; + next[idx].pkg_qty_per_unit = String(perUnit); + const qtyN = parseFloat(next[idx].qty) || 0; + next[idx].pack_count = perUnit > 0 ? String(Math.ceil(qtyN / perUnit)) : "0"; + } + if (field === "qty") { + const perUnit = parseFloat(next[idx].pkg_qty_per_unit) || 0; + const qtyN = parseFloat(value) || 0; + if (perUnit > 0) next[idx].pack_count = String(Math.ceil(qtyN / perUnit)); + } + if (field === "pack_count") { + const perUnit = parseFloat(next[idx].pkg_qty_per_unit) || 0; + const packN = parseFloat(value) || 0; + if (perUnit > 0) next[idx].qty = String(packN * perUnit); + } // 금액 자동 계산 - if (field === "qty" || field === "unit_price") { - const qty = parseFloat(field === "qty" ? value : next[idx].qty) || 0; - const price = parseFloat(field === "unit_price" ? value : next[idx].unit_price) || 0; + if (field === "qty" || field === "unit_price" || field === "pack_count") { + const qty = parseFloat(next[idx].qty) || 0; + const price = parseFloat(next[idx].unit_price) || 0; next[idx].amount = (qty * price).toString(); } return next; @@ -892,8 +1060,25 @@ export default function ChunganSalesOrderPage() { ) : ( updateDetailRow(idx, "unit", e.target.value)} className="h-8 text-sm" placeholder="㎡" /> ); + case "pkg_code": + return (row.pkg_options && row.pkg_options.length > 0) ? ( + + ) : ( + 없음 + ); case "qty": return updateDetailRow(idx, "qty", parseNumber(e.target.value))} className="h-8 text-sm text-right" />; + case "pack_count": + return updateDetailRow(idx, "pack_count", e.target.value)} className="h-8 text-sm text-right font-mono" disabled={!row.pkg_code} />; case "unit_price": return updateDetailRow(idx, "unit_price", parseNumber(e.target.value))} className="h-8 text-sm text-right" />; case "amount": @@ -1045,6 +1230,12 @@ export default function ChunganSalesOrderPage() { }}> 수정 + @@ -1425,6 +1616,87 @@ export default function ChunganSalesOrderPage() { onSave={applyTableSettings} /> + {/* 수주 변경 이력 모달 */} + + + + + + 수주 변경 이력 + {historyOrderNo} + + + 등록·수정·삭제 이력을 시간 역순으로 표시합니다 (총 {historyGroups.length}건) + + +
+ {historyLoading ? ( +
+ 이력 불러오는 중… +
+ ) : historyGroups.length === 0 ? ( +
기록된 이력이 없어요
+ ) : ( +
    + {historyGroups.map((g) => { + const actionColor = + g.action === "INSERT" ? "bg-emerald-100 text-emerald-700" + : g.action === "UPDATE" ? "bg-amber-100 text-amber-700" + : "bg-rose-100 text-rose-700"; + const actionLabel = g.action === "INSERT" ? "등록" : g.action === "UPDATE" ? "수정" : "삭제"; + const refColor = g.ref_table === "master" ? "border-blue-300 text-blue-700" : "border-purple-300 text-purple-700"; + const isExpandable = g.action === "UPDATE" && g.events.length > 0; + const isExpanded = expandedGroups.has(g.key); + return ( +
  1. + + {isExpandable && isExpanded && ( +
    + {g.events.map((ev) => ( +
    +
    + 필드: {ev.changed_column_label || ev.changed_column} +
    +
    + {ev.old_value ?? (빈 값)} + + {ev.new_value ?? (빈 값)} +
    +
    + ))} +
    + )} +
  2. + ); + })} +
+ )} +
+ + + +
+
+ {ConfirmDialogComponent} ); diff --git a/frontend/app/(main)/COMPANY_7/equipment/inspection-record/page.tsx b/frontend/app/(main)/COMPANY_7/equipment/inspection-record/page.tsx index 066ec9a8..801fc0fb 100644 --- a/frontend/app/(main)/COMPANY_7/equipment/inspection-record/page.tsx +++ b/frontend/app/(main)/COMPANY_7/equipment/inspection-record/page.tsx @@ -58,6 +58,9 @@ export default function EquipmentInspectionRecordPage() { const [loading, setLoading] = useState(false); const [selectedId, setSelectedId] = useState(null); const [filterValues, setFilterValues] = useState([]); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(50); + const [total, setTotal] = useState(0); // ─── 데이터 조회 ──────────────────────────────────────── diff --git a/frontend/app/(main)/COMPANY_7/sales/order/page.tsx b/frontend/app/(main)/COMPANY_7/sales/order/page.tsx index e6ec842b..b7980d33 100644 --- a/frontend/app/(main)/COMPANY_7/sales/order/page.tsx +++ b/frontend/app/(main)/COMPANY_7/sales/order/page.tsx @@ -626,9 +626,30 @@ export default function SalesOrderPage() { const detailData = detailRes.data?.data?.data || detailRes.data?.data?.rows || []; setMasterForm(masterData || {}); - setDetailRows(detailData.map((d: any, i: number) => ({ ...d, _id: d.id || `row_${i}` }))); + const initialRows = detailData.map((d: any, i: number) => ({ + ...d, + _id: d.id || `row_${i}`, + pkg_options: [] as any[], + })); + setDetailRows(initialRows); setIsEditMode(true); setIsModalOpen(true); + + // 각 행의 품목별 포장재 옵션 비동기 로드 (이미 저장된 pkg_code는 유지) + void Promise.all( + initialRows.map(async (r: any) => { + if (!r.part_code) return null; + try { + const res = await apiClient.get(`/sales-order/packaging-options/${encodeURIComponent(r.part_code)}`); + return { id: r._id, opts: res.data?.options || [] }; + } catch { return { id: r._id, opts: [] }; } + }) + ).then((results) => { + setDetailRows((prev) => prev.map((row) => { + const found = results.find((rr: any) => rr && rr.id === row._id); + return found ? { ...row, pkg_options: found.opts } : row; + })); + }); } catch (err) { toast.error("수주 정보를 불러오는데 실패했습니다."); } @@ -731,7 +752,11 @@ export default function SalesOrderPage() { await apiClient.post(`/table-management/tables/${MASTER_TABLE}/add`, masterFields); } for (const row of detailRows) { - const { _id, id: rowId, created_date: _cd, updated_date: _ud, writer: _w, company_code: _cc, ...detailFields } = row; + const { + _id, id: rowId, created_date: _cd, updated_date: _ud, writer: _w, company_code: _cc, + pkg_options: _opts, + ...detailFields + } = row; await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/add`, { ...detailFields, id: crypto.randomUUID(), @@ -902,10 +927,12 @@ export default function SalesOrderPage() { part_name: item.item_name, spec: item.size || "", material: getCategoryLabel("item_material", item.material) || item.material || "", - packing_material: "", + pkg_code: "", + pkg_qty_per_unit: "0", + pkg_options: [] as Array<{ pkg_code: string; pkg_name: string; pkg_type: string; pkg_qty_per_unit: number }>, unit: getCategoryLabel("item_inventory_unit", item.inventory_unit) || item.inventory_unit || "", qty: "1", - pack_qty: "0", + pack_count: "0", unit_price: unitPrice, amount: unitPrice ? String(1 * parseFloat(unitPrice)) : "", due_date: "", @@ -916,6 +943,40 @@ export default function SalesOrderPage() { toast.success(`${selected.length}개 품목이 추가되었습니다.`); setItemSelectedMap(new Map()); setItemSelectOpen(false); + + // 신규 행 각각에 대해 등록된 포장재 옵션 비동기 로드 + 1개면 자동 선택 + void Promise.all( + newRows.map(async (nr) => { + if (!nr.part_code) return null; + try { + const res = await apiClient.get(`/sales-order/packaging-options/${encodeURIComponent(nr.part_code)}`); + const opts = (res.data?.options || []) as Array<{ pkg_code: string; pkg_name: string; pkg_type: string; pkg_qty_per_unit: number }>; + return { id: nr._id, opts }; + } catch { return { id: nr._id, opts: [] as any[] }; } + }) + ).then((results) => { + setDetailRows((prev) => prev.map((row) => { + const found = results.find((r) => r && r.id === row._id); + if (!found) return row; + const opts = found.opts; + if (opts.length === 0) return { ...row, pkg_options: [] }; + // 1개면 자동 선택, 2개 이상이면 비워둠 (사용자 선택 유도) + if (opts.length === 1) { + const o = opts[0]; + const qtyN = parseFloat(row.qty) || 0; + const perUnit = Number(o.pkg_qty_per_unit) || 0; + const pack = perUnit > 0 ? Math.ceil(qtyN / perUnit) : 0; + return { + ...row, + pkg_options: opts, + pkg_code: o.pkg_code, + pkg_qty_per_unit: String(perUnit), + pack_count: String(pack), + }; + } + return { ...row, pkg_options: opts }; + })); + }); }; // 단가 재계산: 단가방식/거래처 변경 시 기존 품목 단가 갱신 @@ -986,9 +1047,34 @@ export default function SalesOrderPage() { setDetailRows((prev) => { const next = [...prev]; next[idx] = { ...next[idx], [field]: value }; - if (field === "qty" || field === "unit_price") { - const qty = parseFloat(field === "qty" ? value : next[idx].qty) || 0; - const price = parseFloat(field === "unit_price" ? value : next[idx].unit_price) || 0; + + // 포장재 변경 시: 입수수량 갱신 + pack_count 재계산 + if (field === "pkg_code") { + const opts = (next[idx].pkg_options || []) as Array<{ pkg_code: string; pkg_qty_per_unit: number }>; + const sel = opts.find((o) => o.pkg_code === value); + const perUnit = sel ? Number(sel.pkg_qty_per_unit) || 0 : 0; + next[idx].pkg_qty_per_unit = String(perUnit); + const qtyN = parseFloat(next[idx].qty) || 0; + next[idx].pack_count = perUnit > 0 ? String(Math.ceil(qtyN / perUnit)) : "0"; + } + + // 수량 입력: pack_count = ceil(qty / pkg_qty_per_unit) + if (field === "qty") { + const perUnit = parseFloat(next[idx].pkg_qty_per_unit) || 0; + const qtyN = parseFloat(value) || 0; + if (perUnit > 0) next[idx].pack_count = String(Math.ceil(qtyN / perUnit)); + } + + // 포장수량 입력: qty = pack_count × pkg_qty_per_unit + if (field === "pack_count") { + const perUnit = parseFloat(next[idx].pkg_qty_per_unit) || 0; + const packN = parseFloat(value) || 0; + if (perUnit > 0) next[idx].qty = String(packN * perUnit); + } + + if (field === "qty" || field === "unit_price" || field === "pack_count") { + const qty = parseFloat(next[idx].qty) || 0; + const price = parseFloat(next[idx].unit_price) || 0; next[idx].amount = (qty * price).toString(); } return next; @@ -1661,12 +1747,20 @@ export default function SalesOrderPage() { {row.spec} {row.material} - updateDetailRow(idx, "packing_material", e.target.value)} - placeholder="포장재" - className="h-8 text-xs w-full" - /> + {(row.pkg_options && row.pkg_options.length > 0) ? ( + + ) : ( + 등록된 포장재 없음 + )} updateDetailRow(idx, "pack_qty", e.target.value)} + value={row.pack_count || "0"} + onChange={(e) => updateDetailRow(idx, "pack_count", e.target.value)} className="h-8 text-xs text-right font-mono w-full" + disabled={!row.pkg_code} /> diff --git a/frontend/app/(main)/COMPANY_8/equipment/inspection-record/page.tsx b/frontend/app/(main)/COMPANY_8/equipment/inspection-record/page.tsx index 066ec9a8..801fc0fb 100644 --- a/frontend/app/(main)/COMPANY_8/equipment/inspection-record/page.tsx +++ b/frontend/app/(main)/COMPANY_8/equipment/inspection-record/page.tsx @@ -58,6 +58,9 @@ export default function EquipmentInspectionRecordPage() { const [loading, setLoading] = useState(false); const [selectedId, setSelectedId] = useState(null); const [filterValues, setFilterValues] = useState([]); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(50); + const [total, setTotal] = useState(0); // ─── 데이터 조회 ──────────────────────────────────────── diff --git a/frontend/app/(main)/COMPANY_8/sales/order/page.tsx b/frontend/app/(main)/COMPANY_8/sales/order/page.tsx index e6ec842b..c1b9a21d 100644 --- a/frontend/app/(main)/COMPANY_8/sales/order/page.tsx +++ b/frontend/app/(main)/COMPANY_8/sales/order/page.tsx @@ -626,9 +626,29 @@ export default function SalesOrderPage() { const detailData = detailRes.data?.data?.data || detailRes.data?.data?.rows || []; setMasterForm(masterData || {}); - setDetailRows(detailData.map((d: any, i: number) => ({ ...d, _id: d.id || `row_${i}` }))); + const initialRows = detailData.map((d: any, i: number) => ({ + ...d, + _id: d.id || `row_${i}`, + pkg_options: [] as any[], + })); + setDetailRows(initialRows); setIsEditMode(true); setIsModalOpen(true); + + void Promise.all( + initialRows.map(async (r: any) => { + if (!r.part_code) return null; + try { + const res = await apiClient.get(`/sales-order/packaging-options/${encodeURIComponent(r.part_code)}`); + return { id: r._id, opts: res.data?.options || [] }; + } catch { return { id: r._id, opts: [] }; } + }) + ).then((results) => { + setDetailRows((prev) => prev.map((row) => { + const found = results.find((rr: any) => rr && rr.id === row._id); + return found ? { ...row, pkg_options: found.opts } : row; + })); + }); } catch (err) { toast.error("수주 정보를 불러오는데 실패했습니다."); } @@ -731,7 +751,11 @@ export default function SalesOrderPage() { await apiClient.post(`/table-management/tables/${MASTER_TABLE}/add`, masterFields); } for (const row of detailRows) { - const { _id, id: rowId, created_date: _cd, updated_date: _ud, writer: _w, company_code: _cc, ...detailFields } = row; + const { + _id, id: rowId, created_date: _cd, updated_date: _ud, writer: _w, company_code: _cc, + pkg_options: _opts, + ...detailFields + } = row; await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/add`, { ...detailFields, id: crypto.randomUUID(), @@ -902,10 +926,12 @@ export default function SalesOrderPage() { part_name: item.item_name, spec: item.size || "", material: getCategoryLabel("item_material", item.material) || item.material || "", - packing_material: "", + pkg_code: "", + pkg_qty_per_unit: "0", + pkg_options: [] as Array<{ pkg_code: string; pkg_name: string; pkg_type: string; pkg_qty_per_unit: number }>, unit: getCategoryLabel("item_inventory_unit", item.inventory_unit) || item.inventory_unit || "", qty: "1", - pack_qty: "0", + pack_count: "0", unit_price: unitPrice, amount: unitPrice ? String(1 * parseFloat(unitPrice)) : "", due_date: "", @@ -916,6 +942,38 @@ export default function SalesOrderPage() { toast.success(`${selected.length}개 품목이 추가되었습니다.`); setItemSelectedMap(new Map()); setItemSelectOpen(false); + + void Promise.all( + newRows.map(async (nr) => { + if (!nr.part_code) return null; + try { + const res = await apiClient.get(`/sales-order/packaging-options/${encodeURIComponent(nr.part_code)}`); + const opts = (res.data?.options || []) as Array<{ pkg_code: string; pkg_name: string; pkg_type: string; pkg_qty_per_unit: number }>; + return { id: nr._id, opts }; + } catch { return { id: nr._id, opts: [] as any[] }; } + }) + ).then((results) => { + setDetailRows((prev) => prev.map((row) => { + const found = results.find((r) => r && r.id === row._id); + if (!found) return row; + const opts = found.opts; + if (opts.length === 0) return { ...row, pkg_options: [] }; + if (opts.length === 1) { + const o = opts[0]; + const qtyN = parseFloat(row.qty) || 0; + const perUnit = Number(o.pkg_qty_per_unit) || 0; + const pack = perUnit > 0 ? Math.ceil(qtyN / perUnit) : 0; + return { + ...row, + pkg_options: opts, + pkg_code: o.pkg_code, + pkg_qty_per_unit: String(perUnit), + pack_count: String(pack), + }; + } + return { ...row, pkg_options: opts }; + })); + }); }; // 단가 재계산: 단가방식/거래처 변경 시 기존 품목 단가 갱신 @@ -986,9 +1044,29 @@ export default function SalesOrderPage() { setDetailRows((prev) => { const next = [...prev]; next[idx] = { ...next[idx], [field]: value }; - if (field === "qty" || field === "unit_price") { - const qty = parseFloat(field === "qty" ? value : next[idx].qty) || 0; - const price = parseFloat(field === "unit_price" ? value : next[idx].unit_price) || 0; + + if (field === "pkg_code") { + const opts = (next[idx].pkg_options || []) as Array<{ pkg_code: string; pkg_qty_per_unit: number }>; + const sel = opts.find((o) => o.pkg_code === value); + const perUnit = sel ? Number(sel.pkg_qty_per_unit) || 0 : 0; + next[idx].pkg_qty_per_unit = String(perUnit); + const qtyN = parseFloat(next[idx].qty) || 0; + next[idx].pack_count = perUnit > 0 ? String(Math.ceil(qtyN / perUnit)) : "0"; + } + if (field === "qty") { + const perUnit = parseFloat(next[idx].pkg_qty_per_unit) || 0; + const qtyN = parseFloat(value) || 0; + if (perUnit > 0) next[idx].pack_count = String(Math.ceil(qtyN / perUnit)); + } + if (field === "pack_count") { + const perUnit = parseFloat(next[idx].pkg_qty_per_unit) || 0; + const packN = parseFloat(value) || 0; + if (perUnit > 0) next[idx].qty = String(packN * perUnit); + } + + if (field === "qty" || field === "unit_price" || field === "pack_count") { + const qty = parseFloat(next[idx].qty) || 0; + const price = parseFloat(next[idx].unit_price) || 0; next[idx].amount = (qty * price).toString(); } return next; @@ -1661,12 +1739,20 @@ export default function SalesOrderPage() { {row.spec} {row.material} - updateDetailRow(idx, "packing_material", e.target.value)} - placeholder="포장재" - className="h-8 text-xs w-full" - /> + {(row.pkg_options && row.pkg_options.length > 0) ? ( + + ) : ( + 등록된 포장재 없음 + )} updateDetailRow(idx, "pack_qty", e.target.value)} + value={row.pack_count || "0"} + onChange={(e) => updateDetailRow(idx, "pack_count", e.target.value)} className="h-8 text-xs text-right font-mono w-full" + disabled={!row.pkg_code} /> diff --git a/frontend/app/(main)/COMPANY_9/sales/order/page.tsx b/frontend/app/(main)/COMPANY_9/sales/order/page.tsx index 03a5162c..18d668b5 100644 --- a/frontend/app/(main)/COMPANY_9/sales/order/page.tsx +++ b/frontend/app/(main)/COMPANY_9/sales/order/page.tsx @@ -398,14 +398,31 @@ export default function JeilGlassOrderPage() { const detailData = detailRes.data?.data?.data || detailRes.data?.data?.rows || []; setMasterForm(masterData || {}); - setModalDetailRows(detailData.map((d: any, i: number) => ({ + const initialRows = detailData.map((d: any, i: number) => ({ ...d, _id: d.id || `row_${i}`, _fromItemInfo: !!d.part_code, _divisionLabel: categoryOptions["item_division"]?.find((o: any) => o.code === d.division)?.label || d.division || "", - }))); + pkg_options: [] as any[], + })); + setModalDetailRows(initialRows); setIsEditMode(true); setIsModalOpen(true); + + void Promise.all( + initialRows.map(async (r: any) => { + if (!r.part_code) return null; + try { + const res = await apiClient.get(`/sales-order/packaging-options/${encodeURIComponent(r.part_code)}`); + return { id: r._id, opts: res.data?.options || [] }; + } catch { return { id: r._id, opts: [] }; } + }) + ).then((results) => { + setModalDetailRows((prev) => prev.map((row) => { + const found = results.find((rr: any) => rr && rr.id === row._id); + return found ? { ...row, pkg_options: found.opts } : row; + })); + }); } catch (err) { console.error("수주 상세 조회 실패:", err); toast.error("수주 정보를 불러오는데 실패했습니다."); @@ -546,7 +563,12 @@ export default function JeilGlassOrderPage() { for (let i = 0; i < modalDetailRows.length; i++) { const row = modalDetailRows[i]; - const { _id, _fromItemInfo, _divisionLabel, id: rowId, created_date: _cd, updated_date: _ud, writer: _w, company_code: _cc, ...detailFields } = row; + const { + _id, _fromItemInfo, _divisionLabel, + id: rowId, created_date: _cd, updated_date: _ud, writer: _w, company_code: _cc, + pkg_options: _opts, + ...detailFields + } = row; await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/add`, { ...detailFields, order_no: masterForm.order_no, @@ -643,6 +665,10 @@ export default function JeilGlassOrderPage() { height: item.height || "", thickness: item.thickness || "", area: item.area || autoArea, + pkg_code: "", + pkg_qty_per_unit: "0", + pkg_options: [] as Array<{ pkg_code: string; pkg_name: string; pkg_type: string; pkg_qty_per_unit: number }>, + pack_count: "0", qty: "", unit_price: item.selling_price || item.standard_price || "", amount: "", due_date: "", memo: "", }; @@ -650,6 +676,38 @@ export default function JeilGlassOrderPage() { setModalDetailRows((prev) => [...prev, ...newRows]); setItemSelectOpen(false); setItemCheckedIds(new Set()); + + void Promise.all( + newRows.map(async (nr) => { + if (!nr.part_code) return null; + try { + const res = await apiClient.get(`/sales-order/packaging-options/${encodeURIComponent(nr.part_code)}`); + const opts = (res.data?.options || []) as Array<{ pkg_code: string; pkg_name: string; pkg_type: string; pkg_qty_per_unit: number }>; + return { id: nr._id, opts }; + } catch { return { id: nr._id, opts: [] as any[] }; } + }) + ).then((results) => { + setModalDetailRows((prev) => prev.map((row) => { + const found = results.find((r) => r && r.id === row._id); + if (!found) return row; + const opts = found.opts; + if (opts.length === 0) return { ...row, pkg_options: [] }; + if (opts.length === 1) { + const o = opts[0]; + const qtyN = parseFloat(row.qty) || 0; + const perUnit = Number(o.pkg_qty_per_unit) || 0; + const pack = perUnit > 0 ? Math.ceil(qtyN / perUnit) : 0; + return { + ...row, + pkg_options: opts, + pkg_code: o.pkg_code, + pkg_qty_per_unit: String(perUnit), + pack_count: String(pack), + }; + } + return { ...row, pkg_options: opts }; + })); + }); }; // 빈 행 추가 (품명 직접 입력용) @@ -662,6 +720,7 @@ export default function JeilGlassOrderPage() { _fromItemInfo: false, part_code: "", part_name: "", spec: "", division: divisionCode, _divisionLabel: divisionLabel, unit: "㎡", width: "", height: "", thickness: "", area: "", + pkg_code: "", pkg_qty_per_unit: "0", pkg_options: [], pack_count: "0", qty: "", unit_price: "", amount: "", due_date: "", memo: "", }]); @@ -693,10 +752,29 @@ export default function JeilGlassOrderPage() { if (field === "width" || field === "height" || field === "division") { next[idx].area = calcArea(next[idx]); } + // 포장재 자동매칭 + if (field === "pkg_code") { + const opts = (next[idx].pkg_options || []) as Array<{ pkg_code: string; pkg_qty_per_unit: number }>; + const sel = opts.find((o) => o.pkg_code === value); + const perUnit = sel ? Number(sel.pkg_qty_per_unit) || 0 : 0; + next[idx].pkg_qty_per_unit = String(perUnit); + const qtyN = parseFloat(next[idx].qty) || 0; + next[idx].pack_count = perUnit > 0 ? String(Math.ceil(qtyN / perUnit)) : "0"; + } + if (field === "qty") { + const perUnit = parseFloat(next[idx].pkg_qty_per_unit) || 0; + const qtyN = parseFloat(value) || 0; + if (perUnit > 0) next[idx].pack_count = String(Math.ceil(qtyN / perUnit)); + } + if (field === "pack_count") { + const perUnit = parseFloat(next[idx].pkg_qty_per_unit) || 0; + const packN = parseFloat(value) || 0; + if (perUnit > 0) next[idx].qty = String(packN * perUnit); + } // 금액 자동 계산 - if (field === "qty" || field === "unit_price") { - const qty = parseFloat(field === "qty" ? value : next[idx].qty) || 0; - const price = parseFloat(field === "unit_price" ? value : next[idx].unit_price) || 0; + if (field === "qty" || field === "unit_price" || field === "pack_count") { + const qty = parseFloat(next[idx].qty) || 0; + const price = parseFloat(next[idx].unit_price) || 0; next[idx].amount = (qty * price).toString(); } return next; @@ -993,7 +1071,9 @@ export default function JeilGlassOrderPage() { 두께 면적(㎡) 단위 + 포장재 수량 + 포장수량 단가 금액 납기일 @@ -1079,10 +1159,31 @@ export default function JeilGlassOrderPage() { className="h-8 text-sm" placeholder="㎡" /> )} + {/* 포장재: 등록된 옵션이 있으면 셀렉트, 없으면 안내 */} + + {(row.pkg_options && row.pkg_options.length > 0) ? ( + + ) : ( + 없음 + )} + updateDetailRow(idx, "qty", parseNumber(e.target.value))} className="h-8 text-sm text-right" /> + + updateDetailRow(idx, "pack_count", e.target.value)} + className="h-8 text-sm text-right font-mono" disabled={!row.pkg_code} /> + updateDetailRow(idx, "unit_price", parseNumber(e.target.value))} className="h-8 text-sm text-right" />