diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 9b7434a2..76c854bd 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -164,6 +164,7 @@ import designRoutes from "./routes/designRoutes"; // 설계 모듈 (DR/ECR/프 import materialStatusRoutes from "./routes/materialStatusRoutes"; // 자재현황 import receivingRoutes from "./routes/receivingRoutes"; // 입고관리 import outboundRoutes from "./routes/outboundRoutes"; // 출고관리 +import outsourcingOutboundRoutes from "./routes/outsourcingOutboundRoutes"; // 외주출고 import processInfoRoutes from "./routes/processInfoRoutes"; // 공정정보관리 import quoteRoutes from "./routes/quoteRoutes"; // 견적관리 import { BatchSchedulerService } from "./services/batchSchedulerService"; @@ -388,6 +389,7 @@ app.use("/api/report", analyticsReportRoutes); // 분석 리포트 (생산/재 app.use("/api/design", designRoutes); // 설계 모듈 app.use("/api/receiving", receivingRoutes); // 입고관리 app.use("/api/outbound", outboundRoutes); // 출고관리 +app.use("/api/outsourcing-outbound", outsourcingOutboundRoutes); // 외주출고 app.use("/api/quotes", quoteRoutes); // 견적관리 app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달 app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트) diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index 72dc368a..493f4725 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -2142,6 +2142,35 @@ export const getDepartmentList = async ( } }; +/** + * GET /api/admin/users/name-map + * 사용자 ID → 이름 매핑만 반환하는 경량 엔드포인트 + * 목적: 이력(writer/created_by 등)에 찍힌 user_id를 이름으로 표시하기 위함 + * 보안: 민감 정보(전화번호/이메일 등) 미포함, 인증된 사용자면 누구나 조회 + * 회사 필터 없음 — 최고 관리자 계정(company_code='*')도 포함 + */ +export const getUserNameMap = async (req: AuthenticatedRequest, res: Response) => { + try { + const rows = await query( + `SELECT user_id, user_name FROM user_info WHERE user_id IS NOT NULL`, + [] + ); + res.status(200).json({ + success: true, + data: rows.map((r: any) => ({ + user_id: r.user_id, + user_name: r.user_name, + })), + }); + } catch (error) { + logger.error("사용자 이름 맵 조회 실패", { error }); + res.status(500).json({ + success: false, + message: "사용자 이름 맵 조회 중 오류가 발생했습니다.", + }); + } +}; + /** * GET /api/admin/users/:userId * 사용자 상세 조회 API diff --git a/backend-node/src/controllers/analyticsReportController.ts b/backend-node/src/controllers/analyticsReportController.ts index 1f8cfd7c..e2f73653 100644 --- a/backend-node/src/controllers/analyticsReportController.ts +++ b/backend-node/src/controllers/analyticsReportController.ts @@ -56,47 +56,75 @@ export async function getProductionReportData(req: any, res: Response): Promise< const params: any[] = []; let idx = 1; - const cf = buildCompanyFilter(companyCode, "wi", idx); + const cf = buildCompanyFilter(companyCode, "wop", idx); if (cf.condition) { conditions.push(cf.condition); params.push(...cf.params); idx = cf.nextIdx; } - const df = buildDateFilter(startDate, endDate, "COALESCE(wi.start_date, wi.created_date::date::text)", idx); + const dateExpr = "COALESCE(NULLIF(wop.started_at, ''), wop.created_date::date::text)"; + const df = buildDateFilter(startDate, endDate, dateExpr, idx); conditions.push(...df.conditions); params.push(...df.params); idx = df.nextIdx; const whereClause = buildWhereClause(conditions); + // 실제 공정별 생산 데이터는 work_order_process에 있음 + // (work_instruction.routing은 routing_version_id UUID일 뿐이라 공정명이 아님) const dataQuery = ` SELECT - COALESCE(wi.start_date, wi.created_date::date::text) as date, - COALESCE(NULLIF(rv.version_name, ''), '미지정') as process, - COALESCE(ei.equipment_name, wi.equipment_id, '미지정') as equipment, - COALESCE(ii.item_name, wi.item_id, '미지정') as item, - COALESCE(wi.worker, '미지정') as worker, - CAST(COALESCE(NULLIF(wi.qty, ''), '0') AS numeric) as "planQty", - COALESCE(pr.production_qty, 0) as "prodQty", - COALESCE(pr.defect_qty, 0) as "defectQty", - 0 as "runTime", - 0 as "downTime", - wi.status, - wi.company_code - FROM work_instruction wi - LEFT JOIN item_routing_version rv - ON wi.routing = rv.id AND wi.company_code = rv.company_code - LEFT JOIN ( - SELECT wo_id, company_code, - SUM(CAST(COALESCE(NULLIF(production_qty, ''), '0') AS numeric)) as production_qty, - SUM(CAST(COALESCE(NULLIF(defect_qty, ''), '0') AS numeric)) as defect_qty - FROM production_record GROUP BY wo_id, company_code - ) pr ON wi.id = pr.wo_id AND wi.company_code = pr.company_code - LEFT JOIN ( - SELECT DISTINCT ON (equipment_code, company_code) - equipment_code, equipment_name, equipment_type, company_code - FROM equipment_info ORDER BY equipment_code, company_code, created_date DESC - ) ei ON wi.equipment_id = ei.equipment_code AND wi.company_code = ei.company_code - LEFT JOIN ( - SELECT DISTINCT ON (item_number, company_code) - item_number, item_name, company_code - FROM item_info ORDER BY item_number, company_code, created_date DESC - ) ii ON wi.item_id = ii.item_number AND wi.company_code = ii.company_code + COALESCE(NULLIF(wop.started_at, ''), wop.created_date::date::text) as date, + COALESCE(NULLIF(wop.process_name, ''), NULLIF(wop.process_code, ''), '미지정') as process, + COALESCE(NULLIF(em.equipment_name, ''), NULLIF(em.equipment_code, ''), '미지정') as equipment, + COALESCE(NULLIF(ii.item_name, ''), NULLIF(ii.item_number, ''), '미지정') as item, + COALESCE(NULLIF(wi.worker, ''), '미지정') as worker, + CAST(COALESCE(NULLIF(wop.plan_qty, ''), '0') AS numeric) as "planQty", + CAST(COALESCE(NULLIF(wop.good_qty, ''), '0') AS numeric) as "prodQty", + CAST(COALESCE(NULLIF(wop.defect_qty, ''), '0') AS numeric) as "defectQty", + CASE + WHEN NULLIF(wop.started_at, '') IS NOT NULL + AND NULLIF(wop.completed_at, '') IS NOT NULL + THEN GREATEST( + EXTRACT(EPOCH FROM (wop.completed_at::timestamp - wop.started_at::timestamp)) / 3600.0, + 0 + ) + ELSE 0 + END as "runTime", + CAST(COALESCE(NULLIF(wop.total_paused_time, ''), '0') AS numeric) / 3600.0 as "downTime", + wop.status, + wop.company_code + FROM work_order_process wop + LEFT JOIN work_instruction wi + ON wop.wo_id = wi.id AND wop.company_code = wi.company_code + LEFT JOIN LATERAL ( + SELECT equipment_code, equipment_name + FROM equipment_mng + WHERE company_code = wi.company_code + AND (id = wi.equipment_id OR equipment_code = wi.equipment_id + OR id = wop.equipment_code OR equipment_code = wop.equipment_code) + ORDER BY (id = wi.equipment_id OR id = wop.equipment_code) DESC, created_date DESC + LIMIT 1 + ) em ON true + LEFT JOIN LATERAL ( + SELECT ii_inner.item_number, ii_inner.item_name + FROM item_info ii_inner + WHERE ii_inner.company_code = wi.company_code + AND ( + (NULLIF(wi.item_id, '') IS NOT NULL + AND (ii_inner.id = wi.item_id OR ii_inner.item_number = wi.item_id)) + OR ii_inner.item_number = ( + SELECT wid.item_number + FROM work_instruction_detail wid + WHERE wid.work_instruction_id = wi.id + AND wid.company_code = wi.company_code + AND NULLIF(wid.item_number, '') IS NOT NULL + ORDER BY wid.created_date ASC + LIMIT 1 + ) + ) + ORDER BY + CASE WHEN ii_inner.id = wi.item_id THEN 1 + WHEN ii_inner.item_number = wi.item_id THEN 2 + ELSE 3 END, + ii_inner.created_date DESC + LIMIT 1 + ) ii ON true ${whereClause} ORDER BY date DESC NULLS LAST `; diff --git a/backend-node/src/controllers/materialStatusController.ts b/backend-node/src/controllers/materialStatusController.ts index 9103a7d1..d522997b 100644 --- a/backend-node/src/controllers/materialStatusController.ts +++ b/backend-node/src/controllers/materialStatusController.ts @@ -174,6 +174,7 @@ export async function getMaterialStatus( ii.item_name AS material_name, ii.item_number AS material_code, ii.unit AS material_unit, + ii.inventory_unit AS material_inventory_unit, COALESCE(ii.width::text, '') AS material_width, COALESCE(ii.height::text, '') AS material_height, COALESCE(ii.thickness::text, '') AS material_thickness @@ -220,7 +221,11 @@ export async function getMaterialStatus( materialCode: bomRow.material_code || bomRow.child_item_id, materialName: bomRow.material_name || "알 수 없음", - unit: bomRow.bom_unit || bomRow.material_unit || "EA", + unit: + bomRow.material_inventory_unit || + bomRow.bom_unit || + bomRow.material_unit || + "EA", requiredQty, width: bomRow.material_width || "", height: bomRow.material_height || "", @@ -260,12 +265,16 @@ export async function getMaterialStatus( } const stockQuery = ` - SELECT + SELECT s.item_code, s.warehouse_code, + w.warehouse_name, s.location_code, COALESCE(CAST(s.current_qty AS NUMERIC), 0) AS current_qty FROM inventory_stock s + LEFT JOIN warehouse_info w + ON w.warehouse_code = s.warehouse_code + AND w.company_code = s.company_code WHERE ${stockConditions.join(" AND ")} AND COALESCE(CAST(s.current_qty AS NUMERIC), 0) > 0 ORDER BY s.item_code, s.warehouse_code, s.location_code @@ -277,7 +286,7 @@ export async function getMaterialStatus( // item_code 기준 재고 맵핑 (inventory_stock.item_code는 item_info.item_number 또는 item_info.id일 수 있음) const stockByItem: Record< string, - { location: string; warehouse: string; qty: number }[] + { location: string; warehouse: string; warehouse_name: string; qty: number }[] > = {}; for (const stockRow of stockResult.rows) { @@ -288,6 +297,7 @@ export async function getMaterialStatus( stockByItem[code].push({ location: stockRow.location_code || "", warehouse: stockRow.warehouse_code || "", + warehouse_name: stockRow.warehouse_name || "", qty: Number(stockRow.current_qty), }); } diff --git a/backend-node/src/controllers/outsourcingOutboundController.ts b/backend-node/src/controllers/outsourcingOutboundController.ts new file mode 100644 index 00000000..75582afd --- /dev/null +++ b/backend-node/src/controllers/outsourcingOutboundController.ts @@ -0,0 +1,473 @@ +/** + * 외주출고 컨트롤러 + * + * 이전 공정이 완료되고 다음 공정이 외주 공정이면 + * 자동으로 외주출고 대상 목록에 표시 → 출고 처리 + * + * 출고 데이터는 기존 outbound_mng 테이블 재사용 + * (outbound_type='외주출고', source_type='work_order_process') + */ + +import type { Response } from "express"; +import { getPool } from "../database/db"; +import type { AuthenticatedRequest } from "../types/auth"; +import { adjustInventory } from "../utils/inventoryUtils"; +import { logger } from "../utils/logger"; + +/** + * 외주출고 대상 자동 조회 + * GET /api/outsourcing-outbound/candidates + * + * 이전 공정 완료 + 다음 공정이 외주 공정인 건 자동 표시 + */ +export async function getCandidates(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { keyword } = req.query; + + const pool = getPool(); + + let keywordCondition = ""; + const params: any[] = []; + let paramIdx = 1; + + if (companyCode !== "*") { + params.push(companyCode); + paramIdx++; + } + + if (keyword) { + keywordCondition = `AND ( + wi.instruction_no ILIKE $${paramIdx} + OR wi.item_name ILIKE $${paramIdx} + OR wi.item_code ILIKE $${paramIdx} + OR sm.subcontractor_name ILIKE $${paramIdx} + )`; + params.push(`%${keyword}%`); + paramIdx++; + } + + const companyFilter = companyCode !== "*" + ? `wop_done.company_code = $1` + : `1=1`; + + const query = ` + SELECT + wop_done.id AS completed_process_id, + wop_done.wo_id, + wop_done.seq_no AS completed_seq_no, + wop_done.process_code AS completed_process_code, + COALESCE(pm_done.process_name, wop_done.process_name, wop_done.process_code) AS completed_process_name, + COALESCE(CAST(NULLIF(wop_done.good_qty, '') AS numeric), 0) AS good_qty, + wop_next.id AS next_process_id, + wop_next.seq_no AS next_seq_no, + wop_next.process_code AS next_process_code, + COALESCE(pm_next.process_name, wop_next.process_name, wop_next.process_code) AS next_process_name, + wop_next.status AS next_status, + wi.instruction_no, + wi.item_code, + wi.item_name, + ii.size AS spec, + ii.material, + ii.inventory_unit AS unit, + sm.id AS subcontractor_id, + sm.subcontractor_code, + sm.subcontractor_name + FROM work_order_process wop_done + INNER JOIN work_instruction wi + ON wop_done.wo_id = wi.id + AND wop_done.company_code = wi.company_code + -- 다음 공정 (바로 다음 seq_no) + INNER JOIN LATERAL ( + SELECT wop2.* + FROM work_order_process wop2 + WHERE wop2.wo_id = wop_done.wo_id + AND wop2.company_code = wop_done.company_code + AND wop2.parent_process_id IS NULL + AND CAST(wop2.seq_no AS int) > CAST(wop_done.seq_no AS int) + ORDER BY CAST(wop2.seq_no AS int) + LIMIT 1 + ) wop_next ON TRUE + -- 다음 공정이 외주인지 확인 + INNER JOIN item_routing_subcontractor irs + ON irs.routing_detail_id = wop_next.routing_detail_id + INNER JOIN subcontractor_mng sm + ON irs.subcontractor_id = sm.id + LEFT JOIN item_info ii + ON wi.item_code = ii.item_number AND wi.company_code = ii.company_code + LEFT JOIN process_mng pm_done + ON wop_done.process_code = pm_done.process_code AND wop_done.company_code = pm_done.company_code + LEFT JOIN process_mng pm_next + ON wop_next.process_code = pm_next.process_code AND wop_next.company_code = pm_next.company_code + WHERE ${companyFilter} + AND wop_done.parent_process_id IS NULL + AND wop_done.status IN ('completed', 'acceptable') + AND COALESCE(CAST(NULLIF(wop_done.good_qty, '') AS numeric), 0) > 0 + -- 아직 외주출고 등록 안 된 건만 + AND NOT EXISTS ( + SELECT 1 FROM outbound_mng om + WHERE om.outbound_type = '외주출고' + AND om.source_type = 'work_order_process' + AND om.source_id = wop_done.id + ${companyCode !== "*" ? "AND om.company_code = $1" : ""} + ) + ${keywordCondition} + ORDER BY wi.instruction_no, CAST(wop_done.seq_no AS int) + `; + + 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 }); + } +} + +/** + * 외주출고 목록 조회 + * GET /api/outsourcing-outbound/list + */ +export async function getList(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { outbound_status, search_keyword, date_from, date_to } = req.query; + + const conditions: string[] = ["om.outbound_type = '외주출고'"]; + const params: any[] = []; + let paramIdx = 1; + + if (companyCode !== "*") { + conditions.push(`om.company_code = $${paramIdx}`); + params.push(companyCode); + paramIdx++; + } + + if (outbound_status && outbound_status !== "all") { + conditions.push(`om.outbound_status = $${paramIdx}`); + params.push(outbound_status); + paramIdx++; + } + + if (search_keyword) { + conditions.push(`( + om.outbound_number ILIKE $${paramIdx} + OR om.item_name ILIKE $${paramIdx} + OR om.item_code ILIKE $${paramIdx} + OR om.customer_name ILIKE $${paramIdx} + OR om.reference_number ILIKE $${paramIdx} + )`); + params.push(`%${search_keyword}%`); + paramIdx++; + } + + if (date_from) { + conditions.push(`om.outbound_date >= $${paramIdx}`); + params.push(date_from); + paramIdx++; + } + if (date_to) { + conditions.push(`om.outbound_date <= $${paramIdx}`); + params.push(date_to); + paramIdx++; + } + + const whereClause = `WHERE ${conditions.join(" AND ")}`; + + const pool = getPool(); + const result = await pool.query( + `SELECT om.*, wh.warehouse_name + FROM outbound_mng om + LEFT JOIN warehouse_info wh ON om.warehouse_code = wh.warehouse_code AND om.company_code = wh.company_code + ${whereClause} + ORDER BY om.created_date DESC`, + 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 }); + } +} + +/** + * 외주출고 등록 + * POST /api/outsourcing-outbound + */ +export async function create(req: AuthenticatedRequest, res: Response) { + const pool = getPool(); + const client = await pool.connect(); + + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { + items, + outbound_number, + outbound_date, + warehouse_code, + location_code, + manager_id, + memo, + } = req.body; + + if (!items || !Array.isArray(items) || items.length === 0) { + return res.status(400).json({ success: false, message: "출고 품목이 없습니다." }); + } + + await client.query("BEGIN"); + + const insertedRows: any[] = []; + + for (const item of items) { + const result = await client.query( + `INSERT INTO outbound_mng ( + id, company_code, outbound_number, outbound_type, outbound_date, + reference_number, customer_code, customer_name, + item_code, item_name, specification, material, unit, + outbound_qty, unit_price, total_amount, + warehouse_code, location_code, + outbound_status, manager_id, memo, + source_type, source_id, + created_date, created_by, writer, status + ) VALUES ( + gen_random_uuid()::text, $1, $2, '외주출고', $3, + $4, $5, $6, + $7, $8, $9, $10, $11, + $12, 0, 0, + $13, $14, + '출고완료', $15, $16, + 'work_order_process', $17, + NOW(), $18, $18, '출고' + ) RETURNING *`, + [ + companyCode, + outbound_number || item.outbound_number, + outbound_date || item.outbound_date, + item.reference_number || null, // 작업지시번호 + item.subcontractor_code || null, // 외주사코드 → customer_code + item.subcontractor_name || null, // 외주사명 → customer_name + item.item_code || null, + item.item_name || null, + item.spec || null, + item.material || null, + item.unit || null, + item.outbound_qty || 0, + warehouse_code || item.warehouse_code || null, + location_code || item.location_code || null, + manager_id || item.manager_id || null, + memo || item.memo || null, + item.completed_process_id || null, // source_id = 완료된 공정 ID + userId, + ], + ); + + insertedRows.push(result.rows[0]); + + // 재고 차감 + const itemCode = item.item_code || null; + const whCode = warehouse_code || item.warehouse_code || null; + const locCode = location_code || item.location_code || null; + const outQty = Number(item.outbound_qty) || 0; + + if (itemCode && outQty > 0 && whCode) { + await adjustInventory(client, { + companyCode, + userId, + itemCode, + whCode, + locCode, + delta: -outQty, + transactionType: "외주출고", + remark: `외주출고 (${outbound_number || ""}) → ${item.subcontractor_name || ""}`, + }); + } + } + + await client.query("COMMIT"); + + logger.info("외주출고 등록 완료", { + companyCode, + userId, + count: insertedRows.length, + outbound_number, + }); + + return res.json({ + success: true, + data: insertedRows, + message: `${insertedRows.length}건 외주출고 등록 완료`, + }); + } catch (error: any) { + await client.query("ROLLBACK"); + logger.error("외주출고 등록 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } finally { + client.release(); + } +} + +/** + * 외주출고 수정 + * PUT /api/outsourcing-outbound/:id + */ +export async function update(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { id } = req.params; + const { outbound_date, outbound_qty, warehouse_code, location_code, memo } = req.body; + + const pool = getPool(); + const companyCondition = companyCode === "*" ? "" : `AND company_code = '${companyCode}'`; + + const result = await pool.query( + `UPDATE outbound_mng SET + outbound_date = COALESCE($1::date, outbound_date), + outbound_qty = COALESCE($2::numeric, outbound_qty), + warehouse_code = COALESCE($3, warehouse_code), + location_code = COALESCE($4, location_code), + memo = COALESCE($5, memo), + updated_date = NOW(), + updated_by = $6 + WHERE id = $7 ${companyCondition} + RETURNING *`, + [outbound_date, outbound_qty, warehouse_code, location_code, memo, userId, id], + ); + + if (result.rowCount === 0) { + return res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." }); + } + + 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 }); + } +} + +/** + * 외주출고 삭제 + * DELETE /api/outsourcing-outbound/:id + */ +export async function deleteOutbound(req: AuthenticatedRequest, res: Response) { + const pool = getPool(); + const client = await pool.connect(); + + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { id } = req.params; + + await client.query("BEGIN"); + + // 삭제 전 데이터 조회 (재고 복구용) + const companyCondition = companyCode === "*" ? "" : `AND company_code = $2`; + const queryParams = companyCode === "*" ? [id] : [id, companyCode]; + + const oldRes = await client.query( + `SELECT * FROM outbound_mng WHERE id = $1 ${companyCondition}`, + queryParams, + ); + + if (oldRes.rowCount === 0) { + await client.query("ROLLBACK"); + return res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." }); + } + + const old = oldRes.rows[0]; + const itemCode = old.item_code || null; + const whCode = old.warehouse_code || null; + const locCode = old.location_code || null; + const oldQty = Number(old.outbound_qty) || 0; + + // 재고 복구 + if (itemCode && oldQty > 0 && whCode) { + await adjustInventory(client, { + companyCode: old.company_code, + userId, + itemCode, + whCode, + locCode, + delta: +oldQty, + transactionType: "외주출고취소", + remark: `외주출고 삭제 (${old.outbound_number || ""})`, + }); + } + + // 삭제 + await client.query( + `DELETE FROM outbound_mng WHERE id = $1 ${companyCondition}`, + queryParams, + ); + + await client.query("COMMIT"); + + logger.info("외주출고 삭제", { companyCode, id }); + return res.json({ success: true, message: "삭제되었습니다." }); + } catch (error: any) { + await client.query("ROLLBACK"); + logger.error("외주출고 삭제 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } finally { + client.release(); + } +} + +/** + * 외주출고번호 자동생성 + * GET /api/outsourcing-outbound/generate-number + */ +export async function generateNumber(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const pool = getPool(); + const yyyy = new Date().getFullYear(); + const prefix = `OSOUT-${yyyy}-`; + + const result = await pool.query( + `SELECT outbound_number FROM outbound_mng + WHERE company_code = $1 AND outbound_number LIKE $2 + ORDER BY outbound_number DESC LIMIT 1`, + [companyCode, `${prefix}%`], + ); + + let seq = 1; + if (result.rows.length > 0) { + const lastNo = result.rows[0].outbound_number; + const lastSeq = parseInt(lastNo.replace(prefix, ""), 10); + if (!isNaN(lastSeq)) seq = lastSeq + 1; + } + + const newNumber = `${prefix}${String(seq).padStart(4, "0")}`; + return res.json({ success: true, data: newNumber }); + } catch (error: any) { + logger.error("외주출고번호 생성 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +/** + * 창고 목록 (outbound 컨트롤러와 공유) + */ +export async function getWarehouses(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const pool = getPool(); + + const condition = companyCode === "*" ? "" : `WHERE company_code = $1`; + const params = companyCode === "*" ? [] : [companyCode]; + + const result = await pool.query( + `SELECT warehouse_code, warehouse_name, warehouse_type FROM warehouse_info ${condition} ORDER BY warehouse_name`, + params, + ); + + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + return res.status(500).json({ success: false, message: error.message }); + } +} diff --git a/backend-node/src/controllers/packagingController.ts b/backend-node/src/controllers/packagingController.ts index 811fdb60..8a98c256 100644 --- a/backend-node/src/controllers/packagingController.ts +++ b/backend-node/src/controllers/packagingController.ts @@ -207,7 +207,7 @@ export async function getPkgUnitItems( const pool = getPool(); const result = await pool.query( - `SELECT pui.*, ii.item_name, ii.size AS spec, ii.unit + `SELECT pui.*, ii.item_name, ii.size AS spec, ii.unit, ii.inventory_unit, ii.material FROM pkg_unit_item pui LEFT JOIN item_info ii ON pui.item_number = ii.item_number AND pui.company_code = ii.company_code WHERE pui.pkg_code=$1 AND pui.company_code=$2 @@ -596,7 +596,7 @@ export async function getItemsByDivision( } const result = await pool.query( - `SELECT id, item_number, item_name, size, material, unit, division + `SELECT id, item_number, item_name, size, material, unit, inventory_unit, division FROM item_info WHERE ${conditions.join(" AND ")} ORDER BY item_name`, @@ -649,7 +649,7 @@ export async function getGeneralItems( } const result = await pool.query( - `SELECT id, item_number, item_name, size AS spec, material, unit, division + `SELECT id, item_number, item_name, size AS spec, material, unit, inventory_unit, division FROM item_info WHERE ${conditions.join(" AND ")} ORDER BY item_name diff --git a/backend-node/src/controllers/workInstructionController.ts b/backend-node/src/controllers/workInstructionController.ts index 9c88f858..c6b9e667 100644 --- a/backend-node/src/controllers/workInstructionController.ts +++ b/backend-node/src/controllers/workInstructionController.ts @@ -7,13 +7,19 @@ import { getPool } from "../database/db"; import { logger } from "../utils/logger"; import { numberingRuleService } from "../services/numberingRuleService"; -// 자동 마이그레이션: work_instruction_detail에 routing_version_id 컬럼 추가 +// 자동 마이그레이션: work_instruction_detail에 routing_version_id + 품목별 일정/설비/작업조/작업자 컬럼 추가 let _migrationDone = false; async function ensureDetailRoutingColumn() { if (_migrationDone) return; try { const pool = getPool(); await pool.query("ALTER TABLE work_instruction_detail ADD COLUMN IF NOT EXISTS routing_version_id VARCHAR(500)"); + // 품목별 일정/설비/작업조/작업자 컬럼 (옵션 A — 다중선택 지원) + await pool.query("ALTER TABLE work_instruction_detail ADD COLUMN IF NOT EXISTS start_date VARCHAR(500)"); + await pool.query("ALTER TABLE work_instruction_detail ADD COLUMN IF NOT EXISTS end_date VARCHAR(500)"); + await pool.query("ALTER TABLE work_instruction_detail ADD COLUMN IF NOT EXISTS equipment_ids VARCHAR(1000)"); + await pool.query("ALTER TABLE work_instruction_detail ADD COLUMN IF NOT EXISTS work_teams VARCHAR(200)"); + await pool.query("ALTER TABLE work_instruction_detail ADD COLUMN IF NOT EXISTS workers VARCHAR(1000)"); _migrationDone = true; } catch { /* 이미 존재하거나 권한 문제 시 무시 */ } } @@ -130,6 +136,11 @@ export async function getList(req: AuthenticatedRequest, res: Response) { d.source_table, d.source_id, d.routing_version_id AS detail_routing_version_id, + d.start_date AS detail_start_date, + d.end_date AS detail_end_date, + d.equipment_ids AS detail_equipment_ids, + d.work_teams AS detail_work_teams, + d.workers AS detail_workers, COALESCE(itm.item_name, '') AS item_name, COALESCE(itm.type, '') AS item_type, COALESCE(itm.size, '') AS item_spec, @@ -186,6 +197,11 @@ export async function getList(req: AuthenticatedRequest, res: Response) { d.source_table, d.source_id, d.routing_version_id AS detail_routing_version_id, + d.start_date AS detail_start_date, + d.end_date AS detail_end_date, + d.equipment_ids AS detail_equipment_ids, + d.work_teams AS detail_work_teams, + d.workers AS detail_workers, COALESCE(itm.item_name, '') AS item_name, COALESCE(itm.type, '') AS item_type, COALESCE(itm.size, '') AS item_spec, @@ -293,8 +309,25 @@ export async function save(req: AuthenticatedRequest, res: Response) { if (!firstRouting && itemRouting) firstRouting = itemRouting; totalQty += Number(item.qty || 0); await client.query( - `INSERT INTO work_instruction_detail (id,company_code,work_instruction_no,work_instruction_id,item_number,qty,remark,source_table,source_id,part_code,routing_version_id,created_date,writer) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,NOW(),$11)`, - [companyCode, wiNo, wiId, item.itemNumber||item.itemCode||"", item.qty||"0", item.remark||"", item.sourceTable||"", item.sourceId||"", item.partCode||item.itemNumber||item.itemCode||"", itemRouting, userId] + `INSERT INTO work_instruction_detail (id,company_code,work_instruction_no,work_instruction_id,item_number,qty,remark,source_table,source_id,part_code,routing_version_id,start_date,end_date,equipment_ids,work_teams,workers,created_date,writer) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,NOW(),$16)`, + [ + companyCode, + wiNo, + wiId, + item.itemNumber||item.itemCode||"", + item.qty||"0", + item.remark||"", + item.sourceTable||"", + item.sourceId||"", + item.partCode||item.itemNumber||item.itemCode||"", + itemRouting, + item.startDate||"", + item.endDate||"", + item.equipmentIds||"", + item.workTeams||"", + item.workers||"", + userId, + ] ); } @@ -394,7 +427,30 @@ export async function getProductionPlanSource(req: AuthenticatedRequest, res: Re const pool = getPool(); const cnt = await pool.query(`SELECT COUNT(*) AS total FROM production_plan_mng p WHERE ${w}`, params); params.push(pageSize, offset); - const rows = await pool.query(`SELECT p.id, p.plan_no, p.item_code, COALESCE(p.item_name,'') AS item_name, COALESCE(p.plan_qty,0) AS plan_qty, p.start_date, p.end_date, p.status, COALESCE(p.equipment_name,'') AS equipment_name FROM production_plan_mng p WHERE ${w} ORDER BY p.created_date DESC LIMIT $${idx} OFFSET $${idx+1}`, params); + // work_instruction_detail에서 해당 계획에 이미 내린 작업지시 수량 합계 → applied_qty, remain_qty + const rows = await pool.query( + `SELECT p.id, p.plan_no, p.item_code, + COALESCE(p.item_name,'') AS item_name, + COALESCE(p.plan_qty,0) AS plan_qty, + p.start_date, p.end_date, p.status, + COALESCE(p.equipment_name,'') AS equipment_name, + COALESCE(wi.applied_qty, 0) AS applied_qty, + (COALESCE(CAST(NULLIF(p.plan_qty::text, '') AS numeric), 0) + - COALESCE(wi.applied_qty, 0)) AS remain_qty + FROM production_plan_mng p + LEFT JOIN ( + SELECT source_id, + SUM(COALESCE(CAST(NULLIF(qty, '') AS numeric), 0)) AS applied_qty + FROM work_instruction_detail + WHERE source_table = 'production_plan_mng' + AND company_code = $1 + GROUP BY source_id + ) wi ON wi.source_id = p.id::text + WHERE ${w} + ORDER BY p.created_date DESC + LIMIT $${idx} OFFSET $${idx+1}`, + params, + ); return res.json({ success: true, data: rows.rows, totalCount: parseInt(cnt.rows[0].total), page, pageSize }); } catch (error: any) { return res.status(500).json({ success: false, message: error.message }); } } diff --git a/backend-node/src/routes/adminRoutes.ts b/backend-node/src/routes/adminRoutes.ts index cd31c8a4..448cda48 100644 --- a/backend-node/src/routes/adminRoutes.ts +++ b/backend-node/src/routes/adminRoutes.ts @@ -11,6 +11,7 @@ import { toggleMenuStatus, // 메뉴 상태 토글 copyMenu, // 메뉴 복사 getUserList, + getUserNameMap, // 사용자 ID→이름 맵 (경량) getUserInfo, // 사용자 상세 조회 getUserHistory, // 사용자 변경이력 조회 changeUserStatus, // 사용자 상태 변경 @@ -70,6 +71,7 @@ router.delete("/menus/:menuId", deleteMenu); // 메뉴 삭제 // 사용자 관리 API router.get("/users", getUserList); +router.get("/users/name-map", getUserNameMap); // 사용자 ID→이름 매핑 (경량) router.get("/users/:userId", getUserInfo); // 사용자 상세 조회 router.get("/users/:userId/history", getUserHistory); // 사용자 변경이력 조회 router.get("/users/:userId/with-dept", getUserWithDept); // 사원 + 부서 조회 (NEW!) diff --git a/backend-node/src/routes/outsourcingOutboundRoutes.ts b/backend-node/src/routes/outsourcingOutboundRoutes.ts new file mode 100644 index 00000000..52390d3f --- /dev/null +++ b/backend-node/src/routes/outsourcingOutboundRoutes.ts @@ -0,0 +1,30 @@ +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import * as ctrl from "../controllers/outsourcingOutboundController"; + +const router = Router(); + +router.use(authenticateToken); + +// 외주출고 대상 자동 조회 +router.get("/candidates", ctrl.getCandidates); + +// 외주출고 목록 조회 +router.get("/list", ctrl.getList); + +// 외주출고번호 자동생성 +router.get("/generate-number", ctrl.generateNumber); + +// 창고 목록 +router.get("/warehouses", ctrl.getWarehouses); + +// 외주출고 등록 +router.post("/", ctrl.create); + +// 외주출고 수정 +router.put("/:id", ctrl.update); + +// 외주출고 삭제 +router.delete("/:id", ctrl.deleteOutbound); + +export default router; diff --git a/frontend/app/(main)/COMPANY_10/logistics/info/page.tsx b/frontend/app/(main)/COMPANY_10/logistics/info/page.tsx index 5f44e0ec..c9a5a5c1 100644 --- a/frontend/app/(main)/COMPANY_10/logistics/info/page.tsx +++ b/frontend/app/(main)/COMPANY_10/logistics/info/page.tsx @@ -358,13 +358,15 @@ export default function LogisticsInfoPage() { loadReferences(); }, [loadReferences]); - // 카테고리 옵션 로드 + // 카테고리 옵션 로드 (관리자 계정일 때 filterCompanyCode 미제공 시 "*" 스코프로 빈 결과 반환됨) const loadCategoryOptions = useCallback(async (tableColumn: string) => { if (loadedCategories.current.has(tableColumn)) return; loadedCategories.current.add(tableColumn); const [tableName, columnName] = tableColumn.split(":"); try { - const res = await apiClient.get(`/table-categories/${tableName}/${columnName}/values`); + const res = await apiClient.get( + `/table-categories/${tableName}/${columnName}/values?filterCompanyCode=COMPANY_10` + ); const data = res.data?.data || []; setCategoryOptions((prev) => ({ ...prev, @@ -823,13 +825,24 @@ export default function LogisticsInfoPage() { {/* 테이블 영역 */}
({ - key: col.key, - label: col.label, - align: col.align, - formatNumber: col.formatNumber, - truncate: true, - }))} + columns={getVisibleColumns(tab.key).map((col): EDataTableColumn => { + // 같은 key의 formField에 categoryKey가 있으면 코드→라벨 변환 + const formField = tab.formFields.find((f) => f.key === col.key && f.categoryKey); + return { + key: col.key, + label: col.label, + align: col.align, + formatNumber: col.formatNumber, + truncate: true, + render: formField?.categoryKey + ? (value: any) => { + const opts = categoryOptions[formField.categoryKey!] || []; + const matched = opts.find((o: any) => o.value === value); + return matched?.label || value || "-"; + } + : undefined, + }; + })} data={tsMap[tab.key].groupData(displayData)} rowKey={(row: any) => String(row.id)} loading={tabLoading[tab.key]} diff --git a/frontend/app/(main)/COMPANY_10/logistics/inventory/page.tsx b/frontend/app/(main)/COMPANY_10/logistics/inventory/page.tsx index c6936a6c..9e4b6977 100644 --- a/frontend/app/(main)/COMPANY_10/logistics/inventory/page.tsx +++ b/frontend/app/(main)/COMPANY_10/logistics/inventory/page.tsx @@ -186,12 +186,12 @@ export default function InventoryStatusPage() { }; load(); // 사용자 목록 로드 - apiClient.get("/admin/users", { params: { size: 9999 } }).then((res) => { - const users = res.data?.data || res.data || []; + apiClient.get("/admin/users/name-map").then((res) => { + const users = res.data?.data || []; const map: Record = {}; for (const u of users) { - const id = u.userId || u.user_id || u.id; - const name = u.user_name || u.name || id; + const id = u.user_id; + const name = u.user_name || id; if (id) map[id] = name; } setUserMap(map); diff --git a/frontend/app/(main)/COMPANY_10/logistics/material-status/page.tsx b/frontend/app/(main)/COMPANY_10/logistics/material-status/page.tsx index 58354385..42d9a69a 100644 --- a/frontend/app/(main)/COMPANY_10/logistics/material-status/page.tsx +++ b/frontend/app/(main)/COMPANY_10/logistics/material-status/page.tsx @@ -628,7 +628,7 @@ export default function MaterialStatusPage() { className="inline-flex items-center gap-1 rounded bg-muted/40 px-2 py-0.5 text-xs transition-colors hover:bg-muted/60" > - {loc.location || loc.warehouse} + {loc.warehouse_name || loc.location || loc.warehouse} {loc.qty.toLocaleString()} diff --git a/frontend/app/(main)/COMPANY_10/logistics/packaging/page.tsx b/frontend/app/(main)/COMPANY_10/logistics/packaging/page.tsx index 74585bb8..66b467bb 100644 --- a/frontend/app/(main)/COMPANY_10/logistics/packaging/page.tsx +++ b/frontend/app/(main)/COMPANY_10/logistics/packaging/page.tsx @@ -27,6 +27,7 @@ import { getItemsByDivision, getGeneralItems, type PkgUnit, type PkgUnitItem, type LoadingUnit, type LoadingUnitPkg, type ItemInfoForPkg, } from "@/lib/api/packaging"; +import { apiClient } from "@/lib/api/client"; import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; @@ -118,6 +119,45 @@ export default function PackagingPage() { const [saving, setSaving] = useState(false); + // 카테고리 옵션 (inventory_unit / material) — 코드 → 라벨 변환 + const [categoryOptions, setCategoryOptions] = useState< + Record + >({}); + + useEffect(() => { + const load = async () => { + const flatten = (vals: any[]): { code: string; label: string }[] => { + const out: { code: string; label: string }[] = []; + for (const v of vals) { + out.push({ + code: v.valueCode || v.value_code || v.code, + label: v.valueLabel || v.value_label || v.label, + }); + if (v.children?.length) out.push(...flatten(v.children)); + } + return out; + }; + const optMap: Record = {}; + for (const col of ["inventory_unit", "material"]) { + try { + const res = await apiClient.get( + `/table-categories/item_info/${col}/values` + ); + if (res.data?.success) optMap[col] = flatten(res.data.data || []); + } catch { + /* skip */ + } + } + setCategoryOptions(optMap); + }; + load(); + }, []); + + const resolveCat = (col: string, code: string | null | undefined) => { + if (!code) return ""; + return categoryOptions[col]?.find((o) => o.code === code)?.label || code; + }; + // --- 데이터 로드 (item_info 기반 + pkg_unit/loading_unit LEFT JOIN 방식) --- const fetchPkgUnits = useCallback(async () => { setPkgLoading(true); @@ -622,7 +662,7 @@ export default function PackagingPage() { {item.item_number} {item.item_name || "-"} {item.spec || "-"} - {item.unit || "EA"} + {resolveCat("inventory_unit", item.inventory_unit) || "EA"} {Number(item.pkg_qty).toLocaleString()}
+ {/* 위치명 형식 — 구역/열/단 뒤에 붙일 표현만 자유 입력 */} +
+ +
+ A + setRackZoneLabel(e.target.value)} + placeholder="구역" + className="h-8 w-20 text-xs" + /> + - 01 + setRackRowLabel(e.target.value)} + placeholder="열" + className="h-8 w-20 text-xs" + /> + - 1 + setRackLevelLabel(e.target.value)} + placeholder="단" + className="h-8 w-20 text-xs" + /> +
+

+ 예시: A{rackZoneLabel}-01{rackRowLabel}-1{rackLevelLabel} + {" "}— 구역/열/단 번호는 자동 계산되고, 뒤에 붙는 명칭만 수정할 수 있습니다. +

+
+ {/* 등록 미리보기 */}
diff --git a/frontend/app/(main)/COMPANY_10/outsourcing/subcontractor-item/page.tsx b/frontend/app/(main)/COMPANY_10/outsourcing/subcontractor-item/page.tsx index 8f1802c4..d16596ed 100644 --- a/frontend/app/(main)/COMPANY_10/outsourcing/subcontractor-item/page.tsx +++ b/frontend/app/(main)/COMPANY_10/outsourcing/subcontractor-item/page.tsx @@ -98,12 +98,26 @@ export default function SubcontractorItemPage() { } return result; }; - for (const col of ["material", "division", "type", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"]) { + for (const col of ["material", "division", "type", "status", "unit", "inventory_unit", "currency_code", "user_type01", "user_type02"]) { try { const res = await apiClient.get(`/table-categories/${ITEM_TABLE}/${col}/values`); if (res.data?.success) optMap[col] = flatten(res.data.data || []); } catch { /* skip */ } } + // 외주사관리에서 사용하는 subcontractor_item_prices.currency_code도 병합 + try { + const res = await apiClient.get(`/table-categories/subcontractor_item_prices/currency_code/values`); + if (res.data?.success) { + const extra = flatten(res.data.data || []); + const seen = new Set((optMap["currency_code"] || []).map((o) => o.code)); + for (const e of extra) { + if (!seen.has(e.code)) { + (optMap["currency_code"] ||= []).push(e); + seen.add(e.code); + } + } + } + } catch { /* skip */ } // 외주업체 거래유형 (subcontractor_mng.division) try { const res = await apiClient.get(`/table-categories/${SUBCONTRACTOR_TABLE}/division/values`); @@ -124,10 +138,10 @@ export default function SubcontractorItemPage() { item_number: { width: "w-[110px]" }, item_name: { minWidth: "min-w-[130px]", render: (v) => v || "-" }, size: { width: "w-[90px]", render: (v) => v || "-" }, - unit: { width: "w-[60px]", render: (v) => v || "-" }, + unit: { width: "w-[60px]", render: (v) => resolve("unit", v) || "-" }, standard_price: { width: "w-[90px]", align: "right", formatNumber: true }, selling_price: { width: "w-[90px]", align: "right", formatNumber: true }, - currency_code: { width: "w-[50px]", render: (v) => v || "-" }, + currency_code: { width: "w-[50px]", render: (v) => resolve("currency_code", v) || "-" }, status: { width: "w-[60px]", render: (v) => v || "-" }, }; return ts.visibleColumns.map((col) => ({ @@ -135,7 +149,8 @@ export default function SubcontractorItemPage() { label: col.label, ...colProps[col.key], })); - }, [ts.visibleColumns]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ts.visibleColumns, categoryOptions]); // 좌측: 품목 조회 (division이 "외주관리"인 품목만 필터링) const outsourcingDivisionCode = categoryOptions["division"]?.find( @@ -164,8 +179,8 @@ export default function SubcontractorItemPage() { for (const col of CATS) { if (converted[col]) converted[col] = resolve(col, converted[col]); } - // item_info의 inventory_unit을 단위 표시용 unit에 매핑 - converted.unit = converted.inventory_unit || converted.unit || ""; + // "단위" 컬럼은 재고단위(inventory_unit)만 사용 — unit 폴백 제거 + converted.unit = converted.inventory_unit || ""; return converted; }); setItems(data); @@ -212,11 +227,35 @@ export default function SubcontractorItemPage() { } catch { /* skip */ } } - setSubcontractorItems(mappings.map((m: any) => ({ - ...m, - subcontractor_code: m.subcontractor_id, - subcontractor_name: subMap[m.subcontractor_id]?.subcontractor_name || "", - }))); + // 외주사관리에서 입력된 최신 단가(subcontractor_item_prices) 조회 → subcontractor_id 별 최신 1건 + const priceMap: Record = {}; + try { + const priceRes = await apiClient.post(`/table-management/tables/subcontractor_item_prices/data`, { + page: 1, size: 0, + dataFilter: { enabled: true, filters: [{ columnName: "item_id", operator: "equals", value: itemKey }] }, + autoFilter: true, + }); + const prices = priceRes.data?.data?.data || priceRes.data?.data?.rows || []; + for (const p of prices) { + const key = p.subcontractor_id; + if (!key) continue; + if (!priceMap[key] || (p.start_date && (!priceMap[key].start_date || p.start_date > priceMap[key].start_date))) { + priceMap[key] = p; + } + } + } catch { /* skip */ } + + setSubcontractorItems(mappings.map((m: any) => { + const price = priceMap[m.subcontractor_id] || {}; + return { + ...m, + subcontractor_code: m.subcontractor_id, + subcontractor_name: subMap[m.subcontractor_id]?.subcontractor_name || "", + base_price: price.base_price ?? m.base_price, + calculated_price: price.calculated_price ?? price.unit_price ?? m.calculated_price, + currency_code: resolve("currency_code", price.currency_code ?? m.currency_code), + }; + })); } catch (err) { console.error("외주업체 조회 실패:", err); } finally { @@ -224,7 +263,8 @@ export default function SubcontractorItemPage() { } }; fetchSubcontractorItems(); - }, [selectedItem?.item_number]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedItem?.item_number, categoryOptions]); // 외주업체 검색 const searchSubcontractors = async () => { diff --git a/frontend/app/(main)/COMPANY_10/production/bom/page.tsx b/frontend/app/(main)/COMPANY_10/production/bom/page.tsx index 84b7afbb..01e7ee14 100644 --- a/frontend/app/(main)/COMPANY_10/production/bom/page.tsx +++ b/frontend/app/(main)/COMPANY_10/production/bom/page.tsx @@ -59,6 +59,7 @@ import { Settings2, Save, Package, + Pencil, } from "lucide-react"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; @@ -355,7 +356,13 @@ export default function BomManagementPage() { sort: { columnName: "created_at", order: "desc" }, }); - const rows = res.data?.data?.data || res.data?.data?.rows || []; + // DB 컬럼이 item_type/expired_date → 프론트 내부에서는 bom_type/expiry_date로 통일 + const rawRows = res.data?.data?.data || res.data?.data?.rows || []; + const rows = rawRows.map((r: any) => ({ + ...r, + bom_type: r.bom_type ?? r.item_type, + expiry_date: r.expiry_date ?? r.expired_date, + })); setBomList(rows); setTotalCount(rows.length); } catch (err: any) { @@ -452,9 +459,16 @@ export default function BomManagementPage() { const fetchBomDetail = useCallback(async (bomId: string) => { setDetailLoading(true); try { - // 헤더 조회 + // 헤더 조회 (DB 컬럼 item_type/expired_date → bom_type/expiry_date로 매핑) const headerRes = await apiClient.get(`/bom/${bomId}/header`); - const header = headerRes.data?.data || headerRes.data; + const rawHeader = headerRes.data?.data || headerRes.data; + const header = rawHeader + ? { + ...rawHeader, + bom_type: rawHeader.bom_type ?? rawHeader.item_type, + expiry_date: rawHeader.expiry_date ?? rawHeader.expired_date, + } + : null; setBomHeader(header); setCurrentVersionId(header?.current_version_id || null); @@ -1100,17 +1114,18 @@ export default function BomManagementPage() { setSaving(true); try { + // DB 실제 컬럼: item_type / expired_date (프론트 내부 bom_type/expiry_date와 다름) const bomFields: Record = { item_id: masterForm.item_id, item_code: masterForm.item_code, item_name: masterForm.item_name, - bom_type: masterForm.bom_type, + item_type: masterForm.bom_type, base_qty: masterForm.base_qty || "1", unit: masterForm.unit || "", version: masterForm.version || "1.0", status: masterForm.status || "draft", effective_date: masterForm.effective_date || null, - expiry_date: masterForm.expiry_date || null, + expired_date: masterForm.expiry_date || null, remark: masterForm.remark || "", writer: user?.userId || "", company_code: user?.company_code || "", @@ -1482,6 +1497,21 @@ export default function BomManagementPage() { 등록 +
{showOutsourceField && (
- - + + + + + + +
+ {subcontractorOptions.length === 0 ? ( +
등록된 외주업체가 없어요
+ ) : subcontractorOptions.map((s) => { + const checked = formOutsources.includes(s.id); + return ( + + ); + })} +
+
+
)}
diff --git a/frontend/app/(main)/COMPANY_10/production/work-instruction/page.tsx b/frontend/app/(main)/COMPANY_10/production/work-instruction/page.tsx index ef7c2a39..9a6fc954 100644 --- a/frontend/app/(main)/COMPANY_10/production/work-instruction/page.tsx +++ b/frontend/app/(main)/COMPANY_10/production/work-instruction/page.tsx @@ -202,7 +202,13 @@ export default function WorkInstructionPage() { if (!regCheckedIds.has(getRegId(item))) continue; if (regSourceType === "item") items.push({ itemCode: item.item_code, itemName: item.item_name || "", spec: item.spec || "", qty: 1, remark: "", sourceType: "item", sourceTable: "item_info", sourceId: item.item_code }); else if (regSourceType === "order") items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: item.spec || "", qty: Number(item.qty || 1), remark: "", sourceType: "order", sourceTable: "sales_order_detail", sourceId: item.id }); - else items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: "", qty: Number(item.plan_qty || 1), remark: "", sourceType: "production", sourceTable: "production_plan_mng", sourceId: item.id }); + else { + // 생산계획: 잔량(remain_qty)이 있으면 잔량 기반으로 기본 수량 제안 (0/음수 허용 — 계획 초과 가능) + const defaultQty = item.remain_qty !== undefined && item.remain_qty !== null + ? Number(item.remain_qty) + : Number(item.plan_qty || 1); + items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: "", qty: defaultQty, remark: "", sourceType: "production", sourceTable: "production_plan_mng", sourceId: item.id }); + } } // 동일품목 합산 @@ -578,7 +584,7 @@ export default function WorkInstructionPage() { 0 && regCheckedIds.size === regSourceData.length} onCheckedChange={toggleRegAll} /> {regSourceType === "item" && <>품목코드품목명규격} {regSourceType === "order" && <>수주번호품번품목명규격수량납기일} - {regSourceType === "production" && <>계획번호품번품목명계획수량시작일완료일설비} + {regSourceType === "production" && <>계획번호품번품목명계획수량적용수량잔량시작일완료일설비} @@ -590,7 +596,7 @@ export default function WorkInstructionPage() { e.stopPropagation()}> toggleRegItem(id)} /> {regSourceType === "item" && <>{item.item_code}{item.item_name}{item.spec || "-"}} {regSourceType === "order" && <>{item.order_no}{item.item_code}{item.item_name}{item.spec || "-"}{Number(item.qty || 0).toLocaleString()}{item.due_date || "-"}} - {regSourceType === "production" && <>{item.plan_no}{item.item_code}{item.item_name}{Number(item.plan_qty || 0).toLocaleString()}{item.start_date ? String(item.start_date).split("T")[0] : "-"}{item.end_date ? String(item.end_date).split("T")[0] : "-"}{item.equipment_name || "-"}} + {regSourceType === "production" && <>{item.plan_no}{item.item_code}{item.item_name}{Number(item.plan_qty || 0).toLocaleString()}{Number(item.applied_qty || 0).toLocaleString()}{Number(item.remain_qty ?? item.plan_qty ?? 0).toLocaleString()}{item.start_date ? String(item.start_date).split("T")[0] : "-"}{item.end_date ? String(item.end_date).split("T")[0] : "-"}{item.equipment_name || "-"}} ); })} diff --git a/frontend/app/(main)/COMPANY_10/purchase/purchase-item/page.tsx b/frontend/app/(main)/COMPANY_10/purchase/purchase-item/page.tsx index 42db2edf..72f770b7 100644 --- a/frontend/app/(main)/COMPANY_10/purchase/purchase-item/page.tsx +++ b/frontend/app/(main)/COMPANY_10/purchase/purchase-item/page.tsx @@ -312,6 +312,11 @@ export default function PurchaseItemPage() { // 좌측: 품목 조회 const fetchItems = useCallback(async () => { + // 카테고리 로드 완료 전엔 대기 — 먼저 나간 unfiltered 요청이 나중에 도착해 + // filtered 결과를 덮어쓰는 race condition 방지 + if (!categoryOptions["division"]?.length) { + return; + } setItemLoading(true); try { const filters: { columnName: string; operator: string; value: any }[] = []; diff --git a/frontend/app/(main)/COMPANY_10/quality/inspection/page.tsx b/frontend/app/(main)/COMPANY_10/quality/inspection/page.tsx index 7c529989..8b93fa89 100644 --- a/frontend/app/(main)/COMPANY_10/quality/inspection/page.tsx +++ b/frontend/app/(main)/COMPANY_10/quality/inspection/page.tsx @@ -52,6 +52,7 @@ const INSPECTION_COLUMNS = [ { key: "inspection_code", label: "검사코드" }, { key: "inspection_type", label: "검사유형" }, { key: "inspection_criteria", label: "검사기준" }, + { key: "criteria_detail", label: "기준상세" }, { key: "inspection_item", label: "검사항목" }, { key: "inspection_method", label: "검사방법" }, { key: "judgment_criteria", label: "판단기준" }, diff --git a/frontend/app/(main)/COMPANY_10/quality/item-inspection/page.tsx b/frontend/app/(main)/COMPANY_10/quality/item-inspection/page.tsx index 15118ffc..2c71ed02 100644 --- a/frontend/app/(main)/COMPANY_10/quality/item-inspection/page.tsx +++ b/frontend/app/(main)/COMPANY_10/quality/item-inspection/page.tsx @@ -43,6 +43,7 @@ type InspectionRow = { inspection_detail: string; inspection_method: string; apply_process: string; + classification: string; acceptance_criteria: string; is_required: boolean; judgment_criteria?: string; // 판단기준 라벨 (수치(범위)/텍스트입력/O·X/선택형) @@ -253,6 +254,11 @@ export default function ItemInspectionInfoPage() { loadProcessOptions(item.code); }; + // 복사 모달: 편집 가능한 기준 데이터 상태 (등록/수정 폼과 평행 구조) + const [copyForm, setCopyForm] = useState>({}); + const [copyInspectionRows, setCopyInspectionRows] = useState>({}); + const [copyCollapsedTypes, setCopyCollapsedTypes] = useState>({}); + /* ═══════════════════ 복사 모달 (기준 품목 검사정보 → 다른 품목들) ═══════════════════ */ const [copyModalOpen, setCopyModalOpen] = useState(false); const [copySearchKeyword, setCopySearchKeyword] = useState(""); @@ -294,11 +300,63 @@ export default function ItemInspectionInfoPage() { setCopyTotal(resData?.total || resData?.totalCount || rows.length); } catch { /* skip */ } finally { setCopySearchLoading(false); } }; - const openCopyModal = () => { + const openCopyModal = async () => { if (!selectedItemCode) { toast.error("복사 기준 품목을 먼저 선택해주세요"); return; } const srcGroup = groupedData.find(g => g.item_code === selectedItemCode); if (!srcGroup || srcGroup.rows.length === 0) { toast.error("선택한 품목에 복사할 검사정보가 없어요"); return; } setCopySearchKeyword(""); setCopyPage(1); setCopyCheckedIds([]); + + // 기준 품목 데이터를 편집용 상태로 복제 (openEdit과 동일한 변환 로직) + const baseRow = srcGroup.rows[0]; + try { + const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, { + page: 1, size: 0, + dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: selectedItemCode }] }, + autoFilter: true, + }); + const allRows = res.data?.data?.data || res.data?.data?.rows || []; + const rowMap: Record = {}; + const typeFlags: Record = {}; + for (const r of allRows) { + const inspType = r.inspection_type || ""; + const matched = INSPECTION_TYPES.find(t => + t.matchLabels.some(ml => inspType.includes(ml)) || + inspTypeCatOptions.some(cat => inspType.includes(cat.code) && t.matchLabels.some(ml => cat.label.includes(ml))) + ); + const typeKey = matched?.key || ""; + if (!typeKey) continue; + typeFlags[typeKey] = true; + if (!rowMap[typeKey]) rowMap[typeKey] = []; + const mCode = r.inspection_method || ""; + const mLabel = inspMethodCatOptions.find(o => o.code === mCode)?.label || mCode; + const inspOpt = inspOptions.find(o => o.code === r.inspection_standard_id); + const jcCode = inspOpt?.judgment_criteria || ""; + const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode; + const unitCode = inspOpt?.unit || ""; + const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode; + rowMap[typeKey].push({ + id: crypto.randomUUID(), // 복사본은 새 id 부여 (원본과 분리) + inspection_standard_id: r.inspection_standard_id || "", + inspection_detail: r.inspection_item_name || r.inspection_standard || "", + inspection_method: mLabel, + apply_process: r.apply_process || "", + classification: r.classification || "", + acceptance_criteria: r.pass_criteria || "", + is_required: r.is_required === "true" || r.is_required === true, + judgment_criteria: jcLabel, + selection_options: inspOpt?.selection_options || "", + unit: unitLabel, + }); + } + setCopyInspectionRows(rowMap); + setCopyForm({ ...baseRow, ...typeFlags }); + setCopyCollapsedTypes({}); + } catch { + setCopyInspectionRows({}); + setCopyForm({ ...baseRow }); + setCopyCollapsedTypes({}); + } + setCopyModalOpen(true); searchCopyTargets(1); }; @@ -309,10 +367,18 @@ export default function ItemInspectionInfoPage() { const handleCopy = async () => { if (!selectedItemCode) { toast.error("복사 기준 품목이 없어요"); return; } if (copyCheckedIds.length === 0) { toast.error("붙여넣을 품목을 선택해주세요"); return; } - const sourceGroup = groupedData.find(g => g.item_code === selectedItemCode); - if (!sourceGroup || sourceGroup.rows.length === 0) { toast.error("복사할 검사정보가 없어요"); return; } + + // 편집된 rows를 평탄화 (선택된 검사유형의 rows만) + const enabledTypes = INSPECTION_TYPES.filter(t => !!copyForm[t.key]); + const flatRows: Array<{ row: InspectionRow; typeLabel: string }> = []; + for (const t of enabledTypes) { + const rows = copyInspectionRows[t.key] || []; + for (const r of rows) flatRows.push({ row: r, typeLabel: t.label }); + } + if (flatRows.length === 0) { toast.error("복사할 검사항목이 없어요"); return; } + const ok = await confirm( - `선택한 ${copyCheckedIds.length}개 품목에 검사정보를 복사할까요?`, + `선택한 ${copyCheckedIds.length}개 품목에 편집된 검사정보(${flatRows.length}개 행)를 복사할까요?`, { description: "대상 품목의 기존 검사정보는 삭제 후 교체됩니다.", variant: "info", confirmText: "복사" } ); if (!ok) return; @@ -333,13 +399,26 @@ export default function ItemInspectionInfoPage() { if (existing.length > 0) { await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: existing.map((r: any) => ({ id: r.id })) }); } - for (const r of sourceGroup.rows) { - const { id: _id, created_at: _c, updated_at: _u, ...rest } = r; + let orderSeq = 0; + for (const { row: r, typeLabel } of flatRows) { + orderSeq += 1; await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, { - ...rest, id: crypto.randomUUID(), item_code: targetCode, item_name: targetName, + inspection_type: typeLabel, + inspection_standard_id: r.inspection_standard_id || "", + inspection_item_name: r.inspection_detail || "", + inspection_method: r.inspection_method || "", + apply_process: r.apply_process || "", + classification: r.classification || "", + pass_criteria: r.acceptance_criteria || "", + is_required: r.is_required ? "true" : "false", + is_active: copyForm.is_active || "사용", + manager: copyForm.manager || "", + manager_id: copyForm.manager_id || "", + memo: copyForm.remarks || "", + sort_order: String(orderSeq).padStart(4, "0"), }); } setCopyProgress({ current: i + 1, total: copyCheckedIds.length }); @@ -402,7 +481,13 @@ export default function ItemInspectionInfoPage() { // 선택된 탭의 검사항목 행 const selectedTabRows = useMemo(() => { if (!selectedGroup || !selectedTypeTab) return []; - return selectedGroup.rows.filter((r: any) => r.inspection_type === selectedTypeTab && r.inspection_standard_id); + const filtered = selectedGroup.rows.filter((r: any) => r.inspection_type === selectedTypeTab && r.inspection_standard_id); + return [...filtered].sort((a: any, b: any) => { + const av = parseInt(String(a.sort_order || "9999"), 10); + const bv = parseInt(String(b.sort_order || "9999"), 10); + if (av === bv) return String(a.id).localeCompare(String(b.id)); + return av - bv; + }); }, [selectedGroup, selectedTypeTab]); // 검사기준 ID → 라벨 @@ -436,6 +521,13 @@ export default function ItemInspectionInfoPage() { autoFilter: true, }); const allRows = res.data?.data?.data || res.data?.data?.rows || []; + // sort_order 기준 오름차순 정렬 (varchar이므로 숫자 파싱 후 비교) + allRows.sort((a: any, b: any) => { + const av = parseInt(String(a.sort_order || "9999"), 10); + const bv = parseInt(String(b.sort_order || "9999"), 10); + if (av === bv) return String(a.id).localeCompare(String(b.id)); + return av - bv; + }); const rowMap: Record = {}; const typeFlags: Record = {}; @@ -462,7 +554,8 @@ export default function ItemInspectionInfoPage() { inspection_standard_id: r.inspection_standard_id || "", inspection_detail: r.inspection_item_name || r.inspection_standard || "", inspection_method: mLabel, - apply_process: "", + apply_process: r.apply_process || "", + classification: r.classification || "", acceptance_criteria: r.pass_criteria || "", is_required: r.is_required === "true" || r.is_required === true, judgment_criteria: jcLabel, @@ -480,7 +573,7 @@ export default function ItemInspectionInfoPage() { const addInspRow = (typeKey: string) => { setInspectionRows(prev => ({ ...prev, - [typeKey]: [...(prev[typeKey] || []), { id: crypto.randomUUID(), inspection_standard_id: "", inspection_detail: "", inspection_method: "", apply_process: "", acceptance_criteria: "", is_required: false }], + [typeKey]: [...(prev[typeKey] || []), { id: crypto.randomUUID(), inspection_standard_id: "", inspection_detail: "", inspection_method: "", apply_process: "", classification: "", acceptance_criteria: "", is_required: false }], })); }; const removeInspRow = (typeKey: string, rowId: string) => { @@ -525,6 +618,46 @@ export default function ItemInspectionInfoPage() { }; const toggleCollapse = (typeKey: string) => { setCollapsedTypes(prev => ({ ...prev, [typeKey]: !prev[typeKey] })); }; + /* ═══════════════════ 복사 모달용 검사항목 행 관리 (등록 폼과 평행) ═══════════════════ */ + const addCopyInspRow = (typeKey: string) => { + setCopyInspectionRows(prev => ({ + ...prev, + [typeKey]: [...(prev[typeKey] || []), { id: crypto.randomUUID(), inspection_standard_id: "", inspection_detail: "", inspection_method: "", apply_process: "", classification: "", acceptance_criteria: "", is_required: false }], + })); + }; + const removeCopyInspRow = (typeKey: string, rowId: string) => { + setCopyInspectionRows(prev => ({ ...prev, [typeKey]: (prev[typeKey] || []).filter(r => r.id !== rowId) })); + }; + const updateCopyInspRow = (typeKey: string, rowId: string, field: string, value: any) => { + setCopyInspectionRows(prev => ({ + ...prev, + [typeKey]: (prev[typeKey] || []).map(r => { + if (r.id !== rowId) return r; + if (field === "inspection_standard_id") { + const opt = inspOptions.find(o => o.code === value); + const methodCode = opt?.method || ""; + const methodLabel = inspMethodCatOptions.find(o => o.code === methodCode)?.label || methodCode; + const jcCode = opt?.judgment_criteria || ""; + const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode; + const unitCode = opt?.unit || ""; + const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode; + return { + ...r, + inspection_standard_id: value, + inspection_detail: opt?.detail || "", + inspection_method: methodLabel, + judgment_criteria: jcLabel, + selection_options: opt?.selection_options || "", + unit: unitLabel, + acceptance_criteria: "", + }; + } + return { ...r, [field]: value }; + }), + })); + }; + const toggleCopyCollapse = (typeKey: string) => { setCopyCollapsedTypes(prev => ({ ...prev, [typeKey]: !prev[typeKey] })); }; + const handleSave = async () => { if (!form.item_code) { toast.error("품목코드는 필수예요"); return; } setSaving(true); @@ -542,18 +675,23 @@ export default function ItemInspectionInfoPage() { } const enabledTypes = INSPECTION_TYPES.filter(t => !!form[t.key]); const rows: any[] = []; + let globalOrder = 0; for (const t of enabledTypes) { const typeRows = inspectionRows[t.key] || []; if (typeRows.length === 0) { - rows.push({ id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label, is_active: form.is_active || "사용", manager_id: form.manager_id || "", memo: form.remarks || "" }); + globalOrder += 1; + rows.push({ id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label, is_active: form.is_active || "사용", manager_id: form.manager_id || "", memo: form.remarks || "", sort_order: String(globalOrder).padStart(4, "0") }); } else { for (const r of typeRows) { + globalOrder += 1; rows.push({ id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label, inspection_standard_id: r.inspection_standard_id || "", inspection_item_name: r.inspection_detail || "", inspection_method: r.inspection_method || "", pass_criteria: r.acceptance_criteria || "", + apply_process: r.apply_process || "", classification: r.classification || "", is_required: r.is_required ? "true" : "false", is_active: form.is_active || "사용", manager_id: form.manager_id || "", memo: form.remarks || "", + sort_order: String(globalOrder).padStart(4, "0"), }); } } @@ -974,6 +1112,7 @@ export default function ItemInspectionInfoPage() { 검사기준 검사방법 적용공정 + 구분 판단기준 합격기준 필수 @@ -983,7 +1122,7 @@ export default function ItemInspectionInfoPage() { {selectedTabRows.length === 0 ? ( - 등록된 검사항목이 없어요 + 등록된 검사항목이 없어요 ) : selectedTabRows.map((row: any) => ( @@ -1002,6 +1141,7 @@ export default function ItemInspectionInfoPage() { const proc = processOptions.find(p => p.code === code); return proc?.name || code; })()} + {row.classification || "-"} {(() => { const insp = inspOptions.find(o => o.code === row.inspection_standard_id); @@ -1010,7 +1150,16 @@ export default function ItemInspectionInfoPage() { return jcLabel ? {jcLabel} : "-"; })()} - {row.pass_criteria || "-"} + {(() => { + const pc = row.pass_criteria; + if (!pc) return "-"; + if (pc.includes("|")) { + const [s, t] = pc.split("|"); + if (!t || !t.trim()) return s || "-"; + return `${s} ± ${t}`; + } + return pc; + })()} {row.is_required === "true" || row.is_required === true ? ( 필수 @@ -1185,6 +1334,7 @@ export default function ItemInspectionInfoPage() { 검사기준 상세 검사방법 적용공정 + 구분 판단기준 합격기준 (판단기준별) 필수 @@ -1194,7 +1344,7 @@ export default function ItemInspectionInfoPage() { {(!inspectionRows[key] || inspectionRows[key].length === 0) ? ( - 항목추가 버튼으로 검사항목을 추가하세요 + 항목추가 버튼으로 검사항목을 추가하세요 ) : inspectionRows[key].map((row) => ( @@ -1219,6 +1369,9 @@ export default function ItemInspectionInfoPage() { updateInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" /> )} + + updateInspRow(key, row.id, "classification", e.target.value)} placeholder="구분 입력" /> + {row.judgment_criteria ? {row.judgment_criteria} : -} @@ -1285,20 +1438,20 @@ export default function ItemInspectionInfoPage() { - {/* ═══════════════════ 복사 모달 ═══════════════════ */} + {/* ═══════════════════ 복사 모달 (2단 분할: 좌 대상 / 우 편집) ═══════════════════ */} { if (!copying) setCopyModalOpen(v); }}> e.preventDefault()} onInteractOutside={(e) => e.preventDefault()} onEscapeKeyDown={(e) => { if (copying) e.preventDefault(); }} > - + {copying ? "검사정보 복사 중..." : "검사정보 복사"} {selectedGroup?.item_name || "-"} ({selectedItemCode}) - {copying ? " 의 검사정보를 복사하고 있습니다" : " 의 검사정보를 아래 선택한 품목들에 복사합니다"} + {copying ? " 의 검사정보를 복사하고 있습니다" : " 의 검사정보를 편집해서 선택한 품목들에 복사합니다. 기준 품목은 변경되지 않아요"} {copying ? ( @@ -1322,81 +1475,229 @@ export default function ItemInspectionInfoPage() {

- ) : (<> -
- setCopySearchKeyword(e.target.value)} - onKeyDown={(e) => { if (e.key === "Enter") handleCopySearch(); }} /> - -
-
- - - - - 0 && copyFilteredItems.every(i => copyCheckedIds.includes(i.code))} - onCheckedChange={(v) => { - if (v) setCopyCheckedIds(prev => Array.from(new Set([...prev, ...copyFilteredItems.map(i => i.code)]))); - else setCopyCheckedIds(prev => prev.filter(c => !copyFilteredItems.some(i => i.code === c))); - }} - /> - - 품목코드 - 품목명 - 품목유형 - 단위 - - - - {copyFilteredItems.length === 0 ? ( - - {copySearchLoading ? "검색 중..." : "검색 결과가 없어요"} - - ) : copyFilteredItems.map((item) => ( - toggleCopyChecked(item.code)}> - e.stopPropagation()}> - toggleCopyChecked(item.code)} /> - - {item.code} - {item.name} - {item.item_type} - {item.unit} - - ))} - -
-
-
- - 전체 {copyTotal.toLocaleString()}건 - {copyCheckedIds.length > 0 && 선택 {copyCheckedIds.length}} - -
- - - {Array.from({ length: Math.min(5, copyTotalPages) }, (_, i) => { - const start = Math.max(1, Math.min(copyPage - 2, copyTotalPages - 4)); - const p = start + i; - if (p > copyTotalPages) return null; - return ( - - ); - })} - - + ) : ( +
+ {/* 좌측: 복사 대상 품목 선택 */} +
+
+ 복사 대상 품목 선택 + {copyCheckedIds.length > 0 && 선택 {copyCheckedIds.length}건} +
+
+ setCopySearchKeyword(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") handleCopySearch(); }} /> + +
+
+ + + + + 0 && copyFilteredItems.every(i => copyCheckedIds.includes(i.code))} + onCheckedChange={(v) => { + if (v) setCopyCheckedIds(prev => Array.from(new Set([...prev, ...copyFilteredItems.map(i => i.code)]))); + else setCopyCheckedIds(prev => prev.filter(c => !copyFilteredItems.some(i => i.code === c))); + }} + /> + + 품목코드 + 품목명 + + + + {copyFilteredItems.length === 0 ? ( + + {copySearchLoading ? "검색 중..." : "검색 결과가 없어요"} + + ) : copyFilteredItems.map((item) => ( + toggleCopyChecked(item.code)}> + e.stopPropagation()}> + toggleCopyChecked(item.code)} /> + + {item.code} + {item.name} + + ))} + +
+
+
+ 전체 {copyTotal.toLocaleString()} +
+ + + {copyPage}/{copyTotalPages} + + +
+
+
+ + {/* 우측: 편집 폼 (등록/수정 폼과 동일 구조) */} +
+
+ 복사할 검사정보 편집 (기준: {selectedItemCode}) +
+
+
+
+ + +
+
+ + +
+
+ +
+

검사유형 선택

+
+ {INSPECTION_TYPES.map(({ key, label }) => ( +
+ setCopyForm(p => ({ ...p, [key]: !!v }))} /> + +
+ ))} +
+
+ + {INSPECTION_TYPES.filter(t => !!copyForm[t.key]).map(({ key, label }) => ( +
+ + {!copyCollapsedTypes[key] && ( +
+
+ 검사항목 목록 + +
+
+ + + + 검사기준 선택 + 검사기준 상세 + 검사방법 + 적용공정 + 구분 + 판단기준 + 합격기준 + 필수 + 단위 + + + + + {(!copyInspectionRows[key] || copyInspectionRows[key].length === 0) ? ( + 항목추가 버튼으로 검사항목을 추가하세요 + ) : copyInspectionRows[key].map((row) => ( + + + + + + + + {processOptions.length > 0 ? ( + + ) : ( + updateCopyInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" /> + )} + + + updateCopyInspRow(key, row.id, "classification", e.target.value)} placeholder="구분" /> + + + {row.judgment_criteria ? {row.judgment_criteria} : -} + + + {row.judgment_criteria === "선택형" && row.selection_options ? ( + + ) : row.judgment_criteria === "O/X" ? ( + + ) : row.judgment_criteria === "수치(범위)" ? ( +
+ { + const parts = (row.acceptance_criteria || "||").split("|"); + parts[0] = e.target.value; + updateCopyInspRow(key, row.id, "acceptance_criteria", parts.join("|")); + }} placeholder="기준" disabled={!row.inspection_standard_id} /> + ± + { + const parts = (row.acceptance_criteria || "||").split("|"); + parts[1] = e.target.value; + updateCopyInspRow(key, row.id, "acceptance_criteria", parts.join("|")); + }} placeholder="±" disabled={!row.inspection_standard_id} /> +
+ ) : ( + updateCopyInspRow(key, row.id, "acceptance_criteria", e.target.value)} placeholder="합격기준" disabled={!row.inspection_standard_id} /> + )} +
+ updateCopyInspRow(key, row.id, "is_required", !!v)} /> + {row.unit || "-"} + + + +
+ ))} +
+
+
+
+ )} +
+ ))} +
+
-
- )} + )}
+ {/* 위치명 형식 — 구역/열/단 뒤에 붙일 표현만 자유 입력 */} +
+ +
+ A + setRackZoneLabel(e.target.value)} + placeholder="구역" + className="h-8 w-20 text-xs" + /> + - 01 + setRackRowLabel(e.target.value)} + placeholder="열" + className="h-8 w-20 text-xs" + /> + - 1 + setRackLevelLabel(e.target.value)} + placeholder="단" + className="h-8 w-20 text-xs" + /> +
+

+ 예시: A{rackZoneLabel}-01{rackRowLabel}-1{rackLevelLabel} + {" "}— 구역/열/단 번호는 자동 계산되고, 뒤에 붙는 명칭만 수정할 수 있습니다. +

+
+ {/* 등록 미리보기 */}
diff --git a/frontend/app/(main)/COMPANY_16/outsourcing/outbound/page.tsx b/frontend/app/(main)/COMPANY_16/outsourcing/outbound/page.tsx new file mode 100644 index 00000000..002597be --- /dev/null +++ b/frontend/app/(main)/COMPANY_16/outsourcing/outbound/page.tsx @@ -0,0 +1,1450 @@ +"use client"; + +import React, { useState, useEffect, useCallback, useMemo } 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 { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogDescription, +} from "@/components/ui/dialog"; +import { Badge } from "@/components/ui/badge"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "@/components/ui/resizable"; +import { + Plus, + Trash2, + Loader2, + Download, + Package, + Search, + X, + Settings2, + ChevronRight, + ChevronsLeft, + ChevronLeft, + ChevronsRight, + RefreshCw, + Inbox, + Filter, + Check, + ArrowUp, + ArrowDown, + Save, +} from "lucide-react"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; +import { useAuth } from "@/hooks/useAuth"; +import { useConfirmDialog } from "@/components/common/ConfirmDialog"; +import { toast } from "sonner"; +import { useTableSettings } from "@/hooks/useTableSettings"; +import { TableSettingsModal } from "@/components/common/TableSettingsModal"; +import { + DynamicSearchFilter, + FilterValue, +} from "@/components/common/DynamicSearchFilter"; +import { exportToExcel } from "@/lib/utils/excelExport"; +import { + getOutsourcingOutboundList, + getCandidates, + createOutsourcingOutbound, + deleteOutsourcingOutbound, + generateOutsourcingOutboundNumber, + getOutsourcingWarehouses, + type OutsourcingCandidate, + type OutsourcingOutboundItem, + type WarehouseOption, +} from "@/lib/api/outsourcingOutbound"; + +// ===== 상태 뱃지 색상 ===== +const OUTBOUND_STATUS_OPTIONS = [ + { value: "대기", label: "대기", color: "bg-secondary text-secondary-foreground" }, + { value: "출고완료", label: "출고완료", color: "bg-primary/10 text-primary" }, + { value: "출고취소", label: "출고취소", color: "bg-destructive/10 text-destructive" }, +]; + +const getStatusColor = (status: string) => + OUTBOUND_STATUS_OPTIONS.find((s) => s.value === status)?.color || + "bg-muted text-muted-foreground"; + +// ===== 메인 그리드 컬럼 ===== +const GRID_COLUMNS = [ + { key: "outbound_number", label: "외주출고번호" }, + { key: "outbound_date", label: "출고일" }, + { key: "reference_number", label: "작업지시번호" }, + { key: "customer_name", label: "외주사" }, + { key: "item_code", label: "품목코드" }, + { key: "item_name", label: "품목명" }, + { key: "specification", label: "규격" }, + { key: "outbound_qty", label: "출고수량" }, + { key: "warehouse_name", label: "출고창고" }, + { key: "outbound_status", label: "상태" }, + { key: "memo", label: "비고" }, +]; + +// 체크박스(1) + GRID_COLUMNS(11) = 12 +const TOTAL_COLS = 12; + +// ===== 헤더 필터 Popover ===== +function HeaderFilterPopover({ + colKey, + colLabel, + uniqueValues, + filterValues, + onToggle, + onClear, +}: { + colKey: string; + colLabel: string; + uniqueValues: string[]; + filterValues: Set; + onToggle: (colKey: string, value: string) => void; + onClear: (colKey: string) => void; +}) { + const [filterSearch, setFilterSearch] = useState(""); + const hasFilter = filterValues.size > 0; + const filteredValues = uniqueValues.filter( + (v) => !filterSearch || v.toLowerCase().includes(filterSearch.toLowerCase()), + ); + + return ( + + + + + e.stopPropagation()} + > +
+
+ 필터: {colLabel} + {hasFilter && ( + + )} +
+
+ + setFilterSearch(e.target.value)} + placeholder="검색..." + className="h-7 text-xs pl-7" + /> +
+
+ {filteredValues.slice(0, 100).map((val) => { + const isSelected = filterValues.has(val); + return ( +
onToggle(colKey, val)} + > +
+ {isSelected && ( + + )} +
+ {val || "(빈 값)"} +
+ ); + })} + {filteredValues.length > 100 && ( +
+ ...외 {filteredValues.length - 100}개 +
+ )} +
+
+
+
+ ); +} + +// ===== 선택 품목 인터페이스 (등록 모달에서 사용) ===== +interface SelectedItem { + key: string; // candidate의 completed_process_id + instruction_no: string; + completed_process_name: string; + next_process_name: string; + item_code: string; + item_name: string; + spec: string; + material: string; + unit: string; + outbound_qty: number; // 기본값: good_qty + subcontractor_code: string; + subcontractor_name: string; + completed_process_id: string; +} + +// ===== 메인 페이지 ===== +export default function OutsourcingOutboundPage() { + const ts = useTableSettings( + "c16-outsourcing-outbound", + "outbound_mng", + GRID_COLUMNS, + ); + const { user } = useAuth(); + const { confirm, ConfirmDialogComponent } = useConfirmDialog(); + + // 목록 데이터 + const [data, setData] = useState([]); + const [loading, setLoading] = useState(false); + const [checkedIds, setCheckedIds] = useState([]); + + // 페이지네이션 + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(20); + const [pageSizeInput, setPageSizeInput] = useState("20"); + + // 검색 필터 + const [searchFilters, setSearchFilters] = useState([]); + + // 헤더 필터 & 정렬 + const [headerFilters, setHeaderFilters] = useState< + Record> + >({}); + const [sortState, setSortState] = useState<{ + key: string; + direction: "asc" | "desc"; + } | null>(null); + + // 등록 모달 + const [isModalOpen, setIsModalOpen] = useState(false); + const [modalOutboundNo, setModalOutboundNo] = useState(""); + const [modalOutboundDate, setModalOutboundDate] = useState(""); + const [modalWarehouse, setModalWarehouse] = useState(""); + const [modalManager, setModalManager] = useState(""); + const [modalMemo, setModalMemo] = useState(""); + const [selectedItems, setSelectedItems] = useState([]); + const [saving, setSaving] = useState(false); + + // 후보(대상) 데이터 + const [candidates, setCandidates] = useState([]); + const [candidateKeyword, setCandidateKeyword] = useState(""); + const [candidateLoading, setCandidateLoading] = useState(false); + + // 창고 목록 + const [warehouses, setWarehouses] = useState([]); + + // ===== 목록 조회 ===== + const fetchList = useCallback(async () => { + setLoading(true); + try { + const params: Record = {}; + for (const f of searchFilters) { + if (!f.value) continue; + if (f.columnName === "outbound_status") params.outbound_status = f.value; + else if ( + f.columnName === "outbound_date" && + f.operator === "between" + ) { + const [from, to] = f.value.split("~").map((s) => s.trim()); + if (from) params.date_from = from; + if (to) params.date_to = to; + } else { + params.search_keyword = f.value; + } + } + const res = await getOutsourcingOutboundList(params); + if (res.success) setData(res.data); + } catch { + // ignore + } finally { + setLoading(false); + } + }, [searchFilters]); + + useEffect(() => { + fetchList(); + }, [fetchList]); + + // 창고 목록 로드 + useEffect(() => { + (async () => { + try { + const res = await getOutsourcingWarehouses(); + if (res.success) setWarehouses(res.data); + } catch { + // ignore + } + })(); + }, []); + + // ===== 플랫 행 생성 ===== + const flatRows = useMemo(() => { + return data.map((row) => ({ + ...row, + warehouse_name: row.warehouse_name || row.warehouse_code || "", + specification: row.specification || "", + })); + }, [data]); + + // 컬럼별 고유값 (헤더 필터용) + const columnUniqueValues = useMemo(() => { + const result: Record = {}; + for (const col of GRID_COLUMNS) { + const values = new Set(); + flatRows.forEach((row) => { + const val = (row as any)[col.key]; + if (val !== null && val !== undefined && val !== "") + values.add(String(val)); + }); + result[col.key] = Array.from(values).sort(); + } + return result; + }, [flatRows]); + + // 필터 + 정렬 적용된 데이터 + const filteredRows = useMemo(() => { + let rows = [...flatRows]; + + // 헤더 필터 + for (const [colKey, values] of Object.entries(headerFilters)) { + if (values.size === 0) continue; + rows = rows.filter((row) => { + const cellVal = + (row as any)[colKey] != null ? String((row as any)[colKey]) : ""; + return values.has(cellVal); + }); + } + + // 정렬 + if (sortState) { + const { key, direction } = sortState; + rows.sort((a, b) => { + const av = (a as any)[key] ?? ""; + const bv = (b as any)[key] ?? ""; + const na = Number(av); + const nb = Number(bv); + if (!isNaN(na) && !isNaN(nb)) + return direction === "asc" ? na - nb : nb - na; + return direction === "asc" + ? String(av).localeCompare(String(bv)) + : String(bv).localeCompare(String(av)); + }); + } + + return rows; + }, [flatRows, headerFilters, sortState]); + + // ===== 페이지네이션 ===== + const totalPages = Math.max(1, Math.ceil(filteredRows.length / pageSize)); + const safePage = Math.min(Math.max(1, currentPage), totalPages); + const paginatedRows = useMemo(() => { + const start = (safePage - 1) * pageSize; + return filteredRows.slice(start, start + pageSize); + }, [filteredRows, safePage, pageSize]); + + const applyPageSize = () => { + const n = parseInt(pageSizeInput, 10); + if (!isNaN(n) && n >= 1) { + setPageSize(n); + setCurrentPage(1); + } else { + setPageSizeInput(String(pageSize)); + } + }; + + const getPageNumbers = (): (number | "...")[] => { + const pages: (number | "...")[] = []; + if (totalPages <= 7) { + for (let i = 1; i <= totalPages; i++) pages.push(i); + } else { + pages.push(1); + if (safePage > 3) pages.push("..."); + for ( + let i = Math.max(2, safePage - 1); + i <= Math.min(totalPages - 1, safePage + 1); + i++ + ) + pages.push(i); + if (safePage < totalPages - 2) pages.push("..."); + pages.push(totalPages); + } + return pages; + }; + + // 필터 변경 시 첫 페이지로 이동 + useEffect(() => { + setCurrentPage(1); + }, [headerFilters, sortState, filteredRows.length]); + + // ===== 헤더 필터 핸들러 ===== + const toggleHeaderFilter = (colKey: string, value: string) => { + setHeaderFilters((prev) => { + const next = { ...prev }; + const set = new Set(next[colKey] || []); + if (set.has(value)) set.delete(value); + else set.add(value); + if (set.size === 0) delete next[colKey]; + else next[colKey] = set; + return next; + }); + }; + + const clearHeaderFilter = (colKey: string) => { + setHeaderFilters((prev) => { + const next = { ...prev }; + delete next[colKey]; + return next; + }); + }; + + const handleSort = (key: string) => { + setSortState((prev) => + prev?.key === key + ? prev.direction === "asc" + ? { key, direction: "desc" } + : null + : { key, direction: "asc" }, + ); + }; + + // ===== 삭제 ===== + const handleDelete = async () => { + if (checkedIds.length === 0) return; + const ok = await confirm( + `선택한 ${checkedIds.length}건을 삭제하시겠습니까?`, + { variant: "destructive", confirmText: "삭제" }, + ); + if (!ok) return; + try { + for (const id of checkedIds) { + await deleteOutsourcingOutbound(id); + } + toast.success("삭제 완료"); + setCheckedIds([]); + fetchList(); + } catch { + toast.error("삭제 중 오류가 발생했습니다."); + } + }; + + // ===== 엑셀 다운로드 ===== + const handleExcelDownload = async () => { + if (filteredRows.length === 0) { + toast.error("다운로드할 데이터가 없습니다."); + return; + } + const excelData = filteredRows.map((row) => ({ + 외주출고번호: row.outbound_number || "", + 출고일: row.outbound_date || "", + 작업지시번호: row.reference_number || "", + 외주사: row.customer_name || "", + 품목코드: row.item_code || "", + 품목명: row.item_name || "", + 규격: row.specification || "", + 출고수량: row.outbound_qty || 0, + 출고창고: row.warehouse_name || "", + 상태: row.outbound_status || "", + 비고: row.memo || "", + })); + await exportToExcel(excelData, "외주출고목록.xlsx", "외주출고"); + toast.success("엑셀 다운로드 완료"); + }; + + // ===== 등록 모달 ===== + const loadCandidates = useCallback(async (keyword?: string) => { + setCandidateLoading(true); + try { + const res = await getCandidates(keyword || undefined); + if (res.success) setCandidates(res.data); + } catch { + // ignore + } finally { + setCandidateLoading(false); + } + }, []); + + const openRegisterModal = async () => { + setModalOutboundDate(new Date().toISOString().split("T")[0]); + setModalWarehouse(""); + setModalManager(user?.userName || ""); + setModalMemo(""); + setSelectedItems([]); + setCandidateKeyword(""); + setCandidates([]); + setIsModalOpen(true); + + try { + const [numRes] = await Promise.all([ + generateOutsourcingOutboundNumber(), + loadCandidates(), + ]); + if (numRes.success) setModalOutboundNo(numRes.data); + } catch { + setModalOutboundNo(""); + } + }; + + const searchCandidates = useCallback(async () => { + await loadCandidates(candidateKeyword || undefined); + }, [candidateKeyword, loadCandidates]); + + // 후보 → 선택 품목 추가 + const addCandidate = (c: OutsourcingCandidate) => { + const key = c.completed_process_id; + if (selectedItems.some((s) => s.key === key)) return; + setSelectedItems((prev) => [ + ...prev, + { + key, + instruction_no: c.instruction_no, + completed_process_name: c.completed_process_name, + next_process_name: c.next_process_name, + item_code: c.item_code, + item_name: c.item_name, + spec: c.spec || "", + material: c.material || "", + unit: c.unit || "", + outbound_qty: c.good_qty, + subcontractor_code: c.subcontractor_code, + subcontractor_name: c.subcontractor_name, + completed_process_id: c.completed_process_id, + }, + ]); + }; + + // 선택 품목 수량 변경 + const updateItemQty = (key: string, qty: number) => { + setSelectedItems((prev) => + prev.map((item) => + item.key === key ? { ...item, outbound_qty: qty } : item, + ), + ); + }; + + // 선택 품목 삭제 + const removeItem = (key: string) => { + setSelectedItems((prev) => prev.filter((item) => item.key !== key)); + }; + + // ===== 저장 ===== + const handleSave = async () => { + if (selectedItems.length === 0) { + toast.error("출고할 품목을 선택해주세요."); + return; + } + if (!modalOutboundDate) { + toast.error("출고일을 입력해주세요."); + return; + } + + const zeroQtyItems = selectedItems.filter( + (i) => !i.outbound_qty || i.outbound_qty <= 0, + ); + if (zeroQtyItems.length > 0) { + toast.error("출고수량이 0인 품목이 있습니다. 수량을 입력해주세요."); + return; + } + + setSaving(true); + try { + const res = await createOutsourcingOutbound({ + outbound_number: modalOutboundNo, + outbound_date: modalOutboundDate, + warehouse_code: modalWarehouse || undefined, + memo: modalMemo || undefined, + items: selectedItems.map((item) => ({ + reference_number: item.instruction_no, + subcontractor_code: item.subcontractor_code, + subcontractor_name: item.subcontractor_name, + item_code: item.item_code, + item_name: item.item_name, + spec: item.spec, + material: item.material, + unit: item.unit, + outbound_qty: item.outbound_qty, + completed_process_id: item.completed_process_id, + })), + }); + + if (res.success) { + toast.success(res.message || "외주출고 등록 완료"); + setIsModalOpen(false); + fetchList(); + } + } catch (err: any) { + const msg = + err?.response?.data?.message || "외주출고 등록 중 오류가 발생했습니다."; + toast.error(msg); + } finally { + setSaving(false); + } + }; + + // 합계 계산 + const totalSummary = useMemo(() => { + return { + count: selectedItems.length, + qty: selectedItems.reduce((sum, i) => sum + (i.outbound_qty || 0), 0), + }; + }, [selectedItems]); + + return ( +
+ {/* 검색 영역 */} + + + {/* 외주출고 목록 테이블 */} +
+ {/* 패널 헤더 */} +
+
+ + 외주출고 목록 + + {filteredRows.length}건 + +
+
+ + + + +
+
+ +
+ + + + + + + + + + + + + + + + + + { + const allFilteredIds = filteredRows.map((r) => r.id); + const allChecked = + allFilteredIds.length > 0 && + allFilteredIds.every((id) => checkedIds.includes(id)); + setCheckedIds(allChecked ? [] : allFilteredIds); + }} + > + { + const allFilteredIds = filteredRows.map((r) => r.id); + return ( + allFilteredIds.length > 0 && + allFilteredIds.every((id) => checkedIds.includes(id)) + ); + })()} + onCheckedChange={() => {}} + /> + + {GRID_COLUMNS.map((col) => { + const isRight = ["outbound_qty"].includes(col.key); + return ( + +
+
handleSort(col.key)} + > + {col.label} + {sortState?.key === col.key && + (sortState.direction === "asc" ? ( + + ) : ( + + ))} +
+ {(columnUniqueValues[col.key] || []).length > 0 && ( + () + } + onToggle={toggleHeaderFilter} + onClear={clearHeaderFilter} + /> + )} +
+
+ ); + })} +
+
+ + {loading ? ( + + + + + + ) : filteredRows.length === 0 ? ( + + +
+ + + 등록된 외주출고 내역이 없어요 + +
+
+
+ ) : ( + paginatedRows.map((row) => { + const isChecked = checkedIds.includes(row.id); + return ( + { + setCheckedIds((prev) => + prev.includes(row.id) + ? prev.filter((id) => id !== row.id) + : [...prev, row.id], + ); + }} + > + { + e.stopPropagation(); + setCheckedIds((prev) => + prev.includes(row.id) + ? prev.filter((id) => id !== row.id) + : [...prev, row.id], + ); + }} + > + {}} + /> + + + {row.outbound_number || ""} + + + {row.outbound_date + ? new Date(row.outbound_date).toLocaleDateString( + "ko-KR", + ) + : ""} + + + + {row.reference_number || ""} + + + + + {row.customer_name || ""} + + + + {row.item_code || ""} + + + + {row.item_name || ""} + + + + {row.specification || ""} + + + {row.outbound_qty + ? Number(row.outbound_qty).toLocaleString() + : ""} + + + + {row.warehouse_name || ""} + + + + + {row.outbound_status || "-"} + + + + + {row.memo || ""} + + + + ); + }) + )} +
+
+
+ + {/* 페이지네이션 */} +
+
+
+ 전체 + + {filteredRows.length.toLocaleString()} + + +
+
+ setPageSizeInput(e.target.value)} + onBlur={applyPageSize} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + applyPageSize(); + } + }} + className="h-7 w-16 text-center text-xs" + /> + 건씩 보기 +
+
+
+ + + {getPageNumbers().map((page, idx) => + page === "..." ? ( + + ... + + ) : ( + + ), + )} + + +
+
+ { + if (e.key === "Enter") { + const val = parseInt( + (e.target as HTMLInputElement).value, + 10, + ); + if (!isNaN(val) && val >= 1 && val <= totalPages) { + setCurrentPage(val); + (e.target as HTMLInputElement).value = ""; + (e.target as HTMLInputElement).blur(); + } + } + }} + onBlur={(e) => { + const val = parseInt(e.target.value, 10); + if (!isNaN(val) && val >= 1 && val <= totalPages) { + setCurrentPage(val); + } + e.target.value = ""; + }} + /> + / {totalPages} 페이지 +
+
+
+ + {/* 외주출고 등록 모달 */} + + + + 외주출고 등록 + + 좌측에서 외주출고 대상을 검색하여 우측에 추가한 후 저장해주세요. + + + + {/* 메인 콘텐츠 */} +
+ + {/* 좌측 패널: 외주출고 대상 */} + +
+ {/* 상단: 제목 + 검색 */} +
+ + 외주출고 대상 + + setCandidateKeyword(e.target.value)} + onKeyDown={(e) => + e.key === "Enter" && searchCandidates() + } + className="h-8 flex-1 text-xs" + /> + + +
+ + {/* 대상 목록 헤더 */} +
+
+ + 대상 목록 + + {candidates.length > 0 && ( + + {candidates.length}건 + + )} +
+
+ + {/* 대상 테이블 */} +
+ {candidateLoading ? ( +
+ +
+ ) : candidates.length === 0 ? ( +
+ + 외주출고 대상이 없습니다 +
+ ) : ( + + + + + + 작업지시번호 + + + 완료공정 + + + 외주공정 + + + 품목 + + + 규격 + + + 외주사 + + + 양품수량 + + + + + {candidates.map((c) => { + const isSelected = selectedItems.some( + (s) => s.key === c.completed_process_id, + ); + return ( + !isSelected && addCandidate(c)} + > + + {isSelected ? ( + + 추가됨 + + ) : ( + + )} + + + {c.instruction_no} + + + {c.completed_process_name} + + + {c.next_process_name} + + +
+ + {c.item_name} + + + {c.item_code} + +
+
+ + {c.spec || "-"} + + + {c.subcontractor_name} + + + {Number(c.good_qty).toLocaleString()} + +
+ ); + })} +
+
+ )} +
+
+
+ + e.stopPropagation()} + /> + + {/* 우측 패널: 출고 정보 + 선택 품목 */} + +
+ {/* 출고 정보 폼 */} +
+

+ 출고 정보 +

+
+
+ + 외주출고번호 + + +
+
+ + 출고일 * + + setModalOutboundDate(e.target.value)} + className="h-8 text-xs" + /> +
+
+ + 출고창고 + + +
+
+ + 담당자 + + setModalManager(e.target.value)} + placeholder="담당자" + className="h-8 text-xs" + /> +
+
+ + 메모 + + setModalMemo(e.target.value)} + placeholder="메모" + className="h-8 text-xs" + /> +
+
+
+ + {/* 선택 품목 테이블 */} +
+
+ + 선택 품목 + + + {selectedItems.length}건 + +
+ + {selectedItems.length === 0 ? ( +
+ + 좌측에서 대상을 선택하여 추가해주세요 +
+ ) : ( + + + + + No + + + 작업지시 + + + 외주사 + + + 품목명 + + + 출고수량 + + + + + + {selectedItems.map((item, idx) => ( + + + {idx + 1} + + + + {item.instruction_no} + + + + + {item.subcontractor_name} + + + +
+ + {item.item_name} + + + {item.item_code} + {item.spec ? ` | ${item.spec}` : ""} + +
+
+ + + updateItemQty( + item.key, + Number(e.target.value) || 0, + ) + } + className="h-7 w-[80px] text-right text-xs" + min={0} + /> + + + + +
+ ))} +
+
+ )} +
+
+
+
+
+ + {/* 하단: 합계 + 버튼 */} +
+
+
+ {selectedItems.length > 0 ? ( + <> + {totalSummary.count}건 | 수량 합계:{" "} + {totalSummary.qty.toLocaleString()} + + ) : ( + "품목을 추가해주세요" + )} +
+
+ + +
+
+
+
+
+ + {/* 테이블 설정 모달 */} + + + {/* 확인 다이얼로그 */} + {ConfirmDialogComponent} +
+ ); +} diff --git a/frontend/app/(main)/COMPANY_16/outsourcing/subcontractor-item/page.tsx b/frontend/app/(main)/COMPANY_16/outsourcing/subcontractor-item/page.tsx index 8f1802c4..d16596ed 100644 --- a/frontend/app/(main)/COMPANY_16/outsourcing/subcontractor-item/page.tsx +++ b/frontend/app/(main)/COMPANY_16/outsourcing/subcontractor-item/page.tsx @@ -98,12 +98,26 @@ export default function SubcontractorItemPage() { } return result; }; - for (const col of ["material", "division", "type", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"]) { + for (const col of ["material", "division", "type", "status", "unit", "inventory_unit", "currency_code", "user_type01", "user_type02"]) { try { const res = await apiClient.get(`/table-categories/${ITEM_TABLE}/${col}/values`); if (res.data?.success) optMap[col] = flatten(res.data.data || []); } catch { /* skip */ } } + // 외주사관리에서 사용하는 subcontractor_item_prices.currency_code도 병합 + try { + const res = await apiClient.get(`/table-categories/subcontractor_item_prices/currency_code/values`); + if (res.data?.success) { + const extra = flatten(res.data.data || []); + const seen = new Set((optMap["currency_code"] || []).map((o) => o.code)); + for (const e of extra) { + if (!seen.has(e.code)) { + (optMap["currency_code"] ||= []).push(e); + seen.add(e.code); + } + } + } + } catch { /* skip */ } // 외주업체 거래유형 (subcontractor_mng.division) try { const res = await apiClient.get(`/table-categories/${SUBCONTRACTOR_TABLE}/division/values`); @@ -124,10 +138,10 @@ export default function SubcontractorItemPage() { item_number: { width: "w-[110px]" }, item_name: { minWidth: "min-w-[130px]", render: (v) => v || "-" }, size: { width: "w-[90px]", render: (v) => v || "-" }, - unit: { width: "w-[60px]", render: (v) => v || "-" }, + unit: { width: "w-[60px]", render: (v) => resolve("unit", v) || "-" }, standard_price: { width: "w-[90px]", align: "right", formatNumber: true }, selling_price: { width: "w-[90px]", align: "right", formatNumber: true }, - currency_code: { width: "w-[50px]", render: (v) => v || "-" }, + currency_code: { width: "w-[50px]", render: (v) => resolve("currency_code", v) || "-" }, status: { width: "w-[60px]", render: (v) => v || "-" }, }; return ts.visibleColumns.map((col) => ({ @@ -135,7 +149,8 @@ export default function SubcontractorItemPage() { label: col.label, ...colProps[col.key], })); - }, [ts.visibleColumns]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ts.visibleColumns, categoryOptions]); // 좌측: 품목 조회 (division이 "외주관리"인 품목만 필터링) const outsourcingDivisionCode = categoryOptions["division"]?.find( @@ -164,8 +179,8 @@ export default function SubcontractorItemPage() { for (const col of CATS) { if (converted[col]) converted[col] = resolve(col, converted[col]); } - // item_info의 inventory_unit을 단위 표시용 unit에 매핑 - converted.unit = converted.inventory_unit || converted.unit || ""; + // "단위" 컬럼은 재고단위(inventory_unit)만 사용 — unit 폴백 제거 + converted.unit = converted.inventory_unit || ""; return converted; }); setItems(data); @@ -212,11 +227,35 @@ export default function SubcontractorItemPage() { } catch { /* skip */ } } - setSubcontractorItems(mappings.map((m: any) => ({ - ...m, - subcontractor_code: m.subcontractor_id, - subcontractor_name: subMap[m.subcontractor_id]?.subcontractor_name || "", - }))); + // 외주사관리에서 입력된 최신 단가(subcontractor_item_prices) 조회 → subcontractor_id 별 최신 1건 + const priceMap: Record = {}; + try { + const priceRes = await apiClient.post(`/table-management/tables/subcontractor_item_prices/data`, { + page: 1, size: 0, + dataFilter: { enabled: true, filters: [{ columnName: "item_id", operator: "equals", value: itemKey }] }, + autoFilter: true, + }); + const prices = priceRes.data?.data?.data || priceRes.data?.data?.rows || []; + for (const p of prices) { + const key = p.subcontractor_id; + if (!key) continue; + if (!priceMap[key] || (p.start_date && (!priceMap[key].start_date || p.start_date > priceMap[key].start_date))) { + priceMap[key] = p; + } + } + } catch { /* skip */ } + + setSubcontractorItems(mappings.map((m: any) => { + const price = priceMap[m.subcontractor_id] || {}; + return { + ...m, + subcontractor_code: m.subcontractor_id, + subcontractor_name: subMap[m.subcontractor_id]?.subcontractor_name || "", + base_price: price.base_price ?? m.base_price, + calculated_price: price.calculated_price ?? price.unit_price ?? m.calculated_price, + currency_code: resolve("currency_code", price.currency_code ?? m.currency_code), + }; + })); } catch (err) { console.error("외주업체 조회 실패:", err); } finally { @@ -224,7 +263,8 @@ export default function SubcontractorItemPage() { } }; fetchSubcontractorItems(); - }, [selectedItem?.item_number]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedItem?.item_number, categoryOptions]); // 외주업체 검색 const searchSubcontractors = async () => { diff --git a/frontend/app/(main)/COMPANY_16/production/bom/page.tsx b/frontend/app/(main)/COMPANY_16/production/bom/page.tsx index 84b7afbb..01e7ee14 100644 --- a/frontend/app/(main)/COMPANY_16/production/bom/page.tsx +++ b/frontend/app/(main)/COMPANY_16/production/bom/page.tsx @@ -59,6 +59,7 @@ import { Settings2, Save, Package, + Pencil, } from "lucide-react"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; @@ -355,7 +356,13 @@ export default function BomManagementPage() { sort: { columnName: "created_at", order: "desc" }, }); - const rows = res.data?.data?.data || res.data?.data?.rows || []; + // DB 컬럼이 item_type/expired_date → 프론트 내부에서는 bom_type/expiry_date로 통일 + const rawRows = res.data?.data?.data || res.data?.data?.rows || []; + const rows = rawRows.map((r: any) => ({ + ...r, + bom_type: r.bom_type ?? r.item_type, + expiry_date: r.expiry_date ?? r.expired_date, + })); setBomList(rows); setTotalCount(rows.length); } catch (err: any) { @@ -452,9 +459,16 @@ export default function BomManagementPage() { const fetchBomDetail = useCallback(async (bomId: string) => { setDetailLoading(true); try { - // 헤더 조회 + // 헤더 조회 (DB 컬럼 item_type/expired_date → bom_type/expiry_date로 매핑) const headerRes = await apiClient.get(`/bom/${bomId}/header`); - const header = headerRes.data?.data || headerRes.data; + const rawHeader = headerRes.data?.data || headerRes.data; + const header = rawHeader + ? { + ...rawHeader, + bom_type: rawHeader.bom_type ?? rawHeader.item_type, + expiry_date: rawHeader.expiry_date ?? rawHeader.expired_date, + } + : null; setBomHeader(header); setCurrentVersionId(header?.current_version_id || null); @@ -1100,17 +1114,18 @@ export default function BomManagementPage() { setSaving(true); try { + // DB 실제 컬럼: item_type / expired_date (프론트 내부 bom_type/expiry_date와 다름) const bomFields: Record = { item_id: masterForm.item_id, item_code: masterForm.item_code, item_name: masterForm.item_name, - bom_type: masterForm.bom_type, + item_type: masterForm.bom_type, base_qty: masterForm.base_qty || "1", unit: masterForm.unit || "", version: masterForm.version || "1.0", status: masterForm.status || "draft", effective_date: masterForm.effective_date || null, - expiry_date: masterForm.expiry_date || null, + expired_date: masterForm.expiry_date || null, remark: masterForm.remark || "", writer: user?.userId || "", company_code: user?.company_code || "", @@ -1482,6 +1497,21 @@ export default function BomManagementPage() { 등록 +
{showOutsourceField && (
- - + + + + + + +
+ {subcontractorOptions.length === 0 ? ( +
등록된 외주업체가 없어요
+ ) : subcontractorOptions.map((s) => { + const checked = formOutsources.includes(s.id); + return ( + + ); + })} +
+
+
)}
diff --git a/frontend/app/(main)/COMPANY_16/production/work-instruction/page.tsx b/frontend/app/(main)/COMPANY_16/production/work-instruction/page.tsx index ef7c2a39..114b92a1 100644 --- a/frontend/app/(main)/COMPANY_16/production/work-instruction/page.tsx +++ b/frontend/app/(main)/COMPANY_16/production/work-instruction/page.tsx @@ -185,11 +185,15 @@ export default function WorkInstructionPage() { case "order": r = await getWISalesOrderSource(params); break; case "item": r = await getWIItemSource(params); break; } - if (r?.success) { setRegSourceData(r.data || []); setRegTotalCount(r.totalCount || 0); } + if (r?.success) { + // 생산계획 근거는 백엔드에서 applied_qty / remain_qty 포함해 내려옴 + setRegSourceData(r.data || []); + setRegTotalCount(r.totalCount || 0); + } } catch {} finally { setRegSourceLoading(false); } }, [regSourceType, regKeyword, regPage, regPageSize]); - useEffect(() => { if (isRegModalOpen && regSourceType) { setRegPage(1); setRegCheckedIds(new Set()); fetchRegSource(1); } }, [regSourceType]); + useEffect(() => { if (isRegModalOpen && regSourceType) { setRegPage(1); setRegCheckedIds(new Set()); fetchRegSource(1); } }, [isRegModalOpen, regSourceType]); const getRegId = (item: any) => regSourceType === "item" ? (item.item_code || item.id) : String(item.id); const toggleRegItem = (id: string) => { setRegCheckedIds(prev => { const n = new Set(prev); if (n.has(id)) n.delete(id); else n.add(id); return n; }); }; @@ -202,7 +206,13 @@ export default function WorkInstructionPage() { if (!regCheckedIds.has(getRegId(item))) continue; if (regSourceType === "item") items.push({ itemCode: item.item_code, itemName: item.item_name || "", spec: item.spec || "", qty: 1, remark: "", sourceType: "item", sourceTable: "item_info", sourceId: item.item_code }); else if (regSourceType === "order") items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: item.spec || "", qty: Number(item.qty || 1), remark: "", sourceType: "order", sourceTable: "sales_order_detail", sourceId: item.id }); - else items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: "", qty: Number(item.plan_qty || 1), remark: "", sourceType: "production", sourceTable: "production_plan_mng", sourceId: item.id }); + else { + // 생산계획: 잔량(remain_qty)이 있으면 잔량 기반으로 기본 수량 제안 (0/음수 허용 — 계획 초과 가능) + const defaultQty = item.remain_qty !== undefined && item.remain_qty !== null + ? Number(item.remain_qty) + : Number(item.plan_qty || 1); + items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: "", qty: defaultQty, remark: "", sourceType: "production", sourceTable: "production_plan_mng", sourceId: item.id }); + } } // 동일품목 합산 @@ -578,7 +588,7 @@ export default function WorkInstructionPage() { 0 && regCheckedIds.size === regSourceData.length} onCheckedChange={toggleRegAll} /> {regSourceType === "item" && <>품목코드품목명규격} {regSourceType === "order" && <>수주번호품번품목명규격수량납기일} - {regSourceType === "production" && <>계획번호품번품목명계획수량시작일완료일설비} + {regSourceType === "production" && <>계획번호품번품목명계획수량적용수량잔량시작일완료일설비} @@ -590,7 +600,7 @@ export default function WorkInstructionPage() { e.stopPropagation()}> toggleRegItem(id)} /> {regSourceType === "item" && <>{item.item_code}{item.item_name}{item.spec || "-"}} {regSourceType === "order" && <>{item.order_no}{item.item_code}{item.item_name}{item.spec || "-"}{Number(item.qty || 0).toLocaleString()}{item.due_date || "-"}} - {regSourceType === "production" && <>{item.plan_no}{item.item_code}{item.item_name}{Number(item.plan_qty || 0).toLocaleString()}{item.start_date ? String(item.start_date).split("T")[0] : "-"}{item.end_date ? String(item.end_date).split("T")[0] : "-"}{item.equipment_name || "-"}} + {regSourceType === "production" && <>{item.plan_no}{item.item_code}{item.item_name}{Number(item.plan_qty || 0).toLocaleString()}{Number(item.applied_qty || 0).toLocaleString()}{Number(item.remain_qty ?? item.plan_qty ?? 0).toLocaleString()}{item.start_date ? String(item.start_date).split("T")[0] : "-"}{item.end_date ? String(item.end_date).split("T")[0] : "-"}{item.equipment_name || "-"}} ); })} diff --git a/frontend/app/(main)/COMPANY_16/purchase/purchase-item/page.tsx b/frontend/app/(main)/COMPANY_16/purchase/purchase-item/page.tsx index 42db2edf..72f770b7 100644 --- a/frontend/app/(main)/COMPANY_16/purchase/purchase-item/page.tsx +++ b/frontend/app/(main)/COMPANY_16/purchase/purchase-item/page.tsx @@ -312,6 +312,11 @@ export default function PurchaseItemPage() { // 좌측: 품목 조회 const fetchItems = useCallback(async () => { + // 카테고리 로드 완료 전엔 대기 — 먼저 나간 unfiltered 요청이 나중에 도착해 + // filtered 결과를 덮어쓰는 race condition 방지 + if (!categoryOptions["division"]?.length) { + return; + } setItemLoading(true); try { const filters: { columnName: string; operator: string; value: any }[] = []; diff --git a/frontend/app/(main)/COMPANY_16/quality/inspection/page.tsx b/frontend/app/(main)/COMPANY_16/quality/inspection/page.tsx index 49e547e2..c0a7b6b9 100644 --- a/frontend/app/(main)/COMPANY_16/quality/inspection/page.tsx +++ b/frontend/app/(main)/COMPANY_16/quality/inspection/page.tsx @@ -52,6 +52,7 @@ const INSPECTION_COLUMNS = [ { key: "inspection_code", label: "검사코드" }, { key: "inspection_type", label: "검사유형" }, { key: "inspection_criteria", label: "검사기준" }, + { key: "criteria_detail", label: "기준상세" }, { key: "inspection_item", label: "검사항목" }, { key: "inspection_method", label: "검사방법" }, { key: "judgment_criteria", label: "판단기준" }, diff --git a/frontend/app/(main)/COMPANY_16/quality/item-inspection/page.tsx b/frontend/app/(main)/COMPANY_16/quality/item-inspection/page.tsx index 80d61947..68d40dc0 100644 --- a/frontend/app/(main)/COMPANY_16/quality/item-inspection/page.tsx +++ b/frontend/app/(main)/COMPANY_16/quality/item-inspection/page.tsx @@ -43,6 +43,7 @@ type InspectionRow = { inspection_detail: string; inspection_method: string; apply_process: string; + classification: string; acceptance_criteria: string; is_required: boolean; judgment_criteria?: string; // 판단기준 라벨 (수치(범위)/텍스트입력/O·X/선택형) @@ -472,7 +473,13 @@ export default function ItemInspectionInfoPage() { // 선택된 탭의 검사항목 행 const selectedTabRows = useMemo(() => { if (!selectedGroup || !selectedTypeTab) return []; - return selectedGroup.rows.filter((r: any) => r.inspection_type === selectedTypeTab && r.inspection_standard_id); + const filtered = selectedGroup.rows.filter((r: any) => r.inspection_type === selectedTypeTab && r.inspection_standard_id); + return [...filtered].sort((a: any, b: any) => { + const av = parseInt(String(a.sort_order || "9999"), 10); + const bv = parseInt(String(b.sort_order || "9999"), 10); + if (av === bv) return String(a.id).localeCompare(String(b.id)); + return av - bv; + }); }, [selectedGroup, selectedTypeTab]); // 검사기준 ID → 라벨 @@ -506,6 +513,13 @@ export default function ItemInspectionInfoPage() { autoFilter: true, }); const allRows = res.data?.data?.data || res.data?.data?.rows || []; + // sort_order 기준 오름차순 정렬 (varchar이므로 숫자 파싱 후 비교) + allRows.sort((a: any, b: any) => { + const av = parseInt(String(a.sort_order || "9999"), 10); + const bv = parseInt(String(b.sort_order || "9999"), 10); + if (av === bv) return String(a.id).localeCompare(String(b.id)); + return av - bv; + }); const rowMap: Record = {}; const typeFlags: Record = {}; @@ -532,7 +546,8 @@ export default function ItemInspectionInfoPage() { inspection_standard_id: r.inspection_standard_id || "", inspection_detail: r.inspection_item_name || r.inspection_standard || "", inspection_method: mLabel, - apply_process: "", + apply_process: r.apply_process || "", + classification: r.classification || "", acceptance_criteria: r.pass_criteria || "", is_required: r.is_required === "true" || r.is_required === true, judgment_criteria: jcLabel, @@ -550,7 +565,7 @@ export default function ItemInspectionInfoPage() { const addInspRow = (typeKey: string) => { setInspectionRows(prev => ({ ...prev, - [typeKey]: [...(prev[typeKey] || []), { id: crypto.randomUUID(), inspection_standard_id: "", inspection_detail: "", inspection_method: "", apply_process: "", acceptance_criteria: "", is_required: false }], + [typeKey]: [...(prev[typeKey] || []), { id: crypto.randomUUID(), inspection_standard_id: "", inspection_detail: "", inspection_method: "", apply_process: "", classification: "", acceptance_criteria: "", is_required: false }], })); }; const removeInspRow = (typeKey: string, rowId: string) => { @@ -652,18 +667,23 @@ export default function ItemInspectionInfoPage() { } const enabledTypes = INSPECTION_TYPES.filter(t => !!form[t.key]); const rows: any[] = []; + let globalOrder = 0; for (const t of enabledTypes) { const typeRows = inspectionRows[t.key] || []; if (typeRows.length === 0) { - rows.push({ id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label, is_active: form.is_active || "사용", manager_id: form.manager_id || "", memo: form.remarks || "" }); + globalOrder += 1; + rows.push({ id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label, is_active: form.is_active || "사용", manager_id: form.manager_id || "", memo: form.remarks || "", sort_order: String(globalOrder).padStart(4, "0") }); } else { for (const r of typeRows) { + globalOrder += 1; rows.push({ id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label, inspection_standard_id: r.inspection_standard_id || "", inspection_item_name: r.inspection_detail || "", inspection_method: r.inspection_method || "", pass_criteria: r.acceptance_criteria || "", + apply_process: r.apply_process || "", classification: r.classification || "", is_required: r.is_required ? "true" : "false", is_active: form.is_active || "사용", manager_id: form.manager_id || "", memo: form.remarks || "", + sort_order: String(globalOrder).padStart(4, "0"), }); } } @@ -1084,6 +1104,7 @@ export default function ItemInspectionInfoPage() { 검사기준 검사방법 적용공정 + 구분 판단기준 합격기준 필수 @@ -1093,7 +1114,7 @@ export default function ItemInspectionInfoPage() { {selectedTabRows.length === 0 ? ( - 등록된 검사항목이 없어요 + 등록된 검사항목이 없어요 ) : selectedTabRows.map((row: any) => ( @@ -1112,6 +1133,7 @@ export default function ItemInspectionInfoPage() { const proc = processOptions.find(p => p.code === code); return proc?.name || code; })()} + {row.classification || "-"} {(() => { const insp = inspOptions.find(o => o.code === row.inspection_standard_id); @@ -1120,7 +1142,16 @@ export default function ItemInspectionInfoPage() { return jcLabel ? {jcLabel} : "-"; })()} - {row.pass_criteria || "-"} + {(() => { + const pc = row.pass_criteria; + if (!pc) return "-"; + if (pc.includes("|")) { + const [s, t] = pc.split("|"); + if (!t || !t.trim()) return s || "-"; + return `${s} ± ${t}`; + } + return pc; + })()} {row.is_required === "true" || row.is_required === true ? ( 필수 @@ -1295,6 +1326,7 @@ export default function ItemInspectionInfoPage() { 검사기준 상세 검사방법 적용공정 + 구분 판단기준 합격기준 (판단기준별) 필수 @@ -1304,7 +1336,7 @@ export default function ItemInspectionInfoPage() { {(!inspectionRows[key] || inspectionRows[key].length === 0) ? ( - 항목추가 버튼으로 검사항목을 추가하세요 + 항목추가 버튼으로 검사항목을 추가하세요 ) : inspectionRows[key].map((row) => ( @@ -1329,6 +1361,9 @@ export default function ItemInspectionInfoPage() { updateInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" /> )} + + updateInspRow(key, row.id, "classification", e.target.value)} placeholder="구분 입력" /> + {row.judgment_criteria ? {row.judgment_criteria} : -} @@ -1560,6 +1595,7 @@ export default function ItemInspectionInfoPage() { 검사기준 상세 검사방법 적용공정 + 구분 판단기준 합격기준 필수 @@ -1569,7 +1605,7 @@ export default function ItemInspectionInfoPage() { {(!copyInspectionRows[key] || copyInspectionRows[key].length === 0) ? ( - 항목추가 버튼으로 검사항목을 추가하세요 + 항목추가 버튼으로 검사항목을 추가하세요 ) : copyInspectionRows[key].map((row) => ( @@ -1594,6 +1630,9 @@ export default function ItemInspectionInfoPage() { updateCopyInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" /> )} + + updateCopyInspRow(key, row.id, "classification", e.target.value)} placeholder="구분" /> + {row.judgment_criteria ? {row.judgment_criteria} : -} diff --git a/frontend/app/(main)/COMPANY_16/sales/sales-item/page.tsx b/frontend/app/(main)/COMPANY_16/sales/sales-item/page.tsx index e03b9b65..8db939e9 100644 --- a/frontend/app/(main)/COMPANY_16/sales/sales-item/page.tsx +++ b/frontend/app/(main)/COMPANY_16/sales/sales-item/page.tsx @@ -311,6 +311,11 @@ export default function SalesItemPage() { // 좌측: 품목 조회 const fetchItems = useCallback(async () => { + // 카테고리 로드 완료 전엔 대기 — 먼저 나간 unfiltered 요청이 나중에 도착해 + // filtered 결과를 덮어쓰는 race condition 방지 + if (!categoryOptions["division"]?.length) { + return; + } setItemLoading(true); try { const filters: { columnName: string; operator: string; value: any }[] = []; diff --git a/frontend/app/(main)/COMPANY_29/logistics/info/page.tsx b/frontend/app/(main)/COMPANY_29/logistics/info/page.tsx index 5f44e0ec..66b2fd4e 100644 --- a/frontend/app/(main)/COMPANY_29/logistics/info/page.tsx +++ b/frontend/app/(main)/COMPANY_29/logistics/info/page.tsx @@ -358,13 +358,15 @@ export default function LogisticsInfoPage() { loadReferences(); }, [loadReferences]); - // 카테고리 옵션 로드 + // 카테고리 옵션 로드 (관리자 계정일 때 filterCompanyCode 미제공 시 "*" 스코프로 빈 결과 반환됨) const loadCategoryOptions = useCallback(async (tableColumn: string) => { if (loadedCategories.current.has(tableColumn)) return; loadedCategories.current.add(tableColumn); const [tableName, columnName] = tableColumn.split(":"); try { - const res = await apiClient.get(`/table-categories/${tableName}/${columnName}/values`); + const res = await apiClient.get( + `/table-categories/${tableName}/${columnName}/values?filterCompanyCode=COMPANY_29` + ); const data = res.data?.data || []; setCategoryOptions((prev) => ({ ...prev, @@ -823,13 +825,24 @@ export default function LogisticsInfoPage() { {/* 테이블 영역 */}
({ - key: col.key, - label: col.label, - align: col.align, - formatNumber: col.formatNumber, - truncate: true, - }))} + columns={getVisibleColumns(tab.key).map((col): EDataTableColumn => { + // 같은 key의 formField에 categoryKey가 있으면 코드→라벨 변환 + const formField = tab.formFields.find((f) => f.key === col.key && f.categoryKey); + return { + key: col.key, + label: col.label, + align: col.align, + formatNumber: col.formatNumber, + truncate: true, + render: formField?.categoryKey + ? (value: any) => { + const opts = categoryOptions[formField.categoryKey!] || []; + const matched = opts.find((o: any) => o.value === value); + return matched?.label || value || "-"; + } + : undefined, + }; + })} data={tsMap[tab.key].groupData(displayData)} rowKey={(row: any) => String(row.id)} loading={tabLoading[tab.key]} diff --git a/frontend/app/(main)/COMPANY_29/logistics/inventory/page.tsx b/frontend/app/(main)/COMPANY_29/logistics/inventory/page.tsx index c6936a6c..9e4b6977 100644 --- a/frontend/app/(main)/COMPANY_29/logistics/inventory/page.tsx +++ b/frontend/app/(main)/COMPANY_29/logistics/inventory/page.tsx @@ -186,12 +186,12 @@ export default function InventoryStatusPage() { }; load(); // 사용자 목록 로드 - apiClient.get("/admin/users", { params: { size: 9999 } }).then((res) => { - const users = res.data?.data || res.data || []; + apiClient.get("/admin/users/name-map").then((res) => { + const users = res.data?.data || []; const map: Record = {}; for (const u of users) { - const id = u.userId || u.user_id || u.id; - const name = u.user_name || u.name || id; + const id = u.user_id; + const name = u.user_name || id; if (id) map[id] = name; } setUserMap(map); diff --git a/frontend/app/(main)/COMPANY_29/logistics/material-status/page.tsx b/frontend/app/(main)/COMPANY_29/logistics/material-status/page.tsx index 58354385..42d9a69a 100644 --- a/frontend/app/(main)/COMPANY_29/logistics/material-status/page.tsx +++ b/frontend/app/(main)/COMPANY_29/logistics/material-status/page.tsx @@ -628,7 +628,7 @@ export default function MaterialStatusPage() { className="inline-flex items-center gap-1 rounded bg-muted/40 px-2 py-0.5 text-xs transition-colors hover:bg-muted/60" > - {loc.location || loc.warehouse} + {loc.warehouse_name || loc.location || loc.warehouse} {loc.qty.toLocaleString()} diff --git a/frontend/app/(main)/COMPANY_29/logistics/packaging/page.tsx b/frontend/app/(main)/COMPANY_29/logistics/packaging/page.tsx index 74585bb8..66b467bb 100644 --- a/frontend/app/(main)/COMPANY_29/logistics/packaging/page.tsx +++ b/frontend/app/(main)/COMPANY_29/logistics/packaging/page.tsx @@ -27,6 +27,7 @@ import { getItemsByDivision, getGeneralItems, type PkgUnit, type PkgUnitItem, type LoadingUnit, type LoadingUnitPkg, type ItemInfoForPkg, } from "@/lib/api/packaging"; +import { apiClient } from "@/lib/api/client"; import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; @@ -118,6 +119,45 @@ export default function PackagingPage() { const [saving, setSaving] = useState(false); + // 카테고리 옵션 (inventory_unit / material) — 코드 → 라벨 변환 + const [categoryOptions, setCategoryOptions] = useState< + Record + >({}); + + useEffect(() => { + const load = async () => { + const flatten = (vals: any[]): { code: string; label: string }[] => { + const out: { code: string; label: string }[] = []; + for (const v of vals) { + out.push({ + code: v.valueCode || v.value_code || v.code, + label: v.valueLabel || v.value_label || v.label, + }); + if (v.children?.length) out.push(...flatten(v.children)); + } + return out; + }; + const optMap: Record = {}; + for (const col of ["inventory_unit", "material"]) { + try { + const res = await apiClient.get( + `/table-categories/item_info/${col}/values` + ); + if (res.data?.success) optMap[col] = flatten(res.data.data || []); + } catch { + /* skip */ + } + } + setCategoryOptions(optMap); + }; + load(); + }, []); + + const resolveCat = (col: string, code: string | null | undefined) => { + if (!code) return ""; + return categoryOptions[col]?.find((o) => o.code === code)?.label || code; + }; + // --- 데이터 로드 (item_info 기반 + pkg_unit/loading_unit LEFT JOIN 방식) --- const fetchPkgUnits = useCallback(async () => { setPkgLoading(true); @@ -622,7 +662,7 @@ export default function PackagingPage() { {item.item_number} {item.item_name || "-"} {item.spec || "-"} - {item.unit || "EA"} + {resolveCat("inventory_unit", item.inventory_unit) || "EA"} {Number(item.pkg_qty).toLocaleString()}
+ {/* 위치명 형식 — 구역/열/단 뒤에 붙일 표현만 자유 입력 */} +
+ +
+ A + setRackZoneLabel(e.target.value)} + placeholder="구역" + className="h-8 w-20 text-xs" + /> + - 01 + setRackRowLabel(e.target.value)} + placeholder="열" + className="h-8 w-20 text-xs" + /> + - 1 + setRackLevelLabel(e.target.value)} + placeholder="단" + className="h-8 w-20 text-xs" + /> +
+

+ 예시: A{rackZoneLabel}-01{rackRowLabel}-1{rackLevelLabel} + {" "}— 구역/열/단 번호는 자동 계산되고, 뒤에 붙는 명칭만 수정할 수 있습니다. +

+
+ {/* 등록 미리보기 */}
diff --git a/frontend/app/(main)/COMPANY_29/outsourcing/subcontractor-item/page.tsx b/frontend/app/(main)/COMPANY_29/outsourcing/subcontractor-item/page.tsx index 8f1802c4..d16596ed 100644 --- a/frontend/app/(main)/COMPANY_29/outsourcing/subcontractor-item/page.tsx +++ b/frontend/app/(main)/COMPANY_29/outsourcing/subcontractor-item/page.tsx @@ -98,12 +98,26 @@ export default function SubcontractorItemPage() { } return result; }; - for (const col of ["material", "division", "type", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"]) { + for (const col of ["material", "division", "type", "status", "unit", "inventory_unit", "currency_code", "user_type01", "user_type02"]) { try { const res = await apiClient.get(`/table-categories/${ITEM_TABLE}/${col}/values`); if (res.data?.success) optMap[col] = flatten(res.data.data || []); } catch { /* skip */ } } + // 외주사관리에서 사용하는 subcontractor_item_prices.currency_code도 병합 + try { + const res = await apiClient.get(`/table-categories/subcontractor_item_prices/currency_code/values`); + if (res.data?.success) { + const extra = flatten(res.data.data || []); + const seen = new Set((optMap["currency_code"] || []).map((o) => o.code)); + for (const e of extra) { + if (!seen.has(e.code)) { + (optMap["currency_code"] ||= []).push(e); + seen.add(e.code); + } + } + } + } catch { /* skip */ } // 외주업체 거래유형 (subcontractor_mng.division) try { const res = await apiClient.get(`/table-categories/${SUBCONTRACTOR_TABLE}/division/values`); @@ -124,10 +138,10 @@ export default function SubcontractorItemPage() { item_number: { width: "w-[110px]" }, item_name: { minWidth: "min-w-[130px]", render: (v) => v || "-" }, size: { width: "w-[90px]", render: (v) => v || "-" }, - unit: { width: "w-[60px]", render: (v) => v || "-" }, + unit: { width: "w-[60px]", render: (v) => resolve("unit", v) || "-" }, standard_price: { width: "w-[90px]", align: "right", formatNumber: true }, selling_price: { width: "w-[90px]", align: "right", formatNumber: true }, - currency_code: { width: "w-[50px]", render: (v) => v || "-" }, + currency_code: { width: "w-[50px]", render: (v) => resolve("currency_code", v) || "-" }, status: { width: "w-[60px]", render: (v) => v || "-" }, }; return ts.visibleColumns.map((col) => ({ @@ -135,7 +149,8 @@ export default function SubcontractorItemPage() { label: col.label, ...colProps[col.key], })); - }, [ts.visibleColumns]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ts.visibleColumns, categoryOptions]); // 좌측: 품목 조회 (division이 "외주관리"인 품목만 필터링) const outsourcingDivisionCode = categoryOptions["division"]?.find( @@ -164,8 +179,8 @@ export default function SubcontractorItemPage() { for (const col of CATS) { if (converted[col]) converted[col] = resolve(col, converted[col]); } - // item_info의 inventory_unit을 단위 표시용 unit에 매핑 - converted.unit = converted.inventory_unit || converted.unit || ""; + // "단위" 컬럼은 재고단위(inventory_unit)만 사용 — unit 폴백 제거 + converted.unit = converted.inventory_unit || ""; return converted; }); setItems(data); @@ -212,11 +227,35 @@ export default function SubcontractorItemPage() { } catch { /* skip */ } } - setSubcontractorItems(mappings.map((m: any) => ({ - ...m, - subcontractor_code: m.subcontractor_id, - subcontractor_name: subMap[m.subcontractor_id]?.subcontractor_name || "", - }))); + // 외주사관리에서 입력된 최신 단가(subcontractor_item_prices) 조회 → subcontractor_id 별 최신 1건 + const priceMap: Record = {}; + try { + const priceRes = await apiClient.post(`/table-management/tables/subcontractor_item_prices/data`, { + page: 1, size: 0, + dataFilter: { enabled: true, filters: [{ columnName: "item_id", operator: "equals", value: itemKey }] }, + autoFilter: true, + }); + const prices = priceRes.data?.data?.data || priceRes.data?.data?.rows || []; + for (const p of prices) { + const key = p.subcontractor_id; + if (!key) continue; + if (!priceMap[key] || (p.start_date && (!priceMap[key].start_date || p.start_date > priceMap[key].start_date))) { + priceMap[key] = p; + } + } + } catch { /* skip */ } + + setSubcontractorItems(mappings.map((m: any) => { + const price = priceMap[m.subcontractor_id] || {}; + return { + ...m, + subcontractor_code: m.subcontractor_id, + subcontractor_name: subMap[m.subcontractor_id]?.subcontractor_name || "", + base_price: price.base_price ?? m.base_price, + calculated_price: price.calculated_price ?? price.unit_price ?? m.calculated_price, + currency_code: resolve("currency_code", price.currency_code ?? m.currency_code), + }; + })); } catch (err) { console.error("외주업체 조회 실패:", err); } finally { @@ -224,7 +263,8 @@ export default function SubcontractorItemPage() { } }; fetchSubcontractorItems(); - }, [selectedItem?.item_number]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedItem?.item_number, categoryOptions]); // 외주업체 검색 const searchSubcontractors = async () => { diff --git a/frontend/app/(main)/COMPANY_29/production/bom/page.tsx b/frontend/app/(main)/COMPANY_29/production/bom/page.tsx index 84b7afbb..01e7ee14 100644 --- a/frontend/app/(main)/COMPANY_29/production/bom/page.tsx +++ b/frontend/app/(main)/COMPANY_29/production/bom/page.tsx @@ -59,6 +59,7 @@ import { Settings2, Save, Package, + Pencil, } from "lucide-react"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; @@ -355,7 +356,13 @@ export default function BomManagementPage() { sort: { columnName: "created_at", order: "desc" }, }); - const rows = res.data?.data?.data || res.data?.data?.rows || []; + // DB 컬럼이 item_type/expired_date → 프론트 내부에서는 bom_type/expiry_date로 통일 + const rawRows = res.data?.data?.data || res.data?.data?.rows || []; + const rows = rawRows.map((r: any) => ({ + ...r, + bom_type: r.bom_type ?? r.item_type, + expiry_date: r.expiry_date ?? r.expired_date, + })); setBomList(rows); setTotalCount(rows.length); } catch (err: any) { @@ -452,9 +459,16 @@ export default function BomManagementPage() { const fetchBomDetail = useCallback(async (bomId: string) => { setDetailLoading(true); try { - // 헤더 조회 + // 헤더 조회 (DB 컬럼 item_type/expired_date → bom_type/expiry_date로 매핑) const headerRes = await apiClient.get(`/bom/${bomId}/header`); - const header = headerRes.data?.data || headerRes.data; + const rawHeader = headerRes.data?.data || headerRes.data; + const header = rawHeader + ? { + ...rawHeader, + bom_type: rawHeader.bom_type ?? rawHeader.item_type, + expiry_date: rawHeader.expiry_date ?? rawHeader.expired_date, + } + : null; setBomHeader(header); setCurrentVersionId(header?.current_version_id || null); @@ -1100,17 +1114,18 @@ export default function BomManagementPage() { setSaving(true); try { + // DB 실제 컬럼: item_type / expired_date (프론트 내부 bom_type/expiry_date와 다름) const bomFields: Record = { item_id: masterForm.item_id, item_code: masterForm.item_code, item_name: masterForm.item_name, - bom_type: masterForm.bom_type, + item_type: masterForm.bom_type, base_qty: masterForm.base_qty || "1", unit: masterForm.unit || "", version: masterForm.version || "1.0", status: masterForm.status || "draft", effective_date: masterForm.effective_date || null, - expiry_date: masterForm.expiry_date || null, + expired_date: masterForm.expiry_date || null, remark: masterForm.remark || "", writer: user?.userId || "", company_code: user?.company_code || "", @@ -1482,6 +1497,21 @@ export default function BomManagementPage() { 등록 +
{showOutsourceField && (
- - + + + + + + +
+ {subcontractorOptions.length === 0 ? ( +
등록된 외주업체가 없어요
+ ) : subcontractorOptions.map((s) => { + const checked = formOutsources.includes(s.id); + return ( + + ); + })} +
+
+
)}
diff --git a/frontend/app/(main)/COMPANY_29/production/work-instruction/page.tsx b/frontend/app/(main)/COMPANY_29/production/work-instruction/page.tsx index ef7c2a39..9a6fc954 100644 --- a/frontend/app/(main)/COMPANY_29/production/work-instruction/page.tsx +++ b/frontend/app/(main)/COMPANY_29/production/work-instruction/page.tsx @@ -202,7 +202,13 @@ export default function WorkInstructionPage() { if (!regCheckedIds.has(getRegId(item))) continue; if (regSourceType === "item") items.push({ itemCode: item.item_code, itemName: item.item_name || "", spec: item.spec || "", qty: 1, remark: "", sourceType: "item", sourceTable: "item_info", sourceId: item.item_code }); else if (regSourceType === "order") items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: item.spec || "", qty: Number(item.qty || 1), remark: "", sourceType: "order", sourceTable: "sales_order_detail", sourceId: item.id }); - else items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: "", qty: Number(item.plan_qty || 1), remark: "", sourceType: "production", sourceTable: "production_plan_mng", sourceId: item.id }); + else { + // 생산계획: 잔량(remain_qty)이 있으면 잔량 기반으로 기본 수량 제안 (0/음수 허용 — 계획 초과 가능) + const defaultQty = item.remain_qty !== undefined && item.remain_qty !== null + ? Number(item.remain_qty) + : Number(item.plan_qty || 1); + items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: "", qty: defaultQty, remark: "", sourceType: "production", sourceTable: "production_plan_mng", sourceId: item.id }); + } } // 동일품목 합산 @@ -578,7 +584,7 @@ export default function WorkInstructionPage() { 0 && regCheckedIds.size === regSourceData.length} onCheckedChange={toggleRegAll} /> {regSourceType === "item" && <>품목코드품목명규격} {regSourceType === "order" && <>수주번호품번품목명규격수량납기일} - {regSourceType === "production" && <>계획번호품번품목명계획수량시작일완료일설비} + {regSourceType === "production" && <>계획번호품번품목명계획수량적용수량잔량시작일완료일설비} @@ -590,7 +596,7 @@ export default function WorkInstructionPage() { e.stopPropagation()}> toggleRegItem(id)} /> {regSourceType === "item" && <>{item.item_code}{item.item_name}{item.spec || "-"}} {regSourceType === "order" && <>{item.order_no}{item.item_code}{item.item_name}{item.spec || "-"}{Number(item.qty || 0).toLocaleString()}{item.due_date || "-"}} - {regSourceType === "production" && <>{item.plan_no}{item.item_code}{item.item_name}{Number(item.plan_qty || 0).toLocaleString()}{item.start_date ? String(item.start_date).split("T")[0] : "-"}{item.end_date ? String(item.end_date).split("T")[0] : "-"}{item.equipment_name || "-"}} + {regSourceType === "production" && <>{item.plan_no}{item.item_code}{item.item_name}{Number(item.plan_qty || 0).toLocaleString()}{Number(item.applied_qty || 0).toLocaleString()}{Number(item.remain_qty ?? item.plan_qty ?? 0).toLocaleString()}{item.start_date ? String(item.start_date).split("T")[0] : "-"}{item.end_date ? String(item.end_date).split("T")[0] : "-"}{item.equipment_name || "-"}} ); })} diff --git a/frontend/app/(main)/COMPANY_29/purchase/purchase-item/page.tsx b/frontend/app/(main)/COMPANY_29/purchase/purchase-item/page.tsx index 42db2edf..72f770b7 100644 --- a/frontend/app/(main)/COMPANY_29/purchase/purchase-item/page.tsx +++ b/frontend/app/(main)/COMPANY_29/purchase/purchase-item/page.tsx @@ -312,6 +312,11 @@ export default function PurchaseItemPage() { // 좌측: 품목 조회 const fetchItems = useCallback(async () => { + // 카테고리 로드 완료 전엔 대기 — 먼저 나간 unfiltered 요청이 나중에 도착해 + // filtered 결과를 덮어쓰는 race condition 방지 + if (!categoryOptions["division"]?.length) { + return; + } setItemLoading(true); try { const filters: { columnName: string; operator: string; value: any }[] = []; diff --git a/frontend/app/(main)/COMPANY_29/quality/inspection/page.tsx b/frontend/app/(main)/COMPANY_29/quality/inspection/page.tsx index 7c529989..8b93fa89 100644 --- a/frontend/app/(main)/COMPANY_29/quality/inspection/page.tsx +++ b/frontend/app/(main)/COMPANY_29/quality/inspection/page.tsx @@ -52,6 +52,7 @@ const INSPECTION_COLUMNS = [ { key: "inspection_code", label: "검사코드" }, { key: "inspection_type", label: "검사유형" }, { key: "inspection_criteria", label: "검사기준" }, + { key: "criteria_detail", label: "기준상세" }, { key: "inspection_item", label: "검사항목" }, { key: "inspection_method", label: "검사방법" }, { key: "judgment_criteria", label: "판단기준" }, diff --git a/frontend/app/(main)/COMPANY_29/quality/item-inspection/page.tsx b/frontend/app/(main)/COMPANY_29/quality/item-inspection/page.tsx index 15118ffc..2c71ed02 100644 --- a/frontend/app/(main)/COMPANY_29/quality/item-inspection/page.tsx +++ b/frontend/app/(main)/COMPANY_29/quality/item-inspection/page.tsx @@ -43,6 +43,7 @@ type InspectionRow = { inspection_detail: string; inspection_method: string; apply_process: string; + classification: string; acceptance_criteria: string; is_required: boolean; judgment_criteria?: string; // 판단기준 라벨 (수치(범위)/텍스트입력/O·X/선택형) @@ -253,6 +254,11 @@ export default function ItemInspectionInfoPage() { loadProcessOptions(item.code); }; + // 복사 모달: 편집 가능한 기준 데이터 상태 (등록/수정 폼과 평행 구조) + const [copyForm, setCopyForm] = useState>({}); + const [copyInspectionRows, setCopyInspectionRows] = useState>({}); + const [copyCollapsedTypes, setCopyCollapsedTypes] = useState>({}); + /* ═══════════════════ 복사 모달 (기준 품목 검사정보 → 다른 품목들) ═══════════════════ */ const [copyModalOpen, setCopyModalOpen] = useState(false); const [copySearchKeyword, setCopySearchKeyword] = useState(""); @@ -294,11 +300,63 @@ export default function ItemInspectionInfoPage() { setCopyTotal(resData?.total || resData?.totalCount || rows.length); } catch { /* skip */ } finally { setCopySearchLoading(false); } }; - const openCopyModal = () => { + const openCopyModal = async () => { if (!selectedItemCode) { toast.error("복사 기준 품목을 먼저 선택해주세요"); return; } const srcGroup = groupedData.find(g => g.item_code === selectedItemCode); if (!srcGroup || srcGroup.rows.length === 0) { toast.error("선택한 품목에 복사할 검사정보가 없어요"); return; } setCopySearchKeyword(""); setCopyPage(1); setCopyCheckedIds([]); + + // 기준 품목 데이터를 편집용 상태로 복제 (openEdit과 동일한 변환 로직) + const baseRow = srcGroup.rows[0]; + try { + const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, { + page: 1, size: 0, + dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: selectedItemCode }] }, + autoFilter: true, + }); + const allRows = res.data?.data?.data || res.data?.data?.rows || []; + const rowMap: Record = {}; + const typeFlags: Record = {}; + for (const r of allRows) { + const inspType = r.inspection_type || ""; + const matched = INSPECTION_TYPES.find(t => + t.matchLabels.some(ml => inspType.includes(ml)) || + inspTypeCatOptions.some(cat => inspType.includes(cat.code) && t.matchLabels.some(ml => cat.label.includes(ml))) + ); + const typeKey = matched?.key || ""; + if (!typeKey) continue; + typeFlags[typeKey] = true; + if (!rowMap[typeKey]) rowMap[typeKey] = []; + const mCode = r.inspection_method || ""; + const mLabel = inspMethodCatOptions.find(o => o.code === mCode)?.label || mCode; + const inspOpt = inspOptions.find(o => o.code === r.inspection_standard_id); + const jcCode = inspOpt?.judgment_criteria || ""; + const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode; + const unitCode = inspOpt?.unit || ""; + const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode; + rowMap[typeKey].push({ + id: crypto.randomUUID(), // 복사본은 새 id 부여 (원본과 분리) + inspection_standard_id: r.inspection_standard_id || "", + inspection_detail: r.inspection_item_name || r.inspection_standard || "", + inspection_method: mLabel, + apply_process: r.apply_process || "", + classification: r.classification || "", + acceptance_criteria: r.pass_criteria || "", + is_required: r.is_required === "true" || r.is_required === true, + judgment_criteria: jcLabel, + selection_options: inspOpt?.selection_options || "", + unit: unitLabel, + }); + } + setCopyInspectionRows(rowMap); + setCopyForm({ ...baseRow, ...typeFlags }); + setCopyCollapsedTypes({}); + } catch { + setCopyInspectionRows({}); + setCopyForm({ ...baseRow }); + setCopyCollapsedTypes({}); + } + setCopyModalOpen(true); searchCopyTargets(1); }; @@ -309,10 +367,18 @@ export default function ItemInspectionInfoPage() { const handleCopy = async () => { if (!selectedItemCode) { toast.error("복사 기준 품목이 없어요"); return; } if (copyCheckedIds.length === 0) { toast.error("붙여넣을 품목을 선택해주세요"); return; } - const sourceGroup = groupedData.find(g => g.item_code === selectedItemCode); - if (!sourceGroup || sourceGroup.rows.length === 0) { toast.error("복사할 검사정보가 없어요"); return; } + + // 편집된 rows를 평탄화 (선택된 검사유형의 rows만) + const enabledTypes = INSPECTION_TYPES.filter(t => !!copyForm[t.key]); + const flatRows: Array<{ row: InspectionRow; typeLabel: string }> = []; + for (const t of enabledTypes) { + const rows = copyInspectionRows[t.key] || []; + for (const r of rows) flatRows.push({ row: r, typeLabel: t.label }); + } + if (flatRows.length === 0) { toast.error("복사할 검사항목이 없어요"); return; } + const ok = await confirm( - `선택한 ${copyCheckedIds.length}개 품목에 검사정보를 복사할까요?`, + `선택한 ${copyCheckedIds.length}개 품목에 편집된 검사정보(${flatRows.length}개 행)를 복사할까요?`, { description: "대상 품목의 기존 검사정보는 삭제 후 교체됩니다.", variant: "info", confirmText: "복사" } ); if (!ok) return; @@ -333,13 +399,26 @@ export default function ItemInspectionInfoPage() { if (existing.length > 0) { await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: existing.map((r: any) => ({ id: r.id })) }); } - for (const r of sourceGroup.rows) { - const { id: _id, created_at: _c, updated_at: _u, ...rest } = r; + let orderSeq = 0; + for (const { row: r, typeLabel } of flatRows) { + orderSeq += 1; await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, { - ...rest, id: crypto.randomUUID(), item_code: targetCode, item_name: targetName, + inspection_type: typeLabel, + inspection_standard_id: r.inspection_standard_id || "", + inspection_item_name: r.inspection_detail || "", + inspection_method: r.inspection_method || "", + apply_process: r.apply_process || "", + classification: r.classification || "", + pass_criteria: r.acceptance_criteria || "", + is_required: r.is_required ? "true" : "false", + is_active: copyForm.is_active || "사용", + manager: copyForm.manager || "", + manager_id: copyForm.manager_id || "", + memo: copyForm.remarks || "", + sort_order: String(orderSeq).padStart(4, "0"), }); } setCopyProgress({ current: i + 1, total: copyCheckedIds.length }); @@ -402,7 +481,13 @@ export default function ItemInspectionInfoPage() { // 선택된 탭의 검사항목 행 const selectedTabRows = useMemo(() => { if (!selectedGroup || !selectedTypeTab) return []; - return selectedGroup.rows.filter((r: any) => r.inspection_type === selectedTypeTab && r.inspection_standard_id); + const filtered = selectedGroup.rows.filter((r: any) => r.inspection_type === selectedTypeTab && r.inspection_standard_id); + return [...filtered].sort((a: any, b: any) => { + const av = parseInt(String(a.sort_order || "9999"), 10); + const bv = parseInt(String(b.sort_order || "9999"), 10); + if (av === bv) return String(a.id).localeCompare(String(b.id)); + return av - bv; + }); }, [selectedGroup, selectedTypeTab]); // 검사기준 ID → 라벨 @@ -436,6 +521,13 @@ export default function ItemInspectionInfoPage() { autoFilter: true, }); const allRows = res.data?.data?.data || res.data?.data?.rows || []; + // sort_order 기준 오름차순 정렬 (varchar이므로 숫자 파싱 후 비교) + allRows.sort((a: any, b: any) => { + const av = parseInt(String(a.sort_order || "9999"), 10); + const bv = parseInt(String(b.sort_order || "9999"), 10); + if (av === bv) return String(a.id).localeCompare(String(b.id)); + return av - bv; + }); const rowMap: Record = {}; const typeFlags: Record = {}; @@ -462,7 +554,8 @@ export default function ItemInspectionInfoPage() { inspection_standard_id: r.inspection_standard_id || "", inspection_detail: r.inspection_item_name || r.inspection_standard || "", inspection_method: mLabel, - apply_process: "", + apply_process: r.apply_process || "", + classification: r.classification || "", acceptance_criteria: r.pass_criteria || "", is_required: r.is_required === "true" || r.is_required === true, judgment_criteria: jcLabel, @@ -480,7 +573,7 @@ export default function ItemInspectionInfoPage() { const addInspRow = (typeKey: string) => { setInspectionRows(prev => ({ ...prev, - [typeKey]: [...(prev[typeKey] || []), { id: crypto.randomUUID(), inspection_standard_id: "", inspection_detail: "", inspection_method: "", apply_process: "", acceptance_criteria: "", is_required: false }], + [typeKey]: [...(prev[typeKey] || []), { id: crypto.randomUUID(), inspection_standard_id: "", inspection_detail: "", inspection_method: "", apply_process: "", classification: "", acceptance_criteria: "", is_required: false }], })); }; const removeInspRow = (typeKey: string, rowId: string) => { @@ -525,6 +618,46 @@ export default function ItemInspectionInfoPage() { }; const toggleCollapse = (typeKey: string) => { setCollapsedTypes(prev => ({ ...prev, [typeKey]: !prev[typeKey] })); }; + /* ═══════════════════ 복사 모달용 검사항목 행 관리 (등록 폼과 평행) ═══════════════════ */ + const addCopyInspRow = (typeKey: string) => { + setCopyInspectionRows(prev => ({ + ...prev, + [typeKey]: [...(prev[typeKey] || []), { id: crypto.randomUUID(), inspection_standard_id: "", inspection_detail: "", inspection_method: "", apply_process: "", classification: "", acceptance_criteria: "", is_required: false }], + })); + }; + const removeCopyInspRow = (typeKey: string, rowId: string) => { + setCopyInspectionRows(prev => ({ ...prev, [typeKey]: (prev[typeKey] || []).filter(r => r.id !== rowId) })); + }; + const updateCopyInspRow = (typeKey: string, rowId: string, field: string, value: any) => { + setCopyInspectionRows(prev => ({ + ...prev, + [typeKey]: (prev[typeKey] || []).map(r => { + if (r.id !== rowId) return r; + if (field === "inspection_standard_id") { + const opt = inspOptions.find(o => o.code === value); + const methodCode = opt?.method || ""; + const methodLabel = inspMethodCatOptions.find(o => o.code === methodCode)?.label || methodCode; + const jcCode = opt?.judgment_criteria || ""; + const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode; + const unitCode = opt?.unit || ""; + const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode; + return { + ...r, + inspection_standard_id: value, + inspection_detail: opt?.detail || "", + inspection_method: methodLabel, + judgment_criteria: jcLabel, + selection_options: opt?.selection_options || "", + unit: unitLabel, + acceptance_criteria: "", + }; + } + return { ...r, [field]: value }; + }), + })); + }; + const toggleCopyCollapse = (typeKey: string) => { setCopyCollapsedTypes(prev => ({ ...prev, [typeKey]: !prev[typeKey] })); }; + const handleSave = async () => { if (!form.item_code) { toast.error("품목코드는 필수예요"); return; } setSaving(true); @@ -542,18 +675,23 @@ export default function ItemInspectionInfoPage() { } const enabledTypes = INSPECTION_TYPES.filter(t => !!form[t.key]); const rows: any[] = []; + let globalOrder = 0; for (const t of enabledTypes) { const typeRows = inspectionRows[t.key] || []; if (typeRows.length === 0) { - rows.push({ id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label, is_active: form.is_active || "사용", manager_id: form.manager_id || "", memo: form.remarks || "" }); + globalOrder += 1; + rows.push({ id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label, is_active: form.is_active || "사용", manager_id: form.manager_id || "", memo: form.remarks || "", sort_order: String(globalOrder).padStart(4, "0") }); } else { for (const r of typeRows) { + globalOrder += 1; rows.push({ id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label, inspection_standard_id: r.inspection_standard_id || "", inspection_item_name: r.inspection_detail || "", inspection_method: r.inspection_method || "", pass_criteria: r.acceptance_criteria || "", + apply_process: r.apply_process || "", classification: r.classification || "", is_required: r.is_required ? "true" : "false", is_active: form.is_active || "사용", manager_id: form.manager_id || "", memo: form.remarks || "", + sort_order: String(globalOrder).padStart(4, "0"), }); } } @@ -974,6 +1112,7 @@ export default function ItemInspectionInfoPage() { 검사기준 검사방법 적용공정 + 구분 판단기준 합격기준 필수 @@ -983,7 +1122,7 @@ export default function ItemInspectionInfoPage() { {selectedTabRows.length === 0 ? ( - 등록된 검사항목이 없어요 + 등록된 검사항목이 없어요 ) : selectedTabRows.map((row: any) => ( @@ -1002,6 +1141,7 @@ export default function ItemInspectionInfoPage() { const proc = processOptions.find(p => p.code === code); return proc?.name || code; })()} + {row.classification || "-"} {(() => { const insp = inspOptions.find(o => o.code === row.inspection_standard_id); @@ -1010,7 +1150,16 @@ export default function ItemInspectionInfoPage() { return jcLabel ? {jcLabel} : "-"; })()} - {row.pass_criteria || "-"} + {(() => { + const pc = row.pass_criteria; + if (!pc) return "-"; + if (pc.includes("|")) { + const [s, t] = pc.split("|"); + if (!t || !t.trim()) return s || "-"; + return `${s} ± ${t}`; + } + return pc; + })()} {row.is_required === "true" || row.is_required === true ? ( 필수 @@ -1185,6 +1334,7 @@ export default function ItemInspectionInfoPage() { 검사기준 상세 검사방법 적용공정 + 구분 판단기준 합격기준 (판단기준별) 필수 @@ -1194,7 +1344,7 @@ export default function ItemInspectionInfoPage() { {(!inspectionRows[key] || inspectionRows[key].length === 0) ? ( - 항목추가 버튼으로 검사항목을 추가하세요 + 항목추가 버튼으로 검사항목을 추가하세요 ) : inspectionRows[key].map((row) => ( @@ -1219,6 +1369,9 @@ export default function ItemInspectionInfoPage() { updateInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" /> )} + + updateInspRow(key, row.id, "classification", e.target.value)} placeholder="구분 입력" /> + {row.judgment_criteria ? {row.judgment_criteria} : -} @@ -1285,20 +1438,20 @@ export default function ItemInspectionInfoPage() { - {/* ═══════════════════ 복사 모달 ═══════════════════ */} + {/* ═══════════════════ 복사 모달 (2단 분할: 좌 대상 / 우 편집) ═══════════════════ */} { if (!copying) setCopyModalOpen(v); }}> e.preventDefault()} onInteractOutside={(e) => e.preventDefault()} onEscapeKeyDown={(e) => { if (copying) e.preventDefault(); }} > - + {copying ? "검사정보 복사 중..." : "검사정보 복사"} {selectedGroup?.item_name || "-"} ({selectedItemCode}) - {copying ? " 의 검사정보를 복사하고 있습니다" : " 의 검사정보를 아래 선택한 품목들에 복사합니다"} + {copying ? " 의 검사정보를 복사하고 있습니다" : " 의 검사정보를 편집해서 선택한 품목들에 복사합니다. 기준 품목은 변경되지 않아요"} {copying ? ( @@ -1322,81 +1475,229 @@ export default function ItemInspectionInfoPage() {

- ) : (<> -
- setCopySearchKeyword(e.target.value)} - onKeyDown={(e) => { if (e.key === "Enter") handleCopySearch(); }} /> - -
-
- - - - - 0 && copyFilteredItems.every(i => copyCheckedIds.includes(i.code))} - onCheckedChange={(v) => { - if (v) setCopyCheckedIds(prev => Array.from(new Set([...prev, ...copyFilteredItems.map(i => i.code)]))); - else setCopyCheckedIds(prev => prev.filter(c => !copyFilteredItems.some(i => i.code === c))); - }} - /> - - 품목코드 - 품목명 - 품목유형 - 단위 - - - - {copyFilteredItems.length === 0 ? ( - - {copySearchLoading ? "검색 중..." : "검색 결과가 없어요"} - - ) : copyFilteredItems.map((item) => ( - toggleCopyChecked(item.code)}> - e.stopPropagation()}> - toggleCopyChecked(item.code)} /> - - {item.code} - {item.name} - {item.item_type} - {item.unit} - - ))} - -
-
-
- - 전체 {copyTotal.toLocaleString()}건 - {copyCheckedIds.length > 0 && 선택 {copyCheckedIds.length}} - -
- - - {Array.from({ length: Math.min(5, copyTotalPages) }, (_, i) => { - const start = Math.max(1, Math.min(copyPage - 2, copyTotalPages - 4)); - const p = start + i; - if (p > copyTotalPages) return null; - return ( - - ); - })} - - + ) : ( +
+ {/* 좌측: 복사 대상 품목 선택 */} +
+
+ 복사 대상 품목 선택 + {copyCheckedIds.length > 0 && 선택 {copyCheckedIds.length}건} +
+
+ setCopySearchKeyword(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") handleCopySearch(); }} /> + +
+
+ + + + + 0 && copyFilteredItems.every(i => copyCheckedIds.includes(i.code))} + onCheckedChange={(v) => { + if (v) setCopyCheckedIds(prev => Array.from(new Set([...prev, ...copyFilteredItems.map(i => i.code)]))); + else setCopyCheckedIds(prev => prev.filter(c => !copyFilteredItems.some(i => i.code === c))); + }} + /> + + 품목코드 + 품목명 + + + + {copyFilteredItems.length === 0 ? ( + + {copySearchLoading ? "검색 중..." : "검색 결과가 없어요"} + + ) : copyFilteredItems.map((item) => ( + toggleCopyChecked(item.code)}> + e.stopPropagation()}> + toggleCopyChecked(item.code)} /> + + {item.code} + {item.name} + + ))} + +
+
+
+ 전체 {copyTotal.toLocaleString()} +
+ + + {copyPage}/{copyTotalPages} + + +
+
+
+ + {/* 우측: 편집 폼 (등록/수정 폼과 동일 구조) */} +
+
+ 복사할 검사정보 편집 (기준: {selectedItemCode}) +
+
+
+
+ + +
+
+ + +
+
+ +
+

검사유형 선택

+
+ {INSPECTION_TYPES.map(({ key, label }) => ( +
+ setCopyForm(p => ({ ...p, [key]: !!v }))} /> + +
+ ))} +
+
+ + {INSPECTION_TYPES.filter(t => !!copyForm[t.key]).map(({ key, label }) => ( +
+ + {!copyCollapsedTypes[key] && ( +
+
+ 검사항목 목록 + +
+
+ + + + 검사기준 선택 + 검사기준 상세 + 검사방법 + 적용공정 + 구분 + 판단기준 + 합격기준 + 필수 + 단위 + + + + + {(!copyInspectionRows[key] || copyInspectionRows[key].length === 0) ? ( + 항목추가 버튼으로 검사항목을 추가하세요 + ) : copyInspectionRows[key].map((row) => ( + + + + + + + + {processOptions.length > 0 ? ( + + ) : ( + updateCopyInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" /> + )} + + + updateCopyInspRow(key, row.id, "classification", e.target.value)} placeholder="구분" /> + + + {row.judgment_criteria ? {row.judgment_criteria} : -} + + + {row.judgment_criteria === "선택형" && row.selection_options ? ( + + ) : row.judgment_criteria === "O/X" ? ( + + ) : row.judgment_criteria === "수치(범위)" ? ( +
+ { + const parts = (row.acceptance_criteria || "||").split("|"); + parts[0] = e.target.value; + updateCopyInspRow(key, row.id, "acceptance_criteria", parts.join("|")); + }} placeholder="기준" disabled={!row.inspection_standard_id} /> + ± + { + const parts = (row.acceptance_criteria || "||").split("|"); + parts[1] = e.target.value; + updateCopyInspRow(key, row.id, "acceptance_criteria", parts.join("|")); + }} placeholder="±" disabled={!row.inspection_standard_id} /> +
+ ) : ( + updateCopyInspRow(key, row.id, "acceptance_criteria", e.target.value)} placeholder="합격기준" disabled={!row.inspection_standard_id} /> + )} +
+ updateCopyInspRow(key, row.id, "is_required", !!v)} /> + {row.unit || "-"} + + + +
+ ))} +
+
+
+
+ )} +
+ ))} +
+
-
- )} + )}
+ {/* 위치명 형식 — 구역/열/단 뒤에 붙일 표현만 자유 입력 */} +
+ +
+ A + setRackZoneLabel(e.target.value)} + placeholder="구역" + className="h-8 w-20 text-xs" + /> + - 01 + setRackRowLabel(e.target.value)} + placeholder="열" + className="h-8 w-20 text-xs" + /> + - 1 + setRackLevelLabel(e.target.value)} + placeholder="단" + className="h-8 w-20 text-xs" + /> +
+

+ 예시: A{rackZoneLabel}-01{rackRowLabel}-1{rackLevelLabel} + {" "}— 구역/열/단 번호는 자동 계산되고, 뒤에 붙는 명칭만 수정할 수 있습니다. +

+
+ {/* 등록 미리보기 */}
diff --git a/frontend/app/(main)/COMPANY_30/outsourcing/subcontractor-item/page.tsx b/frontend/app/(main)/COMPANY_30/outsourcing/subcontractor-item/page.tsx index 8c1371bd..b44a1897 100644 --- a/frontend/app/(main)/COMPANY_30/outsourcing/subcontractor-item/page.tsx +++ b/frontend/app/(main)/COMPANY_30/outsourcing/subcontractor-item/page.tsx @@ -101,12 +101,26 @@ export default function SubcontractorItemPage() { } return result; }; - for (const col of ["material", "division", "type", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"]) { + for (const col of ["material", "division", "type", "status", "unit", "inventory_unit", "currency_code", "user_type01", "user_type02"]) { try { const res = await apiClient.get(`/table-categories/${ITEM_TABLE}/${col}/values`); if (res.data?.success) optMap[col] = flatten(res.data.data || []); } catch { /* skip */ } } + // 외주사관리에서 사용하는 subcontractor_item_prices.currency_code도 병합 + try { + const res = await apiClient.get(`/table-categories/subcontractor_item_prices/currency_code/values`); + if (res.data?.success) { + const extra = flatten(res.data.data || []); + const seen = new Set((optMap["currency_code"] || []).map((o) => o.code)); + for (const e of extra) { + if (!seen.has(e.code)) { + (optMap["currency_code"] ||= []).push(e); + seen.add(e.code); + } + } + } + } catch { /* skip */ } // 외주업체 거래유형 (subcontractor_mng.division) try { const res = await apiClient.get(`/table-categories/${SUBCONTRACTOR_TABLE}/division/values`); @@ -130,10 +144,10 @@ export default function SubcontractorItemPage() { width: { width: "w-[70px]", align: "right", render: (v) => v || "-" }, height: { width: "w-[70px]", align: "right", render: (v) => v || "-" }, thickness: { width: "w-[70px]", align: "right", render: (v) => v || "-" }, - unit: { width: "w-[60px]", render: (v) => v || "-" }, + unit: { width: "w-[60px]", render: (v) => resolve("unit", v) || "-" }, standard_price: { width: "w-[90px]", align: "right", formatNumber: true }, selling_price: { width: "w-[90px]", align: "right", formatNumber: true }, - currency_code: { width: "w-[50px]", render: (v) => v || "-" }, + currency_code: { width: "w-[50px]", render: (v) => resolve("currency_code", v) || "-" }, status: { width: "w-[60px]", render: (v) => v || "-" }, }; return ts.visibleColumns.map((col) => ({ @@ -141,7 +155,8 @@ export default function SubcontractorItemPage() { label: col.label, ...colProps[col.key], })); - }, [ts.visibleColumns]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ts.visibleColumns, categoryOptions]); // 좌측: 품목 조회 (division이 "외주관리"인 품목만 필터링) const outsourcingDivisionCode = categoryOptions["division"]?.find( @@ -170,8 +185,8 @@ export default function SubcontractorItemPage() { for (const col of CATS) { if (converted[col]) converted[col] = resolve(col, converted[col]); } - // item_info의 inventory_unit을 단위 표시용 unit에 매핑 - converted.unit = converted.inventory_unit || converted.unit || ""; + // "단위" 컬럼은 재고단위(inventory_unit)만 사용 — unit 폴백 제거 + converted.unit = converted.inventory_unit || ""; return converted; }); setItems(data); @@ -218,11 +233,36 @@ export default function SubcontractorItemPage() { } catch { /* skip */ } } - setSubcontractorItems(mappings.map((m: any) => ({ - ...m, - subcontractor_code: m.subcontractor_id, - subcontractor_name: subMap[m.subcontractor_id]?.subcontractor_name || "", - }))); + // 외주사관리에서 입력된 최신 단가(subcontractor_item_prices) 조회 → subcontractor_id 별 최신 1건 + const priceMap: Record = {}; + try { + const itemKeyLocal = selectedItem.item_number; + const priceRes = await apiClient.post(`/table-management/tables/subcontractor_item_prices/data`, { + page: 1, size: 0, + dataFilter: { enabled: true, filters: [{ columnName: "item_id", operator: "equals", value: itemKeyLocal }] }, + autoFilter: true, + }); + const prices = priceRes.data?.data?.data || priceRes.data?.data?.rows || []; + for (const p of prices) { + const key = p.subcontractor_id; + if (!key) continue; + if (!priceMap[key] || (p.start_date && (!priceMap[key].start_date || p.start_date > priceMap[key].start_date))) { + priceMap[key] = p; + } + } + } catch { /* skip */ } + + setSubcontractorItems(mappings.map((m: any) => { + const price = priceMap[m.subcontractor_id] || {}; + return { + ...m, + subcontractor_code: m.subcontractor_id, + subcontractor_name: subMap[m.subcontractor_id]?.subcontractor_name || "", + base_price: price.base_price ?? m.base_price, + calculated_price: price.calculated_price ?? price.unit_price ?? m.calculated_price, + currency_code: resolve("currency_code", price.currency_code ?? m.currency_code), + }; + })); } catch (err) { console.error("외주업체 조회 실패:", err); } finally { @@ -230,7 +270,8 @@ export default function SubcontractorItemPage() { } }; fetchSubcontractorItems(); - }, [selectedItem?.item_number]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedItem?.item_number, categoryOptions]); // 외주업체 검색 const searchSubcontractors = async () => { diff --git a/frontend/app/(main)/COMPANY_30/production/bom/page.tsx b/frontend/app/(main)/COMPANY_30/production/bom/page.tsx index 589f5f75..d3c52b1a 100644 --- a/frontend/app/(main)/COMPANY_30/production/bom/page.tsx +++ b/frontend/app/(main)/COMPANY_30/production/bom/page.tsx @@ -59,6 +59,7 @@ import { Settings2, Save, Package, + Pencil, } from "lucide-react"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; @@ -359,7 +360,13 @@ export default function BomManagementPage() { sort: { columnName: "created_at", order: "desc" }, }); - const rows = res.data?.data?.data || res.data?.data?.rows || []; + // DB 컬럼이 item_type/expired_date → 프론트 내부에서는 bom_type/expiry_date로 통일 + const rawRows = res.data?.data?.data || res.data?.data?.rows || []; + const rows = rawRows.map((r: any) => ({ + ...r, + bom_type: r.bom_type ?? r.item_type, + expiry_date: r.expiry_date ?? r.expired_date, + })); setBomList(rows); setTotalCount(rows.length); } catch (err: any) { @@ -456,9 +463,16 @@ export default function BomManagementPage() { const fetchBomDetail = useCallback(async (bomId: string) => { setDetailLoading(true); try { - // 헤더 조회 + // 헤더 조회 (DB 컬럼 item_type/expired_date → bom_type/expiry_date로 매핑) const headerRes = await apiClient.get(`/bom/${bomId}/header`); - const header = headerRes.data?.data || headerRes.data; + const rawHeader = headerRes.data?.data || headerRes.data; + const header = rawHeader + ? { + ...rawHeader, + bom_type: rawHeader.bom_type ?? rawHeader.item_type, + expiry_date: rawHeader.expiry_date ?? rawHeader.expired_date, + } + : null; setBomHeader(header); setCurrentVersionId(header?.current_version_id || null); @@ -1107,17 +1121,18 @@ export default function BomManagementPage() { setSaving(true); try { + // DB 실제 컬럼: item_type / expired_date (프론트 내부 bom_type/expiry_date와 다름) const bomFields: Record = { item_id: masterForm.item_id, item_code: masterForm.item_code, item_name: masterForm.item_name, - bom_type: masterForm.bom_type, + item_type: masterForm.bom_type, base_qty: masterForm.base_qty || "1", unit: masterForm.unit || "", version: masterForm.version || "1.0", status: masterForm.status || "draft", effective_date: masterForm.effective_date || null, - expiry_date: masterForm.expiry_date || null, + expired_date: masterForm.expiry_date || null, remark: masterForm.remark || "", writer: user?.userId || "", company_code: user?.company_code || "", @@ -1514,6 +1529,21 @@ export default function BomManagementPage() { 등록 +
{showOutsourceField && (
- - + + + + + + +
+ {subcontractorOptions.length === 0 ? ( +
등록된 외주업체가 없어요
+ ) : subcontractorOptions.map((s) => { + const checked = formOutsources.includes(s.id); + return ( + + ); + })} +
+
+
)}
diff --git a/frontend/app/(main)/COMPANY_30/production/work-instruction/page.tsx b/frontend/app/(main)/COMPANY_30/production/work-instruction/page.tsx index 8141d023..c01796fe 100644 --- a/frontend/app/(main)/COMPANY_30/production/work-instruction/page.tsx +++ b/frontend/app/(main)/COMPANY_30/production/work-instruction/page.tsx @@ -212,7 +212,13 @@ export default function WorkInstructionPage() { if (!regCheckedIds.has(getRegId(item))) continue; if (regSourceType === "item") items.push({ itemCode: item.item_code, itemName: item.item_name || "", spec: item.spec || "", qty: 1, remark: "", sourceType: "item", sourceTable: "item_info", sourceId: item.item_code }); else if (regSourceType === "order") items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: item.spec || "", qty: Number(item.qty || 1), remark: "", sourceType: "order", sourceTable: "sales_order_detail", sourceId: item.id }); - else items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: "", qty: Number(item.plan_qty || 1), remark: "", sourceType: "production", sourceTable: "production_plan_mng", sourceId: item.id }); + else { + // 생산계획: 잔량(remain_qty)이 있으면 잔량 기반으로 기본 수량 제안 (0/음수 허용 — 계획 초과 가능) + const defaultQty = item.remain_qty !== undefined && item.remain_qty !== null + ? Number(item.remain_qty) + : Number(item.plan_qty || 1); + items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: "", qty: defaultQty, remark: "", sourceType: "production", sourceTable: "production_plan_mng", sourceId: item.id }); + } } // 동일품목 합산 @@ -594,7 +600,7 @@ export default function WorkInstructionPage() { 0 && regCheckedIds.size === regSourceData.length} onCheckedChange={toggleRegAll} /> {regSourceType === "item" && <>품목코드품목명규격} {regSourceType === "order" && <>수주번호품번품목명규격수량납기일} - {regSourceType === "production" && <>계획번호품번품목명계획수량시작일완료일설비} + {regSourceType === "production" && <>계획번호품번품목명계획수량적용수량잔량시작일완료일설비} @@ -606,7 +612,7 @@ export default function WorkInstructionPage() { e.stopPropagation()}> toggleRegItem(id)} /> {regSourceType === "item" && <>{item.item_code}{item.item_name}{item.spec || "-"}} {regSourceType === "order" && <>{item.order_no}{item.item_code}{item.item_name}{item.spec || "-"}{Number(item.qty || 0).toLocaleString()}{item.due_date || "-"}} - {regSourceType === "production" && <>{item.plan_no}{item.item_code}{item.item_name}{Number(item.plan_qty || 0).toLocaleString()}{item.start_date ? String(item.start_date).split("T")[0] : "-"}{item.end_date ? String(item.end_date).split("T")[0] : "-"}{item.equipment_name || "-"}} + {regSourceType === "production" && <>{item.plan_no}{item.item_code}{item.item_name}{Number(item.plan_qty || 0).toLocaleString()}{Number(item.applied_qty || 0).toLocaleString()}{Number(item.remain_qty ?? item.plan_qty ?? 0).toLocaleString()}{item.start_date ? String(item.start_date).split("T")[0] : "-"}{item.end_date ? String(item.end_date).split("T")[0] : "-"}{item.equipment_name || "-"}} ); })} diff --git a/frontend/app/(main)/COMPANY_30/purchase/purchase-item/page.tsx b/frontend/app/(main)/COMPANY_30/purchase/purchase-item/page.tsx index 60d9ecae..0793c172 100644 --- a/frontend/app/(main)/COMPANY_30/purchase/purchase-item/page.tsx +++ b/frontend/app/(main)/COMPANY_30/purchase/purchase-item/page.tsx @@ -318,6 +318,11 @@ export default function PurchaseItemPage() { // 좌측: 품목 조회 const fetchItems = useCallback(async () => { + // 카테고리 로드 완료 전엔 대기 — 먼저 나간 unfiltered 요청이 나중에 도착해 + // filtered 결과를 덮어쓰는 race condition 방지 + if (!categoryOptions["division"]?.length) { + return; + } setItemLoading(true); try { const filters: { columnName: string; operator: string; value: any }[] = []; diff --git a/frontend/app/(main)/COMPANY_30/quality/inspection/page.tsx b/frontend/app/(main)/COMPANY_30/quality/inspection/page.tsx index 1c039795..53f0e142 100644 --- a/frontend/app/(main)/COMPANY_30/quality/inspection/page.tsx +++ b/frontend/app/(main)/COMPANY_30/quality/inspection/page.tsx @@ -52,6 +52,7 @@ const INSPECTION_COLUMNS = [ { key: "inspection_code", label: "검사코드" }, { key: "inspection_type", label: "검사유형" }, { key: "inspection_criteria", label: "검사기준" }, + { key: "criteria_detail", label: "기준상세" }, { key: "inspection_item", label: "검사항목" }, { key: "inspection_method", label: "검사방법" }, { key: "judgment_criteria", label: "판단기준" }, diff --git a/frontend/app/(main)/COMPANY_30/quality/item-inspection/page.tsx b/frontend/app/(main)/COMPANY_30/quality/item-inspection/page.tsx index 9555117e..0e976d47 100644 --- a/frontend/app/(main)/COMPANY_30/quality/item-inspection/page.tsx +++ b/frontend/app/(main)/COMPANY_30/quality/item-inspection/page.tsx @@ -43,6 +43,7 @@ type InspectionRow = { inspection_detail: string; inspection_method: string; apply_process: string; + classification: string; acceptance_criteria: string; is_required: boolean; judgment_criteria?: string; // 판단기준 라벨 (수치(범위)/텍스트입력/O·X/선택형) @@ -253,6 +254,11 @@ export default function ItemInspectionInfoPage() { loadProcessOptions(item.code); }; + // 복사 모달: 편집 가능한 기준 데이터 상태 (등록/수정 폼과 평행 구조) + const [copyForm, setCopyForm] = useState>({}); + const [copyInspectionRows, setCopyInspectionRows] = useState>({}); + const [copyCollapsedTypes, setCopyCollapsedTypes] = useState>({}); + /* ═══════════════════ 복사 모달 (기준 품목 검사정보 → 다른 품목들) ═══════════════════ */ const [copyModalOpen, setCopyModalOpen] = useState(false); const [copySearchKeyword, setCopySearchKeyword] = useState(""); @@ -294,11 +300,63 @@ export default function ItemInspectionInfoPage() { setCopyTotal(resData?.total || resData?.totalCount || rows.length); } catch { /* skip */ } finally { setCopySearchLoading(false); } }; - const openCopyModal = () => { + const openCopyModal = async () => { if (!selectedItemCode) { toast.error("복사 기준 품목을 먼저 선택해주세요"); return; } const srcGroup = groupedData.find(g => g.item_code === selectedItemCode); if (!srcGroup || srcGroup.rows.length === 0) { toast.error("선택한 품목에 복사할 검사정보가 없어요"); return; } setCopySearchKeyword(""); setCopyPage(1); setCopyCheckedIds([]); + + // 기준 품목 데이터를 편집용 상태로 복제 (openEdit과 동일한 변환 로직) + const baseRow = srcGroup.rows[0]; + try { + const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, { + page: 1, size: 0, + dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: selectedItemCode }] }, + autoFilter: true, + }); + const allRows = res.data?.data?.data || res.data?.data?.rows || []; + const rowMap: Record = {}; + const typeFlags: Record = {}; + for (const r of allRows) { + const inspType = r.inspection_type || ""; + const matched = INSPECTION_TYPES.find(t => + t.matchLabels.some(ml => inspType.includes(ml)) || + inspTypeCatOptions.some(cat => inspType.includes(cat.code) && t.matchLabels.some(ml => cat.label.includes(ml))) + ); + const typeKey = matched?.key || ""; + if (!typeKey) continue; + typeFlags[typeKey] = true; + if (!rowMap[typeKey]) rowMap[typeKey] = []; + const mCode = r.inspection_method || ""; + const mLabel = inspMethodCatOptions.find(o => o.code === mCode)?.label || mCode; + const inspOpt = inspOptions.find(o => o.code === r.inspection_standard_id); + const jcCode = inspOpt?.judgment_criteria || ""; + const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode; + const unitCode = inspOpt?.unit || ""; + const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode; + rowMap[typeKey].push({ + id: crypto.randomUUID(), // 복사본은 새 id 부여 (원본과 분리) + inspection_standard_id: r.inspection_standard_id || "", + inspection_detail: r.inspection_item_name || r.inspection_standard || "", + inspection_method: mLabel, + apply_process: r.apply_process || "", + classification: r.classification || "", + acceptance_criteria: r.pass_criteria || "", + is_required: r.is_required === "true" || r.is_required === true, + judgment_criteria: jcLabel, + selection_options: inspOpt?.selection_options || "", + unit: unitLabel, + }); + } + setCopyInspectionRows(rowMap); + setCopyForm({ ...baseRow, ...typeFlags }); + setCopyCollapsedTypes({}); + } catch { + setCopyInspectionRows({}); + setCopyForm({ ...baseRow }); + setCopyCollapsedTypes({}); + } + setCopyModalOpen(true); searchCopyTargets(1); }; @@ -309,10 +367,18 @@ export default function ItemInspectionInfoPage() { const handleCopy = async () => { if (!selectedItemCode) { toast.error("복사 기준 품목이 없어요"); return; } if (copyCheckedIds.length === 0) { toast.error("붙여넣을 품목을 선택해주세요"); return; } - const sourceGroup = groupedData.find(g => g.item_code === selectedItemCode); - if (!sourceGroup || sourceGroup.rows.length === 0) { toast.error("복사할 검사정보가 없어요"); return; } + + // 편집된 rows를 평탄화 (선택된 검사유형의 rows만) + const enabledTypes = INSPECTION_TYPES.filter(t => !!copyForm[t.key]); + const flatRows: Array<{ row: InspectionRow; typeLabel: string }> = []; + for (const t of enabledTypes) { + const rows = copyInspectionRows[t.key] || []; + for (const r of rows) flatRows.push({ row: r, typeLabel: t.label }); + } + if (flatRows.length === 0) { toast.error("복사할 검사항목이 없어요"); return; } + const ok = await confirm( - `선택한 ${copyCheckedIds.length}개 품목에 검사정보를 복사할까요?`, + `선택한 ${copyCheckedIds.length}개 품목에 편집된 검사정보(${flatRows.length}개 행)를 복사할까요?`, { description: "대상 품목의 기존 검사정보는 삭제 후 교체됩니다.", variant: "info", confirmText: "복사" } ); if (!ok) return; @@ -325,7 +391,7 @@ export default function ItemInspectionInfoPage() { const target = copyFilteredItems.find(o => o.code === targetCode) || itemOptions.find(o => o.code === targetCode); const targetName = target?.name || ""; const existRes = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, { - page: 1, size: 500, + page: 1, size: 0, dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: targetCode }] }, autoFilter: true, }); @@ -333,13 +399,26 @@ export default function ItemInspectionInfoPage() { if (existing.length > 0) { await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: existing.map((r: any) => ({ id: r.id })) }); } - for (const r of sourceGroup.rows) { - const { id: _id, created_at: _c, updated_at: _u, ...rest } = r; + let orderSeq = 0; + for (const { row: r, typeLabel } of flatRows) { + orderSeq += 1; await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, { - ...rest, id: crypto.randomUUID(), item_code: targetCode, item_name: targetName, + inspection_type: typeLabel, + inspection_standard_id: r.inspection_standard_id || "", + inspection_item_name: r.inspection_detail || "", + inspection_method: r.inspection_method || "", + apply_process: r.apply_process || "", + classification: r.classification || "", + pass_criteria: r.acceptance_criteria || "", + is_required: r.is_required ? "true" : "false", + is_active: copyForm.is_active || "사용", + manager: copyForm.manager || "", + manager_id: copyForm.manager_id || "", + memo: copyForm.remarks || "", + sort_order: String(orderSeq).padStart(4, "0"), }); } setCopyProgress({ current: i + 1, total: copyCheckedIds.length }); @@ -402,7 +481,13 @@ export default function ItemInspectionInfoPage() { // 선택된 탭의 검사항목 행 const selectedTabRows = useMemo(() => { if (!selectedGroup || !selectedTypeTab) return []; - return selectedGroup.rows.filter((r: any) => r.inspection_type === selectedTypeTab && r.inspection_standard_id); + const filtered = selectedGroup.rows.filter((r: any) => r.inspection_type === selectedTypeTab && r.inspection_standard_id); + return [...filtered].sort((a: any, b: any) => { + const av = parseInt(String(a.sort_order || "9999"), 10); + const bv = parseInt(String(b.sort_order || "9999"), 10); + if (av === bv) return String(a.id).localeCompare(String(b.id)); + return av - bv; + }); }, [selectedGroup, selectedTypeTab]); // 검사기준 ID → 라벨 @@ -436,6 +521,13 @@ export default function ItemInspectionInfoPage() { autoFilter: true, }); const allRows = res.data?.data?.data || res.data?.data?.rows || []; + // sort_order 기준 오름차순 정렬 (varchar이므로 숫자 파싱 후 비교) + allRows.sort((a: any, b: any) => { + const av = parseInt(String(a.sort_order || "9999"), 10); + const bv = parseInt(String(b.sort_order || "9999"), 10); + if (av === bv) return String(a.id).localeCompare(String(b.id)); + return av - bv; + }); const rowMap: Record = {}; const typeFlags: Record = {}; @@ -462,7 +554,8 @@ export default function ItemInspectionInfoPage() { inspection_standard_id: r.inspection_standard_id || "", inspection_detail: r.inspection_item_name || r.inspection_standard || "", inspection_method: mLabel, - apply_process: "", + apply_process: r.apply_process || "", + classification: r.classification || "", acceptance_criteria: r.pass_criteria || "", is_required: r.is_required === "true" || r.is_required === true, judgment_criteria: jcLabel, @@ -480,7 +573,7 @@ export default function ItemInspectionInfoPage() { const addInspRow = (typeKey: string) => { setInspectionRows(prev => ({ ...prev, - [typeKey]: [...(prev[typeKey] || []), { id: crypto.randomUUID(), inspection_standard_id: "", inspection_detail: "", inspection_method: "", apply_process: "", acceptance_criteria: "", is_required: false }], + [typeKey]: [...(prev[typeKey] || []), { id: crypto.randomUUID(), inspection_standard_id: "", inspection_detail: "", inspection_method: "", apply_process: "", classification: "", acceptance_criteria: "", is_required: false }], })); }; const removeInspRow = (typeKey: string, rowId: string) => { @@ -525,6 +618,46 @@ export default function ItemInspectionInfoPage() { }; const toggleCollapse = (typeKey: string) => { setCollapsedTypes(prev => ({ ...prev, [typeKey]: !prev[typeKey] })); }; + /* ═══════════════════ 복사 모달용 검사항목 행 관리 (등록 폼과 평행) ═══════════════════ */ + const addCopyInspRow = (typeKey: string) => { + setCopyInspectionRows(prev => ({ + ...prev, + [typeKey]: [...(prev[typeKey] || []), { id: crypto.randomUUID(), inspection_standard_id: "", inspection_detail: "", inspection_method: "", apply_process: "", classification: "", acceptance_criteria: "", is_required: false }], + })); + }; + const removeCopyInspRow = (typeKey: string, rowId: string) => { + setCopyInspectionRows(prev => ({ ...prev, [typeKey]: (prev[typeKey] || []).filter(r => r.id !== rowId) })); + }; + const updateCopyInspRow = (typeKey: string, rowId: string, field: string, value: any) => { + setCopyInspectionRows(prev => ({ + ...prev, + [typeKey]: (prev[typeKey] || []).map(r => { + if (r.id !== rowId) return r; + if (field === "inspection_standard_id") { + const opt = inspOptions.find(o => o.code === value); + const methodCode = opt?.method || ""; + const methodLabel = inspMethodCatOptions.find(o => o.code === methodCode)?.label || methodCode; + const jcCode = opt?.judgment_criteria || ""; + const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode; + const unitCode = opt?.unit || ""; + const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode; + return { + ...r, + inspection_standard_id: value, + inspection_detail: opt?.detail || "", + inspection_method: methodLabel, + judgment_criteria: jcLabel, + selection_options: opt?.selection_options || "", + unit: unitLabel, + acceptance_criteria: "", + }; + } + return { ...r, [field]: value }; + }), + })); + }; + const toggleCopyCollapse = (typeKey: string) => { setCopyCollapsedTypes(prev => ({ ...prev, [typeKey]: !prev[typeKey] })); }; + const handleSave = async () => { if (!form.item_code) { toast.error("품목코드는 필수예요"); return; } setSaving(true); @@ -542,18 +675,23 @@ export default function ItemInspectionInfoPage() { } const enabledTypes = INSPECTION_TYPES.filter(t => !!form[t.key]); const rows: any[] = []; + let globalOrder = 0; for (const t of enabledTypes) { const typeRows = inspectionRows[t.key] || []; if (typeRows.length === 0) { - rows.push({ id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label, is_active: form.is_active || "사용", manager_id: form.manager_id || "", memo: form.remarks || "" }); + globalOrder += 1; + rows.push({ id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label, is_active: form.is_active || "사용", manager_id: form.manager_id || "", memo: form.remarks || "", sort_order: String(globalOrder).padStart(4, "0") }); } else { for (const r of typeRows) { + globalOrder += 1; rows.push({ id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label, inspection_standard_id: r.inspection_standard_id || "", inspection_item_name: r.inspection_detail || "", inspection_method: r.inspection_method || "", pass_criteria: r.acceptance_criteria || "", + apply_process: r.apply_process || "", classification: r.classification || "", is_required: r.is_required ? "true" : "false", is_active: form.is_active || "사용", manager_id: form.manager_id || "", memo: form.remarks || "", + sort_order: String(globalOrder).padStart(4, "0"), }); } } @@ -974,6 +1112,7 @@ export default function ItemInspectionInfoPage() { 검사기준 검사방법 적용공정 + 구분 판단기준 합격기준 필수 @@ -983,7 +1122,7 @@ export default function ItemInspectionInfoPage() { {selectedTabRows.length === 0 ? ( - 등록된 검사항목이 없어요 + 등록된 검사항목이 없어요 ) : selectedTabRows.map((row: any) => ( @@ -1002,6 +1141,7 @@ export default function ItemInspectionInfoPage() { const proc = processOptions.find(p => p.code === code); return proc?.name || code; })()} + {row.classification || "-"} {(() => { const insp = inspOptions.find(o => o.code === row.inspection_standard_id); @@ -1010,7 +1150,16 @@ export default function ItemInspectionInfoPage() { return jcLabel ? {jcLabel} : "-"; })()} - {row.pass_criteria || "-"} + {(() => { + const pc = row.pass_criteria; + if (!pc) return "-"; + if (pc.includes("|")) { + const [s, t] = pc.split("|"); + if (!t || !t.trim()) return s || "-"; + return `${s} ± ${t}`; + } + return pc; + })()} {row.is_required === "true" || row.is_required === true ? ( 필수 @@ -1185,6 +1334,7 @@ export default function ItemInspectionInfoPage() { 검사기준 상세 검사방법 적용공정 + 구분 판단기준 합격기준 (판단기준별) 필수 @@ -1194,7 +1344,7 @@ export default function ItemInspectionInfoPage() { {(!inspectionRows[key] || inspectionRows[key].length === 0) ? ( - 항목추가 버튼으로 검사항목을 추가하세요 + 항목추가 버튼으로 검사항목을 추가하세요 ) : inspectionRows[key].map((row) => ( @@ -1219,6 +1369,9 @@ export default function ItemInspectionInfoPage() { updateInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" /> )} + + updateInspRow(key, row.id, "classification", e.target.value)} placeholder="구분 입력" /> + {row.judgment_criteria ? {row.judgment_criteria} : -} @@ -1285,20 +1438,20 @@ export default function ItemInspectionInfoPage() { - {/* ═══════════════════ 복사 모달 ═══════════════════ */} + {/* ═══════════════════ 복사 모달 (2단 분할: 좌 대상 / 우 편집) ═══════════════════ */} { if (!copying) setCopyModalOpen(v); }}> e.preventDefault()} onInteractOutside={(e) => e.preventDefault()} onEscapeKeyDown={(e) => { if (copying) e.preventDefault(); }} > - + {copying ? "검사정보 복사 중..." : "검사정보 복사"} {selectedGroup?.item_name || "-"} ({selectedItemCode}) - {copying ? " 의 검사정보를 복사하고 있습니다" : " 의 검사정보를 아래 선택한 품목들에 복사합니다"} + {copying ? " 의 검사정보를 복사하고 있습니다" : " 의 검사정보를 편집해서 선택한 품목들에 복사합니다. 기준 품목은 변경되지 않아요"} {copying ? ( @@ -1322,81 +1475,229 @@ export default function ItemInspectionInfoPage() {

- ) : (<> -
- setCopySearchKeyword(e.target.value)} - onKeyDown={(e) => { if (e.key === "Enter") handleCopySearch(); }} /> - -
-
- - - - - 0 && copyFilteredItems.every(i => copyCheckedIds.includes(i.code))} - onCheckedChange={(v) => { - if (v) setCopyCheckedIds(prev => Array.from(new Set([...prev, ...copyFilteredItems.map(i => i.code)]))); - else setCopyCheckedIds(prev => prev.filter(c => !copyFilteredItems.some(i => i.code === c))); - }} - /> - - 품목코드 - 품목명 - 품목유형 - 단위 - - - - {copyFilteredItems.length === 0 ? ( - - {copySearchLoading ? "검색 중..." : "검색 결과가 없어요"} - - ) : copyFilteredItems.map((item) => ( - toggleCopyChecked(item.code)}> - e.stopPropagation()}> - toggleCopyChecked(item.code)} /> - - {item.code} - {item.name} - {item.item_type} - {item.unit} - - ))} - -
-
-
- - 전체 {copyTotal.toLocaleString()}건 - {copyCheckedIds.length > 0 && 선택 {copyCheckedIds.length}} - -
- - - {Array.from({ length: Math.min(5, copyTotalPages) }, (_, i) => { - const start = Math.max(1, Math.min(copyPage - 2, copyTotalPages - 4)); - const p = start + i; - if (p > copyTotalPages) return null; - return ( - - ); - })} - - + ) : ( +
+ {/* 좌측: 복사 대상 품목 선택 */} +
+
+ 복사 대상 품목 선택 + {copyCheckedIds.length > 0 && 선택 {copyCheckedIds.length}건} +
+
+ setCopySearchKeyword(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") handleCopySearch(); }} /> + +
+
+ + + + + 0 && copyFilteredItems.every(i => copyCheckedIds.includes(i.code))} + onCheckedChange={(v) => { + if (v) setCopyCheckedIds(prev => Array.from(new Set([...prev, ...copyFilteredItems.map(i => i.code)]))); + else setCopyCheckedIds(prev => prev.filter(c => !copyFilteredItems.some(i => i.code === c))); + }} + /> + + 품목코드 + 품목명 + + + + {copyFilteredItems.length === 0 ? ( + + {copySearchLoading ? "검색 중..." : "검색 결과가 없어요"} + + ) : copyFilteredItems.map((item) => ( + toggleCopyChecked(item.code)}> + e.stopPropagation()}> + toggleCopyChecked(item.code)} /> + + {item.code} + {item.name} + + ))} + +
+
+
+ 전체 {copyTotal.toLocaleString()} +
+ + + {copyPage}/{copyTotalPages} + + +
+
+
+ + {/* 우측: 편집 폼 (등록/수정 폼과 동일 구조) */} +
+
+ 복사할 검사정보 편집 (기준: {selectedItemCode}) +
+
+
+
+ + +
+
+ + +
+
+ +
+

검사유형 선택

+
+ {INSPECTION_TYPES.map(({ key, label }) => ( +
+ setCopyForm(p => ({ ...p, [key]: !!v }))} /> + +
+ ))} +
+
+ + {INSPECTION_TYPES.filter(t => !!copyForm[t.key]).map(({ key, label }) => ( +
+ + {!copyCollapsedTypes[key] && ( +
+
+ 검사항목 목록 + +
+
+ + + + 검사기준 선택 + 검사기준 상세 + 검사방법 + 적용공정 + 구분 + 판단기준 + 합격기준 + 필수 + 단위 + + + + + {(!copyInspectionRows[key] || copyInspectionRows[key].length === 0) ? ( + 항목추가 버튼으로 검사항목을 추가하세요 + ) : copyInspectionRows[key].map((row) => ( + + + + + + + + {processOptions.length > 0 ? ( + + ) : ( + updateCopyInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" /> + )} + + + updateCopyInspRow(key, row.id, "classification", e.target.value)} placeholder="구분" /> + + + {row.judgment_criteria ? {row.judgment_criteria} : -} + + + {row.judgment_criteria === "선택형" && row.selection_options ? ( + + ) : row.judgment_criteria === "O/X" ? ( + + ) : row.judgment_criteria === "수치(범위)" ? ( +
+ { + const parts = (row.acceptance_criteria || "||").split("|"); + parts[0] = e.target.value; + updateCopyInspRow(key, row.id, "acceptance_criteria", parts.join("|")); + }} placeholder="기준" disabled={!row.inspection_standard_id} /> + ± + { + const parts = (row.acceptance_criteria || "||").split("|"); + parts[1] = e.target.value; + updateCopyInspRow(key, row.id, "acceptance_criteria", parts.join("|")); + }} placeholder="±" disabled={!row.inspection_standard_id} /> +
+ ) : ( + updateCopyInspRow(key, row.id, "acceptance_criteria", e.target.value)} placeholder="합격기준" disabled={!row.inspection_standard_id} /> + )} +
+ updateCopyInspRow(key, row.id, "is_required", !!v)} /> + {row.unit || "-"} + + + +
+ ))} +
+
+
+
+ )} +
+ ))} +
+
-
- )} + )}
+ {/* 위치명 형식 — 구역/열/단 뒤에 붙일 표현만 자유 입력 */} +
+ +
+ A + setRackZoneLabel(e.target.value)} + placeholder="구역" + className="h-8 w-20 text-xs" + /> + - 01 + setRackRowLabel(e.target.value)} + placeholder="열" + className="h-8 w-20 text-xs" + /> + - 1 + setRackLevelLabel(e.target.value)} + placeholder="단" + className="h-8 w-20 text-xs" + /> +
+

+ 예시: A{rackZoneLabel}-01{rackRowLabel}-1{rackLevelLabel} + {" "}— 구역/열/단 번호는 자동 계산되고, 뒤에 붙는 명칭만 수정할 수 있습니다. +

+
+ {/* 등록 미리보기 */}
diff --git a/frontend/app/(main)/COMPANY_7/outsourcing/subcontractor-item/page.tsx b/frontend/app/(main)/COMPANY_7/outsourcing/subcontractor-item/page.tsx index 8f1802c4..d16596ed 100644 --- a/frontend/app/(main)/COMPANY_7/outsourcing/subcontractor-item/page.tsx +++ b/frontend/app/(main)/COMPANY_7/outsourcing/subcontractor-item/page.tsx @@ -98,12 +98,26 @@ export default function SubcontractorItemPage() { } return result; }; - for (const col of ["material", "division", "type", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"]) { + for (const col of ["material", "division", "type", "status", "unit", "inventory_unit", "currency_code", "user_type01", "user_type02"]) { try { const res = await apiClient.get(`/table-categories/${ITEM_TABLE}/${col}/values`); if (res.data?.success) optMap[col] = flatten(res.data.data || []); } catch { /* skip */ } } + // 외주사관리에서 사용하는 subcontractor_item_prices.currency_code도 병합 + try { + const res = await apiClient.get(`/table-categories/subcontractor_item_prices/currency_code/values`); + if (res.data?.success) { + const extra = flatten(res.data.data || []); + const seen = new Set((optMap["currency_code"] || []).map((o) => o.code)); + for (const e of extra) { + if (!seen.has(e.code)) { + (optMap["currency_code"] ||= []).push(e); + seen.add(e.code); + } + } + } + } catch { /* skip */ } // 외주업체 거래유형 (subcontractor_mng.division) try { const res = await apiClient.get(`/table-categories/${SUBCONTRACTOR_TABLE}/division/values`); @@ -124,10 +138,10 @@ export default function SubcontractorItemPage() { item_number: { width: "w-[110px]" }, item_name: { minWidth: "min-w-[130px]", render: (v) => v || "-" }, size: { width: "w-[90px]", render: (v) => v || "-" }, - unit: { width: "w-[60px]", render: (v) => v || "-" }, + unit: { width: "w-[60px]", render: (v) => resolve("unit", v) || "-" }, standard_price: { width: "w-[90px]", align: "right", formatNumber: true }, selling_price: { width: "w-[90px]", align: "right", formatNumber: true }, - currency_code: { width: "w-[50px]", render: (v) => v || "-" }, + currency_code: { width: "w-[50px]", render: (v) => resolve("currency_code", v) || "-" }, status: { width: "w-[60px]", render: (v) => v || "-" }, }; return ts.visibleColumns.map((col) => ({ @@ -135,7 +149,8 @@ export default function SubcontractorItemPage() { label: col.label, ...colProps[col.key], })); - }, [ts.visibleColumns]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ts.visibleColumns, categoryOptions]); // 좌측: 품목 조회 (division이 "외주관리"인 품목만 필터링) const outsourcingDivisionCode = categoryOptions["division"]?.find( @@ -164,8 +179,8 @@ export default function SubcontractorItemPage() { for (const col of CATS) { if (converted[col]) converted[col] = resolve(col, converted[col]); } - // item_info의 inventory_unit을 단위 표시용 unit에 매핑 - converted.unit = converted.inventory_unit || converted.unit || ""; + // "단위" 컬럼은 재고단위(inventory_unit)만 사용 — unit 폴백 제거 + converted.unit = converted.inventory_unit || ""; return converted; }); setItems(data); @@ -212,11 +227,35 @@ export default function SubcontractorItemPage() { } catch { /* skip */ } } - setSubcontractorItems(mappings.map((m: any) => ({ - ...m, - subcontractor_code: m.subcontractor_id, - subcontractor_name: subMap[m.subcontractor_id]?.subcontractor_name || "", - }))); + // 외주사관리에서 입력된 최신 단가(subcontractor_item_prices) 조회 → subcontractor_id 별 최신 1건 + const priceMap: Record = {}; + try { + const priceRes = await apiClient.post(`/table-management/tables/subcontractor_item_prices/data`, { + page: 1, size: 0, + dataFilter: { enabled: true, filters: [{ columnName: "item_id", operator: "equals", value: itemKey }] }, + autoFilter: true, + }); + const prices = priceRes.data?.data?.data || priceRes.data?.data?.rows || []; + for (const p of prices) { + const key = p.subcontractor_id; + if (!key) continue; + if (!priceMap[key] || (p.start_date && (!priceMap[key].start_date || p.start_date > priceMap[key].start_date))) { + priceMap[key] = p; + } + } + } catch { /* skip */ } + + setSubcontractorItems(mappings.map((m: any) => { + const price = priceMap[m.subcontractor_id] || {}; + return { + ...m, + subcontractor_code: m.subcontractor_id, + subcontractor_name: subMap[m.subcontractor_id]?.subcontractor_name || "", + base_price: price.base_price ?? m.base_price, + calculated_price: price.calculated_price ?? price.unit_price ?? m.calculated_price, + currency_code: resolve("currency_code", price.currency_code ?? m.currency_code), + }; + })); } catch (err) { console.error("외주업체 조회 실패:", err); } finally { @@ -224,7 +263,8 @@ export default function SubcontractorItemPage() { } }; fetchSubcontractorItems(); - }, [selectedItem?.item_number]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedItem?.item_number, categoryOptions]); // 외주업체 검색 const searchSubcontractors = async () => { diff --git a/frontend/app/(main)/COMPANY_7/production/bom/page.tsx b/frontend/app/(main)/COMPANY_7/production/bom/page.tsx index 84b7afbb..01e7ee14 100644 --- a/frontend/app/(main)/COMPANY_7/production/bom/page.tsx +++ b/frontend/app/(main)/COMPANY_7/production/bom/page.tsx @@ -59,6 +59,7 @@ import { Settings2, Save, Package, + Pencil, } from "lucide-react"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; @@ -355,7 +356,13 @@ export default function BomManagementPage() { sort: { columnName: "created_at", order: "desc" }, }); - const rows = res.data?.data?.data || res.data?.data?.rows || []; + // DB 컬럼이 item_type/expired_date → 프론트 내부에서는 bom_type/expiry_date로 통일 + const rawRows = res.data?.data?.data || res.data?.data?.rows || []; + const rows = rawRows.map((r: any) => ({ + ...r, + bom_type: r.bom_type ?? r.item_type, + expiry_date: r.expiry_date ?? r.expired_date, + })); setBomList(rows); setTotalCount(rows.length); } catch (err: any) { @@ -452,9 +459,16 @@ export default function BomManagementPage() { const fetchBomDetail = useCallback(async (bomId: string) => { setDetailLoading(true); try { - // 헤더 조회 + // 헤더 조회 (DB 컬럼 item_type/expired_date → bom_type/expiry_date로 매핑) const headerRes = await apiClient.get(`/bom/${bomId}/header`); - const header = headerRes.data?.data || headerRes.data; + const rawHeader = headerRes.data?.data || headerRes.data; + const header = rawHeader + ? { + ...rawHeader, + bom_type: rawHeader.bom_type ?? rawHeader.item_type, + expiry_date: rawHeader.expiry_date ?? rawHeader.expired_date, + } + : null; setBomHeader(header); setCurrentVersionId(header?.current_version_id || null); @@ -1100,17 +1114,18 @@ export default function BomManagementPage() { setSaving(true); try { + // DB 실제 컬럼: item_type / expired_date (프론트 내부 bom_type/expiry_date와 다름) const bomFields: Record = { item_id: masterForm.item_id, item_code: masterForm.item_code, item_name: masterForm.item_name, - bom_type: masterForm.bom_type, + item_type: masterForm.bom_type, base_qty: masterForm.base_qty || "1", unit: masterForm.unit || "", version: masterForm.version || "1.0", status: masterForm.status || "draft", effective_date: masterForm.effective_date || null, - expiry_date: masterForm.expiry_date || null, + expired_date: masterForm.expiry_date || null, remark: masterForm.remark || "", writer: user?.userId || "", company_code: user?.company_code || "", @@ -1482,6 +1497,21 @@ export default function BomManagementPage() { 등록 +
+ + + {searchable && ( +
+ setKeyword(e.target.value)} className="h-7 text-xs" /> +
+ )} +
+ {filtered.length === 0 ? ( +
{emptyMessage}
+ ) : filtered.map(opt => ( + + ))} +
+ {value.length > 0 && ( +
+ {value.length}개 선택됨 + +
+ )} +
+ + ); } export default function WorkInstructionPage() { @@ -197,12 +281,23 @@ export default function WorkInstructionPage() { const applyRegistration = () => { if (regCheckedIds.size === 0) { alert("품목을 선택해주세요."); return; } + const today = new Date().toISOString().split("T")[0]; const items: SelectedItem[] = []; for (const item of regSourceData) { if (!regCheckedIds.has(getRegId(item))) continue; - if (regSourceType === "item") items.push({ itemCode: item.item_code, itemName: item.item_name || "", spec: item.spec || "", qty: 1, remark: "", sourceType: "item", sourceTable: "item_info", sourceId: item.item_code }); - else if (regSourceType === "order") items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: item.spec || "", qty: Number(item.qty || 1), remark: "", sourceType: "order", sourceTable: "sales_order_detail", sourceId: item.id }); - else items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: "", qty: Number(item.plan_qty || 1), remark: "", sourceType: "production", sourceTable: "production_plan_mng", sourceId: item.id }); + const baseExtra = { startDate: today, endDate: "", equipmentIds: [], workTeams: [], workers: [] } as Pick; + if (regSourceType === "item") items.push({ itemCode: item.item_code, itemName: item.item_name || "", spec: item.spec || "", qty: 1, remark: "", sourceType: "item", sourceTable: "item_info", sourceId: item.item_code, ...baseExtra }); + else if (regSourceType === "order") items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: item.spec || "", qty: Number(item.qty || 1), remark: "", sourceType: "order", sourceTable: "sales_order_detail", sourceId: item.id, ...baseExtra }); + else { + // 생산계획: 잔량(remain_qty)이 있으면 잔량 기반으로 기본 수량 제안 (0/음수 허용 — 계획 초과 가능) + const defaultQty = item.remain_qty !== undefined && item.remain_qty !== null + ? Number(item.remain_qty) + : Number(item.plan_qty || 1); + // 생산계획: 일정이 있으면 기본값으로 전달 + const planStart = item.start_date ? String(item.start_date).split("T")[0] : today; + const planEnd = item.end_date ? String(item.end_date).split("T")[0] : ""; + items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: "", qty: defaultQty, remark: "", sourceType: "production", sourceTable: "production_plan_mng", sourceId: item.id, startDate: planStart, endDate: planEnd, equipmentIds: [], workTeams: [], workers: [] }); + } } // 동일품목 합산 @@ -250,6 +345,9 @@ export default function WorkInstructionPage() { itemCode: firstItem?.itemCode || "", itemName: firstItem?.itemName || "", spec: firstItem?.spec || "", qty: Number(confirmAddQty), remark: "", sourceType: "item" as SourceType, sourceTable: "item_info", sourceId: firstItem?.itemCode || "", + startDate: firstItem?.startDate || new Date().toISOString().split("T")[0], + endDate: firstItem?.endDate || "", + equipmentIds: [], workTeams: [], workers: [], }]); setConfirmAddQty(""); }; @@ -259,11 +357,29 @@ export default function WorkInstructionPage() { if (confirmItems.length === 0) { alert("품목이 없습니다."); return; } setSaving(true); try { + // 헤더 대표값: 첫 번째 품목의 첫 번째 값으로 (하위 호환 유지 — 조회 화면이 헤더값으로 표시되는 레거시 대비) + const first = confirmItems[0]; + const headerStart = first?.startDate || ""; + const headerEnd = first?.endDate || ""; + const headerEquipment = first?.equipmentIds?.[0] || ""; + const headerWorkTeam = first?.workTeams?.[0] || ""; + const headerWorker = first?.workers?.[0] || ""; const payload = { - status: confirmStatus, startDate: confirmStartDate, endDate: confirmEndDate, - equipmentId: confirmEquipmentId, workTeam: confirmWorkTeam, worker: confirmWorker, + status: confirmStatus, + startDate: headerStart, endDate: headerEnd, + equipmentId: headerEquipment, workTeam: headerWorkTeam, worker: headerWorker, routing: confirmRouting || null, - items: confirmItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode, routing: i.routing || null })), + items: confirmItems.map(i => ({ + itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, + sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode, + routing: i.routing || null, + // 품목별 일정/설비/작업조/작업자 (옵션 A — 다중값 쉼표 구분) + startDate: i.startDate || "", + endDate: i.endDate || "", + equipmentIds: (i.equipmentIds || []).join(","), + workTeams: (i.workTeams || []).join(","), + workers: (i.workers || []).join(","), + })), }; const r = await saveWorkInstruction(payload); if (r.success) { setIsConfirmModalOpen(false); fetchOrders(); alert("작업지시가 등록되었습니다."); } @@ -286,6 +402,12 @@ export default function WorkInstructionPage() { sourceTable: d.source_table || "item_info", sourceId: d.source_id || "", routing: d.detail_routing_version_id || order.routing_version_id || "", routingOptions: [], + // 품목별 일정/설비/작업조/작업자 (detail 값 우선, 없으면 헤더값 폴백) + startDate: d.detail_start_date || d.start_date || "", + endDate: d.detail_end_date || d.end_date || "", + equipmentIds: (d.detail_equipment_ids || "").split(",").filter(Boolean), + workTeams: (d.detail_work_teams || "").split(",").filter(Boolean), + workers: (d.detail_workers || "").split(",").filter(Boolean), })); setEditItems(items); setAddQty(""); setAddEquipment(""); setAddWorkTeam(""); setAddWorker(""); @@ -316,9 +438,13 @@ export default function WorkInstructionPage() { const addEditItem = () => { if (!addQty || Number(addQty) <= 0) { alert("수량을 입력해주세요."); return; } + const firstItem = editItems[0]; setEditItems(prev => [...prev, { itemCode: editOrder?.item_number || "", itemName: editOrder?.item_name || "", spec: editOrder?.item_spec || "", qty: Number(addQty), remark: "", sourceType: "item", sourceTable: "item_info", sourceId: editOrder?.item_number || "", + startDate: firstItem?.startDate || editStartDate || "", + endDate: firstItem?.endDate || editEndDate || "", + equipmentIds: [], workTeams: [], workers: [], }]); setAddQty(""); }; @@ -327,11 +453,30 @@ export default function WorkInstructionPage() { if (!editOrder || editItems.length === 0) { alert("품목이 없습니다."); return; } setEditSaving(true); try { + // 헤더 대표값: 첫 번째 품목의 첫 번째 값 사용 (하위 호환 — 등록 모달과 동일 패턴) + const first = editItems[0]; + const headerStart = first?.startDate || editStartDate || ""; + const headerEnd = first?.endDate || editEndDate || ""; + const headerEquipment = first?.equipmentIds?.[0] || editEquipmentId || ""; + const headerWorkTeam = first?.workTeams?.[0] || editWorkTeam || ""; + const headerWorker = first?.workers?.[0] || editWorker || ""; const payload = { - id: editOrder.wi_id, status: editStatus, startDate: editStartDate, endDate: editEndDate, - equipmentId: editEquipmentId, workTeam: editWorkTeam, worker: editWorker, remark: editRemark, + id: editOrder.wi_id, status: editStatus, + startDate: headerStart, endDate: headerEnd, + equipmentId: headerEquipment, workTeam: headerWorkTeam, worker: headerWorker, + remark: editRemark, routing: editRouting || null, - items: editItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode, routing: i.routing || null })), + items: editItems.map(i => ({ + itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, + sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode, + routing: i.routing || null, + // 품목별 일정/설비/작업조/작업자 (다중값 쉼표 구분 — 등록 모달과 동일) + startDate: i.startDate || "", + endDate: i.endDate || "", + equipmentIds: (i.equipmentIds || []).join(","), + workTeams: (i.workTeams || []).join(","), + workers: (i.workers || []).join(","), + })), }; const r = await saveWorkInstruction(payload); if (r.success) { setIsEditModalOpen(false); fetchOrders(); alert("수정되었습니다."); } @@ -578,7 +723,7 @@ export default function WorkInstructionPage() { 0 && regCheckedIds.size === regSourceData.length} onCheckedChange={toggleRegAll} /> {regSourceType === "item" && <>품목코드품목명규격} {regSourceType === "order" && <>수주번호품번품목명규격수량납기일} - {regSourceType === "production" && <>계획번호품번품목명계획수량시작일완료일설비} + {regSourceType === "production" && <>계획번호품번품목명계획수량적용수량잔량시작일완료일설비} @@ -590,7 +735,7 @@ export default function WorkInstructionPage() { e.stopPropagation()}> toggleRegItem(id)} /> {regSourceType === "item" && <>{item.item_code}{item.item_name}{item.spec || "-"}} {regSourceType === "order" && <>{item.order_no}{item.item_code}{item.item_name}{item.spec || "-"}{Number(item.qty || 0).toLocaleString()}{item.due_date || "-"}} - {regSourceType === "production" && <>{item.plan_no}{item.item_code}{item.item_name}{Number(item.plan_qty || 0).toLocaleString()}{item.start_date ? String(item.start_date).split("T")[0] : "-"}{item.end_date ? String(item.end_date).split("T")[0] : "-"}{item.equipment_name || "-"}} + {regSourceType === "production" && <>{item.plan_no}{item.item_code}{item.item_name}{Number(item.plan_qty || 0).toLocaleString()}{Number(item.applied_qty || 0).toLocaleString()}{Number(item.remain_qty ?? item.plan_qty ?? 0).toLocaleString()}{item.start_date ? String(item.start_date).split("T")[0] : "-"}{item.end_date ? String(item.end_date).split("T")[0] : "-"}{item.equipment_name || "-"}} ); })} @@ -619,7 +764,7 @@ export default function WorkInstructionPage() { {/* ── 2단계: 확인 모달 ── */} - + 작업지시 적용 확인 기본 정보를 입력하고 '최종 적용' 버튼을 눌러주세요. @@ -628,38 +773,33 @@ export default function WorkInstructionPage() {

작업지시 기본 정보

-
+

시작일·완료예정일·설비·작업조·작업자는 아래 품목별로 지정해주세요.

+
-
setConfirmStartDate(e.target.value)} className="h-9" />
-
setConfirmEndDate(e.target.value)} className="h-9" />
-
- -
-
- -
-
- -

품목 목록

- +
순번 품목코드 - 품목명 + 품목명 규격 - 수량 - 라우팅 - 비고 + 수량 + 라우팅 + 시작일 + 완료예정일 + 설비 + 작업조 + 작업자 + 비고 @@ -668,7 +808,7 @@ export default function WorkInstructionPage() { {idx + 1} {item.itemCode} - {item.itemName || item.itemCode} + {item.itemName || item.itemCode} {item.spec || "-"} setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /> @@ -690,6 +830,40 @@ export default function WorkInstructionPage() { + + setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, startDate: e.target.value } : it))} /> + + + setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, endDate: e.target.value } : it))} /> + + + ({ value: eq.id, label: eq.equipment_name || eq.equipment_code, sub: eq.equipment_code }))} + value={item.equipmentIds || []} + onChange={next => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, equipmentIds: next } : it))} + placeholder="설비 선택" + searchable + emptyMessage="설비가 없어요" + /> + + + setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, workTeams: next } : it))} + placeholder="작업조 선택" + /> + + + ({ value: emp.user_id, label: emp.user_name, sub: emp.dept_name || undefined }))} + value={item.workers || []} + onChange={next => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, workers: next } : it))} + placeholder="작업자 선택" + searchable + emptyMessage="사원을 찾을 수 없어요" + /> + setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /> @@ -710,7 +884,7 @@ export default function WorkInstructionPage() { {/* ── 수정 모달 ── */} { if (!v && wsModalOpen) return; setIsEditModalOpen(v); }}> - + {`작업지시 관리 - ${editOrder?.work_instruction_no || ""}`} 품목을 추가/삭제하고 정보를 수정해주세요. @@ -719,48 +893,47 @@ export default function WorkInstructionPage() {

기본 정보

-
+

시작일·완료예정일·설비·작업조·작업자는 아래 품목별로 지정해주세요.

+
-
setEditStartDate(e.target.value)} className="h-9" />
-
setEditEndDate(e.target.value)} className="h-9" />
-
-
-
- -
-
setEditRemark(e.target.value)} className="h-9" placeholder="비고를 입력해주세요" />
+
setEditRemark(e.target.value)} className="h-9" placeholder="비고를 입력해주세요" />
- {/* 품목 테이블 — 라우팅/공정작업기준을 품목별로 표시 */} + {/* 품목 테이블 — 품목별 일정/설비/작업조/작업자 + 라우팅/공정작업기준 */}
작업지시 항목 {editItems.length}건
-
+
순번 품목코드 - 품목명 + 품목명 규격 - 수량 - 라우팅 - 공정작업기준 - 비고 + 수량 + 라우팅 + 공정작업기준 + 시작일 + 완료예정일 + 설비 + 작업조 + 작업자 + 비고 {editItems.length === 0 ? ( - 등록된 품목이 없어요 + 등록된 품목이 없어요 ) : editItems.map((item, idx) => ( {idx + 1} {item.itemCode} - {item.itemName || "-"} + {item.itemName || "-"} {item.spec || "-"} setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /> @@ -803,6 +976,40 @@ export default function WorkInstructionPage() { 수정 + + setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, startDate: e.target.value } : it))} /> + + + setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, endDate: e.target.value } : it))} /> + + + ({ value: eq.id, label: eq.equipment_name || eq.equipment_code, sub: eq.equipment_code }))} + value={item.equipmentIds || []} + onChange={next => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, equipmentIds: next } : it))} + placeholder="설비 선택" + searchable + emptyMessage="설비가 없어요" + /> + + + setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, workTeams: next } : it))} + placeholder="작업조 선택" + /> + + + ({ value: emp.user_id, label: emp.user_name, sub: emp.dept_name || undefined }))} + value={item.workers || []} + onChange={next => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, workers: next } : it))} + placeholder="작업자 선택" + searchable + emptyMessage="사원을 찾을 수 없어요" + /> + setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /> diff --git a/frontend/app/(main)/COMPANY_7/purchase/purchase-item/page.tsx b/frontend/app/(main)/COMPANY_7/purchase/purchase-item/page.tsx index 77f32fd8..e5cda86f 100644 --- a/frontend/app/(main)/COMPANY_7/purchase/purchase-item/page.tsx +++ b/frontend/app/(main)/COMPANY_7/purchase/purchase-item/page.tsx @@ -329,6 +329,11 @@ export default function PurchaseItemPage() { // 좌측: 품목 조회 const fetchItems = useCallback(async () => { + // 카테고리 로드 완료 전엔 대기 — 먼저 나간 unfiltered 요청이 나중에 도착해 + // filtered 결과를 덮어쓰는 race condition 방지 + if (!categoryOptions["division"]?.length) { + return; + } setItemLoading(true); try { const filters: { columnName: string; operator: string; value: any }[] = []; diff --git a/frontend/app/(main)/COMPANY_7/quality/item-inspection/page.tsx b/frontend/app/(main)/COMPANY_7/quality/item-inspection/page.tsx index 6b0700c8..fb5cc751 100644 --- a/frontend/app/(main)/COMPANY_7/quality/item-inspection/page.tsx +++ b/frontend/app/(main)/COMPANY_7/quality/item-inspection/page.tsx @@ -284,6 +284,11 @@ export default function ItemInspectionInfoPage() { loadProcessOptions(item.code); }; + // 복사 모달: 편집 가능한 기준 데이터 상태 (등록/수정 폼과 평행 구조) + const [copyForm, setCopyForm] = useState>({}); + const [copyInspectionRows, setCopyInspectionRows] = useState>({}); + const [copyCollapsedTypes, setCopyCollapsedTypes] = useState>({}); + /* ═══════════════════ 복사 모달 (기준 품목 검사정보 → 다른 품목들) ═══════════════════ */ const [copyModalOpen, setCopyModalOpen] = useState(false); const [copySearchKeyword, setCopySearchKeyword] = useState(""); @@ -325,11 +330,63 @@ export default function ItemInspectionInfoPage() { setCopyTotal(resData?.total || resData?.totalCount || rows.length); } catch { /* skip */ } finally { setCopySearchLoading(false); } }; - const openCopyModal = () => { + const openCopyModal = async () => { if (!selectedItemCode) { toast.error("복사 기준 품목을 먼저 선택해주세요"); return; } const srcGroup = groupedData.find(g => g.item_code === selectedItemCode); if (!srcGroup || srcGroup.rows.length === 0) { toast.error("선택한 품목에 복사할 검사정보가 없어요"); return; } setCopySearchKeyword(""); setCopyPage(1); setCopyCheckedIds([]); + + // 기준 품목 데이터를 편집용 상태로 복제 (openEdit과 동일한 변환 로직) + const baseRow = srcGroup.rows[0]; + try { + const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, { + page: 1, size: 0, + dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: selectedItemCode }] }, + autoFilter: true, + }); + const allRows = res.data?.data?.data || res.data?.data?.rows || []; + const rowMap: Record = {}; + const typeFlags: Record = {}; + for (const r of allRows) { + const inspType = r.inspection_type || ""; + const matched = INSPECTION_TYPES.find(t => + t.matchLabels.some(ml => inspType.includes(ml)) || + inspTypeCatOptions.some(cat => inspType.includes(cat.code) && t.matchLabels.some(ml => cat.label.includes(ml))) + ); + const typeKey = matched?.key || ""; + if (!typeKey) continue; + typeFlags[typeKey] = true; + if (!rowMap[typeKey]) rowMap[typeKey] = []; + const mCode = r.inspection_method || ""; + const mLabel = inspMethodCatOptions.find(o => o.code === mCode)?.label || mCode; + const inspOpt = inspOptions.find(o => o.code === r.inspection_standard_id); + const jcCode = inspOpt?.judgment_criteria || ""; + const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode; + const unitCode = inspOpt?.unit || ""; + const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode; + rowMap[typeKey].push({ + id: crypto.randomUUID(), // 복사본은 새 id 부여 (원본과 분리) + inspection_standard_id: r.inspection_standard_id || "", + inspection_detail: r.inspection_item_name || r.inspection_standard || "", + inspection_method: mLabel, + apply_process: r.apply_process || "", + classification: r.classification || "", + acceptance_criteria: r.pass_criteria || "", + is_required: r.is_required === "true" || r.is_required === true, + judgment_criteria: jcLabel, + selection_options: inspOpt?.selection_options || "", + unit: unitLabel, + }); + } + setCopyInspectionRows(rowMap); + setCopyForm({ ...baseRow, ...typeFlags }); + setCopyCollapsedTypes({}); + } catch { + setCopyInspectionRows({}); + setCopyForm({ ...baseRow }); + setCopyCollapsedTypes({}); + } + setCopyModalOpen(true); searchCopyTargets(1); }; @@ -340,10 +397,18 @@ export default function ItemInspectionInfoPage() { const handleCopy = async () => { if (!selectedItemCode) { toast.error("복사 기준 품목이 없어요"); return; } if (copyCheckedIds.length === 0) { toast.error("붙여넣을 품목을 선택해주세요"); return; } - const sourceGroup = groupedData.find(g => g.item_code === selectedItemCode); - if (!sourceGroup || sourceGroup.rows.length === 0) { toast.error("복사할 검사정보가 없어요"); return; } + + // 편집된 rows를 평탄화 (선택된 검사유형의 rows만) + const enabledTypes = INSPECTION_TYPES.filter(t => !!copyForm[t.key]); + const flatRows: Array<{ row: InspectionRow; typeLabel: string }> = []; + for (const t of enabledTypes) { + const rows = copyInspectionRows[t.key] || []; + for (const r of rows) flatRows.push({ row: r, typeLabel: t.label }); + } + if (flatRows.length === 0) { toast.error("복사할 검사항목이 없어요"); return; } + const ok = await confirm( - `선택한 ${copyCheckedIds.length}개 품목에 검사정보를 복사할까요?`, + `선택한 ${copyCheckedIds.length}개 품목에 편집된 검사정보(${flatRows.length}개 행)를 복사할까요?`, { description: "대상 품목의 기존 검사정보는 삭제 후 교체됩니다.", variant: "info", confirmText: "복사" } ); if (!ok) return; @@ -364,13 +429,26 @@ export default function ItemInspectionInfoPage() { if (existing.length > 0) { await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: existing.map((r: any) => ({ id: r.id })) }); } - for (const r of sourceGroup.rows) { - const { id: _id, created_at: _c, updated_at: _u, ...rest } = r; + let orderSeq = 0; + for (const { row: r, typeLabel } of flatRows) { + orderSeq += 1; await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, { - ...rest, id: crypto.randomUUID(), item_code: targetCode, item_name: targetName, + inspection_type: typeLabel, + inspection_standard_id: r.inspection_standard_id || "", + inspection_item_name: r.inspection_detail || "", + inspection_method: r.inspection_method || "", + apply_process: r.apply_process || "", + classification: r.classification || "", + pass_criteria: r.acceptance_criteria || "", + is_required: r.is_required ? "true" : "false", + is_active: copyForm.is_active || "사용", + manager: copyForm.manager || "", + manager_id: copyForm.manager_id || "", + memo: copyForm.remarks || "", + sort_order: String(orderSeq).padStart(4, "0"), }); } setCopyProgress({ current: i + 1, total: copyCheckedIds.length }); @@ -579,6 +657,46 @@ export default function ItemInspectionInfoPage() { }; const toggleCollapse = (typeKey: string) => { setCollapsedTypes(prev => ({ ...prev, [typeKey]: !prev[typeKey] })); }; + /* ═══════════════════ 복사 모달용 검사항목 행 관리 (등록 폼과 평행) ═══════════════════ */ + const addCopyInspRow = (typeKey: string) => { + setCopyInspectionRows(prev => ({ + ...prev, + [typeKey]: [...(prev[typeKey] || []), { id: crypto.randomUUID(), inspection_standard_id: "", inspection_detail: "", inspection_method: "", apply_process: "", classification: "", acceptance_criteria: "", is_required: false }], + })); + }; + const removeCopyInspRow = (typeKey: string, rowId: string) => { + setCopyInspectionRows(prev => ({ ...prev, [typeKey]: (prev[typeKey] || []).filter(r => r.id !== rowId) })); + }; + const updateCopyInspRow = (typeKey: string, rowId: string, field: string, value: any) => { + setCopyInspectionRows(prev => ({ + ...prev, + [typeKey]: (prev[typeKey] || []).map(r => { + if (r.id !== rowId) return r; + if (field === "inspection_standard_id") { + const opt = inspOptions.find(o => o.code === value); + const methodCode = opt?.method || ""; + const methodLabel = inspMethodCatOptions.find(o => o.code === methodCode)?.label || methodCode; + const jcCode = opt?.judgment_criteria || ""; + const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode; + const unitCode = opt?.unit || ""; + const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode; + return { + ...r, + inspection_standard_id: value, + inspection_detail: opt?.detail || "", + inspection_method: methodLabel, + judgment_criteria: jcLabel, + selection_options: opt?.selection_options || "", + unit: unitLabel, + acceptance_criteria: "", + }; + } + return { ...r, [field]: value }; + }), + })); + }; + const toggleCopyCollapse = (typeKey: string) => { setCopyCollapsedTypes(prev => ({ ...prev, [typeKey]: !prev[typeKey] })); }; + const handleSave = async () => { if (!form.item_code) { toast.error("품목코드는 필수예요"); return; } setSaving(true); @@ -1071,7 +1189,16 @@ export default function ItemInspectionInfoPage() { return jcLabel ? {jcLabel} : "-"; })()} - {row.pass_criteria || "-"} + {(() => { + const pc = row.pass_criteria; + if (!pc) return "-"; + if (pc.includes("|")) { + const [s, t] = pc.split("|"); + if (!t || !t.trim()) return s || "-"; + return `${s} ± ${t}`; + } + return pc; + })()} {row.is_required === "true" || row.is_required === true ? ( 필수 @@ -1369,20 +1496,20 @@ export default function ItemInspectionInfoPage() { - {/* ═══════════════════ 복사 모달 ═══════════════════ */} + {/* ═══════════════════ 복사 모달 (2단 분할: 좌 대상 / 우 편집) ═══════════════════ */} { if (!copying) setCopyModalOpen(v); }}> e.preventDefault()} onInteractOutside={(e) => e.preventDefault()} onEscapeKeyDown={(e) => { if (copying) e.preventDefault(); }} > - + {copying ? "검사정보 복사 중..." : "검사정보 복사"} {selectedGroup?.item_name || "-"} ({selectedItemCode}) - {copying ? " 의 검사정보를 복사하고 있습니다" : " 의 검사정보를 아래 선택한 품목들에 복사합니다"} + {copying ? " 의 검사정보를 복사하고 있습니다" : " 의 검사정보를 편집해서 선택한 품목들에 복사합니다. 기준 품목은 변경되지 않아요"} {copying ? ( @@ -1406,81 +1533,229 @@ export default function ItemInspectionInfoPage() {

- ) : (<> -
- setCopySearchKeyword(e.target.value)} - onKeyDown={(e) => { if (e.key === "Enter") handleCopySearch(); }} /> - -
-
-
- - - - 0 && copyFilteredItems.every(i => copyCheckedIds.includes(i.code))} - onCheckedChange={(v) => { - if (v) setCopyCheckedIds(prev => Array.from(new Set([...prev, ...copyFilteredItems.map(i => i.code)]))); - else setCopyCheckedIds(prev => prev.filter(c => !copyFilteredItems.some(i => i.code === c))); - }} - /> - - 품목코드 - 품목명 - 품목유형 - 단위 - - - - {copyFilteredItems.length === 0 ? ( - - {copySearchLoading ? "검색 중..." : "검색 결과가 없어요"} - - ) : copyFilteredItems.map((item) => ( - toggleCopyChecked(item.code)}> - e.stopPropagation()}> - toggleCopyChecked(item.code)} /> - - {item.code} - {item.name} - {item.item_type} - {item.unit} - - ))} - -
-
-
- - 전체 {copyTotal.toLocaleString()}건 - {copyCheckedIds.length > 0 && 선택 {copyCheckedIds.length}} - -
- - - {Array.from({ length: Math.min(5, copyTotalPages) }, (_, i) => { - const start = Math.max(1, Math.min(copyPage - 2, copyTotalPages - 4)); - const p = start + i; - if (p > copyTotalPages) return null; - return ( - - ); - })} - - + ) : ( +
+ {/* 좌측: 복사 대상 품목 선택 */} +
+
+ 복사 대상 품목 선택 + {copyCheckedIds.length > 0 && 선택 {copyCheckedIds.length}건} +
+
+ setCopySearchKeyword(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") handleCopySearch(); }} /> + +
+
+ + + + + 0 && copyFilteredItems.every(i => copyCheckedIds.includes(i.code))} + onCheckedChange={(v) => { + if (v) setCopyCheckedIds(prev => Array.from(new Set([...prev, ...copyFilteredItems.map(i => i.code)]))); + else setCopyCheckedIds(prev => prev.filter(c => !copyFilteredItems.some(i => i.code === c))); + }} + /> + + 품목코드 + 품목명 + + + + {copyFilteredItems.length === 0 ? ( + + {copySearchLoading ? "검색 중..." : "검색 결과가 없어요"} + + ) : copyFilteredItems.map((item) => ( + toggleCopyChecked(item.code)}> + e.stopPropagation()}> + toggleCopyChecked(item.code)} /> + + {item.code} + {item.name} + + ))} + +
+
+
+ 전체 {copyTotal.toLocaleString()} +
+ + + {copyPage}/{copyTotalPages} + + +
+
+
+ + {/* 우측: 편집 폼 (등록/수정 폼과 동일 구조) */} +
+
+ 복사할 검사정보 편집 (기준: {selectedItemCode}) +
+
+
+
+ + +
+
+ + +
+
+ +
+

검사유형 선택

+
+ {INSPECTION_TYPES.map(({ key, label }) => ( +
+ setCopyForm(p => ({ ...p, [key]: !!v }))} /> + +
+ ))} +
+
+ + {INSPECTION_TYPES.filter(t => !!copyForm[t.key]).map(({ key, label }) => ( +
+ + {!copyCollapsedTypes[key] && ( +
+
+ 검사항목 목록 + +
+
+ + + + 검사기준 선택 + 검사기준 상세 + 검사방법 + 적용공정 + 구분 + 판단기준 + 합격기준 + 필수 + 단위 + + + + + {(!copyInspectionRows[key] || copyInspectionRows[key].length === 0) ? ( + 항목추가 버튼으로 검사항목을 추가하세요 + ) : copyInspectionRows[key].map((row) => ( + + + + + + + + {processOptions.length > 0 ? ( + + ) : ( + updateCopyInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" /> + )} + + + updateCopyInspRow(key, row.id, "classification", e.target.value)} placeholder="구분" /> + + + {row.judgment_criteria ? {row.judgment_criteria} : -} + + + {row.judgment_criteria === "선택형" && row.selection_options ? ( + + ) : row.judgment_criteria === "O/X" ? ( + + ) : row.judgment_criteria === "수치(범위)" ? ( +
+ { + const parts = (row.acceptance_criteria || "||").split("|"); + parts[0] = e.target.value; + updateCopyInspRow(key, row.id, "acceptance_criteria", parts.join("|")); + }} placeholder="기준" disabled={!row.inspection_standard_id} /> + ± + { + const parts = (row.acceptance_criteria || "||").split("|"); + parts[1] = e.target.value; + updateCopyInspRow(key, row.id, "acceptance_criteria", parts.join("|")); + }} placeholder="±" disabled={!row.inspection_standard_id} /> +
+ ) : ( + updateCopyInspRow(key, row.id, "acceptance_criteria", e.target.value)} placeholder="합격기준" disabled={!row.inspection_standard_id} /> + )} +
+ updateCopyInspRow(key, row.id, "is_required", !!v)} /> + {row.unit || "-"} + + + +
+ ))} +
+
+
+
+ )} +
+ ))} +
+
-
- )} + )}
+ {/* 위치명 형식 — 구역/열/단 뒤에 붙일 표현만 자유 입력 */} +
+ +
+ A + setRackZoneLabel(e.target.value)} + placeholder="구역" + className="h-8 w-20 text-xs" + /> + - 01 + setRackRowLabel(e.target.value)} + placeholder="열" + className="h-8 w-20 text-xs" + /> + - 1 + setRackLevelLabel(e.target.value)} + placeholder="단" + className="h-8 w-20 text-xs" + /> +
+

+ 예시: A{rackZoneLabel}-01{rackRowLabel}-1{rackLevelLabel} + {" "}— 구역/열/단 번호는 자동 계산되고, 뒤에 붙는 명칭만 수정할 수 있습니다. +

+
+ {/* 등록 미리보기 */}
diff --git a/frontend/app/(main)/COMPANY_8/outsourcing/subcontractor-item/page.tsx b/frontend/app/(main)/COMPANY_8/outsourcing/subcontractor-item/page.tsx index 8f1802c4..d16596ed 100644 --- a/frontend/app/(main)/COMPANY_8/outsourcing/subcontractor-item/page.tsx +++ b/frontend/app/(main)/COMPANY_8/outsourcing/subcontractor-item/page.tsx @@ -98,12 +98,26 @@ export default function SubcontractorItemPage() { } return result; }; - for (const col of ["material", "division", "type", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"]) { + for (const col of ["material", "division", "type", "status", "unit", "inventory_unit", "currency_code", "user_type01", "user_type02"]) { try { const res = await apiClient.get(`/table-categories/${ITEM_TABLE}/${col}/values`); if (res.data?.success) optMap[col] = flatten(res.data.data || []); } catch { /* skip */ } } + // 외주사관리에서 사용하는 subcontractor_item_prices.currency_code도 병합 + try { + const res = await apiClient.get(`/table-categories/subcontractor_item_prices/currency_code/values`); + if (res.data?.success) { + const extra = flatten(res.data.data || []); + const seen = new Set((optMap["currency_code"] || []).map((o) => o.code)); + for (const e of extra) { + if (!seen.has(e.code)) { + (optMap["currency_code"] ||= []).push(e); + seen.add(e.code); + } + } + } + } catch { /* skip */ } // 외주업체 거래유형 (subcontractor_mng.division) try { const res = await apiClient.get(`/table-categories/${SUBCONTRACTOR_TABLE}/division/values`); @@ -124,10 +138,10 @@ export default function SubcontractorItemPage() { item_number: { width: "w-[110px]" }, item_name: { minWidth: "min-w-[130px]", render: (v) => v || "-" }, size: { width: "w-[90px]", render: (v) => v || "-" }, - unit: { width: "w-[60px]", render: (v) => v || "-" }, + unit: { width: "w-[60px]", render: (v) => resolve("unit", v) || "-" }, standard_price: { width: "w-[90px]", align: "right", formatNumber: true }, selling_price: { width: "w-[90px]", align: "right", formatNumber: true }, - currency_code: { width: "w-[50px]", render: (v) => v || "-" }, + currency_code: { width: "w-[50px]", render: (v) => resolve("currency_code", v) || "-" }, status: { width: "w-[60px]", render: (v) => v || "-" }, }; return ts.visibleColumns.map((col) => ({ @@ -135,7 +149,8 @@ export default function SubcontractorItemPage() { label: col.label, ...colProps[col.key], })); - }, [ts.visibleColumns]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ts.visibleColumns, categoryOptions]); // 좌측: 품목 조회 (division이 "외주관리"인 품목만 필터링) const outsourcingDivisionCode = categoryOptions["division"]?.find( @@ -164,8 +179,8 @@ export default function SubcontractorItemPage() { for (const col of CATS) { if (converted[col]) converted[col] = resolve(col, converted[col]); } - // item_info의 inventory_unit을 단위 표시용 unit에 매핑 - converted.unit = converted.inventory_unit || converted.unit || ""; + // "단위" 컬럼은 재고단위(inventory_unit)만 사용 — unit 폴백 제거 + converted.unit = converted.inventory_unit || ""; return converted; }); setItems(data); @@ -212,11 +227,35 @@ export default function SubcontractorItemPage() { } catch { /* skip */ } } - setSubcontractorItems(mappings.map((m: any) => ({ - ...m, - subcontractor_code: m.subcontractor_id, - subcontractor_name: subMap[m.subcontractor_id]?.subcontractor_name || "", - }))); + // 외주사관리에서 입력된 최신 단가(subcontractor_item_prices) 조회 → subcontractor_id 별 최신 1건 + const priceMap: Record = {}; + try { + const priceRes = await apiClient.post(`/table-management/tables/subcontractor_item_prices/data`, { + page: 1, size: 0, + dataFilter: { enabled: true, filters: [{ columnName: "item_id", operator: "equals", value: itemKey }] }, + autoFilter: true, + }); + const prices = priceRes.data?.data?.data || priceRes.data?.data?.rows || []; + for (const p of prices) { + const key = p.subcontractor_id; + if (!key) continue; + if (!priceMap[key] || (p.start_date && (!priceMap[key].start_date || p.start_date > priceMap[key].start_date))) { + priceMap[key] = p; + } + } + } catch { /* skip */ } + + setSubcontractorItems(mappings.map((m: any) => { + const price = priceMap[m.subcontractor_id] || {}; + return { + ...m, + subcontractor_code: m.subcontractor_id, + subcontractor_name: subMap[m.subcontractor_id]?.subcontractor_name || "", + base_price: price.base_price ?? m.base_price, + calculated_price: price.calculated_price ?? price.unit_price ?? m.calculated_price, + currency_code: resolve("currency_code", price.currency_code ?? m.currency_code), + }; + })); } catch (err) { console.error("외주업체 조회 실패:", err); } finally { @@ -224,7 +263,8 @@ export default function SubcontractorItemPage() { } }; fetchSubcontractorItems(); - }, [selectedItem?.item_number]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedItem?.item_number, categoryOptions]); // 외주업체 검색 const searchSubcontractors = async () => { diff --git a/frontend/app/(main)/COMPANY_8/production/bom/page.tsx b/frontend/app/(main)/COMPANY_8/production/bom/page.tsx index 84b7afbb..01e7ee14 100644 --- a/frontend/app/(main)/COMPANY_8/production/bom/page.tsx +++ b/frontend/app/(main)/COMPANY_8/production/bom/page.tsx @@ -59,6 +59,7 @@ import { Settings2, Save, Package, + Pencil, } from "lucide-react"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; @@ -355,7 +356,13 @@ export default function BomManagementPage() { sort: { columnName: "created_at", order: "desc" }, }); - const rows = res.data?.data?.data || res.data?.data?.rows || []; + // DB 컬럼이 item_type/expired_date → 프론트 내부에서는 bom_type/expiry_date로 통일 + const rawRows = res.data?.data?.data || res.data?.data?.rows || []; + const rows = rawRows.map((r: any) => ({ + ...r, + bom_type: r.bom_type ?? r.item_type, + expiry_date: r.expiry_date ?? r.expired_date, + })); setBomList(rows); setTotalCount(rows.length); } catch (err: any) { @@ -452,9 +459,16 @@ export default function BomManagementPage() { const fetchBomDetail = useCallback(async (bomId: string) => { setDetailLoading(true); try { - // 헤더 조회 + // 헤더 조회 (DB 컬럼 item_type/expired_date → bom_type/expiry_date로 매핑) const headerRes = await apiClient.get(`/bom/${bomId}/header`); - const header = headerRes.data?.data || headerRes.data; + const rawHeader = headerRes.data?.data || headerRes.data; + const header = rawHeader + ? { + ...rawHeader, + bom_type: rawHeader.bom_type ?? rawHeader.item_type, + expiry_date: rawHeader.expiry_date ?? rawHeader.expired_date, + } + : null; setBomHeader(header); setCurrentVersionId(header?.current_version_id || null); @@ -1100,17 +1114,18 @@ export default function BomManagementPage() { setSaving(true); try { + // DB 실제 컬럼: item_type / expired_date (프론트 내부 bom_type/expiry_date와 다름) const bomFields: Record = { item_id: masterForm.item_id, item_code: masterForm.item_code, item_name: masterForm.item_name, - bom_type: masterForm.bom_type, + item_type: masterForm.bom_type, base_qty: masterForm.base_qty || "1", unit: masterForm.unit || "", version: masterForm.version || "1.0", status: masterForm.status || "draft", effective_date: masterForm.effective_date || null, - expiry_date: masterForm.expiry_date || null, + expired_date: masterForm.expiry_date || null, remark: masterForm.remark || "", writer: user?.userId || "", company_code: user?.company_code || "", @@ -1482,6 +1497,21 @@ export default function BomManagementPage() { 등록 +
{showOutsourceField && (
- - + + + + + + +
+ {subcontractorOptions.length === 0 ? ( +
등록된 외주업체가 없어요
+ ) : subcontractorOptions.map((s) => { + const checked = formOutsources.includes(s.id); + return ( + + ); + })} +
+
+
)}
diff --git a/frontend/app/(main)/COMPANY_8/production/work-instruction/page.tsx b/frontend/app/(main)/COMPANY_8/production/work-instruction/page.tsx index ef7c2a39..9a6fc954 100644 --- a/frontend/app/(main)/COMPANY_8/production/work-instruction/page.tsx +++ b/frontend/app/(main)/COMPANY_8/production/work-instruction/page.tsx @@ -202,7 +202,13 @@ export default function WorkInstructionPage() { if (!regCheckedIds.has(getRegId(item))) continue; if (regSourceType === "item") items.push({ itemCode: item.item_code, itemName: item.item_name || "", spec: item.spec || "", qty: 1, remark: "", sourceType: "item", sourceTable: "item_info", sourceId: item.item_code }); else if (regSourceType === "order") items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: item.spec || "", qty: Number(item.qty || 1), remark: "", sourceType: "order", sourceTable: "sales_order_detail", sourceId: item.id }); - else items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: "", qty: Number(item.plan_qty || 1), remark: "", sourceType: "production", sourceTable: "production_plan_mng", sourceId: item.id }); + else { + // 생산계획: 잔량(remain_qty)이 있으면 잔량 기반으로 기본 수량 제안 (0/음수 허용 — 계획 초과 가능) + const defaultQty = item.remain_qty !== undefined && item.remain_qty !== null + ? Number(item.remain_qty) + : Number(item.plan_qty || 1); + items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: "", qty: defaultQty, remark: "", sourceType: "production", sourceTable: "production_plan_mng", sourceId: item.id }); + } } // 동일품목 합산 @@ -578,7 +584,7 @@ export default function WorkInstructionPage() { 0 && regCheckedIds.size === regSourceData.length} onCheckedChange={toggleRegAll} /> {regSourceType === "item" && <>품목코드품목명규격} {regSourceType === "order" && <>수주번호품번품목명규격수량납기일} - {regSourceType === "production" && <>계획번호품번품목명계획수량시작일완료일설비} + {regSourceType === "production" && <>계획번호품번품목명계획수량적용수량잔량시작일완료일설비} @@ -590,7 +596,7 @@ export default function WorkInstructionPage() { e.stopPropagation()}> toggleRegItem(id)} /> {regSourceType === "item" && <>{item.item_code}{item.item_name}{item.spec || "-"}} {regSourceType === "order" && <>{item.order_no}{item.item_code}{item.item_name}{item.spec || "-"}{Number(item.qty || 0).toLocaleString()}{item.due_date || "-"}} - {regSourceType === "production" && <>{item.plan_no}{item.item_code}{item.item_name}{Number(item.plan_qty || 0).toLocaleString()}{item.start_date ? String(item.start_date).split("T")[0] : "-"}{item.end_date ? String(item.end_date).split("T")[0] : "-"}{item.equipment_name || "-"}} + {regSourceType === "production" && <>{item.plan_no}{item.item_code}{item.item_name}{Number(item.plan_qty || 0).toLocaleString()}{Number(item.applied_qty || 0).toLocaleString()}{Number(item.remain_qty ?? item.plan_qty ?? 0).toLocaleString()}{item.start_date ? String(item.start_date).split("T")[0] : "-"}{item.end_date ? String(item.end_date).split("T")[0] : "-"}{item.equipment_name || "-"}} ); })} diff --git a/frontend/app/(main)/COMPANY_8/purchase/purchase-item/page.tsx b/frontend/app/(main)/COMPANY_8/purchase/purchase-item/page.tsx index 42db2edf..72f770b7 100644 --- a/frontend/app/(main)/COMPANY_8/purchase/purchase-item/page.tsx +++ b/frontend/app/(main)/COMPANY_8/purchase/purchase-item/page.tsx @@ -312,6 +312,11 @@ export default function PurchaseItemPage() { // 좌측: 품목 조회 const fetchItems = useCallback(async () => { + // 카테고리 로드 완료 전엔 대기 — 먼저 나간 unfiltered 요청이 나중에 도착해 + // filtered 결과를 덮어쓰는 race condition 방지 + if (!categoryOptions["division"]?.length) { + return; + } setItemLoading(true); try { const filters: { columnName: string; operator: string; value: any }[] = []; diff --git a/frontend/app/(main)/COMPANY_8/quality/inspection/page.tsx b/frontend/app/(main)/COMPANY_8/quality/inspection/page.tsx index 7c529989..8b93fa89 100644 --- a/frontend/app/(main)/COMPANY_8/quality/inspection/page.tsx +++ b/frontend/app/(main)/COMPANY_8/quality/inspection/page.tsx @@ -52,6 +52,7 @@ const INSPECTION_COLUMNS = [ { key: "inspection_code", label: "검사코드" }, { key: "inspection_type", label: "검사유형" }, { key: "inspection_criteria", label: "검사기준" }, + { key: "criteria_detail", label: "기준상세" }, { key: "inspection_item", label: "검사항목" }, { key: "inspection_method", label: "검사방법" }, { key: "judgment_criteria", label: "판단기준" }, diff --git a/frontend/app/(main)/COMPANY_8/quality/item-inspection/page.tsx b/frontend/app/(main)/COMPANY_8/quality/item-inspection/page.tsx index 15118ffc..2c71ed02 100644 --- a/frontend/app/(main)/COMPANY_8/quality/item-inspection/page.tsx +++ b/frontend/app/(main)/COMPANY_8/quality/item-inspection/page.tsx @@ -43,6 +43,7 @@ type InspectionRow = { inspection_detail: string; inspection_method: string; apply_process: string; + classification: string; acceptance_criteria: string; is_required: boolean; judgment_criteria?: string; // 판단기준 라벨 (수치(범위)/텍스트입력/O·X/선택형) @@ -253,6 +254,11 @@ export default function ItemInspectionInfoPage() { loadProcessOptions(item.code); }; + // 복사 모달: 편집 가능한 기준 데이터 상태 (등록/수정 폼과 평행 구조) + const [copyForm, setCopyForm] = useState>({}); + const [copyInspectionRows, setCopyInspectionRows] = useState>({}); + const [copyCollapsedTypes, setCopyCollapsedTypes] = useState>({}); + /* ═══════════════════ 복사 모달 (기준 품목 검사정보 → 다른 품목들) ═══════════════════ */ const [copyModalOpen, setCopyModalOpen] = useState(false); const [copySearchKeyword, setCopySearchKeyword] = useState(""); @@ -294,11 +300,63 @@ export default function ItemInspectionInfoPage() { setCopyTotal(resData?.total || resData?.totalCount || rows.length); } catch { /* skip */ } finally { setCopySearchLoading(false); } }; - const openCopyModal = () => { + const openCopyModal = async () => { if (!selectedItemCode) { toast.error("복사 기준 품목을 먼저 선택해주세요"); return; } const srcGroup = groupedData.find(g => g.item_code === selectedItemCode); if (!srcGroup || srcGroup.rows.length === 0) { toast.error("선택한 품목에 복사할 검사정보가 없어요"); return; } setCopySearchKeyword(""); setCopyPage(1); setCopyCheckedIds([]); + + // 기준 품목 데이터를 편집용 상태로 복제 (openEdit과 동일한 변환 로직) + const baseRow = srcGroup.rows[0]; + try { + const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, { + page: 1, size: 0, + dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: selectedItemCode }] }, + autoFilter: true, + }); + const allRows = res.data?.data?.data || res.data?.data?.rows || []; + const rowMap: Record = {}; + const typeFlags: Record = {}; + for (const r of allRows) { + const inspType = r.inspection_type || ""; + const matched = INSPECTION_TYPES.find(t => + t.matchLabels.some(ml => inspType.includes(ml)) || + inspTypeCatOptions.some(cat => inspType.includes(cat.code) && t.matchLabels.some(ml => cat.label.includes(ml))) + ); + const typeKey = matched?.key || ""; + if (!typeKey) continue; + typeFlags[typeKey] = true; + if (!rowMap[typeKey]) rowMap[typeKey] = []; + const mCode = r.inspection_method || ""; + const mLabel = inspMethodCatOptions.find(o => o.code === mCode)?.label || mCode; + const inspOpt = inspOptions.find(o => o.code === r.inspection_standard_id); + const jcCode = inspOpt?.judgment_criteria || ""; + const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode; + const unitCode = inspOpt?.unit || ""; + const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode; + rowMap[typeKey].push({ + id: crypto.randomUUID(), // 복사본은 새 id 부여 (원본과 분리) + inspection_standard_id: r.inspection_standard_id || "", + inspection_detail: r.inspection_item_name || r.inspection_standard || "", + inspection_method: mLabel, + apply_process: r.apply_process || "", + classification: r.classification || "", + acceptance_criteria: r.pass_criteria || "", + is_required: r.is_required === "true" || r.is_required === true, + judgment_criteria: jcLabel, + selection_options: inspOpt?.selection_options || "", + unit: unitLabel, + }); + } + setCopyInspectionRows(rowMap); + setCopyForm({ ...baseRow, ...typeFlags }); + setCopyCollapsedTypes({}); + } catch { + setCopyInspectionRows({}); + setCopyForm({ ...baseRow }); + setCopyCollapsedTypes({}); + } + setCopyModalOpen(true); searchCopyTargets(1); }; @@ -309,10 +367,18 @@ export default function ItemInspectionInfoPage() { const handleCopy = async () => { if (!selectedItemCode) { toast.error("복사 기준 품목이 없어요"); return; } if (copyCheckedIds.length === 0) { toast.error("붙여넣을 품목을 선택해주세요"); return; } - const sourceGroup = groupedData.find(g => g.item_code === selectedItemCode); - if (!sourceGroup || sourceGroup.rows.length === 0) { toast.error("복사할 검사정보가 없어요"); return; } + + // 편집된 rows를 평탄화 (선택된 검사유형의 rows만) + const enabledTypes = INSPECTION_TYPES.filter(t => !!copyForm[t.key]); + const flatRows: Array<{ row: InspectionRow; typeLabel: string }> = []; + for (const t of enabledTypes) { + const rows = copyInspectionRows[t.key] || []; + for (const r of rows) flatRows.push({ row: r, typeLabel: t.label }); + } + if (flatRows.length === 0) { toast.error("복사할 검사항목이 없어요"); return; } + const ok = await confirm( - `선택한 ${copyCheckedIds.length}개 품목에 검사정보를 복사할까요?`, + `선택한 ${copyCheckedIds.length}개 품목에 편집된 검사정보(${flatRows.length}개 행)를 복사할까요?`, { description: "대상 품목의 기존 검사정보는 삭제 후 교체됩니다.", variant: "info", confirmText: "복사" } ); if (!ok) return; @@ -333,13 +399,26 @@ export default function ItemInspectionInfoPage() { if (existing.length > 0) { await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: existing.map((r: any) => ({ id: r.id })) }); } - for (const r of sourceGroup.rows) { - const { id: _id, created_at: _c, updated_at: _u, ...rest } = r; + let orderSeq = 0; + for (const { row: r, typeLabel } of flatRows) { + orderSeq += 1; await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, { - ...rest, id: crypto.randomUUID(), item_code: targetCode, item_name: targetName, + inspection_type: typeLabel, + inspection_standard_id: r.inspection_standard_id || "", + inspection_item_name: r.inspection_detail || "", + inspection_method: r.inspection_method || "", + apply_process: r.apply_process || "", + classification: r.classification || "", + pass_criteria: r.acceptance_criteria || "", + is_required: r.is_required ? "true" : "false", + is_active: copyForm.is_active || "사용", + manager: copyForm.manager || "", + manager_id: copyForm.manager_id || "", + memo: copyForm.remarks || "", + sort_order: String(orderSeq).padStart(4, "0"), }); } setCopyProgress({ current: i + 1, total: copyCheckedIds.length }); @@ -402,7 +481,13 @@ export default function ItemInspectionInfoPage() { // 선택된 탭의 검사항목 행 const selectedTabRows = useMemo(() => { if (!selectedGroup || !selectedTypeTab) return []; - return selectedGroup.rows.filter((r: any) => r.inspection_type === selectedTypeTab && r.inspection_standard_id); + const filtered = selectedGroup.rows.filter((r: any) => r.inspection_type === selectedTypeTab && r.inspection_standard_id); + return [...filtered].sort((a: any, b: any) => { + const av = parseInt(String(a.sort_order || "9999"), 10); + const bv = parseInt(String(b.sort_order || "9999"), 10); + if (av === bv) return String(a.id).localeCompare(String(b.id)); + return av - bv; + }); }, [selectedGroup, selectedTypeTab]); // 검사기준 ID → 라벨 @@ -436,6 +521,13 @@ export default function ItemInspectionInfoPage() { autoFilter: true, }); const allRows = res.data?.data?.data || res.data?.data?.rows || []; + // sort_order 기준 오름차순 정렬 (varchar이므로 숫자 파싱 후 비교) + allRows.sort((a: any, b: any) => { + const av = parseInt(String(a.sort_order || "9999"), 10); + const bv = parseInt(String(b.sort_order || "9999"), 10); + if (av === bv) return String(a.id).localeCompare(String(b.id)); + return av - bv; + }); const rowMap: Record = {}; const typeFlags: Record = {}; @@ -462,7 +554,8 @@ export default function ItemInspectionInfoPage() { inspection_standard_id: r.inspection_standard_id || "", inspection_detail: r.inspection_item_name || r.inspection_standard || "", inspection_method: mLabel, - apply_process: "", + apply_process: r.apply_process || "", + classification: r.classification || "", acceptance_criteria: r.pass_criteria || "", is_required: r.is_required === "true" || r.is_required === true, judgment_criteria: jcLabel, @@ -480,7 +573,7 @@ export default function ItemInspectionInfoPage() { const addInspRow = (typeKey: string) => { setInspectionRows(prev => ({ ...prev, - [typeKey]: [...(prev[typeKey] || []), { id: crypto.randomUUID(), inspection_standard_id: "", inspection_detail: "", inspection_method: "", apply_process: "", acceptance_criteria: "", is_required: false }], + [typeKey]: [...(prev[typeKey] || []), { id: crypto.randomUUID(), inspection_standard_id: "", inspection_detail: "", inspection_method: "", apply_process: "", classification: "", acceptance_criteria: "", is_required: false }], })); }; const removeInspRow = (typeKey: string, rowId: string) => { @@ -525,6 +618,46 @@ export default function ItemInspectionInfoPage() { }; const toggleCollapse = (typeKey: string) => { setCollapsedTypes(prev => ({ ...prev, [typeKey]: !prev[typeKey] })); }; + /* ═══════════════════ 복사 모달용 검사항목 행 관리 (등록 폼과 평행) ═══════════════════ */ + const addCopyInspRow = (typeKey: string) => { + setCopyInspectionRows(prev => ({ + ...prev, + [typeKey]: [...(prev[typeKey] || []), { id: crypto.randomUUID(), inspection_standard_id: "", inspection_detail: "", inspection_method: "", apply_process: "", classification: "", acceptance_criteria: "", is_required: false }], + })); + }; + const removeCopyInspRow = (typeKey: string, rowId: string) => { + setCopyInspectionRows(prev => ({ ...prev, [typeKey]: (prev[typeKey] || []).filter(r => r.id !== rowId) })); + }; + const updateCopyInspRow = (typeKey: string, rowId: string, field: string, value: any) => { + setCopyInspectionRows(prev => ({ + ...prev, + [typeKey]: (prev[typeKey] || []).map(r => { + if (r.id !== rowId) return r; + if (field === "inspection_standard_id") { + const opt = inspOptions.find(o => o.code === value); + const methodCode = opt?.method || ""; + const methodLabel = inspMethodCatOptions.find(o => o.code === methodCode)?.label || methodCode; + const jcCode = opt?.judgment_criteria || ""; + const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode; + const unitCode = opt?.unit || ""; + const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode; + return { + ...r, + inspection_standard_id: value, + inspection_detail: opt?.detail || "", + inspection_method: methodLabel, + judgment_criteria: jcLabel, + selection_options: opt?.selection_options || "", + unit: unitLabel, + acceptance_criteria: "", + }; + } + return { ...r, [field]: value }; + }), + })); + }; + const toggleCopyCollapse = (typeKey: string) => { setCopyCollapsedTypes(prev => ({ ...prev, [typeKey]: !prev[typeKey] })); }; + const handleSave = async () => { if (!form.item_code) { toast.error("품목코드는 필수예요"); return; } setSaving(true); @@ -542,18 +675,23 @@ export default function ItemInspectionInfoPage() { } const enabledTypes = INSPECTION_TYPES.filter(t => !!form[t.key]); const rows: any[] = []; + let globalOrder = 0; for (const t of enabledTypes) { const typeRows = inspectionRows[t.key] || []; if (typeRows.length === 0) { - rows.push({ id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label, is_active: form.is_active || "사용", manager_id: form.manager_id || "", memo: form.remarks || "" }); + globalOrder += 1; + rows.push({ id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label, is_active: form.is_active || "사용", manager_id: form.manager_id || "", memo: form.remarks || "", sort_order: String(globalOrder).padStart(4, "0") }); } else { for (const r of typeRows) { + globalOrder += 1; rows.push({ id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label, inspection_standard_id: r.inspection_standard_id || "", inspection_item_name: r.inspection_detail || "", inspection_method: r.inspection_method || "", pass_criteria: r.acceptance_criteria || "", + apply_process: r.apply_process || "", classification: r.classification || "", is_required: r.is_required ? "true" : "false", is_active: form.is_active || "사용", manager_id: form.manager_id || "", memo: form.remarks || "", + sort_order: String(globalOrder).padStart(4, "0"), }); } } @@ -974,6 +1112,7 @@ export default function ItemInspectionInfoPage() { 검사기준 검사방법 적용공정 + 구분 판단기준 합격기준 필수 @@ -983,7 +1122,7 @@ export default function ItemInspectionInfoPage() { {selectedTabRows.length === 0 ? ( - 등록된 검사항목이 없어요 + 등록된 검사항목이 없어요 ) : selectedTabRows.map((row: any) => ( @@ -1002,6 +1141,7 @@ export default function ItemInspectionInfoPage() { const proc = processOptions.find(p => p.code === code); return proc?.name || code; })()} + {row.classification || "-"} {(() => { const insp = inspOptions.find(o => o.code === row.inspection_standard_id); @@ -1010,7 +1150,16 @@ export default function ItemInspectionInfoPage() { return jcLabel ? {jcLabel} : "-"; })()} - {row.pass_criteria || "-"} + {(() => { + const pc = row.pass_criteria; + if (!pc) return "-"; + if (pc.includes("|")) { + const [s, t] = pc.split("|"); + if (!t || !t.trim()) return s || "-"; + return `${s} ± ${t}`; + } + return pc; + })()} {row.is_required === "true" || row.is_required === true ? ( 필수 @@ -1185,6 +1334,7 @@ export default function ItemInspectionInfoPage() { 검사기준 상세 검사방법 적용공정 + 구분 판단기준 합격기준 (판단기준별) 필수 @@ -1194,7 +1344,7 @@ export default function ItemInspectionInfoPage() { {(!inspectionRows[key] || inspectionRows[key].length === 0) ? ( - 항목추가 버튼으로 검사항목을 추가하세요 + 항목추가 버튼으로 검사항목을 추가하세요 ) : inspectionRows[key].map((row) => ( @@ -1219,6 +1369,9 @@ export default function ItemInspectionInfoPage() { updateInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" /> )} + + updateInspRow(key, row.id, "classification", e.target.value)} placeholder="구분 입력" /> + {row.judgment_criteria ? {row.judgment_criteria} : -} @@ -1285,20 +1438,20 @@ export default function ItemInspectionInfoPage() {
- {/* ═══════════════════ 복사 모달 ═══════════════════ */} + {/* ═══════════════════ 복사 모달 (2단 분할: 좌 대상 / 우 편집) ═══════════════════ */} { if (!copying) setCopyModalOpen(v); }}> e.preventDefault()} onInteractOutside={(e) => e.preventDefault()} onEscapeKeyDown={(e) => { if (copying) e.preventDefault(); }} > - + {copying ? "검사정보 복사 중..." : "검사정보 복사"} {selectedGroup?.item_name || "-"} ({selectedItemCode}) - {copying ? " 의 검사정보를 복사하고 있습니다" : " 의 검사정보를 아래 선택한 품목들에 복사합니다"} + {copying ? " 의 검사정보를 복사하고 있습니다" : " 의 검사정보를 편집해서 선택한 품목들에 복사합니다. 기준 품목은 변경되지 않아요"} {copying ? ( @@ -1322,81 +1475,229 @@ export default function ItemInspectionInfoPage() {

- ) : (<> -
- setCopySearchKeyword(e.target.value)} - onKeyDown={(e) => { if (e.key === "Enter") handleCopySearch(); }} /> - -
-
- - - - - 0 && copyFilteredItems.every(i => copyCheckedIds.includes(i.code))} - onCheckedChange={(v) => { - if (v) setCopyCheckedIds(prev => Array.from(new Set([...prev, ...copyFilteredItems.map(i => i.code)]))); - else setCopyCheckedIds(prev => prev.filter(c => !copyFilteredItems.some(i => i.code === c))); - }} - /> - - 품목코드 - 품목명 - 품목유형 - 단위 - - - - {copyFilteredItems.length === 0 ? ( - - {copySearchLoading ? "검색 중..." : "검색 결과가 없어요"} - - ) : copyFilteredItems.map((item) => ( - toggleCopyChecked(item.code)}> - e.stopPropagation()}> - toggleCopyChecked(item.code)} /> - - {item.code} - {item.name} - {item.item_type} - {item.unit} - - ))} - -
-
-
- - 전체 {copyTotal.toLocaleString()}건 - {copyCheckedIds.length > 0 && 선택 {copyCheckedIds.length}} - -
- - - {Array.from({ length: Math.min(5, copyTotalPages) }, (_, i) => { - const start = Math.max(1, Math.min(copyPage - 2, copyTotalPages - 4)); - const p = start + i; - if (p > copyTotalPages) return null; - return ( - - ); - })} - - + ) : ( +
+ {/* 좌측: 복사 대상 품목 선택 */} +
+
+ 복사 대상 품목 선택 + {copyCheckedIds.length > 0 && 선택 {copyCheckedIds.length}건} +
+
+ setCopySearchKeyword(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") handleCopySearch(); }} /> + +
+
+ + + + + 0 && copyFilteredItems.every(i => copyCheckedIds.includes(i.code))} + onCheckedChange={(v) => { + if (v) setCopyCheckedIds(prev => Array.from(new Set([...prev, ...copyFilteredItems.map(i => i.code)]))); + else setCopyCheckedIds(prev => prev.filter(c => !copyFilteredItems.some(i => i.code === c))); + }} + /> + + 품목코드 + 품목명 + + + + {copyFilteredItems.length === 0 ? ( + + {copySearchLoading ? "검색 중..." : "검색 결과가 없어요"} + + ) : copyFilteredItems.map((item) => ( + toggleCopyChecked(item.code)}> + e.stopPropagation()}> + toggleCopyChecked(item.code)} /> + + {item.code} + {item.name} + + ))} + +
+
+
+ 전체 {copyTotal.toLocaleString()} +
+ + + {copyPage}/{copyTotalPages} + + +
+
+
+ + {/* 우측: 편집 폼 (등록/수정 폼과 동일 구조) */} +
+
+ 복사할 검사정보 편집 (기준: {selectedItemCode}) +
+
+
+
+ + +
+
+ + +
+
+ +
+

검사유형 선택

+
+ {INSPECTION_TYPES.map(({ key, label }) => ( +
+ setCopyForm(p => ({ ...p, [key]: !!v }))} /> + +
+ ))} +
+
+ + {INSPECTION_TYPES.filter(t => !!copyForm[t.key]).map(({ key, label }) => ( +
+ + {!copyCollapsedTypes[key] && ( +
+
+ 검사항목 목록 + +
+
+ + + + 검사기준 선택 + 검사기준 상세 + 검사방법 + 적용공정 + 구분 + 판단기준 + 합격기준 + 필수 + 단위 + + + + + {(!copyInspectionRows[key] || copyInspectionRows[key].length === 0) ? ( + 항목추가 버튼으로 검사항목을 추가하세요 + ) : copyInspectionRows[key].map((row) => ( + + + + + + + + {processOptions.length > 0 ? ( + + ) : ( + updateCopyInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" /> + )} + + + updateCopyInspRow(key, row.id, "classification", e.target.value)} placeholder="구분" /> + + + {row.judgment_criteria ? {row.judgment_criteria} : -} + + + {row.judgment_criteria === "선택형" && row.selection_options ? ( + + ) : row.judgment_criteria === "O/X" ? ( + + ) : row.judgment_criteria === "수치(범위)" ? ( +
+ { + const parts = (row.acceptance_criteria || "||").split("|"); + parts[0] = e.target.value; + updateCopyInspRow(key, row.id, "acceptance_criteria", parts.join("|")); + }} placeholder="기준" disabled={!row.inspection_standard_id} /> + ± + { + const parts = (row.acceptance_criteria || "||").split("|"); + parts[1] = e.target.value; + updateCopyInspRow(key, row.id, "acceptance_criteria", parts.join("|")); + }} placeholder="±" disabled={!row.inspection_standard_id} /> +
+ ) : ( + updateCopyInspRow(key, row.id, "acceptance_criteria", e.target.value)} placeholder="합격기준" disabled={!row.inspection_standard_id} /> + )} +
+ updateCopyInspRow(key, row.id, "is_required", !!v)} /> + {row.unit || "-"} + + + +
+ ))} +
+
+
+
+ )} +
+ ))} +
+
-
- )} + )}
+ {/* 위치명 형식 — 구역/열/단 뒤에 붙일 표현만 자유 입력 */} +
+ +
+ A + setRackZoneLabel(e.target.value)} + placeholder="구역" + className="h-8 w-20 text-xs" + /> + - 01 + setRackRowLabel(e.target.value)} + placeholder="열" + className="h-8 w-20 text-xs" + /> + - 1 + setRackLevelLabel(e.target.value)} + placeholder="단" + className="h-8 w-20 text-xs" + /> +
+

+ 예시: A{rackZoneLabel}-01{rackRowLabel}-1{rackLevelLabel} + {" "}— 구역/열/단 번호는 자동 계산되고, 뒤에 붙는 명칭만 수정할 수 있습니다. +

+
+ {/* 등록 미리보기 */}
diff --git a/frontend/app/(main)/COMPANY_9/outsourcing/subcontractor-item/page.tsx b/frontend/app/(main)/COMPANY_9/outsourcing/subcontractor-item/page.tsx index 92673fa0..2c928135 100644 --- a/frontend/app/(main)/COMPANY_9/outsourcing/subcontractor-item/page.tsx +++ b/frontend/app/(main)/COMPANY_9/outsourcing/subcontractor-item/page.tsx @@ -98,12 +98,26 @@ export default function SubcontractorItemPage() { } return result; }; - for (const col of ["material", "division", "type", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"]) { + for (const col of ["material", "division", "type", "status", "unit", "inventory_unit", "currency_code", "user_type01", "user_type02"]) { try { const res = await apiClient.get(`/table-categories/${ITEM_TABLE}/${col}/values`); if (res.data?.success) optMap[col] = flatten(res.data.data || []); } catch { /* skip */ } } + // 외주사관리에서 사용하는 subcontractor_item_prices.currency_code도 병합 + try { + const res = await apiClient.get(`/table-categories/subcontractor_item_prices/currency_code/values`); + if (res.data?.success) { + const extra = flatten(res.data.data || []); + const seen = new Set((optMap["currency_code"] || []).map((o) => o.code)); + for (const e of extra) { + if (!seen.has(e.code)) { + (optMap["currency_code"] ||= []).push(e); + seen.add(e.code); + } + } + } + } catch { /* skip */ } // 외주업체 거래유형 (subcontractor_mng.division) try { const res = await apiClient.get(`/table-categories/${SUBCONTRACTOR_TABLE}/division/values`); @@ -124,10 +138,10 @@ export default function SubcontractorItemPage() { item_number: { width: "w-[110px]" }, item_name: { minWidth: "min-w-[130px]", render: (v) => v || "-" }, size: { width: "w-[90px]", render: (v) => v || "-" }, - unit: { width: "w-[60px]", render: (v) => v || "-" }, + unit: { width: "w-[60px]", render: (v) => resolve("unit", v) || "-" }, standard_price: { width: "w-[90px]", align: "right", formatNumber: true }, selling_price: { width: "w-[90px]", align: "right", formatNumber: true }, - currency_code: { width: "w-[50px]", render: (v) => v || "-" }, + currency_code: { width: "w-[50px]", render: (v) => resolve("currency_code", v) || "-" }, status: { width: "w-[60px]", render: (v) => v || "-" }, }; return ts.visibleColumns.map((col) => ({ @@ -135,7 +149,8 @@ export default function SubcontractorItemPage() { label: col.label, ...colProps[col.key], })); - }, [ts.visibleColumns]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ts.visibleColumns, categoryOptions]); // 좌측: 품목 조회 (division이 "외주관리"인 품목만 필터링) const outsourcingDivisionCode = categoryOptions["division"]?.find( @@ -164,8 +179,8 @@ export default function SubcontractorItemPage() { for (const col of CATS) { if (converted[col]) converted[col] = resolve(col, converted[col]); } - // item_info의 inventory_unit을 단위 표시용 unit에 매핑 - converted.unit = converted.inventory_unit || converted.unit || ""; + // "단위" 컬럼은 재고단위(inventory_unit)만 사용 — unit 폴백 제거 + converted.unit = converted.inventory_unit || ""; return converted; }); setItems(data); @@ -212,11 +227,35 @@ export default function SubcontractorItemPage() { } catch { /* skip */ } } - setSubcontractorItems(mappings.map((m: any) => ({ - ...m, - subcontractor_code: m.subcontractor_id, - subcontractor_name: subMap[m.subcontractor_id]?.subcontractor_name || "", - }))); + // 외주사관리에서 입력된 최신 단가(subcontractor_item_prices) 조회 → subcontractor_id 별 최신 1건 + const priceMap: Record = {}; + try { + const priceRes = await apiClient.post(`/table-management/tables/subcontractor_item_prices/data`, { + page: 1, size: 0, + dataFilter: { enabled: true, filters: [{ columnName: "item_id", operator: "equals", value: itemKey }] }, + autoFilter: true, + }); + const prices = priceRes.data?.data?.data || priceRes.data?.data?.rows || []; + for (const p of prices) { + const key = p.subcontractor_id; + if (!key) continue; + if (!priceMap[key] || (p.start_date && (!priceMap[key].start_date || p.start_date > priceMap[key].start_date))) { + priceMap[key] = p; + } + } + } catch { /* skip */ } + + setSubcontractorItems(mappings.map((m: any) => { + const price = priceMap[m.subcontractor_id] || {}; + return { + ...m, + subcontractor_code: m.subcontractor_id, + subcontractor_name: subMap[m.subcontractor_id]?.subcontractor_name || "", + base_price: price.base_price ?? m.base_price, + calculated_price: price.calculated_price ?? price.unit_price ?? m.calculated_price, + currency_code: resolve("currency_code", price.currency_code ?? m.currency_code), + }; + })); } catch (err) { console.error("외주업체 조회 실패:", err); } finally { @@ -224,7 +263,8 @@ export default function SubcontractorItemPage() { } }; fetchSubcontractorItems(); - }, [selectedItem?.item_number]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedItem?.item_number, categoryOptions]); // 외주업체 검색 const searchSubcontractors = async () => { diff --git a/frontend/app/(main)/COMPANY_9/production/bom/page.tsx b/frontend/app/(main)/COMPANY_9/production/bom/page.tsx index 015e29c7..51bc486a 100644 --- a/frontend/app/(main)/COMPANY_9/production/bom/page.tsx +++ b/frontend/app/(main)/COMPANY_9/production/bom/page.tsx @@ -59,6 +59,7 @@ import { Settings2, Save, Package, + Pencil, } from "lucide-react"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; @@ -359,7 +360,13 @@ export default function BomManagementPage() { sort: { columnName: "created_at", order: "desc" }, }); - const rows = res.data?.data?.data || res.data?.data?.rows || []; + // DB 컬럼이 item_type/expired_date → 프론트 내부에서는 bom_type/expiry_date로 통일 + const rawRows = res.data?.data?.data || res.data?.data?.rows || []; + const rows = rawRows.map((r: any) => ({ + ...r, + bom_type: r.bom_type ?? r.item_type, + expiry_date: r.expiry_date ?? r.expired_date, + })); setBomList(rows); setTotalCount(rows.length); } catch (err: any) { @@ -456,9 +463,16 @@ export default function BomManagementPage() { const fetchBomDetail = useCallback(async (bomId: string) => { setDetailLoading(true); try { - // 헤더 조회 + // 헤더 조회 (DB 컬럼 item_type/expired_date → bom_type/expiry_date로 매핑) const headerRes = await apiClient.get(`/bom/${bomId}/header`); - const header = headerRes.data?.data || headerRes.data; + const rawHeader = headerRes.data?.data || headerRes.data; + const header = rawHeader + ? { + ...rawHeader, + bom_type: rawHeader.bom_type ?? rawHeader.item_type, + expiry_date: rawHeader.expiry_date ?? rawHeader.expired_date, + } + : null; setBomHeader(header); setCurrentVersionId(header?.current_version_id || null); @@ -1107,17 +1121,18 @@ export default function BomManagementPage() { setSaving(true); try { + // DB 실제 컬럼: item_type / expired_date (프론트 내부 bom_type/expiry_date와 다름) const bomFields: Record = { item_id: masterForm.item_id, item_code: masterForm.item_code, item_name: masterForm.item_name, - bom_type: masterForm.bom_type, + item_type: masterForm.bom_type, base_qty: masterForm.base_qty || "1", unit: masterForm.unit || "", version: masterForm.version || "1.0", status: masterForm.status || "draft", effective_date: masterForm.effective_date || null, - expiry_date: masterForm.expiry_date || null, + expired_date: masterForm.expiry_date || null, remark: masterForm.remark || "", writer: user?.userId || "", company_code: user?.company_code || "", @@ -1510,6 +1525,21 @@ export default function BomManagementPage() { 등록 +
{showOutsourceField && (
- - + + + + + + +
+ {subcontractorOptions.length === 0 ? ( +
등록된 외주업체가 없어요
+ ) : subcontractorOptions.map((s) => { + const checked = formOutsources.includes(s.id); + return ( + + ); + })} +
+
+
)}
diff --git a/frontend/app/(main)/COMPANY_9/production/work-instruction/page.tsx b/frontend/app/(main)/COMPANY_9/production/work-instruction/page.tsx index ef7c2a39..9a6fc954 100644 --- a/frontend/app/(main)/COMPANY_9/production/work-instruction/page.tsx +++ b/frontend/app/(main)/COMPANY_9/production/work-instruction/page.tsx @@ -202,7 +202,13 @@ export default function WorkInstructionPage() { if (!regCheckedIds.has(getRegId(item))) continue; if (regSourceType === "item") items.push({ itemCode: item.item_code, itemName: item.item_name || "", spec: item.spec || "", qty: 1, remark: "", sourceType: "item", sourceTable: "item_info", sourceId: item.item_code }); else if (regSourceType === "order") items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: item.spec || "", qty: Number(item.qty || 1), remark: "", sourceType: "order", sourceTable: "sales_order_detail", sourceId: item.id }); - else items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: "", qty: Number(item.plan_qty || 1), remark: "", sourceType: "production", sourceTable: "production_plan_mng", sourceId: item.id }); + else { + // 생산계획: 잔량(remain_qty)이 있으면 잔량 기반으로 기본 수량 제안 (0/음수 허용 — 계획 초과 가능) + const defaultQty = item.remain_qty !== undefined && item.remain_qty !== null + ? Number(item.remain_qty) + : Number(item.plan_qty || 1); + items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: "", qty: defaultQty, remark: "", sourceType: "production", sourceTable: "production_plan_mng", sourceId: item.id }); + } } // 동일품목 합산 @@ -578,7 +584,7 @@ export default function WorkInstructionPage() { 0 && regCheckedIds.size === regSourceData.length} onCheckedChange={toggleRegAll} /> {regSourceType === "item" && <>품목코드품목명규격} {regSourceType === "order" && <>수주번호품번품목명규격수량납기일} - {regSourceType === "production" && <>계획번호품번품목명계획수량시작일완료일설비} + {regSourceType === "production" && <>계획번호품번품목명계획수량적용수량잔량시작일완료일설비} @@ -590,7 +596,7 @@ export default function WorkInstructionPage() { e.stopPropagation()}> toggleRegItem(id)} /> {regSourceType === "item" && <>{item.item_code}{item.item_name}{item.spec || "-"}} {regSourceType === "order" && <>{item.order_no}{item.item_code}{item.item_name}{item.spec || "-"}{Number(item.qty || 0).toLocaleString()}{item.due_date || "-"}} - {regSourceType === "production" && <>{item.plan_no}{item.item_code}{item.item_name}{Number(item.plan_qty || 0).toLocaleString()}{item.start_date ? String(item.start_date).split("T")[0] : "-"}{item.end_date ? String(item.end_date).split("T")[0] : "-"}{item.equipment_name || "-"}} + {regSourceType === "production" && <>{item.plan_no}{item.item_code}{item.item_name}{Number(item.plan_qty || 0).toLocaleString()}{Number(item.applied_qty || 0).toLocaleString()}{Number(item.remain_qty ?? item.plan_qty ?? 0).toLocaleString()}{item.start_date ? String(item.start_date).split("T")[0] : "-"}{item.end_date ? String(item.end_date).split("T")[0] : "-"}{item.equipment_name || "-"}} ); })} diff --git a/frontend/app/(main)/COMPANY_9/purchase/purchase-item/page.tsx b/frontend/app/(main)/COMPANY_9/purchase/purchase-item/page.tsx index c05f7ec0..54497514 100644 --- a/frontend/app/(main)/COMPANY_9/purchase/purchase-item/page.tsx +++ b/frontend/app/(main)/COMPANY_9/purchase/purchase-item/page.tsx @@ -318,6 +318,11 @@ export default function PurchaseItemPage() { // 좌측: 품목 조회 const fetchItems = useCallback(async () => { + // 카테고리 로드 완료 전엔 대기 — 먼저 나간 unfiltered 요청이 나중에 도착해 + // filtered 결과를 덮어쓰는 race condition 방지 + if (!categoryOptions["division"]?.length) { + return; + } setItemLoading(true); try { const filters: { columnName: string; operator: string; value: any }[] = []; diff --git a/frontend/app/(main)/COMPANY_9/quality/inspection/page.tsx b/frontend/app/(main)/COMPANY_9/quality/inspection/page.tsx index fe334a1d..3edaadc3 100644 --- a/frontend/app/(main)/COMPANY_9/quality/inspection/page.tsx +++ b/frontend/app/(main)/COMPANY_9/quality/inspection/page.tsx @@ -52,6 +52,7 @@ const INSPECTION_COLUMNS = [ { key: "inspection_code", label: "검사코드" }, { key: "inspection_type", label: "검사유형" }, { key: "inspection_criteria", label: "검사기준" }, + { key: "criteria_detail", label: "기준상세" }, { key: "inspection_item", label: "검사항목" }, { key: "inspection_method", label: "검사방법" }, { key: "judgment_criteria", label: "판단기준" }, diff --git a/frontend/app/(main)/COMPANY_9/quality/item-inspection/page.tsx b/frontend/app/(main)/COMPANY_9/quality/item-inspection/page.tsx index 9555117e..0e976d47 100644 --- a/frontend/app/(main)/COMPANY_9/quality/item-inspection/page.tsx +++ b/frontend/app/(main)/COMPANY_9/quality/item-inspection/page.tsx @@ -43,6 +43,7 @@ type InspectionRow = { inspection_detail: string; inspection_method: string; apply_process: string; + classification: string; acceptance_criteria: string; is_required: boolean; judgment_criteria?: string; // 판단기준 라벨 (수치(범위)/텍스트입력/O·X/선택형) @@ -253,6 +254,11 @@ export default function ItemInspectionInfoPage() { loadProcessOptions(item.code); }; + // 복사 모달: 편집 가능한 기준 데이터 상태 (등록/수정 폼과 평행 구조) + const [copyForm, setCopyForm] = useState>({}); + const [copyInspectionRows, setCopyInspectionRows] = useState>({}); + const [copyCollapsedTypes, setCopyCollapsedTypes] = useState>({}); + /* ═══════════════════ 복사 모달 (기준 품목 검사정보 → 다른 품목들) ═══════════════════ */ const [copyModalOpen, setCopyModalOpen] = useState(false); const [copySearchKeyword, setCopySearchKeyword] = useState(""); @@ -294,11 +300,63 @@ export default function ItemInspectionInfoPage() { setCopyTotal(resData?.total || resData?.totalCount || rows.length); } catch { /* skip */ } finally { setCopySearchLoading(false); } }; - const openCopyModal = () => { + const openCopyModal = async () => { if (!selectedItemCode) { toast.error("복사 기준 품목을 먼저 선택해주세요"); return; } const srcGroup = groupedData.find(g => g.item_code === selectedItemCode); if (!srcGroup || srcGroup.rows.length === 0) { toast.error("선택한 품목에 복사할 검사정보가 없어요"); return; } setCopySearchKeyword(""); setCopyPage(1); setCopyCheckedIds([]); + + // 기준 품목 데이터를 편집용 상태로 복제 (openEdit과 동일한 변환 로직) + const baseRow = srcGroup.rows[0]; + try { + const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, { + page: 1, size: 0, + dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: selectedItemCode }] }, + autoFilter: true, + }); + const allRows = res.data?.data?.data || res.data?.data?.rows || []; + const rowMap: Record = {}; + const typeFlags: Record = {}; + for (const r of allRows) { + const inspType = r.inspection_type || ""; + const matched = INSPECTION_TYPES.find(t => + t.matchLabels.some(ml => inspType.includes(ml)) || + inspTypeCatOptions.some(cat => inspType.includes(cat.code) && t.matchLabels.some(ml => cat.label.includes(ml))) + ); + const typeKey = matched?.key || ""; + if (!typeKey) continue; + typeFlags[typeKey] = true; + if (!rowMap[typeKey]) rowMap[typeKey] = []; + const mCode = r.inspection_method || ""; + const mLabel = inspMethodCatOptions.find(o => o.code === mCode)?.label || mCode; + const inspOpt = inspOptions.find(o => o.code === r.inspection_standard_id); + const jcCode = inspOpt?.judgment_criteria || ""; + const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode; + const unitCode = inspOpt?.unit || ""; + const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode; + rowMap[typeKey].push({ + id: crypto.randomUUID(), // 복사본은 새 id 부여 (원본과 분리) + inspection_standard_id: r.inspection_standard_id || "", + inspection_detail: r.inspection_item_name || r.inspection_standard || "", + inspection_method: mLabel, + apply_process: r.apply_process || "", + classification: r.classification || "", + acceptance_criteria: r.pass_criteria || "", + is_required: r.is_required === "true" || r.is_required === true, + judgment_criteria: jcLabel, + selection_options: inspOpt?.selection_options || "", + unit: unitLabel, + }); + } + setCopyInspectionRows(rowMap); + setCopyForm({ ...baseRow, ...typeFlags }); + setCopyCollapsedTypes({}); + } catch { + setCopyInspectionRows({}); + setCopyForm({ ...baseRow }); + setCopyCollapsedTypes({}); + } + setCopyModalOpen(true); searchCopyTargets(1); }; @@ -309,10 +367,18 @@ export default function ItemInspectionInfoPage() { const handleCopy = async () => { if (!selectedItemCode) { toast.error("복사 기준 품목이 없어요"); return; } if (copyCheckedIds.length === 0) { toast.error("붙여넣을 품목을 선택해주세요"); return; } - const sourceGroup = groupedData.find(g => g.item_code === selectedItemCode); - if (!sourceGroup || sourceGroup.rows.length === 0) { toast.error("복사할 검사정보가 없어요"); return; } + + // 편집된 rows를 평탄화 (선택된 검사유형의 rows만) + const enabledTypes = INSPECTION_TYPES.filter(t => !!copyForm[t.key]); + const flatRows: Array<{ row: InspectionRow; typeLabel: string }> = []; + for (const t of enabledTypes) { + const rows = copyInspectionRows[t.key] || []; + for (const r of rows) flatRows.push({ row: r, typeLabel: t.label }); + } + if (flatRows.length === 0) { toast.error("복사할 검사항목이 없어요"); return; } + const ok = await confirm( - `선택한 ${copyCheckedIds.length}개 품목에 검사정보를 복사할까요?`, + `선택한 ${copyCheckedIds.length}개 품목에 편집된 검사정보(${flatRows.length}개 행)를 복사할까요?`, { description: "대상 품목의 기존 검사정보는 삭제 후 교체됩니다.", variant: "info", confirmText: "복사" } ); if (!ok) return; @@ -325,7 +391,7 @@ export default function ItemInspectionInfoPage() { const target = copyFilteredItems.find(o => o.code === targetCode) || itemOptions.find(o => o.code === targetCode); const targetName = target?.name || ""; const existRes = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, { - page: 1, size: 500, + page: 1, size: 0, dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: targetCode }] }, autoFilter: true, }); @@ -333,13 +399,26 @@ export default function ItemInspectionInfoPage() { if (existing.length > 0) { await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: existing.map((r: any) => ({ id: r.id })) }); } - for (const r of sourceGroup.rows) { - const { id: _id, created_at: _c, updated_at: _u, ...rest } = r; + let orderSeq = 0; + for (const { row: r, typeLabel } of flatRows) { + orderSeq += 1; await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, { - ...rest, id: crypto.randomUUID(), item_code: targetCode, item_name: targetName, + inspection_type: typeLabel, + inspection_standard_id: r.inspection_standard_id || "", + inspection_item_name: r.inspection_detail || "", + inspection_method: r.inspection_method || "", + apply_process: r.apply_process || "", + classification: r.classification || "", + pass_criteria: r.acceptance_criteria || "", + is_required: r.is_required ? "true" : "false", + is_active: copyForm.is_active || "사용", + manager: copyForm.manager || "", + manager_id: copyForm.manager_id || "", + memo: copyForm.remarks || "", + sort_order: String(orderSeq).padStart(4, "0"), }); } setCopyProgress({ current: i + 1, total: copyCheckedIds.length }); @@ -402,7 +481,13 @@ export default function ItemInspectionInfoPage() { // 선택된 탭의 검사항목 행 const selectedTabRows = useMemo(() => { if (!selectedGroup || !selectedTypeTab) return []; - return selectedGroup.rows.filter((r: any) => r.inspection_type === selectedTypeTab && r.inspection_standard_id); + const filtered = selectedGroup.rows.filter((r: any) => r.inspection_type === selectedTypeTab && r.inspection_standard_id); + return [...filtered].sort((a: any, b: any) => { + const av = parseInt(String(a.sort_order || "9999"), 10); + const bv = parseInt(String(b.sort_order || "9999"), 10); + if (av === bv) return String(a.id).localeCompare(String(b.id)); + return av - bv; + }); }, [selectedGroup, selectedTypeTab]); // 검사기준 ID → 라벨 @@ -436,6 +521,13 @@ export default function ItemInspectionInfoPage() { autoFilter: true, }); const allRows = res.data?.data?.data || res.data?.data?.rows || []; + // sort_order 기준 오름차순 정렬 (varchar이므로 숫자 파싱 후 비교) + allRows.sort((a: any, b: any) => { + const av = parseInt(String(a.sort_order || "9999"), 10); + const bv = parseInt(String(b.sort_order || "9999"), 10); + if (av === bv) return String(a.id).localeCompare(String(b.id)); + return av - bv; + }); const rowMap: Record = {}; const typeFlags: Record = {}; @@ -462,7 +554,8 @@ export default function ItemInspectionInfoPage() { inspection_standard_id: r.inspection_standard_id || "", inspection_detail: r.inspection_item_name || r.inspection_standard || "", inspection_method: mLabel, - apply_process: "", + apply_process: r.apply_process || "", + classification: r.classification || "", acceptance_criteria: r.pass_criteria || "", is_required: r.is_required === "true" || r.is_required === true, judgment_criteria: jcLabel, @@ -480,7 +573,7 @@ export default function ItemInspectionInfoPage() { const addInspRow = (typeKey: string) => { setInspectionRows(prev => ({ ...prev, - [typeKey]: [...(prev[typeKey] || []), { id: crypto.randomUUID(), inspection_standard_id: "", inspection_detail: "", inspection_method: "", apply_process: "", acceptance_criteria: "", is_required: false }], + [typeKey]: [...(prev[typeKey] || []), { id: crypto.randomUUID(), inspection_standard_id: "", inspection_detail: "", inspection_method: "", apply_process: "", classification: "", acceptance_criteria: "", is_required: false }], })); }; const removeInspRow = (typeKey: string, rowId: string) => { @@ -525,6 +618,46 @@ export default function ItemInspectionInfoPage() { }; const toggleCollapse = (typeKey: string) => { setCollapsedTypes(prev => ({ ...prev, [typeKey]: !prev[typeKey] })); }; + /* ═══════════════════ 복사 모달용 검사항목 행 관리 (등록 폼과 평행) ═══════════════════ */ + const addCopyInspRow = (typeKey: string) => { + setCopyInspectionRows(prev => ({ + ...prev, + [typeKey]: [...(prev[typeKey] || []), { id: crypto.randomUUID(), inspection_standard_id: "", inspection_detail: "", inspection_method: "", apply_process: "", classification: "", acceptance_criteria: "", is_required: false }], + })); + }; + const removeCopyInspRow = (typeKey: string, rowId: string) => { + setCopyInspectionRows(prev => ({ ...prev, [typeKey]: (prev[typeKey] || []).filter(r => r.id !== rowId) })); + }; + const updateCopyInspRow = (typeKey: string, rowId: string, field: string, value: any) => { + setCopyInspectionRows(prev => ({ + ...prev, + [typeKey]: (prev[typeKey] || []).map(r => { + if (r.id !== rowId) return r; + if (field === "inspection_standard_id") { + const opt = inspOptions.find(o => o.code === value); + const methodCode = opt?.method || ""; + const methodLabel = inspMethodCatOptions.find(o => o.code === methodCode)?.label || methodCode; + const jcCode = opt?.judgment_criteria || ""; + const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode; + const unitCode = opt?.unit || ""; + const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode; + return { + ...r, + inspection_standard_id: value, + inspection_detail: opt?.detail || "", + inspection_method: methodLabel, + judgment_criteria: jcLabel, + selection_options: opt?.selection_options || "", + unit: unitLabel, + acceptance_criteria: "", + }; + } + return { ...r, [field]: value }; + }), + })); + }; + const toggleCopyCollapse = (typeKey: string) => { setCopyCollapsedTypes(prev => ({ ...prev, [typeKey]: !prev[typeKey] })); }; + const handleSave = async () => { if (!form.item_code) { toast.error("품목코드는 필수예요"); return; } setSaving(true); @@ -542,18 +675,23 @@ export default function ItemInspectionInfoPage() { } const enabledTypes = INSPECTION_TYPES.filter(t => !!form[t.key]); const rows: any[] = []; + let globalOrder = 0; for (const t of enabledTypes) { const typeRows = inspectionRows[t.key] || []; if (typeRows.length === 0) { - rows.push({ id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label, is_active: form.is_active || "사용", manager_id: form.manager_id || "", memo: form.remarks || "" }); + globalOrder += 1; + rows.push({ id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label, is_active: form.is_active || "사용", manager_id: form.manager_id || "", memo: form.remarks || "", sort_order: String(globalOrder).padStart(4, "0") }); } else { for (const r of typeRows) { + globalOrder += 1; rows.push({ id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label, inspection_standard_id: r.inspection_standard_id || "", inspection_item_name: r.inspection_detail || "", inspection_method: r.inspection_method || "", pass_criteria: r.acceptance_criteria || "", + apply_process: r.apply_process || "", classification: r.classification || "", is_required: r.is_required ? "true" : "false", is_active: form.is_active || "사용", manager_id: form.manager_id || "", memo: form.remarks || "", + sort_order: String(globalOrder).padStart(4, "0"), }); } } @@ -974,6 +1112,7 @@ export default function ItemInspectionInfoPage() { 검사기준 검사방법 적용공정 + 구분 판단기준 합격기준 필수 @@ -983,7 +1122,7 @@ export default function ItemInspectionInfoPage() { {selectedTabRows.length === 0 ? ( - 등록된 검사항목이 없어요 + 등록된 검사항목이 없어요 ) : selectedTabRows.map((row: any) => ( @@ -1002,6 +1141,7 @@ export default function ItemInspectionInfoPage() { const proc = processOptions.find(p => p.code === code); return proc?.name || code; })()} + {row.classification || "-"} {(() => { const insp = inspOptions.find(o => o.code === row.inspection_standard_id); @@ -1010,7 +1150,16 @@ export default function ItemInspectionInfoPage() { return jcLabel ? {jcLabel} : "-"; })()} - {row.pass_criteria || "-"} + {(() => { + const pc = row.pass_criteria; + if (!pc) return "-"; + if (pc.includes("|")) { + const [s, t] = pc.split("|"); + if (!t || !t.trim()) return s || "-"; + return `${s} ± ${t}`; + } + return pc; + })()} {row.is_required === "true" || row.is_required === true ? ( 필수 @@ -1185,6 +1334,7 @@ export default function ItemInspectionInfoPage() { 검사기준 상세 검사방법 적용공정 + 구분 판단기준 합격기준 (판단기준별) 필수 @@ -1194,7 +1344,7 @@ export default function ItemInspectionInfoPage() { {(!inspectionRows[key] || inspectionRows[key].length === 0) ? ( - 항목추가 버튼으로 검사항목을 추가하세요 + 항목추가 버튼으로 검사항목을 추가하세요 ) : inspectionRows[key].map((row) => ( @@ -1219,6 +1369,9 @@ export default function ItemInspectionInfoPage() { updateInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" /> )} + + updateInspRow(key, row.id, "classification", e.target.value)} placeholder="구분 입력" /> + {row.judgment_criteria ? {row.judgment_criteria} : -} @@ -1285,20 +1438,20 @@ export default function ItemInspectionInfoPage() { - {/* ═══════════════════ 복사 모달 ═══════════════════ */} + {/* ═══════════════════ 복사 모달 (2단 분할: 좌 대상 / 우 편집) ═══════════════════ */} { if (!copying) setCopyModalOpen(v); }}> e.preventDefault()} onInteractOutside={(e) => e.preventDefault()} onEscapeKeyDown={(e) => { if (copying) e.preventDefault(); }} > - + {copying ? "검사정보 복사 중..." : "검사정보 복사"} {selectedGroup?.item_name || "-"} ({selectedItemCode}) - {copying ? " 의 검사정보를 복사하고 있습니다" : " 의 검사정보를 아래 선택한 품목들에 복사합니다"} + {copying ? " 의 검사정보를 복사하고 있습니다" : " 의 검사정보를 편집해서 선택한 품목들에 복사합니다. 기준 품목은 변경되지 않아요"} {copying ? ( @@ -1322,81 +1475,229 @@ export default function ItemInspectionInfoPage() {

- ) : (<> -
- setCopySearchKeyword(e.target.value)} - onKeyDown={(e) => { if (e.key === "Enter") handleCopySearch(); }} /> - -
-
- - - - - 0 && copyFilteredItems.every(i => copyCheckedIds.includes(i.code))} - onCheckedChange={(v) => { - if (v) setCopyCheckedIds(prev => Array.from(new Set([...prev, ...copyFilteredItems.map(i => i.code)]))); - else setCopyCheckedIds(prev => prev.filter(c => !copyFilteredItems.some(i => i.code === c))); - }} - /> - - 품목코드 - 품목명 - 품목유형 - 단위 - - - - {copyFilteredItems.length === 0 ? ( - - {copySearchLoading ? "검색 중..." : "검색 결과가 없어요"} - - ) : copyFilteredItems.map((item) => ( - toggleCopyChecked(item.code)}> - e.stopPropagation()}> - toggleCopyChecked(item.code)} /> - - {item.code} - {item.name} - {item.item_type} - {item.unit} - - ))} - -
-
-
- - 전체 {copyTotal.toLocaleString()}건 - {copyCheckedIds.length > 0 && 선택 {copyCheckedIds.length}} - -
- - - {Array.from({ length: Math.min(5, copyTotalPages) }, (_, i) => { - const start = Math.max(1, Math.min(copyPage - 2, copyTotalPages - 4)); - const p = start + i; - if (p > copyTotalPages) return null; - return ( - - ); - })} - - + ) : ( +
+ {/* 좌측: 복사 대상 품목 선택 */} +
+
+ 복사 대상 품목 선택 + {copyCheckedIds.length > 0 && 선택 {copyCheckedIds.length}건} +
+
+ setCopySearchKeyword(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") handleCopySearch(); }} /> + +
+
+ + + + + 0 && copyFilteredItems.every(i => copyCheckedIds.includes(i.code))} + onCheckedChange={(v) => { + if (v) setCopyCheckedIds(prev => Array.from(new Set([...prev, ...copyFilteredItems.map(i => i.code)]))); + else setCopyCheckedIds(prev => prev.filter(c => !copyFilteredItems.some(i => i.code === c))); + }} + /> + + 품목코드 + 품목명 + + + + {copyFilteredItems.length === 0 ? ( + + {copySearchLoading ? "검색 중..." : "검색 결과가 없어요"} + + ) : copyFilteredItems.map((item) => ( + toggleCopyChecked(item.code)}> + e.stopPropagation()}> + toggleCopyChecked(item.code)} /> + + {item.code} + {item.name} + + ))} + +
+
+
+ 전체 {copyTotal.toLocaleString()} +
+ + + {copyPage}/{copyTotalPages} + + +
+
+
+ + {/* 우측: 편집 폼 (등록/수정 폼과 동일 구조) */} +
+
+ 복사할 검사정보 편집 (기준: {selectedItemCode}) +
+
+
+
+ + +
+
+ + +
+
+ +
+

검사유형 선택

+
+ {INSPECTION_TYPES.map(({ key, label }) => ( +
+ setCopyForm(p => ({ ...p, [key]: !!v }))} /> + +
+ ))} +
+
+ + {INSPECTION_TYPES.filter(t => !!copyForm[t.key]).map(({ key, label }) => ( +
+ + {!copyCollapsedTypes[key] && ( +
+
+ 검사항목 목록 + +
+
+ + + + 검사기준 선택 + 검사기준 상세 + 검사방법 + 적용공정 + 구분 + 판단기준 + 합격기준 + 필수 + 단위 + + + + + {(!copyInspectionRows[key] || copyInspectionRows[key].length === 0) ? ( + 항목추가 버튼으로 검사항목을 추가하세요 + ) : copyInspectionRows[key].map((row) => ( + + + + + + + + {processOptions.length > 0 ? ( + + ) : ( + updateCopyInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" /> + )} + + + updateCopyInspRow(key, row.id, "classification", e.target.value)} placeholder="구분" /> + + + {row.judgment_criteria ? {row.judgment_criteria} : -} + + + {row.judgment_criteria === "선택형" && row.selection_options ? ( + + ) : row.judgment_criteria === "O/X" ? ( + + ) : row.judgment_criteria === "수치(범위)" ? ( +
+ { + const parts = (row.acceptance_criteria || "||").split("|"); + parts[0] = e.target.value; + updateCopyInspRow(key, row.id, "acceptance_criteria", parts.join("|")); + }} placeholder="기준" disabled={!row.inspection_standard_id} /> + ± + { + const parts = (row.acceptance_criteria || "||").split("|"); + parts[1] = e.target.value; + updateCopyInspRow(key, row.id, "acceptance_criteria", parts.join("|")); + }} placeholder="±" disabled={!row.inspection_standard_id} /> +
+ ) : ( + updateCopyInspRow(key, row.id, "acceptance_criteria", e.target.value)} placeholder="합격기준" disabled={!row.inspection_standard_id} /> + )} +
+ updateCopyInspRow(key, row.id, "is_required", !!v)} /> + {row.unit || "-"} + + + +
+ ))} +
+
+
+
+ )} +
+ ))} +
+
-
- )} + )}
diff --git a/frontend/lib/registry/components/v2-process-work-standard/components/DetailFormModal.tsx b/frontend/lib/registry/components/v2-process-work-standard/components/DetailFormModal.tsx index 68e23e2a..d2ba60fc 100644 --- a/frontend/lib/registry/components/v2-process-work-standard/components/DetailFormModal.tsx +++ b/frontend/lib/registry/components/v2-process-work-standard/components/DetailFormModal.tsx @@ -141,8 +141,8 @@ export function DetailFormModal({ return; } } - // 신규 추가 또는 저장값 없으면 전체 체크 - setBomChecked(new Set(bomMaterials.map((m) => m.child_item_id))); + // 신규 추가 또는 저장값 없으면 전체 해제 + setBomChecked(new Set()); }, [open, bomMaterials, mode, editData]); useEffect(() => { @@ -943,6 +943,29 @@ export function DetailFormModal({ {bomMaterials.length > 0 ? `${bomMaterials.length}건` : ""} + {bomMaterials.length > 0 && ( +
+ 0 + ? "indeterminate" + : false + } + onCheckedChange={(checked) => { + if (checked) { + setBomChecked(new Set(bomMaterials.map((m) => m.child_item_id))); + } else { + setBomChecked(new Set()); + } + }} + /> + + 전체 선택 ({bomChecked.size} / {bomMaterials.length}) + +
+ )}
{bomLoading ? (
diff --git a/frontend/lib/registry/components/v2-process-work-standard/components/WorkItemDetailList.tsx b/frontend/lib/registry/components/v2-process-work-standard/components/WorkItemDetailList.tsx index ce869bf0..3fdcf8e9 100644 --- a/frontend/lib/registry/components/v2-process-work-standard/components/WorkItemDetailList.tsx +++ b/frontend/lib/registry/components/v2-process-work-standard/components/WorkItemDetailList.tsx @@ -1,7 +1,7 @@ "use client"; import React, { useState, useRef } from "react"; -import { Plus, Pencil, Trash2 } from "lucide-react"; +import { Plus, Pencil, Trash2, GripVertical } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { cn } from "@/lib/utils"; @@ -18,6 +18,7 @@ interface WorkItemDetailListProps { onCreateDetail: (data: Partial) => void; onUpdateDetail: (id: string, data: Partial) => void; onDeleteDetail: (id: string) => void; + onReorderDetails?: (orderedDetails: WorkItemDetail[]) => void; } export function WorkItemDetailList({ @@ -30,11 +31,14 @@ export function WorkItemDetailList({ onCreateDetail, onUpdateDetail, onDeleteDetail, + onReorderDetails, }: WorkItemDetailListProps) { const [modalOpen, setModalOpen] = useState(false); const [modalMode, setModalMode] = useState<"add" | "edit">("add"); const [editTarget, setEditTarget] = useState(null); const editFirstRef = useRef(false); + const [dragIdx, setDragIdx] = useState(null); + const [overIdx, setOverIdx] = useState(null); if (!workItem) { return ( @@ -154,6 +158,7 @@ export function WorkItemDetailList({ + @@ -177,8 +182,35 @@ export function WorkItemDetailList({ {details.map((detail, idx) => ( setDragIdx(idx)} + onDragOver={(e) => { + e.preventDefault(); + if (dragIdx !== null && dragIdx !== idx) setOverIdx(idx); + }} + onDrop={(e) => { + e.preventDefault(); + if (dragIdx === null || dragIdx === idx) { + setDragIdx(null); setOverIdx(null); return; + } + const next = [...details]; + const [moved] = next.splice(dragIdx, 1); + next.splice(idx, 0, moved); + onReorderDetails?.(next); + setDragIdx(null); setOverIdx(null); + }} + onDragEnd={() => { setDragIdx(null); setOverIdx(null); }} + className={cn( + "border-b transition-colors hover:bg-muted/30", + dragIdx === idx && "opacity-50", + overIdx === idx && "bg-primary/5" + )} > + diff --git a/frontend/lib/registry/components/v2-process-work-standard/components/WorkPhaseSection.tsx b/frontend/lib/registry/components/v2-process-work-standard/components/WorkPhaseSection.tsx index fd1084e3..634bfd5e 100644 --- a/frontend/lib/registry/components/v2-process-work-standard/components/WorkPhaseSection.tsx +++ b/frontend/lib/registry/components/v2-process-work-standard/components/WorkPhaseSection.tsx @@ -1,6 +1,6 @@ "use client"; -import React from "react"; +import React, { useState } from "react"; import { Plus, ClipboardList } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; @@ -29,6 +29,8 @@ interface WorkPhaseSectionProps { onCreateDetail: (workItemId: string, data: Partial, phaseKey: string) => void; onUpdateDetail: (id: string, data: Partial, phaseKey: string) => void; onDeleteDetail: (id: string, phaseKey: string) => void; + onReorderWorkItems?: (orderedIds: string[]) => void; + onReorderDetails?: (workItemId: string, orderedDetails: WorkItemDetail[], phaseKey: string) => void; } export function WorkPhaseSection({ @@ -47,9 +49,37 @@ export function WorkPhaseSection({ onCreateDetail, onUpdateDetail, onDeleteDetail, + onReorderWorkItems, + onReorderDetails, }: WorkPhaseSectionProps) { const selectedItem = items.find((i) => i.id === selectedWorkItemId) || null; + const [dragIdx, setDragIdx] = useState(null); + const [overIdx, setOverIdx] = useState(null); + + const handleDragStart = (idx: number) => setDragIdx(idx); + const handleDragOver = (e: React.DragEvent, idx: number) => { + e.preventDefault(); + if (dragIdx === null || dragIdx === idx) return; + setOverIdx(idx); + }; + const handleDragEnd = () => { + setDragIdx(null); + setOverIdx(null); + }; + const handleDrop = (e: React.DragEvent, idx: number) => { + e.preventDefault(); + if (dragIdx === null || dragIdx === idx) { + handleDragEnd(); + return; + } + const next = [...items]; + const [moved] = next.splice(dragIdx, 1); + next.splice(idx, 0, moved); + onReorderWorkItems?.(next.map((i) => i.id)); + handleDragEnd(); + }; + return (
{/* 섹션 헤더 */} @@ -89,16 +119,31 @@ export function WorkPhaseSection({
) : (
- {items.map((item) => ( - ( +
onSelectWorkItem(item.id, phase.key)} - onEdit={() => onEditWorkItem(item)} - onDelete={() => onDeleteWorkItem(item.id)} - /> + draggable={!readonly} + onDragStart={() => handleDragStart(idx)} + onDragOver={(e) => handleDragOver(e, idx)} + onDrop={(e) => handleDrop(e, idx)} + onDragEnd={handleDragEnd} + className={ + dragIdx === idx + ? "opacity-50" + : overIdx === idx + ? "ring-2 ring-primary/40 rounded-lg" + : "" + } + > + onSelectWorkItem(item.id, phase.key)} + onEdit={() => onEditWorkItem(item)} + onDelete={() => onDeleteWorkItem(item.id)} + /> +
))}
)} @@ -118,6 +163,11 @@ export function WorkPhaseSection({ } onUpdateDetail={(id, data) => onUpdateDetail(id, data, phase.key)} onDeleteDetail={(id) => onDeleteDetail(id, phase.key)} + onReorderDetails={ + onReorderDetails && selectedWorkItemId + ? (orderedDetails) => onReorderDetails(selectedWorkItemId, orderedDetails, phase.key) + : undefined + } /> diff --git a/frontend/lib/registry/components/v2-process-work-standard/hooks/useProcessWorkStandard.ts b/frontend/lib/registry/components/v2-process-work-standard/hooks/useProcessWorkStandard.ts index e874b75d..4bdbf5a4 100644 --- a/frontend/lib/registry/components/v2-process-work-standard/hooks/useProcessWorkStandard.ts +++ b/frontend/lib/registry/components/v2-process-work-standard/hooks/useProcessWorkStandard.ts @@ -383,6 +383,42 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) { ] ); + // 작업 항목 순서 일괄 재배치 + const reorderWorkItems = useCallback( + async (orderedIds: string[]) => { + if (!selection.routingDetailId || orderedIds.length === 0) return; + try { + await Promise.all( + orderedIds.map((id, idx) => + apiClient.put(`${API_BASE}/work-items/${id}`, { sort_order: idx + 1 }) + ) + ); + await fetchWorkItems(selection.routingDetailId); + } catch (err) { + console.error("작업 항목 순서 변경 실패", err); + } + }, + [selection.routingDetailId, fetchWorkItems] + ); + + // 상세 항목 순서 일괄 재배치 (전체 필드 보존 위해 객체 배열 수신) + const reorderDetails = useCallback( + async (workItemId: string, orderedDetails: WorkItemDetail[], phaseKey: string) => { + if (orderedDetails.length === 0) return; + try { + await Promise.all( + orderedDetails.map((d, idx) => + apiClient.put(`${API_BASE}/work-item-details/${d.id}`, { ...d, sort_order: idx + 1 }) + ) + ); + await fetchWorkItemDetails(workItemId, phaseKey); + } catch (err) { + console.error("상세 순서 변경 실패", err); + } + }, + [fetchWorkItemDetails] + ); + return { items, routings, @@ -406,5 +442,7 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) { createDetail, updateDetail, deleteDetail, + reorderWorkItems, + reorderDetails, }; } diff --git a/frontend/lib/utils/validation.ts b/frontend/lib/utils/validation.ts index f9320d4d..43785a9c 100644 --- a/frontend/lib/utils/validation.ts +++ b/frontend/lib/utils/validation.ts @@ -5,7 +5,8 @@ // --- 자동 포맷팅 --- // 전화번호: 숫자만 추출 → 자동 하이픈 -// 010-1234-5678 / 02-1234-5678 / 031-123-4567 +// 010-1234-5678(휴대폰 11자리) / 02-xxx-xxxx / 02-xxxx-xxxx +// 지역번호 10자리(032-672-1418) → 3-3-4 / 11자리(031-1234-5678) → 3-4-4 export function formatPhone(value: string): string { const nums = value.replace(/\D/g, "").slice(0, 11); if (nums.startsWith("02")) { @@ -15,7 +16,8 @@ export function formatPhone(value: string): string { return `${nums.slice(0, 2)}-${nums.slice(2, 6)}-${nums.slice(6)}`; } if (nums.length <= 3) return nums; - if (nums.length <= 7) return `${nums.slice(0, 3)}-${nums.slice(3)}`; + if (nums.length <= 6) return `${nums.slice(0, 3)}-${nums.slice(3)}`; + if (nums.length <= 10) return `${nums.slice(0, 3)}-${nums.slice(3, 6)}-${nums.slice(6)}`; return `${nums.slice(0, 3)}-${nums.slice(3, 7)}-${nums.slice(7)}`; }
순서
+ {!readonly && onReorderDetails && ( + + )} + {idx + 1}