Merge branch 'jskim-node' of https://g.wace.me/jskim/vexplor_dev into mhkim-node

This commit is contained in:
kmh
2026-04-22 14:55:32 +09:00
111 changed files with 7067 additions and 1075 deletions
+2
View File
@@ -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 서비스 포트)
@@ -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
@@ -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
`;
@@ -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),
});
}
@@ -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 });
}
}
@@ -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
@@ -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 }); }
}
+2
View File
@@ -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!)
@@ -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;
@@ -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() {
{/* 테이블 영역 */}
<div className="flex-1 overflow-auto">
<EDataTable
columns={getVisibleColumns(tab.key).map((col): EDataTableColumn => ({
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]}
@@ -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<string, string> = {};
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);
@@ -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"
>
<span className="font-semibold font-mono text-primary">
{loc.location || loc.warehouse}
{loc.warehouse_name || loc.location || loc.warehouse}
</span>
<span className="font-semibold">
{loc.qty.toLocaleString()}
@@ -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<string, { code: string; label: string }[]>
>({});
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<string, { code: string; label: string }[]> = {};
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() {
<TableCell className="p-2 font-medium">{item.item_number}</TableCell>
<TableCell className="p-2">{item.item_name || "-"}</TableCell>
<TableCell className="p-2">{item.spec || "-"}</TableCell>
<TableCell className="p-2">{item.unit || "EA"}</TableCell>
<TableCell className="p-2">{resolveCat("inventory_unit", item.inventory_unit) || "EA"}</TableCell>
<TableCell className="p-2 text-right font-semibold">{Number(item.pkg_qty).toLocaleString()}</TableCell>
<TableCell className="p-2 text-center">
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => handleDeletePkgItem(item)}>
@@ -954,8 +994,8 @@ export default function PackagingPage() {
<TableCell className="p-2 font-medium">{item.item_number}</TableCell>
<TableCell className="p-2">{item.item_name}</TableCell>
<TableCell className="p-2">{item.spec || "-"}</TableCell>
<TableCell className="p-2">{item.material || "-"}</TableCell>
<TableCell className="p-2">{item.unit || "EA"}</TableCell>
<TableCell className="p-2">{resolveCat("material", item.material) || "-"}</TableCell>
<TableCell className="p-2">{resolveCat("inventory_unit", item.inventory_unit) || "EA"}</TableCell>
</TableRow>
))}
</TableBody>
@@ -158,6 +158,10 @@ export default function WarehouseManagementPage() {
const [rackStatus, setRackStatus] = useState("");
const [rackPreview, setRackPreview] = useState<any[]>([]);
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() {
</div>
</div>
{/* 위치명 형식 — 구역/열/단 뒤에 붙일 표현만 자유 입력 */}
<div className="rounded-lg border p-3 bg-muted/30 space-y-2">
<Label className="text-xs font-semibold"> </Label>
<div className="flex items-center gap-1 text-xs flex-wrap">
<span className="font-mono text-muted-foreground">A</span>
<Input
value={rackZoneLabel}
onChange={(e) => setRackZoneLabel(e.target.value)}
placeholder="구역"
className="h-8 w-20 text-xs"
/>
<span className="font-mono text-muted-foreground">- 01</span>
<Input
value={rackRowLabel}
onChange={(e) => setRackRowLabel(e.target.value)}
placeholder="열"
className="h-8 w-20 text-xs"
/>
<span className="font-mono text-muted-foreground">- 1</span>
<Input
value={rackLevelLabel}
onChange={(e) => setRackLevelLabel(e.target.value)}
placeholder="단"
className="h-8 w-20 text-xs"
/>
</div>
<p className="text-[11px] text-muted-foreground">
: <span className="font-mono font-semibold">A{rackZoneLabel}-01{rackRowLabel}-1{rackLevelLabel}</span>
{" "} // , .
</p>
</div>
{/* 등록 미리보기 */}
<div>
<div className="flex items-center justify-between mb-3">
@@ -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<string, any> = {};
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 () => {
@@ -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<string, any> = {
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() {
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
if (!selectedBomId || !bomHeader) {
toast.error("수정할 BOM을 선택해주세요");
return;
}
openEditModal();
}}
disabled={!selectedBomId || !bomHeader}
>
<Pencil className="w-3.5 h-3.5 mr-1" />
</Button>
<div className="w-px h-4 bg-border mx-0.5" />
<Button
size="sm"
@@ -12,6 +12,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
import {
@@ -91,8 +92,8 @@ export function ItemRoutingTab() {
const [formFixedOrder, setFormFixedOrder] = useState("Y");
const [formWorkType, setFormWorkType] = useState("내부");
const [formStandardTime, setFormStandardTime] = useState("");
const [formOutsource, setFormOutsource] = useState("");
const [subcontractorOptions, setSubcontractorOptions] = useState<{ code: string; name: string }[]>([]);
const [formOutsources, setFormOutsources] = useState<string[]>([]);
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() {
</div>
{showOutsourceField && (
<div className="space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select value={formOutsource || ""} onValueChange={setFormOutsource}>
<SelectTrigger className="h-9"><SelectValue placeholder="외주업체 선택" /></SelectTrigger>
<SelectContent>
{subcontractorOptions.map((s) => (
<SelectItem key={s.code} value={s.code}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> ( )</Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="h-9 w-full justify-between font-normal">
<span className="truncate text-left text-sm">
{formOutsources.length === 0
? "외주업체 선택"
: formOutsources
.map((i) => subcontractorOptions.find((s) => s.id === i)?.name || i)
.join(", ")}
</span>
<Badge variant="secondary" className="ml-2 shrink-0">{formOutsources.length}</Badge>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[320px] p-0" align="start">
<div className="max-h-64 overflow-y-auto p-2 space-y-1">
{subcontractorOptions.length === 0 ? (
<div className="text-xs text-muted-foreground px-2 py-3"> </div>
) : subcontractorOptions.map((s) => {
const checked = formOutsources.includes(s.id);
return (
<label
key={s.id}
className="flex items-center gap-2 rounded px-2 py-1.5 text-sm cursor-pointer hover:bg-muted"
>
<Checkbox
checked={checked}
onCheckedChange={(v) => {
setFormOutsources((prev) =>
v ? [...prev, s.id] : prev.filter((i) => i !== s.id),
);
}}
/>
<span className="truncate">{s.name}</span>
</label>
);
})}
</div>
</PopoverContent>
</Popover>
</div>
)}
</div>
@@ -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() {
<TableHead className="w-[50px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground"><Checkbox checked={regSourceData.length > 0 && regCheckedIds.size === regSourceData.length} onCheckedChange={toggleRegAll} /></TableHead>
{regSourceType === "item" && <><TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead><TableHead></TableHead><TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead></>}
{regSourceType === "order" && <><TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead><TableHead className="w-[100px]"></TableHead><TableHead></TableHead><TableHead className="w-[100px]"></TableHead><TableHead className="w-[80px] text-right"></TableHead><TableHead className="w-[100px]"></TableHead></>}
{regSourceType === "production" && <><TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead><TableHead className="w-[100px]"></TableHead><TableHead></TableHead><TableHead className="w-[80px] text-right"></TableHead><TableHead className="w-[90px]"></TableHead><TableHead className="w-[90px]"></TableHead><TableHead className="w-[100px]"></TableHead></>}
{regSourceType === "production" && <><TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead><TableHead className="w-[100px]"></TableHead><TableHead></TableHead><TableHead className="w-[80px] text-right"></TableHead><TableHead className="w-[80px] text-right"></TableHead><TableHead className="w-[80px] text-right"></TableHead><TableHead className="w-[90px]"></TableHead><TableHead className="w-[90px]"></TableHead><TableHead className="w-[100px]"></TableHead></>}
</TableRow>
</TableHeader>
<TableBody>
@@ -590,7 +596,7 @@ export default function WorkInstructionPage() {
<TableCell className="text-center" onClick={e => e.stopPropagation()}><Checkbox checked={checked} onCheckedChange={() => toggleRegItem(id)} /></TableCell>
{regSourceType === "item" && <><TableCell className="text-[13px] font-medium">{item.item_code}</TableCell><TableCell className="text-sm">{item.item_name}</TableCell><TableCell className="text-[13px]">{item.spec || "-"}</TableCell></>}
{regSourceType === "order" && <><TableCell className="text-[13px]">{item.order_no}</TableCell><TableCell className="text-[13px]">{item.item_code}</TableCell><TableCell className="text-sm">{item.item_name}</TableCell><TableCell className="text-[13px]">{item.spec || "-"}</TableCell><TableCell className="text-right text-[13px]">{Number(item.qty || 0).toLocaleString()}</TableCell><TableCell className="text-[13px]">{item.due_date || "-"}</TableCell></>}
{regSourceType === "production" && <><TableCell className="text-[13px]">{item.plan_no}</TableCell><TableCell className="text-[13px]">{item.item_code}</TableCell><TableCell className="text-sm">{item.item_name}</TableCell><TableCell className="text-right text-[13px]">{Number(item.plan_qty || 0).toLocaleString()}</TableCell><TableCell className="text-[13px]">{item.start_date ? String(item.start_date).split("T")[0] : "-"}</TableCell><TableCell className="text-[13px]">{item.end_date ? String(item.end_date).split("T")[0] : "-"}</TableCell><TableCell className="text-[13px]">{item.equipment_name || "-"}</TableCell></>}
{regSourceType === "production" && <><TableCell className="text-[13px]">{item.plan_no}</TableCell><TableCell className="text-[13px]">{item.item_code}</TableCell><TableCell className="text-sm">{item.item_name}</TableCell><TableCell className="text-right text-[13px]">{Number(item.plan_qty || 0).toLocaleString()}</TableCell><TableCell className="text-right text-[13px] text-muted-foreground">{Number(item.applied_qty || 0).toLocaleString()}</TableCell><TableCell className={cn("text-right text-[13px] font-semibold", Number(item.remain_qty ?? item.plan_qty ?? 0) < 0 && "text-destructive")}>{Number(item.remain_qty ?? item.plan_qty ?? 0).toLocaleString()}</TableCell><TableCell className="text-[13px]">{item.start_date ? String(item.start_date).split("T")[0] : "-"}</TableCell><TableCell className="text-[13px]">{item.end_date ? String(item.end_date).split("T")[0] : "-"}</TableCell><TableCell className="text-[13px]">{item.equipment_name || "-"}</TableCell></>}
</TableRow>
);
})}
@@ -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 }[] = [];
@@ -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: "판단기준" },
@@ -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<Record<string, any>>({});
const [copyInspectionRows, setCopyInspectionRows] = useState<Record<string, InspectionRow[]>>({});
const [copyCollapsedTypes, setCopyCollapsedTypes] = useState<Record<string, boolean>>({});
/* ═══════════════════ 복사 모달 (기준 품목 검사정보 → 다른 품목들) ═══════════════════ */
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<string, InspectionRow[]> = {};
const typeFlags: Record<string, boolean> = {};
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<string, InspectionRow[]> = {};
const typeFlags: Record<string, boolean> = {};
@@ -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() {
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8 w-[50px]"></TableHead>
@@ -983,7 +1122,7 @@ export default function ItemInspectionInfoPage() {
<TableBody>
{selectedTabRows.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-8 text-xs text-muted-foreground"> </TableCell>
<TableCell colSpan={9} className="text-center py-8 text-xs text-muted-foreground"> </TableCell>
</TableRow>
) : selectedTabRows.map((row: any) => (
<TableRow key={row.id}>
@@ -1002,6 +1141,7 @@ export default function ItemInspectionInfoPage() {
const proc = processOptions.find(p => p.code === code);
return proc?.name || code;
})()}</TableCell>
<TableCell className="text-xs py-2">{row.classification || "-"}</TableCell>
<TableCell className="text-xs py-2">
{(() => {
const insp = inspOptions.find(o => o.code === row.inspection_standard_id);
@@ -1010,7 +1150,16 @@ export default function ItemInspectionInfoPage() {
return jcLabel ? <Badge variant="outline" className="text-[10px]">{jcLabel}</Badge> : "-";
})()}
</TableCell>
<TableCell className="text-xs py-2 font-mono">{row.pass_criteria || "-"}</TableCell>
<TableCell className="text-xs py-2 font-mono">{(() => {
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;
})()}</TableCell>
<TableCell className="text-xs py-2 text-center">
{row.is_required === "true" || row.is_required === true ? (
<Badge variant="destructive" className="text-[9px]"></Badge>
@@ -1185,6 +1334,7 @@ export default function ItemInspectionInfoPage() {
<TableHead className="text-[10px] font-bold w-[130px]"> </TableHead>
<TableHead className="text-[10px] font-bold w-[90px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[90px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[100px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[80px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[200px]"> ()</TableHead>
<TableHead className="text-[10px] font-bold w-[40px]"></TableHead>
@@ -1194,7 +1344,7 @@ export default function ItemInspectionInfoPage() {
</TableHeader>
<TableBody>
{(!inspectionRows[key] || inspectionRows[key].length === 0) ? (
<TableRow><TableCell colSpan={9} className="text-center py-4 text-xs text-muted-foreground"> </TableCell></TableRow>
<TableRow><TableCell colSpan={10} className="text-center py-4 text-xs text-muted-foreground"> </TableCell></TableRow>
) : inspectionRows[key].map((row) => (
<TableRow key={row.id}>
<TableCell className="p-1">
@@ -1219,6 +1369,9 @@ export default function ItemInspectionInfoPage() {
<Input className="h-8 text-xs" value={row.apply_process} onChange={(e) => updateInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" />
)}
</TableCell>
<TableCell className="p-1">
<Input className="h-8 text-xs" value={row.classification || ""} onChange={(e) => updateInspRow(key, row.id, "classification", e.target.value)} placeholder="구분 입력" />
</TableCell>
<TableCell className="p-1 text-center">
{row.judgment_criteria ? <Badge variant="outline" className="text-[10px]">{row.judgment_criteria}</Badge> : <span className="text-[10px] text-muted-foreground">-</span>}
</TableCell>
@@ -1285,20 +1438,20 @@ export default function ItemInspectionInfoPage() {
</DialogContent>
</Dialog>
{/* ═══════════════════ 복사 모달 ═══════════════════ */}
{/* ═══════════════════ 복사 모달 (2단 분할: 좌 대상 / 우 편집) ═══════════════════ */}
<Dialog open={copyModalOpen} onOpenChange={(v) => { if (!copying) setCopyModalOpen(v); }}>
<DialogContent
className="max-w-3xl w-[95vw] max-h-[85vh] flex flex-col overflow-hidden"
className="max-w-[95vw] sm:max-w-[1400px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden"
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => { if (copying) e.preventDefault(); }}
>
<DialogHeader>
<DialogHeader className="shrink-0">
<DialogTitle>{copying ? "검사정보 복사 중..." : "검사정보 복사"}</DialogTitle>
<DialogDescription>
<span className="font-medium text-foreground">{selectedGroup?.item_name || "-"}</span>
<span className="text-muted-foreground"> ({selectedItemCode})</span>
<span>{copying ? " 의 검사정보를 복사하고 있습니다" : " 의 검사정보를 아래 선택한 품목들에 복사합니다"}</span>
<span>{copying ? " 의 검사정보를 복사하고 있습니다" : " 의 검사정보를 편집해서 선택한 품목들에 복사합니다. 기준 품목은 변경되지 않아요"}</span>
</DialogDescription>
</DialogHeader>
{copying ? (
@@ -1322,81 +1475,229 @@ export default function ItemInspectionInfoPage() {
</p>
</div>
</div>
) : (<>
<div className="flex gap-2 shrink-0">
<Input className="h-9 flex-1" placeholder="품목코드 또는 품목명" value={copySearchKeyword}
onChange={(e) => setCopySearchKeyword(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleCopySearch(); }} />
<Button size="sm" className="h-9" onClick={handleCopySearch} disabled={copySearchLoading}>
{copySearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" /></>}
</Button>
</div>
<div className="flex-1 border rounded-lg overflow-auto">
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[40px] text-center">
<Checkbox
checked={copyFilteredItems.length > 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)));
}}
/>
</TableHead>
<TableHead className="text-[11px] font-bold w-[140px]"></TableHead>
<TableHead className="text-[11px] font-bold"></TableHead>
<TableHead className="text-[11px] font-bold w-[100px]"></TableHead>
<TableHead className="text-[11px] font-bold w-[80px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{copyFilteredItems.length === 0 ? (
<TableRow><TableCell colSpan={5} className="text-center py-8 text-muted-foreground text-sm">
{copySearchLoading ? "검색 중..." : "검색 결과가 없어요"}
</TableCell></TableRow>
) : copyFilteredItems.map((item) => (
<TableRow key={item.code} className="cursor-pointer hover:bg-primary/5"
onClick={() => toggleCopyChecked(item.code)}>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox checked={copyCheckedIds.includes(item.code)} onCheckedChange={() => toggleCopyChecked(item.code)} />
</TableCell>
<TableCell className="text-sm font-mono">{item.code}</TableCell>
<TableCell className="text-sm">{item.name}</TableCell>
<TableCell className="text-sm">{item.item_type}</TableCell>
<TableCell className="text-sm">{item.unit}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="shrink-0 flex items-center justify-between border-t px-2 py-2 text-xs text-muted-foreground">
<span>
<span className="font-medium text-foreground">{copyTotal.toLocaleString()}</span>
{copyCheckedIds.length > 0 && <span className="ml-2"> <span className="font-medium text-primary">{copyCheckedIds.length}</span></span>}
</span>
<div className="flex items-center gap-0.5">
<button onClick={() => { 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"><ChevronsLeft className="h-3.5 w-3.5" /></button>
<button onClick={() => { 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"><ChevronLeft className="h-3.5 w-3.5" /></button>
{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 (
<button key={p} onClick={() => { 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}</button>
);
})}
<button onClick={() => { 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"><ChevronRight className="h-3.5 w-3.5" /></button>
<button onClick={() => { 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"><ChevronsRight className="h-3.5 w-3.5" /></button>
) : (
<div className="flex-1 grid grid-cols-[420px_1fr] gap-4 overflow-hidden">
{/* 좌측: 복사 대상 품목 선택 */}
<div className="flex flex-col overflow-hidden border rounded-lg">
<div className="flex items-center justify-between border-b bg-muted/50 px-3 py-2">
<span className="text-xs font-semibold"> </span>
{copyCheckedIds.length > 0 && <span className="text-[10px] text-primary"> {copyCheckedIds.length}</span>}
</div>
<div className="flex gap-2 px-2 pt-2">
<Input className="h-8 flex-1 text-xs" placeholder="품목코드 또는 품목명" value={copySearchKeyword}
onChange={(e) => setCopySearchKeyword(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleCopySearch(); }} />
<Button size="sm" className="h-8 text-xs" onClick={handleCopySearch} disabled={copySearchLoading}>
{copySearchLoading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Search className="w-3.5 h-3.5" />}
</Button>
</div>
<div className="flex-1 overflow-auto mt-2">
<Table>
<TableHeader className="sticky top-0 z-10 bg-background">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[36px] text-center text-[10px]">
<Checkbox
checked={copyFilteredItems.length > 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)));
}}
/>
</TableHead>
<TableHead className="text-[10px] font-bold w-[120px]"></TableHead>
<TableHead className="text-[10px] font-bold"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{copyFilteredItems.length === 0 ? (
<TableRow><TableCell colSpan={3} className="text-center py-6 text-muted-foreground text-xs">
{copySearchLoading ? "검색 중..." : "검색 결과가 없어요"}
</TableCell></TableRow>
) : copyFilteredItems.map((item) => (
<TableRow key={item.code} className="cursor-pointer hover:bg-primary/5"
onClick={() => toggleCopyChecked(item.code)}>
<TableCell className="text-center p-1" onClick={(e) => e.stopPropagation()}>
<Checkbox checked={copyCheckedIds.includes(item.code)} onCheckedChange={() => toggleCopyChecked(item.code)} />
</TableCell>
<TableCell className="text-xs font-mono p-1">{item.code}</TableCell>
<TableCell className="text-xs p-1 truncate">{item.name}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="border-t flex items-center justify-between px-2 py-1 text-[10px] text-muted-foreground">
<span> <span className="font-medium text-foreground">{copyTotal.toLocaleString()}</span></span>
<div className="flex items-center gap-0.5">
<button onClick={() => { 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"><ChevronsLeft className="h-3 w-3" /></button>
<button onClick={() => { 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"><ChevronLeft className="h-3 w-3" /></button>
<span className="text-[10px] mx-1">{copyPage}/{copyTotalPages}</span>
<button onClick={() => { 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"><ChevronRight className="h-3 w-3" /></button>
<button onClick={() => { 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"><ChevronsRight className="h-3 w-3" /></button>
</div>
</div>
</div>
{/* 우측: 편집 폼 (등록/수정 폼과 동일 구조) */}
<div className="flex flex-col overflow-hidden border rounded-lg">
<div className="border-b bg-muted/50 px-3 py-2">
<span className="text-xs font-semibold"> (: {selectedItemCode})</span>
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Select value={copyForm.is_active === false || copyForm.is_active === "N" ? "N" : "Y"} onValueChange={(v) => setCopyForm(p => ({ ...p, is_active: v === "Y" ? "사용" : "미사용" }))}>
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="Y"></SelectItem>
<SelectItem value="N"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Select value={copyForm.manager || ""} onValueChange={(v) => setCopyForm(p => ({ ...p, manager: v }))}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="관리자 선택" /></SelectTrigger>
<SelectContent>{userOptions.map(o => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<h4 className="text-xs font-semibold"> </h4>
<div className="flex flex-wrap gap-3">
{INSPECTION_TYPES.map(({ key, label }) => (
<div key={key} className="flex items-center gap-1.5">
<Checkbox checked={!!copyForm[key]} onCheckedChange={(v) => setCopyForm(p => ({ ...p, [key]: !!v }))} />
<Label className="text-xs cursor-pointer">{label}</Label>
</div>
))}
</div>
</div>
{INSPECTION_TYPES.filter(t => !!copyForm[t.key]).map(({ key, label }) => (
<div key={key} className="space-y-1.5">
<button type="button" className="w-full flex items-center gap-2 py-1.5 px-2 rounded-md border bg-muted/50 hover:bg-muted text-left" onClick={() => toggleCopyCollapse(key)}>
<Badge variant="default" className="text-[10px]">{label}</Badge>
<span className="text-xs font-medium"> </span>
<span className="text-[10px] text-muted-foreground ml-auto">{(copyInspectionRows[key] || []).length}</span>
</button>
{!copyCollapsedTypes[key] && (
<div className="space-y-1.5 pl-1">
<div className="flex items-center justify-between">
<span className="text-[10px] font-semibold text-muted-foreground"> </span>
<Button type="button" size="sm" variant="outline" className="h-6 text-[10px]" onClick={() => addCopyInspRow(key)}>
<Plus className="w-3 h-3 mr-1" />
</Button>
</div>
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-muted/50 hover:bg-muted/50">
<TableHead className="text-[10px] font-bold w-[150px]"> </TableHead>
<TableHead className="text-[10px] font-bold w-[110px]"> </TableHead>
<TableHead className="text-[10px] font-bold w-[80px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[80px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[90px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[70px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[180px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[36px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[60px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[32px]" />
</TableRow>
</TableHeader>
<TableBody>
{(!copyInspectionRows[key] || copyInspectionRows[key].length === 0) ? (
<TableRow><TableCell colSpan={10} className="text-center py-3 text-[10px] text-muted-foreground"> </TableCell></TableRow>
) : copyInspectionRows[key].map((row) => (
<TableRow key={row.id}>
<TableCell className="p-1">
<Select value={row.inspection_standard_id || ""} onValueChange={(v) => updateCopyInspRow(key, row.id, "inspection_standard_id", v)}>
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="검사기준" /></SelectTrigger>
<SelectContent>{getFilteredInspOptions(key).map(o => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}</SelectContent>
</Select>
</TableCell>
<TableCell className="p-1"><Input className="h-7 text-[10px] bg-muted" value={row.inspection_detail} readOnly placeholder="자동" /></TableCell>
<TableCell className="p-1"><Input className="h-7 text-[10px] bg-muted" value={row.inspection_method} readOnly placeholder="자동" /></TableCell>
<TableCell className="p-1">
{processOptions.length > 0 ? (
<Select value={row.apply_process || ""} onValueChange={(v) => updateCopyInspRow(key, row.id, "apply_process", v)}>
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="공정" /></SelectTrigger>
<SelectContent>
{processOptions.map((p) => (
<SelectItem key={p.code} value={p.code} className="text-xs">{p.name}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input className="h-7 text-[10px]" value={row.apply_process} onChange={(e) => updateCopyInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" />
)}
</TableCell>
<TableCell className="p-1">
<Input className="h-7 text-[10px]" value={row.classification || ""} onChange={(e) => updateCopyInspRow(key, row.id, "classification", e.target.value)} placeholder="구분" />
</TableCell>
<TableCell className="p-1 text-center">
{row.judgment_criteria ? <Badge variant="outline" className="text-[9px]">{row.judgment_criteria}</Badge> : <span className="text-[9px] text-muted-foreground">-</span>}
</TableCell>
<TableCell className="p-1">
{row.judgment_criteria === "선택형" && row.selection_options ? (
<Select value={row.acceptance_criteria || ""} onValueChange={(v) => updateCopyInspRow(key, row.id, "acceptance_criteria", v)} disabled={!row.inspection_standard_id}>
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{row.selection_options.split(",").filter(Boolean).map((opt) => (
<SelectItem key={opt} value={opt} className="text-xs">{opt}</SelectItem>
))}
</SelectContent>
</Select>
) : row.judgment_criteria === "O/X" ? (
<Select value={row.acceptance_criteria || ""} onValueChange={(v) => updateCopyInspRow(key, row.id, "acceptance_criteria", v)} disabled={!row.inspection_standard_id}>
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
<SelectItem value="O" className="text-xs">O ()</SelectItem>
<SelectItem value="X" className="text-xs">X ()</SelectItem>
</SelectContent>
</Select>
) : row.judgment_criteria === "수치(범위)" ? (
<div className="flex items-center gap-1">
<Input className="h-7 text-[10px] w-14" value={row.acceptance_criteria?.split("|")[0] || ""} onChange={(e) => {
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} />
<span className="text-[9px] text-muted-foreground">±</span>
<Input className="h-7 text-[10px] w-10" value={row.acceptance_criteria?.split("|")[1] || ""} onChange={(e) => {
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} />
</div>
) : (
<Input className="h-7 text-[10px]" value={row.acceptance_criteria} onChange={(e) => updateCopyInspRow(key, row.id, "acceptance_criteria", e.target.value)} placeholder="합격기준" disabled={!row.inspection_standard_id} />
)}
</TableCell>
<TableCell className="p-1 text-center"><Checkbox checked={row.is_required} onCheckedChange={(v) => updateCopyInspRow(key, row.id, "is_required", !!v)} /></TableCell>
<TableCell className="p-1 text-[10px] text-muted-foreground">{row.unit || "-"}</TableCell>
<TableCell className="p-1">
<Button type="button" variant="destructive" size="sm" className="h-6 w-6 p-0" onClick={() => removeCopyInspRow(key, row.id)}><Trash2 className="w-3 h-3" /></Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
</div>
))}
</div>
</div>
</div>
</div>
</>)}
)}
<DialogFooter className="shrink-0">
<Button variant="outline" onClick={() => setCopyModalOpen(false)} disabled={copying}></Button>
<Button onClick={handleCopy} disabled={copying || copyCheckedIds.length === 0}>
@@ -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 }[] = [];
@@ -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_16`
);
const data = res.data?.data || [];
setCategoryOptions((prev) => ({
...prev,
@@ -823,13 +825,24 @@ export default function LogisticsInfoPage() {
{/* 테이블 영역 */}
<div className="flex-1 overflow-auto">
<EDataTable
columns={getVisibleColumns(tab.key).map((col): EDataTableColumn => ({
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]}
@@ -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<string, string> = {};
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);
@@ -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"
>
<span className="font-semibold font-mono text-primary">
{loc.location || loc.warehouse}
{loc.warehouse_name || loc.location || loc.warehouse}
</span>
<span className="font-semibold">
{loc.qty.toLocaleString()}
@@ -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<string, { code: string; label: string }[]>
>({});
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<string, { code: string; label: string }[]> = {};
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() {
<TableCell className="p-2 font-medium">{item.item_number}</TableCell>
<TableCell className="p-2">{item.item_name || "-"}</TableCell>
<TableCell className="p-2">{item.spec || "-"}</TableCell>
<TableCell className="p-2">{item.unit || "EA"}</TableCell>
<TableCell className="p-2">{resolveCat("inventory_unit", item.inventory_unit) || "EA"}</TableCell>
<TableCell className="p-2 text-right font-semibold">{Number(item.pkg_qty).toLocaleString()}</TableCell>
<TableCell className="p-2 text-center">
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => handleDeletePkgItem(item)}>
@@ -954,8 +994,8 @@ export default function PackagingPage() {
<TableCell className="p-2 font-medium">{item.item_number}</TableCell>
<TableCell className="p-2">{item.item_name}</TableCell>
<TableCell className="p-2">{item.spec || "-"}</TableCell>
<TableCell className="p-2">{item.material || "-"}</TableCell>
<TableCell className="p-2">{item.unit || "EA"}</TableCell>
<TableCell className="p-2">{resolveCat("material", item.material) || "-"}</TableCell>
<TableCell className="p-2">{resolveCat("inventory_unit", item.inventory_unit) || "EA"}</TableCell>
</TableRow>
))}
</TableBody>
@@ -158,6 +158,10 @@ export default function WarehouseManagementPage() {
const [rackStatus, setRackStatus] = useState("");
const [rackPreview, setRackPreview] = useState<any[]>([]);
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() {
</div>
</div>
{/* 위치명 형식 — 구역/열/단 뒤에 붙일 표현만 자유 입력 */}
<div className="rounded-lg border p-3 bg-muted/30 space-y-2">
<Label className="text-xs font-semibold"> </Label>
<div className="flex items-center gap-1 text-xs flex-wrap">
<span className="font-mono text-muted-foreground">A</span>
<Input
value={rackZoneLabel}
onChange={(e) => setRackZoneLabel(e.target.value)}
placeholder="구역"
className="h-8 w-20 text-xs"
/>
<span className="font-mono text-muted-foreground">- 01</span>
<Input
value={rackRowLabel}
onChange={(e) => setRackRowLabel(e.target.value)}
placeholder="열"
className="h-8 w-20 text-xs"
/>
<span className="font-mono text-muted-foreground">- 1</span>
<Input
value={rackLevelLabel}
onChange={(e) => setRackLevelLabel(e.target.value)}
placeholder="단"
className="h-8 w-20 text-xs"
/>
</div>
<p className="text-[11px] text-muted-foreground">
: <span className="font-mono font-semibold">A{rackZoneLabel}-01{rackRowLabel}-1{rackLevelLabel}</span>
{" "} // , .
</p>
</div>
{/* 등록 미리보기 */}
<div>
<div className="flex items-center justify-between mb-3">
File diff suppressed because it is too large Load Diff
@@ -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<string, any> = {};
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 () => {
@@ -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<string, any> = {
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() {
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
if (!selectedBomId || !bomHeader) {
toast.error("수정할 BOM을 선택해주세요");
return;
}
openEditModal();
}}
disabled={!selectedBomId || !bomHeader}
>
<Pencil className="w-3.5 h-3.5 mr-1" />
</Button>
<div className="w-px h-4 bg-border mx-0.5" />
<Button
size="sm"
@@ -12,6 +12,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
import {
@@ -91,8 +92,8 @@ export function ItemRoutingTab() {
const [formFixedOrder, setFormFixedOrder] = useState("Y");
const [formWorkType, setFormWorkType] = useState("내부");
const [formStandardTime, setFormStandardTime] = useState("");
const [formOutsource, setFormOutsource] = useState("");
const [subcontractorOptions, setSubcontractorOptions] = useState<{ code: string; name: string }[]>([]);
const [formOutsources, setFormOutsources] = useState<string[]>([]);
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() {
</div>
{showOutsourceField && (
<div className="space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select value={formOutsource || ""} onValueChange={setFormOutsource}>
<SelectTrigger className="h-9"><SelectValue placeholder="외주업체 선택" /></SelectTrigger>
<SelectContent>
{subcontractorOptions.map((s) => (
<SelectItem key={s.code} value={s.code}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> ( )</Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="h-9 w-full justify-between font-normal">
<span className="truncate text-left text-sm">
{formOutsources.length === 0
? "외주업체 선택"
: formOutsources
.map((i) => subcontractorOptions.find((s) => s.id === i)?.name || i)
.join(", ")}
</span>
<Badge variant="secondary" className="ml-2 shrink-0">{formOutsources.length}</Badge>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[320px] p-0" align="start">
<div className="max-h-64 overflow-y-auto p-2 space-y-1">
{subcontractorOptions.length === 0 ? (
<div className="text-xs text-muted-foreground px-2 py-3"> </div>
) : subcontractorOptions.map((s) => {
const checked = formOutsources.includes(s.id);
return (
<label
key={s.id}
className="flex items-center gap-2 rounded px-2 py-1.5 text-sm cursor-pointer hover:bg-muted"
>
<Checkbox
checked={checked}
onCheckedChange={(v) => {
setFormOutsources((prev) =>
v ? [...prev, s.id] : prev.filter((i) => i !== s.id),
);
}}
/>
<span className="truncate">{s.name}</span>
</label>
);
})}
</div>
</PopoverContent>
</Popover>
</div>
)}
</div>
@@ -185,11 +185,15 @@ export default function WorkInstructionPage() {
case "order": r = await getWISalesOrderSource(params); break;
case "item": r = await getWIItemSource(params); break;
}
if (r?.success) { setRegSourceData(r.data || []); setRegTotalCount(r.totalCount || 0); }
if (r?.success) {
// 생산계획 근거는 백엔드에서 applied_qty / remain_qty 포함해 내려옴
setRegSourceData(r.data || []);
setRegTotalCount(r.totalCount || 0);
}
} catch {} finally { setRegSourceLoading(false); }
}, [regSourceType, regKeyword, regPage, regPageSize]);
useEffect(() => { if (isRegModalOpen && regSourceType) { setRegPage(1); setRegCheckedIds(new Set()); fetchRegSource(1); } }, [regSourceType]);
useEffect(() => { if (isRegModalOpen && regSourceType) { setRegPage(1); setRegCheckedIds(new Set()); fetchRegSource(1); } }, [isRegModalOpen, regSourceType]);
const getRegId = (item: any) => regSourceType === "item" ? (item.item_code || item.id) : String(item.id);
const toggleRegItem = (id: string) => { setRegCheckedIds(prev => { const n = new Set(prev); if (n.has(id)) n.delete(id); else n.add(id); return n; }); };
@@ -202,7 +206,13 @@ export default function WorkInstructionPage() {
if (!regCheckedIds.has(getRegId(item))) continue;
if (regSourceType === "item") items.push({ itemCode: item.item_code, itemName: item.item_name || "", spec: item.spec || "", qty: 1, remark: "", sourceType: "item", sourceTable: "item_info", sourceId: item.item_code });
else if (regSourceType === "order") items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: item.spec || "", qty: Number(item.qty || 1), remark: "", sourceType: "order", sourceTable: "sales_order_detail", sourceId: item.id });
else items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: "", qty: Number(item.plan_qty || 1), remark: "", sourceType: "production", sourceTable: "production_plan_mng", sourceId: item.id });
else {
// 생산계획: 잔량(remain_qty)이 있으면 잔량 기반으로 기본 수량 제안 (0/음수 허용 — 계획 초과 가능)
const defaultQty = item.remain_qty !== undefined && item.remain_qty !== null
? Number(item.remain_qty)
: Number(item.plan_qty || 1);
items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: "", qty: defaultQty, remark: "", sourceType: "production", sourceTable: "production_plan_mng", sourceId: item.id });
}
}
// 동일품목 합산
@@ -578,7 +588,7 @@ export default function WorkInstructionPage() {
<TableHead className="w-[50px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground"><Checkbox checked={regSourceData.length > 0 && regCheckedIds.size === regSourceData.length} onCheckedChange={toggleRegAll} /></TableHead>
{regSourceType === "item" && <><TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead><TableHead></TableHead><TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead></>}
{regSourceType === "order" && <><TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead><TableHead className="w-[100px]"></TableHead><TableHead></TableHead><TableHead className="w-[100px]"></TableHead><TableHead className="w-[80px] text-right"></TableHead><TableHead className="w-[100px]"></TableHead></>}
{regSourceType === "production" && <><TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead><TableHead className="w-[100px]"></TableHead><TableHead></TableHead><TableHead className="w-[80px] text-right"></TableHead><TableHead className="w-[90px]"></TableHead><TableHead className="w-[90px]"></TableHead><TableHead className="w-[100px]"></TableHead></>}
{regSourceType === "production" && <><TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead><TableHead className="w-[100px]"></TableHead><TableHead></TableHead><TableHead className="w-[80px] text-right"></TableHead><TableHead className="w-[80px] text-right"></TableHead><TableHead className="w-[80px] text-right"></TableHead><TableHead className="w-[90px]"></TableHead><TableHead className="w-[90px]"></TableHead><TableHead className="w-[100px]"></TableHead></>}
</TableRow>
</TableHeader>
<TableBody>
@@ -590,7 +600,7 @@ export default function WorkInstructionPage() {
<TableCell className="text-center" onClick={e => e.stopPropagation()}><Checkbox checked={checked} onCheckedChange={() => toggleRegItem(id)} /></TableCell>
{regSourceType === "item" && <><TableCell className="text-[13px] font-medium">{item.item_code}</TableCell><TableCell className="text-sm">{item.item_name}</TableCell><TableCell className="text-[13px]">{item.spec || "-"}</TableCell></>}
{regSourceType === "order" && <><TableCell className="text-[13px]">{item.order_no}</TableCell><TableCell className="text-[13px]">{item.item_code}</TableCell><TableCell className="text-sm">{item.item_name}</TableCell><TableCell className="text-[13px]">{item.spec || "-"}</TableCell><TableCell className="text-right text-[13px]">{Number(item.qty || 0).toLocaleString()}</TableCell><TableCell className="text-[13px]">{item.due_date || "-"}</TableCell></>}
{regSourceType === "production" && <><TableCell className="text-[13px]">{item.plan_no}</TableCell><TableCell className="text-[13px]">{item.item_code}</TableCell><TableCell className="text-sm">{item.item_name}</TableCell><TableCell className="text-right text-[13px]">{Number(item.plan_qty || 0).toLocaleString()}</TableCell><TableCell className="text-[13px]">{item.start_date ? String(item.start_date).split("T")[0] : "-"}</TableCell><TableCell className="text-[13px]">{item.end_date ? String(item.end_date).split("T")[0] : "-"}</TableCell><TableCell className="text-[13px]">{item.equipment_name || "-"}</TableCell></>}
{regSourceType === "production" && <><TableCell className="text-[13px]">{item.plan_no}</TableCell><TableCell className="text-[13px]">{item.item_code}</TableCell><TableCell className="text-sm">{item.item_name}</TableCell><TableCell className="text-right text-[13px]">{Number(item.plan_qty || 0).toLocaleString()}</TableCell><TableCell className="text-right text-[13px] text-muted-foreground">{Number(item.applied_qty || 0).toLocaleString()}</TableCell><TableCell className={cn("text-right text-[13px] font-semibold", Number(item.remain_qty ?? item.plan_qty ?? 0) < 0 && "text-destructive")}>{Number(item.remain_qty ?? item.plan_qty ?? 0).toLocaleString()}</TableCell><TableCell className="text-[13px]">{item.start_date ? String(item.start_date).split("T")[0] : "-"}</TableCell><TableCell className="text-[13px]">{item.end_date ? String(item.end_date).split("T")[0] : "-"}</TableCell><TableCell className="text-[13px]">{item.equipment_name || "-"}</TableCell></>}
</TableRow>
);
})}
@@ -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 }[] = [];
@@ -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: "판단기준" },
@@ -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<string, InspectionRow[]> = {};
const typeFlags: Record<string, boolean> = {};
@@ -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() {
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8 w-[50px]"></TableHead>
@@ -1093,7 +1114,7 @@ export default function ItemInspectionInfoPage() {
<TableBody>
{selectedTabRows.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-8 text-xs text-muted-foreground"> </TableCell>
<TableCell colSpan={9} className="text-center py-8 text-xs text-muted-foreground"> </TableCell>
</TableRow>
) : selectedTabRows.map((row: any) => (
<TableRow key={row.id}>
@@ -1112,6 +1133,7 @@ export default function ItemInspectionInfoPage() {
const proc = processOptions.find(p => p.code === code);
return proc?.name || code;
})()}</TableCell>
<TableCell className="text-xs py-2">{row.classification || "-"}</TableCell>
<TableCell className="text-xs py-2">
{(() => {
const insp = inspOptions.find(o => o.code === row.inspection_standard_id);
@@ -1120,7 +1142,16 @@ export default function ItemInspectionInfoPage() {
return jcLabel ? <Badge variant="outline" className="text-[10px]">{jcLabel}</Badge> : "-";
})()}
</TableCell>
<TableCell className="text-xs py-2 font-mono">{row.pass_criteria || "-"}</TableCell>
<TableCell className="text-xs py-2 font-mono">{(() => {
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;
})()}</TableCell>
<TableCell className="text-xs py-2 text-center">
{row.is_required === "true" || row.is_required === true ? (
<Badge variant="destructive" className="text-[9px]"></Badge>
@@ -1295,6 +1326,7 @@ export default function ItemInspectionInfoPage() {
<TableHead className="text-[10px] font-bold w-[130px]"> </TableHead>
<TableHead className="text-[10px] font-bold w-[90px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[90px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[100px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[80px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[200px]"> ()</TableHead>
<TableHead className="text-[10px] font-bold w-[40px]"></TableHead>
@@ -1304,7 +1336,7 @@ export default function ItemInspectionInfoPage() {
</TableHeader>
<TableBody>
{(!inspectionRows[key] || inspectionRows[key].length === 0) ? (
<TableRow><TableCell colSpan={9} className="text-center py-4 text-xs text-muted-foreground"> </TableCell></TableRow>
<TableRow><TableCell colSpan={10} className="text-center py-4 text-xs text-muted-foreground"> </TableCell></TableRow>
) : inspectionRows[key].map((row) => (
<TableRow key={row.id}>
<TableCell className="p-1">
@@ -1329,6 +1361,9 @@ export default function ItemInspectionInfoPage() {
<Input className="h-8 text-xs" value={row.apply_process} onChange={(e) => updateInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" />
)}
</TableCell>
<TableCell className="p-1">
<Input className="h-8 text-xs" value={row.classification || ""} onChange={(e) => updateInspRow(key, row.id, "classification", e.target.value)} placeholder="구분 입력" />
</TableCell>
<TableCell className="p-1 text-center">
{row.judgment_criteria ? <Badge variant="outline" className="text-[10px]">{row.judgment_criteria}</Badge> : <span className="text-[10px] text-muted-foreground">-</span>}
</TableCell>
@@ -1560,6 +1595,7 @@ export default function ItemInspectionInfoPage() {
<TableHead className="text-[10px] font-bold w-[110px]"> </TableHead>
<TableHead className="text-[10px] font-bold w-[80px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[80px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[80px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[70px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[180px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[36px]"></TableHead>
@@ -1569,7 +1605,7 @@ export default function ItemInspectionInfoPage() {
</TableHeader>
<TableBody>
{(!copyInspectionRows[key] || copyInspectionRows[key].length === 0) ? (
<TableRow><TableCell colSpan={9} className="text-center py-3 text-[10px] text-muted-foreground"> </TableCell></TableRow>
<TableRow><TableCell colSpan={10} className="text-center py-3 text-[10px] text-muted-foreground"> </TableCell></TableRow>
) : copyInspectionRows[key].map((row) => (
<TableRow key={row.id}>
<TableCell className="p-1">
@@ -1594,6 +1630,9 @@ export default function ItemInspectionInfoPage() {
<Input className="h-7 text-[10px]" value={row.apply_process} onChange={(e) => updateCopyInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" />
)}
</TableCell>
<TableCell className="p-1">
<Input className="h-7 text-[10px]" value={row.classification || ""} onChange={(e) => updateCopyInspRow(key, row.id, "classification", e.target.value)} placeholder="구분" />
</TableCell>
<TableCell className="p-1 text-center">
{row.judgment_criteria ? <Badge variant="outline" className="text-[9px]">{row.judgment_criteria}</Badge> : <span className="text-[9px] text-muted-foreground">-</span>}
</TableCell>
@@ -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 }[] = [];
@@ -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() {
{/* 테이블 영역 */}
<div className="flex-1 overflow-auto">
<EDataTable
columns={getVisibleColumns(tab.key).map((col): EDataTableColumn => ({
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]}
@@ -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<string, string> = {};
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);
@@ -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"
>
<span className="font-semibold font-mono text-primary">
{loc.location || loc.warehouse}
{loc.warehouse_name || loc.location || loc.warehouse}
</span>
<span className="font-semibold">
{loc.qty.toLocaleString()}
@@ -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<string, { code: string; label: string }[]>
>({});
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<string, { code: string; label: string }[]> = {};
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() {
<TableCell className="p-2 font-medium">{item.item_number}</TableCell>
<TableCell className="p-2">{item.item_name || "-"}</TableCell>
<TableCell className="p-2">{item.spec || "-"}</TableCell>
<TableCell className="p-2">{item.unit || "EA"}</TableCell>
<TableCell className="p-2">{resolveCat("inventory_unit", item.inventory_unit) || "EA"}</TableCell>
<TableCell className="p-2 text-right font-semibold">{Number(item.pkg_qty).toLocaleString()}</TableCell>
<TableCell className="p-2 text-center">
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => handleDeletePkgItem(item)}>
@@ -954,8 +994,8 @@ export default function PackagingPage() {
<TableCell className="p-2 font-medium">{item.item_number}</TableCell>
<TableCell className="p-2">{item.item_name}</TableCell>
<TableCell className="p-2">{item.spec || "-"}</TableCell>
<TableCell className="p-2">{item.material || "-"}</TableCell>
<TableCell className="p-2">{item.unit || "EA"}</TableCell>
<TableCell className="p-2">{resolveCat("material", item.material) || "-"}</TableCell>
<TableCell className="p-2">{resolveCat("inventory_unit", item.inventory_unit) || "EA"}</TableCell>
</TableRow>
))}
</TableBody>
@@ -158,6 +158,10 @@ export default function WarehouseManagementPage() {
const [rackStatus, setRackStatus] = useState("");
const [rackPreview, setRackPreview] = useState<any[]>([]);
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() {
</div>
</div>
{/* 위치명 형식 — 구역/열/단 뒤에 붙일 표현만 자유 입력 */}
<div className="rounded-lg border p-3 bg-muted/30 space-y-2">
<Label className="text-xs font-semibold"> </Label>
<div className="flex items-center gap-1 text-xs flex-wrap">
<span className="font-mono text-muted-foreground">A</span>
<Input
value={rackZoneLabel}
onChange={(e) => setRackZoneLabel(e.target.value)}
placeholder="구역"
className="h-8 w-20 text-xs"
/>
<span className="font-mono text-muted-foreground">- 01</span>
<Input
value={rackRowLabel}
onChange={(e) => setRackRowLabel(e.target.value)}
placeholder="열"
className="h-8 w-20 text-xs"
/>
<span className="font-mono text-muted-foreground">- 1</span>
<Input
value={rackLevelLabel}
onChange={(e) => setRackLevelLabel(e.target.value)}
placeholder="단"
className="h-8 w-20 text-xs"
/>
</div>
<p className="text-[11px] text-muted-foreground">
: <span className="font-mono font-semibold">A{rackZoneLabel}-01{rackRowLabel}-1{rackLevelLabel}</span>
{" "} // , .
</p>
</div>
{/* 등록 미리보기 */}
<div>
<div className="flex items-center justify-between mb-3">
@@ -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<string, any> = {};
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 () => {
@@ -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<string, any> = {
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() {
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
if (!selectedBomId || !bomHeader) {
toast.error("수정할 BOM을 선택해주세요");
return;
}
openEditModal();
}}
disabled={!selectedBomId || !bomHeader}
>
<Pencil className="w-3.5 h-3.5 mr-1" />
</Button>
<div className="w-px h-4 bg-border mx-0.5" />
<Button
size="sm"
@@ -12,6 +12,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
import {
@@ -91,8 +92,8 @@ export function ItemRoutingTab() {
const [formFixedOrder, setFormFixedOrder] = useState("Y");
const [formWorkType, setFormWorkType] = useState("내부");
const [formStandardTime, setFormStandardTime] = useState("");
const [formOutsource, setFormOutsource] = useState("");
const [subcontractorOptions, setSubcontractorOptions] = useState<{ code: string; name: string }[]>([]);
const [formOutsources, setFormOutsources] = useState<string[]>([]);
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() {
</div>
{showOutsourceField && (
<div className="space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select value={formOutsource || ""} onValueChange={setFormOutsource}>
<SelectTrigger className="h-9"><SelectValue placeholder="외주업체 선택" /></SelectTrigger>
<SelectContent>
{subcontractorOptions.map((s) => (
<SelectItem key={s.code} value={s.code}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> ( )</Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="h-9 w-full justify-between font-normal">
<span className="truncate text-left text-sm">
{formOutsources.length === 0
? "외주업체 선택"
: formOutsources
.map((i) => subcontractorOptions.find((s) => s.id === i)?.name || i)
.join(", ")}
</span>
<Badge variant="secondary" className="ml-2 shrink-0">{formOutsources.length}</Badge>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[320px] p-0" align="start">
<div className="max-h-64 overflow-y-auto p-2 space-y-1">
{subcontractorOptions.length === 0 ? (
<div className="text-xs text-muted-foreground px-2 py-3"> </div>
) : subcontractorOptions.map((s) => {
const checked = formOutsources.includes(s.id);
return (
<label
key={s.id}
className="flex items-center gap-2 rounded px-2 py-1.5 text-sm cursor-pointer hover:bg-muted"
>
<Checkbox
checked={checked}
onCheckedChange={(v) => {
setFormOutsources((prev) =>
v ? [...prev, s.id] : prev.filter((i) => i !== s.id),
);
}}
/>
<span className="truncate">{s.name}</span>
</label>
);
})}
</div>
</PopoverContent>
</Popover>
</div>
)}
</div>
@@ -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() {
<TableHead className="w-[50px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground"><Checkbox checked={regSourceData.length > 0 && regCheckedIds.size === regSourceData.length} onCheckedChange={toggleRegAll} /></TableHead>
{regSourceType === "item" && <><TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead><TableHead></TableHead><TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead></>}
{regSourceType === "order" && <><TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead><TableHead className="w-[100px]"></TableHead><TableHead></TableHead><TableHead className="w-[100px]"></TableHead><TableHead className="w-[80px] text-right"></TableHead><TableHead className="w-[100px]"></TableHead></>}
{regSourceType === "production" && <><TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead><TableHead className="w-[100px]"></TableHead><TableHead></TableHead><TableHead className="w-[80px] text-right"></TableHead><TableHead className="w-[90px]"></TableHead><TableHead className="w-[90px]"></TableHead><TableHead className="w-[100px]"></TableHead></>}
{regSourceType === "production" && <><TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead><TableHead className="w-[100px]"></TableHead><TableHead></TableHead><TableHead className="w-[80px] text-right"></TableHead><TableHead className="w-[80px] text-right"></TableHead><TableHead className="w-[80px] text-right"></TableHead><TableHead className="w-[90px]"></TableHead><TableHead className="w-[90px]"></TableHead><TableHead className="w-[100px]"></TableHead></>}
</TableRow>
</TableHeader>
<TableBody>
@@ -590,7 +596,7 @@ export default function WorkInstructionPage() {
<TableCell className="text-center" onClick={e => e.stopPropagation()}><Checkbox checked={checked} onCheckedChange={() => toggleRegItem(id)} /></TableCell>
{regSourceType === "item" && <><TableCell className="text-[13px] font-medium">{item.item_code}</TableCell><TableCell className="text-sm">{item.item_name}</TableCell><TableCell className="text-[13px]">{item.spec || "-"}</TableCell></>}
{regSourceType === "order" && <><TableCell className="text-[13px]">{item.order_no}</TableCell><TableCell className="text-[13px]">{item.item_code}</TableCell><TableCell className="text-sm">{item.item_name}</TableCell><TableCell className="text-[13px]">{item.spec || "-"}</TableCell><TableCell className="text-right text-[13px]">{Number(item.qty || 0).toLocaleString()}</TableCell><TableCell className="text-[13px]">{item.due_date || "-"}</TableCell></>}
{regSourceType === "production" && <><TableCell className="text-[13px]">{item.plan_no}</TableCell><TableCell className="text-[13px]">{item.item_code}</TableCell><TableCell className="text-sm">{item.item_name}</TableCell><TableCell className="text-right text-[13px]">{Number(item.plan_qty || 0).toLocaleString()}</TableCell><TableCell className="text-[13px]">{item.start_date ? String(item.start_date).split("T")[0] : "-"}</TableCell><TableCell className="text-[13px]">{item.end_date ? String(item.end_date).split("T")[0] : "-"}</TableCell><TableCell className="text-[13px]">{item.equipment_name || "-"}</TableCell></>}
{regSourceType === "production" && <><TableCell className="text-[13px]">{item.plan_no}</TableCell><TableCell className="text-[13px]">{item.item_code}</TableCell><TableCell className="text-sm">{item.item_name}</TableCell><TableCell className="text-right text-[13px]">{Number(item.plan_qty || 0).toLocaleString()}</TableCell><TableCell className="text-right text-[13px] text-muted-foreground">{Number(item.applied_qty || 0).toLocaleString()}</TableCell><TableCell className={cn("text-right text-[13px] font-semibold", Number(item.remain_qty ?? item.plan_qty ?? 0) < 0 && "text-destructive")}>{Number(item.remain_qty ?? item.plan_qty ?? 0).toLocaleString()}</TableCell><TableCell className="text-[13px]">{item.start_date ? String(item.start_date).split("T")[0] : "-"}</TableCell><TableCell className="text-[13px]">{item.end_date ? String(item.end_date).split("T")[0] : "-"}</TableCell><TableCell className="text-[13px]">{item.equipment_name || "-"}</TableCell></>}
</TableRow>
);
})}
@@ -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 }[] = [];
@@ -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: "판단기준" },
@@ -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<Record<string, any>>({});
const [copyInspectionRows, setCopyInspectionRows] = useState<Record<string, InspectionRow[]>>({});
const [copyCollapsedTypes, setCopyCollapsedTypes] = useState<Record<string, boolean>>({});
/* ═══════════════════ 복사 모달 (기준 품목 검사정보 → 다른 품목들) ═══════════════════ */
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<string, InspectionRow[]> = {};
const typeFlags: Record<string, boolean> = {};
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<string, InspectionRow[]> = {};
const typeFlags: Record<string, boolean> = {};
@@ -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() {
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8 w-[50px]"></TableHead>
@@ -983,7 +1122,7 @@ export default function ItemInspectionInfoPage() {
<TableBody>
{selectedTabRows.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-8 text-xs text-muted-foreground"> </TableCell>
<TableCell colSpan={9} className="text-center py-8 text-xs text-muted-foreground"> </TableCell>
</TableRow>
) : selectedTabRows.map((row: any) => (
<TableRow key={row.id}>
@@ -1002,6 +1141,7 @@ export default function ItemInspectionInfoPage() {
const proc = processOptions.find(p => p.code === code);
return proc?.name || code;
})()}</TableCell>
<TableCell className="text-xs py-2">{row.classification || "-"}</TableCell>
<TableCell className="text-xs py-2">
{(() => {
const insp = inspOptions.find(o => o.code === row.inspection_standard_id);
@@ -1010,7 +1150,16 @@ export default function ItemInspectionInfoPage() {
return jcLabel ? <Badge variant="outline" className="text-[10px]">{jcLabel}</Badge> : "-";
})()}
</TableCell>
<TableCell className="text-xs py-2 font-mono">{row.pass_criteria || "-"}</TableCell>
<TableCell className="text-xs py-2 font-mono">{(() => {
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;
})()}</TableCell>
<TableCell className="text-xs py-2 text-center">
{row.is_required === "true" || row.is_required === true ? (
<Badge variant="destructive" className="text-[9px]"></Badge>
@@ -1185,6 +1334,7 @@ export default function ItemInspectionInfoPage() {
<TableHead className="text-[10px] font-bold w-[130px]"> </TableHead>
<TableHead className="text-[10px] font-bold w-[90px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[90px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[100px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[80px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[200px]"> ()</TableHead>
<TableHead className="text-[10px] font-bold w-[40px]"></TableHead>
@@ -1194,7 +1344,7 @@ export default function ItemInspectionInfoPage() {
</TableHeader>
<TableBody>
{(!inspectionRows[key] || inspectionRows[key].length === 0) ? (
<TableRow><TableCell colSpan={9} className="text-center py-4 text-xs text-muted-foreground"> </TableCell></TableRow>
<TableRow><TableCell colSpan={10} className="text-center py-4 text-xs text-muted-foreground"> </TableCell></TableRow>
) : inspectionRows[key].map((row) => (
<TableRow key={row.id}>
<TableCell className="p-1">
@@ -1219,6 +1369,9 @@ export default function ItemInspectionInfoPage() {
<Input className="h-8 text-xs" value={row.apply_process} onChange={(e) => updateInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" />
)}
</TableCell>
<TableCell className="p-1">
<Input className="h-8 text-xs" value={row.classification || ""} onChange={(e) => updateInspRow(key, row.id, "classification", e.target.value)} placeholder="구분 입력" />
</TableCell>
<TableCell className="p-1 text-center">
{row.judgment_criteria ? <Badge variant="outline" className="text-[10px]">{row.judgment_criteria}</Badge> : <span className="text-[10px] text-muted-foreground">-</span>}
</TableCell>
@@ -1285,20 +1438,20 @@ export default function ItemInspectionInfoPage() {
</DialogContent>
</Dialog>
{/* ═══════════════════ 복사 모달 ═══════════════════ */}
{/* ═══════════════════ 복사 모달 (2단 분할: 좌 대상 / 우 편집) ═══════════════════ */}
<Dialog open={copyModalOpen} onOpenChange={(v) => { if (!copying) setCopyModalOpen(v); }}>
<DialogContent
className="max-w-3xl w-[95vw] max-h-[85vh] flex flex-col overflow-hidden"
className="max-w-[95vw] sm:max-w-[1400px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden"
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => { if (copying) e.preventDefault(); }}
>
<DialogHeader>
<DialogHeader className="shrink-0">
<DialogTitle>{copying ? "검사정보 복사 중..." : "검사정보 복사"}</DialogTitle>
<DialogDescription>
<span className="font-medium text-foreground">{selectedGroup?.item_name || "-"}</span>
<span className="text-muted-foreground"> ({selectedItemCode})</span>
<span>{copying ? " 의 검사정보를 복사하고 있습니다" : " 의 검사정보를 아래 선택한 품목들에 복사합니다"}</span>
<span>{copying ? " 의 검사정보를 복사하고 있습니다" : " 의 검사정보를 편집해서 선택한 품목들에 복사합니다. 기준 품목은 변경되지 않아요"}</span>
</DialogDescription>
</DialogHeader>
{copying ? (
@@ -1322,81 +1475,229 @@ export default function ItemInspectionInfoPage() {
</p>
</div>
</div>
) : (<>
<div className="flex gap-2 shrink-0">
<Input className="h-9 flex-1" placeholder="품목코드 또는 품목명" value={copySearchKeyword}
onChange={(e) => setCopySearchKeyword(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleCopySearch(); }} />
<Button size="sm" className="h-9" onClick={handleCopySearch} disabled={copySearchLoading}>
{copySearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" /></>}
</Button>
</div>
<div className="flex-1 border rounded-lg overflow-auto">
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[40px] text-center">
<Checkbox
checked={copyFilteredItems.length > 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)));
}}
/>
</TableHead>
<TableHead className="text-[11px] font-bold w-[140px]"></TableHead>
<TableHead className="text-[11px] font-bold"></TableHead>
<TableHead className="text-[11px] font-bold w-[100px]"></TableHead>
<TableHead className="text-[11px] font-bold w-[80px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{copyFilteredItems.length === 0 ? (
<TableRow><TableCell colSpan={5} className="text-center py-8 text-muted-foreground text-sm">
{copySearchLoading ? "검색 중..." : "검색 결과가 없어요"}
</TableCell></TableRow>
) : copyFilteredItems.map((item) => (
<TableRow key={item.code} className="cursor-pointer hover:bg-primary/5"
onClick={() => toggleCopyChecked(item.code)}>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox checked={copyCheckedIds.includes(item.code)} onCheckedChange={() => toggleCopyChecked(item.code)} />
</TableCell>
<TableCell className="text-sm font-mono">{item.code}</TableCell>
<TableCell className="text-sm">{item.name}</TableCell>
<TableCell className="text-sm">{item.item_type}</TableCell>
<TableCell className="text-sm">{item.unit}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="shrink-0 flex items-center justify-between border-t px-2 py-2 text-xs text-muted-foreground">
<span>
<span className="font-medium text-foreground">{copyTotal.toLocaleString()}</span>
{copyCheckedIds.length > 0 && <span className="ml-2"> <span className="font-medium text-primary">{copyCheckedIds.length}</span></span>}
</span>
<div className="flex items-center gap-0.5">
<button onClick={() => { 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"><ChevronsLeft className="h-3.5 w-3.5" /></button>
<button onClick={() => { 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"><ChevronLeft className="h-3.5 w-3.5" /></button>
{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 (
<button key={p} onClick={() => { 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}</button>
);
})}
<button onClick={() => { 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"><ChevronRight className="h-3.5 w-3.5" /></button>
<button onClick={() => { 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"><ChevronsRight className="h-3.5 w-3.5" /></button>
) : (
<div className="flex-1 grid grid-cols-[420px_1fr] gap-4 overflow-hidden">
{/* 좌측: 복사 대상 품목 선택 */}
<div className="flex flex-col overflow-hidden border rounded-lg">
<div className="flex items-center justify-between border-b bg-muted/50 px-3 py-2">
<span className="text-xs font-semibold"> </span>
{copyCheckedIds.length > 0 && <span className="text-[10px] text-primary"> {copyCheckedIds.length}</span>}
</div>
<div className="flex gap-2 px-2 pt-2">
<Input className="h-8 flex-1 text-xs" placeholder="품목코드 또는 품목명" value={copySearchKeyword}
onChange={(e) => setCopySearchKeyword(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleCopySearch(); }} />
<Button size="sm" className="h-8 text-xs" onClick={handleCopySearch} disabled={copySearchLoading}>
{copySearchLoading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Search className="w-3.5 h-3.5" />}
</Button>
</div>
<div className="flex-1 overflow-auto mt-2">
<Table>
<TableHeader className="sticky top-0 z-10 bg-background">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[36px] text-center text-[10px]">
<Checkbox
checked={copyFilteredItems.length > 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)));
}}
/>
</TableHead>
<TableHead className="text-[10px] font-bold w-[120px]"></TableHead>
<TableHead className="text-[10px] font-bold"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{copyFilteredItems.length === 0 ? (
<TableRow><TableCell colSpan={3} className="text-center py-6 text-muted-foreground text-xs">
{copySearchLoading ? "검색 중..." : "검색 결과가 없어요"}
</TableCell></TableRow>
) : copyFilteredItems.map((item) => (
<TableRow key={item.code} className="cursor-pointer hover:bg-primary/5"
onClick={() => toggleCopyChecked(item.code)}>
<TableCell className="text-center p-1" onClick={(e) => e.stopPropagation()}>
<Checkbox checked={copyCheckedIds.includes(item.code)} onCheckedChange={() => toggleCopyChecked(item.code)} />
</TableCell>
<TableCell className="text-xs font-mono p-1">{item.code}</TableCell>
<TableCell className="text-xs p-1 truncate">{item.name}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="border-t flex items-center justify-between px-2 py-1 text-[10px] text-muted-foreground">
<span> <span className="font-medium text-foreground">{copyTotal.toLocaleString()}</span></span>
<div className="flex items-center gap-0.5">
<button onClick={() => { 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"><ChevronsLeft className="h-3 w-3" /></button>
<button onClick={() => { 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"><ChevronLeft className="h-3 w-3" /></button>
<span className="text-[10px] mx-1">{copyPage}/{copyTotalPages}</span>
<button onClick={() => { 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"><ChevronRight className="h-3 w-3" /></button>
<button onClick={() => { 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"><ChevronsRight className="h-3 w-3" /></button>
</div>
</div>
</div>
{/* 우측: 편집 폼 (등록/수정 폼과 동일 구조) */}
<div className="flex flex-col overflow-hidden border rounded-lg">
<div className="border-b bg-muted/50 px-3 py-2">
<span className="text-xs font-semibold"> (: {selectedItemCode})</span>
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Select value={copyForm.is_active === false || copyForm.is_active === "N" ? "N" : "Y"} onValueChange={(v) => setCopyForm(p => ({ ...p, is_active: v === "Y" ? "사용" : "미사용" }))}>
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="Y"></SelectItem>
<SelectItem value="N"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Select value={copyForm.manager || ""} onValueChange={(v) => setCopyForm(p => ({ ...p, manager: v }))}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="관리자 선택" /></SelectTrigger>
<SelectContent>{userOptions.map(o => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<h4 className="text-xs font-semibold"> </h4>
<div className="flex flex-wrap gap-3">
{INSPECTION_TYPES.map(({ key, label }) => (
<div key={key} className="flex items-center gap-1.5">
<Checkbox checked={!!copyForm[key]} onCheckedChange={(v) => setCopyForm(p => ({ ...p, [key]: !!v }))} />
<Label className="text-xs cursor-pointer">{label}</Label>
</div>
))}
</div>
</div>
{INSPECTION_TYPES.filter(t => !!copyForm[t.key]).map(({ key, label }) => (
<div key={key} className="space-y-1.5">
<button type="button" className="w-full flex items-center gap-2 py-1.5 px-2 rounded-md border bg-muted/50 hover:bg-muted text-left" onClick={() => toggleCopyCollapse(key)}>
<Badge variant="default" className="text-[10px]">{label}</Badge>
<span className="text-xs font-medium"> </span>
<span className="text-[10px] text-muted-foreground ml-auto">{(copyInspectionRows[key] || []).length}</span>
</button>
{!copyCollapsedTypes[key] && (
<div className="space-y-1.5 pl-1">
<div className="flex items-center justify-between">
<span className="text-[10px] font-semibold text-muted-foreground"> </span>
<Button type="button" size="sm" variant="outline" className="h-6 text-[10px]" onClick={() => addCopyInspRow(key)}>
<Plus className="w-3 h-3 mr-1" />
</Button>
</div>
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-muted/50 hover:bg-muted/50">
<TableHead className="text-[10px] font-bold w-[150px]"> </TableHead>
<TableHead className="text-[10px] font-bold w-[110px]"> </TableHead>
<TableHead className="text-[10px] font-bold w-[80px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[80px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[90px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[70px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[180px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[36px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[60px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[32px]" />
</TableRow>
</TableHeader>
<TableBody>
{(!copyInspectionRows[key] || copyInspectionRows[key].length === 0) ? (
<TableRow><TableCell colSpan={10} className="text-center py-3 text-[10px] text-muted-foreground"> </TableCell></TableRow>
) : copyInspectionRows[key].map((row) => (
<TableRow key={row.id}>
<TableCell className="p-1">
<Select value={row.inspection_standard_id || ""} onValueChange={(v) => updateCopyInspRow(key, row.id, "inspection_standard_id", v)}>
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="검사기준" /></SelectTrigger>
<SelectContent>{getFilteredInspOptions(key).map(o => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}</SelectContent>
</Select>
</TableCell>
<TableCell className="p-1"><Input className="h-7 text-[10px] bg-muted" value={row.inspection_detail} readOnly placeholder="자동" /></TableCell>
<TableCell className="p-1"><Input className="h-7 text-[10px] bg-muted" value={row.inspection_method} readOnly placeholder="자동" /></TableCell>
<TableCell className="p-1">
{processOptions.length > 0 ? (
<Select value={row.apply_process || ""} onValueChange={(v) => updateCopyInspRow(key, row.id, "apply_process", v)}>
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="공정" /></SelectTrigger>
<SelectContent>
{processOptions.map((p) => (
<SelectItem key={p.code} value={p.code} className="text-xs">{p.name}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input className="h-7 text-[10px]" value={row.apply_process} onChange={(e) => updateCopyInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" />
)}
</TableCell>
<TableCell className="p-1">
<Input className="h-7 text-[10px]" value={row.classification || ""} onChange={(e) => updateCopyInspRow(key, row.id, "classification", e.target.value)} placeholder="구분" />
</TableCell>
<TableCell className="p-1 text-center">
{row.judgment_criteria ? <Badge variant="outline" className="text-[9px]">{row.judgment_criteria}</Badge> : <span className="text-[9px] text-muted-foreground">-</span>}
</TableCell>
<TableCell className="p-1">
{row.judgment_criteria === "선택형" && row.selection_options ? (
<Select value={row.acceptance_criteria || ""} onValueChange={(v) => updateCopyInspRow(key, row.id, "acceptance_criteria", v)} disabled={!row.inspection_standard_id}>
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{row.selection_options.split(",").filter(Boolean).map((opt) => (
<SelectItem key={opt} value={opt} className="text-xs">{opt}</SelectItem>
))}
</SelectContent>
</Select>
) : row.judgment_criteria === "O/X" ? (
<Select value={row.acceptance_criteria || ""} onValueChange={(v) => updateCopyInspRow(key, row.id, "acceptance_criteria", v)} disabled={!row.inspection_standard_id}>
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
<SelectItem value="O" className="text-xs">O ()</SelectItem>
<SelectItem value="X" className="text-xs">X ()</SelectItem>
</SelectContent>
</Select>
) : row.judgment_criteria === "수치(범위)" ? (
<div className="flex items-center gap-1">
<Input className="h-7 text-[10px] w-14" value={row.acceptance_criteria?.split("|")[0] || ""} onChange={(e) => {
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} />
<span className="text-[9px] text-muted-foreground">±</span>
<Input className="h-7 text-[10px] w-10" value={row.acceptance_criteria?.split("|")[1] || ""} onChange={(e) => {
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} />
</div>
) : (
<Input className="h-7 text-[10px]" value={row.acceptance_criteria} onChange={(e) => updateCopyInspRow(key, row.id, "acceptance_criteria", e.target.value)} placeholder="합격기준" disabled={!row.inspection_standard_id} />
)}
</TableCell>
<TableCell className="p-1 text-center"><Checkbox checked={row.is_required} onCheckedChange={(v) => updateCopyInspRow(key, row.id, "is_required", !!v)} /></TableCell>
<TableCell className="p-1 text-[10px] text-muted-foreground">{row.unit || "-"}</TableCell>
<TableCell className="p-1">
<Button type="button" variant="destructive" size="sm" className="h-6 w-6 p-0" onClick={() => removeCopyInspRow(key, row.id)}><Trash2 className="w-3 h-3" /></Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
</div>
))}
</div>
</div>
</div>
</div>
</>)}
)}
<DialogFooter className="shrink-0">
<Button variant="outline" onClick={() => setCopyModalOpen(false)} disabled={copying}></Button>
<Button onClick={handleCopy} disabled={copying || copyCheckedIds.length === 0}>
@@ -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 }[] = [];
@@ -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() {
{/* 테이블 영역 */}
<div className="flex-1 overflow-auto">
<EDataTable
columns={getVisibleColumns(tab.key).map((col): EDataTableColumn => ({
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]}
@@ -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<string, string> = {};
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);
@@ -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"
>
<span className="font-semibold font-mono text-primary">
{loc.location || loc.warehouse}
{loc.warehouse_name || loc.location || loc.warehouse}
</span>
<span className="font-semibold">
{loc.qty.toLocaleString()}
@@ -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<string, { code: string; label: string }[]>
>({});
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<string, { code: string; label: string }[]> = {};
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() {
<TableCell className="p-2 font-medium">{item.item_number}</TableCell>
<TableCell className="p-2">{item.item_name || "-"}</TableCell>
<TableCell className="p-2">{item.spec || "-"}</TableCell>
<TableCell className="p-2">{item.unit || "EA"}</TableCell>
<TableCell className="p-2">{resolveCat("inventory_unit", item.inventory_unit) || "EA"}</TableCell>
<TableCell className="p-2 text-right font-semibold">{Number(item.pkg_qty).toLocaleString()}</TableCell>
<TableCell className="p-2 text-center">
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => handleDeletePkgItem(item)}>
@@ -954,8 +994,8 @@ export default function PackagingPage() {
<TableCell className="p-2 font-medium">{item.item_number}</TableCell>
<TableCell className="p-2">{item.item_name}</TableCell>
<TableCell className="p-2">{item.spec || "-"}</TableCell>
<TableCell className="p-2">{item.material || "-"}</TableCell>
<TableCell className="p-2">{item.unit || "EA"}</TableCell>
<TableCell className="p-2">{resolveCat("material", item.material) || "-"}</TableCell>
<TableCell className="p-2">{resolveCat("inventory_unit", item.inventory_unit) || "EA"}</TableCell>
</TableRow>
))}
</TableBody>
@@ -158,6 +158,10 @@ export default function WarehouseManagementPage() {
const [rackStatus, setRackStatus] = useState("");
const [rackPreview, setRackPreview] = useState<any[]>([]);
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() {
</div>
</div>
{/* 위치명 형식 — 구역/열/단 뒤에 붙일 표현만 자유 입력 */}
<div className="rounded-lg border p-3 bg-muted/30 space-y-2">
<Label className="text-xs font-semibold"> </Label>
<div className="flex items-center gap-1 text-xs flex-wrap">
<span className="font-mono text-muted-foreground">A</span>
<Input
value={rackZoneLabel}
onChange={(e) => setRackZoneLabel(e.target.value)}
placeholder="구역"
className="h-8 w-20 text-xs"
/>
<span className="font-mono text-muted-foreground">- 01</span>
<Input
value={rackRowLabel}
onChange={(e) => setRackRowLabel(e.target.value)}
placeholder="열"
className="h-8 w-20 text-xs"
/>
<span className="font-mono text-muted-foreground">- 1</span>
<Input
value={rackLevelLabel}
onChange={(e) => setRackLevelLabel(e.target.value)}
placeholder="단"
className="h-8 w-20 text-xs"
/>
</div>
<p className="text-[11px] text-muted-foreground">
: <span className="font-mono font-semibold">A{rackZoneLabel}-01{rackRowLabel}-1{rackLevelLabel}</span>
{" "} // , .
</p>
</div>
{/* 등록 미리보기 */}
<div>
<div className="flex items-center justify-between mb-3">
@@ -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<string, any> = {};
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 () => {
@@ -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<string, any> = {
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() {
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
if (!selectedBomId || !bomHeader) {
toast.error("수정할 BOM을 선택해주세요");
return;
}
openEditModal();
}}
disabled={!selectedBomId || !bomHeader}
>
<Pencil className="w-3.5 h-3.5 mr-1" />
</Button>
<div className="w-px h-4 bg-border mx-0.5" />
<Button
size="sm"
@@ -12,6 +12,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
import {
@@ -91,8 +92,8 @@ export function ItemRoutingTab() {
const [formFixedOrder, setFormFixedOrder] = useState("Y");
const [formWorkType, setFormWorkType] = useState("내부");
const [formStandardTime, setFormStandardTime] = useState("");
const [formOutsource, setFormOutsource] = useState("");
const [subcontractorOptions, setSubcontractorOptions] = useState<{ code: string; name: string }[]>([]);
const [formOutsources, setFormOutsources] = useState<string[]>([]);
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() {
</div>
{showOutsourceField && (
<div className="space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select value={formOutsource || ""} onValueChange={setFormOutsource}>
<SelectTrigger className="h-9"><SelectValue placeholder="외주업체 선택" /></SelectTrigger>
<SelectContent>
{subcontractorOptions.map((s) => (
<SelectItem key={s.code} value={s.code}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> ( )</Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="h-9 w-full justify-between font-normal">
<span className="truncate text-left text-sm">
{formOutsources.length === 0
? "외주업체 선택"
: formOutsources
.map((i) => subcontractorOptions.find((s) => s.id === i)?.name || i)
.join(", ")}
</span>
<Badge variant="secondary" className="ml-2 shrink-0">{formOutsources.length}</Badge>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[320px] p-0" align="start">
<div className="max-h-64 overflow-y-auto p-2 space-y-1">
{subcontractorOptions.length === 0 ? (
<div className="text-xs text-muted-foreground px-2 py-3"> </div>
) : subcontractorOptions.map((s) => {
const checked = formOutsources.includes(s.id);
return (
<label
key={s.id}
className="flex items-center gap-2 rounded px-2 py-1.5 text-sm cursor-pointer hover:bg-muted"
>
<Checkbox
checked={checked}
onCheckedChange={(v) => {
setFormOutsources((prev) =>
v ? [...prev, s.id] : prev.filter((i) => i !== s.id),
);
}}
/>
<span className="truncate">{s.name}</span>
</label>
);
})}
</div>
</PopoverContent>
</Popover>
</div>
)}
</div>
@@ -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() {
<TableHead className="w-[50px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground"><Checkbox checked={regSourceData.length > 0 && regCheckedIds.size === regSourceData.length} onCheckedChange={toggleRegAll} /></TableHead>
{regSourceType === "item" && <><TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead><TableHead></TableHead><TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead></>}
{regSourceType === "order" && <><TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead><TableHead className="w-[100px]"></TableHead><TableHead></TableHead><TableHead className="w-[100px]"></TableHead><TableHead className="w-[80px] text-right"></TableHead><TableHead className="w-[100px]"></TableHead></>}
{regSourceType === "production" && <><TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead><TableHead className="w-[100px]"></TableHead><TableHead></TableHead><TableHead className="w-[80px] text-right"></TableHead><TableHead className="w-[90px]"></TableHead><TableHead className="w-[90px]"></TableHead><TableHead className="w-[100px]"></TableHead></>}
{regSourceType === "production" && <><TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead><TableHead className="w-[100px]"></TableHead><TableHead></TableHead><TableHead className="w-[80px] text-right"></TableHead><TableHead className="w-[80px] text-right"></TableHead><TableHead className="w-[80px] text-right"></TableHead><TableHead className="w-[90px]"></TableHead><TableHead className="w-[90px]"></TableHead><TableHead className="w-[100px]"></TableHead></>}
</TableRow>
</TableHeader>
<TableBody>
@@ -606,7 +612,7 @@ export default function WorkInstructionPage() {
<TableCell className="text-center" onClick={e => e.stopPropagation()}><Checkbox checked={checked} onCheckedChange={() => toggleRegItem(id)} /></TableCell>
{regSourceType === "item" && <><TableCell className="text-[13px] font-medium">{item.item_code}</TableCell><TableCell className="text-sm">{item.item_name}</TableCell><TableCell className="text-[13px]">{item.spec || "-"}</TableCell></>}
{regSourceType === "order" && <><TableCell className="text-[13px]">{item.order_no}</TableCell><TableCell className="text-[13px]">{item.item_code}</TableCell><TableCell className="text-sm">{item.item_name}</TableCell><TableCell className="text-[13px]">{item.spec || "-"}</TableCell><TableCell className="text-right text-[13px]">{Number(item.qty || 0).toLocaleString()}</TableCell><TableCell className="text-[13px]">{item.due_date || "-"}</TableCell></>}
{regSourceType === "production" && <><TableCell className="text-[13px]">{item.plan_no}</TableCell><TableCell className="text-[13px]">{item.item_code}</TableCell><TableCell className="text-sm">{item.item_name}</TableCell><TableCell className="text-right text-[13px]">{Number(item.plan_qty || 0).toLocaleString()}</TableCell><TableCell className="text-[13px]">{item.start_date ? String(item.start_date).split("T")[0] : "-"}</TableCell><TableCell className="text-[13px]">{item.end_date ? String(item.end_date).split("T")[0] : "-"}</TableCell><TableCell className="text-[13px]">{item.equipment_name || "-"}</TableCell></>}
{regSourceType === "production" && <><TableCell className="text-[13px]">{item.plan_no}</TableCell><TableCell className="text-[13px]">{item.item_code}</TableCell><TableCell className="text-sm">{item.item_name}</TableCell><TableCell className="text-right text-[13px]">{Number(item.plan_qty || 0).toLocaleString()}</TableCell><TableCell className="text-right text-[13px] text-muted-foreground">{Number(item.applied_qty || 0).toLocaleString()}</TableCell><TableCell className={cn("text-right text-[13px] font-semibold", Number(item.remain_qty ?? item.plan_qty ?? 0) < 0 && "text-destructive")}>{Number(item.remain_qty ?? item.plan_qty ?? 0).toLocaleString()}</TableCell><TableCell className="text-[13px]">{item.start_date ? String(item.start_date).split("T")[0] : "-"}</TableCell><TableCell className="text-[13px]">{item.end_date ? String(item.end_date).split("T")[0] : "-"}</TableCell><TableCell className="text-[13px]">{item.equipment_name || "-"}</TableCell></>}
</TableRow>
);
})}
@@ -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 }[] = [];
@@ -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: "판단기준" },
@@ -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<Record<string, any>>({});
const [copyInspectionRows, setCopyInspectionRows] = useState<Record<string, InspectionRow[]>>({});
const [copyCollapsedTypes, setCopyCollapsedTypes] = useState<Record<string, boolean>>({});
/* ═══════════════════ 복사 모달 (기준 품목 검사정보 → 다른 품목들) ═══════════════════ */
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<string, InspectionRow[]> = {};
const typeFlags: Record<string, boolean> = {};
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<string, InspectionRow[]> = {};
const typeFlags: Record<string, boolean> = {};
@@ -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() {
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8 w-[50px]"></TableHead>
@@ -983,7 +1122,7 @@ export default function ItemInspectionInfoPage() {
<TableBody>
{selectedTabRows.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-8 text-xs text-muted-foreground"> </TableCell>
<TableCell colSpan={9} className="text-center py-8 text-xs text-muted-foreground"> </TableCell>
</TableRow>
) : selectedTabRows.map((row: any) => (
<TableRow key={row.id}>
@@ -1002,6 +1141,7 @@ export default function ItemInspectionInfoPage() {
const proc = processOptions.find(p => p.code === code);
return proc?.name || code;
})()}</TableCell>
<TableCell className="text-xs py-2">{row.classification || "-"}</TableCell>
<TableCell className="text-xs py-2">
{(() => {
const insp = inspOptions.find(o => o.code === row.inspection_standard_id);
@@ -1010,7 +1150,16 @@ export default function ItemInspectionInfoPage() {
return jcLabel ? <Badge variant="outline" className="text-[10px]">{jcLabel}</Badge> : "-";
})()}
</TableCell>
<TableCell className="text-xs py-2 font-mono">{row.pass_criteria || "-"}</TableCell>
<TableCell className="text-xs py-2 font-mono">{(() => {
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;
})()}</TableCell>
<TableCell className="text-xs py-2 text-center">
{row.is_required === "true" || row.is_required === true ? (
<Badge variant="destructive" className="text-[9px]"></Badge>
@@ -1185,6 +1334,7 @@ export default function ItemInspectionInfoPage() {
<TableHead className="text-[10px] font-bold w-[130px]"> </TableHead>
<TableHead className="text-[10px] font-bold w-[90px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[90px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[100px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[80px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[200px]"> ()</TableHead>
<TableHead className="text-[10px] font-bold w-[40px]"></TableHead>
@@ -1194,7 +1344,7 @@ export default function ItemInspectionInfoPage() {
</TableHeader>
<TableBody>
{(!inspectionRows[key] || inspectionRows[key].length === 0) ? (
<TableRow><TableCell colSpan={9} className="text-center py-4 text-xs text-muted-foreground"> </TableCell></TableRow>
<TableRow><TableCell colSpan={10} className="text-center py-4 text-xs text-muted-foreground"> </TableCell></TableRow>
) : inspectionRows[key].map((row) => (
<TableRow key={row.id}>
<TableCell className="p-1">
@@ -1219,6 +1369,9 @@ export default function ItemInspectionInfoPage() {
<Input className="h-8 text-xs" value={row.apply_process} onChange={(e) => updateInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" />
)}
</TableCell>
<TableCell className="p-1">
<Input className="h-8 text-xs" value={row.classification || ""} onChange={(e) => updateInspRow(key, row.id, "classification", e.target.value)} placeholder="구분 입력" />
</TableCell>
<TableCell className="p-1 text-center">
{row.judgment_criteria ? <Badge variant="outline" className="text-[10px]">{row.judgment_criteria}</Badge> : <span className="text-[10px] text-muted-foreground">-</span>}
</TableCell>
@@ -1285,20 +1438,20 @@ export default function ItemInspectionInfoPage() {
</DialogContent>
</Dialog>
{/* ═══════════════════ 복사 모달 ═══════════════════ */}
{/* ═══════════════════ 복사 모달 (2단 분할: 좌 대상 / 우 편집) ═══════════════════ */}
<Dialog open={copyModalOpen} onOpenChange={(v) => { if (!copying) setCopyModalOpen(v); }}>
<DialogContent
className="max-w-3xl w-[95vw] max-h-[85vh] flex flex-col overflow-hidden"
className="max-w-[95vw] sm:max-w-[1400px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden"
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => { if (copying) e.preventDefault(); }}
>
<DialogHeader>
<DialogHeader className="shrink-0">
<DialogTitle>{copying ? "검사정보 복사 중..." : "검사정보 복사"}</DialogTitle>
<DialogDescription>
<span className="font-medium text-foreground">{selectedGroup?.item_name || "-"}</span>
<span className="text-muted-foreground"> ({selectedItemCode})</span>
<span>{copying ? " 의 검사정보를 복사하고 있습니다" : " 의 검사정보를 아래 선택한 품목들에 복사합니다"}</span>
<span>{copying ? " 의 검사정보를 복사하고 있습니다" : " 의 검사정보를 편집해서 선택한 품목들에 복사합니다. 기준 품목은 변경되지 않아요"}</span>
</DialogDescription>
</DialogHeader>
{copying ? (
@@ -1322,81 +1475,229 @@ export default function ItemInspectionInfoPage() {
</p>
</div>
</div>
) : (<>
<div className="flex gap-2 shrink-0">
<Input className="h-9 flex-1" placeholder="품목코드 또는 품목명" value={copySearchKeyword}
onChange={(e) => setCopySearchKeyword(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleCopySearch(); }} />
<Button size="sm" className="h-9" onClick={handleCopySearch} disabled={copySearchLoading}>
{copySearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" /></>}
</Button>
</div>
<div className="flex-1 border rounded-lg overflow-auto">
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[40px] text-center">
<Checkbox
checked={copyFilteredItems.length > 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)));
}}
/>
</TableHead>
<TableHead className="text-[11px] font-bold w-[140px]"></TableHead>
<TableHead className="text-[11px] font-bold"></TableHead>
<TableHead className="text-[11px] font-bold w-[100px]"></TableHead>
<TableHead className="text-[11px] font-bold w-[80px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{copyFilteredItems.length === 0 ? (
<TableRow><TableCell colSpan={5} className="text-center py-8 text-muted-foreground text-sm">
{copySearchLoading ? "검색 중..." : "검색 결과가 없어요"}
</TableCell></TableRow>
) : copyFilteredItems.map((item) => (
<TableRow key={item.code} className="cursor-pointer hover:bg-primary/5"
onClick={() => toggleCopyChecked(item.code)}>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox checked={copyCheckedIds.includes(item.code)} onCheckedChange={() => toggleCopyChecked(item.code)} />
</TableCell>
<TableCell className="text-sm font-mono">{item.code}</TableCell>
<TableCell className="text-sm">{item.name}</TableCell>
<TableCell className="text-sm">{item.item_type}</TableCell>
<TableCell className="text-sm">{item.unit}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="shrink-0 flex items-center justify-between border-t px-2 py-2 text-xs text-muted-foreground">
<span>
<span className="font-medium text-foreground">{copyTotal.toLocaleString()}</span>
{copyCheckedIds.length > 0 && <span className="ml-2"> <span className="font-medium text-primary">{copyCheckedIds.length}</span></span>}
</span>
<div className="flex items-center gap-0.5">
<button onClick={() => { 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"><ChevronsLeft className="h-3.5 w-3.5" /></button>
<button onClick={() => { 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"><ChevronLeft className="h-3.5 w-3.5" /></button>
{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 (
<button key={p} onClick={() => { 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}</button>
);
})}
<button onClick={() => { 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"><ChevronRight className="h-3.5 w-3.5" /></button>
<button onClick={() => { 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"><ChevronsRight className="h-3.5 w-3.5" /></button>
) : (
<div className="flex-1 grid grid-cols-[420px_1fr] gap-4 overflow-hidden">
{/* 좌측: 복사 대상 품목 선택 */}
<div className="flex flex-col overflow-hidden border rounded-lg">
<div className="flex items-center justify-between border-b bg-muted/50 px-3 py-2">
<span className="text-xs font-semibold"> </span>
{copyCheckedIds.length > 0 && <span className="text-[10px] text-primary"> {copyCheckedIds.length}</span>}
</div>
<div className="flex gap-2 px-2 pt-2">
<Input className="h-8 flex-1 text-xs" placeholder="품목코드 또는 품목명" value={copySearchKeyword}
onChange={(e) => setCopySearchKeyword(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleCopySearch(); }} />
<Button size="sm" className="h-8 text-xs" onClick={handleCopySearch} disabled={copySearchLoading}>
{copySearchLoading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Search className="w-3.5 h-3.5" />}
</Button>
</div>
<div className="flex-1 overflow-auto mt-2">
<Table>
<TableHeader className="sticky top-0 z-10 bg-background">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[36px] text-center text-[10px]">
<Checkbox
checked={copyFilteredItems.length > 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)));
}}
/>
</TableHead>
<TableHead className="text-[10px] font-bold w-[120px]"></TableHead>
<TableHead className="text-[10px] font-bold"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{copyFilteredItems.length === 0 ? (
<TableRow><TableCell colSpan={3} className="text-center py-6 text-muted-foreground text-xs">
{copySearchLoading ? "검색 중..." : "검색 결과가 없어요"}
</TableCell></TableRow>
) : copyFilteredItems.map((item) => (
<TableRow key={item.code} className="cursor-pointer hover:bg-primary/5"
onClick={() => toggleCopyChecked(item.code)}>
<TableCell className="text-center p-1" onClick={(e) => e.stopPropagation()}>
<Checkbox checked={copyCheckedIds.includes(item.code)} onCheckedChange={() => toggleCopyChecked(item.code)} />
</TableCell>
<TableCell className="text-xs font-mono p-1">{item.code}</TableCell>
<TableCell className="text-xs p-1 truncate">{item.name}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="border-t flex items-center justify-between px-2 py-1 text-[10px] text-muted-foreground">
<span> <span className="font-medium text-foreground">{copyTotal.toLocaleString()}</span></span>
<div className="flex items-center gap-0.5">
<button onClick={() => { 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"><ChevronsLeft className="h-3 w-3" /></button>
<button onClick={() => { 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"><ChevronLeft className="h-3 w-3" /></button>
<span className="text-[10px] mx-1">{copyPage}/{copyTotalPages}</span>
<button onClick={() => { 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"><ChevronRight className="h-3 w-3" /></button>
<button onClick={() => { 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"><ChevronsRight className="h-3 w-3" /></button>
</div>
</div>
</div>
{/* 우측: 편집 폼 (등록/수정 폼과 동일 구조) */}
<div className="flex flex-col overflow-hidden border rounded-lg">
<div className="border-b bg-muted/50 px-3 py-2">
<span className="text-xs font-semibold"> (: {selectedItemCode})</span>
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Select value={copyForm.is_active === false || copyForm.is_active === "N" ? "N" : "Y"} onValueChange={(v) => setCopyForm(p => ({ ...p, is_active: v === "Y" ? "사용" : "미사용" }))}>
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="Y"></SelectItem>
<SelectItem value="N"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Select value={copyForm.manager || ""} onValueChange={(v) => setCopyForm(p => ({ ...p, manager: v }))}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="관리자 선택" /></SelectTrigger>
<SelectContent>{userOptions.map(o => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<h4 className="text-xs font-semibold"> </h4>
<div className="flex flex-wrap gap-3">
{INSPECTION_TYPES.map(({ key, label }) => (
<div key={key} className="flex items-center gap-1.5">
<Checkbox checked={!!copyForm[key]} onCheckedChange={(v) => setCopyForm(p => ({ ...p, [key]: !!v }))} />
<Label className="text-xs cursor-pointer">{label}</Label>
</div>
))}
</div>
</div>
{INSPECTION_TYPES.filter(t => !!copyForm[t.key]).map(({ key, label }) => (
<div key={key} className="space-y-1.5">
<button type="button" className="w-full flex items-center gap-2 py-1.5 px-2 rounded-md border bg-muted/50 hover:bg-muted text-left" onClick={() => toggleCopyCollapse(key)}>
<Badge variant="default" className="text-[10px]">{label}</Badge>
<span className="text-xs font-medium"> </span>
<span className="text-[10px] text-muted-foreground ml-auto">{(copyInspectionRows[key] || []).length}</span>
</button>
{!copyCollapsedTypes[key] && (
<div className="space-y-1.5 pl-1">
<div className="flex items-center justify-between">
<span className="text-[10px] font-semibold text-muted-foreground"> </span>
<Button type="button" size="sm" variant="outline" className="h-6 text-[10px]" onClick={() => addCopyInspRow(key)}>
<Plus className="w-3 h-3 mr-1" />
</Button>
</div>
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-muted/50 hover:bg-muted/50">
<TableHead className="text-[10px] font-bold w-[150px]"> </TableHead>
<TableHead className="text-[10px] font-bold w-[110px]"> </TableHead>
<TableHead className="text-[10px] font-bold w-[80px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[80px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[90px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[70px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[180px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[36px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[60px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[32px]" />
</TableRow>
</TableHeader>
<TableBody>
{(!copyInspectionRows[key] || copyInspectionRows[key].length === 0) ? (
<TableRow><TableCell colSpan={10} className="text-center py-3 text-[10px] text-muted-foreground"> </TableCell></TableRow>
) : copyInspectionRows[key].map((row) => (
<TableRow key={row.id}>
<TableCell className="p-1">
<Select value={row.inspection_standard_id || ""} onValueChange={(v) => updateCopyInspRow(key, row.id, "inspection_standard_id", v)}>
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="검사기준" /></SelectTrigger>
<SelectContent>{getFilteredInspOptions(key).map(o => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}</SelectContent>
</Select>
</TableCell>
<TableCell className="p-1"><Input className="h-7 text-[10px] bg-muted" value={row.inspection_detail} readOnly placeholder="자동" /></TableCell>
<TableCell className="p-1"><Input className="h-7 text-[10px] bg-muted" value={row.inspection_method} readOnly placeholder="자동" /></TableCell>
<TableCell className="p-1">
{processOptions.length > 0 ? (
<Select value={row.apply_process || ""} onValueChange={(v) => updateCopyInspRow(key, row.id, "apply_process", v)}>
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="공정" /></SelectTrigger>
<SelectContent>
{processOptions.map((p) => (
<SelectItem key={p.code} value={p.code} className="text-xs">{p.name}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input className="h-7 text-[10px]" value={row.apply_process} onChange={(e) => updateCopyInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" />
)}
</TableCell>
<TableCell className="p-1">
<Input className="h-7 text-[10px]" value={row.classification || ""} onChange={(e) => updateCopyInspRow(key, row.id, "classification", e.target.value)} placeholder="구분" />
</TableCell>
<TableCell className="p-1 text-center">
{row.judgment_criteria ? <Badge variant="outline" className="text-[9px]">{row.judgment_criteria}</Badge> : <span className="text-[9px] text-muted-foreground">-</span>}
</TableCell>
<TableCell className="p-1">
{row.judgment_criteria === "선택형" && row.selection_options ? (
<Select value={row.acceptance_criteria || ""} onValueChange={(v) => updateCopyInspRow(key, row.id, "acceptance_criteria", v)} disabled={!row.inspection_standard_id}>
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{row.selection_options.split(",").filter(Boolean).map((opt) => (
<SelectItem key={opt} value={opt} className="text-xs">{opt}</SelectItem>
))}
</SelectContent>
</Select>
) : row.judgment_criteria === "O/X" ? (
<Select value={row.acceptance_criteria || ""} onValueChange={(v) => updateCopyInspRow(key, row.id, "acceptance_criteria", v)} disabled={!row.inspection_standard_id}>
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
<SelectItem value="O" className="text-xs">O ()</SelectItem>
<SelectItem value="X" className="text-xs">X ()</SelectItem>
</SelectContent>
</Select>
) : row.judgment_criteria === "수치(범위)" ? (
<div className="flex items-center gap-1">
<Input className="h-7 text-[10px] w-14" value={row.acceptance_criteria?.split("|")[0] || ""} onChange={(e) => {
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} />
<span className="text-[9px] text-muted-foreground">±</span>
<Input className="h-7 text-[10px] w-10" value={row.acceptance_criteria?.split("|")[1] || ""} onChange={(e) => {
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} />
</div>
) : (
<Input className="h-7 text-[10px]" value={row.acceptance_criteria} onChange={(e) => updateCopyInspRow(key, row.id, "acceptance_criteria", e.target.value)} placeholder="합격기준" disabled={!row.inspection_standard_id} />
)}
</TableCell>
<TableCell className="p-1 text-center"><Checkbox checked={row.is_required} onCheckedChange={(v) => updateCopyInspRow(key, row.id, "is_required", !!v)} /></TableCell>
<TableCell className="p-1 text-[10px] text-muted-foreground">{row.unit || "-"}</TableCell>
<TableCell className="p-1">
<Button type="button" variant="destructive" size="sm" className="h-6 w-6 p-0" onClick={() => removeCopyInspRow(key, row.id)}><Trash2 className="w-3 h-3" /></Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
</div>
))}
</div>
</div>
</div>
</div>
</>)}
)}
<DialogFooter className="shrink-0">
<Button variant="outline" onClick={() => setCopyModalOpen(false)} disabled={copying}></Button>
<Button onClick={handleCopy} disabled={copying || copyCheckedIds.length === 0}>
@@ -191,13 +191,13 @@ export default function CustomerManagementPage() {
const optMap: Record<string, { code: string; label: string }[]> = {};
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<string, { code: string; label: string }[]> = {};
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}
@@ -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 }[] = [];
@@ -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() {
{/* 테이블 영역 */}
<div className="flex-1 overflow-auto">
<EDataTable
columns={getVisibleColumns(tab.key).map((col): EDataTableColumn => ({
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]}
@@ -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<string, string> = {};
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);
@@ -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"
>
<span className="font-semibold font-mono text-primary">
{loc.location || loc.warehouse}
{loc.warehouse_name || loc.location || loc.warehouse}
</span>
<span className="font-semibold">
{loc.qty.toLocaleString()}
@@ -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<string, { code: string; label: string }[]>
>({});
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<string, { code: string; label: string }[]> = {};
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() {
<TableCell className="p-2 font-medium">{item.item_number}</TableCell>
<TableCell className="p-2">{item.item_name || "-"}</TableCell>
<TableCell className="p-2">{item.spec || "-"}</TableCell>
<TableCell className="p-2">{item.unit || "EA"}</TableCell>
<TableCell className="p-2">{resolveCat("inventory_unit", item.inventory_unit) || "EA"}</TableCell>
<TableCell className="p-2 text-right font-semibold">{Number(item.pkg_qty).toLocaleString()}</TableCell>
<TableCell className="p-2 text-center">
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => handleDeletePkgItem(item)}>
@@ -954,8 +994,8 @@ export default function PackagingPage() {
<TableCell className="p-2 font-medium">{item.item_number}</TableCell>
<TableCell className="p-2">{item.item_name}</TableCell>
<TableCell className="p-2">{item.spec || "-"}</TableCell>
<TableCell className="p-2">{item.material || "-"}</TableCell>
<TableCell className="p-2">{item.unit || "EA"}</TableCell>
<TableCell className="p-2">{resolveCat("material", item.material) || "-"}</TableCell>
<TableCell className="p-2">{resolveCat("inventory_unit", item.inventory_unit) || "EA"}</TableCell>
</TableRow>
))}
</TableBody>
@@ -158,6 +158,10 @@ export default function WarehouseManagementPage() {
const [rackStatus, setRackStatus] = useState("");
const [rackPreview, setRackPreview] = useState<any[]>([]);
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() {
</div>
</div>
{/* 위치명 형식 — 구역/열/단 뒤에 붙일 표현만 자유 입력 */}
<div className="rounded-lg border p-3 bg-muted/30 space-y-2">
<Label className="text-xs font-semibold"> </Label>
<div className="flex items-center gap-1 text-xs flex-wrap">
<span className="font-mono text-muted-foreground">A</span>
<Input
value={rackZoneLabel}
onChange={(e) => setRackZoneLabel(e.target.value)}
placeholder="구역"
className="h-8 w-20 text-xs"
/>
<span className="font-mono text-muted-foreground">- 01</span>
<Input
value={rackRowLabel}
onChange={(e) => setRackRowLabel(e.target.value)}
placeholder="열"
className="h-8 w-20 text-xs"
/>
<span className="font-mono text-muted-foreground">- 1</span>
<Input
value={rackLevelLabel}
onChange={(e) => setRackLevelLabel(e.target.value)}
placeholder="단"
className="h-8 w-20 text-xs"
/>
</div>
<p className="text-[11px] text-muted-foreground">
: <span className="font-mono font-semibold">A{rackZoneLabel}-01{rackRowLabel}-1{rackLevelLabel}</span>
{" "} // , .
</p>
</div>
{/* 등록 미리보기 */}
<div>
<div className="flex items-center justify-between mb-3">
@@ -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<string, any> = {};
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 () => {
@@ -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<string, any> = {
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() {
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
if (!selectedBomId || !bomHeader) {
toast.error("수정할 BOM을 선택해주세요");
return;
}
openEditModal();
}}
disabled={!selectedBomId || !bomHeader}
>
<Pencil className="w-3.5 h-3.5 mr-1" />
</Button>
<div className="w-px h-4 bg-border mx-0.5" />
<Button
size="sm"
@@ -59,6 +59,90 @@ interface SelectedItem {
itemCode: string; itemName: string; spec: string; qty: number; remark: string;
sourceType: SourceType; sourceTable: string; sourceId: string | number;
routing?: string; routingOptions?: RoutingVersionData[];
// 품목별 일정/설비/작업조/작업자 (옵션 A — 다중선택 지원)
startDate?: string;
endDate?: string;
equipmentIds?: string[];
workTeams?: string[];
workers?: string[];
}
// 공용 다중선택 Popover 컴포넌트 (설비/작업조/작업자에 재사용)
interface MultiSelectOption { value: string; label: string; sub?: string; }
interface MultiSelectPopoverProps {
options: MultiSelectOption[];
value: string[];
onChange: (next: string[]) => 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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" aria-expanded={open}
className={cn("w-full justify-between font-normal", triggerClassName || "h-7 text-xs")}>
<span className={cn("truncate", value.length === 0 && "text-muted-foreground")}>{display}</span>
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)", minWidth: 200 }} align="start">
{searchable && (
<div className="p-2 border-b">
<Input placeholder="검색..." value={keyword} onChange={e => setKeyword(e.target.value)} className="h-7 text-xs" />
</div>
)}
<div className="max-h-56 overflow-y-auto py-1">
{filtered.length === 0 ? (
<div className="py-4 text-center text-xs text-muted-foreground">{emptyMessage}</div>
) : filtered.map(opt => (
<label
key={opt.value}
className="flex items-center gap-2 px-2 py-1.5 cursor-pointer hover:bg-muted/50 text-xs"
onClick={e => { e.preventDefault(); toggle(opt.value); }}
>
<Checkbox checked={selectedSet.has(opt.value)} onCheckedChange={() => toggle(opt.value)} className="h-3.5 w-3.5" />
<span className="flex-1 truncate">{opt.label}{opt.sub ? <span className="text-muted-foreground ml-1">({opt.sub})</span> : null}</span>
</label>
))}
</div>
{value.length > 0 && (
<div className="p-1.5 border-t flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">{value.length} </span>
<Button variant="ghost" size="sm" className="h-6 text-[11px] px-2" onClick={() => onChange([])}></Button>
</div>
)}
</PopoverContent>
</Popover>
);
}
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<SelectedItem, "startDate" | "endDate" | "equipmentIds" | "workTeams" | "workers">;
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() {
<TableHead className="w-[50px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground"><Checkbox checked={regSourceData.length > 0 && regCheckedIds.size === regSourceData.length} onCheckedChange={toggleRegAll} /></TableHead>
{regSourceType === "item" && <><TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead><TableHead></TableHead><TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead></>}
{regSourceType === "order" && <><TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead><TableHead className="w-[100px]"></TableHead><TableHead></TableHead><TableHead className="w-[100px]"></TableHead><TableHead className="w-[80px] text-right"></TableHead><TableHead className="w-[100px]"></TableHead></>}
{regSourceType === "production" && <><TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead><TableHead className="w-[100px]"></TableHead><TableHead></TableHead><TableHead className="w-[80px] text-right"></TableHead><TableHead className="w-[90px]"></TableHead><TableHead className="w-[90px]"></TableHead><TableHead className="w-[100px]"></TableHead></>}
{regSourceType === "production" && <><TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead><TableHead className="w-[100px]"></TableHead><TableHead></TableHead><TableHead className="w-[80px] text-right"></TableHead><TableHead className="w-[80px] text-right"></TableHead><TableHead className="w-[80px] text-right"></TableHead><TableHead className="w-[90px]"></TableHead><TableHead className="w-[90px]"></TableHead><TableHead className="w-[100px]"></TableHead></>}
</TableRow>
</TableHeader>
<TableBody>
@@ -590,7 +735,7 @@ export default function WorkInstructionPage() {
<TableCell className="text-center" onClick={e => e.stopPropagation()}><Checkbox checked={checked} onCheckedChange={() => toggleRegItem(id)} /></TableCell>
{regSourceType === "item" && <><TableCell className="text-[13px] font-medium">{item.item_code}</TableCell><TableCell className="text-sm">{item.item_name}</TableCell><TableCell className="text-[13px]">{item.spec || "-"}</TableCell></>}
{regSourceType === "order" && <><TableCell className="text-[13px]">{item.order_no}</TableCell><TableCell className="text-[13px]">{item.item_code}</TableCell><TableCell className="text-sm">{item.item_name}</TableCell><TableCell className="text-[13px]">{item.spec || "-"}</TableCell><TableCell className="text-right text-[13px]">{Number(item.qty || 0).toLocaleString()}</TableCell><TableCell className="text-[13px]">{item.due_date || "-"}</TableCell></>}
{regSourceType === "production" && <><TableCell className="text-[13px]">{item.plan_no}</TableCell><TableCell className="text-[13px]">{item.item_code}</TableCell><TableCell className="text-sm">{item.item_name}</TableCell><TableCell className="text-right text-[13px]">{Number(item.plan_qty || 0).toLocaleString()}</TableCell><TableCell className="text-[13px]">{item.start_date ? String(item.start_date).split("T")[0] : "-"}</TableCell><TableCell className="text-[13px]">{item.end_date ? String(item.end_date).split("T")[0] : "-"}</TableCell><TableCell className="text-[13px]">{item.equipment_name || "-"}</TableCell></>}
{regSourceType === "production" && <><TableCell className="text-[13px]">{item.plan_no}</TableCell><TableCell className="text-[13px]">{item.item_code}</TableCell><TableCell className="text-sm">{item.item_name}</TableCell><TableCell className="text-right text-[13px]">{Number(item.plan_qty || 0).toLocaleString()}</TableCell><TableCell className="text-right text-[13px] text-muted-foreground">{Number(item.applied_qty || 0).toLocaleString()}</TableCell><TableCell className={cn("text-right text-[13px] font-semibold", Number(item.remain_qty ?? item.plan_qty ?? 0) < 0 && "text-destructive")}>{Number(item.remain_qty ?? item.plan_qty ?? 0).toLocaleString()}</TableCell><TableCell className="text-[13px]">{item.start_date ? String(item.start_date).split("T")[0] : "-"}</TableCell><TableCell className="text-[13px]">{item.end_date ? String(item.end_date).split("T")[0] : "-"}</TableCell><TableCell className="text-[13px]">{item.equipment_name || "-"}</TableCell></>}
</TableRow>
);
})}
@@ -619,7 +764,7 @@ export default function WorkInstructionPage() {
{/* ── 2단계: 확인 모달 ── */}
<Dialog open={isConfirmModalOpen} onOpenChange={setIsConfirmModalOpen}>
<DialogContent className="max-w-[1100px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden">
<DialogContent className="max-w-[95vw] sm:max-w-[1500px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> '최종 적용' .</DialogDescription>
@@ -628,38 +773,33 @@ export default function WorkInstructionPage() {
<div className="space-y-5">
<div className="bg-muted/30 border rounded-lg p-5">
<h4 className="text-[13px] font-bold pb-2 border-b text-foreground"> </h4>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-3">
<p className="text-[11px] text-muted-foreground mt-2">···· .</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-3">
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Input value={confirmWiNo} readOnly className="h-9 bg-muted cursor-not-allowed font-mono" /></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Select value={confirmStatus} onValueChange={setConfirmStatus}><SelectTrigger className="h-9"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="일반"></SelectItem><SelectItem value="긴급"></SelectItem></SelectContent></Select>
</div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Input type="date" value={confirmStartDate} onChange={(e) => setConfirmStartDate(e.target.value)} className="h-9" /></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Input type="date" value={confirmEndDate} onChange={(e) => setConfirmEndDate(e.target.value)} className="h-9" /></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Select value={nv(confirmEquipmentId)} onValueChange={v => setConfirmEquipmentId(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="설비 선택" /></SelectTrigger><SelectContent><SelectItem value="none"> </SelectItem>{equipmentOptions.map(eq => <SelectItem key={eq.id} value={eq.id}>{eq.equipment_name}</SelectItem>)}</SelectContent></Select>
</div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Select value={nv(confirmWorkTeam)} onValueChange={v => setConfirmWorkTeam(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="작업조" /></SelectTrigger><SelectContent><SelectItem value="none"> </SelectItem><SelectItem value="주간"></SelectItem><SelectItem value="야간"></SelectItem></SelectContent></Select>
</div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<WorkerCombobox value={confirmWorker} onChange={setConfirmWorker} open={confirmWorkerOpen} onOpenChange={setConfirmWorkerOpen} />
</div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Input className="h-9" placeholder="비고를 입력해주세요" /></div>
</div>
</div>
<div className="border rounded-lg p-5">
<h4 className="text-[13px] font-bold pb-2 border-b text-foreground mb-3"> </h4>
<div className="overflow-auto">
<Table>
<Table className="min-w-[1500px]">
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[50px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[140px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[180px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[140px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[40px]" />
</TableRow>
</TableHeader>
@@ -668,7 +808,7 @@ export default function WorkInstructionPage() {
<TableRow key={idx}>
<TableCell className="text-[13px] text-center">{idx + 1}</TableCell>
<TableCell className="text-[13px] font-medium">{item.itemCode}</TableCell>
<TableCell className="text-sm">{item.itemName || item.itemCode}</TableCell>
<TableCell className="text-sm truncate max-w-[140px]" title={item.itemName || item.itemCode}>{item.itemName || item.itemCode}</TableCell>
<TableCell className="text-[13px]">{item.spec || "-"}</TableCell>
<TableCell><Input type="number" className="h-7 text-[13px] w-20" value={item.qty} onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /></TableCell>
<TableCell>
@@ -690,6 +830,40 @@ export default function WorkInstructionPage() {
</SelectContent>
</Select>
</TableCell>
<TableCell>
<Input type="date" className="h-7 text-[13px]" value={item.startDate || ""} onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, startDate: e.target.value } : it))} />
</TableCell>
<TableCell>
<Input type="date" className="h-7 text-[13px]" value={item.endDate || ""} onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, endDate: e.target.value } : it))} />
</TableCell>
<TableCell>
<MultiSelectPopover
options={equipmentOptions.map(eq => ({ 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="설비가 없어요"
/>
</TableCell>
<TableCell>
<MultiSelectPopover
options={[{ value: "주간", label: "주간" }, { value: "야간", label: "야간" }]}
value={item.workTeams || []}
onChange={next => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, workTeams: next } : it))}
placeholder="작업조 선택"
/>
</TableCell>
<TableCell>
<MultiSelectPopover
options={employeeOptions.map(emp => ({ 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="사원을 찾을 수 없어요"
/>
</TableCell>
<TableCell><Input className="h-7 text-[13px]" value={item.remark} placeholder="비고" onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /></TableCell>
<TableCell><Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setConfirmItems(prev => prev.filter((_, i) => i !== idx))}><X className="w-3 h-3 text-destructive" /></Button></TableCell>
</TableRow>
@@ -710,7 +884,7 @@ export default function WorkInstructionPage() {
{/* ── 수정 모달 ── */}
<Dialog open={isEditModalOpen} onOpenChange={(v) => { if (!v && wsModalOpen) return; setIsEditModalOpen(v); }}>
<DialogContent className="max-w-[1100px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden">
<DialogContent className="max-w-[95vw] sm:max-w-[1500px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden">
<DialogHeader>
<DialogTitle>{`작업지시 관리 - ${editOrder?.work_instruction_no || ""}`}</DialogTitle>
<DialogDescription> / .</DialogDescription>
@@ -719,48 +893,47 @@ export default function WorkInstructionPage() {
<div className="space-y-5">
<div className="bg-muted/30 border rounded-lg p-5">
<h4 className="text-[13px] font-bold pb-2 border-b text-foreground"> </h4>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-3">
<p className="text-[11px] text-muted-foreground mt-2">···· .</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-3">
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Select value={editStatus} onValueChange={setEditStatus}><SelectTrigger className="h-9"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="일반"></SelectItem><SelectItem value="긴급"></SelectItem></SelectContent></Select></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Input type="date" value={editStartDate} onChange={(e) => setEditStartDate(e.target.value)} className="h-9" /></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Input type="date" value={editEndDate} onChange={(e) => setEditEndDate(e.target.value)} className="h-9" /></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Select value={nv(editEquipmentId)} onValueChange={v => setEditEquipmentId(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="설비 선택" /></SelectTrigger><SelectContent><SelectItem value="none"> </SelectItem>{equipmentOptions.map(eq => <SelectItem key={eq.id} value={eq.id}>{eq.equipment_name}</SelectItem>)}</SelectContent></Select></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Select value={nv(editWorkTeam)} onValueChange={v => setEditWorkTeam(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="작업조" /></SelectTrigger><SelectContent><SelectItem value="none"> </SelectItem><SelectItem value="주간"></SelectItem><SelectItem value="야간"></SelectItem></SelectContent></Select></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<WorkerCombobox value={editWorker} onChange={setEditWorker} open={editWorkerOpen} onOpenChange={setEditWorkerOpen} />
</div>
<div className="space-y-1 col-span-2"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Input value={editRemark} onChange={e => setEditRemark(e.target.value)} className="h-9" placeholder="비고를 입력해주세요" /></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Input value={editRemark} onChange={e => setEditRemark(e.target.value)} className="h-9" placeholder="비고를 입력해주세요" /></div>
</div>
</div>
{/* 품목 테이블 — 라우팅/공정작업기준을 품목별로 표시 */}
{/* 품목 테이블 — 품목별 일정/설비/작업조/작업자 + 라우팅/공정작업기준 */}
<div className="border rounded-lg overflow-hidden">
<div className="flex items-center justify-between px-4 py-2.5 bg-muted/30 border-b">
<span className="text-[13px] font-bold text-foreground"> </span>
<span className="text-[11px] font-semibold text-primary bg-primary/8 border border-primary/15 px-2 py-0.5 rounded-full font-mono">{editItems.length}</span>
</div>
<div className="overflow-auto">
<Table>
<Table className="min-w-[1500px]">
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[50px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[140px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[90px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[180px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[80px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[140px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[40px]" />
</TableRow>
</TableHeader>
<TableBody>
{editItems.length === 0 ? (
<TableRow><TableCell colSpan={9} className="text-center py-8 text-sm text-muted-foreground"> </TableCell></TableRow>
<TableRow><TableCell colSpan={14} className="text-center py-8 text-sm text-muted-foreground"> </TableCell></TableRow>
) : editItems.map((item, idx) => (
<TableRow key={idx}>
<TableCell className="text-[13px] text-center">{idx + 1}</TableCell>
<TableCell className="text-[13px] font-medium">{item.itemCode}</TableCell>
<TableCell className="text-[13px] max-w-[150px] truncate" title={item.itemName}>{item.itemName || "-"}</TableCell>
<TableCell className="text-sm truncate max-w-[140px]" title={item.itemName}>{item.itemName || "-"}</TableCell>
<TableCell className="text-[13px] truncate" title={item.spec}>{item.spec || "-"}</TableCell>
<TableCell className="text-right"><Input type="number" className="h-7 text-[13px] w-20 ml-auto" value={item.qty} onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /></TableCell>
<TableCell>
@@ -803,6 +976,40 @@ export default function WorkInstructionPage() {
<ClipboardCheck className="w-3 h-3 mr-1" />
</Button>
</TableCell>
<TableCell>
<Input type="date" className="h-7 text-[13px]" value={item.startDate || ""} onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, startDate: e.target.value } : it))} />
</TableCell>
<TableCell>
<Input type="date" className="h-7 text-[13px]" value={item.endDate || ""} onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, endDate: e.target.value } : it))} />
</TableCell>
<TableCell>
<MultiSelectPopover
options={equipmentOptions.map(eq => ({ 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="설비가 없어요"
/>
</TableCell>
<TableCell>
<MultiSelectPopover
options={[{ value: "주간", label: "주간" }, { value: "야간", label: "야간" }]}
value={item.workTeams || []}
onChange={next => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, workTeams: next } : it))}
placeholder="작업조 선택"
/>
</TableCell>
<TableCell>
<MultiSelectPopover
options={employeeOptions.map(emp => ({ 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="사원을 찾을 수 없어요"
/>
</TableCell>
<TableCell><Input className="h-7 text-[13px]" value={item.remark} placeholder="비고" onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /></TableCell>
<TableCell><Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setEditItems(prev => prev.filter((_, i) => i !== idx))}><X className="w-3 h-3 text-destructive" /></Button></TableCell>
</TableRow>
@@ -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 }[] = [];
@@ -284,6 +284,11 @@ export default function ItemInspectionInfoPage() {
loadProcessOptions(item.code);
};
// 복사 모달: 편집 가능한 기준 데이터 상태 (등록/수정 폼과 평행 구조)
const [copyForm, setCopyForm] = useState<Record<string, any>>({});
const [copyInspectionRows, setCopyInspectionRows] = useState<Record<string, InspectionRow[]>>({});
const [copyCollapsedTypes, setCopyCollapsedTypes] = useState<Record<string, boolean>>({});
/* ═══════════════════ 복사 모달 (기준 품목 검사정보 → 다른 품목들) ═══════════════════ */
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<string, InspectionRow[]> = {};
const typeFlags: Record<string, boolean> = {};
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 ? <Badge variant="outline" className="text-[10px]">{jcLabel}</Badge> : "-";
})()}
</TableCell>
<TableCell className="text-xs py-2 font-mono">{row.pass_criteria || "-"}</TableCell>
<TableCell className="text-xs py-2 font-mono">{(() => {
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;
})()}</TableCell>
<TableCell className="text-xs py-2 text-center">
{row.is_required === "true" || row.is_required === true ? (
<Badge variant="destructive" className="text-[9px]"></Badge>
@@ -1369,20 +1496,20 @@ export default function ItemInspectionInfoPage() {
</DialogContent>
</Dialog>
{/* ═══════════════════ 복사 모달 ═══════════════════ */}
{/* ═══════════════════ 복사 모달 (2단 분할: 좌 대상 / 우 편집) ═══════════════════ */}
<Dialog open={copyModalOpen} onOpenChange={(v) => { if (!copying) setCopyModalOpen(v); }}>
<DialogContent
className="max-w-3xl w-[95vw] max-h-[85vh] flex flex-col overflow-hidden"
className="max-w-[95vw] sm:max-w-[1400px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden"
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => { if (copying) e.preventDefault(); }}
>
<DialogHeader>
<DialogHeader className="shrink-0">
<DialogTitle>{copying ? "검사정보 복사 중..." : "검사정보 복사"}</DialogTitle>
<DialogDescription>
<span className="font-medium text-foreground">{selectedGroup?.item_name || "-"}</span>
<span className="text-muted-foreground"> ({selectedItemCode})</span>
<span>{copying ? " 의 검사정보를 복사하고 있습니다" : " 의 검사정보를 아래 선택한 품목들에 복사합니다"}</span>
<span>{copying ? " 의 검사정보를 복사하고 있습니다" : " 의 검사정보를 편집해서 선택한 품목들에 복사합니다. 기준 품목은 변경되지 않아요"}</span>
</DialogDescription>
</DialogHeader>
{copying ? (
@@ -1406,81 +1533,229 @@ export default function ItemInspectionInfoPage() {
</p>
</div>
</div>
) : (<>
<div className="flex gap-2 shrink-0">
<Input className="h-9 flex-1" placeholder="품목코드 또는 품목명" value={copySearchKeyword}
onChange={(e) => setCopySearchKeyword(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleCopySearch(); }} />
<Button size="sm" className="h-9" onClick={handleCopySearch} disabled={copySearchLoading}>
{copySearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" /></>}
</Button>
</div>
<div className="flex-1 border rounded-lg overflow-auto">
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[40px] text-center">
<Checkbox
checked={copyFilteredItems.length > 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)));
}}
/>
</TableHead>
<TableHead className="text-[11px] font-bold w-[140px]"></TableHead>
<TableHead className="text-[11px] font-bold"></TableHead>
<TableHead className="text-[11px] font-bold w-[100px]"></TableHead>
<TableHead className="text-[11px] font-bold w-[80px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{copyFilteredItems.length === 0 ? (
<TableRow><TableCell colSpan={5} className="text-center py-8 text-muted-foreground text-sm">
{copySearchLoading ? "검색 중..." : "검색 결과가 없어요"}
</TableCell></TableRow>
) : copyFilteredItems.map((item) => (
<TableRow key={item.code} className="cursor-pointer hover:bg-primary/5"
onClick={() => toggleCopyChecked(item.code)}>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox checked={copyCheckedIds.includes(item.code)} onCheckedChange={() => toggleCopyChecked(item.code)} />
</TableCell>
<TableCell className="text-sm font-mono">{item.code}</TableCell>
<TableCell className="text-sm">{item.name}</TableCell>
<TableCell className="text-sm">{item.item_type}</TableCell>
<TableCell className="text-sm">{item.unit}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="shrink-0 flex items-center justify-between border-t px-2 py-2 text-xs text-muted-foreground">
<span>
<span className="font-medium text-foreground">{copyTotal.toLocaleString()}</span>
{copyCheckedIds.length > 0 && <span className="ml-2"> <span className="font-medium text-primary">{copyCheckedIds.length}</span></span>}
</span>
<div className="flex items-center gap-0.5">
<button onClick={() => { 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"><ChevronsLeft className="h-3.5 w-3.5" /></button>
<button onClick={() => { 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"><ChevronLeft className="h-3.5 w-3.5" /></button>
{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 (
<button key={p} onClick={() => { 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}</button>
);
})}
<button onClick={() => { 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"><ChevronRight className="h-3.5 w-3.5" /></button>
<button onClick={() => { 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"><ChevronsRight className="h-3.5 w-3.5" /></button>
) : (
<div className="flex-1 grid grid-cols-[420px_1fr] gap-4 overflow-hidden">
{/* 좌측: 복사 대상 품목 선택 */}
<div className="flex flex-col overflow-hidden border rounded-lg">
<div className="flex items-center justify-between border-b bg-muted/50 px-3 py-2">
<span className="text-xs font-semibold"> </span>
{copyCheckedIds.length > 0 && <span className="text-[10px] text-primary"> {copyCheckedIds.length}</span>}
</div>
<div className="flex gap-2 px-2 pt-2">
<Input className="h-8 flex-1 text-xs" placeholder="품목코드 또는 품목명" value={copySearchKeyword}
onChange={(e) => setCopySearchKeyword(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleCopySearch(); }} />
<Button size="sm" className="h-8 text-xs" onClick={handleCopySearch} disabled={copySearchLoading}>
{copySearchLoading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Search className="w-3.5 h-3.5" />}
</Button>
</div>
<div className="flex-1 overflow-auto mt-2">
<Table>
<TableHeader className="sticky top-0 z-10 bg-background">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[36px] text-center text-[10px]">
<Checkbox
checked={copyFilteredItems.length > 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)));
}}
/>
</TableHead>
<TableHead className="text-[10px] font-bold w-[120px]"></TableHead>
<TableHead className="text-[10px] font-bold"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{copyFilteredItems.length === 0 ? (
<TableRow><TableCell colSpan={3} className="text-center py-6 text-muted-foreground text-xs">
{copySearchLoading ? "검색 중..." : "검색 결과가 없어요"}
</TableCell></TableRow>
) : copyFilteredItems.map((item) => (
<TableRow key={item.code} className="cursor-pointer hover:bg-primary/5"
onClick={() => toggleCopyChecked(item.code)}>
<TableCell className="text-center p-1" onClick={(e) => e.stopPropagation()}>
<Checkbox checked={copyCheckedIds.includes(item.code)} onCheckedChange={() => toggleCopyChecked(item.code)} />
</TableCell>
<TableCell className="text-xs font-mono p-1">{item.code}</TableCell>
<TableCell className="text-xs p-1 truncate">{item.name}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="border-t flex items-center justify-between px-2 py-1 text-[10px] text-muted-foreground">
<span> <span className="font-medium text-foreground">{copyTotal.toLocaleString()}</span></span>
<div className="flex items-center gap-0.5">
<button onClick={() => { 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"><ChevronsLeft className="h-3 w-3" /></button>
<button onClick={() => { 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"><ChevronLeft className="h-3 w-3" /></button>
<span className="text-[10px] mx-1">{copyPage}/{copyTotalPages}</span>
<button onClick={() => { 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"><ChevronRight className="h-3 w-3" /></button>
<button onClick={() => { 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"><ChevronsRight className="h-3 w-3" /></button>
</div>
</div>
</div>
{/* 우측: 편집 폼 (등록/수정 폼과 동일 구조) */}
<div className="flex flex-col overflow-hidden border rounded-lg">
<div className="border-b bg-muted/50 px-3 py-2">
<span className="text-xs font-semibold"> (: {selectedItemCode})</span>
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Select value={copyForm.is_active === false || copyForm.is_active === "N" ? "N" : "Y"} onValueChange={(v) => setCopyForm(p => ({ ...p, is_active: v === "Y" ? "사용" : "미사용" }))}>
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="Y"></SelectItem>
<SelectItem value="N"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Select value={copyForm.manager || ""} onValueChange={(v) => setCopyForm(p => ({ ...p, manager: v }))}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="관리자 선택" /></SelectTrigger>
<SelectContent>{userOptions.map(o => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<h4 className="text-xs font-semibold"> </h4>
<div className="flex flex-wrap gap-3">
{INSPECTION_TYPES.map(({ key, label }) => (
<div key={key} className="flex items-center gap-1.5">
<Checkbox checked={!!copyForm[key]} onCheckedChange={(v) => setCopyForm(p => ({ ...p, [key]: !!v }))} />
<Label className="text-xs cursor-pointer">{label}</Label>
</div>
))}
</div>
</div>
{INSPECTION_TYPES.filter(t => !!copyForm[t.key]).map(({ key, label }) => (
<div key={key} className="space-y-1.5">
<button type="button" className="w-full flex items-center gap-2 py-1.5 px-2 rounded-md border bg-muted/50 hover:bg-muted text-left" onClick={() => toggleCopyCollapse(key)}>
<Badge variant="default" className="text-[10px]">{label}</Badge>
<span className="text-xs font-medium"> </span>
<span className="text-[10px] text-muted-foreground ml-auto">{(copyInspectionRows[key] || []).length}</span>
</button>
{!copyCollapsedTypes[key] && (
<div className="space-y-1.5 pl-1">
<div className="flex items-center justify-between">
<span className="text-[10px] font-semibold text-muted-foreground"> </span>
<Button type="button" size="sm" variant="outline" className="h-6 text-[10px]" onClick={() => addCopyInspRow(key)}>
<Plus className="w-3 h-3 mr-1" />
</Button>
</div>
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-muted/50 hover:bg-muted/50">
<TableHead className="text-[10px] font-bold w-[150px]"> </TableHead>
<TableHead className="text-[10px] font-bold w-[110px]"> </TableHead>
<TableHead className="text-[10px] font-bold w-[80px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[80px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[90px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[70px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[180px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[36px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[60px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[32px]" />
</TableRow>
</TableHeader>
<TableBody>
{(!copyInspectionRows[key] || copyInspectionRows[key].length === 0) ? (
<TableRow><TableCell colSpan={10} className="text-center py-3 text-[10px] text-muted-foreground"> </TableCell></TableRow>
) : copyInspectionRows[key].map((row) => (
<TableRow key={row.id}>
<TableCell className="p-1">
<Select value={row.inspection_standard_id || ""} onValueChange={(v) => updateCopyInspRow(key, row.id, "inspection_standard_id", v)}>
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="검사기준" /></SelectTrigger>
<SelectContent>{getFilteredInspOptions(key).map(o => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}</SelectContent>
</Select>
</TableCell>
<TableCell className="p-1"><Input className="h-7 text-[10px] bg-muted" value={row.inspection_detail} readOnly placeholder="자동" /></TableCell>
<TableCell className="p-1"><Input className="h-7 text-[10px] bg-muted" value={row.inspection_method} readOnly placeholder="자동" /></TableCell>
<TableCell className="p-1">
{processOptions.length > 0 ? (
<Select value={row.apply_process || ""} onValueChange={(v) => updateCopyInspRow(key, row.id, "apply_process", v)}>
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="공정" /></SelectTrigger>
<SelectContent>
{processOptions.map((p) => (
<SelectItem key={p.code} value={p.code} className="text-xs">{p.name}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input className="h-7 text-[10px]" value={row.apply_process} onChange={(e) => updateCopyInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" />
)}
</TableCell>
<TableCell className="p-1">
<Input className="h-7 text-[10px]" value={row.classification || ""} onChange={(e) => updateCopyInspRow(key, row.id, "classification", e.target.value)} placeholder="구분" />
</TableCell>
<TableCell className="p-1 text-center">
{row.judgment_criteria ? <Badge variant="outline" className="text-[9px]">{row.judgment_criteria}</Badge> : <span className="text-[9px] text-muted-foreground">-</span>}
</TableCell>
<TableCell className="p-1">
{row.judgment_criteria === "선택형" && row.selection_options ? (
<Select value={row.acceptance_criteria || ""} onValueChange={(v) => updateCopyInspRow(key, row.id, "acceptance_criteria", v)} disabled={!row.inspection_standard_id}>
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{row.selection_options.split(",").filter(Boolean).map((opt) => (
<SelectItem key={opt} value={opt} className="text-xs">{opt}</SelectItem>
))}
</SelectContent>
</Select>
) : row.judgment_criteria === "O/X" ? (
<Select value={row.acceptance_criteria || ""} onValueChange={(v) => updateCopyInspRow(key, row.id, "acceptance_criteria", v)} disabled={!row.inspection_standard_id}>
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
<SelectItem value="O" className="text-xs">O ()</SelectItem>
<SelectItem value="X" className="text-xs">X ()</SelectItem>
</SelectContent>
</Select>
) : row.judgment_criteria === "수치(범위)" ? (
<div className="flex items-center gap-1">
<Input className="h-7 text-[10px] w-14" value={row.acceptance_criteria?.split("|")[0] || ""} onChange={(e) => {
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} />
<span className="text-[9px] text-muted-foreground">±</span>
<Input className="h-7 text-[10px] w-10" value={row.acceptance_criteria?.split("|")[1] || ""} onChange={(e) => {
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} />
</div>
) : (
<Input className="h-7 text-[10px]" value={row.acceptance_criteria} onChange={(e) => updateCopyInspRow(key, row.id, "acceptance_criteria", e.target.value)} placeholder="합격기준" disabled={!row.inspection_standard_id} />
)}
</TableCell>
<TableCell className="p-1 text-center"><Checkbox checked={row.is_required} onCheckedChange={(v) => updateCopyInspRow(key, row.id, "is_required", !!v)} /></TableCell>
<TableCell className="p-1 text-[10px] text-muted-foreground">{row.unit || "-"}</TableCell>
<TableCell className="p-1">
<Button type="button" variant="destructive" size="sm" className="h-6 w-6 p-0" onClick={() => removeCopyInspRow(key, row.id)}><Trash2 className="w-3 h-3" /></Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
</div>
))}
</div>
</div>
</div>
</div>
</>)}
)}
<DialogFooter className="shrink-0">
<Button variant="outline" onClick={() => setCopyModalOpen(false)} disabled={copying}></Button>
<Button onClick={handleCopy} disabled={copying || copyCheckedIds.length === 0}>
@@ -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 }[] = [];
@@ -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() {
{/* 테이블 영역 */}
<div className="flex-1 overflow-auto">
<EDataTable
columns={getVisibleColumns(tab.key).map((col): EDataTableColumn => ({
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]}
@@ -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<string, string> = {};
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);
@@ -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"
>
<span className="font-semibold font-mono text-primary">
{loc.location || loc.warehouse}
{loc.warehouse_name || loc.location || loc.warehouse}
</span>
<span className="font-semibold">
{loc.qty.toLocaleString()}
@@ -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<string, { code: string; label: string }[]>
>({});
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<string, { code: string; label: string }[]> = {};
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() {
<TableCell className="p-2 font-medium">{item.item_number}</TableCell>
<TableCell className="p-2">{item.item_name || "-"}</TableCell>
<TableCell className="p-2">{item.spec || "-"}</TableCell>
<TableCell className="p-2">{item.unit || "EA"}</TableCell>
<TableCell className="p-2">{resolveCat("inventory_unit", item.inventory_unit) || "EA"}</TableCell>
<TableCell className="p-2 text-right font-semibold">{Number(item.pkg_qty).toLocaleString()}</TableCell>
<TableCell className="p-2 text-center">
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => handleDeletePkgItem(item)}>
@@ -954,8 +994,8 @@ export default function PackagingPage() {
<TableCell className="p-2 font-medium">{item.item_number}</TableCell>
<TableCell className="p-2">{item.item_name}</TableCell>
<TableCell className="p-2">{item.spec || "-"}</TableCell>
<TableCell className="p-2">{item.material || "-"}</TableCell>
<TableCell className="p-2">{item.unit || "EA"}</TableCell>
<TableCell className="p-2">{resolveCat("material", item.material) || "-"}</TableCell>
<TableCell className="p-2">{resolveCat("inventory_unit", item.inventory_unit) || "EA"}</TableCell>
</TableRow>
))}
</TableBody>
@@ -158,6 +158,10 @@ export default function WarehouseManagementPage() {
const [rackStatus, setRackStatus] = useState("");
const [rackPreview, setRackPreview] = useState<any[]>([]);
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() {
</div>
</div>
{/* 위치명 형식 — 구역/열/단 뒤에 붙일 표현만 자유 입력 */}
<div className="rounded-lg border p-3 bg-muted/30 space-y-2">
<Label className="text-xs font-semibold"> </Label>
<div className="flex items-center gap-1 text-xs flex-wrap">
<span className="font-mono text-muted-foreground">A</span>
<Input
value={rackZoneLabel}
onChange={(e) => setRackZoneLabel(e.target.value)}
placeholder="구역"
className="h-8 w-20 text-xs"
/>
<span className="font-mono text-muted-foreground">- 01</span>
<Input
value={rackRowLabel}
onChange={(e) => setRackRowLabel(e.target.value)}
placeholder="열"
className="h-8 w-20 text-xs"
/>
<span className="font-mono text-muted-foreground">- 1</span>
<Input
value={rackLevelLabel}
onChange={(e) => setRackLevelLabel(e.target.value)}
placeholder="단"
className="h-8 w-20 text-xs"
/>
</div>
<p className="text-[11px] text-muted-foreground">
: <span className="font-mono font-semibold">A{rackZoneLabel}-01{rackRowLabel}-1{rackLevelLabel}</span>
{" "} // , .
</p>
</div>
{/* 등록 미리보기 */}
<div>
<div className="flex items-center justify-between mb-3">
@@ -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<string, any> = {};
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 () => {
@@ -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<string, any> = {
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() {
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
if (!selectedBomId || !bomHeader) {
toast.error("수정할 BOM을 선택해주세요");
return;
}
openEditModal();
}}
disabled={!selectedBomId || !bomHeader}
>
<Pencil className="w-3.5 h-3.5 mr-1" />
</Button>
<div className="w-px h-4 bg-border mx-0.5" />
<Button
size="sm"
@@ -12,6 +12,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
import {
@@ -91,8 +92,8 @@ export function ItemRoutingTab() {
const [formFixedOrder, setFormFixedOrder] = useState("Y");
const [formWorkType, setFormWorkType] = useState("내부");
const [formStandardTime, setFormStandardTime] = useState("");
const [formOutsource, setFormOutsource] = useState("");
const [subcontractorOptions, setSubcontractorOptions] = useState<{ code: string; name: string }[]>([]);
const [formOutsources, setFormOutsources] = useState<string[]>([]);
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() {
</div>
{showOutsourceField && (
<div className="space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select value={formOutsource || ""} onValueChange={setFormOutsource}>
<SelectTrigger className="h-9"><SelectValue placeholder="외주업체 선택" /></SelectTrigger>
<SelectContent>
{subcontractorOptions.map((s) => (
<SelectItem key={s.code} value={s.code}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> ( )</Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="h-9 w-full justify-between font-normal">
<span className="truncate text-left text-sm">
{formOutsources.length === 0
? "외주업체 선택"
: formOutsources
.map((i) => subcontractorOptions.find((s) => s.id === i)?.name || i)
.join(", ")}
</span>
<Badge variant="secondary" className="ml-2 shrink-0">{formOutsources.length}</Badge>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[320px] p-0" align="start">
<div className="max-h-64 overflow-y-auto p-2 space-y-1">
{subcontractorOptions.length === 0 ? (
<div className="text-xs text-muted-foreground px-2 py-3"> </div>
) : subcontractorOptions.map((s) => {
const checked = formOutsources.includes(s.id);
return (
<label
key={s.id}
className="flex items-center gap-2 rounded px-2 py-1.5 text-sm cursor-pointer hover:bg-muted"
>
<Checkbox
checked={checked}
onCheckedChange={(v) => {
setFormOutsources((prev) =>
v ? [...prev, s.id] : prev.filter((i) => i !== s.id),
);
}}
/>
<span className="truncate">{s.name}</span>
</label>
);
})}
</div>
</PopoverContent>
</Popover>
</div>
)}
</div>
@@ -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() {
<TableHead className="w-[50px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground"><Checkbox checked={regSourceData.length > 0 && regCheckedIds.size === regSourceData.length} onCheckedChange={toggleRegAll} /></TableHead>
{regSourceType === "item" && <><TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead><TableHead></TableHead><TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead></>}
{regSourceType === "order" && <><TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead><TableHead className="w-[100px]"></TableHead><TableHead></TableHead><TableHead className="w-[100px]"></TableHead><TableHead className="w-[80px] text-right"></TableHead><TableHead className="w-[100px]"></TableHead></>}
{regSourceType === "production" && <><TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead><TableHead className="w-[100px]"></TableHead><TableHead></TableHead><TableHead className="w-[80px] text-right"></TableHead><TableHead className="w-[90px]"></TableHead><TableHead className="w-[90px]"></TableHead><TableHead className="w-[100px]"></TableHead></>}
{regSourceType === "production" && <><TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead><TableHead className="w-[100px]"></TableHead><TableHead></TableHead><TableHead className="w-[80px] text-right"></TableHead><TableHead className="w-[80px] text-right"></TableHead><TableHead className="w-[80px] text-right"></TableHead><TableHead className="w-[90px]"></TableHead><TableHead className="w-[90px]"></TableHead><TableHead className="w-[100px]"></TableHead></>}
</TableRow>
</TableHeader>
<TableBody>
@@ -590,7 +596,7 @@ export default function WorkInstructionPage() {
<TableCell className="text-center" onClick={e => e.stopPropagation()}><Checkbox checked={checked} onCheckedChange={() => toggleRegItem(id)} /></TableCell>
{regSourceType === "item" && <><TableCell className="text-[13px] font-medium">{item.item_code}</TableCell><TableCell className="text-sm">{item.item_name}</TableCell><TableCell className="text-[13px]">{item.spec || "-"}</TableCell></>}
{regSourceType === "order" && <><TableCell className="text-[13px]">{item.order_no}</TableCell><TableCell className="text-[13px]">{item.item_code}</TableCell><TableCell className="text-sm">{item.item_name}</TableCell><TableCell className="text-[13px]">{item.spec || "-"}</TableCell><TableCell className="text-right text-[13px]">{Number(item.qty || 0).toLocaleString()}</TableCell><TableCell className="text-[13px]">{item.due_date || "-"}</TableCell></>}
{regSourceType === "production" && <><TableCell className="text-[13px]">{item.plan_no}</TableCell><TableCell className="text-[13px]">{item.item_code}</TableCell><TableCell className="text-sm">{item.item_name}</TableCell><TableCell className="text-right text-[13px]">{Number(item.plan_qty || 0).toLocaleString()}</TableCell><TableCell className="text-[13px]">{item.start_date ? String(item.start_date).split("T")[0] : "-"}</TableCell><TableCell className="text-[13px]">{item.end_date ? String(item.end_date).split("T")[0] : "-"}</TableCell><TableCell className="text-[13px]">{item.equipment_name || "-"}</TableCell></>}
{regSourceType === "production" && <><TableCell className="text-[13px]">{item.plan_no}</TableCell><TableCell className="text-[13px]">{item.item_code}</TableCell><TableCell className="text-sm">{item.item_name}</TableCell><TableCell className="text-right text-[13px]">{Number(item.plan_qty || 0).toLocaleString()}</TableCell><TableCell className="text-right text-[13px] text-muted-foreground">{Number(item.applied_qty || 0).toLocaleString()}</TableCell><TableCell className={cn("text-right text-[13px] font-semibold", Number(item.remain_qty ?? item.plan_qty ?? 0) < 0 && "text-destructive")}>{Number(item.remain_qty ?? item.plan_qty ?? 0).toLocaleString()}</TableCell><TableCell className="text-[13px]">{item.start_date ? String(item.start_date).split("T")[0] : "-"}</TableCell><TableCell className="text-[13px]">{item.end_date ? String(item.end_date).split("T")[0] : "-"}</TableCell><TableCell className="text-[13px]">{item.equipment_name || "-"}</TableCell></>}
</TableRow>
);
})}
@@ -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 }[] = [];
@@ -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: "판단기준" },
@@ -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<Record<string, any>>({});
const [copyInspectionRows, setCopyInspectionRows] = useState<Record<string, InspectionRow[]>>({});
const [copyCollapsedTypes, setCopyCollapsedTypes] = useState<Record<string, boolean>>({});
/* ═══════════════════ 복사 모달 (기준 품목 검사정보 → 다른 품목들) ═══════════════════ */
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<string, InspectionRow[]> = {};
const typeFlags: Record<string, boolean> = {};
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<string, InspectionRow[]> = {};
const typeFlags: Record<string, boolean> = {};
@@ -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() {
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8 w-[50px]"></TableHead>
@@ -983,7 +1122,7 @@ export default function ItemInspectionInfoPage() {
<TableBody>
{selectedTabRows.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-8 text-xs text-muted-foreground"> </TableCell>
<TableCell colSpan={9} className="text-center py-8 text-xs text-muted-foreground"> </TableCell>
</TableRow>
) : selectedTabRows.map((row: any) => (
<TableRow key={row.id}>
@@ -1002,6 +1141,7 @@ export default function ItemInspectionInfoPage() {
const proc = processOptions.find(p => p.code === code);
return proc?.name || code;
})()}</TableCell>
<TableCell className="text-xs py-2">{row.classification || "-"}</TableCell>
<TableCell className="text-xs py-2">
{(() => {
const insp = inspOptions.find(o => o.code === row.inspection_standard_id);
@@ -1010,7 +1150,16 @@ export default function ItemInspectionInfoPage() {
return jcLabel ? <Badge variant="outline" className="text-[10px]">{jcLabel}</Badge> : "-";
})()}
</TableCell>
<TableCell className="text-xs py-2 font-mono">{row.pass_criteria || "-"}</TableCell>
<TableCell className="text-xs py-2 font-mono">{(() => {
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;
})()}</TableCell>
<TableCell className="text-xs py-2 text-center">
{row.is_required === "true" || row.is_required === true ? (
<Badge variant="destructive" className="text-[9px]"></Badge>
@@ -1185,6 +1334,7 @@ export default function ItemInspectionInfoPage() {
<TableHead className="text-[10px] font-bold w-[130px]"> </TableHead>
<TableHead className="text-[10px] font-bold w-[90px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[90px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[100px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[80px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[200px]"> ()</TableHead>
<TableHead className="text-[10px] font-bold w-[40px]"></TableHead>
@@ -1194,7 +1344,7 @@ export default function ItemInspectionInfoPage() {
</TableHeader>
<TableBody>
{(!inspectionRows[key] || inspectionRows[key].length === 0) ? (
<TableRow><TableCell colSpan={9} className="text-center py-4 text-xs text-muted-foreground"> </TableCell></TableRow>
<TableRow><TableCell colSpan={10} className="text-center py-4 text-xs text-muted-foreground"> </TableCell></TableRow>
) : inspectionRows[key].map((row) => (
<TableRow key={row.id}>
<TableCell className="p-1">
@@ -1219,6 +1369,9 @@ export default function ItemInspectionInfoPage() {
<Input className="h-8 text-xs" value={row.apply_process} onChange={(e) => updateInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" />
)}
</TableCell>
<TableCell className="p-1">
<Input className="h-8 text-xs" value={row.classification || ""} onChange={(e) => updateInspRow(key, row.id, "classification", e.target.value)} placeholder="구분 입력" />
</TableCell>
<TableCell className="p-1 text-center">
{row.judgment_criteria ? <Badge variant="outline" className="text-[10px]">{row.judgment_criteria}</Badge> : <span className="text-[10px] text-muted-foreground">-</span>}
</TableCell>
@@ -1285,20 +1438,20 @@ export default function ItemInspectionInfoPage() {
</DialogContent>
</Dialog>
{/* ═══════════════════ 복사 모달 ═══════════════════ */}
{/* ═══════════════════ 복사 모달 (2단 분할: 좌 대상 / 우 편집) ═══════════════════ */}
<Dialog open={copyModalOpen} onOpenChange={(v) => { if (!copying) setCopyModalOpen(v); }}>
<DialogContent
className="max-w-3xl w-[95vw] max-h-[85vh] flex flex-col overflow-hidden"
className="max-w-[95vw] sm:max-w-[1400px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden"
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => { if (copying) e.preventDefault(); }}
>
<DialogHeader>
<DialogHeader className="shrink-0">
<DialogTitle>{copying ? "검사정보 복사 중..." : "검사정보 복사"}</DialogTitle>
<DialogDescription>
<span className="font-medium text-foreground">{selectedGroup?.item_name || "-"}</span>
<span className="text-muted-foreground"> ({selectedItemCode})</span>
<span>{copying ? " 의 검사정보를 복사하고 있습니다" : " 의 검사정보를 아래 선택한 품목들에 복사합니다"}</span>
<span>{copying ? " 의 검사정보를 복사하고 있습니다" : " 의 검사정보를 편집해서 선택한 품목들에 복사합니다. 기준 품목은 변경되지 않아요"}</span>
</DialogDescription>
</DialogHeader>
{copying ? (
@@ -1322,81 +1475,229 @@ export default function ItemInspectionInfoPage() {
</p>
</div>
</div>
) : (<>
<div className="flex gap-2 shrink-0">
<Input className="h-9 flex-1" placeholder="품목코드 또는 품목명" value={copySearchKeyword}
onChange={(e) => setCopySearchKeyword(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleCopySearch(); }} />
<Button size="sm" className="h-9" onClick={handleCopySearch} disabled={copySearchLoading}>
{copySearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" /></>}
</Button>
</div>
<div className="flex-1 border rounded-lg overflow-auto">
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[40px] text-center">
<Checkbox
checked={copyFilteredItems.length > 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)));
}}
/>
</TableHead>
<TableHead className="text-[11px] font-bold w-[140px]"></TableHead>
<TableHead className="text-[11px] font-bold"></TableHead>
<TableHead className="text-[11px] font-bold w-[100px]"></TableHead>
<TableHead className="text-[11px] font-bold w-[80px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{copyFilteredItems.length === 0 ? (
<TableRow><TableCell colSpan={5} className="text-center py-8 text-muted-foreground text-sm">
{copySearchLoading ? "검색 중..." : "검색 결과가 없어요"}
</TableCell></TableRow>
) : copyFilteredItems.map((item) => (
<TableRow key={item.code} className="cursor-pointer hover:bg-primary/5"
onClick={() => toggleCopyChecked(item.code)}>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox checked={copyCheckedIds.includes(item.code)} onCheckedChange={() => toggleCopyChecked(item.code)} />
</TableCell>
<TableCell className="text-sm font-mono">{item.code}</TableCell>
<TableCell className="text-sm">{item.name}</TableCell>
<TableCell className="text-sm">{item.item_type}</TableCell>
<TableCell className="text-sm">{item.unit}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="shrink-0 flex items-center justify-between border-t px-2 py-2 text-xs text-muted-foreground">
<span>
<span className="font-medium text-foreground">{copyTotal.toLocaleString()}</span>
{copyCheckedIds.length > 0 && <span className="ml-2"> <span className="font-medium text-primary">{copyCheckedIds.length}</span></span>}
</span>
<div className="flex items-center gap-0.5">
<button onClick={() => { 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"><ChevronsLeft className="h-3.5 w-3.5" /></button>
<button onClick={() => { 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"><ChevronLeft className="h-3.5 w-3.5" /></button>
{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 (
<button key={p} onClick={() => { 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}</button>
);
})}
<button onClick={() => { 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"><ChevronRight className="h-3.5 w-3.5" /></button>
<button onClick={() => { 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"><ChevronsRight className="h-3.5 w-3.5" /></button>
) : (
<div className="flex-1 grid grid-cols-[420px_1fr] gap-4 overflow-hidden">
{/* 좌측: 복사 대상 품목 선택 */}
<div className="flex flex-col overflow-hidden border rounded-lg">
<div className="flex items-center justify-between border-b bg-muted/50 px-3 py-2">
<span className="text-xs font-semibold"> </span>
{copyCheckedIds.length > 0 && <span className="text-[10px] text-primary"> {copyCheckedIds.length}</span>}
</div>
<div className="flex gap-2 px-2 pt-2">
<Input className="h-8 flex-1 text-xs" placeholder="품목코드 또는 품목명" value={copySearchKeyword}
onChange={(e) => setCopySearchKeyword(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleCopySearch(); }} />
<Button size="sm" className="h-8 text-xs" onClick={handleCopySearch} disabled={copySearchLoading}>
{copySearchLoading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Search className="w-3.5 h-3.5" />}
</Button>
</div>
<div className="flex-1 overflow-auto mt-2">
<Table>
<TableHeader className="sticky top-0 z-10 bg-background">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[36px] text-center text-[10px]">
<Checkbox
checked={copyFilteredItems.length > 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)));
}}
/>
</TableHead>
<TableHead className="text-[10px] font-bold w-[120px]"></TableHead>
<TableHead className="text-[10px] font-bold"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{copyFilteredItems.length === 0 ? (
<TableRow><TableCell colSpan={3} className="text-center py-6 text-muted-foreground text-xs">
{copySearchLoading ? "검색 중..." : "검색 결과가 없어요"}
</TableCell></TableRow>
) : copyFilteredItems.map((item) => (
<TableRow key={item.code} className="cursor-pointer hover:bg-primary/5"
onClick={() => toggleCopyChecked(item.code)}>
<TableCell className="text-center p-1" onClick={(e) => e.stopPropagation()}>
<Checkbox checked={copyCheckedIds.includes(item.code)} onCheckedChange={() => toggleCopyChecked(item.code)} />
</TableCell>
<TableCell className="text-xs font-mono p-1">{item.code}</TableCell>
<TableCell className="text-xs p-1 truncate">{item.name}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="border-t flex items-center justify-between px-2 py-1 text-[10px] text-muted-foreground">
<span> <span className="font-medium text-foreground">{copyTotal.toLocaleString()}</span></span>
<div className="flex items-center gap-0.5">
<button onClick={() => { 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"><ChevronsLeft className="h-3 w-3" /></button>
<button onClick={() => { 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"><ChevronLeft className="h-3 w-3" /></button>
<span className="text-[10px] mx-1">{copyPage}/{copyTotalPages}</span>
<button onClick={() => { 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"><ChevronRight className="h-3 w-3" /></button>
<button onClick={() => { 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"><ChevronsRight className="h-3 w-3" /></button>
</div>
</div>
</div>
{/* 우측: 편집 폼 (등록/수정 폼과 동일 구조) */}
<div className="flex flex-col overflow-hidden border rounded-lg">
<div className="border-b bg-muted/50 px-3 py-2">
<span className="text-xs font-semibold"> (: {selectedItemCode})</span>
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Select value={copyForm.is_active === false || copyForm.is_active === "N" ? "N" : "Y"} onValueChange={(v) => setCopyForm(p => ({ ...p, is_active: v === "Y" ? "사용" : "미사용" }))}>
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="Y"></SelectItem>
<SelectItem value="N"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Select value={copyForm.manager || ""} onValueChange={(v) => setCopyForm(p => ({ ...p, manager: v }))}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="관리자 선택" /></SelectTrigger>
<SelectContent>{userOptions.map(o => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<h4 className="text-xs font-semibold"> </h4>
<div className="flex flex-wrap gap-3">
{INSPECTION_TYPES.map(({ key, label }) => (
<div key={key} className="flex items-center gap-1.5">
<Checkbox checked={!!copyForm[key]} onCheckedChange={(v) => setCopyForm(p => ({ ...p, [key]: !!v }))} />
<Label className="text-xs cursor-pointer">{label}</Label>
</div>
))}
</div>
</div>
{INSPECTION_TYPES.filter(t => !!copyForm[t.key]).map(({ key, label }) => (
<div key={key} className="space-y-1.5">
<button type="button" className="w-full flex items-center gap-2 py-1.5 px-2 rounded-md border bg-muted/50 hover:bg-muted text-left" onClick={() => toggleCopyCollapse(key)}>
<Badge variant="default" className="text-[10px]">{label}</Badge>
<span className="text-xs font-medium"> </span>
<span className="text-[10px] text-muted-foreground ml-auto">{(copyInspectionRows[key] || []).length}</span>
</button>
{!copyCollapsedTypes[key] && (
<div className="space-y-1.5 pl-1">
<div className="flex items-center justify-between">
<span className="text-[10px] font-semibold text-muted-foreground"> </span>
<Button type="button" size="sm" variant="outline" className="h-6 text-[10px]" onClick={() => addCopyInspRow(key)}>
<Plus className="w-3 h-3 mr-1" />
</Button>
</div>
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-muted/50 hover:bg-muted/50">
<TableHead className="text-[10px] font-bold w-[150px]"> </TableHead>
<TableHead className="text-[10px] font-bold w-[110px]"> </TableHead>
<TableHead className="text-[10px] font-bold w-[80px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[80px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[90px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[70px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[180px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[36px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[60px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[32px]" />
</TableRow>
</TableHeader>
<TableBody>
{(!copyInspectionRows[key] || copyInspectionRows[key].length === 0) ? (
<TableRow><TableCell colSpan={10} className="text-center py-3 text-[10px] text-muted-foreground"> </TableCell></TableRow>
) : copyInspectionRows[key].map((row) => (
<TableRow key={row.id}>
<TableCell className="p-1">
<Select value={row.inspection_standard_id || ""} onValueChange={(v) => updateCopyInspRow(key, row.id, "inspection_standard_id", v)}>
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="검사기준" /></SelectTrigger>
<SelectContent>{getFilteredInspOptions(key).map(o => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}</SelectContent>
</Select>
</TableCell>
<TableCell className="p-1"><Input className="h-7 text-[10px] bg-muted" value={row.inspection_detail} readOnly placeholder="자동" /></TableCell>
<TableCell className="p-1"><Input className="h-7 text-[10px] bg-muted" value={row.inspection_method} readOnly placeholder="자동" /></TableCell>
<TableCell className="p-1">
{processOptions.length > 0 ? (
<Select value={row.apply_process || ""} onValueChange={(v) => updateCopyInspRow(key, row.id, "apply_process", v)}>
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="공정" /></SelectTrigger>
<SelectContent>
{processOptions.map((p) => (
<SelectItem key={p.code} value={p.code} className="text-xs">{p.name}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input className="h-7 text-[10px]" value={row.apply_process} onChange={(e) => updateCopyInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" />
)}
</TableCell>
<TableCell className="p-1">
<Input className="h-7 text-[10px]" value={row.classification || ""} onChange={(e) => updateCopyInspRow(key, row.id, "classification", e.target.value)} placeholder="구분" />
</TableCell>
<TableCell className="p-1 text-center">
{row.judgment_criteria ? <Badge variant="outline" className="text-[9px]">{row.judgment_criteria}</Badge> : <span className="text-[9px] text-muted-foreground">-</span>}
</TableCell>
<TableCell className="p-1">
{row.judgment_criteria === "선택형" && row.selection_options ? (
<Select value={row.acceptance_criteria || ""} onValueChange={(v) => updateCopyInspRow(key, row.id, "acceptance_criteria", v)} disabled={!row.inspection_standard_id}>
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{row.selection_options.split(",").filter(Boolean).map((opt) => (
<SelectItem key={opt} value={opt} className="text-xs">{opt}</SelectItem>
))}
</SelectContent>
</Select>
) : row.judgment_criteria === "O/X" ? (
<Select value={row.acceptance_criteria || ""} onValueChange={(v) => updateCopyInspRow(key, row.id, "acceptance_criteria", v)} disabled={!row.inspection_standard_id}>
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
<SelectItem value="O" className="text-xs">O ()</SelectItem>
<SelectItem value="X" className="text-xs">X ()</SelectItem>
</SelectContent>
</Select>
) : row.judgment_criteria === "수치(범위)" ? (
<div className="flex items-center gap-1">
<Input className="h-7 text-[10px] w-14" value={row.acceptance_criteria?.split("|")[0] || ""} onChange={(e) => {
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} />
<span className="text-[9px] text-muted-foreground">±</span>
<Input className="h-7 text-[10px] w-10" value={row.acceptance_criteria?.split("|")[1] || ""} onChange={(e) => {
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} />
</div>
) : (
<Input className="h-7 text-[10px]" value={row.acceptance_criteria} onChange={(e) => updateCopyInspRow(key, row.id, "acceptance_criteria", e.target.value)} placeholder="합격기준" disabled={!row.inspection_standard_id} />
)}
</TableCell>
<TableCell className="p-1 text-center"><Checkbox checked={row.is_required} onCheckedChange={(v) => updateCopyInspRow(key, row.id, "is_required", !!v)} /></TableCell>
<TableCell className="p-1 text-[10px] text-muted-foreground">{row.unit || "-"}</TableCell>
<TableCell className="p-1">
<Button type="button" variant="destructive" size="sm" className="h-6 w-6 p-0" onClick={() => removeCopyInspRow(key, row.id)}><Trash2 className="w-3 h-3" /></Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
</div>
))}
</div>
</div>
</div>
</div>
</>)}
)}
<DialogFooter className="shrink-0">
<Button variant="outline" onClick={() => setCopyModalOpen(false)} disabled={copying}></Button>
<Button onClick={handleCopy} disabled={copying || copyCheckedIds.length === 0}>
@@ -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 }[] = [];
@@ -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() {
{/* 테이블 영역 */}
<div className="flex-1 overflow-auto">
<EDataTable
columns={getVisibleColumns(tab.key).map((col): EDataTableColumn => ({
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]}
@@ -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<string, string> = {};
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);
@@ -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"
>
<span className="font-semibold font-mono text-primary">
{loc.location || loc.warehouse}
{loc.warehouse_name || loc.location || loc.warehouse}
</span>
<span className="font-semibold">
{loc.qty.toLocaleString()}
@@ -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<string, { code: string; label: string }[]>
>({});
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<string, { code: string; label: string }[]> = {};
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() {
<TableCell className="p-2 font-medium">{item.item_number}</TableCell>
<TableCell className="p-2">{item.item_name || "-"}</TableCell>
<TableCell className="p-2">{item.spec || "-"}</TableCell>
<TableCell className="p-2">{item.unit || "EA"}</TableCell>
<TableCell className="p-2">{resolveCat("inventory_unit", item.inventory_unit) || "EA"}</TableCell>
<TableCell className="p-2 text-right font-semibold">{Number(item.pkg_qty).toLocaleString()}</TableCell>
<TableCell className="p-2 text-center">
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => handleDeletePkgItem(item)}>
@@ -954,8 +994,8 @@ export default function PackagingPage() {
<TableCell className="p-2 font-medium">{item.item_number}</TableCell>
<TableCell className="p-2">{item.item_name}</TableCell>
<TableCell className="p-2">{item.spec || "-"}</TableCell>
<TableCell className="p-2">{item.material || "-"}</TableCell>
<TableCell className="p-2">{item.unit || "EA"}</TableCell>
<TableCell className="p-2">{resolveCat("material", item.material) || "-"}</TableCell>
<TableCell className="p-2">{resolveCat("inventory_unit", item.inventory_unit) || "EA"}</TableCell>
</TableRow>
))}
</TableBody>
@@ -158,6 +158,10 @@ export default function WarehouseManagementPage() {
const [rackStatus, setRackStatus] = useState("");
const [rackPreview, setRackPreview] = useState<any[]>([]);
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() {
</div>
</div>
{/* 위치명 형식 — 구역/열/단 뒤에 붙일 표현만 자유 입력 */}
<div className="rounded-lg border p-3 bg-muted/30 space-y-2">
<Label className="text-xs font-semibold"> </Label>
<div className="flex items-center gap-1 text-xs flex-wrap">
<span className="font-mono text-muted-foreground">A</span>
<Input
value={rackZoneLabel}
onChange={(e) => setRackZoneLabel(e.target.value)}
placeholder="구역"
className="h-8 w-20 text-xs"
/>
<span className="font-mono text-muted-foreground">- 01</span>
<Input
value={rackRowLabel}
onChange={(e) => setRackRowLabel(e.target.value)}
placeholder="열"
className="h-8 w-20 text-xs"
/>
<span className="font-mono text-muted-foreground">- 1</span>
<Input
value={rackLevelLabel}
onChange={(e) => setRackLevelLabel(e.target.value)}
placeholder="단"
className="h-8 w-20 text-xs"
/>
</div>
<p className="text-[11px] text-muted-foreground">
: <span className="font-mono font-semibold">A{rackZoneLabel}-01{rackRowLabel}-1{rackLevelLabel}</span>
{" "} // , .
</p>
</div>
{/* 등록 미리보기 */}
<div>
<div className="flex items-center justify-between mb-3">
@@ -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<string, any> = {};
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 () => {
@@ -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<string, any> = {
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() {
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
if (!selectedBomId || !bomHeader) {
toast.error("수정할 BOM을 선택해주세요");
return;
}
openEditModal();
}}
disabled={!selectedBomId || !bomHeader}
>
<Pencil className="w-3.5 h-3.5 mr-1" />
</Button>
<div className="w-px h-4 bg-border mx-0.5" />
<Button
size="sm"
@@ -12,6 +12,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
import {
@@ -91,8 +92,8 @@ export function ItemRoutingTab() {
const [formFixedOrder, setFormFixedOrder] = useState("Y");
const [formWorkType, setFormWorkType] = useState("내부");
const [formStandardTime, setFormStandardTime] = useState("");
const [formOutsource, setFormOutsource] = useState("");
const [subcontractorOptions, setSubcontractorOptions] = useState<{ code: string; name: string }[]>([]);
const [formOutsources, setFormOutsources] = useState<string[]>([]);
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() {
</div>
{showOutsourceField && (
<div className="space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select value={formOutsource || ""} onValueChange={setFormOutsource}>
<SelectTrigger className="h-9"><SelectValue placeholder="외주업체 선택" /></SelectTrigger>
<SelectContent>
{subcontractorOptions.map((s) => (
<SelectItem key={s.code} value={s.code}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> ( )</Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="h-9 w-full justify-between font-normal">
<span className="truncate text-left text-sm">
{formOutsources.length === 0
? "외주업체 선택"
: formOutsources
.map((i) => subcontractorOptions.find((s) => s.id === i)?.name || i)
.join(", ")}
</span>
<Badge variant="secondary" className="ml-2 shrink-0">{formOutsources.length}</Badge>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[320px] p-0" align="start">
<div className="max-h-64 overflow-y-auto p-2 space-y-1">
{subcontractorOptions.length === 0 ? (
<div className="text-xs text-muted-foreground px-2 py-3"> </div>
) : subcontractorOptions.map((s) => {
const checked = formOutsources.includes(s.id);
return (
<label
key={s.id}
className="flex items-center gap-2 rounded px-2 py-1.5 text-sm cursor-pointer hover:bg-muted"
>
<Checkbox
checked={checked}
onCheckedChange={(v) => {
setFormOutsources((prev) =>
v ? [...prev, s.id] : prev.filter((i) => i !== s.id),
);
}}
/>
<span className="truncate">{s.name}</span>
</label>
);
})}
</div>
</PopoverContent>
</Popover>
</div>
)}
</div>
@@ -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() {
<TableHead className="w-[50px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground"><Checkbox checked={regSourceData.length > 0 && regCheckedIds.size === regSourceData.length} onCheckedChange={toggleRegAll} /></TableHead>
{regSourceType === "item" && <><TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead><TableHead></TableHead><TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead></>}
{regSourceType === "order" && <><TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead><TableHead className="w-[100px]"></TableHead><TableHead></TableHead><TableHead className="w-[100px]"></TableHead><TableHead className="w-[80px] text-right"></TableHead><TableHead className="w-[100px]"></TableHead></>}
{regSourceType === "production" && <><TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead><TableHead className="w-[100px]"></TableHead><TableHead></TableHead><TableHead className="w-[80px] text-right"></TableHead><TableHead className="w-[90px]"></TableHead><TableHead className="w-[90px]"></TableHead><TableHead className="w-[100px]"></TableHead></>}
{regSourceType === "production" && <><TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead><TableHead className="w-[100px]"></TableHead><TableHead></TableHead><TableHead className="w-[80px] text-right"></TableHead><TableHead className="w-[80px] text-right"></TableHead><TableHead className="w-[80px] text-right"></TableHead><TableHead className="w-[90px]"></TableHead><TableHead className="w-[90px]"></TableHead><TableHead className="w-[100px]"></TableHead></>}
</TableRow>
</TableHeader>
<TableBody>
@@ -590,7 +596,7 @@ export default function WorkInstructionPage() {
<TableCell className="text-center" onClick={e => e.stopPropagation()}><Checkbox checked={checked} onCheckedChange={() => toggleRegItem(id)} /></TableCell>
{regSourceType === "item" && <><TableCell className="text-[13px] font-medium">{item.item_code}</TableCell><TableCell className="text-sm">{item.item_name}</TableCell><TableCell className="text-[13px]">{item.spec || "-"}</TableCell></>}
{regSourceType === "order" && <><TableCell className="text-[13px]">{item.order_no}</TableCell><TableCell className="text-[13px]">{item.item_code}</TableCell><TableCell className="text-sm">{item.item_name}</TableCell><TableCell className="text-[13px]">{item.spec || "-"}</TableCell><TableCell className="text-right text-[13px]">{Number(item.qty || 0).toLocaleString()}</TableCell><TableCell className="text-[13px]">{item.due_date || "-"}</TableCell></>}
{regSourceType === "production" && <><TableCell className="text-[13px]">{item.plan_no}</TableCell><TableCell className="text-[13px]">{item.item_code}</TableCell><TableCell className="text-sm">{item.item_name}</TableCell><TableCell className="text-right text-[13px]">{Number(item.plan_qty || 0).toLocaleString()}</TableCell><TableCell className="text-[13px]">{item.start_date ? String(item.start_date).split("T")[0] : "-"}</TableCell><TableCell className="text-[13px]">{item.end_date ? String(item.end_date).split("T")[0] : "-"}</TableCell><TableCell className="text-[13px]">{item.equipment_name || "-"}</TableCell></>}
{regSourceType === "production" && <><TableCell className="text-[13px]">{item.plan_no}</TableCell><TableCell className="text-[13px]">{item.item_code}</TableCell><TableCell className="text-sm">{item.item_name}</TableCell><TableCell className="text-right text-[13px]">{Number(item.plan_qty || 0).toLocaleString()}</TableCell><TableCell className="text-right text-[13px] text-muted-foreground">{Number(item.applied_qty || 0).toLocaleString()}</TableCell><TableCell className={cn("text-right text-[13px] font-semibold", Number(item.remain_qty ?? item.plan_qty ?? 0) < 0 && "text-destructive")}>{Number(item.remain_qty ?? item.plan_qty ?? 0).toLocaleString()}</TableCell><TableCell className="text-[13px]">{item.start_date ? String(item.start_date).split("T")[0] : "-"}</TableCell><TableCell className="text-[13px]">{item.end_date ? String(item.end_date).split("T")[0] : "-"}</TableCell><TableCell className="text-[13px]">{item.equipment_name || "-"}</TableCell></>}
</TableRow>
);
})}
@@ -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 }[] = [];
@@ -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: "판단기준" },
@@ -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<Record<string, any>>({});
const [copyInspectionRows, setCopyInspectionRows] = useState<Record<string, InspectionRow[]>>({});
const [copyCollapsedTypes, setCopyCollapsedTypes] = useState<Record<string, boolean>>({});
/* ═══════════════════ 복사 모달 (기준 품목 검사정보 → 다른 품목들) ═══════════════════ */
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<string, InspectionRow[]> = {};
const typeFlags: Record<string, boolean> = {};
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<string, InspectionRow[]> = {};
const typeFlags: Record<string, boolean> = {};
@@ -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() {
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8 w-[50px]"></TableHead>
@@ -983,7 +1122,7 @@ export default function ItemInspectionInfoPage() {
<TableBody>
{selectedTabRows.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-8 text-xs text-muted-foreground"> </TableCell>
<TableCell colSpan={9} className="text-center py-8 text-xs text-muted-foreground"> </TableCell>
</TableRow>
) : selectedTabRows.map((row: any) => (
<TableRow key={row.id}>
@@ -1002,6 +1141,7 @@ export default function ItemInspectionInfoPage() {
const proc = processOptions.find(p => p.code === code);
return proc?.name || code;
})()}</TableCell>
<TableCell className="text-xs py-2">{row.classification || "-"}</TableCell>
<TableCell className="text-xs py-2">
{(() => {
const insp = inspOptions.find(o => o.code === row.inspection_standard_id);
@@ -1010,7 +1150,16 @@ export default function ItemInspectionInfoPage() {
return jcLabel ? <Badge variant="outline" className="text-[10px]">{jcLabel}</Badge> : "-";
})()}
</TableCell>
<TableCell className="text-xs py-2 font-mono">{row.pass_criteria || "-"}</TableCell>
<TableCell className="text-xs py-2 font-mono">{(() => {
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;
})()}</TableCell>
<TableCell className="text-xs py-2 text-center">
{row.is_required === "true" || row.is_required === true ? (
<Badge variant="destructive" className="text-[9px]"></Badge>
@@ -1185,6 +1334,7 @@ export default function ItemInspectionInfoPage() {
<TableHead className="text-[10px] font-bold w-[130px]"> </TableHead>
<TableHead className="text-[10px] font-bold w-[90px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[90px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[100px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[80px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[200px]"> ()</TableHead>
<TableHead className="text-[10px] font-bold w-[40px]"></TableHead>
@@ -1194,7 +1344,7 @@ export default function ItemInspectionInfoPage() {
</TableHeader>
<TableBody>
{(!inspectionRows[key] || inspectionRows[key].length === 0) ? (
<TableRow><TableCell colSpan={9} className="text-center py-4 text-xs text-muted-foreground"> </TableCell></TableRow>
<TableRow><TableCell colSpan={10} className="text-center py-4 text-xs text-muted-foreground"> </TableCell></TableRow>
) : inspectionRows[key].map((row) => (
<TableRow key={row.id}>
<TableCell className="p-1">
@@ -1219,6 +1369,9 @@ export default function ItemInspectionInfoPage() {
<Input className="h-8 text-xs" value={row.apply_process} onChange={(e) => updateInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" />
)}
</TableCell>
<TableCell className="p-1">
<Input className="h-8 text-xs" value={row.classification || ""} onChange={(e) => updateInspRow(key, row.id, "classification", e.target.value)} placeholder="구분 입력" />
</TableCell>
<TableCell className="p-1 text-center">
{row.judgment_criteria ? <Badge variant="outline" className="text-[10px]">{row.judgment_criteria}</Badge> : <span className="text-[10px] text-muted-foreground">-</span>}
</TableCell>
@@ -1285,20 +1438,20 @@ export default function ItemInspectionInfoPage() {
</DialogContent>
</Dialog>
{/* ═══════════════════ 복사 모달 ═══════════════════ */}
{/* ═══════════════════ 복사 모달 (2단 분할: 좌 대상 / 우 편집) ═══════════════════ */}
<Dialog open={copyModalOpen} onOpenChange={(v) => { if (!copying) setCopyModalOpen(v); }}>
<DialogContent
className="max-w-3xl w-[95vw] max-h-[85vh] flex flex-col overflow-hidden"
className="max-w-[95vw] sm:max-w-[1400px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden"
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => { if (copying) e.preventDefault(); }}
>
<DialogHeader>
<DialogHeader className="shrink-0">
<DialogTitle>{copying ? "검사정보 복사 중..." : "검사정보 복사"}</DialogTitle>
<DialogDescription>
<span className="font-medium text-foreground">{selectedGroup?.item_name || "-"}</span>
<span className="text-muted-foreground"> ({selectedItemCode})</span>
<span>{copying ? " 의 검사정보를 복사하고 있습니다" : " 의 검사정보를 아래 선택한 품목들에 복사합니다"}</span>
<span>{copying ? " 의 검사정보를 복사하고 있습니다" : " 의 검사정보를 편집해서 선택한 품목들에 복사합니다. 기준 품목은 변경되지 않아요"}</span>
</DialogDescription>
</DialogHeader>
{copying ? (
@@ -1322,81 +1475,229 @@ export default function ItemInspectionInfoPage() {
</p>
</div>
</div>
) : (<>
<div className="flex gap-2 shrink-0">
<Input className="h-9 flex-1" placeholder="품목코드 또는 품목명" value={copySearchKeyword}
onChange={(e) => setCopySearchKeyword(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleCopySearch(); }} />
<Button size="sm" className="h-9" onClick={handleCopySearch} disabled={copySearchLoading}>
{copySearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" /></>}
</Button>
</div>
<div className="flex-1 border rounded-lg overflow-auto">
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[40px] text-center">
<Checkbox
checked={copyFilteredItems.length > 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)));
}}
/>
</TableHead>
<TableHead className="text-[11px] font-bold w-[140px]"></TableHead>
<TableHead className="text-[11px] font-bold"></TableHead>
<TableHead className="text-[11px] font-bold w-[100px]"></TableHead>
<TableHead className="text-[11px] font-bold w-[80px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{copyFilteredItems.length === 0 ? (
<TableRow><TableCell colSpan={5} className="text-center py-8 text-muted-foreground text-sm">
{copySearchLoading ? "검색 중..." : "검색 결과가 없어요"}
</TableCell></TableRow>
) : copyFilteredItems.map((item) => (
<TableRow key={item.code} className="cursor-pointer hover:bg-primary/5"
onClick={() => toggleCopyChecked(item.code)}>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox checked={copyCheckedIds.includes(item.code)} onCheckedChange={() => toggleCopyChecked(item.code)} />
</TableCell>
<TableCell className="text-sm font-mono">{item.code}</TableCell>
<TableCell className="text-sm">{item.name}</TableCell>
<TableCell className="text-sm">{item.item_type}</TableCell>
<TableCell className="text-sm">{item.unit}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="shrink-0 flex items-center justify-between border-t px-2 py-2 text-xs text-muted-foreground">
<span>
<span className="font-medium text-foreground">{copyTotal.toLocaleString()}</span>
{copyCheckedIds.length > 0 && <span className="ml-2"> <span className="font-medium text-primary">{copyCheckedIds.length}</span></span>}
</span>
<div className="flex items-center gap-0.5">
<button onClick={() => { 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"><ChevronsLeft className="h-3.5 w-3.5" /></button>
<button onClick={() => { 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"><ChevronLeft className="h-3.5 w-3.5" /></button>
{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 (
<button key={p} onClick={() => { 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}</button>
);
})}
<button onClick={() => { 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"><ChevronRight className="h-3.5 w-3.5" /></button>
<button onClick={() => { 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"><ChevronsRight className="h-3.5 w-3.5" /></button>
) : (
<div className="flex-1 grid grid-cols-[420px_1fr] gap-4 overflow-hidden">
{/* 좌측: 복사 대상 품목 선택 */}
<div className="flex flex-col overflow-hidden border rounded-lg">
<div className="flex items-center justify-between border-b bg-muted/50 px-3 py-2">
<span className="text-xs font-semibold"> </span>
{copyCheckedIds.length > 0 && <span className="text-[10px] text-primary"> {copyCheckedIds.length}</span>}
</div>
<div className="flex gap-2 px-2 pt-2">
<Input className="h-8 flex-1 text-xs" placeholder="품목코드 또는 품목명" value={copySearchKeyword}
onChange={(e) => setCopySearchKeyword(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleCopySearch(); }} />
<Button size="sm" className="h-8 text-xs" onClick={handleCopySearch} disabled={copySearchLoading}>
{copySearchLoading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Search className="w-3.5 h-3.5" />}
</Button>
</div>
<div className="flex-1 overflow-auto mt-2">
<Table>
<TableHeader className="sticky top-0 z-10 bg-background">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[36px] text-center text-[10px]">
<Checkbox
checked={copyFilteredItems.length > 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)));
}}
/>
</TableHead>
<TableHead className="text-[10px] font-bold w-[120px]"></TableHead>
<TableHead className="text-[10px] font-bold"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{copyFilteredItems.length === 0 ? (
<TableRow><TableCell colSpan={3} className="text-center py-6 text-muted-foreground text-xs">
{copySearchLoading ? "검색 중..." : "검색 결과가 없어요"}
</TableCell></TableRow>
) : copyFilteredItems.map((item) => (
<TableRow key={item.code} className="cursor-pointer hover:bg-primary/5"
onClick={() => toggleCopyChecked(item.code)}>
<TableCell className="text-center p-1" onClick={(e) => e.stopPropagation()}>
<Checkbox checked={copyCheckedIds.includes(item.code)} onCheckedChange={() => toggleCopyChecked(item.code)} />
</TableCell>
<TableCell className="text-xs font-mono p-1">{item.code}</TableCell>
<TableCell className="text-xs p-1 truncate">{item.name}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="border-t flex items-center justify-between px-2 py-1 text-[10px] text-muted-foreground">
<span> <span className="font-medium text-foreground">{copyTotal.toLocaleString()}</span></span>
<div className="flex items-center gap-0.5">
<button onClick={() => { 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"><ChevronsLeft className="h-3 w-3" /></button>
<button onClick={() => { 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"><ChevronLeft className="h-3 w-3" /></button>
<span className="text-[10px] mx-1">{copyPage}/{copyTotalPages}</span>
<button onClick={() => { 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"><ChevronRight className="h-3 w-3" /></button>
<button onClick={() => { 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"><ChevronsRight className="h-3 w-3" /></button>
</div>
</div>
</div>
{/* 우측: 편집 폼 (등록/수정 폼과 동일 구조) */}
<div className="flex flex-col overflow-hidden border rounded-lg">
<div className="border-b bg-muted/50 px-3 py-2">
<span className="text-xs font-semibold"> (: {selectedItemCode})</span>
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Select value={copyForm.is_active === false || copyForm.is_active === "N" ? "N" : "Y"} onValueChange={(v) => setCopyForm(p => ({ ...p, is_active: v === "Y" ? "사용" : "미사용" }))}>
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="Y"></SelectItem>
<SelectItem value="N"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Select value={copyForm.manager || ""} onValueChange={(v) => setCopyForm(p => ({ ...p, manager: v }))}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="관리자 선택" /></SelectTrigger>
<SelectContent>{userOptions.map(o => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<h4 className="text-xs font-semibold"> </h4>
<div className="flex flex-wrap gap-3">
{INSPECTION_TYPES.map(({ key, label }) => (
<div key={key} className="flex items-center gap-1.5">
<Checkbox checked={!!copyForm[key]} onCheckedChange={(v) => setCopyForm(p => ({ ...p, [key]: !!v }))} />
<Label className="text-xs cursor-pointer">{label}</Label>
</div>
))}
</div>
</div>
{INSPECTION_TYPES.filter(t => !!copyForm[t.key]).map(({ key, label }) => (
<div key={key} className="space-y-1.5">
<button type="button" className="w-full flex items-center gap-2 py-1.5 px-2 rounded-md border bg-muted/50 hover:bg-muted text-left" onClick={() => toggleCopyCollapse(key)}>
<Badge variant="default" className="text-[10px]">{label}</Badge>
<span className="text-xs font-medium"> </span>
<span className="text-[10px] text-muted-foreground ml-auto">{(copyInspectionRows[key] || []).length}</span>
</button>
{!copyCollapsedTypes[key] && (
<div className="space-y-1.5 pl-1">
<div className="flex items-center justify-between">
<span className="text-[10px] font-semibold text-muted-foreground"> </span>
<Button type="button" size="sm" variant="outline" className="h-6 text-[10px]" onClick={() => addCopyInspRow(key)}>
<Plus className="w-3 h-3 mr-1" />
</Button>
</div>
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-muted/50 hover:bg-muted/50">
<TableHead className="text-[10px] font-bold w-[150px]"> </TableHead>
<TableHead className="text-[10px] font-bold w-[110px]"> </TableHead>
<TableHead className="text-[10px] font-bold w-[80px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[80px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[90px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[70px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[180px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[36px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[60px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[32px]" />
</TableRow>
</TableHeader>
<TableBody>
{(!copyInspectionRows[key] || copyInspectionRows[key].length === 0) ? (
<TableRow><TableCell colSpan={10} className="text-center py-3 text-[10px] text-muted-foreground"> </TableCell></TableRow>
) : copyInspectionRows[key].map((row) => (
<TableRow key={row.id}>
<TableCell className="p-1">
<Select value={row.inspection_standard_id || ""} onValueChange={(v) => updateCopyInspRow(key, row.id, "inspection_standard_id", v)}>
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="검사기준" /></SelectTrigger>
<SelectContent>{getFilteredInspOptions(key).map(o => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}</SelectContent>
</Select>
</TableCell>
<TableCell className="p-1"><Input className="h-7 text-[10px] bg-muted" value={row.inspection_detail} readOnly placeholder="자동" /></TableCell>
<TableCell className="p-1"><Input className="h-7 text-[10px] bg-muted" value={row.inspection_method} readOnly placeholder="자동" /></TableCell>
<TableCell className="p-1">
{processOptions.length > 0 ? (
<Select value={row.apply_process || ""} onValueChange={(v) => updateCopyInspRow(key, row.id, "apply_process", v)}>
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="공정" /></SelectTrigger>
<SelectContent>
{processOptions.map((p) => (
<SelectItem key={p.code} value={p.code} className="text-xs">{p.name}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input className="h-7 text-[10px]" value={row.apply_process} onChange={(e) => updateCopyInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" />
)}
</TableCell>
<TableCell className="p-1">
<Input className="h-7 text-[10px]" value={row.classification || ""} onChange={(e) => updateCopyInspRow(key, row.id, "classification", e.target.value)} placeholder="구분" />
</TableCell>
<TableCell className="p-1 text-center">
{row.judgment_criteria ? <Badge variant="outline" className="text-[9px]">{row.judgment_criteria}</Badge> : <span className="text-[9px] text-muted-foreground">-</span>}
</TableCell>
<TableCell className="p-1">
{row.judgment_criteria === "선택형" && row.selection_options ? (
<Select value={row.acceptance_criteria || ""} onValueChange={(v) => updateCopyInspRow(key, row.id, "acceptance_criteria", v)} disabled={!row.inspection_standard_id}>
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{row.selection_options.split(",").filter(Boolean).map((opt) => (
<SelectItem key={opt} value={opt} className="text-xs">{opt}</SelectItem>
))}
</SelectContent>
</Select>
) : row.judgment_criteria === "O/X" ? (
<Select value={row.acceptance_criteria || ""} onValueChange={(v) => updateCopyInspRow(key, row.id, "acceptance_criteria", v)} disabled={!row.inspection_standard_id}>
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
<SelectItem value="O" className="text-xs">O ()</SelectItem>
<SelectItem value="X" className="text-xs">X ()</SelectItem>
</SelectContent>
</Select>
) : row.judgment_criteria === "수치(범위)" ? (
<div className="flex items-center gap-1">
<Input className="h-7 text-[10px] w-14" value={row.acceptance_criteria?.split("|")[0] || ""} onChange={(e) => {
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} />
<span className="text-[9px] text-muted-foreground">±</span>
<Input className="h-7 text-[10px] w-10" value={row.acceptance_criteria?.split("|")[1] || ""} onChange={(e) => {
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} />
</div>
) : (
<Input className="h-7 text-[10px]" value={row.acceptance_criteria} onChange={(e) => updateCopyInspRow(key, row.id, "acceptance_criteria", e.target.value)} placeholder="합격기준" disabled={!row.inspection_standard_id} />
)}
</TableCell>
<TableCell className="p-1 text-center"><Checkbox checked={row.is_required} onCheckedChange={(v) => updateCopyInspRow(key, row.id, "is_required", !!v)} /></TableCell>
<TableCell className="p-1 text-[10px] text-muted-foreground">{row.unit || "-"}</TableCell>
<TableCell className="p-1">
<Button type="button" variant="destructive" size="sm" className="h-6 w-6 p-0" onClick={() => removeCopyInspRow(key, row.id)}><Trash2 className="w-3 h-3" /></Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
</div>
))}
</div>
</div>
</div>
</div>
</>)}
)}
<DialogFooter className="shrink-0">
<Button variant="outline" onClick={() => setCopyModalOpen(false)} disabled={copying}></Button>
<Button onClick={handleCopy} disabled={copying || copyCheckedIds.length === 0}>
@@ -191,13 +191,13 @@ export default function CustomerManagementPage() {
const optMap: Record<string, { code: string; label: string }[]> = {};
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<string, { code: string; label: string }[]> = {};
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 */ }
};

Some files were not shown because too many files have changed in this diff Show More