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()}
+ {/* 위치명 형식 — 구역/열/단 뒤에 붙일 표현만 자유 입력 */}
+
+
{/* 등록 미리보기 */}
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단 분할: 좌 대상 / 우 편집) ═══════════════════ */}
@@ -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()}
handleDeletePkgItem(item)}>
@@ -954,8 +994,8 @@ export default function PackagingPage() {
{item.item_number}
{item.item_name}
{item.spec || "-"}
- {item.material || "-"}
- {item.unit || "EA"}
+ {resolveCat("material", item.material) || "-"}
+ {resolveCat("inventory_unit", item.inventory_unit) || "EA"}
))}
diff --git a/frontend/app/(main)/COMPANY_29/logistics/warehouse/page.tsx b/frontend/app/(main)/COMPANY_29/logistics/warehouse/page.tsx
index 6c7b71b9..21880a04 100644
--- a/frontend/app/(main)/COMPANY_29/logistics/warehouse/page.tsx
+++ b/frontend/app/(main)/COMPANY_29/logistics/warehouse/page.tsx
@@ -158,6 +158,10 @@ export default function WarehouseManagementPage() {
const [rackStatus, setRackStatus] = useState("");
const [rackPreview, setRackPreview] = useState([]);
const [rackSaving, setRackSaving] = useState(false);
+ // 위치명 접미사 (자동 조립: {zone}{구역접미사}-{row}{열접미사}-{level}{단접미사})
+ const [rackZoneLabel, setRackZoneLabel] = useState("구역");
+ const [rackRowLabel, setRackRowLabel] = useState("열");
+ const [rackLevelLabel, setRackLevelLabel] = useState("단");
// 카테고리 옵션
const [categoryOptions, setCategoryOptions] = useState<
@@ -636,7 +640,7 @@ export default function WarehouseManagementPage() {
duplicates.push(locationCode);
continue;
}
- const locationName = `${zoneCode}구역-${rowStr}열-${level}단`;
+ const locationName = `${zoneCode}${rackZoneLabel}-${rowStr}${rackRowLabel}-${level}${rackLevelLabel}`;
items.push({
location_code: locationCode,
location_name: locationName,
@@ -1502,6 +1506,38 @@ export default function WarehouseManagementPage() {
+ {/* 위치명 형식 — 구역/열/단 뒤에 붙일 표현만 자유 입력 */}
+
+
{/* 등록 미리보기 */}
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() {
등록
+ {
+ if (!selectedBomId || !bomHeader) {
+ toast.error("수정할 BOM을 선택해주세요");
+ return;
+ }
+ openEditModal();
+ }}
+ disabled={!selectedBomId || !bomHeader}
+ >
+
+ 수정
+
([]);
+ const [formOutsources, setFormOutsources] = useState([]);
+ const [subcontractorOptions, setSubcontractorOptions] = useState<{ id: string; code: string; name: string }[]>([]);
const [detailSubmitting, setDetailSubmitting] = useState(false);
const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
@@ -116,7 +117,7 @@ export function ItemRoutingTab() {
page: 1, size: 500, autoFilter: true,
});
const rows = res.data?.data?.data || res.data?.data?.rows || [];
- setSubcontractorOptions(rows.map((r: any) => ({ code: r.subcontractor_code || r.id, name: r.subcontractor_name || "" })));
+ setSubcontractorOptions(rows.map((r: any) => ({ id: r.id, code: r.subcontractor_code || "", name: r.subcontractor_name || "" })));
} catch { /* skip */ }
})();
}, []);
@@ -281,7 +282,7 @@ export function ItemRoutingTab() {
setFormFixedOrder("Y");
setFormWorkType("내부");
setFormStandardTime("");
- setFormOutsource("");
+ setFormOutsources([]);
setDetailDialogOpen(true);
};
@@ -308,7 +309,19 @@ export function ItemRoutingTab() {
setFormFixedOrder(row.is_fixed_order === "N" ? "N" : "Y");
setFormWorkType(row.work_type || "내부");
setFormStandardTime(row.standard_time || "");
- setFormOutsource(row.outsource_supplier || "");
+ // 우선순위: id 배열 → legacy code 배열(id로 역변환) → legacy 단일 code(id로 역변환)
+ let loadedIds: string[] = [];
+ if (Array.isArray(row.outsource_supplier_ids) && row.outsource_supplier_ids.length > 0) {
+ loadedIds = row.outsource_supplier_ids;
+ } else {
+ const legacyCodes = Array.isArray(row.outsource_supplier_list) && row.outsource_supplier_list.length > 0
+ ? row.outsource_supplier_list
+ : (row.outsource_supplier ? [row.outsource_supplier] : []);
+ loadedIds = legacyCodes
+ .map((c: string) => subcontractorOptions.find((s) => s.code === c)?.id)
+ .filter((v): v is string => Boolean(v));
+ }
+ setFormOutsources(loadedIds);
setDetailDialogOpen(true);
};
@@ -329,7 +342,10 @@ export function ItemRoutingTab() {
return;
}
const proc = processes.find((p) => p.process_code === formProcessCode);
- const outsource = showOutsourceField ? formOutsource.trim() : "";
+ const outsourceIds = showOutsourceField ? formOutsources.filter((s) => s && s.trim() !== "") : [];
+ const outsourcePrimaryCode = outsourceIds.length > 0
+ ? (subcontractorOptions.find((s) => s.id === outsourceIds[0])?.code || "")
+ : "";
setDetailSubmitting(true);
try {
@@ -344,7 +360,8 @@ export function ItemRoutingTab() {
is_fixed_order: formFixedOrder,
work_type: formWorkType,
standard_time: st || "0",
- outsource_supplier: outsource,
+ outsource_supplier: outsourcePrimaryCode,
+ outsource_supplier_ids: outsourceIds,
};
setDetails((prev) => sortDetailsBySeq([...prev, newRow]));
toast.success("공정이 추가되었어요. 저장을 눌러 반영해주세요");
@@ -362,7 +379,8 @@ export function ItemRoutingTab() {
is_fixed_order: formFixedOrder,
work_type: formWorkType,
standard_time: st || "0",
- outsource_supplier: outsource,
+ outsource_supplier: outsourcePrimaryCode,
+ outsource_supplier_ids: outsourceIds,
}
: d,
),
@@ -399,6 +417,7 @@ export function ItemRoutingTab() {
work_type: d.work_type || "내부",
standard_time: String(d.standard_time ?? "0"),
outsource_supplier: d.outsource_supplier || "",
+ outsource_supplier_ids: d.outsource_supplier_ids || [],
}));
setSaving(true);
@@ -480,11 +499,23 @@ export function ItemRoutingTab() {
const detailsGridData = useMemo(
() =>
- details.map((d) => ({
- ...d,
- process_display: d.process_name || d.process_code,
- outsource_display: subcontractorOptions.find((s) => s.code === d.outsource_supplier)?.name || d.outsource_supplier || "—",
- })),
+ details.map((d) => {
+ const ids = Array.isArray(d.outsource_supplier_ids) && d.outsource_supplier_ids.length > 0
+ ? d.outsource_supplier_ids
+ : [];
+ let names = ids
+ .map((i) => subcontractorOptions.find((s) => s.id === i)?.name)
+ .filter((v): v is string => Boolean(v));
+ // 레거시 폴백: id 매핑 없을 때 단일 code로 표시
+ if (names.length === 0 && d.outsource_supplier) {
+ names = [subcontractorOptions.find((s) => s.code === d.outsource_supplier)?.name || d.outsource_supplier];
+ }
+ return {
+ ...d,
+ process_display: d.process_name || d.process_code,
+ outsource_display: names.length === 0 ? "—" : names.join(", "),
+ };
+ }),
[details, subcontractorOptions],
);
@@ -909,15 +940,46 @@ export function ItemRoutingTab() {
{showOutsourceField && (
-
-
+
+
+
+
+
+ {formOutsources.length === 0
+ ? "외주업체 선택"
+ : formOutsources
+ .map((i) => subcontractorOptions.find((s) => s.id === i)?.name || i)
+ .join(", ")}
+
+ {formOutsources.length}
+
+
+
+
+ {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(); }} />
-
- {copySearchLoading ? : <>검색>}
-
-
-
-
-
-
-
- 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}건}
-
-
-
{ setCopyPage(1); searchCopyTargets(1); }} disabled={copyPage <= 1}
- className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed">
-
{ const p = copyPage - 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage <= 1}
- className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed">
- {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 (
-
{ setCopyPage(p); searchCopyTargets(p); }}
- className={cn("h-7 w-7 flex items-center justify-center rounded text-xs",
- p === copyPage ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted")}>{p}
- );
- })}
-
{ const p = copyPage + 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage >= copyTotalPages}
- className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed">
-
{ setCopyPage(copyTotalPages); searchCopyTargets(copyTotalPages); }} disabled={copyPage >= copyTotalPages}
- className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed">
+ ) : (
+
+ {/* 좌측: 복사 대상 품목 선택 */}
+
+
+ 복사 대상 품목 선택
+ {copyCheckedIds.length > 0 && 선택 {copyCheckedIds.length}건}
+
+
+ setCopySearchKeyword(e.target.value)}
+ onKeyDown={(e) => { if (e.key === "Enter") handleCopySearch(); }} />
+
+ {copySearchLoading ? : }
+
+
+
+
+
+
+
+ 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()}건
+
+ { setCopyPage(1); searchCopyTargets(1); }} disabled={copyPage <= 1}
+ className="h-6 w-6 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed">
+ { const p = copyPage - 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage <= 1}
+ className="h-6 w-6 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed">
+ {copyPage}/{copyTotalPages}
+ { const p = copyPage + 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage >= copyTotalPages}
+ className="h-6 w-6 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed">
+ { setCopyPage(copyTotalPages); searchCopyTargets(copyTotalPages); }} disabled={copyPage >= copyTotalPages}
+ className="h-6 w-6 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed">
+
+
+
+
+ {/* 우측: 편집 폼 (등록/수정 폼과 동일 구조) */}
+
+
+ 복사할 검사정보 편집 (기준: {selectedItemCode})
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
검사유형 선택
+
+ {INSPECTION_TYPES.map(({ key, label }) => (
+
+ setCopyForm(p => ({ ...p, [key]: !!v }))} />
+
+
+ ))}
+
+
+
+ {INSPECTION_TYPES.filter(t => !!copyForm[t.key]).map(({ key, label }) => (
+
+
toggleCopyCollapse(key)}>
+ {label}
+ 검사항목 설정
+ {(copyInspectionRows[key] || []).length}개
+
+ {!copyCollapsedTypes[key] && (
+
+
+
검사항목 목록
+
addCopyInspRow(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 || "-"}
+
+ removeCopyInspRow(key, row.id)}>
+
+
+ ))}
+
+
+
+
+ )}
+
+ ))}
+
+
-
- >)}
+ )}
setCopyModalOpen(false)} disabled={copying}>취소
diff --git a/frontend/app/(main)/COMPANY_29/sales/sales-item/page.tsx b/frontend/app/(main)/COMPANY_29/sales/sales-item/page.tsx
index e03b9b65..8db939e9 100644
--- a/frontend/app/(main)/COMPANY_29/sales/sales-item/page.tsx
+++ b/frontend/app/(main)/COMPANY_29/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_30/logistics/info/page.tsx b/frontend/app/(main)/COMPANY_30/logistics/info/page.tsx
index 35764744..d62133ab 100644
--- a/frontend/app/(main)/COMPANY_30/logistics/info/page.tsx
+++ b/frontend/app/(main)/COMPANY_30/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_30`
+ );
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_30/logistics/inventory/page.tsx b/frontend/app/(main)/COMPANY_30/logistics/inventory/page.tsx
index cb889be7..f41c3439 100644
--- a/frontend/app/(main)/COMPANY_30/logistics/inventory/page.tsx
+++ b/frontend/app/(main)/COMPANY_30/logistics/inventory/page.tsx
@@ -189,12 +189,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_30/logistics/material-status/page.tsx b/frontend/app/(main)/COMPANY_30/logistics/material-status/page.tsx
index 87fc9766..698e44a5 100644
--- a/frontend/app/(main)/COMPANY_30/logistics/material-status/page.tsx
+++ b/frontend/app/(main)/COMPANY_30/logistics/material-status/page.tsx
@@ -648,7 +648,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_30/logistics/packaging/page.tsx b/frontend/app/(main)/COMPANY_30/logistics/packaging/page.tsx
index 74585bb8..66b467bb 100644
--- a/frontend/app/(main)/COMPANY_30/logistics/packaging/page.tsx
+++ b/frontend/app/(main)/COMPANY_30/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()}
handleDeletePkgItem(item)}>
@@ -954,8 +994,8 @@ export default function PackagingPage() {
{item.item_number}
{item.item_name}
{item.spec || "-"}
- {item.material || "-"}
- {item.unit || "EA"}
+ {resolveCat("material", item.material) || "-"}
+ {resolveCat("inventory_unit", item.inventory_unit) || "EA"}
))}
diff --git a/frontend/app/(main)/COMPANY_30/logistics/warehouse/page.tsx b/frontend/app/(main)/COMPANY_30/logistics/warehouse/page.tsx
index c148cbf6..51aa0610 100644
--- a/frontend/app/(main)/COMPANY_30/logistics/warehouse/page.tsx
+++ b/frontend/app/(main)/COMPANY_30/logistics/warehouse/page.tsx
@@ -158,6 +158,10 @@ export default function WarehouseManagementPage() {
const [rackStatus, setRackStatus] = useState("");
const [rackPreview, setRackPreview] = useState([]);
const [rackSaving, setRackSaving] = useState(false);
+ // 위치명 접미사 (자동 조립: {zone}{구역접미사}-{row}{열접미사}-{level}{단접미사})
+ const [rackZoneLabel, setRackZoneLabel] = useState("구역");
+ const [rackRowLabel, setRackRowLabel] = useState("열");
+ const [rackLevelLabel, setRackLevelLabel] = useState("단");
// 카테고리 옵션
const [categoryOptions, setCategoryOptions] = useState<
@@ -636,7 +640,7 @@ export default function WarehouseManagementPage() {
duplicates.push(locationCode);
continue;
}
- const locationName = `${zoneCode}구역-${rowStr}열-${level}단`;
+ const locationName = `${zoneCode}${rackZoneLabel}-${rowStr}${rackRowLabel}-${level}${rackLevelLabel}`;
items.push({
location_code: locationCode,
location_name: locationName,
@@ -1502,6 +1506,38 @@ export default function WarehouseManagementPage() {
+ {/* 위치명 형식 — 구역/열/단 뒤에 붙일 표현만 자유 입력 */}
+
+
{/* 등록 미리보기 */}
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() {
등록
+ {
+ if (!selectedBomId || !bomHeader) {
+ toast.error("수정할 BOM을 선택해주세요");
+ return;
+ }
+ openEditModal();
+ }}
+ disabled={!selectedBomId || !bomHeader}
+ >
+
+ 수정
+
([]);
+ const [formOutsources, setFormOutsources] = useState([]);
+ const [subcontractorOptions, setSubcontractorOptions] = useState<{ id: string; code: string; name: string }[]>([]);
const [detailSubmitting, setDetailSubmitting] = useState(false);
const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
@@ -116,7 +117,7 @@ export function ItemRoutingTab() {
page: 1, size: 500, autoFilter: true,
});
const rows = res.data?.data?.data || res.data?.data?.rows || [];
- setSubcontractorOptions(rows.map((r: any) => ({ code: r.subcontractor_code || r.id, name: r.subcontractor_name || "" })));
+ setSubcontractorOptions(rows.map((r: any) => ({ id: r.id, code: r.subcontractor_code || "", name: r.subcontractor_name || "" })));
} catch { /* skip */ }
})();
}, []);
@@ -281,7 +282,7 @@ export function ItemRoutingTab() {
setFormFixedOrder("Y");
setFormWorkType("내부");
setFormStandardTime("");
- setFormOutsource("");
+ setFormOutsources([]);
setDetailDialogOpen(true);
};
@@ -308,7 +309,19 @@ export function ItemRoutingTab() {
setFormFixedOrder(row.is_fixed_order === "N" ? "N" : "Y");
setFormWorkType(row.work_type || "내부");
setFormStandardTime(row.standard_time || "");
- setFormOutsource(row.outsource_supplier || "");
+ // 우선순위: id 배열 → legacy code 배열(id로 역변환) → legacy 단일 code(id로 역변환)
+ let loadedIds: string[] = [];
+ if (Array.isArray(row.outsource_supplier_ids) && row.outsource_supplier_ids.length > 0) {
+ loadedIds = row.outsource_supplier_ids;
+ } else {
+ const legacyCodes = Array.isArray(row.outsource_supplier_list) && row.outsource_supplier_list.length > 0
+ ? row.outsource_supplier_list
+ : (row.outsource_supplier ? [row.outsource_supplier] : []);
+ loadedIds = legacyCodes
+ .map((c: string) => subcontractorOptions.find((s) => s.code === c)?.id)
+ .filter((v): v is string => Boolean(v));
+ }
+ setFormOutsources(loadedIds);
setDetailDialogOpen(true);
};
@@ -329,7 +342,10 @@ export function ItemRoutingTab() {
return;
}
const proc = processes.find((p) => p.process_code === formProcessCode);
- const outsource = showOutsourceField ? formOutsource.trim() : "";
+ const outsourceIds = showOutsourceField ? formOutsources.filter((s) => s && s.trim() !== "") : [];
+ const outsourcePrimaryCode = outsourceIds.length > 0
+ ? (subcontractorOptions.find((s) => s.id === outsourceIds[0])?.code || "")
+ : "";
setDetailSubmitting(true);
try {
@@ -344,7 +360,8 @@ export function ItemRoutingTab() {
is_fixed_order: formFixedOrder,
work_type: formWorkType,
standard_time: st || "0",
- outsource_supplier: outsource,
+ outsource_supplier: outsourcePrimaryCode,
+ outsource_supplier_ids: outsourceIds,
};
setDetails((prev) => sortDetailsBySeq([...prev, newRow]));
toast.success("공정이 추가되었어요. 저장을 눌러 반영해주세요");
@@ -362,7 +379,8 @@ export function ItemRoutingTab() {
is_fixed_order: formFixedOrder,
work_type: formWorkType,
standard_time: st || "0",
- outsource_supplier: outsource,
+ outsource_supplier: outsourcePrimaryCode,
+ outsource_supplier_ids: outsourceIds,
}
: d,
),
@@ -399,6 +417,7 @@ export function ItemRoutingTab() {
work_type: d.work_type || "내부",
standard_time: String(d.standard_time ?? "0"),
outsource_supplier: d.outsource_supplier || "",
+ outsource_supplier_ids: d.outsource_supplier_ids || [],
}));
setSaving(true);
@@ -480,11 +499,23 @@ export function ItemRoutingTab() {
const detailsGridData = useMemo(
() =>
- details.map((d) => ({
- ...d,
- process_display: d.process_name || d.process_code,
- outsource_display: subcontractorOptions.find((s) => s.code === d.outsource_supplier)?.name || d.outsource_supplier || "—",
- })),
+ details.map((d) => {
+ const ids = Array.isArray(d.outsource_supplier_ids) && d.outsource_supplier_ids.length > 0
+ ? d.outsource_supplier_ids
+ : [];
+ let names = ids
+ .map((i) => subcontractorOptions.find((s) => s.id === i)?.name)
+ .filter((v): v is string => Boolean(v));
+ // 레거시 폴백: id 매핑 없을 때 단일 code로 표시
+ if (names.length === 0 && d.outsource_supplier) {
+ names = [subcontractorOptions.find((s) => s.code === d.outsource_supplier)?.name || d.outsource_supplier];
+ }
+ return {
+ ...d,
+ process_display: d.process_name || d.process_code,
+ outsource_display: names.length === 0 ? "—" : names.join(", "),
+ };
+ }),
[details, subcontractorOptions],
);
@@ -909,15 +940,46 @@ export function ItemRoutingTab() {
{showOutsourceField && (
-
-
+
+
+
+
+
+ {formOutsources.length === 0
+ ? "외주업체 선택"
+ : formOutsources
+ .map((i) => subcontractorOptions.find((s) => s.id === i)?.name || i)
+ .join(", ")}
+
+ {formOutsources.length}
+
+
+
+
+ {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(); }} />
-
- {copySearchLoading ? : <>검색>}
-
-
-
-
-
-
-
- 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}건}
-
-
-
{ setCopyPage(1); searchCopyTargets(1); }} disabled={copyPage <= 1}
- className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed">
-
{ const p = copyPage - 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage <= 1}
- className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed">
- {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 (
-
{ setCopyPage(p); searchCopyTargets(p); }}
- className={cn("h-7 w-7 flex items-center justify-center rounded text-xs",
- p === copyPage ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted")}>{p}
- );
- })}
-
{ const p = copyPage + 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage >= copyTotalPages}
- className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed">
-
{ setCopyPage(copyTotalPages); searchCopyTargets(copyTotalPages); }} disabled={copyPage >= copyTotalPages}
- className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed">
+ ) : (
+
+ {/* 좌측: 복사 대상 품목 선택 */}
+
+
+ 복사 대상 품목 선택
+ {copyCheckedIds.length > 0 && 선택 {copyCheckedIds.length}건}
+
+
+ setCopySearchKeyword(e.target.value)}
+ onKeyDown={(e) => { if (e.key === "Enter") handleCopySearch(); }} />
+
+ {copySearchLoading ? : }
+
+
+
+
+
+
+
+ 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()}건
+
+ { setCopyPage(1); searchCopyTargets(1); }} disabled={copyPage <= 1}
+ className="h-6 w-6 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed">
+ { const p = copyPage - 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage <= 1}
+ className="h-6 w-6 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed">
+ {copyPage}/{copyTotalPages}
+ { const p = copyPage + 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage >= copyTotalPages}
+ className="h-6 w-6 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed">
+ { setCopyPage(copyTotalPages); searchCopyTargets(copyTotalPages); }} disabled={copyPage >= copyTotalPages}
+ className="h-6 w-6 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed">
+
+
+
+
+ {/* 우측: 편집 폼 (등록/수정 폼과 동일 구조) */}
+
+
+ 복사할 검사정보 편집 (기준: {selectedItemCode})
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
검사유형 선택
+
+ {INSPECTION_TYPES.map(({ key, label }) => (
+
+ setCopyForm(p => ({ ...p, [key]: !!v }))} />
+
+
+ ))}
+
+
+
+ {INSPECTION_TYPES.filter(t => !!copyForm[t.key]).map(({ key, label }) => (
+
+
toggleCopyCollapse(key)}>
+ {label}
+ 검사항목 설정
+ {(copyInspectionRows[key] || []).length}개
+
+ {!copyCollapsedTypes[key] && (
+
+
+
검사항목 목록
+
addCopyInspRow(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 || "-"}
+
+ removeCopyInspRow(key, row.id)}>
+
+
+ ))}
+
+
+
+
+ )}
+
+ ))}
+
+
-
- >)}
+ )}
setCopyModalOpen(false)} disabled={copying}>취소
diff --git a/frontend/app/(main)/COMPANY_30/sales/customer/page.tsx b/frontend/app/(main)/COMPANY_30/sales/customer/page.tsx
index aee2df5c..c1554f01 100644
--- a/frontend/app/(main)/COMPANY_30/sales/customer/page.tsx
+++ b/frontend/app/(main)/COMPANY_30/sales/customer/page.tsx
@@ -191,13 +191,13 @@ export default function CustomerManagementPage() {
const optMap: Record = {};
for (const col of ["division", "status"]) {
try {
- const res = await apiClient.get(`/table-categories/${CUSTOMER_TABLE}/${col}/values`);
+ const res = await apiClient.get(`/table-categories/${CUSTOMER_TABLE}/${col}/values?filterCompanyCode=COMPANY_30`);
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
} catch { /* skip */ }
}
for (const col of ["division", "inventory_unit", "material"]) {
try {
- const res = await apiClient.get(`/table-categories/item_info/${col}/values`);
+ const res = await apiClient.get(`/table-categories/item_info/${col}/values?filterCompanyCode=COMPANY_30`);
if (res.data?.success) optMap[`item_${col}`] = flatten(res.data.data || []);
} catch { /* skip */ }
}
@@ -206,7 +206,7 @@ export default function CustomerManagementPage() {
const priceOpts: Record = {};
for (const col of ["base_price_type", "currency_code", "discount_type", "rounding_type", "rounding_unit_value"]) {
try {
- const res = await apiClient.get(`/table-categories/${PRICE_TABLE}/${col}/values`);
+ const res = await apiClient.get(`/table-categories/${PRICE_TABLE}/${col}/values?filterCompanyCode=COMPANY_30`);
if (res.data?.success) priceOpts[col] = flatten(res.data.data || []);
} catch { /* skip */ }
}
@@ -214,7 +214,7 @@ export default function CustomerManagementPage() {
// 세금유형 카테고리
try {
- const taxRes = await apiClient.get(`/table-categories/customer_tax_type/tax_type_name/values`);
+ const taxRes = await apiClient.get(`/table-categories/customer_tax_type/tax_type_name/values?filterCompanyCode=COMPANY_30`);
if (taxRes.data?.success) setTaxTypeOptions(flatten(taxRes.data.data || []));
} catch { /* skip */ }
};
@@ -593,9 +593,12 @@ export default function CustomerManagementPage() {
} catch { /* skip */ }
};
- const openCustomerEdit = () => {
- if (!selectedCustomer) return;
- const rawData = rawCustomers.find((c) => c.id === selectedCustomerId);
+ const openCustomerEdit = (rowArg?: any) => {
+ const targetId = rowArg?.id ?? selectedCustomerId;
+ const rawData =
+ (rowArg && !("_resolved" in rowArg) ? rowArg : null) ||
+ rawCustomers.find((c) => String(c.id) === String(targetId));
+ if (!rawData && !selectedCustomer) return;
setCustomerForm({ ...(rawData || selectedCustomer) });
setFormErrors({});
setCustomerEditMode(true);
@@ -607,8 +610,10 @@ export default function CustomerManagementPage() {
setModalContactEditId(null);
setModalDeliveryEditId(null);
// 수정 모드에서는 바로 조회
- const code = (rawData || selectedCustomer).customer_code;
- const id = (rawData || selectedCustomer).id;
+ const targetCustomer = rawData || selectedCustomer;
+ if (!targetCustomer) { setCustomerModalOpen(true); return; }
+ const code = targetCustomer.customer_code;
+ const id = targetCustomer.id;
if (id) {
fetchModalContacts(id);
// 세금유형 로드
@@ -1478,7 +1483,11 @@ export default function CustomerManagementPage() {
emptyMessage="등록된 거래처가 없어요"
selectedId={selectedCustomerId}
onSelect={(id) => setSelectedCustomerId(id)}
- onRowDoubleClick={(row) => { setSelectedCustomerId(row.id); openCustomerEdit(); }}
+ onRowDoubleClick={(row) => {
+ setSelectedCustomerId(row.id);
+ const rawRow = rawCustomers.find((c) => String(c.id) === String(row.id));
+ openCustomerEdit(rawRow || row);
+ }}
showRowNumber
showPagination
defaultPageSize={20}
diff --git a/frontend/app/(main)/COMPANY_30/sales/sales-item/page.tsx b/frontend/app/(main)/COMPANY_30/sales/sales-item/page.tsx
index 3c52e0fe..b35c7213 100644
--- a/frontend/app/(main)/COMPANY_30/sales/sales-item/page.tsx
+++ b/frontend/app/(main)/COMPANY_30/sales/sales-item/page.tsx
@@ -317,6 +317,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_7/logistics/info/page.tsx b/frontend/app/(main)/COMPANY_7/logistics/info/page.tsx
index 5f44e0ec..a88403b8 100644
--- a/frontend/app/(main)/COMPANY_7/logistics/info/page.tsx
+++ b/frontend/app/(main)/COMPANY_7/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_7`
+ );
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_7/logistics/inventory/page.tsx b/frontend/app/(main)/COMPANY_7/logistics/inventory/page.tsx
index ac27563d..5b6e6c41 100644
--- a/frontend/app/(main)/COMPANY_7/logistics/inventory/page.tsx
+++ b/frontend/app/(main)/COMPANY_7/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_7/logistics/material-status/page.tsx b/frontend/app/(main)/COMPANY_7/logistics/material-status/page.tsx
index 58354385..42d9a69a 100644
--- a/frontend/app/(main)/COMPANY_7/logistics/material-status/page.tsx
+++ b/frontend/app/(main)/COMPANY_7/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_7/logistics/packaging/page.tsx b/frontend/app/(main)/COMPANY_7/logistics/packaging/page.tsx
index 74585bb8..66b467bb 100644
--- a/frontend/app/(main)/COMPANY_7/logistics/packaging/page.tsx
+++ b/frontend/app/(main)/COMPANY_7/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()}
handleDeletePkgItem(item)}>
@@ -954,8 +994,8 @@ export default function PackagingPage() {
{item.item_number}
{item.item_name}
{item.spec || "-"}
- {item.material || "-"}
- {item.unit || "EA"}
+ {resolveCat("material", item.material) || "-"}
+ {resolveCat("inventory_unit", item.inventory_unit) || "EA"}
))}
diff --git a/frontend/app/(main)/COMPANY_7/logistics/warehouse/page.tsx b/frontend/app/(main)/COMPANY_7/logistics/warehouse/page.tsx
index 1d409dd2..544ab0c4 100644
--- a/frontend/app/(main)/COMPANY_7/logistics/warehouse/page.tsx
+++ b/frontend/app/(main)/COMPANY_7/logistics/warehouse/page.tsx
@@ -158,6 +158,10 @@ export default function WarehouseManagementPage() {
const [rackStatus, setRackStatus] = useState("");
const [rackPreview, setRackPreview] = useState([]);
const [rackSaving, setRackSaving] = useState(false);
+ // 위치명 접미사 (자동 조립: {zone}{구역접미사}-{row}{열접미사}-{level}{단접미사})
+ const [rackZoneLabel, setRackZoneLabel] = useState("구역");
+ const [rackRowLabel, setRackRowLabel] = useState("열");
+ const [rackLevelLabel, setRackLevelLabel] = useState("단");
// 카테고리 옵션
const [categoryOptions, setCategoryOptions] = useState<
@@ -645,7 +649,7 @@ export default function WarehouseManagementPage() {
duplicates.push(locationCode);
continue;
}
- const locationName = `${zoneCode}구역-${rowStr}열-${level}단`;
+ const locationName = `${zoneCode}${rackZoneLabel}-${rowStr}${rackRowLabel}-${level}${rackLevelLabel}`;
items.push({
location_code: locationCode,
location_name: locationName,
@@ -1511,6 +1515,38 @@ export default function WarehouseManagementPage() {
+ {/* 위치명 형식 — 구역/열/단 뒤에 붙일 표현만 자유 입력 */}
+
+
{/* 등록 미리보기 */}
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() {
등록
+ {
+ if (!selectedBomId || !bomHeader) {
+ toast.error("수정할 BOM을 선택해주세요");
+ return;
+ }
+ openEditModal();
+ }}
+ disabled={!selectedBomId || !bomHeader}
+ >
+
+ 수정
+
void;
+ placeholder?: string;
+ searchable?: boolean;
+ triggerClassName?: string;
+ emptyMessage?: string;
+}
+
+function MultiSelectPopover({ options, value, onChange, placeholder = "선택", searchable = false, triggerClassName, emptyMessage = "항목이 없어요" }: MultiSelectPopoverProps) {
+ const [open, setOpen] = useState(false);
+ const [keyword, setKeyword] = useState("");
+
+ const selectedSet = useMemo(() => new Set(value), [value]);
+ const toggle = (val: string) => {
+ if (selectedSet.has(val)) onChange(value.filter(v => v !== val));
+ else onChange([...value, val]);
+ };
+
+ const filtered = useMemo(() => {
+ if (!searchable || !keyword.trim()) return options;
+ const k = keyword.trim().toLowerCase();
+ return options.filter(o => o.label.toLowerCase().includes(k) || (o.sub || "").toLowerCase().includes(k));
+ }, [options, keyword, searchable]);
+
+ const display = useMemo(() => {
+ if (value.length === 0) return placeholder;
+ if (value.length === 1) return options.find(o => o.value === value[0])?.label || value[0];
+ if (value.length === 2) {
+ const labels = value.map(v => options.find(o => o.value === v)?.label || v);
+ return labels.join(", ");
+ }
+ return `${value.length}개 선택`;
+ }, [value, options, placeholder]);
+
+ return (
+
+
+
+ {display}
+
+
+
+
+ {searchable && (
+
+ setKeyword(e.target.value)} className="h-7 text-xs" />
+
+ )}
+
+ {filtered.length === 0 ? (
+
{emptyMessage}
+ ) : filtered.map(opt => (
+
+ ))}
+
+ {value.length > 0 && (
+
+ {value.length}개 선택됨
+ onChange([])}>초기화
+
+ )}
+
+
+ );
}
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))} />
setConfirmItems(prev => prev.filter((_, i) => i !== idx))}>
@@ -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))} />
setEditItems(prev => prev.filter((_, i) => i !== idx))}>
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(); }} />
-
- {copySearchLoading ? : <>검색>}
-
-
-
-
-
-
-
- 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}건}
-
-
-
{ setCopyPage(1); searchCopyTargets(1); }} disabled={copyPage <= 1}
- className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed">
-
{ const p = copyPage - 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage <= 1}
- className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed">
- {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 (
-
{ setCopyPage(p); searchCopyTargets(p); }}
- className={cn("h-7 w-7 flex items-center justify-center rounded text-xs",
- p === copyPage ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted")}>{p}
- );
- })}
-
{ const p = copyPage + 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage >= copyTotalPages}
- className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed">
-
{ setCopyPage(copyTotalPages); searchCopyTargets(copyTotalPages); }} disabled={copyPage >= copyTotalPages}
- className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed">
+ ) : (
+
+ {/* 좌측: 복사 대상 품목 선택 */}
+
+
+ 복사 대상 품목 선택
+ {copyCheckedIds.length > 0 && 선택 {copyCheckedIds.length}건}
+
+
+ setCopySearchKeyword(e.target.value)}
+ onKeyDown={(e) => { if (e.key === "Enter") handleCopySearch(); }} />
+
+ {copySearchLoading ? : }
+
+
+
+
+
+
+
+ 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()}건
+
+ { setCopyPage(1); searchCopyTargets(1); }} disabled={copyPage <= 1}
+ className="h-6 w-6 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed">
+ { const p = copyPage - 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage <= 1}
+ className="h-6 w-6 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed">
+ {copyPage}/{copyTotalPages}
+ { const p = copyPage + 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage >= copyTotalPages}
+ className="h-6 w-6 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed">
+ { setCopyPage(copyTotalPages); searchCopyTargets(copyTotalPages); }} disabled={copyPage >= copyTotalPages}
+ className="h-6 w-6 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed">
+
+
+
+
+ {/* 우측: 편집 폼 (등록/수정 폼과 동일 구조) */}
+
+
+ 복사할 검사정보 편집 (기준: {selectedItemCode})
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
검사유형 선택
+
+ {INSPECTION_TYPES.map(({ key, label }) => (
+
+ setCopyForm(p => ({ ...p, [key]: !!v }))} />
+
+
+ ))}
+
+
+
+ {INSPECTION_TYPES.filter(t => !!copyForm[t.key]).map(({ key, label }) => (
+
+
toggleCopyCollapse(key)}>
+ {label}
+ 검사항목 설정
+ {(copyInspectionRows[key] || []).length}개
+
+ {!copyCollapsedTypes[key] && (
+
+
+
검사항목 목록
+
addCopyInspRow(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 || "-"}
+
+ removeCopyInspRow(key, row.id)}>
+
+
+ ))}
+
+
+
+
+ )}
+
+ ))}
+
+
-
- >)}
+ )}
setCopyModalOpen(false)} disabled={copying}>취소
diff --git a/frontend/app/(main)/COMPANY_7/sales/sales-item/page.tsx b/frontend/app/(main)/COMPANY_7/sales/sales-item/page.tsx
index 4905a814..8be08768 100644
--- a/frontend/app/(main)/COMPANY_7/sales/sales-item/page.tsx
+++ b/frontend/app/(main)/COMPANY_7/sales/sales-item/page.tsx
@@ -329,6 +329,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_8/logistics/info/page.tsx b/frontend/app/(main)/COMPANY_8/logistics/info/page.tsx
index 5f44e0ec..3fd75c08 100644
--- a/frontend/app/(main)/COMPANY_8/logistics/info/page.tsx
+++ b/frontend/app/(main)/COMPANY_8/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_8`
+ );
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_8/logistics/inventory/page.tsx b/frontend/app/(main)/COMPANY_8/logistics/inventory/page.tsx
index 2874f8ac..740ecf68 100644
--- a/frontend/app/(main)/COMPANY_8/logistics/inventory/page.tsx
+++ b/frontend/app/(main)/COMPANY_8/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_8/logistics/material-status/page.tsx b/frontend/app/(main)/COMPANY_8/logistics/material-status/page.tsx
index 58354385..42d9a69a 100644
--- a/frontend/app/(main)/COMPANY_8/logistics/material-status/page.tsx
+++ b/frontend/app/(main)/COMPANY_8/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_8/logistics/packaging/page.tsx b/frontend/app/(main)/COMPANY_8/logistics/packaging/page.tsx
index 74585bb8..66b467bb 100644
--- a/frontend/app/(main)/COMPANY_8/logistics/packaging/page.tsx
+++ b/frontend/app/(main)/COMPANY_8/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()}
handleDeletePkgItem(item)}>
@@ -954,8 +994,8 @@ export default function PackagingPage() {
{item.item_number}
{item.item_name}
{item.spec || "-"}
- {item.material || "-"}
- {item.unit || "EA"}
+ {resolveCat("material", item.material) || "-"}
+ {resolveCat("inventory_unit", item.inventory_unit) || "EA"}
))}
diff --git a/frontend/app/(main)/COMPANY_8/logistics/warehouse/page.tsx b/frontend/app/(main)/COMPANY_8/logistics/warehouse/page.tsx
index 6c7b71b9..21880a04 100644
--- a/frontend/app/(main)/COMPANY_8/logistics/warehouse/page.tsx
+++ b/frontend/app/(main)/COMPANY_8/logistics/warehouse/page.tsx
@@ -158,6 +158,10 @@ export default function WarehouseManagementPage() {
const [rackStatus, setRackStatus] = useState("");
const [rackPreview, setRackPreview] = useState([]);
const [rackSaving, setRackSaving] = useState(false);
+ // 위치명 접미사 (자동 조립: {zone}{구역접미사}-{row}{열접미사}-{level}{단접미사})
+ const [rackZoneLabel, setRackZoneLabel] = useState("구역");
+ const [rackRowLabel, setRackRowLabel] = useState("열");
+ const [rackLevelLabel, setRackLevelLabel] = useState("단");
// 카테고리 옵션
const [categoryOptions, setCategoryOptions] = useState<
@@ -636,7 +640,7 @@ export default function WarehouseManagementPage() {
duplicates.push(locationCode);
continue;
}
- const locationName = `${zoneCode}구역-${rowStr}열-${level}단`;
+ const locationName = `${zoneCode}${rackZoneLabel}-${rowStr}${rackRowLabel}-${level}${rackLevelLabel}`;
items.push({
location_code: locationCode,
location_name: locationName,
@@ -1502,6 +1506,38 @@ export default function WarehouseManagementPage() {
+ {/* 위치명 형식 — 구역/열/단 뒤에 붙일 표현만 자유 입력 */}
+
+
{/* 등록 미리보기 */}
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() {
등록
+ {
+ if (!selectedBomId || !bomHeader) {
+ toast.error("수정할 BOM을 선택해주세요");
+ return;
+ }
+ openEditModal();
+ }}
+ disabled={!selectedBomId || !bomHeader}
+ >
+
+ 수정
+
([]);
+ const [formOutsources, setFormOutsources] = useState([]);
+ const [subcontractorOptions, setSubcontractorOptions] = useState<{ id: string; code: string; name: string }[]>([]);
const [detailSubmitting, setDetailSubmitting] = useState(false);
const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
@@ -116,7 +117,7 @@ export function ItemRoutingTab() {
page: 1, size: 500, autoFilter: true,
});
const rows = res.data?.data?.data || res.data?.data?.rows || [];
- setSubcontractorOptions(rows.map((r: any) => ({ code: r.subcontractor_code || r.id, name: r.subcontractor_name || "" })));
+ setSubcontractorOptions(rows.map((r: any) => ({ id: r.id, code: r.subcontractor_code || "", name: r.subcontractor_name || "" })));
} catch { /* skip */ }
})();
}, []);
@@ -281,7 +282,7 @@ export function ItemRoutingTab() {
setFormFixedOrder("Y");
setFormWorkType("내부");
setFormStandardTime("");
- setFormOutsource("");
+ setFormOutsources([]);
setDetailDialogOpen(true);
};
@@ -308,7 +309,19 @@ export function ItemRoutingTab() {
setFormFixedOrder(row.is_fixed_order === "N" ? "N" : "Y");
setFormWorkType(row.work_type || "내부");
setFormStandardTime(row.standard_time || "");
- setFormOutsource(row.outsource_supplier || "");
+ // 우선순위: id 배열 → legacy code 배열(id로 역변환) → legacy 단일 code(id로 역변환)
+ let loadedIds: string[] = [];
+ if (Array.isArray(row.outsource_supplier_ids) && row.outsource_supplier_ids.length > 0) {
+ loadedIds = row.outsource_supplier_ids;
+ } else {
+ const legacyCodes = Array.isArray(row.outsource_supplier_list) && row.outsource_supplier_list.length > 0
+ ? row.outsource_supplier_list
+ : (row.outsource_supplier ? [row.outsource_supplier] : []);
+ loadedIds = legacyCodes
+ .map((c: string) => subcontractorOptions.find((s) => s.code === c)?.id)
+ .filter((v): v is string => Boolean(v));
+ }
+ setFormOutsources(loadedIds);
setDetailDialogOpen(true);
};
@@ -329,7 +342,10 @@ export function ItemRoutingTab() {
return;
}
const proc = processes.find((p) => p.process_code === formProcessCode);
- const outsource = showOutsourceField ? formOutsource.trim() : "";
+ const outsourceIds = showOutsourceField ? formOutsources.filter((s) => s && s.trim() !== "") : [];
+ const outsourcePrimaryCode = outsourceIds.length > 0
+ ? (subcontractorOptions.find((s) => s.id === outsourceIds[0])?.code || "")
+ : "";
setDetailSubmitting(true);
try {
@@ -344,7 +360,8 @@ export function ItemRoutingTab() {
is_fixed_order: formFixedOrder,
work_type: formWorkType,
standard_time: st || "0",
- outsource_supplier: outsource,
+ outsource_supplier: outsourcePrimaryCode,
+ outsource_supplier_ids: outsourceIds,
};
setDetails((prev) => sortDetailsBySeq([...prev, newRow]));
toast.success("공정이 추가되었어요. 저장을 눌러 반영해주세요");
@@ -362,7 +379,8 @@ export function ItemRoutingTab() {
is_fixed_order: formFixedOrder,
work_type: formWorkType,
standard_time: st || "0",
- outsource_supplier: outsource,
+ outsource_supplier: outsourcePrimaryCode,
+ outsource_supplier_ids: outsourceIds,
}
: d,
),
@@ -399,6 +417,7 @@ export function ItemRoutingTab() {
work_type: d.work_type || "내부",
standard_time: String(d.standard_time ?? "0"),
outsource_supplier: d.outsource_supplier || "",
+ outsource_supplier_ids: d.outsource_supplier_ids || [],
}));
setSaving(true);
@@ -480,11 +499,23 @@ export function ItemRoutingTab() {
const detailsGridData = useMemo(
() =>
- details.map((d) => ({
- ...d,
- process_display: d.process_name || d.process_code,
- outsource_display: subcontractorOptions.find((s) => s.code === d.outsource_supplier)?.name || d.outsource_supplier || "—",
- })),
+ details.map((d) => {
+ const ids = Array.isArray(d.outsource_supplier_ids) && d.outsource_supplier_ids.length > 0
+ ? d.outsource_supplier_ids
+ : [];
+ let names = ids
+ .map((i) => subcontractorOptions.find((s) => s.id === i)?.name)
+ .filter((v): v is string => Boolean(v));
+ // 레거시 폴백: id 매핑 없을 때 단일 code로 표시
+ if (names.length === 0 && d.outsource_supplier) {
+ names = [subcontractorOptions.find((s) => s.code === d.outsource_supplier)?.name || d.outsource_supplier];
+ }
+ return {
+ ...d,
+ process_display: d.process_name || d.process_code,
+ outsource_display: names.length === 0 ? "—" : names.join(", "),
+ };
+ }),
[details, subcontractorOptions],
);
@@ -909,15 +940,46 @@ export function ItemRoutingTab() {
{showOutsourceField && (
-
-
+
+
+
+
+
+ {formOutsources.length === 0
+ ? "외주업체 선택"
+ : formOutsources
+ .map((i) => subcontractorOptions.find((s) => s.id === i)?.name || i)
+ .join(", ")}
+
+ {formOutsources.length}
+
+
+
+
+ {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(); }} />
-
- {copySearchLoading ? : <>검색>}
-
-
-
-
-
-
-
- 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}건}
-
-
-
{ setCopyPage(1); searchCopyTargets(1); }} disabled={copyPage <= 1}
- className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed">
-
{ const p = copyPage - 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage <= 1}
- className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed">
- {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 (
-
{ setCopyPage(p); searchCopyTargets(p); }}
- className={cn("h-7 w-7 flex items-center justify-center rounded text-xs",
- p === copyPage ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted")}>{p}
- );
- })}
-
{ const p = copyPage + 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage >= copyTotalPages}
- className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed">
-
{ setCopyPage(copyTotalPages); searchCopyTargets(copyTotalPages); }} disabled={copyPage >= copyTotalPages}
- className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed">
+ ) : (
+
+ {/* 좌측: 복사 대상 품목 선택 */}
+
+
+ 복사 대상 품목 선택
+ {copyCheckedIds.length > 0 && 선택 {copyCheckedIds.length}건}
+
+
+ setCopySearchKeyword(e.target.value)}
+ onKeyDown={(e) => { if (e.key === "Enter") handleCopySearch(); }} />
+
+ {copySearchLoading ? : }
+
+
+
+
+
+
+
+ 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()}건
+
+ { setCopyPage(1); searchCopyTargets(1); }} disabled={copyPage <= 1}
+ className="h-6 w-6 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed">
+ { const p = copyPage - 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage <= 1}
+ className="h-6 w-6 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed">
+ {copyPage}/{copyTotalPages}
+ { const p = copyPage + 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage >= copyTotalPages}
+ className="h-6 w-6 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed">
+ { setCopyPage(copyTotalPages); searchCopyTargets(copyTotalPages); }} disabled={copyPage >= copyTotalPages}
+ className="h-6 w-6 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed">
+
+
+
+
+ {/* 우측: 편집 폼 (등록/수정 폼과 동일 구조) */}
+
+
+ 복사할 검사정보 편집 (기준: {selectedItemCode})
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
검사유형 선택
+
+ {INSPECTION_TYPES.map(({ key, label }) => (
+
+ setCopyForm(p => ({ ...p, [key]: !!v }))} />
+
+
+ ))}
+
+
+
+ {INSPECTION_TYPES.filter(t => !!copyForm[t.key]).map(({ key, label }) => (
+
+
toggleCopyCollapse(key)}>
+ {label}
+ 검사항목 설정
+ {(copyInspectionRows[key] || []).length}개
+
+ {!copyCollapsedTypes[key] && (
+
+
+
검사항목 목록
+
addCopyInspRow(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 || "-"}
+
+ removeCopyInspRow(key, row.id)}>
+
+
+ ))}
+
+
+
+
+ )}
+
+ ))}
+
+
-
- >)}
+ )}
setCopyModalOpen(false)} disabled={copying}>취소
diff --git a/frontend/app/(main)/COMPANY_8/sales/sales-item/page.tsx b/frontend/app/(main)/COMPANY_8/sales/sales-item/page.tsx
index e03b9b65..8db939e9 100644
--- a/frontend/app/(main)/COMPANY_8/sales/sales-item/page.tsx
+++ b/frontend/app/(main)/COMPANY_8/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_9/logistics/info/page.tsx b/frontend/app/(main)/COMPANY_9/logistics/info/page.tsx
index 35764744..bb63772c 100644
--- a/frontend/app/(main)/COMPANY_9/logistics/info/page.tsx
+++ b/frontend/app/(main)/COMPANY_9/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_9`
+ );
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_9/logistics/inventory/page.tsx b/frontend/app/(main)/COMPANY_9/logistics/inventory/page.tsx
index 99d579a0..73a8c8b3 100644
--- a/frontend/app/(main)/COMPANY_9/logistics/inventory/page.tsx
+++ b/frontend/app/(main)/COMPANY_9/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_9/logistics/material-status/page.tsx b/frontend/app/(main)/COMPANY_9/logistics/material-status/page.tsx
index 58354385..42d9a69a 100644
--- a/frontend/app/(main)/COMPANY_9/logistics/material-status/page.tsx
+++ b/frontend/app/(main)/COMPANY_9/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_9/logistics/packaging/page.tsx b/frontend/app/(main)/COMPANY_9/logistics/packaging/page.tsx
index 74585bb8..66b467bb 100644
--- a/frontend/app/(main)/COMPANY_9/logistics/packaging/page.tsx
+++ b/frontend/app/(main)/COMPANY_9/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()}
handleDeletePkgItem(item)}>
@@ -954,8 +994,8 @@ export default function PackagingPage() {
{item.item_number}
{item.item_name}
{item.spec || "-"}
- {item.material || "-"}
- {item.unit || "EA"}
+ {resolveCat("material", item.material) || "-"}
+ {resolveCat("inventory_unit", item.inventory_unit) || "EA"}
))}
diff --git a/frontend/app/(main)/COMPANY_9/logistics/warehouse/page.tsx b/frontend/app/(main)/COMPANY_9/logistics/warehouse/page.tsx
index c148cbf6..51aa0610 100644
--- a/frontend/app/(main)/COMPANY_9/logistics/warehouse/page.tsx
+++ b/frontend/app/(main)/COMPANY_9/logistics/warehouse/page.tsx
@@ -158,6 +158,10 @@ export default function WarehouseManagementPage() {
const [rackStatus, setRackStatus] = useState("");
const [rackPreview, setRackPreview] = useState([]);
const [rackSaving, setRackSaving] = useState(false);
+ // 위치명 접미사 (자동 조립: {zone}{구역접미사}-{row}{열접미사}-{level}{단접미사})
+ const [rackZoneLabel, setRackZoneLabel] = useState("구역");
+ const [rackRowLabel, setRackRowLabel] = useState("열");
+ const [rackLevelLabel, setRackLevelLabel] = useState("단");
// 카테고리 옵션
const [categoryOptions, setCategoryOptions] = useState<
@@ -636,7 +640,7 @@ export default function WarehouseManagementPage() {
duplicates.push(locationCode);
continue;
}
- const locationName = `${zoneCode}구역-${rowStr}열-${level}단`;
+ const locationName = `${zoneCode}${rackZoneLabel}-${rowStr}${rackRowLabel}-${level}${rackLevelLabel}`;
items.push({
location_code: locationCode,
location_name: locationName,
@@ -1502,6 +1506,38 @@ export default function WarehouseManagementPage() {
+ {/* 위치명 형식 — 구역/열/단 뒤에 붙일 표현만 자유 입력 */}
+
+
{/* 등록 미리보기 */}
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() {
등록
+ {
+ if (!selectedBomId || !bomHeader) {
+ toast.error("수정할 BOM을 선택해주세요");
+ return;
+ }
+ openEditModal();
+ }}
+ disabled={!selectedBomId || !bomHeader}
+ >
+
+ 수정
+
([]);
+ const [formOutsources, setFormOutsources] = useState([]);
+ const [subcontractorOptions, setSubcontractorOptions] = useState<{ id: string; code: string; name: string }[]>([]);
const [detailSubmitting, setDetailSubmitting] = useState(false);
const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
@@ -116,7 +117,7 @@ export function ItemRoutingTab() {
page: 1, size: 500, autoFilter: true,
});
const rows = res.data?.data?.data || res.data?.data?.rows || [];
- setSubcontractorOptions(rows.map((r: any) => ({ code: r.subcontractor_code || r.id, name: r.subcontractor_name || "" })));
+ setSubcontractorOptions(rows.map((r: any) => ({ id: r.id, code: r.subcontractor_code || "", name: r.subcontractor_name || "" })));
} catch { /* skip */ }
})();
}, []);
@@ -281,7 +282,7 @@ export function ItemRoutingTab() {
setFormFixedOrder("Y");
setFormWorkType("내부");
setFormStandardTime("");
- setFormOutsource("");
+ setFormOutsources([]);
setDetailDialogOpen(true);
};
@@ -308,7 +309,19 @@ export function ItemRoutingTab() {
setFormFixedOrder(row.is_fixed_order === "N" ? "N" : "Y");
setFormWorkType(row.work_type || "내부");
setFormStandardTime(row.standard_time || "");
- setFormOutsource(row.outsource_supplier || "");
+ // 우선순위: id 배열 → legacy code 배열(id로 역변환) → legacy 단일 code(id로 역변환)
+ let loadedIds: string[] = [];
+ if (Array.isArray(row.outsource_supplier_ids) && row.outsource_supplier_ids.length > 0) {
+ loadedIds = row.outsource_supplier_ids;
+ } else {
+ const legacyCodes = Array.isArray(row.outsource_supplier_list) && row.outsource_supplier_list.length > 0
+ ? row.outsource_supplier_list
+ : (row.outsource_supplier ? [row.outsource_supplier] : []);
+ loadedIds = legacyCodes
+ .map((c: string) => subcontractorOptions.find((s) => s.code === c)?.id)
+ .filter((v): v is string => Boolean(v));
+ }
+ setFormOutsources(loadedIds);
setDetailDialogOpen(true);
};
@@ -329,7 +342,10 @@ export function ItemRoutingTab() {
return;
}
const proc = processes.find((p) => p.process_code === formProcessCode);
- const outsource = showOutsourceField ? formOutsource.trim() : "";
+ const outsourceIds = showOutsourceField ? formOutsources.filter((s) => s && s.trim() !== "") : [];
+ const outsourcePrimaryCode = outsourceIds.length > 0
+ ? (subcontractorOptions.find((s) => s.id === outsourceIds[0])?.code || "")
+ : "";
setDetailSubmitting(true);
try {
@@ -344,7 +360,8 @@ export function ItemRoutingTab() {
is_fixed_order: formFixedOrder,
work_type: formWorkType,
standard_time: st || "0",
- outsource_supplier: outsource,
+ outsource_supplier: outsourcePrimaryCode,
+ outsource_supplier_ids: outsourceIds,
};
setDetails((prev) => sortDetailsBySeq([...prev, newRow]));
toast.success("공정이 추가되었어요. 저장을 눌러 반영해주세요");
@@ -362,7 +379,8 @@ export function ItemRoutingTab() {
is_fixed_order: formFixedOrder,
work_type: formWorkType,
standard_time: st || "0",
- outsource_supplier: outsource,
+ outsource_supplier: outsourcePrimaryCode,
+ outsource_supplier_ids: outsourceIds,
}
: d,
),
@@ -399,6 +417,7 @@ export function ItemRoutingTab() {
work_type: d.work_type || "내부",
standard_time: String(d.standard_time ?? "0"),
outsource_supplier: d.outsource_supplier || "",
+ outsource_supplier_ids: d.outsource_supplier_ids || [],
}));
setSaving(true);
@@ -480,11 +499,23 @@ export function ItemRoutingTab() {
const detailsGridData = useMemo(
() =>
- details.map((d) => ({
- ...d,
- process_display: d.process_name || d.process_code,
- outsource_display: subcontractorOptions.find((s) => s.code === d.outsource_supplier)?.name || d.outsource_supplier || "—",
- })),
+ details.map((d) => {
+ const ids = Array.isArray(d.outsource_supplier_ids) && d.outsource_supplier_ids.length > 0
+ ? d.outsource_supplier_ids
+ : [];
+ let names = ids
+ .map((i) => subcontractorOptions.find((s) => s.id === i)?.name)
+ .filter((v): v is string => Boolean(v));
+ // 레거시 폴백: id 매핑 없을 때 단일 code로 표시
+ if (names.length === 0 && d.outsource_supplier) {
+ names = [subcontractorOptions.find((s) => s.code === d.outsource_supplier)?.name || d.outsource_supplier];
+ }
+ return {
+ ...d,
+ process_display: d.process_name || d.process_code,
+ outsource_display: names.length === 0 ? "—" : names.join(", "),
+ };
+ }),
[details, subcontractorOptions],
);
@@ -909,15 +940,46 @@ export function ItemRoutingTab() {
{showOutsourceField && (
-
-
+
+
+
+
+
+ {formOutsources.length === 0
+ ? "외주업체 선택"
+ : formOutsources
+ .map((i) => subcontractorOptions.find((s) => s.id === i)?.name || i)
+ .join(", ")}
+
+ {formOutsources.length}
+
+
+
+
+ {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(); }} />
-
- {copySearchLoading ? : <>검색>}
-
-
-
-
-
-
-
- 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}건}
-
-
-
{ setCopyPage(1); searchCopyTargets(1); }} disabled={copyPage <= 1}
- className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed">
-
{ const p = copyPage - 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage <= 1}
- className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed">
- {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 (
-
{ setCopyPage(p); searchCopyTargets(p); }}
- className={cn("h-7 w-7 flex items-center justify-center rounded text-xs",
- p === copyPage ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted")}>{p}
- );
- })}
-
{ const p = copyPage + 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage >= copyTotalPages}
- className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed">
-
{ setCopyPage(copyTotalPages); searchCopyTargets(copyTotalPages); }} disabled={copyPage >= copyTotalPages}
- className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed">
+ ) : (
+
+ {/* 좌측: 복사 대상 품목 선택 */}
+
+
+ 복사 대상 품목 선택
+ {copyCheckedIds.length > 0 && 선택 {copyCheckedIds.length}건}
+
+
+ setCopySearchKeyword(e.target.value)}
+ onKeyDown={(e) => { if (e.key === "Enter") handleCopySearch(); }} />
+
+ {copySearchLoading ? : }
+
+
+
+
+
+
+
+ 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()}건
+
+ { setCopyPage(1); searchCopyTargets(1); }} disabled={copyPage <= 1}
+ className="h-6 w-6 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed">
+ { const p = copyPage - 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage <= 1}
+ className="h-6 w-6 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed">
+ {copyPage}/{copyTotalPages}
+ { const p = copyPage + 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage >= copyTotalPages}
+ className="h-6 w-6 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed">
+ { setCopyPage(copyTotalPages); searchCopyTargets(copyTotalPages); }} disabled={copyPage >= copyTotalPages}
+ className="h-6 w-6 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed">
+
+
+
+
+ {/* 우측: 편집 폼 (등록/수정 폼과 동일 구조) */}
+
+
+ 복사할 검사정보 편집 (기준: {selectedItemCode})
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
검사유형 선택
+
+ {INSPECTION_TYPES.map(({ key, label }) => (
+
+ setCopyForm(p => ({ ...p, [key]: !!v }))} />
+
+
+ ))}
+
+
+
+ {INSPECTION_TYPES.filter(t => !!copyForm[t.key]).map(({ key, label }) => (
+
+
toggleCopyCollapse(key)}>
+ {label}
+ 검사항목 설정
+ {(copyInspectionRows[key] || []).length}개
+
+ {!copyCollapsedTypes[key] && (
+
+
+
검사항목 목록
+
addCopyInspRow(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 || "-"}
+
+ removeCopyInspRow(key, row.id)}>
+
+
+ ))}
+
+
+
+
+ )}
+
+ ))}
+
+
-
- >)}
+ )}
setCopyModalOpen(false)} disabled={copying}>취소
diff --git a/frontend/app/(main)/COMPANY_9/sales/customer/page.tsx b/frontend/app/(main)/COMPANY_9/sales/customer/page.tsx
index aee2df5c..2293bd91 100644
--- a/frontend/app/(main)/COMPANY_9/sales/customer/page.tsx
+++ b/frontend/app/(main)/COMPANY_9/sales/customer/page.tsx
@@ -191,13 +191,13 @@ export default function CustomerManagementPage() {
const optMap: Record = {};
for (const col of ["division", "status"]) {
try {
- const res = await apiClient.get(`/table-categories/${CUSTOMER_TABLE}/${col}/values`);
+ const res = await apiClient.get(`/table-categories/${CUSTOMER_TABLE}/${col}/values?filterCompanyCode=COMPANY_9`);
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
} catch { /* skip */ }
}
for (const col of ["division", "inventory_unit", "material"]) {
try {
- const res = await apiClient.get(`/table-categories/item_info/${col}/values`);
+ const res = await apiClient.get(`/table-categories/item_info/${col}/values?filterCompanyCode=COMPANY_9`);
if (res.data?.success) optMap[`item_${col}`] = flatten(res.data.data || []);
} catch { /* skip */ }
}
@@ -206,7 +206,7 @@ export default function CustomerManagementPage() {
const priceOpts: Record = {};
for (const col of ["base_price_type", "currency_code", "discount_type", "rounding_type", "rounding_unit_value"]) {
try {
- const res = await apiClient.get(`/table-categories/${PRICE_TABLE}/${col}/values`);
+ const res = await apiClient.get(`/table-categories/${PRICE_TABLE}/${col}/values?filterCompanyCode=COMPANY_9`);
if (res.data?.success) priceOpts[col] = flatten(res.data.data || []);
} catch { /* skip */ }
}
@@ -214,7 +214,7 @@ export default function CustomerManagementPage() {
// 세금유형 카테고리
try {
- const taxRes = await apiClient.get(`/table-categories/customer_tax_type/tax_type_name/values`);
+ const taxRes = await apiClient.get(`/table-categories/customer_tax_type/tax_type_name/values?filterCompanyCode=COMPANY_9`);
if (taxRes.data?.success) setTaxTypeOptions(flatten(taxRes.data.data || []));
} catch { /* skip */ }
};
diff --git a/frontend/app/(main)/COMPANY_9/sales/sales-item/page.tsx b/frontend/app/(main)/COMPANY_9/sales/sales-item/page.tsx
index b98232cf..f3f28610 100644
--- a/frontend/app/(main)/COMPANY_9/sales/sales-item/page.tsx
+++ b/frontend/app/(main)/COMPANY_9/sales/sales-item/page.tsx
@@ -317,6 +317,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/components/layout/AdminPageRenderer.tsx b/frontend/components/layout/AdminPageRenderer.tsx
index e47509a9..1d56dc49 100644
--- a/frontend/components/layout/AdminPageRenderer.tsx
+++ b/frontend/components/layout/AdminPageRenderer.tsx
@@ -172,6 +172,7 @@ const ADMIN_PAGE_REGISTRY: Record> = {
"/COMPANY_16/logistics/info": dynamic(() => import("@/app/(main)/COMPANY_16/logistics/info/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_16/outsourcing/subcontractor": dynamic(() => import("@/app/(main)/COMPANY_16/outsourcing/subcontractor/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_16/outsourcing/subcontractor-item": dynamic(() => import("@/app/(main)/COMPANY_16/outsourcing/subcontractor-item/page"), { ssr: false, loading: LoadingFallback }),
+ "/COMPANY_16/outsourcing/outbound": dynamic(() => import("@/app/(main)/COMPANY_16/outsourcing/outbound/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_16/purchase/order": dynamic(() => import("@/app/(main)/COMPANY_16/purchase/order/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_16/purchase/purchase-item": dynamic(() => import("@/app/(main)/COMPANY_16/purchase/purchase-item/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_16/purchase/supplier": dynamic(() => import("@/app/(main)/COMPANY_16/purchase/supplier/page"), { ssr: false, loading: LoadingFallback }),
diff --git a/frontend/lib/api/materialStatus.ts b/frontend/lib/api/materialStatus.ts
index acbc88e9..8f0c59a4 100644
--- a/frontend/lib/api/materialStatus.ts
+++ b/frontend/lib/api/materialStatus.ts
@@ -22,6 +22,7 @@ export interface WorkOrder {
export interface MaterialLocation {
location: string;
warehouse: string;
+ warehouse_name?: string;
qty: number;
}
diff --git a/frontend/lib/api/outsourcingOutbound.ts b/frontend/lib/api/outsourcingOutbound.ts
new file mode 100644
index 00000000..344c44cb
--- /dev/null
+++ b/frontend/lib/api/outsourcingOutbound.ts
@@ -0,0 +1,121 @@
+import { apiClient } from "./client";
+
+// ===== 타입 =====
+
+export interface OutsourcingCandidate {
+ completed_process_id: string;
+ wo_id: string;
+ completed_seq_no: string;
+ completed_process_code: string;
+ completed_process_name: string;
+ good_qty: number;
+ next_process_id: string;
+ next_seq_no: string;
+ next_process_code: string;
+ next_process_name: string;
+ next_status: string;
+ instruction_no: string;
+ item_code: string;
+ item_name: string;
+ spec: string;
+ material: string;
+ unit: string;
+ subcontractor_id: string;
+ subcontractor_code: string;
+ subcontractor_name: string;
+}
+
+export interface OutsourcingOutboundItem {
+ id: string;
+ outbound_number: string;
+ outbound_type: string;
+ outbound_date: string;
+ reference_number: string;
+ customer_code: string;
+ customer_name: string;
+ item_code: string;
+ item_name: string;
+ specification: string;
+ material: string;
+ unit: string;
+ outbound_qty: number;
+ warehouse_code: string;
+ warehouse_name?: string;
+ location_code: string;
+ outbound_status: string;
+ source_type: string;
+ source_id: string;
+ manager_id: string;
+ memo: string;
+}
+
+export interface WarehouseOption {
+ warehouse_code: string;
+ warehouse_name: string;
+ warehouse_type?: string;
+}
+
+// ===== API 함수 =====
+
+export async function getCandidates(keyword?: string) {
+ const params: Record = {};
+ if (keyword) params.keyword = keyword;
+ const res = await apiClient.get("/outsourcing-outbound/candidates", { params });
+ return res.data as { success: boolean; data: OutsourcingCandidate[] };
+}
+
+export async function getOutsourcingOutboundList(params?: {
+ outbound_status?: string;
+ search_keyword?: string;
+ date_from?: string;
+ date_to?: string;
+}) {
+ const res = await apiClient.get("/outsourcing-outbound/list", { params: params || {} });
+ return res.data as { success: boolean; data: OutsourcingOutboundItem[] };
+}
+
+export async function createOutsourcingOutbound(payload: {
+ outbound_number: string;
+ outbound_date: string;
+ warehouse_code?: string;
+ location_code?: string;
+ manager_id?: string;
+ memo?: string;
+ items: Array<{
+ reference_number?: string;
+ subcontractor_code?: string;
+ subcontractor_name?: string;
+ item_code?: string;
+ item_name?: string;
+ spec?: string;
+ material?: string;
+ unit?: string;
+ outbound_qty: number;
+ completed_process_id?: string;
+ warehouse_code?: string;
+ location_code?: string;
+ }>;
+}) {
+ const res = await apiClient.post("/outsourcing-outbound", payload);
+ return res.data as { success: boolean; data: OutsourcingOutboundItem[]; message?: string };
+}
+
+export async function updateOutsourcingOutbound(id: string, payload: Partial) {
+ const res = await apiClient.put(`/outsourcing-outbound/${id}`, payload);
+ return res.data as { success: boolean; data: OutsourcingOutboundItem };
+}
+
+export async function deleteOutsourcingOutbound(id: string) {
+ const res = await apiClient.delete(`/outsourcing-outbound/${id}`);
+ return res.data as { success: boolean; message?: string };
+}
+
+export async function generateOutsourcingOutboundNumber() {
+ const res = await apiClient.get("/outsourcing-outbound/generate-number");
+ return res.data as { success: boolean; data: string };
+}
+
+export async function getOutsourcingWarehouses() {
+ const res = await apiClient.get("/outsourcing-outbound/warehouses");
+ return res.data as { success: boolean; data: WarehouseOption[] };
+}
diff --git a/frontend/lib/api/packaging.ts b/frontend/lib/api/packaging.ts
index cd3ec742..b172bb10 100644
--- a/frontend/lib/api/packaging.ts
+++ b/frontend/lib/api/packaging.ts
@@ -31,6 +31,8 @@ export interface PkgUnitItem {
item_name?: string;
spec?: string;
unit?: string;
+ inventory_unit?: string | null;
+ material?: string | null;
}
export interface LoadingUnit {
@@ -72,6 +74,7 @@ export interface ItemInfoForPkg {
spec?: string | null;
material: string | null;
unit: string | null;
+ inventory_unit?: string | null;
division: string | null;
}
diff --git a/frontend/lib/registry/components/v2-process-work-standard/ProcessWorkStandardComponent.tsx b/frontend/lib/registry/components/v2-process-work-standard/ProcessWorkStandardComponent.tsx
index 567877a4..98b94623 100644
--- a/frontend/lib/registry/components/v2-process-work-standard/ProcessWorkStandardComponent.tsx
+++ b/frontend/lib/registry/components/v2-process-work-standard/ProcessWorkStandardComponent.tsx
@@ -69,6 +69,8 @@ export function ProcessWorkStandardComponent({
createDetail,
updateDetail,
deleteDetail,
+ reorderWorkItems,
+ reorderDetails,
} = useProcessWorkStandard(config);
// 모달 상태
@@ -217,6 +219,8 @@ export function ProcessWorkStandardComponent({
onCreateDetail={createDetail}
onUpdateDetail={updateDetail}
onDeleteDetail={deleteDetail}
+ onReorderWorkItems={reorderWorkItems}
+ onReorderDetails={reorderDetails}
/>
))}
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"
+ )}
>
+ |
+ {!readonly && onReorderDetails && (
+
+ )}
+ |
{idx + 1}
|
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)}`;
}