Merge branch 'jskim-node' of https://g.wace.me/jskim/vexplor_dev into mhkim-node
This commit is contained in:
@@ -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 }); }
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user