Merge remote-tracking branch 'origin/jskim-node' into gbpark-node

This commit is contained in:
DDD1542
2026-04-23 14:03:57 +09:00
285 changed files with 24705 additions and 4629 deletions
+4
View File
@@ -158,12 +158,14 @@ import workInstructionRoutes from "./routes/workInstructionRoutes"; // 작업지
import cuttingPlanRoutes from "./routes/cuttingPlanRoutes"; // 절단계획 관리
import salesReportRoutes from "./routes/salesReportRoutes"; // 영업 리포트
import reportPresetRoutes from "./routes/reportPresetRoutes"; // 리포트 프리셋 저장 (회사별/리포트별)
import reportCellValueRoutes from "./routes/reportCellValueRoutes"; // 리포트 셀 커스텀 입력값 (input 셀)
import analyticsReportRoutes from "./routes/analyticsReportRoutes"; // 분석 리포트 (생산/재고/구매/품질/설비/금형)
import systemNoticeRoutes from "./routes/systemNoticeRoutes"; // 시스템 공지
import designRoutes from "./routes/designRoutes"; // 설계 모듈 (DR/ECR/프로젝트/ECN)
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";
@@ -383,11 +385,13 @@ app.use("/api/work-instruction", workInstructionRoutes); // 작업지시 관리
app.use("/api/cutting-plan", cuttingPlanRoutes); // 절단계획 관리
app.use("/api/sales-report", salesReportRoutes); // 영업 리포트
app.use("/api/report-presets", reportPresetRoutes); // 리포트 프리셋 (회사별/리포트별 저장)
app.use("/api/report-cell-values", reportCellValueRoutes); // 리포트 셀 커스텀 입력값
app.use("/api/system-notice", systemNoticeRoutes); // 시스템 공지
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,45 +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(wi.routing, '미지정') 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 (
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),
});
}
+27 -4
View File
@@ -512,13 +512,36 @@ export async function getMoldSerialSummary(req: AuthenticatedRequest, res: Respo
const companyCode = req.user!.companyCode;
const { moldCode } = req.params;
// 카테고리 코드/영문코드/한글라벨 모두 대응
// 먼저 카테고리 값 조회하여 매핑
// mold_serial.status + mold_mng.operation_status 양쪽 카테고리 모두 조회
const catSql = `SELECT value_code, value_label FROM category_values
WHERE ((table_name='mold_serial' AND column_name='status') OR (table_name='mold_mng' AND column_name='operation_status'))
AND company_code=$1`;
const catRows = await query(catSql, [companyCode]);
// 카테고리 라벨 기준으로 그룹핑할 코드 목록 생성
const codesByLabel: Record<string, string[]> = { "사용중": ["IN_USE"], "수리중": ["REPAIR"], "보관중": ["STORED"], "폐기": ["DISPOSED"] };
for (const cat of catRows) {
const label = cat.value_label || "";
if (label.includes("사용")) (codesByLabel["사용중"] = codesByLabel["사용중"] || []).push(cat.value_code);
else if (label.includes("수리")) (codesByLabel["수리중"] = codesByLabel["수리중"] || []).push(cat.value_code);
else if (label.includes("보관") || label.includes("미사용")) (codesByLabel["보관중"] = codesByLabel["보관중"] || []).push(cat.value_code);
else if (label.includes("폐기")) (codesByLabel["폐기"] = codesByLabel["폐기"] || []).push(cat.value_code);
}
const inUseCodes = codesByLabel["사용중"].map(c => `'${c}'`).join(",");
const repairCodes = codesByLabel["수리중"].map(c => `'${c}'`).join(",");
const storedCodes = codesByLabel["보관중"].map(c => `'${c}'`).join(",");
const disposedCodes = codesByLabel["폐기"].map(c => `'${c}'`).join(",");
const sql = `
SELECT
COUNT(*) as total,
COUNT(*) FILTER (WHERE status = 'IN_USE') as in_use,
COUNT(*) FILTER (WHERE status = 'REPAIR') as repair,
COUNT(*) FILTER (WHERE status = 'STORED') as stored,
COUNT(*) FILTER (WHERE status = 'DISPOSED') as disposed
COUNT(*) FILTER (WHERE status IN (${inUseCodes})) as in_use,
COUNT(*) FILTER (WHERE status IN (${repairCodes})) as repair,
COUNT(*) FILTER (WHERE status IN (${storedCodes})) as stored,
COUNT(*) FILTER (WHERE status IN (${disposedCodes})) as disposed
FROM mold_serial
WHERE mold_code = $1 AND company_code = $2
`;
@@ -10,6 +10,7 @@
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";
// 출고 목록 조회
@@ -324,6 +325,9 @@ export async function create(req: AuthenticatedRequest, res: Response) {
// 출고 수정
export async function update(req: AuthenticatedRequest, res: Response) {
const pool = getPool();
const client = await pool.connect();
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
@@ -341,8 +345,90 @@ export async function update(req: AuthenticatedRequest, res: Response) {
memo,
} = req.body;
const pool = getPool();
const result = await pool.query(
await client.query("BEGIN");
// 변경 전 값 조회
const oldRes = await client.query(
`SELECT * FROM outbound_mng WHERE id = $1 AND company_code = $2`,
[id, companyCode],
);
if (oldRes.rowCount === 0) {
await client.query("ROLLBACK");
return res
.status(404)
.json({ success: false, message: "출고 데이터를 찾을 수 없습니다." });
}
const old = oldRes.rows[0];
const oldQty = Number(old.outbound_qty) || 0;
const oldWhCode = old.warehouse_code || null;
const oldLocCode = old.location_code || null;
const itemCode = old.item_code || old.item_number || null;
const outboundNumber = old.outbound_number;
const newQty =
outbound_qty !== undefined && outbound_qty !== null
? Number(outbound_qty)
: oldQty;
const newWhCode =
warehouse_code !== undefined ? warehouse_code : oldWhCode;
const newLocCode =
location_code !== undefined ? location_code : oldLocCode;
// 재고/이력 반영 (append-only): 수량 또는 창고/위치 변경 시
const qtyChanged = newQty !== oldQty;
const whChanged =
(newWhCode || "") !== (oldWhCode || "") ||
(newLocCode || "") !== (oldLocCode || "");
if (itemCode && (qtyChanged || whChanged)) {
if (whChanged) {
// 기존 창고 복구
if (oldQty > 0) {
await adjustInventory(client, {
companyCode,
userId,
itemCode,
whCode: oldWhCode,
locCode: oldLocCode,
delta: +oldQty,
transactionType: "출고취소",
remark: `출고수정-창고변경 (${outboundNumber}) ${oldWhCode || ""}${newWhCode || ""}`,
});
}
// 신규 창고 차감 (재고부족 검증)
if (newQty > 0) {
await adjustInventory(client, {
companyCode,
userId,
itemCode,
whCode: newWhCode,
locCode: newLocCode,
delta: -newQty,
transactionType: "출고수정",
remark: `출고수정-창고변경 (${outboundNumber}) ${oldWhCode || ""}${newWhCode || ""}, 수량 ${oldQty}${newQty}`,
validateStockEnough: true,
});
}
} else {
// 창고 동일, 수량만 변경: 기존 복구(+oldQty) + 신규 차감(-newQty) = delta(+복구/-추가차감)
const delta = oldQty - newQty;
if (delta !== 0) {
await adjustInventory(client, {
companyCode,
userId,
itemCode,
whCode: newWhCode,
locCode: newLocCode,
delta,
transactionType: "출고수정",
remark: `출고수정 (${outboundNumber}) 수량 ${oldQty}${newQty}`,
validateStockEnough: delta < 0,
});
}
}
}
const result = await client.query(
`UPDATE outbound_mng SET
outbound_date = COALESCE($1, outbound_date),
outbound_qty = COALESCE($2, outbound_qty),
@@ -375,45 +461,95 @@ export async function update(req: AuthenticatedRequest, res: Response) {
],
);
if (result.rowCount === 0) {
return res
.status(404)
.json({ success: false, message: "출고 데이터를 찾을 수 없습니다." });
}
await client.query("COMMIT");
logger.info("출고 수정", { companyCode, userId, id });
logger.info("출고 수정", {
companyCode,
userId,
id,
oldQty,
newQty,
oldWhCode,
newWhCode,
});
return res.json({ success: true, data: result.rows[0] });
} 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();
}
}
// 출고 삭제
// 출고 삭제 (재고 복구 + '출고취소' 이력 기록 포함)
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;
const pool = getPool();
const result = await pool.query(
`DELETE FROM outbound_mng WHERE id = $1 AND company_code = $2 RETURNING id`,
await client.query("BEGIN");
// 대상 출고 조회
const oldRes = await client.query(
`SELECT * FROM outbound_mng WHERE id = $1 AND company_code = $2`,
[id, companyCode],
);
if (result.rowCount === 0) {
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 || old.item_number || null;
const whCode = old.warehouse_code || null;
const locCode = old.location_code || null;
const qty = Number(old.outbound_qty) || 0;
const outboundNumber = old.outbound_number;
logger.info("출고 삭제", { companyCode, id });
// 재고 복구 + 이력
if (itemCode && qty > 0) {
await adjustInventory(client, {
companyCode,
userId,
itemCode,
whCode,
locCode,
delta: +qty,
transactionType: "출고취소",
remark: `출고 삭제 (${outboundNumber})`,
});
} else {
logger.warn("출고 삭제 - 재고 복구 스킵", {
companyCode,
id,
itemCode,
qty,
});
}
await client.query(
`DELETE FROM outbound_mng WHERE id = $1 AND company_code = $2`,
[id, companyCode],
);
await client.query("COMMIT");
logger.info("출고 삭제", { companyCode, userId, id, itemCode, qty });
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();
}
}
@@ -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 });
}
}
@@ -175,7 +175,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
@@ -228,10 +228,11 @@ export async function deletePkgUnitItem(
const { id } = req.params;
const pool = getPool();
const result = await pool.query(
`DELETE FROM pkg_unit_item WHERE id=$1 AND company_code=$2 RETURNING id`,
[id, companyCode]
);
const query = companyCode === "*"
? `DELETE FROM pkg_unit_item WHERE id=$1 RETURNING id`
: `DELETE FROM pkg_unit_item WHERE id=$1 AND company_code=$2 RETURNING id`;
const params = companyCode === "*" ? [id] : [id, companyCode];
const result = await pool.query(query, params);
if (result.rowCount === 0) {
res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." });
@@ -471,10 +472,11 @@ export async function deleteLoadingUnitPkg(
const { id } = req.params;
const pool = getPool();
const result = await pool.query(
`DELETE FROM loading_unit_pkg WHERE id=$1 AND company_code=$2 RETURNING id`,
[id, companyCode]
);
const query = companyCode === "*"
? `DELETE FROM loading_unit_pkg WHERE id=$1 RETURNING id`
: `DELETE FROM loading_unit_pkg WHERE id=$1 AND company_code=$2 RETURNING id`;
const params = companyCode === "*" ? [id] : [id, companyCode];
const result = await pool.query(query, params);
if (result.rowCount === 0) {
res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." });
@@ -530,7 +532,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`,
@@ -583,7 +585,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
@@ -154,10 +154,13 @@ export async function getProcessEquipments(req: AuthenticatedRequest, res: Respo
const companyCode = req.user!.companyCode;
const { processCode } = req.params;
// equipment_code 컬럼에 코드(legacy) 또는 id(신규)가 들어올 수 있어 두 경우 모두 매칭
const result = await pool.query(
`SELECT pe.*, em.equipment_name
FROM process_equipment pe
LEFT JOIN equipment_mng em ON pe.equipment_code = em.equipment_code AND pe.company_code = em.company_code
LEFT JOIN equipment_mng em
ON pe.company_code = em.company_code
AND (pe.equipment_code = em.equipment_code OR pe.equipment_code = em.id)
WHERE pe.process_code = $1 AND pe.company_code = $2
ORDER BY pe.equipment_code`,
[processCode, companyCode]
@@ -382,7 +385,38 @@ export async function getRoutingDetails(req: AuthenticatedRequest, res: Response
[versionId, companyCode]
);
return res.json({ success: true, data: result.rows });
const rows = result.rows;
const detailIds = rows.map((r: any) => r.id).filter(Boolean);
let idsByDetail: Record<string, string[]> = {};
let codesByDetail: Record<string, string[]> = {};
if (detailIds.length > 0) {
const mapRes = await pool.query(
`SELECT irs.routing_detail_id, irs.subcontractor_id, sm.subcontractor_code
FROM item_routing_subcontractor irs
LEFT JOIN subcontractor_mng sm ON irs.subcontractor_id = sm.id
WHERE irs.routing_detail_id = ANY($1::varchar[])
ORDER BY irs.seq_order`,
[detailIds]
);
for (const m of mapRes.rows) {
const key = String(m.routing_detail_id);
(idsByDetail[key] ||= []).push(m.subcontractor_id);
if (m.subcontractor_code) (codesByDetail[key] ||= []).push(m.subcontractor_code);
}
}
const enriched = rows.map((r: any) => {
const ids = idsByDetail[String(r.id)] || [];
const codes = codesByDetail[String(r.id)] || [];
// 레거시 폴백: 매핑이 비어있고 legacy 단일 컬럼(code)에 값이 있으면 code 배열로 반환
const legacyCodes = ids.length === 0 && r.outsource_supplier ? [r.outsource_supplier] : codes;
return {
...r,
outsource_supplier_ids: ids,
outsource_supplier_list: legacyCodes, // 하위호환 별칭 (code 배열)
};
});
return res.json({ success: true, data: enriched });
} catch (error: any) {
logger.error("라우팅 상세 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
@@ -400,6 +434,15 @@ export async function saveRoutingDetails(req: AuthenticatedRequest, res: Respons
try {
await client.query("BEGIN");
// 기존 상세의 외주업체 매핑을 먼저 제거
await client.query(
`DELETE FROM item_routing_subcontractor
WHERE routing_detail_id IN (
SELECT id FROM item_routing_detail WHERE routing_version_id=$1 AND company_code=$2
)`,
[versionId, companyCode]
);
// 기존 상세 삭제 후 재입력
await client.query(
`DELETE FROM item_routing_detail WHERE routing_version_id=$1 AND company_code=$2`,
@@ -407,11 +450,38 @@ export async function saveRoutingDetails(req: AuthenticatedRequest, res: Respons
);
for (const d of details) {
await client.query(
const supplierIds: string[] = Array.isArray(d.outsource_supplier_ids)
? d.outsource_supplier_ids.filter((s: any) => typeof s === "string" && s.trim() !== "")
: [];
// legacy code 해석: 첫 번째 subcontractor_id → subcontractor_code 조회
let legacyCode = "";
if (supplierIds.length > 0) {
const codeRes = await client.query(
`SELECT subcontractor_code FROM subcontractor_mng WHERE id=$1 LIMIT 1`,
[supplierIds[0]]
);
legacyCode = codeRes.rows[0]?.subcontractor_code || "";
} else if (d.outsource_supplier) {
// 프론트가 아직 id 없이 code만 보낸 경우(레거시 호환)
legacyCode = d.outsource_supplier;
}
const insertRes = await client.query(
`INSERT INTO item_routing_detail (id, company_code, routing_version_id, seq_no, process_code, is_required, is_fixed_order, work_type, standard_time, outsource_supplier, writer)
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
[companyCode, versionId, d.seq_no, d.process_code, d.is_required || "Y", d.is_fixed_order || "Y", d.work_type || "내부", d.standard_time || "0", d.outsource_supplier || "", writer]
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id`,
[companyCode, versionId, d.seq_no, d.process_code, d.is_required || "Y", d.is_fixed_order || "Y", d.work_type || "내부", d.standard_time || "0", legacyCode, writer]
);
const newDetailId = insertRes.rows[0].id;
for (let i = 0; i < supplierIds.length; i++) {
await client.query(
`INSERT INTO item_routing_subcontractor (id, company_code, routing_detail_id, subcontractor_id, seq_order)
VALUES (gen_random_uuid()::text, $1, $2, $3, $4)`,
[companyCode, newDetailId, supplierIds[i], i]
);
}
}
await client.query("COMMIT");
@@ -10,6 +10,7 @@
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";
// 입고 목록 조회 (헤더-디테일 JOIN, 레거시 호환)
@@ -472,7 +473,46 @@ export async function update(req: AuthenticatedRequest, res: Response) {
await client.query("BEGIN");
// 헤더 업데이트 (inbound_mng) — 헤더 레벨 필드만
// 변경 전 값 조회 (헤더)
const oldHeaderRes = await client.query(
`SELECT * FROM inbound_mng WHERE id = $1 AND company_code = $2`,
[id, companyCode],
);
if (oldHeaderRes.rowCount === 0) {
await client.query("ROLLBACK");
return res
.status(404)
.json({ success: false, message: "입고 데이터를 찾을 수 없습니다." });
}
const oldHeader = oldHeaderRes.rows[0];
// 변경 전 값 조회 (디테일, 있을 경우)
let oldDetail: any = null;
if (detail_id) {
const oldDetailRes = await client.query(
`SELECT * FROM inbound_detail WHERE id = $1 AND company_code = $2`,
[detail_id, companyCode],
);
oldDetail = oldDetailRes.rows[0] || null;
}
const oldQty =
Number(oldDetail?.inbound_qty ?? oldHeader.inbound_qty) || 0;
const oldWhCode = oldHeader.warehouse_code || null;
const oldLocCode = oldHeader.location_code || null;
const itemCode = oldDetail?.item_number || oldHeader.item_number || null;
const inboundNumber = oldHeader.inbound_number;
const newQty =
inbound_qty !== undefined && inbound_qty !== null
? Number(inbound_qty)
: oldQty;
const newWhCode =
warehouse_code !== undefined ? warehouse_code : oldWhCode;
const newLocCode =
location_code !== undefined ? location_code : oldLocCode;
// 입고 레코드 업데이트 (헤더 + 품목 필드 모두)
const headerResult = await client.query(
`UPDATE inbound_mng SET
inbound_date = COALESCE($1::date, inbound_date),
@@ -482,6 +522,9 @@ export async function update(req: AuthenticatedRequest, res: Response) {
inspector = COALESCE($5, inspector),
manager = COALESCE($6, manager),
memo = COALESCE($7, memo),
inbound_qty = COALESCE($11::numeric, inbound_qty),
unit_price = COALESCE($12::numeric, unit_price),
total_amount = COALESCE($13::numeric, total_amount),
updated_date = NOW(),
updated_by = $8
WHERE id = $9 AND company_code = $10
@@ -497,16 +540,12 @@ export async function update(req: AuthenticatedRequest, res: Response) {
userId,
id,
companyCode,
inbound_qty || null,
unit_price || null,
total_amount || null,
],
);
if (headerResult.rowCount === 0) {
await client.query("ROLLBACK");
return res
.status(404)
.json({ success: false, message: "입고 데이터를 찾을 수 없습니다." });
}
// 디테일 업데이트 (inbound_detail) — detail_id가 있으면 디테일 레벨 필드 업데이트
let detailRow = null;
if (detail_id) {
@@ -557,9 +596,67 @@ export async function update(req: AuthenticatedRequest, res: Response) {
);
}
// 재고/이력 반영 (append-only): 수량 또는 창고/위치 변경 시
const qtyChanged = newQty !== oldQty;
const whChanged =
(newWhCode || "") !== (oldWhCode || "") ||
(newLocCode || "") !== (oldLocCode || "");
if (itemCode && (qtyChanged || whChanged)) {
if (whChanged) {
if (oldQty > 0) {
await adjustInventory(client, {
companyCode,
userId,
itemCode,
whCode: oldWhCode,
locCode: oldLocCode,
delta: -oldQty,
transactionType: "입고취소",
remark: `입고수정-창고변경 (${inboundNumber}) ${oldWhCode || ""}${newWhCode || ""}`,
});
}
if (newQty > 0) {
await adjustInventory(client, {
companyCode,
userId,
itemCode,
whCode: newWhCode,
locCode: newLocCode,
delta: newQty,
transactionType: "입고수정",
remark: `입고수정-창고변경 (${inboundNumber}) ${oldWhCode || ""}${newWhCode || ""}, 수량 ${oldQty}${newQty}`,
});
}
} else {
const delta = newQty - oldQty;
if (delta !== 0) {
await adjustInventory(client, {
companyCode,
userId,
itemCode,
whCode: newWhCode,
locCode: newLocCode,
delta,
transactionType: "입고수정",
remark: `입고수정 (${inboundNumber}) 수량 ${oldQty}${newQty}`,
});
}
}
}
await client.query("COMMIT");
logger.info("입고 수정", { companyCode, userId, id, detail_id });
logger.info("입고 수정", {
companyCode,
userId,
id,
detail_id,
oldQty,
newQty,
oldWhCode,
newWhCode,
});
return res.json({
success: true,
@@ -0,0 +1,93 @@
/**
* 리포트 셀 커스텀 입력값 컨트롤러
*
* 리포트 디자이너에서 cellType="input"으로 지정한 셀에 대해
* 각 대상 레코드(quote 등)별로 사용자가 입력한 값을 관리
*/
import type { Response } from "express";
import { getPool } from "../database/db";
import type { AuthenticatedRequest } from "../types/auth";
import { logger } from "../utils/logger";
// 목록 조회: 특정 리포트 + 타겟에 대한 모든 셀 값
export async function getList(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { report_id, target_type, target_id } = req.query;
if (!report_id || !target_type || !target_id) {
return res.status(400).json({
success: false,
message: "report_id, target_type, target_id는 필수입니다.",
});
}
const pool = getPool();
const result = await pool.query(
`SELECT id, report_id, target_type, target_id, component_id, cell_id, value
FROM report_cell_values
WHERE company_code = $1 AND report_id = $2 AND target_type = $3 AND target_id = $4`,
[companyCode, report_id, target_type, target_id],
);
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 });
}
}
// UPSERT 단건: 같은 (report_id, target_type, target_id, component_id, cell_id)면 UPDATE, 아니면 INSERT
export async function upsert(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { report_id, target_type, target_id, component_id, cell_id, value } =
req.body;
if (!report_id || !target_type || !target_id || !component_id || !cell_id) {
return res.status(400).json({
success: false,
message: "필수 필드 누락",
});
}
const pool = getPool();
// value가 빈 문자열이면 DELETE (오버라이드 해제)
if (value === "" || value === null || value === undefined) {
await pool.query(
`DELETE FROM report_cell_values
WHERE company_code = $1 AND report_id = $2 AND target_type = $3
AND target_id = $4 AND component_id = $5 AND cell_id = $6`,
[companyCode, report_id, target_type, target_id, component_id, cell_id],
);
return res.json({ success: true, data: null });
}
const result = await pool.query(
`INSERT INTO report_cell_values
(id, company_code, report_id, target_type, target_id, component_id, cell_id, value, created_by, updated_by)
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $8)
ON CONFLICT (company_code, report_id, target_type, target_id, component_id, cell_id)
DO UPDATE SET value = EXCLUDED.value, updated_at = CURRENT_TIMESTAMP, updated_by = EXCLUDED.updated_by
RETURNING *`,
[
companyCode,
report_id,
target_type,
target_id,
component_id,
cell_id,
value,
userId,
],
);
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 });
}
}
@@ -67,6 +67,7 @@ export const getCategoryValues = async (req: AuthenticatedRequest, res: Response
const includeInactive = req.query.includeInactive === "true";
const menuObjid = req.query.menuObjid ? Number(req.query.menuObjid) : undefined;
const filterCompanyCode = req.query.filterCompanyCode as string | undefined;
const topLevelOnly = req.query.topLevelOnly === "true";
// 최고관리자가 특정 회사 기준 필터링을 요청한 경우 해당 회사 코드 사용
const effectiveCompanyCode = (userCompanyCode === "*" && filterCompanyCode)
@@ -86,7 +87,8 @@ export const getCategoryValues = async (req: AuthenticatedRequest, res: Response
columnName,
effectiveCompanyCode,
includeInactive,
menuObjid
menuObjid,
topLevelOnly
);
return res.json({
@@ -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 { /* 이미 존재하거나 권한 문제 시 무시 */ }
}
@@ -23,7 +29,12 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
try {
await ensureDetailRoutingColumn();
const companyCode = req.user!.companyCode;
const { dateFrom, dateTo, status, progressStatus, keyword } = req.query;
const { dateFrom, dateTo, status, progressStatus, keyword, page, pageSize } = req.query;
// 페이지네이션 파라미터 파싱 (page 없으면 전체 반환 — 하위호환)
const pageNum = page ? Math.max(1, parseInt(page as string, 10) || 1) : null;
const sizeNum = pageSize ? Math.max(1, Math.min(1000, parseInt(pageSize as string, 10) || 20)) : null;
const paginated = pageNum !== null && sizeNum !== null;
const conditions: string[] = [];
const params: any[] = [];
@@ -54,14 +65,115 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
params.push(progressStatus);
idx++;
}
// keyword 검색: wi 자체 필드 + detail.item_number 존재 여부로 EXISTS
if (keyword) {
conditions.push(`(wi.work_instruction_no ILIKE $${idx} OR wi.worker ILIKE $${idx} OR COALESCE(itm.item_name,'') ILIKE $${idx} OR COALESCE(d.item_number,'') ILIKE $${idx})`);
conditions.push(`(
wi.work_instruction_no ILIKE $${idx}
OR wi.worker ILIKE $${idx}
OR EXISTS (
SELECT 1 FROM work_instruction_detail dd
LEFT JOIN item_info ii ON ii.item_number = dd.item_number AND ii.company_code = wi.company_code
WHERE dd.work_instruction_id = wi.id
AND (dd.item_number ILIKE $${idx} OR COALESCE(ii.item_name,'') ILIKE $${idx})
)
)`);
params.push(`%${keyword}%`);
idx++;
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const pool = getPool();
// 페이지네이션 모드: WI 단위로 페이지 잘라낸 뒤 detail과 JOIN
if (paginated) {
// 1) 총 WI 개수 카운트
const countSql = `
SELECT COUNT(*)::int AS cnt
FROM work_instruction wi
${whereClause}
`;
const countRes = await pool.query(countSql, params);
const totalCount = countRes.rows[0]?.cnt ?? 0;
// 2) 현재 페이지 WI id 목록
const offset = (pageNum! - 1) * sizeNum!;
const pageSql = `
SELECT wi.id
FROM work_instruction wi
${whereClause}
ORDER BY wi.created_date DESC, wi.id DESC
LIMIT ${sizeNum} OFFSET ${offset}
`;
const pageRes = await pool.query(pageSql, params);
const wiIds = pageRes.rows.map((r) => r.id);
if (wiIds.length === 0) {
return res.json({ success: true, data: [], totalCount, page: pageNum, pageSize: sizeNum });
}
// 3) 해당 WI들의 detail + 품목/설비/라우팅 JOIN
const dataSql = `
SELECT
wi.id AS wi_id,
wi.work_instruction_no,
wi.status,
wi.progress_status,
wi.qty AS total_qty,
wi.completed_qty,
wi.start_date,
wi.end_date,
wi.equipment_id,
wi.work_team,
wi.worker,
wi.remark AS wi_remark,
wi.created_date,
d.id AS detail_id,
d.item_number,
d.qty AS detail_qty,
d.remark AS detail_remark,
d.part_code,
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,
COALESCE(e.equipment_name, '') AS equipment_name,
COALESCE(e.equipment_code, '') AS equipment_code,
wi.routing AS routing_version_id,
COALESCE(rv.version_name, '') AS routing_name,
ROW_NUMBER() OVER (PARTITION BY wi.work_instruction_no ORDER BY d.created_date) AS detail_seq,
COUNT(*) OVER (PARTITION BY wi.work_instruction_no) AS detail_count
FROM work_instruction wi
INNER JOIN work_instruction_detail d
ON d.work_instruction_id = wi.id
LEFT JOIN item_info itm
ON itm.item_number = d.item_number AND itm.company_code = wi.company_code
LEFT JOIN equipment_mng e
ON wi.equipment_id = e.id AND wi.company_code = e.company_code
LEFT JOIN item_routing_version rv
ON wi.routing = rv.id AND rv.company_code = wi.company_code
WHERE wi.id = ANY($1::varchar[])
ORDER BY wi.created_date DESC, wi.id DESC, d.created_date ASC
`;
const dataRes = await pool.query(dataSql, [wiIds]);
return res.json({
success: true,
data: dataRes.rows,
totalCount,
page: pageNum,
pageSize: sizeNum,
});
}
// 비페이지 모드 (하위호환): 기존 방식 유지, LATERAL만 LEFT JOIN으로 교체
const query = `
SELECT
wi.id AS wi_id,
@@ -85,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,
@@ -97,17 +214,14 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
FROM work_instruction wi
INNER JOIN work_instruction_detail d
ON d.work_instruction_id = wi.id
LEFT JOIN LATERAL (
SELECT item_name, size, type FROM item_info
WHERE item_number = d.item_number AND company_code = wi.company_code LIMIT 1
) itm ON true
LEFT JOIN item_info itm
ON itm.item_number = d.item_number AND itm.company_code = wi.company_code
LEFT JOIN equipment_mng e ON wi.equipment_id = e.id AND wi.company_code = e.company_code
LEFT JOIN item_routing_version rv ON wi.routing = rv.id AND rv.company_code = wi.company_code
${whereClause}
ORDER BY wi.created_date DESC, d.created_date ASC
`;
const pool = getPool();
const result = await pool.query(query, params);
return res.json({ success: true, data: result.rows });
} catch (error: any) {
@@ -195,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,
]
);
}
@@ -296,7 +427,30 @@ export async function getProductionPlanSource(req: AuthenticatedRequest, res: Re
const pool = getPool();
const cnt = await pool.query(`SELECT COUNT(*) AS total FROM production_plan_mng p WHERE ${w}`, params);
params.push(pageSize, offset);
const rows = await pool.query(`SELECT p.id, p.plan_no, p.item_code, COALESCE(p.item_name,'') AS item_name, COALESCE(p.plan_qty,0) AS plan_qty, p.start_date, p.end_date, p.status, COALESCE(p.equipment_name,'') AS equipment_name FROM production_plan_mng p WHERE ${w} ORDER BY p.created_date DESC LIMIT $${idx} OFFSET $${idx+1}`, params);
// work_instruction_detail에서 해당 계획에 이미 내린 작업지시 수량 합계 → applied_qty, remain_qty
const rows = await pool.query(
`SELECT p.id, p.plan_no, p.item_code,
COALESCE(p.item_name,'') AS item_name,
COALESCE(p.plan_qty,0) AS plan_qty,
p.start_date, p.end_date, p.status,
COALESCE(p.equipment_name,'') AS equipment_name,
COALESCE(wi.applied_qty, 0) AS applied_qty,
(COALESCE(CAST(NULLIF(p.plan_qty::text, '') AS numeric), 0)
- COALESCE(wi.applied_qty, 0)) AS remain_qty
FROM production_plan_mng p
LEFT JOIN (
SELECT source_id,
SUM(COALESCE(CAST(NULLIF(qty, '') AS numeric), 0)) AS applied_qty
FROM work_instruction_detail
WHERE source_table = 'production_plan_mng'
AND company_code = $1
GROUP BY source_id
) wi ON wi.source_id = p.id::text
WHERE ${w}
ORDER BY p.created_date DESC
LIMIT $${idx} OFFSET $${idx+1}`,
params,
);
return res.json({ success: true, data: rows.rows, totalCount: parseInt(cnt.rows[0].total), page, pageSize });
} catch (error: any) { return res.status(500).json({ success: false, message: error.message }); }
}
+2
View File
@@ -11,6 +11,7 @@ import {
toggleMenuStatus, // 메뉴 상태 토글
copyMenu, // 메뉴 복사
getUserList,
getUserNameMap, // 사용자 ID→이름 맵 (경량)
getUserInfo, // 사용자 상세 조회
getUserHistory, // 사용자 변경이력 조회
changeUserStatus, // 사용자 상태 변경
@@ -70,6 +71,7 @@ router.delete("/menus/:menuId", deleteMenu); // 메뉴 삭제
// 사용자 관리 API
router.get("/users", getUserList);
router.get("/users/name-map", getUserNameMap); // 사용자 ID→이름 매핑 (경량)
router.get("/users/:userId", getUserInfo); // 사용자 상세 조회
router.get("/users/:userId/history", getUserHistory); // 사용자 변경이력 조회
router.get("/users/:userId/with-dept", getUserWithDept); // 사원 + 부서 조회 (NEW!)
@@ -0,0 +1,30 @@
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import * as ctrl from "../controllers/outsourcingOutboundController";
const router = Router();
router.use(authenticateToken);
// 외주출고 대상 자동 조회
router.get("/candidates", ctrl.getCandidates);
// 외주출고 목록 조회
router.get("/list", ctrl.getList);
// 외주출고번호 자동생성
router.get("/generate-number", ctrl.generateNumber);
// 창고 목록
router.get("/warehouses", ctrl.getWarehouses);
// 외주출고 등록
router.post("/", ctrl.create);
// 외주출고 수정
router.put("/:id", ctrl.update);
// 외주출고 삭제
router.delete("/:id", ctrl.deleteOutbound);
export default router;
@@ -0,0 +1,12 @@
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import * as controller from "../controllers/reportCellValueController";
const router = Router();
router.use(authenticateToken);
router.get("/", controller.getList);
router.post("/", controller.upsert);
export default router;
+2 -1
View File
@@ -60,8 +60,9 @@ export async function getBomHeader(bomId: string, tableName?: string) {
const sql = `
SELECT b.*,
i.item_name, i.item_number, i.division as item_type,
COALESCE(b.unit, i.unit) as unit,
COALESCE(NULLIF(b.unit, ''), NULLIF(i.unit, ''), NULLIF(i.inventory_unit, '')) as unit,
i.unit as item_unit,
i.inventory_unit as item_inventory_unit,
i.division, i.size, i.material
FROM ${table} b
LEFT JOIN item_info i ON b.item_id = i.id
@@ -223,13 +223,14 @@ class CategoryTreeService {
const query = `
INSERT INTO category_values (
table_name, column_name, value_code, value_label, value_order,
value_id, table_name, column_name, value_code, value_label, value_order,
parent_value_id, depth, path, description, color, icon,
is_active, is_default, company_code, created_by, updated_by
) VALUES (
(SELECT COALESCE(MAX(value_id), 0) + 1 FROM category_values),
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $15
)
RETURNING
RETURNING
value_id AS "valueId",
table_name AS "tableName",
column_name AS "columnName",
@@ -694,13 +694,16 @@ export async function mergeSchedules(
[companyCode, ...scheduleIds]
);
// 병합된 스케줄 생성
// 병합된 스케줄 생성 (PP-YYYYMMDD-NNNN 형식)
const todayStr = new Date().toISOString().split("T")[0].replace(/-/g, "");
const planNoResult = await client.query(
`SELECT COALESCE(MAX(CAST(REPLACE(plan_no, 'PP-', '') AS INTEGER)), 0) + 1 AS next_no
FROM production_plan_mng WHERE company_code = $1`,
[companyCode]
`SELECT COUNT(*) + 1 AS next_no
FROM production_plan_mng
WHERE company_code = $1 AND plan_no LIKE $2`,
[companyCode, `PP-${todayStr}-%`]
);
const planNo = `PP-${String(planNoResult.rows[0].next_no || 1).padStart(6, "0")}`;
const nextNo = parseInt(planNoResult.rows[0].next_no, 10) || 1;
const planNo = `PP-${todayStr}-${String(nextNo).padStart(4, "0")}`;
const insertResult = await client.query(
`INSERT INTO production_plan_mng (
@@ -1017,13 +1020,16 @@ export async function splitSchedule(
[originalQty - splitQty, splitBy, planId, companyCode]
);
// 분할된 새 계획 생성
// 분할된 새 계획 생성 (PP-YYYYMMDD-NNNN 형식)
const todayStr = new Date().toISOString().split("T")[0].replace(/-/g, "");
const planNoResult = await client.query(
`SELECT COALESCE(MAX(CAST(REPLACE(plan_no, 'PP-', '') AS INTEGER)), 0) + 1 AS next_no
FROM production_plan_mng WHERE company_code = $1`,
[companyCode]
`SELECT COUNT(*) + 1 AS next_no
FROM production_plan_mng
WHERE company_code = $1 AND plan_no LIKE $2`,
[companyCode, `PP-${todayStr}-%`]
);
const planNo = `PP-${String(planNoResult.rows[0].next_no || 1).padStart(6, "0")}`;
const nextNo = parseInt(planNoResult.rows[0].next_no, 10) || 1;
const planNo = `PP-${todayStr}-${String(nextNo).padStart(4, "0")}`;
const insertResult = await client.query(
`INSERT INTO production_plan_mng (
+8 -3
View File
@@ -884,18 +884,23 @@ export class ReportService {
menuObjid: number,
companyCode: string
): Promise<{ items: ReportMaster[]; total: number }> {
// 매핑 없는 리포트(글로벌)는 어느 메뉴에서나 보이고,
// 매핑 있는 리포트는 해당 menu_objid에 매핑된 경우에만 보임.
const companyFilter = companyCode !== "*" ? " AND rm.company_code = $2" : "";
const params = companyCode !== "*" ? [menuObjid, companyCode] : [menuObjid];
const items = await query<ReportMaster>(
`SELECT rm.report_id, rm.report_name_kor, rm.report_name_eng,
`SELECT DISTINCT rm.report_id, rm.report_name_kor, rm.report_name_eng,
rm.template_id, rt.template_name_kor AS template_name,
rm.report_type, rm.company_code, rm.description, rm.use_yn,
rm.created_at, rm.created_by, rm.updated_at, rm.updated_by
FROM report_master rm
JOIN report_menu_mapping rmm ON rm.report_id = rmm.report_id
LEFT JOIN report_template rt ON rm.template_id = rt.template_id
WHERE rmm.menu_objid = $1 AND rm.use_yn = 'Y'${companyFilter}
WHERE rm.use_yn = 'Y'${companyFilter}
AND (
NOT EXISTS (SELECT 1 FROM report_menu_mapping WHERE report_id = rm.report_id)
OR EXISTS (SELECT 1 FROM report_menu_mapping WHERE report_id = rm.report_id AND menu_objid = $1)
)
ORDER BY rm.report_name_kor ASC`,
params
);
@@ -167,7 +167,8 @@ class TableCategoryValueService {
columnName: string,
companyCode: string,
includeInactive: boolean = false,
menuObjid?: number
menuObjid?: number,
topLevelOnly: boolean = false
): Promise<TableCategoryValue[]> {
try {
logger.info("카테고리 값 목록 조회 (메뉴 스코프)", {
@@ -235,6 +236,10 @@ class TableCategoryValueService {
query += ` AND is_active = true`;
}
if (topLevelOnly) {
query += ` AND (depth = 1 OR depth IS NULL OR parent_value_id IS NULL)`;
}
query += ` ORDER BY value_order, value_label`;
const result = await pool.query(query, params);
+130
View File
@@ -0,0 +1,130 @@
import type { PoolClient } from "pg";
export interface AdjustInventoryParams {
companyCode: string;
userId: string;
itemCode: string;
whCode: string | null;
locCode: string | null;
delta: number;
transactionType: string;
remark: string;
validateStockEnough?: boolean;
}
export async function adjustInventory(
client: PoolClient,
params: AdjustInventoryParams,
): Promise<void> {
const {
companyCode,
userId,
itemCode,
whCode,
locCode,
delta,
transactionType,
remark,
validateStockEnough,
} = params;
if (!itemCode || delta === 0) return;
if (validateStockEnough && delta < 0) {
const stockRes = await client.query(
`SELECT COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) AS cur
FROM inventory_stock
WHERE company_code = $1 AND item_code = $2
AND COALESCE(warehouse_code, '') = COALESCE($3, '')
AND COALESCE(location_code, '') = COALESCE($4, '')
LIMIT 1`,
[companyCode, itemCode, whCode || "", locCode || ""],
);
const cur = parseFloat(stockRes.rows[0]?.cur || "0");
if (cur + delta < 0) {
throw new Error(
`재고 부족: 품목 ${itemCode} (창고 ${whCode || "미지정"}) — 현재 재고 ${cur}, 차감 요청 ${-delta}`,
);
}
}
const existing = await client.query(
`SELECT id FROM inventory_stock
WHERE company_code = $1 AND item_code = $2
AND COALESCE(warehouse_code, '') = COALESCE($3, '')
AND COALESCE(location_code, '') = COALESCE($4, '')
LIMIT 1`,
[companyCode, itemCode, whCode || "", locCode || ""],
);
if (existing.rows.length > 0) {
if (delta >= 0) {
await client.query(
`UPDATE inventory_stock
SET current_qty = CAST(COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) + $1 AS text),
last_in_date = NOW(),
updated_date = NOW()
WHERE id = $2`,
[delta, existing.rows[0].id],
);
} else {
await client.query(
`UPDATE inventory_stock
SET current_qty = CAST(GREATEST(COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) + $1, 0) AS text),
last_out_date = NOW(),
updated_date = NOW()
WHERE id = $2`,
[delta, existing.rows[0].id],
);
}
} else {
const initQty = Math.max(delta, 0);
await client.query(
`INSERT INTO inventory_stock (
id, company_code, item_code, warehouse_code, location_code,
current_qty, safety_qty, last_in_date, last_out_date,
created_date, updated_date, writer
) VALUES (
gen_random_uuid()::text, $1, $2, $3, $4,
$5, '0',
${delta > 0 ? "NOW()" : "NULL"},
${delta < 0 ? "NOW()" : "NULL"},
NOW(), NOW(), $6
)`,
[companyCode, itemCode, whCode, locCode, String(initQty), userId],
);
}
const afterRes = await client.query(
`SELECT current_qty FROM inventory_stock
WHERE company_code = $1 AND item_code = $2
AND COALESCE(warehouse_code, '') = COALESCE($3, '')
AND COALESCE(location_code, '') = COALESCE($4, '')
LIMIT 1`,
[companyCode, itemCode, whCode || "", locCode || ""],
);
const afterQty = afterRes.rows[0]?.current_qty || "0";
await client.query(
`INSERT INTO inventory_history (
id, company_code, item_code, warehouse_code, location_code,
transaction_type, transaction_date, quantity, balance_qty, remark,
writer, created_date
) VALUES (
gen_random_uuid()::text, $1, $2, $3, $4,
$5, NOW(), $6, $7, $8,
$9, NOW()
)`,
[
companyCode,
itemCode,
whCode,
locCode,
transactionType,
(delta > 0 ? "+" : "") + String(delta),
afterQty,
remark,
userId,
],
);
}
@@ -86,6 +86,7 @@ export default function EquipmentInfoPage() {
const [inspectionForm, setInspectionForm] = useState<Record<string, any>>({});
const [inspectionContinuous, setInspectionContinuous] = useState(false);
const [inspectionEditMode, setInspectionEditMode] = useState(false);
const [checkedInspectionIds, setCheckedInspectionIds] = useState<Set<string>>(new Set());
// 소모품 추가/수정 모달
const [consumableModalOpen, setConsumableModalOpen] = useState(false);
@@ -93,6 +94,7 @@ export default function EquipmentInfoPage() {
const [consumableContinuous, setConsumableContinuous] = useState(false);
const [consumableEditMode, setConsumableEditMode] = useState(false);
const [consumableItemOptions, setConsumableItemOptions] = useState<any[]>([]);
const [checkedConsumableIds, setCheckedConsumableIds] = useState<Set<string>>(new Set());
// 점검항목 복사
const [copyModalOpen, setCopyModalOpen] = useState(false);
@@ -147,17 +149,17 @@ export default function EquipmentInfoPage() {
const colProps: Record<string, Partial<EDataTableColumn>> = {
equipment_code: { width: "w-[110px]" },
equipment_name: { minWidth: "min-w-[130px]", truncate: true, render: (v) => v || "-" },
equipment_type: { width: "w-[90px]", render: (v) => v || "-" },
equipment_type: { width: "w-[90px]", render: (v) => resolve("equipment_type", v) || v || "-" },
manufacturer: { width: "w-[100px]", render: (v) => v || "-" },
installation_location: { width: "w-[100px]", render: (v) => v || "-" },
operation_status: { width: "w-[80px]", render: (v) => v || "-" },
operation_status: { width: "w-[80px]", render: (v) => resolve("operation_status", v) || v || "-" },
};
return ts.visibleColumns.map((col) => ({
key: col.key,
label: col.label,
...colProps[col.key],
}));
}, [ts.visibleColumns]);
}, [ts.visibleColumns, catOptions]);
// 설비 조회
const fetchEquipments = useCallback(async () => {
@@ -165,16 +167,12 @@ export default function EquipmentInfoPage() {
try {
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
const res = await apiClient.post(`/table-management/tables/${EQUIP_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const raw = res.data?.data?.data || res.data?.data?.rows || [];
setEquipments(raw.map((r: any) => ({
...r,
equipment_type: resolve("equipment_type", r.equipment_type),
operation_status: resolve("operation_status", r.operation_status),
})));
setEquipments(raw);
setEquipCount(res.data?.data?.total || raw.length);
} catch { toast.error("설비 목록 조회 실패"); } finally { setEquipLoading(false); }
}, [searchFilters, catOptions]);
@@ -204,12 +202,13 @@ export default function EquipmentInfoPage() {
// 우측: 점검항목 조회
useEffect(() => {
setCheckedInspectionIds(new Set());
if (!selectedEquip?.equipment_code) { setInspections([]); return; }
const fetchData = async () => {
setInspectionLoading(true);
try {
const res = await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "equals", value: selectedEquip.equipment_code }] },
autoFilter: true,
});
@@ -221,12 +220,13 @@ export default function EquipmentInfoPage() {
// 우측: 소모품 조회
useEffect(() => {
setCheckedConsumableIds(new Set());
if (!selectedEquip?.equipment_code) { setConsumables([]); return; }
const fetchData = async () => {
setConsumableLoading(true);
try {
const res = await apiClient.post(`/table-management/tables/${CONSUMABLE_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "equals", value: selectedEquip.equipment_code }] },
autoFilter: true,
});
@@ -296,6 +296,34 @@ export default function EquipmentInfoPage() {
} catch { toast.error("삭제 실패"); }
};
// 점검항목 삭제
const handleInspectionDelete = async () => {
const ids = Array.from(checkedInspectionIds);
if (ids.length === 0) { toast.error("삭제할 점검항목을 선택해주세요."); return; }
const ok = await confirm(`선택한 ${ids.length}건의 점검항목을 삭제하시겠습니까?`, { variant: "destructive", confirmText: "삭제" });
if (!ok) return;
try {
await apiClient.delete(`/table-management/tables/${INSPECTION_TABLE}/delete`, { data: ids.map((id) => ({ id })) });
toast.success("삭제되었습니다.");
setCheckedInspectionIds(new Set());
refreshRight();
} catch { toast.error("삭제 실패"); }
};
// 소모품 삭제
const handleConsumableDelete = async () => {
const ids = Array.from(checkedConsumableIds);
if (ids.length === 0) { toast.error("삭제할 소모품을 선택해주세요."); return; }
const ok = await confirm(`선택한 ${ids.length}건의 소모품을 삭제하시겠습니까?`, { variant: "destructive", confirmText: "삭제" });
if (!ok) return;
try {
await apiClient.delete(`/table-management/tables/${CONSUMABLE_TABLE}/delete`, { data: ids.map((id) => ({ id })) });
toast.success("삭제되었습니다.");
setCheckedConsumableIds(new Set());
refreshRight();
} catch { toast.error("삭제 실패"); }
};
// 점검항목 추가
const handleInspectionSave = async () => {
if (!inspectionForm.inspection_item) { toast.error("점검항목명은 필수입니다."); return; }
@@ -362,7 +390,7 @@ export default function EquipmentInfoPage() {
if (consumableDiv) filters.push({ columnName: "division", operator: "equals", value: consumableDiv.valueCode });
const results = await Promise.all(filters.map((f) =>
apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [f] },
autoFilter: true,
})
@@ -409,7 +437,7 @@ export default function EquipmentInfoPage() {
setCopyLoading(true);
try {
const res = await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "equals", value: equipCode }] },
autoFilter: true,
});
@@ -437,9 +465,9 @@ export default function EquipmentInfoPage() {
const handleExcelDownload = async () => {
if (equipments.length === 0) return;
await exportToExcel(equipments.map((e) => ({
설비코드: e.equipment_code, 설비명: e.equipment_name, 설비유형: e.equipment_type,
설비코드: e.equipment_code, 설비명: e.equipment_name, 설비유형: resolve("equipment_type", e.equipment_type),
제조사: e.manufacturer, 모델명: e.model_name, 설치장소: e.installation_location,
도입일자: e.introduction_date, 가동상태: e.operation_status,
도입일자: e.introduction_date, 가동상태: resolve("operation_status", e.operation_status),
})), "설비정보.xlsx", "설비");
toast.success("다운로드 완료");
};
@@ -550,15 +578,23 @@ export default function EquipmentInfoPage() {
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setInspectionForm({}); setInspectionEditMode(false); setInspectionModalOpen(true); }}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" disabled={checkedInspectionIds.size === 0} onClick={handleInspectionDelete} className="text-destructive border-destructive/20 bg-destructive/5 hover:bg-destructive/10">
<Trash2 className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setCopySourceEquip(""); setCopyItems([]); setCopyChecked(new Set()); setCopyModalOpen(true); }}>
<Copy className="w-3.5 h-3.5 mr-1" />
</Button>
</>
)}
{rightTab === "consumable" && (
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setConsumableForm({}); setConsumableEditMode(false); loadConsumableItems(); setConsumableModalOpen(true); }}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
<>
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setConsumableForm({}); setConsumableEditMode(false); loadConsumableItems(); setConsumableModalOpen(true); }}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" disabled={checkedConsumableIds.size === 0} onClick={handleConsumableDelete} className="text-destructive border-destructive/20 bg-destructive/5 hover:bg-destructive/10">
<Trash2 className="w-3.5 h-3.5 mr-1" />
</Button>
</>
)}
</div>
</div>
@@ -637,6 +673,16 @@ export default function EquipmentInfoPage() {
<Table noWrapper>
<thead className="sticky top-0 z-10 bg-card">
<TableRow>
<TableHead
className="w-[40px] text-center cursor-pointer"
onClick={() => {
const allChecked = inspections.length > 0 && checkedInspectionIds.size === inspections.length;
if (allChecked) setCheckedInspectionIds(new Set());
else setCheckedInspectionIds(new Set(inspections.map((i) => i.id)));
}}
>
<Checkbox checked={inspections.length > 0 && checkedInspectionIds.size === inspections.length} />
</TableHead>
<TableHead className="min-w-[120px] 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-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
@@ -664,6 +710,20 @@ export default function EquipmentInfoPage() {
setInspectionEditMode(true);
setInspectionModalOpen(true);
}}>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedInspectionIds((prev) => {
const next = new Set(prev);
if (next.has(item.id)) next.delete(item.id); else next.add(item.id);
return next;
});
}}
onDoubleClick={(e) => e.stopPropagation()}
>
<Checkbox checked={checkedInspectionIds.has(item.id)} />
</TableCell>
<TableCell className="text-sm">{item.inspection_item || "-"}</TableCell>
<TableCell className="text-[13px]">{resolve("inspection_cycle", item.inspection_cycle)}</TableCell>
<TableCell className="text-[13px]">{resolve("inspection_method", item.inspection_method)}</TableCell>
@@ -692,6 +752,16 @@ export default function EquipmentInfoPage() {
<Table noWrapper>
<thead className="sticky top-0 z-10 bg-card">
<TableRow>
<TableHead
className="w-[40px] text-center cursor-pointer"
onClick={() => {
const allChecked = consumables.length > 0 && checkedConsumableIds.size === consumables.length;
if (allChecked) setCheckedConsumableIds(new Set());
else setCheckedConsumableIds(new Set(consumables.map((i) => i.id)));
}}
>
<Checkbox checked={consumables.length > 0 && checkedConsumableIds.size === consumables.length} />
</TableHead>
<TableHead className="min-w-[120px] 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-[60px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
@@ -707,6 +777,20 @@ export default function EquipmentInfoPage() {
loadConsumableItems();
setConsumableModalOpen(true);
}}>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedConsumableIds((prev) => {
const next = new Set(prev);
if (next.has(item.id)) next.delete(item.id); else next.add(item.id);
return next;
});
}}
onDoubleClick={(e) => e.stopPropagation()}
>
<Checkbox checked={checkedConsumableIds.has(item.id)} />
</TableCell>
<TableCell className="text-sm">{item.consumable_name || "-"}</TableCell>
<TableCell className="text-[13px]">{item.replacement_cycle || "-"}</TableCell>
<TableCell className="text-[13px]">{item.unit || "-"}</TableCell>
@@ -83,7 +83,7 @@ export default function EquipmentInspectionRecordPage() {
}).catch(() => ({ data: { data: { data: [] } } })),
apiClient.post(`/table-management/tables/equipment_mng/data`, {
page: 1,
size: 500,
size: 0,
autoFilter: true,
}).catch(() => ({ data: { data: { data: [] } } })),
]);
@@ -100,7 +100,7 @@ export default function PlcSettingsPage() {
useEffect(() => {
const load = async () => {
try {
const eqRes = await apiClient.post(`/table-management/tables/${EQUIPMENT_TABLE}/data`, { page: 1, size: 500, autoFilter: true });
const eqRes = await apiClient.post(`/table-management/tables/${EQUIPMENT_TABLE}/data`, { page: 1, size: 0, autoFilter: true });
const eqs = eqRes.data?.data?.data || eqRes.data?.data?.rows || [];
setEquipOptions(eqs.map((r: any) => ({ code: r.equipment_code, label: `${r.equipment_code} - ${r.equipment_name || ""}` })));
} catch { /* skip */ }
@@ -122,7 +122,7 @@ export default function PlcSettingsPage() {
const filters: any[] = [];
if (kw.trim()) filters.push({ columnName: "equipment_code", operator: "contains", value: kw.trim() });
const res = await apiClient.post(`/table-management/tables/${DATATYPE_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
@@ -140,7 +140,7 @@ export default function PlcSettingsPage() {
const filters: any[] = [];
if (kw.trim()) filters.push({ columnName: "config_name", operator: "contains", value: kw.trim() });
const res = await apiClient.post(`/table-management/tables/${COLLECTION_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
@@ -150,7 +150,7 @@ export default function InboundOutboundPage() {
if (writerIds.length > 0) {
try {
const userRes = await apiClient.post(`/table-management/tables/user_info/data`, {
page: 1, size: 500, autoFilter: true,
page: 1, size: 0, autoFilter: true,
});
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
const uMap: Record<string, string> = {};
@@ -327,11 +327,11 @@ export default function LogisticsInfoPage() {
try {
const [carrierRes, routeRes] = await Promise.all([
apiClient.post("/table-management/tables/carrier_mng/data", {
page: 1, size: 500, autoFilter: true,
page: 1, size: 0, autoFilter: true,
sort: { columnName: "carrier_code", order: "asc" },
}),
apiClient.post("/table-management/tables/delivery_route_mng/data", {
page: 1, size: 500, autoFilter: true,
page: 1, size: 0, autoFilter: true,
sort: { columnName: "route_code", order: "asc" },
}),
]);
@@ -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,
@@ -393,7 +395,7 @@ export default function LogisticsInfoPage() {
const res = await apiClient.post(
`/table-management/tables/${config.tableName}/data`,
{
page: 1, size: 500, autoFilter: true,
page: 1, size: 0, autoFilter: true,
sort: { columnName: config.defaultSortColumn, order: "asc" },
}
);
@@ -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]}
@@ -13,6 +13,7 @@ import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
@@ -68,6 +69,7 @@ const STOCK_TABLE = "inventory_stock";
const STOCK_COLUMNS = [
{ key: "item_code", label: "품목코드" },
{ key: "item_name", label: "품명" },
{ key: "spec", label: "규격" },
{ key: "warehouse_code", label: "창고" },
{ key: "location_code", label: "위치" },
{ key: "current_qty", label: "현재수량", align: "right" as const },
@@ -87,6 +89,8 @@ const getStatusVariant = (
return "destructive";
case "과잉":
return "secondary";
case "미등록":
return "outline";
default:
return "outline";
}
@@ -119,6 +123,15 @@ export default function InventoryStatusPage() {
const [stockLoading, setStockLoading] = useState(false);
const [selectedStockId, setSelectedStockId] = useState<string | null>(null);
// 재고 없는 품목 표시 여부
const [showMissingItems, setShowMissingItems] = useState(false);
// 창고 목록 (조정 모달에서 사용)
const [warehouseList, setWarehouseList] = useState<{ code: string; name: string }[]>([]);
// 선택된 창고의 위치 목록 (조정 모달에서 사용)
const [locationList, setLocationList] = useState<{ code: string; name: string }[]>([]);
// 검색 필터
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
@@ -132,7 +145,9 @@ export default function InventoryStatusPage() {
adjust_type: string;
adjust_qty: string;
reason: string;
}>({ adjust_type: "증가", adjust_qty: "", reason: "" });
warehouse_code: string;
location_code: string;
}>({ adjust_type: "증가", adjust_qty: "", reason: "", warehouse_code: "", location_code: "" });
const [adjustSaving, setAdjustSaving] = useState(false);
// 카테고리 옵션
@@ -171,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);
@@ -190,19 +205,20 @@ export default function InventoryStatusPage() {
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
const [stockRes, itemRes, whRes] = await Promise.all([
apiClient.post(`/table-management/tables/${STOCK_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
sort: { columnName: "item_code", order: "asc" },
}),
apiClient.post(`/table-management/tables/item_info/data`, { page: 1, size: 500, autoFilter: true }),
apiClient.post(`/table-management/tables/warehouse_info/data`, { page: 1, size: 500, autoFilter: true }),
apiClient.post(`/table-management/tables/item_info/data`, { page: 1, size: 0, autoFilter: true }),
apiClient.post(`/table-management/tables/warehouse_info/data`, { page: 1, size: 0, autoFilter: true }),
]);
const raw = stockRes.data?.data?.data || stockRes.data?.data?.rows || [];
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
const warehouses = whRes.data?.data?.data || whRes.data?.data?.rows || [];
const itemMap = new Map(items.map((i: any) => [i.item_number || i.item_code, { name: i.item_name || "", unit: i.inventory_unit || "" }]));
const itemMap = new Map(items.map((i: any) => [i.item_number || i.item_code, { name: i.item_name || "", unit: i.inventory_unit || "", spec: i.size || "" }]));
const whMap = new Map(warehouses.map((w: any) => [w.warehouse_code, w.warehouse_name || w.warehouse_code]));
setWarehouseList(warehouses.map((w: any) => ({ code: w.warehouse_code, name: w.warehouse_name || w.warehouse_code })));
const resolve = (col: string, code: string) => {
if (!code) return "";
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
@@ -213,19 +229,50 @@ export default function InventoryStatusPage() {
return {
...r,
item_name: itemInfo?.name || "",
spec: itemInfo?.spec || "",
unit: resolve("item_inventory_unit", rawUnit) || rawUnit,
warehouse_name: whMap.get(r.warehouse_code) || r.warehouse_code || "",
status: resolve("status", r.status),
_isLow: r.safety_qty && Number(r.current_qty) < Number(r.safety_qty),
};
});
setStockItems(data);
if (showMissingItems) {
const existingCodes = new Set(raw.map((r: any) => r.item_code).filter(Boolean));
const missingRows = items
.filter((i: any) => {
const code = i.item_number || i.item_code;
return code && !existingCodes.has(code);
})
.map((i: any) => {
const code = i.item_number || i.item_code;
const rawUnit = i.inventory_unit || "";
return {
id: `missing-${code}`,
item_code: code,
item_name: i.item_name || "",
spec: i.size || "",
warehouse_code: "",
warehouse_name: "",
location_code: "",
current_qty: "0",
safety_qty: "",
unit: resolve("item_inventory_unit", rawUnit) || rawUnit,
status: "미등록",
_isLow: false,
_isMissing: true,
};
});
setStockItems([...data, ...missingRows]);
} else {
setStockItems(data);
}
} catch {
toast.error("재고 목록을 불러오지 못했어요");
} finally {
setStockLoading(false);
}
}, [categoryOptions, searchFilters]);
}, [categoryOptions, searchFilters, showMissingItems]);
useEffect(() => {
fetchStock();
@@ -260,7 +307,7 @@ export default function InventoryStatusPage() {
`/table-management/tables/${HISTORY_TABLE}/data`,
{
page: 1,
size: 500,
size: 0,
dataFilter: { enabled: true, filters: historyFilters },
autoFilter: true,
sort: { columnName: "transaction_date", order: "desc" },
@@ -279,6 +326,35 @@ export default function InventoryStatusPage() {
fetchHistory();
}, [fetchHistory]);
useEffect(() => {
const whCode = adjustForm.warehouse_code;
if (!whCode) {
setLocationList([]);
return;
}
(async () => {
try {
const res = await apiClient.post(`/table-management/tables/warehouse_location/data`, {
page: 1,
size: 0,
dataFilter: {
enabled: true,
filters: [{ columnName: "warehouse_code", operator: "equals", value: whCode }],
},
autoFilter: true,
});
const rows = res.data?.data?.data || res.data?.data?.rows || [];
setLocationList(
rows
.filter((r: any) => r.location_code)
.map((r: any) => ({ code: r.location_code, name: r.location_name || r.location_code }))
);
} catch {
setLocationList([]);
}
})();
}, [adjustForm.warehouse_code]);
// 재고 조정 저장
const handleAdjustSave = async () => {
if (!selectedStock) return;
@@ -291,6 +367,20 @@ export default function InventoryStatusPage() {
toast.error("조정 사유를 입력해주세요");
return;
}
const isMissing = !!selectedStock._isMissing;
const targetWhCode = isMissing ? adjustForm.warehouse_code : (selectedStock.warehouse_code || "");
const targetLocCode = isMissing ? adjustForm.location_code : (selectedStock.location_code || "");
if (isMissing && !targetWhCode) {
toast.error("창고를 선택해주세요");
return;
}
if (isMissing && adjustForm.adjust_type === "감소") {
toast.error("미등록 품목은 감소 조정이 불가해요");
return;
}
setAdjustSaving(true);
try {
const changeQty = adjustForm.adjust_type === "증가" ? qty : -qty;
@@ -301,8 +391,8 @@ export default function InventoryStatusPage() {
{
id: crypto.randomUUID(),
item_code: selectedStock.item_code,
warehouse_code: selectedStock.warehouse_code || "",
location_code: selectedStock.location_code || "",
warehouse_code: targetWhCode,
location_code: targetLocCode,
transaction_type: "조정",
transaction_date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().replace("Z", "+09:00"),
quantity: String(changeQty),
@@ -311,17 +401,33 @@ export default function InventoryStatusPage() {
}
);
await apiClient.put(
`/table-management/tables/${STOCK_TABLE}/edit`,
{
originalData: { id: selectedStock.id },
updatedData: { current_qty: afterQty },
}
);
if (isMissing) {
await apiClient.post(
`/table-management/tables/${STOCK_TABLE}/add`,
{
id: crypto.randomUUID(),
item_code: selectedStock.item_code,
warehouse_code: targetWhCode,
location_code: targetLocCode,
current_qty: String(afterQty),
safety_qty: "0",
last_in_date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().replace("Z", "+09:00"),
}
);
} else {
await apiClient.put(
`/table-management/tables/${STOCK_TABLE}/edit`,
{
originalData: { id: selectedStock.id },
updatedData: { current_qty: afterQty },
}
);
}
toast.success("재고가 조정되었어요");
setAdjustModalOpen(false);
setAdjustForm({ adjust_type: "증가", adjust_qty: "", reason: "" });
setAdjustForm({ adjust_type: "증가", adjust_qty: "", reason: "", warehouse_code: "", location_code: "" });
setSelectedStockId(null);
fetchStock();
} catch {
toast.error("재고 조정에 실패했어요");
@@ -385,6 +491,7 @@ export default function InventoryStatusPage() {
stockItems.map((r) => ({
품목코드: r.item_code,
품명: r.item_name,
규격: r.spec || "",
창고: r.warehouse_name || r.warehouse_code,
위치: r.location_code,
현재수량: r.current_qty,
@@ -438,6 +545,13 @@ export default function InventoryStatusPage() {
{stockItems.length}
</Badge>
</div>
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
<Checkbox
checked={showMissingItems}
onCheckedChange={(v) => setShowMissingItems(!!v)}
/>
<span> </span>
</label>
</div>
<EDataTable
@@ -513,6 +627,8 @@ export default function InventoryStatusPage() {
adjust_type: "증가",
adjust_qty: "",
reason: "",
warehouse_code: selectedStock._isMissing ? "" : (selectedStock.warehouse_code || ""),
location_code: selectedStock._isMissing ? "" : (selectedStock.location_code || ""),
});
setAdjustModalOpen(true);
}}
@@ -672,6 +788,68 @@ export default function InventoryStatusPage() {
</DialogHeader>
<div className="grid gap-4 py-2">
{selectedStock?._isMissing && (
<>
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground">
<span className="text-destructive">*</span>
</Label>
<Select
value={adjustForm.warehouse_code}
onValueChange={(v) =>
setAdjustForm((prev) => ({
...prev,
warehouse_code: v,
location_code: "",
}))
}
>
<SelectTrigger>
<SelectValue placeholder="창고를 선택해주세요" />
</SelectTrigger>
<SelectContent>
{warehouseList.map((w) => (
<SelectItem key={w.code} value={w.code}>
{w.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground">
</Label>
<Select
value={adjustForm.location_code}
onValueChange={(v) =>
setAdjustForm((prev) => ({ ...prev, location_code: v }))
}
disabled={!adjustForm.warehouse_code || locationList.length === 0}
>
<SelectTrigger>
<SelectValue
placeholder={
!adjustForm.warehouse_code
? "창고를 먼저 선택하세요"
: locationList.length === 0
? "등록된 위치가 없어요"
: "위치 선택 (선택 사항)"
}
/>
</SelectTrigger>
<SelectContent>
{locationList.map((l) => (
<SelectItem key={l.code} value={l.code}>
{l.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
)}
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground">
@@ -681,6 +859,7 @@ export default function InventoryStatusPage() {
onValueChange={(v) =>
setAdjustForm((prev) => ({ ...prev, adjust_type: v }))
}
disabled={!!selectedStock?._isMissing}
>
<SelectTrigger>
<SelectValue placeholder="조정 유형 선택" />
@@ -690,6 +869,11 @@ export default function InventoryStatusPage() {
<SelectItem value="감소"> ( )</SelectItem>
</SelectContent>
</Select>
{selectedStock?._isMissing && (
<p className="text-[10px] text-muted-foreground">
( )
</p>
)}
</div>
<div className="grid gap-1.5">
@@ -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);
@@ -528,9 +568,9 @@ export default function PackagingPage() {
{/* 4. 콘텐츠 영역 */}
{activeTab === "packing" ? (
<div className="flex flex-1 flex-col overflow-hidden rounded-lg border bg-card">
<div className="flex flex-1 flex-row overflow-hidden rounded-lg border bg-card">
{/* 포장재 목록 테이블 */}
<div className={cn("overflow-auto", selectedPkg ? "flex-[0_0_50%] border-b" : "flex-1")}>
<div className="flex-[0_0_50%] overflow-auto border-r">
<EDataTable
columns={ts.visibleColumns.map((col): EDataTableColumn<PkgUnit> => {
const renderMap: Record<string, Partial<EDataTableColumn<PkgUnit>>> = {
@@ -570,8 +610,8 @@ export default function PackagingPage() {
</div>
{/* 매칭 품목 서브패널 */}
{selectedPkg && (
<>
{selectedPkg ? (
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex shrink-0 items-center justify-between bg-muted/50 px-4 py-2">
<div className="flex items-center gap-2">
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"> </span>
@@ -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)}>
@@ -635,14 +675,21 @@ export default function PackagingPage() {
</Table>
)}
</div>
</>
</div>
) : (
<div className="flex flex-1 items-center justify-center p-8">
<div className="text-center">
<Package className="mx-auto mb-2 h-10 w-10 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground"> </p>
</div>
</div>
)}
</div>
) : (
/* 적재함 관리 탭 */
<div className="flex flex-1 flex-col overflow-hidden rounded-lg border bg-card">
<div className="flex flex-1 flex-row overflow-hidden rounded-lg border bg-card">
{/* 적재함 목록 테이블 */}
<div className={cn("overflow-auto", selectedLoading ? "flex-[0_0_50%] border-b" : "flex-1")}>
<div className="flex-[0_0_50%] overflow-auto border-r">
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
@@ -709,8 +756,8 @@ export default function PackagingPage() {
</div>
{/* 포장구성 서브패널 */}
{selectedLoading && (
<>
{selectedLoading ? (
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex shrink-0 items-center justify-between bg-muted/50 px-4 py-2">
<div className="flex items-center gap-2">
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"> </span>
@@ -774,7 +821,14 @@ export default function PackagingPage() {
</Table>
)}
</div>
</>
</div>
) : (
<div className="flex flex-1 items-center justify-center p-8">
<div className="text-center">
<Box className="mx-auto mb-2 h-10 w-10 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground"> </p>
</div>
</div>
)}
</div>
)}
@@ -940,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>
@@ -250,6 +250,8 @@ interface SelectedSourceItem {
total_amount: number;
source_table: string;
source_id: string;
detail_id?: string;
header_id?: string;
}
export default function ReceivingPage() {
@@ -584,7 +586,7 @@ export default function ReceivingPage() {
const first = grouped[0] || row;
setEditMode(true);
setEditItemIds(grouped.map((g) => g.id));
setEditItemIds(grouped.map((g, idx) => (g as any).detail_id || `${g.id}__${idx}`));
setModalInboundNo(inNo);
setModalInboundType(first.inbound_type || "구매입고");
setModalInboundDate(first.inbound_date ? String(first.inbound_date).slice(0, 10) : "");
@@ -594,8 +596,10 @@ export default function ReceivingPage() {
setModalManager((first as any).manager || "");
setModalMemo(first.memo || "");
setSelectedItems(
grouped.map((g) => ({
key: g.id,
grouped.map((g, idx) => ({
key: (g as any).detail_id || `${g.id}__${idx}`,
detail_id: (g as any).detail_id || undefined,
header_id: g.id,
inbound_type: (g as any).detail_inbound_type || g.inbound_type || "",
reference_number: g.reference_number || "",
supplier_code: (g as any).supplier_code || "",
@@ -782,7 +786,7 @@ export default function ReceivingPage() {
await Promise.all([
...toDelete.map((id) => deleteReceiving(id)),
...toUpdate.map((item) =>
updateReceiving(item.key, {
updateReceiving(item.header_id || item.key, {
inbound_date: modalInboundDate,
inbound_qty: item.inbound_qty,
unit_price: item.unit_price,
@@ -790,6 +794,7 @@ export default function ReceivingPage() {
warehouse_code: modalWarehouse || undefined,
location_code: modalLocation || undefined,
memo: modalMemo || undefined,
detail_id: item.detail_id,
} as any)
),
...(toCreate.length > 0
@@ -74,7 +74,7 @@ const WAREHOUSE_COLUMNS = [
{ key: "warehouse_code", label: "창고코드" },
{ key: "warehouse_name", label: "창고명" },
{ key: "warehouse_type", label: "유형" },
{ key: "manager", label: "관리자" },
{ key: "manager_name", label: "관리자" },
{ key: "status", label: "상태" },
];
const LOCATION_TABLE = "warehouse_location";
@@ -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<
@@ -230,7 +234,7 @@ export default function WarehouseManagementPage() {
`/table-management/tables/${WAREHOUSE_TABLE}/data`,
{
page: 1,
size: 500,
size: 0,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
sort: { columnName: "warehouse_code", order: "asc" },
@@ -239,6 +243,8 @@ export default function WarehouseManagementPage() {
const raw = res.data?.data?.data || res.data?.data?.rows || [];
const data = raw.map((r: any) => ({
...r,
_warehouse_type_code: r.warehouse_type,
_status_code: r.status,
warehouse_type: resolveCategory(categoryOptions, "warehouse_type", r.warehouse_type),
status: resolveCategory(categoryOptions, "status", r.status),
}));
@@ -270,7 +276,7 @@ export default function WarehouseManagementPage() {
`/table-management/tables/${LOCATION_TABLE}/data`,
{
page: 1,
size: 500,
size: 0,
dataFilter: {
enabled: true,
filters: [
@@ -344,7 +350,11 @@ export default function WarehouseManagementPage() {
const openWarehouseEditModal = (row: any) => {
setWarehouseEditMode(true);
setWarehouseForm({ ...row });
setWarehouseForm({
...row,
warehouse_type: row._warehouse_type_code ?? row.warehouse_type ?? "",
status: row._status_code ?? row.status ?? "",
});
setWarehouseModalOpen(true);
};
@@ -374,10 +384,10 @@ export default function WarehouseManagementPage() {
warehouse_code: finalWarehouseCode,
warehouse_name: warehouseForm.warehouse_name?.trim(),
warehouse_type: warehouseForm.warehouse_type || "",
manager: warehouseForm.manager || "",
address: warehouseForm.address || "",
manager_name: warehouseForm.manager_name || "",
contact: warehouseForm.contact || "",
status: warehouseForm.status || "",
description: warehouseForm.description || "",
memo: warehouseForm.memo || "",
};
// 신규 등록 시 창고코드 중복 체크
@@ -630,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,
@@ -729,7 +739,7 @@ export default function WarehouseManagementPage() {
창고코드: r.warehouse_code,
창고명: r.warehouse_name,
유형: r.warehouse_type,
관리자: r.manager,
관리자: r.manager_name,
상태: r.status,
})),
"창고정보"
@@ -1041,9 +1051,9 @@ export default function WarehouseManagementPage() {
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Input
value={warehouseForm.manager || ""}
value={warehouseForm.manager_name || ""}
onChange={(e) =>
setWarehouseForm((prev) => ({ ...prev, manager: e.target.value }))
setWarehouseForm((prev) => ({ ...prev, manager_name: e.target.value }))
}
placeholder="관리자를 입력해주세요"
/>
@@ -1069,24 +1079,24 @@ export default function WarehouseManagementPage() {
</SelectContent>
</Select>
</div>
{/* 주소 (전체 너비) */}
{/* 연락처 (전체 너비) */}
<div className="grid gap-1.5 col-span-2">
<Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Input
value={warehouseForm.address || ""}
value={warehouseForm.contact || ""}
onChange={(e) =>
setWarehouseForm((prev) => ({ ...prev, address: e.target.value }))
setWarehouseForm((prev) => ({ ...prev, contact: e.target.value }))
}
placeholder="주소를 입력해주세요"
placeholder="연락처를 입력해주세요"
/>
</div>
{/* 비고 (전체 너비) */}
<div className="grid gap-1.5 col-span-2">
<Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Input
value={warehouseForm.description || ""}
value={warehouseForm.memo || ""}
onChange={(e) =>
setWarehouseForm((prev) => ({ ...prev, description: e.target.value }))
setWarehouseForm((prev) => ({ ...prev, memo: e.target.value }))
}
placeholder="비고를 입력해주세요"
/>
@@ -1496,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">
@@ -193,7 +193,7 @@ export default function CompanyPage() {
setDeptLoading(true);
try {
const res = await apiClient.post(`/table-management/tables/${DEPT_TABLE}/data`, {
page: 1, size: 500, autoFilter: true,
page: 1, size: 0, autoFilter: true,
});
const raw = res.data?.data?.data || res.data?.data?.rows || [];
setDepts(raw);
@@ -217,7 +217,7 @@ export default function CompanyPage() {
setMemberLoading(true);
try {
const res = await apiClient.post(`/table-management/tables/${USER_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [{ columnName: "dept_code", operator: "equals", value: selectedDeptCode }] },
autoFilter: true,
});
@@ -563,10 +563,6 @@ export default function CompanyPage() {
{/* 기본 정보 그리드 (2열) */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Input value={companyForm.company_code || ""} className="h-9 bg-muted/50" disabled readOnly />
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<span className="text-destructive">*</span>
@@ -99,7 +99,7 @@ export default function DepartmentPage() {
try {
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
const res = await apiClient.post(`/table-management/tables/${DEPT_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
@@ -129,7 +129,7 @@ export default function DepartmentPage() {
? [{ columnName: "dept_code", operator: "equals", value: selectedDeptCode }]
: [];
const res = await apiClient.post(`/table-management/tables/${USER_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
@@ -5,7 +5,12 @@ import { Settings2, Tags, Hash } from "lucide-react";
import { cn } from "@/lib/utils";
import { CategoryColumnList } from "@/components/table-category/CategoryColumnList";
import { CategoryValueManager } from "@/components/table-category/CategoryValueManager";
import { CategoryValueManagerTree } from "@/components/table-category/CategoryValueManagerTree";
import { NumberingRuleDesigner } from "@/components/numbering-rule/NumberingRuleDesigner";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
const TABS = [
{ id: "category", label: "카테고리 설정", icon: Tags },
@@ -21,6 +26,12 @@ export default function OptionsSettingPage() {
const [selectedColumnLabel, setSelectedColumnLabel] = useState("");
const [selectedTableName, setSelectedTableName] = useState("");
const [useHierarchy, setUseHierarchy] = useState(false);
const [hasChildRows, setHasChildRows] = useState(false);
const [detectingHierarchy, setDetectingHierarchy] = useState(false);
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
const [leftWidth, setLeftWidth] = useState(340);
const [isDragging, setIsDragging] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
@@ -51,6 +62,71 @@ export default function OptionsSettingPage() {
};
}, [isDragging]);
useEffect(() => {
if (!selectedColumn || !selectedTableName) {
setUseHierarchy(false);
setHasChildRows(false);
return;
}
const columnNameOnly = selectedColumn.includes(".")
? selectedColumn.split(".").pop()!
: selectedColumn;
let cancelled = false;
setDetectingHierarchy(true);
(async () => {
const res = await getCategoryValues(selectedTableName, columnNameOnly, true);
if (cancelled) return;
const values = (res as any)?.data || [];
const hasChild = Array.isArray(values)
? values.some(
(v: any) =>
(typeof v.depth === "number" && v.depth > 1) ||
(v.parentValueId !== null && v.parentValueId !== undefined),
)
: false;
setHasChildRows(hasChild);
setUseHierarchy(hasChild);
setDetectingHierarchy(false);
})();
return () => {
cancelled = true;
};
}, [selectedColumn, selectedTableName]);
const handleToggleHierarchy = useCallback(
async (checked: boolean) => {
if (!checked && hasChildRows) {
const ok = await confirm(
"이미 등록된 하위분류(중/소분류)가 있습니다.\n하위분류 사용을 해제해도 기존 데이터는 삭제되지 않으며, 다시 사용 설정 시 그대로 복원됩니다.\n계속하시겠습니까?",
{ variant: "destructive", confirmText: "해제" },
);
if (!ok) return;
}
setUseHierarchy(checked);
},
[hasChildRows, confirm],
);
const columnNameOnly = selectedColumn
? selectedColumn.includes(".")
? selectedColumn.split(".").pop()!
: selectedColumn
: "";
const headerRight = selectedColumn ? (
<div className="flex items-center gap-2">
<Label htmlFor="use-hierarchy-switch" className="cursor-pointer text-xs">
</Label>
<Switch
id="use-hierarchy-switch"
checked={useHierarchy}
onCheckedChange={handleToggleHierarchy}
disabled={detectingHierarchy}
/>
</div>
) : null;
return (
<div className="flex h-full flex-col p-3 gap-3">
<div className="flex items-center gap-4">
@@ -108,11 +184,21 @@ export default function OptionsSettingPage() {
<div className="flex-1 min-w-0 border rounded-lg bg-card overflow-hidden">
{selectedColumn && selectedTableName ? (
<CategoryValueManager
tableName={selectedTableName}
columnName={selectedColumn.includes(".") ? selectedColumn.split(".").pop()! : selectedColumn}
columnLabel={selectedColumnLabel}
/>
useHierarchy ? (
<CategoryValueManagerTree
tableName={selectedTableName}
columnName={columnNameOnly}
columnLabel={selectedColumnLabel}
headerRight={headerRight}
/>
) : (
<CategoryValueManager
tableName={selectedTableName}
columnName={columnNameOnly}
columnLabel={selectedColumnLabel}
headerRight={headerRight}
/>
)
) : (
<div className="flex h-full items-center justify-center">
<div className="text-center space-y-2">
@@ -131,6 +217,7 @@ export default function OptionsSettingPage() {
</div>
)}
</div>
{ConfirmDialogComponent}
</div>
);
}
@@ -72,6 +72,36 @@ export default function MoldInfoPage() {
const [selectedMoldCode, setSelectedMoldCode] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<"list" | "grid">("list");
// ─── 카테고리 옵션 (금형유형, 운영상태) ───
const [moldTypeCatOptions, setMoldTypeCatOptions] = useState<{ code: string; label: string }[]>([]);
const [operationStatusCatOptions, setOperationStatusCatOptions] = useState<{ code: string; label: string }[]>([]);
useEffect(() => {
const flatten = (arr: any[]): { code: string; label: string }[] => {
const result: { code: string; label: string }[] = [];
for (const v of arr) { result.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) result.push(...flatten(v.children)); }
return result;
};
(async () => {
try {
const [typeRes, statusRes] = await Promise.all([
apiClient.get("/table-categories/mold_mng/mold_type/values"),
apiClient.get("/table-categories/mold_mng/operation_status/values"),
]);
if (typeRes.data?.success) setMoldTypeCatOptions(flatten(typeRes.data.data || []));
if (statusRes.data?.success) setOperationStatusCatOptions(flatten(statusRes.data.data || []));
} catch { /* skip */ }
})();
}, []);
const resolveMoldType = (code: string) => moldTypeCatOptions.find((o) => o.code === code)?.label || code;
const resolveOpStatus = (code: string) => {
const catLabel = operationStatusCatOptions.find((o) => o.code === code)?.label;
if (catLabel) return catLabel;
const legacyMap: Record<string, string> = { ACTIVE: "사용중", INACTIVE: "미사용", REPAIR: "수리중", DISPOSED: "폐기", IN_USE: "사용중" };
return legacyMap[code] || code;
};
// ─── 검색 필터 ───
const [filterCode, setFilterCode] = useState("");
const [filterName, setFilterName] = useState("");
@@ -426,7 +456,7 @@ export default function MoldInfoPage() {
// ─── 카드 렌더링 ───
const renderCard = (mold: any) => {
const pct = calcLifePct(mold);
const st = STATUS_MAP[mold.operation_status] || { label: mold.operation_status || "-", variant: "secondary" as const };
const stLabel = resolveOpStatus(mold.operation_status);
const isSelected = selectedMoldCode === mold.mold_code;
return (
@@ -460,7 +490,7 @@ export default function MoldInfoPage() {
<Box className="w-8 h-8 text-muted-foreground/50" />
)}
<div className="absolute top-2 right-2">
<Badge variant={st.variant} className="text-[10px]">{st.label}</Badge>
<Badge variant="secondary" className="text-[10px]">{stLabel}</Badge>
</div>
</div>
@@ -470,7 +500,7 @@ export default function MoldInfoPage() {
<p className="text-xs text-muted-foreground font-mono truncate">{mold.mold_code}</p>
<p className="text-sm font-semibold truncate">{mold.mold_name}</p>
{mold.mold_type && (
<Badge variant="outline" className="text-[10px] mt-1">{mold.mold_type}</Badge>
<Badge variant="outline" className="text-[10px] mt-1">{resolveMoldType(mold.mold_type)}</Badge>
)}
</div>
@@ -531,10 +561,7 @@ export default function MoldInfoPage() {
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__"></SelectItem>
<SelectItem value="사출금형"></SelectItem>
<SelectItem value="프레스금형"></SelectItem>
<SelectItem value="다이캐스팅"></SelectItem>
<SelectItem value="단조금형"></SelectItem>
{moldTypeCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
@@ -546,10 +573,7 @@ export default function MoldInfoPage() {
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__"></SelectItem>
<SelectItem value="ACTIVE"></SelectItem>
<SelectItem value="INSPECTION"></SelectItem>
<SelectItem value="REPAIR"></SelectItem>
<SelectItem value="DISPOSED"></SelectItem>
{operationStatusCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
@@ -670,13 +694,13 @@ export default function MoldInfoPage() {
<h2 className="text-xl font-bold mb-2 truncate">{selectedMold.mold_name}</h2>
<div className="flex gap-1.5 mb-4 flex-wrap">
{selectedMold.mold_type && (
<Badge variant="outline">{selectedMold.mold_type}</Badge>
<Badge variant="outline">{resolveMoldType(selectedMold.mold_type)}</Badge>
)}
{selectedMold.category && (
<Badge variant="secondary">{selectedMold.category}</Badge>
)}
<Badge variant={STATUS_MAP[selectedMold.operation_status]?.variant || "secondary"}>
{STATUS_MAP[selectedMold.operation_status]?.label || selectedMold.operation_status || "-"}
<Badge variant="secondary">
{resolveOpStatus(selectedMold.operation_status) || "-"}
</Badge>
</div>
@@ -811,15 +835,15 @@ export default function MoldInfoPage() {
</TableHeader>
<TableBody>
{serials.map((s: any) => {
const ss = SERIAL_STATUS_MAP[s.status] || { label: s.status || "-", variant: "secondary" as const };
const maxShot = detail?.shot_count || 0;
const ssLabel = resolveOpStatus(s.status);
const maxShot = selectedMold?.shot_count || 0;
const curShot = s.current_shot_count || 0;
const pct = maxShot > 0 ? Math.min(Math.round((curShot / maxShot) * 100), 100) : 0;
return (
<TableRow key={s.id}>
<TableCell className="text-[13px] font-mono font-semibold">{s.serial_number}</TableCell>
<TableCell>
<Badge variant={ss.variant} className="text-[10px]">{ss.label}</Badge>
<Badge variant="secondary" className="text-[10px]">{ssLabel}</Badge>
</TableCell>
<TableCell>
{maxShot > 0 ? (
@@ -1043,10 +1067,7 @@ export default function MoldInfoPage() {
<SelectValue placeholder="선택해주세요" />
</SelectTrigger>
<SelectContent>
<SelectItem value="사출금형"></SelectItem>
<SelectItem value="프레스금형"></SelectItem>
<SelectItem value="다이캐스팅"></SelectItem>
<SelectItem value="단조금형"></SelectItem>
{moldTypeCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
@@ -1117,10 +1138,7 @@ export default function MoldInfoPage() {
<SelectValue placeholder="선택해주세요" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ACTIVE"></SelectItem>
<SelectItem value="INSPECTION"></SelectItem>
<SelectItem value="REPAIR"></SelectItem>
<SelectItem value="DISPOSED"></SelectItem>
{operationStatusCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
@@ -1175,10 +1193,7 @@ export default function MoldInfoPage() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="IN_USE"></SelectItem>
<SelectItem value="STORED"></SelectItem>
<SelectItem value="REPAIR"></SelectItem>
<SelectItem value="DISPOSED"></SelectItem>
{operationStatusCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
@@ -170,7 +170,7 @@ export default function EquipmentMonitoringPage() {
apiClient.post("/table-management/tables/equipment_mng/data", {
autoFilter: true,
page: 1,
size: 500,
size: 0,
}),
apiClient.get("/work-instruction/list").catch(() => ({ data: { data: [] } })),
apiClient.post("/table-management/tables/work_order_process/data", {
@@ -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(
@@ -153,7 +168,7 @@ export default function SubcontractorItemPage() {
filters.push({ columnName: "item_name", operator: "contains", value: searchKeyword });
}
const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
@@ -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);
@@ -191,7 +206,7 @@ export default function SubcontractorItemPage() {
setSubcontractorLoading(true);
try {
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [{ columnName: "item_id", operator: "equals", value: itemKey }] },
autoFilter: true,
});
@@ -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 () => {
@@ -194,7 +194,7 @@ export default function SubcontractorManagementPage() {
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
const res = await apiClient.post(`/table-management/tables/${SUBCONTRACTOR_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
@@ -229,7 +229,7 @@ export default function SubcontractorManagementPage() {
setPriceLoading(true);
try {
const mappingRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [
{ columnName: "subcontractor_id", operator: "equals", value: selectedSubcontractor.subcontractor_code },
]},
@@ -256,7 +256,7 @@ export default function SubcontractorManagementPage() {
if (mappings.length > 0) {
try {
const priceRes = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [
{ columnName: "subcontractor_id", operator: "equals", value: selectedSubcontractor.subcontractor_code },
]},
@@ -413,7 +413,7 @@ export default function SubcontractorManagementPage() {
const filters: any[] = [];
if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
@@ -550,7 +550,7 @@ export default function SubcontractorManagementPage() {
}> = [];
try {
const priceRes = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [
{ columnName: "subcontractor_id", operator: "equals", value: selectedSubcontractor!.subcontractor_code },
{ columnName: "item_id", operator: "equals", value: itemKey },
@@ -610,7 +610,7 @@ export default function SubcontractorManagementPage() {
// 2) 기존 단가 모두 삭제 (subcontractor_id + item_id 기준)
try {
const existingPrices = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [
{ columnName: "subcontractor_id", operator: "equals", value: selectedSubcontractor.subcontractor_code },
{ columnName: "item_id", operator: "equals", value: itemKey },
@@ -727,7 +727,7 @@ export default function SubcontractorManagementPage() {
if (subCodes.length > 0) {
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
page: 1, size: 5000, autoFilter: true,
page: 1, size: 0, autoFilter: true,
});
const mappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
@@ -59,6 +59,7 @@ import {
Settings2,
Save,
Package,
Pencil,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
@@ -349,13 +350,19 @@ export default function BomManagementPage() {
const res = await apiClient.post(`/table-management/tables/${BOM_TABLE}/data`, {
page: 1,
size: 500,
size: 0,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
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);
@@ -497,12 +511,14 @@ export default function BomManagementPage() {
const c = code.trim();
return categoryOptions["division"]?.find((o) => o.code === c)?.label || c;
}).filter((v: string) => v && v !== "s").join(", ");
const rawUnit = d.unit || item?.inventory_unit || "";
const unitLabel = categoryOptions["inventory_unit"]?.find((o) => o.code === rawUnit)?.label || rawUnit;
return {
...d,
item_number: item?.item_number || "",
item_name: item?.item_name || "",
item_type: divisionLabel,
unit: d.unit || item?.inventory_unit || "",
unit: unitLabel,
spec: item?.size || item?.spec || "",
writer: d.writer || "",
updated_date: d.updated_at || d.updated_date || "",
@@ -631,7 +647,7 @@ export default function BomManagementPage() {
// bom_detail에서 child_item_id가 현재 품목인 행 조회
const itemId = bomHeader.item_id || bomHeader.id;
const res = await apiClient.post(`/table-management/tables/bom_detail/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [{ columnName: "child_item_id", operator: "equals", value: itemId }] },
autoFilter: true,
});
@@ -818,6 +834,8 @@ export default function BomManagementPage() {
return;
}
// 같은 레벨 중복 허용 — 소요량/공정 등이 다른 동일 품목을 별도 row로 등록할 수 있음
const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2)}`;
const parentNode = addTargetParentId ? findNodeById(editingTree, addTargetParentId) : null;
const newLevel = parentNode ? ((parentNode._level ?? parentNode.level ?? 0) as number) + 1 : 0;
@@ -1089,17 +1107,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 || "",
@@ -1471,6 +1490,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"
@@ -1530,53 +1564,6 @@ export default function BomManagementPage() {
</div>
) : (
<div className="flex flex-col h-full">
{/* 상세 카드 */}
<div className="border-b shrink-0">
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/50">
<h3 className="text-[13px] font-bold text-foreground">BOM </h3>
<Button size="sm" variant="ghost" onClick={openEditModal}>
<FileText className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
{detailLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
</div>
) : bomHeader ? (
<div className="grid grid-cols-2 text-sm">
<div className="flex flex-col gap-1 p-3 border-b border-r">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider"></span>
<span className="font-mono text-xs">{bomHeader.item_code || bomHeader.item_number || "-"}</span>
</div>
<div className="flex flex-col gap-1 p-3 border-b">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider"></span>
<span className="text-xs">{bomHeader.item_name || "-"}</span>
</div>
<div className="flex flex-col gap-1 p-3 border-b border-r">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">BOM </span>
<span className="text-xs">{BOM_TYPE_OPTIONS.find((o) => o.code === bomHeader.bom_type)?.label || bomHeader.bom_type || "-"}</span>
</div>
<div className="flex flex-col gap-1 p-3 border-b">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider"></span>
<span className="text-xs">{bomHeader.version || "-"}</span>
</div>
<div className="flex flex-col gap-1 p-3 border-b border-r">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider"></span>
<span className="text-xs">{bomHeader.base_qty || "1"} {bomHeader.unit || ""}</span>
</div>
<div className="flex flex-col gap-1 p-3 border-b">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider"></span>
{renderStatusBadge(bomHeader.status)}
</div>
<div className="flex flex-col gap-1 p-3 col-span-2">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider"></span>
<span className="text-xs text-muted-foreground">{bomHeader.remark || "-"}</span>
</div>
</div>
) : null}
</div>
{/* 하단 탭: 트리뷰 / 버전 / 이력 */}
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
<Tabs value={rightTab} onValueChange={(v) => {
@@ -1808,7 +1795,7 @@ export default function BomManagementPage() {
{/* 소요량 */}
<td className="px-3 py-2 text-center">{isVirtualRoot ? (bomHeader?.base_qty || "1") : (node.quantity || "-")}</td>
{/* 단위 */}
<td className="px-3 py-2 text-center">{isVirtualRoot ? "-" : (node.unit || "-")}</td>
<td className="px-3 py-2 text-center">{isVirtualRoot ? (categoryOptions["inventory_unit"]?.find((o) => o.code === bomHeader?.unit)?.label || bomHeader?.unit || "-") : (node.unit || "-")}</td>
{/* 공정구분 */}
<td className="overflow-hidden text-ellipsis whitespace-nowrap px-3 py-2">{isVirtualRoot ? "-" : (node.process_type || "-")}</td>
{/* 규격 */}
@@ -185,9 +185,6 @@ export default function ProductionPlanManagementPage() {
const [modalQuantity, setModalQuantity] = useState(0);
const [modalStartDate, setModalStartDate] = useState("");
const [modalEndDate, setModalEndDate] = useState("");
const [modalManager, setModalManager] = useState("");
const [modalWorkOrderNo, setModalWorkOrderNo] = useState("");
const [modalRemarks, setModalRemarks] = useState("");
const [modalEquipmentId, setModalEquipmentId] = useState("");
// 미리보기 데이터
@@ -200,7 +197,10 @@ export default function ProductionPlanManagementPage() {
const [selectedPlanIds, setSelectedPlanIds] = useState<Set<number>>(new Set());
// useConfirmDialog
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
const { confirm, ConfirmDialogComponent, isConfirmOpenRef } = useConfirmDialog();
// 수량 지정 분할 입력값
const [customSplitQty, setCustomSplitQty] = useState<number | "">("");
// ========== 데이터 로드 ==========
@@ -694,10 +694,8 @@ export default function ProductionPlanManagementPage() {
setModalQuantity(Number(plan.plan_qty));
setModalStartDate(plan.start_date?.split("T")[0] || "");
setModalEndDate(plan.end_date?.split("T")[0] || "");
setModalManager((plan as any).manager_name || "");
setModalWorkOrderNo((plan as any).work_order_no || "");
setModalRemarks(plan.remarks || "");
setModalEquipmentId((plan as any).equipment_code || (plan.equipment_id ? String(plan.equipment_id) : ""));
setCustomSplitQty("");
setScheduleModalOpen(true);
}, []);
@@ -709,9 +707,6 @@ export default function ProductionPlanManagementPage() {
plan_qty: modalQuantity,
start_date: modalStartDate,
end_date: modalEndDate,
manager_name: modalManager,
work_order_no: modalWorkOrderNo,
remarks: modalRemarks,
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
equipment_name: modalEquipmentId && modalEquipmentId !== "none"
? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null
@@ -721,13 +716,14 @@ export default function ProductionPlanManagementPage() {
toast.success("생산계획이 수정되었습니다");
setScheduleModalOpen(false);
fetchPlans();
fetchOrderSummary();
}
} catch (err: any) {
toast.error("수정 실패: " + (err.message || ""));
toast.error("수정 실패: " + (err?.response?.data?.message || err.message || ""));
} finally {
setSaving(false);
}
}, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalManager, modalWorkOrderNo, modalRemarks, modalEquipmentId, fetchPlans]);
}, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId, equipmentList, fetchPlans, fetchOrderSummary]);
const handleDeletePlan = useCallback(async () => {
if (!selectedPlan) return;
@@ -741,24 +737,158 @@ export default function ProductionPlanManagementPage() {
toast.success("삭제되었습니다");
setScheduleModalOpen(false);
fetchPlans();
fetchOrderSummary();
} catch (err: any) {
toast.error("삭제 실패: " + (err.message || ""));
toast.error("삭제 실패: " + (err?.response?.data?.message || err.message || ""));
}
}, [selectedPlan, fetchPlans, confirm]);
}, [selectedPlan, fetchPlans, fetchOrderSummary, confirm]);
// 에러 메시지 추출 헬퍼
const extractErrMsg = (err: any): string => {
return err?.response?.data?.message || err?.message || "";
};
// modalQuantity/일정/설비가 DB의 selectedPlan 값과 다른지 확인 (dirty 체크)
const isModalDirty = useCallback((): boolean => {
if (!selectedPlan) return false;
const planQty = Number(selectedPlan.plan_qty) || 0;
const planStart = selectedPlan.start_date?.split("T")[0] || "";
const planEnd = selectedPlan.end_date?.split("T")[0] || "";
const planEq = (selectedPlan as any).equipment_code || (selectedPlan.equipment_id ? String(selectedPlan.equipment_id) : "");
return (
planQty !== Number(modalQuantity) ||
planStart !== modalStartDate ||
planEnd !== modalEndDate ||
planEq !== modalEquipmentId
);
}, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId]);
// dirty 상태면 자동 저장 후 selectedPlan 을 최신 값으로 갱신
const ensureSavedBeforeSplit = useCallback(async (): Promise<boolean> => {
if (!selectedPlan) return false;
if (!isModalDirty()) return true;
try {
const res = await updatePlan(selectedPlan.id, {
plan_qty: modalQuantity,
start_date: modalStartDate,
end_date: modalEndDate,
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
equipment_name: modalEquipmentId && modalEquipmentId !== "none"
? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null
: null,
} as any);
if (!res.success) {
toast.error("저장 실패로 분할이 중단되었습니다");
return false;
}
// selectedPlan 을 최신 값으로 동기화 (이후 로직에서 plan_qty 를 참조)
setSelectedPlan((prev) => prev ? ({
...prev,
plan_qty: modalQuantity,
start_date: modalStartDate,
end_date: modalEndDate,
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
} as any) : prev);
return true;
} catch (err: any) {
toast.error("저장 실패로 분할이 중단되었습니다: " + extractErrMsg(err));
return false;
}
}, [selectedPlan, isModalDirty, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId, equipmentList]);
// 균등 분할 (2/3/4분할 버튼)
const handleSplitSchedule = useCallback(async (splitCount: number) => {
if (!selectedPlan || splitCount < 2) return;
// 모달 입력값 기준 (이후 자동 저장되므로 modalQuantity 가 진실)
const originalQty = Number(modalQuantity) || 0;
if (originalQty < splitCount) {
toast.error(`${splitCount}분할하려면 수량이 ${splitCount} 이상이어야 합니다`);
return;
}
if (selectedPlan.status && selectedPlan.status !== "planned") {
toast.error("계획 상태인 건만 분할할 수 있습니다");
return;
}
const ok = await confirm(`이 계획을 ${splitCount}개로 균등 분할하시겠습니까?`, {
description: `수량 ${originalQty}이(가) ${splitCount}개로 나뉩니다.`,
confirmText: "분할",
});
if (!ok) return;
// dirty 면 자동 저장
const saved = await ensureSavedBeforeSplit();
if (!saved) return;
const eachQty = Math.floor(originalQty / splitCount);
if (eachQty <= 0) {
toast.error("분할 수량이 부족합니다");
return;
}
let successCount = 0;
try {
// N-1회 호출: 매번 eachQty만큼 원본에서 떼어내 새 plan 생성
for (let i = 0; i < splitCount - 1; i++) {
const res = await splitSchedule(selectedPlan.id, eachQty);
if (!res.success) throw new Error("분할 응답 실패");
successCount++;
}
toast.success(`계획이 ${splitCount}개로 분할되었습니다`);
setScheduleModalOpen(false);
fetchPlans();
fetchOrderSummary();
} catch (err: any) {
const msg = extractErrMsg(err);
if (successCount > 0) {
toast.error(`분할 일부 실패 (${successCount + 1}개 생성됨): ${msg}`);
} else {
toast.error("분할 실패: " + msg);
}
fetchPlans();
fetchOrderSummary();
}
}, [selectedPlan, modalQuantity, fetchPlans, fetchOrderSummary, confirm, ensureSavedBeforeSplit]);
// 수량 지정 분할 (원본에서 입력 수량만큼 떼어내기)
const handleCustomSplit = useCallback(async () => {
if (!selectedPlan) return;
const splitQty = Number(customSplitQty);
const originalQty = Number(modalQuantity) || 0;
if (!splitQty || splitQty < 1) {
toast.error("떼어낼 수량을 1 이상으로 입력하세요");
return;
}
if (splitQty >= originalQty) {
toast.error("떼어낼 수량은 원본 수량보다 작아야 합니다");
return;
}
if (selectedPlan.status && selectedPlan.status !== "planned") {
toast.error("계획 상태인 건만 분할할 수 있습니다");
return;
}
const ok = await confirm(`이 계획에서 ${splitQty}만큼 떼어내시겠습니까?`, {
description: `원본 ${originalQty} → 원본 ${originalQty - splitQty} + 신규 ${splitQty}`,
confirmText: "분할",
});
if (!ok) return;
const saved = await ensureSavedBeforeSplit();
if (!saved) return;
const handleSplitSchedule = useCallback(async (splitQty: number) => {
if (!selectedPlan || splitQty <= 0) return;
try {
const res = await splitSchedule(selectedPlan.id, splitQty);
if (res.success) {
toast.success("계획이 분되었습니다");
setScheduleModalOpen(false);
fetchPlans();
}
if (!res.success) throw new Error("분할 응답 실패");
toast.success(`${splitQty} 수량이 분되었습니다`);
setCustomSplitQty("");
setScheduleModalOpen(false);
fetchPlans();
fetchOrderSummary();
} catch (err: any) {
toast.error("분할 실패: " + (err.message || ""));
toast.error("분할 실패: " + extractErrMsg(err));
fetchPlans();
fetchOrderSummary();
}
}, [selectedPlan, fetchPlans]);
}, [selectedPlan, modalQuantity, customSplitQty, fetchPlans, fetchOrderSummary, confirm, ensureSavedBeforeSplit]);
// 병합 핸들러
const handleMergeSchedules = useCallback(async () => {
@@ -780,11 +910,12 @@ export default function ProductionPlanManagementPage() {
toast.success("계획이 병합되었습니다");
setSelectedPlanIds(new Set());
fetchPlans();
fetchOrderSummary();
}
} catch (err: any) {
toast.error("병합 실패: " + (err.message || ""));
toast.error("병합 실패: " + (err?.response?.data?.message || err.message || ""));
}
}, [selectedPlanIds, rightTab, fetchPlans, confirm]);
}, [selectedPlanIds, rightTab, fetchPlans, fetchOrderSummary, confirm]);
// 타임라인 이벤트 드래그 이동
const handleEventMove = useCallback(async (eventId: string | number, newStart: string, newEnd: string) => {
@@ -796,11 +927,12 @@ export default function ProductionPlanManagementPage() {
if (res.success) {
toast.success("일정이 변경되었습니다");
fetchPlans();
fetchOrderSummary();
}
} catch (err: any) {
toast.error("일정 변경 실패: " + (err.message || ""));
}
}, [fetchPlans]);
}, [fetchPlans, fetchOrderSummary]);
// 타임라인 이벤트 리사이즈
const handleEventResize = useCallback(async (eventId: string | number, newStart: string, newEnd: string) => {
@@ -812,11 +944,12 @@ export default function ProductionPlanManagementPage() {
if (res.success) {
toast.success("기간이 변경되었습니다");
fetchPlans();
fetchOrderSummary();
}
} catch (err: any) {
toast.error("기간 변경 실패: " + (err.message || ""));
}
}, [fetchPlans]);
}, [fetchPlans, fetchOrderSummary]);
// 불러오기 처리
const handleImportOrderItems = useCallback(async () => {
@@ -1463,8 +1596,26 @@ export default function ProductionPlanManagementPage() {
{/* ========== 모달들 ========== */}
{/* 스케줄 상세/편집 모달 */}
<Dialog open={scheduleModalOpen} onOpenChange={setScheduleModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[650px] max-h-[85vh] overflow-y-auto">
<Dialog
open={scheduleModalOpen}
onOpenChange={(v) => {
// confirm 다이얼로그가 열려 있는 동안 발생하는 닫힘 이벤트(포커스 이탈 등)는 무시
if (!v && isConfirmOpenRef.current) return;
setScheduleModalOpen(v);
}}
>
<DialogContent
className="max-w-[95vw] sm:max-w-[650px] max-h-[85vh] overflow-y-auto"
onPointerDownOutside={(e) => {
if (isConfirmOpenRef.current) e.preventDefault();
}}
onInteractOutside={(e) => {
if (isConfirmOpenRef.current) e.preventDefault();
}}
onFocusOutside={(e) => {
if (isConfirmOpenRef.current) e.preventDefault();
}}
>
<DialogHeader>
<DialogTitle className="text-base sm:text-lg flex items-center gap-2">
<ClipboardList className="h-5 w-5" />
@@ -1554,37 +1705,67 @@ export default function ProductionPlanManagementPage() {
<Scissors className="h-4 w-4" />
</p>
<div className="flex gap-1.5">
{[2, 3, 4].map((n) => {
const canSplit =
modalQuantity >= n &&
(selectedPlan?.status === "planned" || !selectedPlan?.status);
return (
<Button
key={n}
size="sm"
variant="warning"
className="h-7 text-xs"
disabled={!canSplit}
onClick={() => handleSplitSchedule(n)}
>
{n}
</Button>
);
})}
</div>
</div>
<p className="text-xs text-foreground mb-2">
. ( )
</p>
{/* 수량 지정 분할 */}
<div className="flex items-center gap-1.5 pt-2 border-t border-warning/20">
<Label className="text-xs text-muted-foreground shrink-0"> :</Label>
<Input
type="number"
value={customSplitQty}
onChange={(e) => {
const v = e.target.value;
if (v === "") setCustomSplitQty("");
else setCustomSplitQty(Math.max(0, Math.floor(Number(v) || 0)));
}}
className="h-7 w-28 text-xs"
placeholder="떼어낼 수량"
min={1}
max={Math.max(0, modalQuantity - 1)}
step={1}
/>
<span className="text-xs text-muted-foreground">
/ {modalQuantity}
</span>
<Button
size="sm"
variant="warning"
className="h-7 text-xs"
onClick={() => {
const qty = Math.floor(modalQuantity / 2);
if (qty > 0) handleSplitSchedule(qty);
}}
className="h-7 text-xs ml-auto"
disabled={
!customSplitQty ||
Number(customSplitQty) < 1 ||
Number(customSplitQty) >= modalQuantity ||
!(selectedPlan?.status === "planned" || !selectedPlan?.status)
}
onClick={handleCustomSplit}
>
2
</Button>
</div>
<p className="text-xs text-foreground"> .</p>
</div>
<div>
<p className="text-sm font-semibold mb-3 pb-2 border-b"> </p>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs"></Label>
<Input value={modalManager} onChange={(e) => setModalManager(e.target.value)} className="h-9 text-xs" placeholder="담당자명" />
</div>
<div>
<Label className="text-xs"></Label>
<Input value={modalWorkOrderNo} onChange={(e) => setModalWorkOrderNo(e.target.value)} className="h-9 text-xs" placeholder="자동생성" />
</div>
<div className="col-span-2">
<Label className="text-xs"></Label>
<Input value={modalRemarks} onChange={(e) => setModalRemarks(e.target.value)} className="h-9 text-xs" placeholder="비고사항 입력" />
</div>
</div>
<p className="text-[11px] text-muted-foreground mt-1.5">
. (1 , )
</p>
</div>
</div>
)}
@@ -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,7 +92,8 @@ export function ItemRoutingTab() {
const [formFixedOrder, setFormFixedOrder] = useState("Y");
const [formWorkType, setFormWorkType] = useState("내부");
const [formStandardTime, setFormStandardTime] = useState("");
const [formOutsource, setFormOutsource] = useState("");
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);
@@ -107,6 +109,19 @@ export function ItemRoutingTab() {
return () => window.clearTimeout(t);
}, [searchInput]);
// 외주사 목록 로드
useEffect(() => {
(async () => {
try {
const res = await apiClient.post("/table-management/tables/subcontractor_mng/data", {
page: 1, size: 500, autoFilter: true,
});
const rows = res.data?.data?.data || res.data?.data?.rows || [];
setSubcontractorOptions(rows.map((r: any) => ({ id: r.id, code: r.subcontractor_code || "", name: r.subcontractor_name || "" })));
} catch { /* skip */ }
})();
}, []);
useEffect(() => {
const t = window.setTimeout(() => setRegisterSearchDebounced(registerSearch.trim()), 300);
return () => window.clearTimeout(t);
@@ -267,7 +282,7 @@ export function ItemRoutingTab() {
setFormFixedOrder("Y");
setFormWorkType("내부");
setFormStandardTime("");
setFormOutsource("");
setFormOutsources([]);
setDetailDialogOpen(true);
};
@@ -294,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);
};
@@ -315,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 {
@@ -330,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("공정이 추가되었어요. 저장을 눌러 반영해주세요");
@@ -348,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,
),
@@ -385,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);
@@ -466,12 +499,24 @@ export function ItemRoutingTab() {
const detailsGridData = useMemo(
() =>
details.map((d) => ({
...d,
process_display: d.process_name || d.process_code,
outsource_display: d.outsource_supplier || "—",
})),
[details],
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],
);
return (
@@ -895,13 +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>
<Input
value={formOutsource}
onChange={(e) => setFormOutsource(e.target.value)}
placeholder="외주 업체명"
className="h-9"
/>
<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>
@@ -47,6 +47,7 @@ import {
} from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
import { SmartSelect } from "@/components/common/SmartSelect";
import {
getProcessList,
createProcess,
@@ -221,18 +222,28 @@ export function ProcessMasterTab() {
};
const openEdit = () => {
if (!selectedProcess) {
toast.message("수정할 공정을 좌측 목록에서 선택해주세요");
if (selectedIds.size === 0) {
toast.message("수정할 공정을 체크박스로 선택해주세요");
return;
}
if (selectedIds.size > 1) {
toast.message("수정은 1건만 선택해주세요");
return;
}
const targetId = Array.from(selectedIds)[0];
const target = processes.find((p) => p.id === targetId);
if (!target) {
toast.error("선택한 공정을 찾을 수 없습니다");
return;
}
setFormMode("edit");
setEditingId(selectedProcess.id);
setFormProcessCode(selectedProcess.process_code);
setFormProcessName(selectedProcess.process_name);
setFormProcessType(selectedProcess.process_type);
setFormStandardTime(selectedProcess.standard_time ?? "");
setFormWorkerCount(selectedProcess.worker_count ?? "");
setFormUseYn(selectedProcess.use_yn);
setEditingId(target.id);
setFormProcessCode(target.process_code);
setFormProcessName(target.process_name);
setFormProcessType(target.process_type);
setFormStandardTime(target.standard_time ?? "");
setFormWorkerCount(target.worker_count ?? "");
setFormUseYn(target.use_yn);
setFormOpen(true);
};
@@ -313,8 +324,15 @@ export function ProcessMasterTab() {
};
const availableEquipments = useMemo(() => {
const used = new Set(processEquipments.map((e) => e.equipment_code));
return equipmentMaster.filter((e) => !used.has(e.equipment_code));
const used = new Set<string>();
for (const pe of processEquipments) {
if (pe.equipment_code) used.add(pe.equipment_code);
}
return equipmentMaster.filter((e) => {
if (e.equipment_code && used.has(e.equipment_code)) return false;
if (e.id && used.has(e.id)) return false;
return true;
});
}, [equipmentMaster, processEquipments]);
const handleAddEquipment = async () => {
@@ -323,11 +341,16 @@ export function ProcessMasterTab() {
toast.message("추가할 설비를 선택해주세요");
return;
}
const picked = availableEquipments.find((e) => e.id === equipmentPick);
if (!picked) {
toast.error("선택한 설비를 찾을 수 없어요");
return;
}
setAddingEquipment(true);
try {
const res = await addProcessEquipment({
process_code: selectedProcess.process_code,
equipment_code: equipmentPick,
equipment_code: picked.equipment_code || picked.id,
});
if (!res.success) {
toast.error(res.message || "설비 추가에 실패했어요");
@@ -501,23 +524,17 @@ export function ProcessMasterTab() {
<div className="flex items-end gap-2">
<div className="min-w-0 flex-1 space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> </Label>
<Select
<SmartSelect
key={selectedProcess.id}
value={equipmentPick || undefined}
options={availableEquipments.map((eq) => ({
code: eq.id,
label: eq.equipment_name,
}))}
value={equipmentPick || ""}
onValueChange={setEquipmentPick}
placeholder="설비를 선택해주세요"
disabled={addingEquipment || availableEquipments.length === 0}
>
<SelectTrigger className="h-9" size="sm">
<SelectValue placeholder="설비를 선택해주세요" />
</SelectTrigger>
<SelectContent>
{availableEquipments.map((eq) => (
<SelectItem key={eq.id} value={eq.equipment_code}>
{eq.equipment_code} · {eq.equipment_name}
</SelectItem>
))}
</SelectContent>
</Select>
/>
</div>
<Button
size="sm"
@@ -548,8 +565,11 @@ export function ProcessMasterTab() {
{processEquipments.map((pe) => (
<li key={pe.id} className="flex items-center gap-3 rounded-lg border p-3 transition-colors hover:bg-muted/30">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{pe.equipment_code}</p>
<p className="truncate text-xs text-muted-foreground">{pe.equipment_name || "설비명 없음"}</p>
<p className="truncate text-sm font-medium">
{pe.equipment_name
|| equipmentMaster.find((e) => e.id === pe.equipment_code || e.equipment_code === pe.equipment_code)?.equipment_name
|| "설비명 없음"}
</p>
</div>
<Button variant="ghost" size="sm" onClick={() => void handleRemoveEquipment(pe)}>
<Trash2 className="mr-1 h-3.5 w-3.5" />
@@ -1,6 +1,6 @@
"use client";
import React, { useState, useCallback, useEffect } from "react";
import React, { useState, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import {
@@ -9,7 +9,6 @@ import {
ClipboardList,
ChevronRight,
Factory,
Keyboard,
Plus,
Pencil,
Trash2,
@@ -41,7 +40,6 @@ const TAB_META = [
icon: Settings,
color: "text-blue-500",
badgeColor: "bg-blue-50 text-blue-700 ring-blue-600/20",
shortcut: "1",
actions: ["공정 등록", "공정 수정", "공정 삭제", "설비 연결"],
},
{
@@ -53,7 +51,6 @@ const TAB_META = [
icon: GitBranch,
color: "text-emerald-500",
badgeColor: "bg-emerald-50 text-emerald-700 ring-emerald-600/20",
shortcut: "2",
actions: ["버전 생성", "공정 순서 설정", "품목 등록", "품목 해제"],
},
{
@@ -65,7 +62,6 @@ const TAB_META = [
icon: ClipboardList,
color: "text-violet-500",
badgeColor: "bg-violet-50 text-violet-700 ring-violet-600/20",
shortcut: "3",
actions: ["기준서 등록", "기준서 수정", "기준서 삭제", "작업 표준 관리"],
},
] as const;
@@ -77,7 +73,6 @@ const ACTION_ICONS = [Plus, Pencil, Trash2, List] as const;
export default function ProcessInfoPage() {
const ts = useTableSettings("c16-process-info", "process_mst", GRID_COLUMNS);
const [activeTab, setActiveTab] = useState<TabValue>("process");
const [showShortcutHint, setShowShortcutHint] = useState(false);
const activeMeta = TAB_META.find((t) => t.value === activeTab)!;
@@ -85,19 +80,6 @@ export default function ProcessInfoPage() {
setActiveTab(value as TabValue);
}, []);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!e.altKey) return;
const tabByShortcut = TAB_META.find((t) => t.shortcut === e.key);
if (tabByShortcut) {
e.preventDefault();
setActiveTab(tabByShortcut.value);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, []);
return (
<div className="flex h-[calc(100vh-4rem)] flex-col bg-muted/30">
{/* 페이지 헤더 */}
@@ -120,30 +102,8 @@ export default function ProcessInfoPage() {
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
<Settings2 className="h-3.5 w-3.5" />
</Button>
<button
type="button"
className="flex items-center gap-1 rounded px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onClick={() => setShowShortcutHint((v) => !v)}
aria-label="키보드 단축키 보기"
>
<Keyboard className="h-3 w-3" />
<span></span>
</button>
</div>
</div>
{showShortcutHint && (
<div className="mt-2 flex items-center gap-3 rounded-md bg-muted px-3 py-2 text-xs text-muted-foreground">
<span className="font-medium text-foreground"> :</span>
{TAB_META.map((t) => (
<span key={t.value} className="flex items-center gap-1">
<kbd className="rounded border border-border bg-background px-1.5 py-0.5 font-mono text-[10px]">
Alt+{t.shortcut}
</kbd>
<span>{t.shortLabel}</span>
</span>
))}
</div>
)}
</div>
<Tabs
@@ -154,12 +114,12 @@ export default function ProcessInfoPage() {
{/* 탭 네비게이션 */}
<div className="shrink-0 border-b bg-background px-4">
<TabsList className="h-12 bg-transparent gap-1">
{TAB_META.map(({ value, label, icon: Icon, shortcut }) => (
{TAB_META.map(({ value, label, icon: Icon }) => (
<TabsTrigger
key={value}
value={value}
className="data-[state=active]:border-b-2 data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none rounded-none px-4 gap-1.5"
aria-label={`${label} (Alt+${shortcut})`}
aria-label={label}
>
<Icon className="h-4 w-4" />
{label}
@@ -168,34 +128,6 @@ export default function ProcessInfoPage() {
</TabsList>
</div>
{/* 탭 설명 배너 */}
<div className="shrink-0 border-b bg-background/60 px-6 py-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset ${activeMeta.badgeColor}`}
>
{activeMeta.shortLabel}
</span>
<span className="text-xs text-muted-foreground">{activeMeta.detailDesc}</span>
</div>
<div className="hidden items-center gap-2 sm:flex">
{activeMeta.actions.map((action, i) => {
const ActionIcon = ACTION_ICONS[i % ACTION_ICONS.length];
return (
<span
key={action}
className="flex items-center gap-1 text-xs text-muted-foreground/70"
>
<ActionIcon className="h-3 w-3" />
{action}
</span>
);
})}
</div>
</div>
</div>
{/* 탭 컨텐츠 */}
<TabsContent value="process" className="min-h-0 flex-1 overflow-hidden mt-0">
<ProcessMasterTab />
@@ -183,7 +183,7 @@ export default function ProductionResultPage() {
setProcessLoading(true);
try {
const res = await apiClient.post(`/table-management/tables/${WOP_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [{ columnName: "wo_id", operator: "equals", value: selectedWiId }] },
autoFilter: true,
sort: { columnName: "seq_no", order: "asc" },
@@ -586,7 +586,7 @@ export function WorkStandardEditModal({
<thead className="sticky top-0 bg-muted/50">
<tr className="border-b">
<th className="w-12 px-2 py-2 text-center font-medium text-muted-foreground"></th>
<th className="w-20 px-2 py-2 text-left font-medium text-muted-foreground"></th>
<th className="w-24 px-2 py-2 text-left font-medium text-muted-foreground"></th>
<th className="px-2 py-2 text-left font-medium text-muted-foreground"></th>
<th className="w-14 px-2 py-2 text-center font-medium text-muted-foreground"></th>
<th className="w-16 px-2 py-2 text-center font-medium text-muted-foreground"></th>
@@ -597,7 +597,7 @@ export function WorkStandardEditModal({
<tr key={detail.id || idx} className="border-b transition-colors hover:bg-muted/30">
<td className="px-2 py-1.5 text-center text-muted-foreground">{idx + 1}</td>
<td className="px-2 py-1.5">
<Badge variant="outline" className="text-[10px] font-normal">
<Badge variant="outline" className="text-[10px] font-normal whitespace-nowrap">
{getDetailTypeLabel(detail.detail_type || "checklist")}
</Badge>
</td>
@@ -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>
@@ -30,6 +30,7 @@ import { toast } from "sonner";
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { SmartSelect } from "@/components/common/SmartSelect";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
const MASTER_TABLE = "purchase_order_mng";
@@ -237,7 +238,7 @@ export default function PurchaseOrderPage() {
);
try {
const suppRes = await apiClient.post(`/table-management/tables/supplier_mng/data`, {
page: 1, size: 5000, autoFilter: true,
page: 1, size: 0, autoFilter: true,
});
const supps = suppRes.data?.data?.data || suppRes.data?.data?.rows || [];
optMap["supplier_code"] = supps.map((s: any) => ({
@@ -247,7 +248,7 @@ export default function PurchaseOrderPage() {
} catch { /* skip */ }
try {
const userRes = await apiClient.post(`/table-management/tables/user_info/data`, {
page: 1, size: 5000, autoFilter: true,
page: 1, size: 0, autoFilter: true,
});
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
optMap["manager"] = users.map((u: any) => ({
@@ -293,7 +294,7 @@ export default function PurchaseOrderPage() {
}
const res = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
page: 1, size: 5000,
page: 1, size: 0,
dataFilter: detailFilters.length > 0 ? { enabled: true, filters: detailFilters } : undefined,
autoFilter: true,
sort: { columnName: "purchase_no", order: "desc" },
@@ -555,6 +556,48 @@ export default function PurchaseOrderPage() {
if (divLabel) divValues.push(divLabel);
filters.push({ columnName: "division", operator: "in", value: divValues });
}
// 공급업체 선택 시 supplier_item_mapping으로 매핑 id 정규화 → 서버 필터 적용
const supplierCode = masterForm.supplier_code;
if (supplierCode) {
try {
const mappingRes = await apiClient.post(`/table-management/tables/supplier_item_mapping/data`, {
page: 1, size: 0,
dataFilter: { enabled: true, filters: [{ columnName: "supplier_id", operator: "equals", value: supplierCode }] },
autoFilter: true,
});
const mappings = mappingRes.data?.data?.data || mappingRes.data?.data?.rows || [];
const rawIds = [...new Set(mappings.map((m: any) => m.item_id).filter(Boolean))] as string[];
if (rawIds.length === 0) {
setItemSearchResults([]); setItemTotal(0); setItemTotalPages(1);
setItemSearchLoading(false);
return;
}
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const uuidIds = rawIds.filter((v) => uuidRegex.test(v));
const codeIds = rawIds.filter((v) => !uuidRegex.test(v));
let convertedIds: string[] = [];
if (codeIds.length > 0) {
const convRes = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: codeIds.length + 10,
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: codeIds }] },
autoFilter: true,
});
const convRows = convRes.data?.data?.data || convRes.data?.data?.rows || [];
convertedIds = convRows.map((r: any) => r.id).filter(Boolean);
}
const finalIds = [...new Set([...uuidIds, ...convertedIds])];
if (finalIds.length === 0) {
setItemSearchResults([]); setItemTotal(0); setItemTotalPages(1);
setItemSearchLoading(false);
return;
}
filters.push({ columnName: "id", operator: "in", value: finalIds });
} catch { /* skip */ }
}
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: p, size: s,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
@@ -607,7 +650,7 @@ export default function PurchaseOrderPage() {
try {
const itemIds = selected.map((item) => item.item_number || item.id);
const res = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, {
page: 1, size: 5000,
page: 1, size: 0,
dataFilter: {
enabled: true,
filters: [
@@ -670,7 +713,7 @@ export default function PurchaseOrderPage() {
if (itemCodes.length === 0) return;
try {
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 5000,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: itemCodes }] },
autoFilter: true,
});
@@ -692,7 +735,7 @@ export default function PurchaseOrderPage() {
if (itemCodes.length === 0) return;
try {
const res = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, {
page: 1, size: 5000,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [
{ columnName: "supplier_id", operator: "equals", value: supplierCode },
{ columnName: "item_id", operator: "in", value: itemCodes },
@@ -984,7 +1027,8 @@ export default function PurchaseOrderPage() {
<div className="grid grid-cols-2 gap-3.5">
<div className="space-y-1">
<Label className="text-[11px] font-semibold text-muted-foreground tracking-wide"></Label>
<Select
<SmartSelect
options={categoryOptions["supplier_code"] || []}
value={masterForm.supplier_code || ""}
onValueChange={(v) => {
const supp = categoryOptions["supplier_code"]?.find((o) => o.code === v);
@@ -992,15 +1036,9 @@ export default function PurchaseOrderPage() {
setMasterForm((p) => ({ ...p, supplier_code: v, supplier_name: name }));
recalcPrices(masterForm.price_mode || "", v);
}}
placeholder="공급업체 선택"
disabled={isReadOnly}
>
<SelectTrigger className="h-9"><SelectValue placeholder="공급업체 선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["supplier_code"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
/>
</div>
</div>
</div>
@@ -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 }[] = [];
@@ -328,7 +333,7 @@ export default function PurchaseItemPage() {
}
const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
page: 1, size: 5000,
page: 1, size: 0,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
@@ -619,7 +624,7 @@ export default function PurchaseItemPage() {
try {
// 1. supplier_item_mapping에서 해당 품목의 매핑 조회
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [{ columnName: "item_id", operator: "equals", value: itemKey }] },
autoFilter: true,
sort: { columnName: "created_date", order: "asc" },
@@ -647,7 +652,7 @@ export default function PurchaseItemPage() {
if (mappings.length > 0) {
try {
const priceRes = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [
{ columnName: "item_id", operator: "equals", value: itemKey },
]},
@@ -1104,7 +1109,7 @@ export default function PurchaseItemPage() {
for (const suppCode of supplierCodes) {
// 해당 공급업체의 모든 매핑 조회 → item_id null 처리
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [
{ columnName: "item_id", operator: "equals", value: selectedItem!.item_number },
{ columnName: "supplier_id", operator: "equals", value: suppCode },
@@ -1121,7 +1126,7 @@ export default function PurchaseItemPage() {
// 해당 공급업체의 모든 단가 조회 → item_id null 처리
try {
const priceRes = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [
{ columnName: "item_id", operator: "equals", value: selectedItem!.item_number },
{ columnName: "supplier_id", operator: "equals", value: suppCode },
@@ -219,7 +219,7 @@ export default function SupplierManagementPage() {
} catch { /* skip */ }
};
load();
apiClient.post(`/table-management/tables/user_info/data`, { page: 1, size: 500, autoFilter: true })
apiClient.post(`/table-management/tables/user_info/data`, { page: 1, size: 0, autoFilter: true })
.then((res) => {
const users = res.data?.data?.data || res.data?.data?.rows || [];
setEmployeeOptions(users.map((u: any) => ({
@@ -239,7 +239,7 @@ export default function SupplierManagementPage() {
}));
const res = await apiClient.post(`/table-management/tables/${SUPPLIER_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
sort: { columnName: "supplier_code", order: "desc" },
@@ -284,7 +284,7 @@ export default function SupplierManagementPage() {
const fetchMainContacts = useCallback(async () => {
try {
const contactRes = await apiClient.post(`/table-management/tables/${CONTACT_TABLE}/data`, {
page: 1, size: 500, autoFilter: true,
page: 1, size: 0, autoFilter: true,
dataFilter: { enabled: true, filters: [{ columnName: "is_main", operator: "equals", value: "Y" }] },
});
const allContacts = contactRes.data?.data?.data || contactRes.data?.data?.rows || [];
@@ -310,7 +310,7 @@ export default function SupplierManagementPage() {
setPriceLoading(true);
try {
const mappingRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [
{ columnName: "supplier_id", operator: "equals", value: selectedSupplier.supplier_code },
]},
@@ -337,7 +337,7 @@ export default function SupplierManagementPage() {
if (mappings.length > 0) {
try {
const priceRes = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [
{ columnName: "supplier_id", operator: "equals", value: selectedSupplier.supplier_code },
]},
@@ -412,7 +412,7 @@ export default function SupplierManagementPage() {
setDeliveryLoading(true);
try {
const res = await apiClient.post(`/table-management/tables/${DELIVERY_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [
{ columnName: "supplier_code", operator: "equals", value: selectedSupplier.supplier_code },
]},
@@ -492,7 +492,7 @@ export default function SupplierManagementPage() {
if (ruleData?.success && ruleData?.data?.ruleId) {
const ruleId = ruleData.data.ruleId;
const allRes = await apiClient.post(`/table-management/tables/${DELIVERY_TABLE}/data`, {
page: 1, size: 500, autoFilter: true,
page: 1, size: 0, autoFilter: true,
sort: { columnName: "destination_code", order: "desc" },
});
const allRows = allRes.data?.data?.data || allRes.data?.data?.rows || [];
@@ -565,7 +565,7 @@ export default function SupplierManagementPage() {
const ruleId = ruleData.data.ruleId;
// 기존 데이터에서 CUST-XXX 패턴의 최대 순번 조회
const allRes = await apiClient.post(`/table-management/tables/${SUPPLIER_TABLE}/data`, {
page: 1, size: 500, autoFilter: true,
page: 1, size: 0, autoFilter: true,
sort: { columnName: "supplier_code", order: "desc" },
});
const allRows = allRes.data?.data?.data || allRes.data?.data?.rows || [];
@@ -766,7 +766,7 @@ export default function SupplierManagementPage() {
const ruleData = ruleRes.data;
if (ruleData?.success && ruleData?.data?.ruleId) {
const ruleId = ruleData.data.ruleId;
const allRes = await apiClient.post(`/table-management/tables/${SUPPLIER_TABLE}/data`, { page: 1, size: 500, autoFilter: true, sort: { columnName: "supplier_code", order: "desc" } });
const allRes = await apiClient.post(`/table-management/tables/${SUPPLIER_TABLE}/data`, { page: 1, size: 0, autoFilter: true, sort: { columnName: "supplier_code", order: "desc" } });
const allRows = allRes.data?.data?.data || allRes.data?.data?.rows || [];
let maxSeq = 0;
for (const row of allRows) { const match = (row.supplier_code || "").match(/(\d+)$/); if (match) { const seq = parseInt(match[1], 10); if (seq > maxSeq) maxSeq = seq; } }
@@ -819,7 +819,7 @@ export default function SupplierManagementPage() {
const filters: any[] = [];
if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 5000,
page: 1, size: 0,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
@@ -1227,7 +1227,7 @@ export default function SupplierManagementPage() {
for (const itemId of itemIds) {
// 해당 품목의 모든 매핑 조회 → supplier_id null 처리
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [
{ columnName: "supplier_id", operator: "equals", value: selectedSupplier!.supplier_code },
{ columnName: "item_id", operator: "equals", value: itemId },
@@ -1244,7 +1244,7 @@ export default function SupplierManagementPage() {
// 해당 품목의 모든 단가 조회 → supplier_id null 처리
try {
const priceRes = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [
{ columnName: "supplier_id", operator: "equals", value: selectedSupplier!.supplier_code },
{ columnName: "item_id", operator: "equals", value: itemId },
@@ -1316,7 +1316,7 @@ export default function SupplierManagementPage() {
try {
const allMappings: any[] = [];
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
page: 1, size: 5000, autoFilter: true,
page: 1, size: 0, autoFilter: true,
});
const mappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
const itemIds = [...new Set(mappings.map((m: any) => m.item_id).filter(Boolean))];
@@ -101,7 +101,7 @@ export default function InspectionResultPage() {
try {
const res = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
page: 1,
size: 500,
size: 0,
autoFilter: true,
search: { master_id: masterId },
});
File diff suppressed because it is too large Load Diff
@@ -12,7 +12,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Di
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import {
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ClipboardList,
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, FileSpreadsheet,
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, FileSpreadsheet, Copy,
} from "lucide-react";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { cn } from "@/lib/utils";
@@ -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/선택형)
@@ -119,9 +120,9 @@ export default function ItemInspectionInfoPage() {
const loadOptions = async () => {
try {
const [itemRes, inspRes, userRes] = await Promise.all([
apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, { page: 1, size: 500, autoFilter: true }),
apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, { page: 1, size: 500, autoFilter: true }),
apiClient.post(`/table-management/tables/user_info/data`, { page: 1, size: 500, autoFilter: true }),
apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, { page: 1, size: 0, autoFilter: true }),
apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, { page: 1, size: 0, autoFilter: true }),
apiClient.post(`/table-management/tables/user_info/data`, { page: 1, size: 0, autoFilter: true }),
]);
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
setItemOptions(items.map((r: any) => ({
@@ -253,13 +254,193 @@ 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("");
const [copyFilteredItems, setCopyFilteredItems] = useState<typeof itemOptions>([]);
const [copySearchLoading, setCopySearchLoading] = useState(false);
const [copyPage, setCopyPage] = useState(1);
const [copyTotal, setCopyTotal] = useState(0);
const [copyCheckedIds, setCopyCheckedIds] = useState<string[]>([]);
const [copying, setCopying] = useState(false);
const [copyProgress, setCopyProgress] = useState({ current: 0, total: 0 });
const copyPageSize = 20;
const copyTotalPages = Math.max(1, Math.ceil(copyTotal / copyPageSize));
const searchCopyTargets = async (page?: number) => {
const p = page ?? copyPage;
setCopySearchLoading(true);
try {
const filters: any[] = [];
if (copySearchKeyword.trim()) {
filters.push({ columnName: "item_name", operator: "contains", value: copySearchKeyword.trim() });
}
const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
page: p, size: copyPageSize,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const resData = res.data?.data;
const rows = resData?.data || resData?.rows || [];
const cm = itemCatMapRef.current;
const list = rows
.filter((r: any) => r.item_number !== selectedItemCode)
.map((r: any) => ({
code: r.item_number,
name: r.item_name,
item_type: cm["type"]?.[r.type] || r.type || "",
unit: cm["inventory_unit"]?.[r.inventory_unit] || r.inventory_unit || "",
}));
setCopyFilteredItems(list);
setCopyTotal(resData?.total || resData?.totalCount || rows.length);
} catch { /* skip */ } finally { setCopySearchLoading(false); }
};
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);
};
const handleCopySearch = () => { setCopyPage(1); searchCopyTargets(1); };
const toggleCopyChecked = (code: string) => {
setCopyCheckedIds(prev => prev.includes(code) ? prev.filter(c => c !== code) : [...prev, code]);
};
const handleCopy = async () => {
if (!selectedItemCode) { toast.error("복사 기준 품목이 없어요"); return; }
if (copyCheckedIds.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}개 품목에 편집된 검사정보(${flatRows.length}개 행)를 복사할까요?`,
{ description: "대상 품목의 기존 검사정보는 삭제 후 교체됩니다.", variant: "info", confirmText: "복사" }
);
if (!ok) return;
setCopying(true);
setCopyProgress({ current: 0, total: copyCheckedIds.length });
await new Promise(resolve => setTimeout(resolve, 50));
try {
for (let i = 0; i < copyCheckedIds.length; i++) {
const targetCode = copyCheckedIds[i];
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: 0,
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: targetCode }] },
autoFilter: true,
});
const existing = existRes.data?.data?.data || existRes.data?.data?.rows || [];
if (existing.length > 0) {
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: existing.map((r: any) => ({ id: r.id })) });
}
let orderSeq = 0;
for (const { row: r, typeLabel } of flatRows) {
orderSeq += 1;
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, {
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 });
}
await new Promise(resolve => setTimeout(resolve, 500));
toast.success(`${copyCheckedIds.length}개 품목에 복사했어요`);
setCopyModalOpen(false);
fetchData();
} catch { toast.error("복사에 실패했어요"); }
finally {
setCopying(false);
setCopyProgress({ current: 0, total: 0 });
}
};
/* ═══════════════════ 데이터 조회 ═══════════════════ */
const fetchData = useCallback(async () => {
setLoading(true);
try {
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
@@ -300,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 → 라벨
@@ -329,11 +516,18 @@ export default function ItemInspectionInfoPage() {
try {
const res = 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: code }] },
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> = {};
@@ -360,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,
@@ -378,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) => {
@@ -423,13 +618,53 @@ 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);
try {
if (editMode) {
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: form.item_code }] },
autoFilter: true,
});
@@ -440,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"),
});
}
}
@@ -732,7 +972,6 @@ export default function ItemInspectionInfoPage() {
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={openExcelUpload}>
<FileSpreadsheet className="w-3.5 h-3.5 mr-1" />
</Button>
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" /></Button>
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" /></Button>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => ts.setOpen(true)}><Settings2 className="h-3.5 w-3.5" /></Button>
</div>
@@ -814,6 +1053,7 @@ export default function ItemInspectionInfoPage() {
{selectedGroup && (
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" /></Button>
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={openCopyModal}><Copy className="w-3.5 h-3.5 mr-1" /></Button>
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" /></Button>
</div>
)}
@@ -872,15 +1112,17 @@ 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>
<TableHead className="text-[10px] font-bold h-8 w-[70px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{selectedTabRows.length === 0 ? (
<TableRow>
<TableCell colSpan={7} 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}>
@@ -899,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);
@@ -907,12 +1150,29 @@ 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>
) : "-"}
</TableCell>
<TableCell className="text-xs py-2">
{(() => {
const insp = inspOptions.find(o => o.code === row.inspection_standard_id);
const unitCode = insp?.unit || "";
const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode;
return unitLabel || "-";
})()}
</TableCell>
</TableRow>
))}
</TableBody>
@@ -1074,15 +1334,17 @@ 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>
<TableHead className="text-[10px] font-bold w-[70px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[36px]" />
</TableRow>
</TableHeader>
<TableBody>
{(!inspectionRows[key] || inspectionRows[key].length === 0) ? (
<TableRow><TableCell colSpan={8} 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">
@@ -1107,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>
@@ -1148,6 +1413,7 @@ export default function ItemInspectionInfoPage() {
)}
</TableCell>
<TableCell className="p-1 text-center"><Checkbox checked={row.is_required} onCheckedChange={(v) => updateInspRow(key, row.id, "is_required", !!v)} /></TableCell>
<TableCell className="p-1 text-xs text-muted-foreground">{row.unit || "-"}</TableCell>
<TableCell className="p-1">
<Button type="button" variant="destructive" size="sm" className="h-7 w-7 p-0" onClick={() => removeInspRow(key, row.id)}><Trash2 className="w-3.5 h-3.5" /></Button>
</TableCell>
@@ -1172,6 +1438,278 @@ export default function ItemInspectionInfoPage() {
</DialogContent>
</Dialog>
{/* ═══════════════════ 복사 모달 (2단 분할: 좌 대상 / 우 편집) ═══════════════════ */}
<Dialog open={copyModalOpen} onOpenChange={(v) => { if (!copying) setCopyModalOpen(v); }}>
<DialogContent
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 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>
</DialogDescription>
</DialogHeader>
{copying ? (
<div className="flex-1 flex flex-col items-center justify-center gap-4 py-8 px-4">
<div className="w-full max-w-sm space-y-3">
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin text-blue-600" />
<span className="text-sm font-medium text-blue-700"> ...</span>
<span className="text-xs text-blue-600 ml-auto">
{copyProgress.current.toLocaleString()} / {copyProgress.total.toLocaleString()}
</span>
</div>
<div className="w-full bg-blue-100 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${copyProgress.total > 0 ? Math.round((copyProgress.current / copyProgress.total) * 100) : 0}%` }}
/>
</div>
<p className="text-xs text-muted-foreground text-center pt-2">
. .
</p>
</div>
</div>
) : (
<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>
)}
<DialogFooter className="shrink-0">
<Button variant="outline" onClick={() => setCopyModalOpen(false)} disabled={copying}></Button>
<Button onClick={handleCopy} disabled={copying || copyCheckedIds.length === 0}>
{copying ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Copy className="w-4 h-4 mr-1" />}
{copying
? `복사 중 (${copyProgress.current}/${copyProgress.total})`
: copyCheckedIds.length > 0 ? `${copyCheckedIds.length}개 품목에 복사` : "복사"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<TableSettingsModal open={ts.open} onOpenChange={ts.setOpen} tableName={ts.tableName} settingsId={ts.settingsId} defaultVisibleKeys={ts.defaultVisibleKeys} onSave={ts.applySettings} />
{/* ═══════ 엑셀 업로드 모달 ═══════ */}
@@ -190,7 +190,7 @@ export default function ClaimManagementPage() {
const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
page: 1,
size: 500,
size: 0,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
sort: { columnName: "claim_date", order: "desc" },
@@ -219,7 +219,7 @@ export default function CustomerManagementPage() {
} catch { /* skip */ }
};
load();
apiClient.post(`/table-management/tables/user_info/data`, { page: 1, size: 500, autoFilter: true })
apiClient.post(`/table-management/tables/user_info/data`, { page: 1, size: 0, autoFilter: true })
.then((res) => {
const users = res.data?.data?.data || res.data?.data?.rows || [];
setEmployeeOptions(users.map((u: any) => ({
@@ -284,7 +284,7 @@ export default function CustomerManagementPage() {
const fetchMainContacts = useCallback(async () => {
try {
const contactRes = await apiClient.post(`/table-management/tables/${CONTACT_TABLE}/data`, {
page: 1, size: 500, autoFilter: true,
page: 1, size: 0, autoFilter: true,
dataFilter: { enabled: true, filters: [{ columnName: "is_main", operator: "equals", value: "Y" }] },
});
const allContacts = contactRes.data?.data?.data || contactRes.data?.data?.rows || [];
@@ -310,7 +310,7 @@ export default function CustomerManagementPage() {
setPriceLoading(true);
try {
const mappingRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [
{ columnName: "customer_id", operator: "equals", value: selectedCustomer.customer_code },
]},
@@ -337,7 +337,7 @@ export default function CustomerManagementPage() {
if (mappings.length > 0) {
try {
const priceRes = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [
{ columnName: "customer_id", operator: "equals", value: selectedCustomer.customer_code },
]},
@@ -413,7 +413,7 @@ export default function CustomerManagementPage() {
setDeliveryLoading(true);
try {
const res = await apiClient.post(`/table-management/tables/${DELIVERY_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [
{ columnName: "customer_code", operator: "equals", value: selectedCustomer.customer_code },
]},
@@ -493,7 +493,7 @@ export default function CustomerManagementPage() {
if (ruleData?.success && ruleData?.data?.ruleId) {
const ruleId = ruleData.data.ruleId;
const allRes = await apiClient.post(`/table-management/tables/${DELIVERY_TABLE}/data`, {
page: 1, size: 500, autoFilter: true,
page: 1, size: 0, autoFilter: true,
sort: { columnName: "destination_code", order: "desc" },
});
const allRows = allRes.data?.data?.data || allRes.data?.data?.rows || [];
@@ -566,7 +566,7 @@ export default function CustomerManagementPage() {
const ruleId = ruleData.data.ruleId;
// 기존 데이터에서 CUST-XXX 패턴의 최대 순번 조회
const allRes = await apiClient.post(`/table-management/tables/${CUSTOMER_TABLE}/data`, {
page: 1, size: 500, autoFilter: true,
page: 1, size: 0, autoFilter: true,
sort: { columnName: "customer_code", order: "desc" },
});
const allRows = allRes.data?.data?.data || allRes.data?.data?.rows || [];
@@ -767,7 +767,7 @@ export default function CustomerManagementPage() {
const ruleData = ruleRes.data;
if (ruleData?.success && ruleData?.data?.ruleId) {
const ruleId = ruleData.data.ruleId;
const allRes = await apiClient.post(`/table-management/tables/${CUSTOMER_TABLE}/data`, { page: 1, size: 500, autoFilter: true, sort: { columnName: "customer_code", order: "desc" } });
const allRes = await apiClient.post(`/table-management/tables/${CUSTOMER_TABLE}/data`, { page: 1, size: 0, autoFilter: true, sort: { columnName: "customer_code", order: "desc" } });
const allRows = allRes.data?.data?.data || allRes.data?.data?.rows || [];
let maxSeq = 0;
for (const row of allRows) { const match = (row.customer_code || "").match(/(\d+)$/); if (match) { const seq = parseInt(match[1], 10); if (seq > maxSeq) maxSeq = seq; } }
@@ -822,7 +822,7 @@ export default function CustomerManagementPage() {
? [{ columnName: "division", operator: "contains", value: salesCode }]
: [];
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 5000,
page: 1, size: 0,
dataFilter: { enabled: true, filters },
autoFilter: true,
});
@@ -1247,7 +1247,7 @@ export default function CustomerManagementPage() {
for (const itemId of itemIds) {
// 해당 품목의 모든 매핑 조회 → customer_id null 처리
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [
{ columnName: "customer_id", operator: "equals", value: selectedCustomer!.customer_code },
{ columnName: "item_id", operator: "equals", value: itemId },
@@ -1264,7 +1264,7 @@ export default function CustomerManagementPage() {
// 해당 품목의 모든 단가 조회 → customer_id null 처리
try {
const priceRes = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [
{ columnName: "customer_id", operator: "equals", value: selectedCustomer!.customer_code },
{ columnName: "item_id", operator: "equals", value: itemId },
@@ -1336,7 +1336,7 @@ export default function CustomerManagementPage() {
try {
const allMappings: any[] = [];
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
page: 1, size: 5000, autoFilter: true,
page: 1, size: 0, autoFilter: true,
});
const mappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
const itemIds = [...new Set(mappings.map((m: any) => m.item_id).filter(Boolean))];
@@ -28,6 +28,7 @@ import { ShippingPlanBatchModal } from "@/components/common/ShippingPlanBatchMod
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { SmartSelect } from "@/components/common/SmartSelect";
import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
const DETAIL_TABLE = "sales_order_detail";
@@ -277,7 +278,7 @@ export default function SalesOrderPage() {
// 거래처 목록
try {
const custRes = await apiClient.post(`/table-management/tables/customer_mng/data`, {
page: 1, size: 500, autoFilter: true,
page: 1, size: 0, autoFilter: true,
});
const custs = custRes.data?.data?.data || custRes.data?.data?.rows || [];
optMap["partner_id"] = custs.map((c: any) => ({ code: c.customer_code, label: c.customer_name }));
@@ -285,7 +286,7 @@ export default function SalesOrderPage() {
// 사용자 목록
try {
const userRes = await apiClient.post(`/table-management/tables/user_info/data`, {
page: 1, size: 500, autoFilter: true,
page: 1, size: 0, autoFilter: true,
});
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
optMap["manager_id"] = users.map((u: any) => ({
@@ -330,7 +331,7 @@ export default function SalesOrderPage() {
}));
const res = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
sort: { columnName: "order_no", order: "desc" },
@@ -770,7 +771,7 @@ export default function SalesOrderPage() {
if (isCustomerPrice && partnerId) {
try {
const mappingRes = await apiClient.post(`/table-management/tables/customer_item_mapping/data`, {
page: 1, size: 5000,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [{ columnName: "customer_id", operator: "equals", value: partnerId }] },
autoFilter: true,
});
@@ -860,7 +861,7 @@ export default function SalesOrderPage() {
try {
const itemIds = selected.map((item) => item.item_number || item.id);
const res = await apiClient.post(`/table-management/tables/customer_item_prices/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: {
enabled: true,
filters: [
@@ -931,7 +932,7 @@ export default function SalesOrderPage() {
if (itemCodes.length === 0) return;
try {
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: itemCodes }] },
autoFilter: true,
});
@@ -954,7 +955,7 @@ export default function SalesOrderPage() {
if (itemCodes.length === 0) return;
try {
const res = await apiClient.post(`/table-management/tables/customer_item_prices/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [
{ columnName: "customer_id", operator: "equals", value: partnerId },
{ columnName: "item_id", operator: "in", value: itemCodes },
@@ -1481,17 +1482,12 @@ export default function SalesOrderPage() {
<div className="grid grid-cols-1 gap-3.5 sm:grid-cols-2 lg:grid-cols-4">
<div className="space-y-1">
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select
<SmartSelect
options={categoryOptions["partner_id"] || []}
value={masterForm.partner_id || ""}
onValueChange={(v) => { setMasterForm((p) => ({ ...p, partner_id: v, delivery_partner_id: "" })); loadDeliveryOptions(v); recalcPrices(masterForm.price_mode || "", v); }}
>
<SelectTrigger className="h-9"><SelectValue placeholder="거래처 선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["partner_id"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
placeholder="거래처 선택"
/>
</div>
<div className="space-y-1">
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></Label>
@@ -16,6 +16,7 @@ import {
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { reportApi } from "@/lib/api/reportApi";
import { useCurrent2ndLevelMenuObjid } from "@/hooks/useCurrent2ndLevelMenuObjid";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { exportToExcel } from "@/lib/utils/excelExport";
import { useAuth } from "@/hooks/useAuth";
@@ -153,10 +154,13 @@ export default function QuoteManagementPage() {
useEffect(() => { fetchQuotes(); }, [fetchQuotes]);
const current2ndLevelMenuObjid = useCurrent2ndLevelMenuObjid();
useEffect(() => {
if (current2ndLevelMenuObjid === null) return;
(async () => {
try {
const res = await reportApi.getReports({ page: 1, limit: 100 });
const res = await reportApi.getReportsByMenuObjid(current2ndLevelMenuObjid);
if (res.success) {
const items = res.data.items ?? [];
setReportList(items);
@@ -164,7 +168,7 @@ export default function QuoteManagementPage() {
}
} catch { /* 무시 */ }
})();
}, []);
}, [current2ndLevelMenuObjid]);
// ── "신규" → DB에 draft 즉시 생성 → 자동 선택 ──
@@ -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 }[] = [];
@@ -327,7 +332,7 @@ export default function SalesItemPage() {
}
const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
page: 1, size: 5000,
page: 1, size: 0,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
@@ -373,7 +378,7 @@ export default function SalesItemPage() {
try {
// 1. customer_item_mapping에서 해당 품목의 매핑 조회
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [{ columnName: "item_id", operator: "equals", value: itemKey }] },
autoFilter: true,
sort: { columnName: "created_date", order: "asc" },
@@ -401,7 +406,7 @@ export default function SalesItemPage() {
if (mappings.length > 0) {
try {
const priceRes = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [
{ columnName: "item_id", operator: "equals", value: itemKey },
]},
@@ -1111,7 +1116,7 @@ export default function SalesItemPage() {
for (const custCode of customerCodes) {
// 해당 거래처의 모든 매핑 조회 → item_id null 처리
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [
{ columnName: "item_id", operator: "equals", value: selectedItem!.item_number },
{ columnName: "customer_id", operator: "equals", value: custCode },
@@ -1128,7 +1133,7 @@ export default function SalesItemPage() {
// 해당 거래처의 모든 단가 조회 → item_id null 처리
try {
const priceRes = await apiClient.post(`/table-management/tables/customer_item_prices/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [
{ columnName: "item_id", operator: "equals", value: selectedItem!.item_number },
{ columnName: "customer_id", operator: "equals", value: custCode },
@@ -150,17 +150,17 @@ export default function EquipmentInfoPage() {
const colProps: Record<string, Partial<EDataTableColumn>> = {
equipment_code: { width: "w-[110px]" },
equipment_name: { minWidth: "min-w-[130px]", truncate: true, render: (v) => v || "-" },
equipment_type: { width: "w-[90px]", render: (v) => v || "-" },
equipment_type: { width: "w-[90px]", render: (v) => resolve("equipment_type", v) || v || "-" },
manufacturer: { width: "w-[100px]", render: (v) => v || "-" },
installation_location: { width: "w-[100px]", render: (v) => v || "-" },
operation_status: { width: "w-[80px]", render: (v) => v || "-" },
operation_status: { width: "w-[80px]", render: (v) => resolve("operation_status", v) || v || "-" },
};
return ts.visibleColumns.map((col) => ({
key: col.key,
label: col.label,
...colProps[col.key],
}));
}, [ts.visibleColumns]);
}, [ts.visibleColumns, catOptions]);
// 설비 조회
const fetchEquipments = useCallback(async () => {
@@ -168,16 +168,12 @@ export default function EquipmentInfoPage() {
try {
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
const res = await apiClient.post(`/table-management/tables/${EQUIP_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const raw = res.data?.data?.data || res.data?.data?.rows || [];
setEquipments(raw.map((r: any) => ({
...r,
equipment_type: resolve("equipment_type", r.equipment_type),
operation_status: resolve("operation_status", r.operation_status),
})));
setEquipments(raw);
setEquipCount(res.data?.data?.total || raw.length);
} catch { toast.error("설비 목록 조회 실패"); } finally { setEquipLoading(false); }
}, [searchFilters, catOptions]);
@@ -213,7 +209,7 @@ export default function EquipmentInfoPage() {
setInspectionLoading(true);
try {
const res = await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "equals", value: selectedEquip.equipment_code }] },
autoFilter: true,
});
@@ -231,7 +227,7 @@ export default function EquipmentInfoPage() {
setConsumableLoading(true);
try {
const res = await apiClient.post(`/table-management/tables/${CONSUMABLE_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "equals", value: selectedEquip.equipment_code }] },
autoFilter: true,
});
@@ -407,7 +403,7 @@ export default function EquipmentInfoPage() {
if (consumableDiv) filters.push({ columnName: "division", operator: "equals", value: consumableDiv.valueCode });
const results = await Promise.all(filters.map((f) =>
apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [f] },
autoFilter: true,
})
@@ -454,7 +450,7 @@ export default function EquipmentInfoPage() {
setCopyLoading(true);
try {
const res = await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "equals", value: equipCode }] },
autoFilter: true,
});
@@ -482,9 +478,9 @@ export default function EquipmentInfoPage() {
const handleExcelDownload = async () => {
if (equipments.length === 0) return;
await exportToExcel(equipments.map((e) => ({
설비코드: e.equipment_code, 설비명: e.equipment_name, 설비유형: e.equipment_type,
설비코드: e.equipment_code, 설비명: e.equipment_name, 설비유형: resolve("equipment_type", e.equipment_type),
제조사: e.manufacturer, 모델명: e.model_name, 설치장소: e.installation_location,
도입일자: e.introduction_date, 가동상태: e.operation_status,
도입일자: e.introduction_date, 가동상태: resolve("operation_status", e.operation_status),
})), "설비정보.xlsx", "설비");
toast.success("다운로드 완료");
};
@@ -83,7 +83,7 @@ export default function EquipmentInspectionRecordPage() {
}).catch(() => ({ data: { data: { data: [] } } })),
apiClient.post(`/table-management/tables/equipment_mng/data`, {
page: 1,
size: 500,
size: 0,
autoFilter: true,
}).catch(() => ({ data: { data: { data: [] } } })),
]);
@@ -100,7 +100,7 @@ export default function PlcSettingsPage() {
useEffect(() => {
const load = async () => {
try {
const eqRes = await apiClient.post(`/table-management/tables/${EQUIPMENT_TABLE}/data`, { page: 1, size: 500, autoFilter: true });
const eqRes = await apiClient.post(`/table-management/tables/${EQUIPMENT_TABLE}/data`, { page: 1, size: 0, autoFilter: true });
const eqs = eqRes.data?.data?.data || eqRes.data?.data?.rows || [];
setEquipOptions(eqs.map((r: any) => ({ code: r.equipment_code, label: `${r.equipment_code} - ${r.equipment_name || ""}` })));
} catch { /* skip */ }
@@ -122,7 +122,7 @@ export default function PlcSettingsPage() {
const filters: any[] = [];
if (kw.trim()) filters.push({ columnName: "equipment_code", operator: "contains", value: kw.trim() });
const res = await apiClient.post(`/table-management/tables/${DATATYPE_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
@@ -140,7 +140,7 @@ export default function PlcSettingsPage() {
const filters: any[] = [];
if (kw.trim()) filters.push({ columnName: "config_name", operator: "contains", value: kw.trim() });
const res = await apiClient.post(`/table-management/tables/${COLLECTION_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
@@ -150,7 +150,7 @@ export default function InboundOutboundPage() {
if (writerIds.length > 0) {
try {
const userRes = await apiClient.post(`/table-management/tables/user_info/data`, {
page: 1, size: 500, autoFilter: true,
page: 1, size: 0, autoFilter: true,
});
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
const uMap: Record<string, string> = {};
@@ -327,11 +327,11 @@ export default function LogisticsInfoPage() {
try {
const [carrierRes, routeRes] = await Promise.all([
apiClient.post("/table-management/tables/carrier_mng/data", {
page: 1, size: 500, autoFilter: true,
page: 1, size: 0, autoFilter: true,
sort: { columnName: "carrier_code", order: "asc" },
}),
apiClient.post("/table-management/tables/delivery_route_mng/data", {
page: 1, size: 500, autoFilter: true,
page: 1, size: 0, autoFilter: true,
sort: { columnName: "route_code", order: "asc" },
}),
]);
@@ -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,
@@ -393,7 +395,7 @@ export default function LogisticsInfoPage() {
const res = await apiClient.post(
`/table-management/tables/${config.tableName}/data`,
{
page: 1, size: 500, autoFilter: true,
page: 1, size: 0, autoFilter: true,
sort: { columnName: config.defaultSortColumn, order: "asc" },
}
);
@@ -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]}
@@ -13,6 +13,7 @@ import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
@@ -68,6 +69,7 @@ const STOCK_TABLE = "inventory_stock";
const STOCK_COLUMNS = [
{ key: "item_code", label: "품목코드" },
{ key: "item_name", label: "품명" },
{ key: "spec", label: "규격" },
{ key: "warehouse_code", label: "창고" },
{ key: "location_code", label: "위치" },
{ key: "current_qty", label: "현재수량", align: "right" as const },
@@ -87,6 +89,8 @@ const getStatusVariant = (
return "destructive";
case "과잉":
return "secondary";
case "미등록":
return "outline";
default:
return "outline";
}
@@ -119,6 +123,15 @@ export default function InventoryStatusPage() {
const [stockLoading, setStockLoading] = useState(false);
const [selectedStockId, setSelectedStockId] = useState<string | null>(null);
// 재고 없는 품목 표시 여부
const [showMissingItems, setShowMissingItems] = useState(false);
// 창고 목록 (조정 모달에서 사용)
const [warehouseList, setWarehouseList] = useState<{ code: string; name: string }[]>([]);
// 선택된 창고의 위치 목록 (조정 모달에서 사용)
const [locationList, setLocationList] = useState<{ code: string; name: string }[]>([]);
// 검색 필터
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
@@ -132,7 +145,9 @@ export default function InventoryStatusPage() {
adjust_type: string;
adjust_qty: string;
reason: string;
}>({ adjust_type: "증가", adjust_qty: "", reason: "" });
warehouse_code: string;
location_code: string;
}>({ adjust_type: "증가", adjust_qty: "", reason: "", warehouse_code: "", location_code: "" });
const [adjustSaving, setAdjustSaving] = useState(false);
// 카테고리 옵션
@@ -171,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);
@@ -190,19 +205,20 @@ export default function InventoryStatusPage() {
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
const [stockRes, itemRes, whRes] = await Promise.all([
apiClient.post(`/table-management/tables/${STOCK_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
sort: { columnName: "item_code", order: "asc" },
}),
apiClient.post(`/table-management/tables/item_info/data`, { page: 1, size: 500, autoFilter: true }),
apiClient.post(`/table-management/tables/warehouse_info/data`, { page: 1, size: 500, autoFilter: true }),
apiClient.post(`/table-management/tables/item_info/data`, { page: 1, size: 0, autoFilter: true }),
apiClient.post(`/table-management/tables/warehouse_info/data`, { page: 1, size: 0, autoFilter: true }),
]);
const raw = stockRes.data?.data?.data || stockRes.data?.data?.rows || [];
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
const warehouses = whRes.data?.data?.data || whRes.data?.data?.rows || [];
const itemMap = new Map(items.map((i: any) => [i.item_number || i.item_code, { name: i.item_name || "", unit: i.inventory_unit || "" }]));
const itemMap = new Map(items.map((i: any) => [i.item_number || i.item_code, { name: i.item_name || "", unit: i.inventory_unit || "", spec: i.size || "" }]));
const whMap = new Map(warehouses.map((w: any) => [w.warehouse_code, w.warehouse_name || w.warehouse_code]));
setWarehouseList(warehouses.map((w: any) => ({ code: w.warehouse_code, name: w.warehouse_name || w.warehouse_code })));
const resolve = (col: string, code: string) => {
if (!code) return "";
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
@@ -213,19 +229,51 @@ export default function InventoryStatusPage() {
return {
...r,
item_name: itemInfo?.name || "",
spec: itemInfo?.spec || "",
unit: resolve("item_inventory_unit", rawUnit) || rawUnit,
warehouse_name: whMap.get(r.warehouse_code) || r.warehouse_code || "",
status: resolve("status", r.status),
_isLow: r.safety_qty && Number(r.current_qty) < Number(r.safety_qty),
};
});
setStockItems(data);
// 재고 없는 품목 표시: inventory_stock에 없는 item_info 품목을 미등록 가상 행으로 추가
if (showMissingItems) {
const existingCodes = new Set(raw.map((r: any) => r.item_code).filter(Boolean));
const missingRows = items
.filter((i: any) => {
const code = i.item_number || i.item_code;
return code && !existingCodes.has(code);
})
.map((i: any) => {
const code = i.item_number || i.item_code;
const rawUnit = i.inventory_unit || "";
return {
id: `missing-${code}`,
item_code: code,
item_name: i.item_name || "",
spec: i.size || "",
warehouse_code: "",
warehouse_name: "",
location_code: "",
current_qty: "0",
safety_qty: "",
unit: resolve("item_inventory_unit", rawUnit) || rawUnit,
status: "미등록",
_isLow: false,
_isMissing: true,
};
});
setStockItems([...data, ...missingRows]);
} else {
setStockItems(data);
}
} catch {
toast.error("재고 목록을 불러오지 못했어요");
} finally {
setStockLoading(false);
}
}, [categoryOptions, searchFilters]);
}, [categoryOptions, searchFilters, showMissingItems]);
useEffect(() => {
fetchStock();
@@ -260,7 +308,7 @@ export default function InventoryStatusPage() {
`/table-management/tables/${HISTORY_TABLE}/data`,
{
page: 1,
size: 500,
size: 0,
dataFilter: { enabled: true, filters: historyFilters },
autoFilter: true,
sort: { columnName: "transaction_date", order: "desc" },
@@ -279,6 +327,36 @@ export default function InventoryStatusPage() {
fetchHistory();
}, [fetchHistory]);
// 창고 선택 시 해당 창고의 위치 목록 조회 (조정 모달용)
useEffect(() => {
const whCode = adjustForm.warehouse_code;
if (!whCode) {
setLocationList([]);
return;
}
(async () => {
try {
const res = await apiClient.post(`/table-management/tables/warehouse_location/data`, {
page: 1,
size: 0,
dataFilter: {
enabled: true,
filters: [{ columnName: "warehouse_code", operator: "equals", value: whCode }],
},
autoFilter: true,
});
const rows = res.data?.data?.data || res.data?.data?.rows || [];
setLocationList(
rows
.filter((r: any) => r.location_code)
.map((r: any) => ({ code: r.location_code, name: r.location_name || r.location_code }))
);
} catch {
setLocationList([]);
}
})();
}, [adjustForm.warehouse_code]);
// 재고 조정 저장
const handleAdjustSave = async () => {
if (!selectedStock) return;
@@ -291,6 +369,20 @@ export default function InventoryStatusPage() {
toast.error("조정 사유를 입력해주세요");
return;
}
const isMissing = !!selectedStock._isMissing;
const targetWhCode = isMissing ? adjustForm.warehouse_code : (selectedStock.warehouse_code || "");
const targetLocCode = isMissing ? adjustForm.location_code : (selectedStock.location_code || "");
if (isMissing && !targetWhCode) {
toast.error("창고를 선택해주세요");
return;
}
if (isMissing && adjustForm.adjust_type === "감소") {
toast.error("미등록 품목은 감소 조정이 불가해요");
return;
}
setAdjustSaving(true);
try {
const changeQty = adjustForm.adjust_type === "증가" ? qty : -qty;
@@ -301,8 +393,8 @@ export default function InventoryStatusPage() {
{
id: crypto.randomUUID(),
item_code: selectedStock.item_code,
warehouse_code: selectedStock.warehouse_code || "",
location_code: selectedStock.location_code || "",
warehouse_code: targetWhCode,
location_code: targetLocCode,
transaction_type: "조정",
transaction_date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().replace("Z", "+09:00"),
quantity: String(changeQty),
@@ -311,17 +403,34 @@ export default function InventoryStatusPage() {
}
);
await apiClient.put(
`/table-management/tables/${STOCK_TABLE}/edit`,
{
originalData: { id: selectedStock.id },
updatedData: { current_qty: afterQty },
}
);
if (isMissing) {
// 새 재고 레코드 생성
await apiClient.post(
`/table-management/tables/${STOCK_TABLE}/add`,
{
id: crypto.randomUUID(),
item_code: selectedStock.item_code,
warehouse_code: targetWhCode,
location_code: targetLocCode,
current_qty: String(afterQty),
safety_qty: "0",
last_in_date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().replace("Z", "+09:00"),
}
);
} else {
await apiClient.put(
`/table-management/tables/${STOCK_TABLE}/edit`,
{
originalData: { id: selectedStock.id },
updatedData: { current_qty: afterQty },
}
);
}
toast.success("재고가 조정되었어요");
setAdjustModalOpen(false);
setAdjustForm({ adjust_type: "증가", adjust_qty: "", reason: "" });
setAdjustForm({ adjust_type: "증가", adjust_qty: "", reason: "", warehouse_code: "", location_code: "" });
setSelectedStockId(null);
fetchStock();
} catch {
toast.error("재고 조정에 실패했어요");
@@ -385,6 +494,7 @@ export default function InventoryStatusPage() {
stockItems.map((r) => ({
품목코드: r.item_code,
품명: r.item_name,
규격: r.spec || "",
창고: r.warehouse_name || r.warehouse_code,
위치: r.location_code,
현재수량: r.current_qty,
@@ -438,6 +548,13 @@ export default function InventoryStatusPage() {
{stockItems.length}
</Badge>
</div>
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
<Checkbox
checked={showMissingItems}
onCheckedChange={(v) => setShowMissingItems(!!v)}
/>
<span> </span>
</label>
</div>
<EDataTable
@@ -513,6 +630,8 @@ export default function InventoryStatusPage() {
adjust_type: "증가",
adjust_qty: "",
reason: "",
warehouse_code: selectedStock._isMissing ? "" : (selectedStock.warehouse_code || ""),
location_code: selectedStock._isMissing ? "" : (selectedStock.location_code || ""),
});
setAdjustModalOpen(true);
}}
@@ -672,6 +791,68 @@ export default function InventoryStatusPage() {
</DialogHeader>
<div className="grid gap-4 py-2">
{selectedStock?._isMissing && (
<>
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground">
<span className="text-destructive">*</span>
</Label>
<Select
value={adjustForm.warehouse_code}
onValueChange={(v) =>
setAdjustForm((prev) => ({
...prev,
warehouse_code: v,
location_code: "",
}))
}
>
<SelectTrigger>
<SelectValue placeholder="창고를 선택해주세요" />
</SelectTrigger>
<SelectContent>
{warehouseList.map((w) => (
<SelectItem key={w.code} value={w.code}>
{w.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground">
</Label>
<Select
value={adjustForm.location_code}
onValueChange={(v) =>
setAdjustForm((prev) => ({ ...prev, location_code: v }))
}
disabled={!adjustForm.warehouse_code || locationList.length === 0}
>
<SelectTrigger>
<SelectValue
placeholder={
!adjustForm.warehouse_code
? "창고를 먼저 선택하세요"
: locationList.length === 0
? "등록된 위치가 없어요"
: "위치 선택 (선택 사항)"
}
/>
</SelectTrigger>
<SelectContent>
{locationList.map((l) => (
<SelectItem key={l.code} value={l.code}>
{l.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
)}
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground">
@@ -681,6 +862,7 @@ export default function InventoryStatusPage() {
onValueChange={(v) =>
setAdjustForm((prev) => ({ ...prev, adjust_type: v }))
}
disabled={!!selectedStock?._isMissing}
>
<SelectTrigger>
<SelectValue placeholder="조정 유형 선택" />
@@ -690,6 +872,11 @@ export default function InventoryStatusPage() {
<SelectItem value="감소"> ( )</SelectItem>
</SelectContent>
</Select>
{selectedStock?._isMissing && (
<p className="text-[10px] text-muted-foreground">
( )
</p>
)}
</div>
<div className="grid gap-1.5">
@@ -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);
@@ -528,9 +568,9 @@ export default function PackagingPage() {
{/* 4. 콘텐츠 영역 */}
{activeTab === "packing" ? (
<div className="flex flex-1 flex-col overflow-hidden rounded-lg border bg-card">
<div className="flex flex-1 flex-row overflow-hidden rounded-lg border bg-card">
{/* 포장재 목록 테이블 */}
<div className={cn("overflow-auto", selectedPkg ? "flex-[0_0_50%] border-b" : "flex-1")}>
<div className="flex-[0_0_50%] overflow-auto border-r">
<EDataTable
columns={ts.visibleColumns.map((col): EDataTableColumn<PkgUnit> => {
const renderMap: Record<string, Partial<EDataTableColumn<PkgUnit>>> = {
@@ -570,8 +610,8 @@ export default function PackagingPage() {
</div>
{/* 매칭 품목 서브패널 */}
{selectedPkg && (
<>
{selectedPkg ? (
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex shrink-0 items-center justify-between bg-muted/50 px-4 py-2">
<div className="flex items-center gap-2">
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"> </span>
@@ -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)}>
@@ -635,14 +675,21 @@ export default function PackagingPage() {
</Table>
)}
</div>
</>
</div>
) : (
<div className="flex flex-1 items-center justify-center p-8">
<div className="text-center">
<Package className="mx-auto mb-2 h-10 w-10 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground"> </p>
</div>
</div>
)}
</div>
) : (
/* 적재함 관리 탭 */
<div className="flex flex-1 flex-col overflow-hidden rounded-lg border bg-card">
<div className="flex flex-1 flex-row overflow-hidden rounded-lg border bg-card">
{/* 적재함 목록 테이블 */}
<div className={cn("overflow-auto", selectedLoading ? "flex-[0_0_50%] border-b" : "flex-1")}>
<div className="flex-[0_0_50%] overflow-auto border-r">
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
@@ -709,8 +756,8 @@ export default function PackagingPage() {
</div>
{/* 포장구성 서브패널 */}
{selectedLoading && (
<>
{selectedLoading ? (
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex shrink-0 items-center justify-between bg-muted/50 px-4 py-2">
<div className="flex items-center gap-2">
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"> </span>
@@ -774,7 +821,14 @@ export default function PackagingPage() {
</Table>
)}
</div>
</>
</div>
) : (
<div className="flex flex-1 items-center justify-center p-8">
<div className="text-center">
<Box className="mx-auto mb-2 h-10 w-10 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground"> </p>
</div>
</div>
)}
</div>
)}
@@ -940,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>
@@ -250,6 +250,8 @@ interface SelectedSourceItem {
total_amount: number;
source_table: string;
source_id: string;
detail_id?: string;
header_id?: string;
}
export default function ReceivingPage() {
@@ -584,7 +586,7 @@ export default function ReceivingPage() {
const first = grouped[0] || row;
setEditMode(true);
setEditItemIds(grouped.map((g) => g.id));
setEditItemIds(grouped.map((g, idx) => (g as any).detail_id || `${g.id}__${idx}`));
setModalInboundNo(inNo);
setModalInboundType(first.inbound_type || "구매입고");
setModalInboundDate(first.inbound_date ? String(first.inbound_date).slice(0, 10) : "");
@@ -594,8 +596,10 @@ export default function ReceivingPage() {
setModalManager((first as any).manager || "");
setModalMemo(first.memo || "");
setSelectedItems(
grouped.map((g) => ({
key: g.id,
grouped.map((g, idx) => ({
key: (g as any).detail_id || `${g.id}__${idx}`,
detail_id: (g as any).detail_id || undefined,
header_id: g.id,
inbound_type: (g as any).detail_inbound_type || g.inbound_type || "",
reference_number: g.reference_number || "",
supplier_code: (g as any).supplier_code || "",
@@ -782,7 +786,7 @@ export default function ReceivingPage() {
await Promise.all([
...toDelete.map((id) => deleteReceiving(id)),
...toUpdate.map((item) =>
updateReceiving(item.key, {
updateReceiving(item.header_id || item.key, {
inbound_date: modalInboundDate,
inbound_qty: item.inbound_qty,
unit_price: item.unit_price,
@@ -790,6 +794,7 @@ export default function ReceivingPage() {
warehouse_code: modalWarehouse || undefined,
location_code: modalLocation || undefined,
memo: modalMemo || undefined,
detail_id: item.detail_id,
} as any)
),
...(toCreate.length > 0
@@ -74,7 +74,7 @@ const WAREHOUSE_COLUMNS = [
{ key: "warehouse_code", label: "창고코드" },
{ key: "warehouse_name", label: "창고명" },
{ key: "warehouse_type", label: "유형" },
{ key: "manager", label: "관리자" },
{ key: "manager_name", label: "관리자" },
{ key: "status", label: "상태" },
];
const LOCATION_TABLE = "warehouse_location";
@@ -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<
@@ -230,7 +234,7 @@ export default function WarehouseManagementPage() {
`/table-management/tables/${WAREHOUSE_TABLE}/data`,
{
page: 1,
size: 500,
size: 0,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
sort: { columnName: "warehouse_code", order: "asc" },
@@ -239,6 +243,8 @@ export default function WarehouseManagementPage() {
const raw = res.data?.data?.data || res.data?.data?.rows || [];
const data = raw.map((r: any) => ({
...r,
_warehouse_type_code: r.warehouse_type,
_status_code: r.status,
warehouse_type: resolveCategory(categoryOptions, "warehouse_type", r.warehouse_type),
status: resolveCategory(categoryOptions, "status", r.status),
}));
@@ -270,7 +276,7 @@ export default function WarehouseManagementPage() {
`/table-management/tables/${LOCATION_TABLE}/data`,
{
page: 1,
size: 500,
size: 0,
dataFilter: {
enabled: true,
filters: [
@@ -344,7 +350,11 @@ export default function WarehouseManagementPage() {
const openWarehouseEditModal = (row: any) => {
setWarehouseEditMode(true);
setWarehouseForm({ ...row });
setWarehouseForm({
...row,
warehouse_type: row._warehouse_type_code ?? row.warehouse_type ?? "",
status: row._status_code ?? row.status ?? "",
});
setWarehouseModalOpen(true);
};
@@ -374,10 +384,10 @@ export default function WarehouseManagementPage() {
warehouse_code: finalWarehouseCode,
warehouse_name: warehouseForm.warehouse_name?.trim(),
warehouse_type: warehouseForm.warehouse_type || "",
manager: warehouseForm.manager || "",
address: warehouseForm.address || "",
manager_name: warehouseForm.manager_name || "",
contact: warehouseForm.contact || "",
status: warehouseForm.status || "",
description: warehouseForm.description || "",
memo: warehouseForm.memo || "",
};
// 신규 등록 시 창고코드 중복 체크
@@ -630,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,
@@ -729,7 +739,7 @@ export default function WarehouseManagementPage() {
창고코드: r.warehouse_code,
창고명: r.warehouse_name,
유형: r.warehouse_type,
관리자: r.manager,
관리자: r.manager_name,
상태: r.status,
})),
"창고정보"
@@ -1041,9 +1051,9 @@ export default function WarehouseManagementPage() {
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Input
value={warehouseForm.manager || ""}
value={warehouseForm.manager_name || ""}
onChange={(e) =>
setWarehouseForm((prev) => ({ ...prev, manager: e.target.value }))
setWarehouseForm((prev) => ({ ...prev, manager_name: e.target.value }))
}
placeholder="관리자를 입력해주세요"
/>
@@ -1069,24 +1079,24 @@ export default function WarehouseManagementPage() {
</SelectContent>
</Select>
</div>
{/* 주소 (전체 너비) */}
{/* 연락처 (전체 너비) */}
<div className="grid gap-1.5 col-span-2">
<Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Input
value={warehouseForm.address || ""}
value={warehouseForm.contact || ""}
onChange={(e) =>
setWarehouseForm((prev) => ({ ...prev, address: e.target.value }))
setWarehouseForm((prev) => ({ ...prev, contact: e.target.value }))
}
placeholder="주소를 입력해주세요"
placeholder="연락처를 입력해주세요"
/>
</div>
{/* 비고 (전체 너비) */}
<div className="grid gap-1.5 col-span-2">
<Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Input
value={warehouseForm.description || ""}
value={warehouseForm.memo || ""}
onChange={(e) =>
setWarehouseForm((prev) => ({ ...prev, description: e.target.value }))
setWarehouseForm((prev) => ({ ...prev, memo: e.target.value }))
}
placeholder="비고를 입력해주세요"
/>
@@ -1496,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">
@@ -193,7 +193,7 @@ export default function CompanyPage() {
setDeptLoading(true);
try {
const res = await apiClient.post(`/table-management/tables/${DEPT_TABLE}/data`, {
page: 1, size: 500, autoFilter: true,
page: 1, size: 0, autoFilter: true,
});
const raw = res.data?.data?.data || res.data?.data?.rows || [];
setDepts(raw);
@@ -217,7 +217,7 @@ export default function CompanyPage() {
setMemberLoading(true);
try {
const res = await apiClient.post(`/table-management/tables/${USER_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [{ columnName: "dept_code", operator: "equals", value: selectedDeptCode }] },
autoFilter: true,
});
@@ -563,10 +563,6 @@ export default function CompanyPage() {
{/* 기본 정보 그리드 (2열) */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Input value={companyForm.company_code || ""} className="h-9 bg-muted/50" disabled readOnly />
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<span className="text-destructive">*</span>
@@ -99,7 +99,7 @@ export default function DepartmentPage() {
try {
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
const res = await apiClient.post(`/table-management/tables/${DEPT_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
@@ -129,7 +129,7 @@ export default function DepartmentPage() {
? [{ columnName: "dept_code", operator: "equals", value: selectedDeptCode }]
: [];
const res = await apiClient.post(`/table-management/tables/${USER_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
@@ -5,7 +5,12 @@ import { Settings2, Tags, Hash } from "lucide-react";
import { cn } from "@/lib/utils";
import { CategoryColumnList } from "@/components/table-category/CategoryColumnList";
import { CategoryValueManager } from "@/components/table-category/CategoryValueManager";
import { CategoryValueManagerTree } from "@/components/table-category/CategoryValueManagerTree";
import { NumberingRuleDesigner } from "@/components/numbering-rule/NumberingRuleDesigner";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
const TABS = [
{ id: "category", label: "카테고리 설정", icon: Tags },
@@ -21,6 +26,13 @@ export default function OptionsSettingPage() {
const [selectedColumnLabel, setSelectedColumnLabel] = useState("");
const [selectedTableName, setSelectedTableName] = useState("");
// 하위분류 사용 여부 (카테고리별 자동감지 + 수동 토글)
const [useHierarchy, setUseHierarchy] = useState(false);
const [hasChildRows, setHasChildRows] = useState(false);
const [detectingHierarchy, setDetectingHierarchy] = useState(false);
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
const [leftWidth, setLeftWidth] = useState(340);
const [isDragging, setIsDragging] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
@@ -51,6 +63,72 @@ export default function OptionsSettingPage() {
};
}, [isDragging]);
// 카테고리 선택 변경 시 하위분류 존재 여부 자동감지
useEffect(() => {
if (!selectedColumn || !selectedTableName) {
setUseHierarchy(false);
setHasChildRows(false);
return;
}
const columnNameOnly = selectedColumn.includes(".")
? selectedColumn.split(".").pop()!
: selectedColumn;
let cancelled = false;
setDetectingHierarchy(true);
(async () => {
const res = await getCategoryValues(selectedTableName, columnNameOnly, true);
if (cancelled) return;
const values = (res as any)?.data || [];
const hasChild = Array.isArray(values)
? values.some(
(v: any) =>
(typeof v.depth === "number" && v.depth > 1) ||
(v.parentValueId !== null && v.parentValueId !== undefined),
)
: false;
setHasChildRows(hasChild);
setUseHierarchy(hasChild);
setDetectingHierarchy(false);
})();
return () => {
cancelled = true;
};
}, [selectedColumn, selectedTableName]);
const handleToggleHierarchy = useCallback(
async (checked: boolean) => {
if (!checked && hasChildRows) {
const ok = await confirm(
"이미 등록된 하위분류(중/소분류)가 있습니다.\n하위분류 사용을 해제해도 기존 데이터는 삭제되지 않으며, 다시 사용 설정 시 그대로 복원됩니다.\n계속하시겠습니까?",
{ variant: "destructive", confirmText: "해제" },
);
if (!ok) return;
}
setUseHierarchy(checked);
},
[hasChildRows, confirm],
);
const columnNameOnly = selectedColumn
? selectedColumn.includes(".")
? selectedColumn.split(".").pop()!
: selectedColumn
: "";
const headerRight = selectedColumn ? (
<div className="flex items-center gap-2">
<Label htmlFor="use-hierarchy-switch" className="cursor-pointer text-xs">
</Label>
<Switch
id="use-hierarchy-switch"
checked={useHierarchy}
onCheckedChange={handleToggleHierarchy}
disabled={detectingHierarchy}
/>
</div>
) : null;
return (
<div className="flex h-full flex-col p-3 gap-3">
<div className="flex items-center gap-4">
@@ -108,11 +186,21 @@ export default function OptionsSettingPage() {
<div className="flex-1 min-w-0 border rounded-lg bg-card overflow-hidden">
{selectedColumn && selectedTableName ? (
<CategoryValueManager
tableName={selectedTableName}
columnName={selectedColumn.includes(".") ? selectedColumn.split(".").pop()! : selectedColumn}
columnLabel={selectedColumnLabel}
/>
useHierarchy ? (
<CategoryValueManagerTree
tableName={selectedTableName}
columnName={columnNameOnly}
columnLabel={selectedColumnLabel}
headerRight={headerRight}
/>
) : (
<CategoryValueManager
tableName={selectedTableName}
columnName={columnNameOnly}
columnLabel={selectedColumnLabel}
headerRight={headerRight}
/>
)
) : (
<div className="flex h-full items-center justify-center">
<div className="text-center space-y-2">
@@ -131,6 +219,7 @@ export default function OptionsSettingPage() {
</div>
)}
</div>
{ConfirmDialogComponent}
</div>
);
}
@@ -72,6 +72,36 @@ export default function MoldInfoPage() {
const [selectedMoldCode, setSelectedMoldCode] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<"list" | "grid">("list");
// ─── 카테고리 옵션 (금형유형, 운영상태) ───
const [moldTypeCatOptions, setMoldTypeCatOptions] = useState<{ code: string; label: string }[]>([]);
const [operationStatusCatOptions, setOperationStatusCatOptions] = useState<{ code: string; label: string }[]>([]);
useEffect(() => {
const flatten = (arr: any[]): { code: string; label: string }[] => {
const result: { code: string; label: string }[] = [];
for (const v of arr) { result.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) result.push(...flatten(v.children)); }
return result;
};
(async () => {
try {
const [typeRes, statusRes] = await Promise.all([
apiClient.get("/table-categories/mold_mng/mold_type/values"),
apiClient.get("/table-categories/mold_mng/operation_status/values"),
]);
if (typeRes.data?.success) setMoldTypeCatOptions(flatten(typeRes.data.data || []));
if (statusRes.data?.success) setOperationStatusCatOptions(flatten(statusRes.data.data || []));
} catch { /* skip */ }
})();
}, []);
const resolveMoldType = (code: string) => moldTypeCatOptions.find((o) => o.code === code)?.label || code;
const resolveOpStatus = (code: string) => {
const catLabel = operationStatusCatOptions.find((o) => o.code === code)?.label;
if (catLabel) return catLabel;
const legacyMap: Record<string, string> = { ACTIVE: "사용중", INACTIVE: "미사용", REPAIR: "수리중", DISPOSED: "폐기", IN_USE: "사용중" };
return legacyMap[code] || code;
};
// ─── 검색 필터 ───
const [filterCode, setFilterCode] = useState("");
const [filterName, setFilterName] = useState("");
@@ -426,7 +456,7 @@ export default function MoldInfoPage() {
// ─── 카드 렌더링 ───
const renderCard = (mold: any) => {
const pct = calcLifePct(mold);
const st = STATUS_MAP[mold.operation_status] || { label: mold.operation_status || "-", variant: "secondary" as const };
const stLabel = resolveOpStatus(mold.operation_status);
const isSelected = selectedMoldCode === mold.mold_code;
return (
@@ -460,7 +490,7 @@ export default function MoldInfoPage() {
<Box className="w-8 h-8 text-muted-foreground/50" />
)}
<div className="absolute top-2 right-2">
<Badge variant={st.variant} className="text-[10px]">{st.label}</Badge>
<Badge variant="secondary" className="text-[10px]">{stLabel}</Badge>
</div>
</div>
@@ -470,7 +500,7 @@ export default function MoldInfoPage() {
<p className="text-xs text-muted-foreground font-mono truncate">{mold.mold_code}</p>
<p className="text-sm font-semibold truncate">{mold.mold_name}</p>
{mold.mold_type && (
<Badge variant="outline" className="text-[10px] mt-1">{mold.mold_type}</Badge>
<Badge variant="outline" className="text-[10px] mt-1">{resolveMoldType(mold.mold_type)}</Badge>
)}
</div>
@@ -531,10 +561,7 @@ export default function MoldInfoPage() {
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__"></SelectItem>
<SelectItem value="사출금형"></SelectItem>
<SelectItem value="프레스금형"></SelectItem>
<SelectItem value="다이캐스팅"></SelectItem>
<SelectItem value="단조금형"></SelectItem>
{moldTypeCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
@@ -546,10 +573,7 @@ export default function MoldInfoPage() {
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__"></SelectItem>
<SelectItem value="ACTIVE"></SelectItem>
<SelectItem value="INSPECTION"></SelectItem>
<SelectItem value="REPAIR"></SelectItem>
<SelectItem value="DISPOSED"></SelectItem>
{operationStatusCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
@@ -670,13 +694,13 @@ export default function MoldInfoPage() {
<h2 className="text-xl font-bold mb-2 truncate">{selectedMold.mold_name}</h2>
<div className="flex gap-1.5 mb-4 flex-wrap">
{selectedMold.mold_type && (
<Badge variant="outline">{selectedMold.mold_type}</Badge>
<Badge variant="outline">{resolveMoldType(selectedMold.mold_type)}</Badge>
)}
{selectedMold.category && (
<Badge variant="secondary">{selectedMold.category}</Badge>
)}
<Badge variant={STATUS_MAP[selectedMold.operation_status]?.variant || "secondary"}>
{STATUS_MAP[selectedMold.operation_status]?.label || selectedMold.operation_status || "-"}
<Badge variant="secondary">
{resolveOpStatus(selectedMold.operation_status) || "-"}
</Badge>
</div>
@@ -811,15 +835,15 @@ export default function MoldInfoPage() {
</TableHeader>
<TableBody>
{serials.map((s: any) => {
const ss = SERIAL_STATUS_MAP[s.status] || { label: s.status || "-", variant: "secondary" as const };
const maxShot = detail?.shot_count || 0;
const ssLabel = resolveOpStatus(s.status);
const maxShot = selectedMold?.shot_count || 0;
const curShot = s.current_shot_count || 0;
const pct = maxShot > 0 ? Math.min(Math.round((curShot / maxShot) * 100), 100) : 0;
return (
<TableRow key={s.id}>
<TableCell className="text-[13px] font-mono font-semibold">{s.serial_number}</TableCell>
<TableCell>
<Badge variant={ss.variant} className="text-[10px]">{ss.label}</Badge>
<Badge variant="secondary" className="text-[10px]">{ssLabel}</Badge>
</TableCell>
<TableCell>
{maxShot > 0 ? (
@@ -1043,10 +1067,7 @@ export default function MoldInfoPage() {
<SelectValue placeholder="선택해주세요" />
</SelectTrigger>
<SelectContent>
<SelectItem value="사출금형"></SelectItem>
<SelectItem value="프레스금형"></SelectItem>
<SelectItem value="다이캐스팅"></SelectItem>
<SelectItem value="단조금형"></SelectItem>
{moldTypeCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
@@ -1117,10 +1138,7 @@ export default function MoldInfoPage() {
<SelectValue placeholder="선택해주세요" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ACTIVE"></SelectItem>
<SelectItem value="INSPECTION"></SelectItem>
<SelectItem value="REPAIR"></SelectItem>
<SelectItem value="DISPOSED"></SelectItem>
{operationStatusCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
@@ -1175,10 +1193,7 @@ export default function MoldInfoPage() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="IN_USE"></SelectItem>
<SelectItem value="STORED"></SelectItem>
<SelectItem value="REPAIR"></SelectItem>
<SelectItem value="DISPOSED"></SelectItem>
{operationStatusCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
@@ -170,7 +170,7 @@ export default function EquipmentMonitoringPage() {
apiClient.post("/table-management/tables/equipment_mng/data", {
autoFilter: true,
page: 1,
size: 500,
size: 0,
}),
apiClient.get("/work-instruction/list").catch(() => ({ data: { data: [] } })),
apiClient.post("/table-management/tables/work_order_process/data", {
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(
@@ -153,7 +168,7 @@ export default function SubcontractorItemPage() {
filters.push({ columnName: "item_name", operator: "contains", value: searchKeyword });
}
const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
@@ -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);
@@ -191,7 +206,7 @@ export default function SubcontractorItemPage() {
setSubcontractorLoading(true);
try {
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [{ columnName: "item_id", operator: "equals", value: itemKey }] },
autoFilter: true,
});
@@ -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 () => {
@@ -194,7 +194,7 @@ export default function SubcontractorManagementPage() {
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
const res = await apiClient.post(`/table-management/tables/${SUBCONTRACTOR_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
@@ -229,7 +229,7 @@ export default function SubcontractorManagementPage() {
setPriceLoading(true);
try {
const mappingRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [
{ columnName: "subcontractor_id", operator: "equals", value: selectedSubcontractor.subcontractor_code },
]},
@@ -256,7 +256,7 @@ export default function SubcontractorManagementPage() {
if (mappings.length > 0) {
try {
const priceRes = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [
{ columnName: "subcontractor_id", operator: "equals", value: selectedSubcontractor.subcontractor_code },
]},
@@ -413,7 +413,7 @@ export default function SubcontractorManagementPage() {
const filters: any[] = [];
if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
@@ -550,7 +550,7 @@ export default function SubcontractorManagementPage() {
}> = [];
try {
const priceRes = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [
{ columnName: "subcontractor_id", operator: "equals", value: selectedSubcontractor!.subcontractor_code },
{ columnName: "item_id", operator: "equals", value: itemKey },
@@ -610,7 +610,7 @@ export default function SubcontractorManagementPage() {
// 2) 기존 단가 모두 삭제 (subcontractor_id + item_id 기준)
try {
const existingPrices = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [
{ columnName: "subcontractor_id", operator: "equals", value: selectedSubcontractor.subcontractor_code },
{ columnName: "item_id", operator: "equals", value: itemKey },
@@ -727,7 +727,7 @@ export default function SubcontractorManagementPage() {
if (subCodes.length > 0) {
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
page: 1, size: 5000, autoFilter: true,
page: 1, size: 0, autoFilter: true,
});
const mappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
@@ -59,6 +59,7 @@ import {
Settings2,
Save,
Package,
Pencil,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
@@ -349,13 +350,19 @@ export default function BomManagementPage() {
const res = await apiClient.post(`/table-management/tables/${BOM_TABLE}/data`, {
page: 1,
size: 500,
size: 0,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
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);
@@ -497,12 +511,14 @@ export default function BomManagementPage() {
const c = code.trim();
return categoryOptions["division"]?.find((o) => o.code === c)?.label || c;
}).filter((v: string) => v && v !== "s").join(", ");
const rawUnit = d.unit || item?.inventory_unit || "";
const unitLabel = categoryOptions["inventory_unit"]?.find((o) => o.code === rawUnit)?.label || rawUnit;
return {
...d,
item_number: item?.item_number || "",
item_name: item?.item_name || "",
item_type: divisionLabel,
unit: d.unit || item?.inventory_unit || "",
unit: unitLabel,
spec: item?.size || item?.spec || "",
writer: d.writer || "",
updated_date: d.updated_at || d.updated_date || "",
@@ -631,7 +647,7 @@ export default function BomManagementPage() {
// bom_detail에서 child_item_id가 현재 품목인 행 조회
const itemId = bomHeader.item_id || bomHeader.id;
const res = await apiClient.post(`/table-management/tables/bom_detail/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [{ columnName: "child_item_id", operator: "equals", value: itemId }] },
autoFilter: true,
});
@@ -818,6 +834,8 @@ export default function BomManagementPage() {
return;
}
// 같은 레벨 중복 허용 — 소요량/공정 등이 다른 동일 품목을 별도 row로 등록할 수 있음
const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2)}`;
const parentNode = addTargetParentId ? findNodeById(editingTree, addTargetParentId) : null;
const newLevel = parentNode ? ((parentNode._level ?? parentNode.level ?? 0) as number) + 1 : 0;
@@ -1089,17 +1107,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 || "",
@@ -1471,6 +1490,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"
@@ -1530,53 +1564,6 @@ export default function BomManagementPage() {
</div>
) : (
<div className="flex flex-col h-full">
{/* 상세 카드 */}
<div className="border-b shrink-0">
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/50">
<h3 className="text-[13px] font-bold text-foreground">BOM </h3>
<Button size="sm" variant="ghost" onClick={openEditModal}>
<FileText className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
{detailLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
</div>
) : bomHeader ? (
<div className="grid grid-cols-2 text-sm">
<div className="flex flex-col gap-1 p-3 border-b border-r">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider"></span>
<span className="font-mono text-xs">{bomHeader.item_code || bomHeader.item_number || "-"}</span>
</div>
<div className="flex flex-col gap-1 p-3 border-b">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider"></span>
<span className="text-xs">{bomHeader.item_name || "-"}</span>
</div>
<div className="flex flex-col gap-1 p-3 border-b border-r">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">BOM </span>
<span className="text-xs">{BOM_TYPE_OPTIONS.find((o) => o.code === bomHeader.bom_type)?.label || bomHeader.bom_type || "-"}</span>
</div>
<div className="flex flex-col gap-1 p-3 border-b">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider"></span>
<span className="text-xs">{bomHeader.version || "-"}</span>
</div>
<div className="flex flex-col gap-1 p-3 border-b border-r">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider"></span>
<span className="text-xs">{bomHeader.base_qty || "1"} {bomHeader.unit || ""}</span>
</div>
<div className="flex flex-col gap-1 p-3 border-b">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider"></span>
{renderStatusBadge(bomHeader.status)}
</div>
<div className="flex flex-col gap-1 p-3 col-span-2">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider"></span>
<span className="text-xs text-muted-foreground">{bomHeader.remark || "-"}</span>
</div>
</div>
) : null}
</div>
{/* 하단 탭: 트리뷰 / 버전 / 이력 */}
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
<Tabs value={rightTab} onValueChange={(v) => {
@@ -1808,7 +1795,7 @@ export default function BomManagementPage() {
{/* 소요량 */}
<td className="px-3 py-2 text-center">{isVirtualRoot ? (bomHeader?.base_qty || "1") : (node.quantity || "-")}</td>
{/* 단위 */}
<td className="px-3 py-2 text-center">{isVirtualRoot ? "-" : (node.unit || "-")}</td>
<td className="px-3 py-2 text-center">{isVirtualRoot ? (categoryOptions["inventory_unit"]?.find((o) => o.code === bomHeader?.unit)?.label || bomHeader?.unit || "-") : (node.unit || "-")}</td>
{/* 공정구분 */}
<td className="overflow-hidden text-ellipsis whitespace-nowrap px-3 py-2">{isVirtualRoot ? "-" : (node.process_type || "-")}</td>
{/* 규격 */}
@@ -185,9 +185,6 @@ export default function ProductionPlanManagementPage() {
const [modalQuantity, setModalQuantity] = useState(0);
const [modalStartDate, setModalStartDate] = useState("");
const [modalEndDate, setModalEndDate] = useState("");
const [modalManager, setModalManager] = useState("");
const [modalWorkOrderNo, setModalWorkOrderNo] = useState("");
const [modalRemarks, setModalRemarks] = useState("");
const [modalEquipmentId, setModalEquipmentId] = useState("");
// 미리보기 데이터
@@ -200,7 +197,10 @@ export default function ProductionPlanManagementPage() {
const [selectedPlanIds, setSelectedPlanIds] = useState<Set<number>>(new Set());
// useConfirmDialog
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
const { confirm, ConfirmDialogComponent, isConfirmOpenRef } = useConfirmDialog();
// 수량 지정 분할 입력값
const [customSplitQty, setCustomSplitQty] = useState<number | "">("");
// ========== 데이터 로드 ==========
@@ -694,10 +694,8 @@ export default function ProductionPlanManagementPage() {
setModalQuantity(Number(plan.plan_qty));
setModalStartDate(plan.start_date?.split("T")[0] || "");
setModalEndDate(plan.end_date?.split("T")[0] || "");
setModalManager((plan as any).manager_name || "");
setModalWorkOrderNo((plan as any).work_order_no || "");
setModalRemarks(plan.remarks || "");
setModalEquipmentId((plan as any).equipment_code || (plan.equipment_id ? String(plan.equipment_id) : ""));
setCustomSplitQty("");
setScheduleModalOpen(true);
}, []);
@@ -709,9 +707,6 @@ export default function ProductionPlanManagementPage() {
plan_qty: modalQuantity,
start_date: modalStartDate,
end_date: modalEndDate,
manager_name: modalManager,
work_order_no: modalWorkOrderNo,
remarks: modalRemarks,
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
equipment_name: modalEquipmentId && modalEquipmentId !== "none"
? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null
@@ -721,13 +716,14 @@ export default function ProductionPlanManagementPage() {
toast.success("생산계획이 수정되었습니다");
setScheduleModalOpen(false);
fetchPlans();
fetchOrderSummary();
}
} catch (err: any) {
toast.error("수정 실패: " + (err.message || ""));
toast.error("수정 실패: " + (err?.response?.data?.message || err.message || ""));
} finally {
setSaving(false);
}
}, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalManager, modalWorkOrderNo, modalRemarks, modalEquipmentId, fetchPlans]);
}, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId, equipmentList, fetchPlans, fetchOrderSummary]);
const handleDeletePlan = useCallback(async () => {
if (!selectedPlan) return;
@@ -741,24 +737,158 @@ export default function ProductionPlanManagementPage() {
toast.success("삭제되었습니다");
setScheduleModalOpen(false);
fetchPlans();
fetchOrderSummary();
} catch (err: any) {
toast.error("삭제 실패: " + (err.message || ""));
toast.error("삭제 실패: " + (err?.response?.data?.message || err.message || ""));
}
}, [selectedPlan, fetchPlans, confirm]);
}, [selectedPlan, fetchPlans, fetchOrderSummary, confirm]);
// 에러 메시지 추출 헬퍼
const extractErrMsg = (err: any): string => {
return err?.response?.data?.message || err?.message || "";
};
// modalQuantity/일정/설비가 DB의 selectedPlan 값과 다른지 확인 (dirty 체크)
const isModalDirty = useCallback((): boolean => {
if (!selectedPlan) return false;
const planQty = Number(selectedPlan.plan_qty) || 0;
const planStart = selectedPlan.start_date?.split("T")[0] || "";
const planEnd = selectedPlan.end_date?.split("T")[0] || "";
const planEq = (selectedPlan as any).equipment_code || (selectedPlan.equipment_id ? String(selectedPlan.equipment_id) : "");
return (
planQty !== Number(modalQuantity) ||
planStart !== modalStartDate ||
planEnd !== modalEndDate ||
planEq !== modalEquipmentId
);
}, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId]);
// dirty 상태면 자동 저장 후 selectedPlan 을 최신 값으로 갱신
const ensureSavedBeforeSplit = useCallback(async (): Promise<boolean> => {
if (!selectedPlan) return false;
if (!isModalDirty()) return true;
try {
const res = await updatePlan(selectedPlan.id, {
plan_qty: modalQuantity,
start_date: modalStartDate,
end_date: modalEndDate,
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
equipment_name: modalEquipmentId && modalEquipmentId !== "none"
? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null
: null,
} as any);
if (!res.success) {
toast.error("저장 실패로 분할이 중단되었습니다");
return false;
}
// selectedPlan 을 최신 값으로 동기화 (이후 로직에서 plan_qty 를 참조)
setSelectedPlan((prev) => prev ? ({
...prev,
plan_qty: modalQuantity,
start_date: modalStartDate,
end_date: modalEndDate,
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
} as any) : prev);
return true;
} catch (err: any) {
toast.error("저장 실패로 분할이 중단되었습니다: " + extractErrMsg(err));
return false;
}
}, [selectedPlan, isModalDirty, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId, equipmentList]);
// 균등 분할 (2/3/4분할 버튼)
const handleSplitSchedule = useCallback(async (splitCount: number) => {
if (!selectedPlan || splitCount < 2) return;
// 모달 입력값 기준 (이후 자동 저장되므로 modalQuantity 가 진실)
const originalQty = Number(modalQuantity) || 0;
if (originalQty < splitCount) {
toast.error(`${splitCount}분할하려면 수량이 ${splitCount} 이상이어야 합니다`);
return;
}
if (selectedPlan.status && selectedPlan.status !== "planned") {
toast.error("계획 상태인 건만 분할할 수 있습니다");
return;
}
const ok = await confirm(`이 계획을 ${splitCount}개로 균등 분할하시겠습니까?`, {
description: `수량 ${originalQty}이(가) ${splitCount}개로 나뉩니다.`,
confirmText: "분할",
});
if (!ok) return;
// dirty 면 자동 저장
const saved = await ensureSavedBeforeSplit();
if (!saved) return;
const eachQty = Math.floor(originalQty / splitCount);
if (eachQty <= 0) {
toast.error("분할 수량이 부족합니다");
return;
}
let successCount = 0;
try {
// N-1회 호출: 매번 eachQty만큼 원본에서 떼어내 새 plan 생성
for (let i = 0; i < splitCount - 1; i++) {
const res = await splitSchedule(selectedPlan.id, eachQty);
if (!res.success) throw new Error("분할 응답 실패");
successCount++;
}
toast.success(`계획이 ${splitCount}개로 분할되었습니다`);
setScheduleModalOpen(false);
fetchPlans();
fetchOrderSummary();
} catch (err: any) {
const msg = extractErrMsg(err);
if (successCount > 0) {
toast.error(`분할 일부 실패 (${successCount + 1}개 생성됨): ${msg}`);
} else {
toast.error("분할 실패: " + msg);
}
fetchPlans();
fetchOrderSummary();
}
}, [selectedPlan, modalQuantity, fetchPlans, fetchOrderSummary, confirm, ensureSavedBeforeSplit]);
// 수량 지정 분할 (원본에서 입력 수량만큼 떼어내기)
const handleCustomSplit = useCallback(async () => {
if (!selectedPlan) return;
const splitQty = Number(customSplitQty);
const originalQty = Number(modalQuantity) || 0;
if (!splitQty || splitQty < 1) {
toast.error("떼어낼 수량을 1 이상으로 입력하세요");
return;
}
if (splitQty >= originalQty) {
toast.error("떼어낼 수량은 원본 수량보다 작아야 합니다");
return;
}
if (selectedPlan.status && selectedPlan.status !== "planned") {
toast.error("계획 상태인 건만 분할할 수 있습니다");
return;
}
const ok = await confirm(`이 계획에서 ${splitQty}만큼 떼어내시겠습니까?`, {
description: `원본 ${originalQty} → 원본 ${originalQty - splitQty} + 신규 ${splitQty}`,
confirmText: "분할",
});
if (!ok) return;
const saved = await ensureSavedBeforeSplit();
if (!saved) return;
const handleSplitSchedule = useCallback(async (splitQty: number) => {
if (!selectedPlan || splitQty <= 0) return;
try {
const res = await splitSchedule(selectedPlan.id, splitQty);
if (res.success) {
toast.success("계획이 분되었습니다");
setScheduleModalOpen(false);
fetchPlans();
}
if (!res.success) throw new Error("분할 응답 실패");
toast.success(`${splitQty} 수량이 분되었습니다`);
setCustomSplitQty("");
setScheduleModalOpen(false);
fetchPlans();
fetchOrderSummary();
} catch (err: any) {
toast.error("분할 실패: " + (err.message || ""));
toast.error("분할 실패: " + extractErrMsg(err));
fetchPlans();
fetchOrderSummary();
}
}, [selectedPlan, fetchPlans]);
}, [selectedPlan, modalQuantity, customSplitQty, fetchPlans, fetchOrderSummary, confirm, ensureSavedBeforeSplit]);
// 병합 핸들러
const handleMergeSchedules = useCallback(async () => {
@@ -780,11 +910,12 @@ export default function ProductionPlanManagementPage() {
toast.success("계획이 병합되었습니다");
setSelectedPlanIds(new Set());
fetchPlans();
fetchOrderSummary();
}
} catch (err: any) {
toast.error("병합 실패: " + (err.message || ""));
toast.error("병합 실패: " + (err?.response?.data?.message || err.message || ""));
}
}, [selectedPlanIds, rightTab, fetchPlans, confirm]);
}, [selectedPlanIds, rightTab, fetchPlans, fetchOrderSummary, confirm]);
// 타임라인 이벤트 드래그 이동
const handleEventMove = useCallback(async (eventId: string | number, newStart: string, newEnd: string) => {
@@ -796,11 +927,12 @@ export default function ProductionPlanManagementPage() {
if (res.success) {
toast.success("일정이 변경되었습니다");
fetchPlans();
fetchOrderSummary();
}
} catch (err: any) {
toast.error("일정 변경 실패: " + (err.message || ""));
}
}, [fetchPlans]);
}, [fetchPlans, fetchOrderSummary]);
// 타임라인 이벤트 리사이즈
const handleEventResize = useCallback(async (eventId: string | number, newStart: string, newEnd: string) => {
@@ -812,11 +944,12 @@ export default function ProductionPlanManagementPage() {
if (res.success) {
toast.success("기간이 변경되었습니다");
fetchPlans();
fetchOrderSummary();
}
} catch (err: any) {
toast.error("기간 변경 실패: " + (err.message || ""));
}
}, [fetchPlans]);
}, [fetchPlans, fetchOrderSummary]);
// 불러오기 처리
const handleImportOrderItems = useCallback(async () => {
@@ -1463,8 +1596,26 @@ export default function ProductionPlanManagementPage() {
{/* ========== 모달들 ========== */}
{/* 스케줄 상세/편집 모달 */}
<Dialog open={scheduleModalOpen} onOpenChange={setScheduleModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[650px] max-h-[85vh] overflow-y-auto">
<Dialog
open={scheduleModalOpen}
onOpenChange={(v) => {
// confirm 다이얼로그가 열려 있는 동안 발생하는 닫힘 이벤트(포커스 이탈 등)는 무시
if (!v && isConfirmOpenRef.current) return;
setScheduleModalOpen(v);
}}
>
<DialogContent
className="max-w-[95vw] sm:max-w-[650px] max-h-[85vh] overflow-y-auto"
onPointerDownOutside={(e) => {
if (isConfirmOpenRef.current) e.preventDefault();
}}
onInteractOutside={(e) => {
if (isConfirmOpenRef.current) e.preventDefault();
}}
onFocusOutside={(e) => {
if (isConfirmOpenRef.current) e.preventDefault();
}}
>
<DialogHeader>
<DialogTitle className="text-base sm:text-lg flex items-center gap-2">
<ClipboardList className="h-5 w-5" />
@@ -1554,37 +1705,67 @@ export default function ProductionPlanManagementPage() {
<Scissors className="h-4 w-4" />
</p>
<div className="flex gap-1.5">
{[2, 3, 4].map((n) => {
const canSplit =
modalQuantity >= n &&
(selectedPlan?.status === "planned" || !selectedPlan?.status);
return (
<Button
key={n}
size="sm"
variant="warning"
className="h-7 text-xs"
disabled={!canSplit}
onClick={() => handleSplitSchedule(n)}
>
{n}
</Button>
);
})}
</div>
</div>
<p className="text-xs text-foreground mb-2">
. ( )
</p>
{/* 수량 지정 분할 */}
<div className="flex items-center gap-1.5 pt-2 border-t border-warning/20">
<Label className="text-xs text-muted-foreground shrink-0"> :</Label>
<Input
type="number"
value={customSplitQty}
onChange={(e) => {
const v = e.target.value;
if (v === "") setCustomSplitQty("");
else setCustomSplitQty(Math.max(0, Math.floor(Number(v) || 0)));
}}
className="h-7 w-28 text-xs"
placeholder="떼어낼 수량"
min={1}
max={Math.max(0, modalQuantity - 1)}
step={1}
/>
<span className="text-xs text-muted-foreground">
/ {modalQuantity}
</span>
<Button
size="sm"
variant="warning"
className="h-7 text-xs"
onClick={() => {
const qty = Math.floor(modalQuantity / 2);
if (qty > 0) handleSplitSchedule(qty);
}}
className="h-7 text-xs ml-auto"
disabled={
!customSplitQty ||
Number(customSplitQty) < 1 ||
Number(customSplitQty) >= modalQuantity ||
!(selectedPlan?.status === "planned" || !selectedPlan?.status)
}
onClick={handleCustomSplit}
>
2
</Button>
</div>
<p className="text-xs text-foreground"> .</p>
</div>
<div>
<p className="text-sm font-semibold mb-3 pb-2 border-b"> </p>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs"></Label>
<Input value={modalManager} onChange={(e) => setModalManager(e.target.value)} className="h-9 text-xs" placeholder="담당자명" />
</div>
<div>
<Label className="text-xs"></Label>
<Input value={modalWorkOrderNo} onChange={(e) => setModalWorkOrderNo(e.target.value)} className="h-9 text-xs" placeholder="자동생성" />
</div>
<div className="col-span-2">
<Label className="text-xs"></Label>
<Input value={modalRemarks} onChange={(e) => setModalRemarks(e.target.value)} className="h-9 text-xs" placeholder="비고사항 입력" />
</div>
</div>
<p className="text-[11px] text-muted-foreground mt-1.5">
. (1 , )
</p>
</div>
</div>
)}
@@ -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,7 +92,8 @@ export function ItemRoutingTab() {
const [formFixedOrder, setFormFixedOrder] = useState("Y");
const [formWorkType, setFormWorkType] = useState("내부");
const [formStandardTime, setFormStandardTime] = useState("");
const [formOutsource, setFormOutsource] = useState("");
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);
@@ -107,6 +109,19 @@ export function ItemRoutingTab() {
return () => window.clearTimeout(t);
}, [searchInput]);
// 외주사 목록 로드
useEffect(() => {
(async () => {
try {
const res = await apiClient.post("/table-management/tables/subcontractor_mng/data", {
page: 1, size: 500, autoFilter: true,
});
const rows = res.data?.data?.data || res.data?.data?.rows || [];
setSubcontractorOptions(rows.map((r: any) => ({ id: r.id, code: r.subcontractor_code || "", name: r.subcontractor_name || "" })));
} catch { /* skip */ }
})();
}, []);
useEffect(() => {
const t = window.setTimeout(() => setRegisterSearchDebounced(registerSearch.trim()), 300);
return () => window.clearTimeout(t);
@@ -267,7 +282,7 @@ export function ItemRoutingTab() {
setFormFixedOrder("Y");
setFormWorkType("내부");
setFormStandardTime("");
setFormOutsource("");
setFormOutsources([]);
setDetailDialogOpen(true);
};
@@ -294,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);
};
@@ -315,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 {
@@ -330,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("공정이 추가되었어요. 저장을 눌러 반영해주세요");
@@ -348,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,
),
@@ -385,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);
@@ -466,12 +499,24 @@ export function ItemRoutingTab() {
const detailsGridData = useMemo(
() =>
details.map((d) => ({
...d,
process_display: d.process_name || d.process_code,
outsource_display: d.outsource_supplier || "—",
})),
[details],
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],
);
return (
@@ -895,13 +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>
<Input
value={formOutsource}
onChange={(e) => setFormOutsource(e.target.value)}
placeholder="외주 업체명"
className="h-9"
/>
<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>
@@ -10,6 +10,7 @@ import {
Search,
RotateCcw,
Wrench,
Upload,
} from "lucide-react";
import { toast } from "sonner";
@@ -47,6 +48,7 @@ import {
} from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
import { SmartSelect } from "@/components/common/SmartSelect";
import {
getProcessList,
createProcess,
@@ -61,6 +63,10 @@ import {
type Equipment,
} from "@/lib/api/processInfo"; // API: /process-info/*
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
import { SmartExcelUploadModal } from "@/components/common/SmartExcelUpload";
import type { SmartExcelUploadConfig, ParsedSheetData } from "@/components/common/SmartExcelUpload";
import { allocateNumberingCode } from "@/lib/api/numberingRule";
import { apiClient } from "@/lib/api/client";
const ALL_VALUE = "__all__";
@@ -68,6 +74,8 @@ export function ProcessMasterTab() {
const [processes, setProcesses] = useState<ProcessMaster[]>([]);
const [equipmentMaster, setEquipmentMaster] = useState<Equipment[]>([]);
const [processTypeOptions, setProcessTypeOptions] = useState<{ valueCode: string; valueLabel: string }[]>([]);
const [useYnOptions, setUseYnOptions] = useState<{ valueCode: string; valueLabel: string }[]>([]);
const [excelOpen, setExcelOpen] = useState(false);
const [loadingInitial, setLoadingInitial] = useState(true);
const [loadingList, setLoadingList] = useState(false);
const [loadingEquipments, setLoadingEquipments] = useState(false);
@@ -153,6 +161,18 @@ export function ProcessMasterTab() {
});
setProcessTypeOptions(unique.map((v: any) => ({ valueCode: v.valueCode, valueLabel: v.valueLabel })));
}
// 사용여부 카테고리 (엑셀업로드 드롭다운용)
const uyRes = await getCategoryValues("process_mng", "use_yn");
if (uyRes.success && "data" in uyRes && Array.isArray(uyRes.data)) {
const activeValues = uyRes.data.filter((v: any) => v.isActive !== false);
const seen = new Set<string>();
const unique = activeValues.filter((v: any) => {
if (seen.has(v.valueCode)) return false;
seen.add(v.valueCode);
return true;
});
setUseYnOptions(unique.map((v: any) => ({ valueCode: v.valueCode, valueLabel: v.valueLabel })));
}
} finally {
setLoadingInitial(false);
}
@@ -221,18 +241,28 @@ export function ProcessMasterTab() {
};
const openEdit = () => {
if (!selectedProcess) {
toast.message("수정할 공정을 좌측 목록에서 선택해주세요");
if (selectedIds.size === 0) {
toast.message("수정할 공정을 체크박스로 선택해주세요");
return;
}
if (selectedIds.size > 1) {
toast.message("수정은 1건만 선택해주세요");
return;
}
const targetId = Array.from(selectedIds)[0];
const target = processes.find((p) => p.id === targetId);
if (!target) {
toast.error("선택한 공정을 찾을 수 없습니다");
return;
}
setFormMode("edit");
setEditingId(selectedProcess.id);
setFormProcessCode(selectedProcess.process_code);
setFormProcessName(selectedProcess.process_name);
setFormProcessType(selectedProcess.process_type);
setFormStandardTime(selectedProcess.standard_time ?? "");
setFormWorkerCount(selectedProcess.worker_count ?? "");
setFormUseYn(selectedProcess.use_yn);
setEditingId(target.id);
setFormProcessCode(target.process_code);
setFormProcessName(target.process_name);
setFormProcessType(target.process_type);
setFormStandardTime(target.standard_time ?? "");
setFormWorkerCount(target.worker_count ?? "");
setFormUseYn(target.use_yn);
setFormOpen(true);
};
@@ -313,8 +343,17 @@ export function ProcessMasterTab() {
};
const availableEquipments = useMemo(() => {
const used = new Set(processEquipments.map((e) => e.equipment_code));
return equipmentMaster.filter((e) => !used.has(e.equipment_code));
// equipment_code 컬럼에 code(legacy) 또는 id(신규)가 들어있을 수 있음
// 빈 값은 used set에서 제외 (코드 없는 설비를 모두 가리는 것 방지)
const used = new Set<string>();
for (const pe of processEquipments) {
if (pe.equipment_code) used.add(pe.equipment_code);
}
return equipmentMaster.filter((e) => {
if (e.equipment_code && used.has(e.equipment_code)) return false;
if (e.id && used.has(e.id)) return false;
return true;
});
}, [equipmentMaster, processEquipments]);
const handleAddEquipment = async () => {
@@ -323,11 +362,17 @@ export function ProcessMasterTab() {
toast.message("추가할 설비를 선택해주세요");
return;
}
const picked = availableEquipments.find((e) => e.id === equipmentPick);
if (!picked) {
toast.error("선택한 설비를 찾을 수 없어요");
return;
}
setAddingEquipment(true);
try {
// equipment_code가 비어있으면 id를 저장 (백엔드 JOIN이 양쪽 다 매칭)
const res = await addProcessEquipment({
process_code: selectedProcess.process_code,
equipment_code: equipmentPick,
equipment_code: picked.equipment_code || picked.id,
});
if (!res.success) {
toast.error(res.message || "설비 추가에 실패했어요");
@@ -357,6 +402,175 @@ export function ProcessMasterTab() {
const listBusy = loadingInitial || loadingList;
/* ═══════════════════ 엑셀 업로드 설정 ═══════════════════ */
const processTypeLabelToCode = useMemo(() => {
const m: Record<string, string> = {};
processTypeOptions.forEach((o) => {
m[o.valueLabel] = o.valueCode;
});
return m;
}, [processTypeOptions]);
const useYnLabelToCode = useMemo(() => {
const m: Record<string, string> = {};
useYnOptions.forEach((o) => {
m[o.valueLabel] = o.valueCode;
});
return m;
}, [useYnOptions]);
const excelConfig = useMemo<SmartExcelUploadConfig>(
() => ({
templateName: "공정 마스터",
sheets: [
{
name: "공정 마스터",
typeKey: "process_mng",
columns: [
{ key: "process_code", label: "공정코드", type: "text", width: 14 },
{ key: "process_name", label: "공정명", required: true, type: "text", width: 20 },
{
key: "process_type",
label: "공정유형",
required: true,
type: "dropdown",
dropdown: { source: "custom", values: processTypeOptions.map((o) => o.valueLabel) },
width: 18,
},
{ key: "standard_time", label: "표준시간(분)", type: "number", width: 12 },
{ key: "worker_count", label: "작업인원", type: "number", width: 10 },
{
key: "use_yn",
label: "사용여부",
type: "dropdown",
dropdown: { source: "custom", values: useYnOptions.map((o) => o.valueLabel) },
width: 10,
},
// 설비명을 쉼표로 구분하여 입력 (예: "절단기1,포장기"). 매칭 실패한 이름은 스킵.
{ key: "equipment_list", label: "사용설비(쉼표구분)", type: "text", width: 28 },
],
},
],
}),
[processTypeOptions, useYnOptions],
);
const excelDropdownOptions = useMemo<Record<string, string[]>>(
() => ({
process_type: processTypeOptions.map((o) => o.valueLabel),
use_yn: useYnOptions.map((o) => o.valueLabel),
}),
[processTypeOptions, useYnOptions],
);
const excelLabelToCodeMap = useMemo<Record<string, Record<string, string>>>(
() => ({
process_type: processTypeLabelToCode,
use_yn: useYnLabelToCode,
}),
[processTypeLabelToCode, useYnLabelToCode],
);
const handleExcelUpload = async (data: ParsedSheetData[]) => {
const rows = data[0]?.rows ?? [];
if (rows.length === 0) {
toast.error("업로드할 데이터가 없어요");
return;
}
// 채번 규칙 사전 조회 (공정코드 미입력 행에만 사용)
let ruleId: string | null = null;
try {
const res = await apiClient.get(`/numbering-rules/by-column/process_mng/process_code`);
if (res.data?.success && res.data?.data?.ruleId) {
ruleId = res.data.data.ruleId;
}
} catch {
/* 채번 규칙 없으면 스킵 */
}
let okCount = 0;
const failList: string[] = [];
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
try {
// row.{key}는 templateParser가 이미 label→code로 자동변환한 값 (dropdown 컬럼)
let processCode = String(row.process_code || "").trim();
if (!processCode && ruleId) {
const alloc = await allocateNumberingCode(ruleId);
if (alloc.success && alloc.data?.generatedCode) {
processCode = alloc.data.generatedCode;
} else {
failList.push(`${i + 2}행: 채번 실패`);
continue;
}
}
if (!processCode) {
failList.push(`${i + 2}행: 공정코드 없음 (채번 규칙 필요)`);
continue;
}
const payload = {
process_code: processCode,
process_name: row.process_name || "",
process_type: row.process_type || "",
standard_time: String(row.standard_time ?? ""),
worker_count: String(row.worker_count ?? ""),
use_yn: row.use_yn || "USE_Y",
};
// 기존 공정 조회 후 upsert
const existRes = await getProcessList({ processCode });
const existing = existRes.success
? (existRes.data ?? []).find((p) => p.process_code === processCode)
: null;
if (existing) {
const up = await updateProcess(existing.id, payload);
if (!up.success) {
failList.push(`${i + 2}행: 수정 실패`);
continue;
}
} else {
const cr = await createProcess(payload);
if (!cr.success) {
failList.push(`${i + 2}행: 등록 실패`);
continue;
}
}
// 사용설비 매핑 (쉼표 구분 설비명). 매칭 안 되는 이름은 무시 (사용자 책임).
const rawEqs = String(row.equipment_list || "").trim();
if (rawEqs) {
const eqLabels = rawEqs.split(",").map((s) => s.trim()).filter(Boolean);
// 기존 매핑 조회 후 중복 방지
const curRes = await getProcessEquipments(processCode);
const currentSet = new Set((curRes.success ? curRes.data ?? [] : []).map((e) => e.equipment_code));
const eqByName = new Map(equipmentMaster.map((e) => [e.equipment_name, e.equipment_code]));
for (const label of eqLabels) {
const eqCode = eqByName.get(label);
if (!eqCode) continue; // 오타/미등록 설비 → 스킵
if (currentSet.has(eqCode)) continue; // 이미 매핑됨
await addProcessEquipment({ process_code: processCode, equipment_code: eqCode });
currentSet.add(eqCode);
}
}
okCount++;
} catch {
failList.push(`${i + 2}행: 저장 실패`);
}
}
if (okCount > 0) toast.success(`${okCount}건을 업로드했어요`);
if (failList.length > 0) {
toast.error(
`실패 ${failList.length}건: ${failList.slice(0, 3).join(" / ")}${failList.length > 3 ? " …" : ""}`,
);
}
void loadProcesses();
};
// 표시용 데이터
const processGridData = useMemo(
() =>
@@ -420,6 +634,10 @@ export function ProcessMasterTab() {
{/* 액션 바 */}
<div className="flex items-center justify-end gap-2 border-b bg-muted/30 px-4 py-2">
<Button size="sm" variant="outline" onClick={() => setExcelOpen(true)}>
<Upload className="mr-1 h-3.5 w-3.5" />
</Button>
<Button size="sm" onClick={openAdd}>
<Plus className="mr-1 h-3.5 w-3.5" />
@@ -501,23 +719,17 @@ export function ProcessMasterTab() {
<div className="flex items-end gap-2">
<div className="min-w-0 flex-1 space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> </Label>
<Select
<SmartSelect
key={selectedProcess.id}
value={equipmentPick || undefined}
options={availableEquipments.map((eq) => ({
code: eq.id,
label: eq.equipment_name,
}))}
value={equipmentPick || ""}
onValueChange={setEquipmentPick}
placeholder="설비를 선택해주세요"
disabled={addingEquipment || availableEquipments.length === 0}
>
<SelectTrigger className="h-9" size="sm">
<SelectValue placeholder="설비를 선택해주세요" />
</SelectTrigger>
<SelectContent>
{availableEquipments.map((eq) => (
<SelectItem key={eq.id} value={eq.equipment_code}>
{eq.equipment_code} · {eq.equipment_name}
</SelectItem>
))}
</SelectContent>
</Select>
/>
</div>
<Button
size="sm"
@@ -548,8 +760,11 @@ export function ProcessMasterTab() {
{processEquipments.map((pe) => (
<li key={pe.id} className="flex items-center gap-3 rounded-lg border p-3 transition-colors hover:bg-muted/30">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{pe.equipment_code}</p>
<p className="truncate text-xs text-muted-foreground">{pe.equipment_name || "설비명 없음"}</p>
<p className="truncate text-sm font-medium">
{pe.equipment_name
|| equipmentMaster.find((e) => e.id === pe.equipment_code || e.equipment_code === pe.equipment_code)?.equipment_name
|| "설비명 없음"}
</p>
</div>
<Button variant="ghost" size="sm" onClick={() => void handleRemoveEquipment(pe)}>
<Trash2 className="mr-1 h-3.5 w-3.5" />
@@ -659,6 +874,38 @@ export function ProcessMasterTab() {
</DialogFooter>
</DialogContent>
</Dialog>
{/* ═══════════════════ 엑셀업로드 모달 ═══════════════════ */}
<SmartExcelUploadModal
open={excelOpen}
onOpenChange={setExcelOpen}
config={excelConfig}
dropdownOptions={excelDropdownOptions}
labelToCodeMap={excelLabelToCodeMap}
onUpload={handleExcelUpload}
customValidator={(data) => {
// 사용설비 컬럼 검증: 쉼표 구분 각 이름이 equipmentMaster에 등록된 설비명인지
const errors: any[] = [];
const validNames = new Set(equipmentMaster.map((e) => e.equipment_name));
for (const sheet of data) {
sheet.rows.forEach((row, idx) => {
const raw = String(row.equipment_list || "").trim();
if (!raw) return;
const names = raw.split(",").map((s) => s.trim()).filter(Boolean);
const invalid = names.filter((n) => !validNames.has(n));
if (invalid.length > 0) {
errors.push({
sheet: sheet.sheetName,
row: idx + 2,
column: "사용설비(쉼표구분)",
message: `등록되지 않은 설비: ${invalid.join(", ")}`,
});
}
});
}
return errors;
}}
/>
</div>
);
}
@@ -1,6 +1,6 @@
"use client";
import React, { useState, useCallback, useEffect } from "react";
import React, { useState, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import {
@@ -9,7 +9,6 @@ import {
ClipboardList,
ChevronRight,
Factory,
Keyboard,
Plus,
Pencil,
Trash2,
@@ -41,7 +40,6 @@ const TAB_META = [
icon: Settings,
color: "text-blue-500",
badgeColor: "bg-blue-50 text-blue-700 ring-blue-600/20",
shortcut: "1",
actions: ["공정 등록", "공정 수정", "공정 삭제", "설비 연결"],
},
{
@@ -53,7 +51,6 @@ const TAB_META = [
icon: GitBranch,
color: "text-emerald-500",
badgeColor: "bg-emerald-50 text-emerald-700 ring-emerald-600/20",
shortcut: "2",
actions: ["버전 생성", "공정 순서 설정", "품목 등록", "품목 해제"],
},
{
@@ -65,7 +62,6 @@ const TAB_META = [
icon: ClipboardList,
color: "text-violet-500",
badgeColor: "bg-violet-50 text-violet-700 ring-violet-600/20",
shortcut: "3",
actions: ["기준서 등록", "기준서 수정", "기준서 삭제", "작업 표준 관리"],
},
] as const;
@@ -77,7 +73,6 @@ const ACTION_ICONS = [Plus, Pencil, Trash2, List] as const;
export default function ProcessInfoPage() {
const ts = useTableSettings("c16-process-info", "process_mst", GRID_COLUMNS);
const [activeTab, setActiveTab] = useState<TabValue>("process");
const [showShortcutHint, setShowShortcutHint] = useState(false);
const activeMeta = TAB_META.find((t) => t.value === activeTab)!;
@@ -85,19 +80,6 @@ export default function ProcessInfoPage() {
setActiveTab(value as TabValue);
}, []);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!e.altKey) return;
const tabByShortcut = TAB_META.find((t) => t.shortcut === e.key);
if (tabByShortcut) {
e.preventDefault();
setActiveTab(tabByShortcut.value);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, []);
return (
<div className="flex h-[calc(100vh-4rem)] flex-col bg-muted/30">
{/* 페이지 헤더 */}
@@ -120,30 +102,8 @@ export default function ProcessInfoPage() {
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
<Settings2 className="h-3.5 w-3.5" />
</Button>
<button
type="button"
className="flex items-center gap-1 rounded px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onClick={() => setShowShortcutHint((v) => !v)}
aria-label="키보드 단축키 보기"
>
<Keyboard className="h-3 w-3" />
<span></span>
</button>
</div>
</div>
{showShortcutHint && (
<div className="mt-2 flex items-center gap-3 rounded-md bg-muted px-3 py-2 text-xs text-muted-foreground">
<span className="font-medium text-foreground"> :</span>
{TAB_META.map((t) => (
<span key={t.value} className="flex items-center gap-1">
<kbd className="rounded border border-border bg-background px-1.5 py-0.5 font-mono text-[10px]">
Alt+{t.shortcut}
</kbd>
<span>{t.shortLabel}</span>
</span>
))}
</div>
)}
</div>
<Tabs
@@ -154,12 +114,12 @@ export default function ProcessInfoPage() {
{/* 탭 네비게이션 */}
<div className="shrink-0 border-b bg-background px-4">
<TabsList className="h-12 bg-transparent gap-1">
{TAB_META.map(({ value, label, icon: Icon, shortcut }) => (
{TAB_META.map(({ value, label, icon: Icon }) => (
<TabsTrigger
key={value}
value={value}
className="data-[state=active]:border-b-2 data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none rounded-none px-4 gap-1.5"
aria-label={`${label} (Alt+${shortcut})`}
aria-label={label}
>
<Icon className="h-4 w-4" />
{label}
@@ -168,34 +128,6 @@ export default function ProcessInfoPage() {
</TabsList>
</div>
{/* 탭 설명 배너 */}
<div className="shrink-0 border-b bg-background/60 px-6 py-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset ${activeMeta.badgeColor}`}
>
{activeMeta.shortLabel}
</span>
<span className="text-xs text-muted-foreground">{activeMeta.detailDesc}</span>
</div>
<div className="hidden items-center gap-2 sm:flex">
{activeMeta.actions.map((action, i) => {
const ActionIcon = ACTION_ICONS[i % ACTION_ICONS.length];
return (
<span
key={action}
className="flex items-center gap-1 text-xs text-muted-foreground/70"
>
<ActionIcon className="h-3 w-3" />
{action}
</span>
);
})}
</div>
</div>
</div>
{/* 탭 컨텐츠 */}
<TabsContent value="process" className="min-h-0 flex-1 overflow-hidden mt-0">
<ProcessMasterTab />
@@ -183,7 +183,7 @@ export default function ProductionResultPage() {
setProcessLoading(true);
try {
const res = await apiClient.post(`/table-management/tables/${WOP_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [{ columnName: "wo_id", operator: "equals", value: selectedWiId }] },
autoFilter: true,
sort: { columnName: "seq_no", order: "asc" },
@@ -586,7 +586,7 @@ export function WorkStandardEditModal({
<thead className="sticky top-0 bg-muted/50">
<tr className="border-b">
<th className="w-12 px-2 py-2 text-center font-medium text-muted-foreground"></th>
<th className="w-20 px-2 py-2 text-left font-medium text-muted-foreground"></th>
<th className="w-24 px-2 py-2 text-left font-medium text-muted-foreground"></th>
<th className="px-2 py-2 text-left font-medium text-muted-foreground"></th>
<th className="w-14 px-2 py-2 text-center font-medium text-muted-foreground"></th>
<th className="w-16 px-2 py-2 text-center font-medium text-muted-foreground"></th>
@@ -597,7 +597,7 @@ export function WorkStandardEditModal({
<tr key={detail.id || idx} className="border-b transition-colors hover:bg-muted/30">
<td className="px-2 py-1.5 text-center text-muted-foreground">{idx + 1}</td>
<td className="px-2 py-1.5">
<Badge variant="outline" className="text-[10px] font-normal">
<Badge variant="outline" className="text-[10px] font-normal whitespace-nowrap">
{getDetailTypeLabel(detail.detail_type || "checklist")}
</Badge>
</td>
@@ -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() {
@@ -185,11 +269,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; }); };
@@ -197,12 +285,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 +349,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 +361,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 +406,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 +442,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 +457,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 +727,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 +739,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 +768,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 +777,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 +812,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 +834,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 +888,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 +897,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 +980,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>
@@ -30,6 +30,7 @@ import { toast } from "sonner";
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { SmartSelect } from "@/components/common/SmartSelect";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
const MASTER_TABLE = "purchase_order_mng";
@@ -237,7 +238,7 @@ export default function PurchaseOrderPage() {
);
try {
const suppRes = await apiClient.post(`/table-management/tables/supplier_mng/data`, {
page: 1, size: 5000, autoFilter: true,
page: 1, size: 0, autoFilter: true,
});
const supps = suppRes.data?.data?.data || suppRes.data?.data?.rows || [];
optMap["supplier_code"] = supps.map((s: any) => ({
@@ -247,7 +248,7 @@ export default function PurchaseOrderPage() {
} catch { /* skip */ }
try {
const userRes = await apiClient.post(`/table-management/tables/user_info/data`, {
page: 1, size: 5000, autoFilter: true,
page: 1, size: 0, autoFilter: true,
});
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
optMap["manager"] = users.map((u: any) => ({
@@ -293,7 +294,7 @@ export default function PurchaseOrderPage() {
}
const res = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
page: 1, size: 5000,
page: 1, size: 0,
dataFilter: detailFilters.length > 0 ? { enabled: true, filters: detailFilters } : undefined,
autoFilter: true,
sort: { columnName: "purchase_no", order: "desc" },
@@ -555,6 +556,50 @@ export default function PurchaseOrderPage() {
if (divLabel) divValues.push(divLabel);
filters.push({ columnName: "division", operator: "in", value: divValues });
}
// 공급업체 선택 시 supplier_item_mapping으로 매핑 id 정규화 → 서버 필터 적용
const supplierCode = masterForm.supplier_code;
if (supplierCode) {
try {
const mappingRes = await apiClient.post(`/table-management/tables/supplier_item_mapping/data`, {
page: 1, size: 0,
dataFilter: { enabled: true, filters: [{ columnName: "supplier_id", operator: "equals", value: supplierCode }] },
autoFilter: true,
});
const mappings = mappingRes.data?.data?.data || mappingRes.data?.data?.rows || [];
const rawIds = [...new Set(mappings.map((m: any) => m.item_id).filter(Boolean))] as string[];
if (rawIds.length === 0) {
setItemSearchResults([]); setItemTotal(0); setItemTotalPages(1);
setItemSearchLoading(false);
return;
}
// UUID와 문자열(item_number) 분리
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const uuidIds = rawIds.filter((v) => uuidRegex.test(v));
const codeIds = rawIds.filter((v) => !uuidRegex.test(v));
// 문자열(item_number)을 item_info에서 id로 변환
let convertedIds: string[] = [];
if (codeIds.length > 0) {
const convRes = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: codeIds.length + 10,
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: codeIds }] },
autoFilter: true,
});
const convRows = convRes.data?.data?.data || convRes.data?.data?.rows || [];
convertedIds = convRows.map((r: any) => r.id).filter(Boolean);
}
const finalIds = [...new Set([...uuidIds, ...convertedIds])];
if (finalIds.length === 0) {
setItemSearchResults([]); setItemTotal(0); setItemTotalPages(1);
setItemSearchLoading(false);
return;
}
filters.push({ columnName: "id", operator: "in", value: finalIds });
} catch { /* skip */ }
}
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: p, size: s,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
@@ -607,7 +652,7 @@ export default function PurchaseOrderPage() {
try {
const itemIds = selected.map((item) => item.item_number || item.id);
const res = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, {
page: 1, size: 5000,
page: 1, size: 0,
dataFilter: {
enabled: true,
filters: [
@@ -670,7 +715,7 @@ export default function PurchaseOrderPage() {
if (itemCodes.length === 0) return;
try {
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 5000,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: itemCodes }] },
autoFilter: true,
});
@@ -692,7 +737,7 @@ export default function PurchaseOrderPage() {
if (itemCodes.length === 0) return;
try {
const res = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, {
page: 1, size: 5000,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [
{ columnName: "supplier_id", operator: "equals", value: supplierCode },
{ columnName: "item_id", operator: "in", value: itemCodes },
@@ -984,7 +1029,8 @@ export default function PurchaseOrderPage() {
<div className="grid grid-cols-2 gap-3.5">
<div className="space-y-1">
<Label className="text-[11px] font-semibold text-muted-foreground tracking-wide"></Label>
<Select
<SmartSelect
options={categoryOptions["supplier_code"] || []}
value={masterForm.supplier_code || ""}
onValueChange={(v) => {
const supp = categoryOptions["supplier_code"]?.find((o) => o.code === v);
@@ -992,15 +1038,9 @@ export default function PurchaseOrderPage() {
setMasterForm((p) => ({ ...p, supplier_code: v, supplier_name: name }));
recalcPrices(masterForm.price_mode || "", v);
}}
placeholder="공급업체 선택"
disabled={isReadOnly}
>
<SelectTrigger className="h-9"><SelectValue placeholder="공급업체 선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["supplier_code"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
/>
</div>
</div>
</div>
@@ -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 }[] = [];
@@ -328,7 +333,7 @@ export default function PurchaseItemPage() {
}
const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
page: 1, size: 5000,
page: 1, size: 0,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
@@ -619,7 +624,7 @@ export default function PurchaseItemPage() {
try {
// 1. supplier_item_mapping에서 해당 품목의 매핑 조회
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [{ columnName: "item_id", operator: "equals", value: itemKey }] },
autoFilter: true,
sort: { columnName: "created_date", order: "asc" },
@@ -647,7 +652,7 @@ export default function PurchaseItemPage() {
if (mappings.length > 0) {
try {
const priceRes = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [
{ columnName: "item_id", operator: "equals", value: itemKey },
]},
@@ -1104,7 +1109,7 @@ export default function PurchaseItemPage() {
for (const suppCode of supplierCodes) {
// 해당 공급업체의 모든 매핑 조회 → item_id null 처리
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [
{ columnName: "item_id", operator: "equals", value: selectedItem!.item_number },
{ columnName: "supplier_id", operator: "equals", value: suppCode },
@@ -1121,7 +1126,7 @@ export default function PurchaseItemPage() {
// 해당 공급업체의 모든 단가 조회 → item_id null 처리
try {
const priceRes = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [
{ columnName: "item_id", operator: "equals", value: selectedItem!.item_number },
{ columnName: "supplier_id", operator: "equals", value: suppCode },
@@ -224,7 +224,7 @@ export default function SupplierManagementPage() {
} catch { /* skip */ }
};
load();
apiClient.post(`/table-management/tables/user_info/data`, { page: 1, size: 500, autoFilter: true })
apiClient.post(`/table-management/tables/user_info/data`, { page: 1, size: 0, autoFilter: true })
.then((res) => {
const users = res.data?.data?.data || res.data?.data?.rows || [];
setEmployeeOptions(users.map((u: any) => ({
@@ -244,7 +244,7 @@ export default function SupplierManagementPage() {
}));
const res = await apiClient.post(`/table-management/tables/${SUPPLIER_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
sort: { columnName: "supplier_code", order: "desc" },
@@ -289,7 +289,7 @@ export default function SupplierManagementPage() {
const fetchMainContacts = useCallback(async () => {
try {
const contactRes = await apiClient.post(`/table-management/tables/${CONTACT_TABLE}/data`, {
page: 1, size: 500, autoFilter: true,
page: 1, size: 0, autoFilter: true,
dataFilter: { enabled: true, filters: [{ columnName: "is_main", operator: "equals", value: "Y" }] },
});
const allContacts = contactRes.data?.data?.data || contactRes.data?.data?.rows || [];
@@ -315,7 +315,7 @@ export default function SupplierManagementPage() {
setPriceLoading(true);
try {
const mappingRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [
{ columnName: "supplier_id", operator: "equals", value: selectedSupplier.supplier_code },
]},
@@ -342,7 +342,7 @@ export default function SupplierManagementPage() {
if (mappings.length > 0) {
try {
const priceRes = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [
{ columnName: "supplier_id", operator: "equals", value: selectedSupplier.supplier_code },
]},
@@ -417,7 +417,7 @@ export default function SupplierManagementPage() {
setDeliveryLoading(true);
try {
const res = await apiClient.post(`/table-management/tables/${DELIVERY_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [
{ columnName: "supplier_code", operator: "equals", value: selectedSupplier.supplier_code },
]},
@@ -497,7 +497,7 @@ export default function SupplierManagementPage() {
if (ruleData?.success && ruleData?.data?.ruleId) {
const ruleId = ruleData.data.ruleId;
const allRes = await apiClient.post(`/table-management/tables/${DELIVERY_TABLE}/data`, {
page: 1, size: 500, autoFilter: true,
page: 1, size: 0, autoFilter: true,
sort: { columnName: "destination_code", order: "desc" },
});
const allRows = allRes.data?.data?.data || allRes.data?.data?.rows || [];
@@ -570,7 +570,7 @@ export default function SupplierManagementPage() {
const ruleId = ruleData.data.ruleId;
// 기존 데이터에서 CUST-XXX 패턴의 최대 순번 조회
const allRes = await apiClient.post(`/table-management/tables/${SUPPLIER_TABLE}/data`, {
page: 1, size: 500, autoFilter: true,
page: 1, size: 0, autoFilter: true,
sort: { columnName: "supplier_code", order: "desc" },
});
const allRows = allRes.data?.data?.data || allRes.data?.data?.rows || [];
@@ -771,7 +771,7 @@ export default function SupplierManagementPage() {
const ruleData = ruleRes.data;
if (ruleData?.success && ruleData?.data?.ruleId) {
const ruleId = ruleData.data.ruleId;
const allRes = await apiClient.post(`/table-management/tables/${SUPPLIER_TABLE}/data`, { page: 1, size: 500, autoFilter: true, sort: { columnName: "supplier_code", order: "desc" } });
const allRes = await apiClient.post(`/table-management/tables/${SUPPLIER_TABLE}/data`, { page: 1, size: 0, autoFilter: true, sort: { columnName: "supplier_code", order: "desc" } });
const allRows = allRes.data?.data?.data || allRes.data?.data?.rows || [];
let maxSeq = 0;
for (const row of allRows) { const match = (row.supplier_code || "").match(/(\d+)$/); if (match) { const seq = parseInt(match[1], 10); if (seq > maxSeq) maxSeq = seq; } }
@@ -824,7 +824,7 @@ export default function SupplierManagementPage() {
const filters: any[] = [];
if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 5000,
page: 1, size: 0,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
@@ -1232,7 +1232,7 @@ export default function SupplierManagementPage() {
for (const itemId of itemIds) {
// 해당 품목의 모든 매핑 조회 → supplier_id null 처리
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [
{ columnName: "supplier_id", operator: "equals", value: selectedSupplier!.supplier_code },
{ columnName: "item_id", operator: "equals", value: itemId },
@@ -1249,7 +1249,7 @@ export default function SupplierManagementPage() {
// 해당 품목의 모든 단가 조회 → supplier_id null 처리
try {
const priceRes = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [
{ columnName: "supplier_id", operator: "equals", value: selectedSupplier!.supplier_code },
{ columnName: "item_id", operator: "equals", value: itemId },
@@ -1324,7 +1324,7 @@ export default function SupplierManagementPage() {
try {
const allMappings: any[] = [];
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
page: 1, size: 5000, autoFilter: true,
page: 1, size: 0, autoFilter: true,
});
const mappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
const itemIds = [...new Set(mappings.map((m: any) => m.item_id).filter(Boolean))];
@@ -101,7 +101,7 @@ export default function InspectionResultPage() {
try {
const res = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
page: 1,
size: 500,
size: 0,
autoFilter: true,
search: { master_id: masterId },
});
File diff suppressed because it is too large Load Diff
@@ -12,7 +12,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Di
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import {
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ClipboardList,
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, FileSpreadsheet,
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, FileSpreadsheet, Copy,
} from "lucide-react";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { cn } from "@/lib/utils";
@@ -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/선택형)
@@ -98,6 +99,11 @@ export default function ItemInspectionInfoPage() {
const [inspectionRows, setInspectionRows] = useState<Record<string, InspectionRow[]>>({});
const [collapsedTypes, setCollapsedTypes] = useState<Record<string, boolean>>({});
// 복사 모달: 편집 가능한 기준 데이터 상태 (등록/수정 폼과 평행 구조)
const [copyForm, setCopyForm] = useState<Record<string, any>>({});
const [copyInspectionRows, setCopyInspectionRows] = useState<Record<string, InspectionRow[]>>({});
const [copyCollapsedTypes, setCopyCollapsedTypes] = useState<Record<string, boolean>>({});
// 기본 라우팅 공정 목록 (적용공정 Select용)
const [processOptions, setProcessOptions] = useState<{ code: string; name: string }[]>([]);
@@ -119,9 +125,9 @@ export default function ItemInspectionInfoPage() {
const loadOptions = async () => {
try {
const [itemRes, inspRes, userRes] = await Promise.all([
apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, { page: 1, size: 500, autoFilter: true }),
apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, { page: 1, size: 500, autoFilter: true }),
apiClient.post(`/table-management/tables/user_info/data`, { page: 1, size: 500, autoFilter: true }),
apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, { page: 1, size: 0, autoFilter: true }),
apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, { page: 1, size: 0, autoFilter: true }),
apiClient.post(`/table-management/tables/user_info/data`, { page: 1, size: 0, autoFilter: true }),
]);
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
setItemOptions(items.map((r: any) => ({
@@ -253,13 +259,180 @@ export default function ItemInspectionInfoPage() {
loadProcessOptions(item.code);
};
/* ═══════════════════ 복사 모달 (기준 품목 검사정보 → 다른 품목들) ═══════════════════ */
const [copyModalOpen, setCopyModalOpen] = useState(false);
const [copySearchKeyword, setCopySearchKeyword] = useState("");
const [copyFilteredItems, setCopyFilteredItems] = useState<typeof itemOptions>([]);
const [copySearchLoading, setCopySearchLoading] = useState(false);
const [copyPage, setCopyPage] = useState(1);
const [copyTotal, setCopyTotal] = useState(0);
const [copyCheckedIds, setCopyCheckedIds] = useState<string[]>([]);
const [copying, setCopying] = useState(false);
const [copyProgress, setCopyProgress] = useState({ current: 0, total: 0 });
const copyPageSize = 20;
const copyTotalPages = Math.max(1, Math.ceil(copyTotal / copyPageSize));
const searchCopyTargets = async (page?: number) => {
const p = page ?? copyPage;
setCopySearchLoading(true);
try {
const filters: any[] = [];
if (copySearchKeyword.trim()) {
filters.push({ columnName: "item_name", operator: "contains", value: copySearchKeyword.trim() });
}
const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
page: p, size: copyPageSize,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const resData = res.data?.data;
const rows = resData?.data || resData?.rows || [];
const cm = itemCatMapRef.current;
const list = rows
.filter((r: any) => r.item_number !== selectedItemCode)
.map((r: any) => ({
code: r.item_number,
name: r.item_name,
item_type: cm["type"]?.[r.type] || r.type || "",
unit: cm["inventory_unit"]?.[r.inventory_unit] || r.inventory_unit || "",
}));
setCopyFilteredItems(list);
setCopyTotal(resData?.total || resData?.totalCount || rows.length);
} catch { /* skip */ } finally { setCopySearchLoading(false); }
};
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: "",
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);
};
const handleCopySearch = () => { setCopyPage(1); searchCopyTargets(1); };
const toggleCopyChecked = (code: string) => {
setCopyCheckedIds(prev => prev.includes(code) ? prev.filter(c => c !== code) : [...prev, code]);
};
const handleCopy = async () => {
if (!selectedItemCode) { toast.error("복사 기준 품목이 없어요"); return; }
if (copyCheckedIds.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}개 품목에 편집된 검사정보(${flatRows.length}개 행)를 복사할까요?`,
{ description: "대상 품목의 기존 검사정보는 삭제 후 교체됩니다.", variant: "info", confirmText: "복사" }
);
if (!ok) return;
setCopying(true);
setCopyProgress({ current: 0, total: copyCheckedIds.length });
await new Promise(resolve => setTimeout(resolve, 50));
try {
for (let i = 0; i < copyCheckedIds.length; i++) {
const targetCode = copyCheckedIds[i];
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: 0,
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: targetCode }] },
autoFilter: true,
});
const existing = existRes.data?.data?.data || existRes.data?.data?.rows || [];
if (existing.length > 0) {
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: existing.map((r: any) => ({ id: r.id })) });
}
for (const { row: r, typeLabel } of flatRows) {
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, {
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 || "",
pass_criteria: r.acceptance_criteria || "",
is_required: r.is_required ? "true" : "false",
is_active: copyForm.is_active || "사용",
manager: copyForm.manager || "",
});
}
setCopyProgress({ current: i + 1, total: copyCheckedIds.length });
}
await new Promise(resolve => setTimeout(resolve, 500));
toast.success(`${copyCheckedIds.length}개 품목에 복사했어요`);
setCopyModalOpen(false);
fetchData();
} catch { toast.error("복사에 실패했어요"); }
finally {
setCopying(false);
setCopyProgress({ current: 0, total: 0 });
}
};
/* ═══════════════════ 데이터 조회 ═══════════════════ */
const fetchData = useCallback(async () => {
setLoading(true);
try {
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
@@ -300,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 → 라벨
@@ -329,11 +508,18 @@ export default function ItemInspectionInfoPage() {
try {
const res = 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: code }] },
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> = {};
@@ -360,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,
@@ -378,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) => {
@@ -423,13 +610,53 @@ 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: "", 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);
try {
if (editMode) {
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: form.item_code }] },
autoFilter: true,
});
@@ -440,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"),
});
}
}
@@ -732,7 +964,6 @@ export default function ItemInspectionInfoPage() {
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={openExcelUpload}>
<FileSpreadsheet className="w-3.5 h-3.5 mr-1" />
</Button>
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" /></Button>
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" /></Button>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => ts.setOpen(true)}><Settings2 className="h-3.5 w-3.5" /></Button>
</div>
@@ -814,6 +1045,7 @@ export default function ItemInspectionInfoPage() {
{selectedGroup && (
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" /></Button>
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={openCopyModal}><Copy className="w-3.5 h-3.5 mr-1" /></Button>
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" /></Button>
</div>
)}
@@ -872,15 +1104,17 @@ 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>
<TableHead className="text-[10px] font-bold h-8 w-[70px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{selectedTabRows.length === 0 ? (
<TableRow>
<TableCell colSpan={7} 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}>
@@ -899,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);
@@ -907,12 +1142,29 @@ 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>
) : "-"}
</TableCell>
<TableCell className="text-xs py-2">
{(() => {
const insp = inspOptions.find(o => o.code === row.inspection_standard_id);
const unitCode = insp?.unit || "";
const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode;
return unitLabel || "-";
})()}
</TableCell>
</TableRow>
))}
</TableBody>
@@ -1074,15 +1326,17 @@ 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>
<TableHead className="text-[10px] font-bold w-[70px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[36px]" />
</TableRow>
</TableHeader>
<TableBody>
{(!inspectionRows[key] || inspectionRows[key].length === 0) ? (
<TableRow><TableCell colSpan={8} 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">
@@ -1107,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>
@@ -1148,6 +1405,7 @@ export default function ItemInspectionInfoPage() {
)}
</TableCell>
<TableCell className="p-1 text-center"><Checkbox checked={row.is_required} onCheckedChange={(v) => updateInspRow(key, row.id, "is_required", !!v)} /></TableCell>
<TableCell className="p-1 text-xs text-muted-foreground">{row.unit || "-"}</TableCell>
<TableCell className="p-1">
<Button type="button" variant="destructive" size="sm" className="h-7 w-7 p-0" onClick={() => removeInspRow(key, row.id)}><Trash2 className="w-3.5 h-3.5" /></Button>
</TableCell>
@@ -1172,6 +1430,278 @@ export default function ItemInspectionInfoPage() {
</DialogContent>
</Dialog>
{/* ═══════════════════ 복사 모달 (2단 분할: 좌 대상 / 우 편집) ═══════════════════ */}
<Dialog open={copyModalOpen} onOpenChange={(v) => { if (!copying) setCopyModalOpen(v); }}>
<DialogContent
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 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>
</DialogDescription>
</DialogHeader>
{copying ? (
<div className="flex-1 flex flex-col items-center justify-center gap-4 py-8 px-4">
<div className="w-full max-w-sm space-y-3">
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin text-blue-600" />
<span className="text-sm font-medium text-blue-700"> ...</span>
<span className="text-xs text-blue-600 ml-auto">
{copyProgress.current.toLocaleString()} / {copyProgress.total.toLocaleString()}
</span>
</div>
<div className="w-full bg-blue-100 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${copyProgress.total > 0 ? Math.round((copyProgress.current / copyProgress.total) * 100) : 0}%` }}
/>
</div>
<p className="text-xs text-muted-foreground text-center pt-2">
. .
</p>
</div>
</div>
) : (
<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-[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>
<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>
)}
<DialogFooter className="shrink-0">
<Button variant="outline" onClick={() => setCopyModalOpen(false)} disabled={copying}></Button>
<Button onClick={handleCopy} disabled={copying || copyCheckedIds.length === 0}>
{copying ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Copy className="w-4 h-4 mr-1" />}
{copying
? `복사 중 (${copyProgress.current}/${copyProgress.total})`
: copyCheckedIds.length > 0 ? `${copyCheckedIds.length}개 품목에 복사` : "복사"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<TableSettingsModal open={ts.open} onOpenChange={ts.setOpen} tableName={ts.tableName} settingsId={ts.settingsId} defaultVisibleKeys={ts.defaultVisibleKeys} onSave={ts.applySettings} />
{/* ═══════ 엑셀 업로드 모달 ═══════ */}
@@ -190,7 +190,7 @@ export default function ClaimManagementPage() {
const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
page: 1,
size: 500,
size: 0,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
sort: { columnName: "claim_date", order: "desc" },
@@ -224,7 +224,7 @@ export default function CustomerManagementPage() {
} catch { /* skip */ }
};
load();
apiClient.post(`/table-management/tables/user_info/data`, { page: 1, size: 500, autoFilter: true })
apiClient.post(`/table-management/tables/user_info/data`, { page: 1, size: 0, autoFilter: true })
.then((res) => {
const users = res.data?.data?.data || res.data?.data?.rows || [];
setEmployeeOptions(users.map((u: any) => ({
@@ -289,7 +289,7 @@ export default function CustomerManagementPage() {
const fetchMainContacts = useCallback(async () => {
try {
const contactRes = await apiClient.post(`/table-management/tables/${CONTACT_TABLE}/data`, {
page: 1, size: 500, autoFilter: true,
page: 1, size: 0, autoFilter: true,
dataFilter: { enabled: true, filters: [{ columnName: "is_main", operator: "equals", value: "Y" }] },
});
const allContacts = contactRes.data?.data?.data || contactRes.data?.data?.rows || [];
@@ -315,7 +315,7 @@ export default function CustomerManagementPage() {
setPriceLoading(true);
try {
const mappingRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [
{ columnName: "customer_id", operator: "equals", value: selectedCustomer.customer_code },
]},
@@ -342,7 +342,7 @@ export default function CustomerManagementPage() {
if (mappings.length > 0) {
try {
const priceRes = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [
{ columnName: "customer_id", operator: "equals", value: selectedCustomer.customer_code },
]},
@@ -418,7 +418,7 @@ export default function CustomerManagementPage() {
setDeliveryLoading(true);
try {
const res = await apiClient.post(`/table-management/tables/${DELIVERY_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [
{ columnName: "customer_code", operator: "equals", value: selectedCustomer.customer_code },
]},
@@ -498,7 +498,7 @@ export default function CustomerManagementPage() {
if (ruleData?.success && ruleData?.data?.ruleId) {
const ruleId = ruleData.data.ruleId;
const allRes = await apiClient.post(`/table-management/tables/${DELIVERY_TABLE}/data`, {
page: 1, size: 500, autoFilter: true,
page: 1, size: 0, autoFilter: true,
sort: { columnName: "destination_code", order: "desc" },
});
const allRows = allRes.data?.data?.data || allRes.data?.data?.rows || [];
@@ -571,7 +571,7 @@ export default function CustomerManagementPage() {
const ruleId = ruleData.data.ruleId;
// 기존 데이터에서 CUST-XXX 패턴의 최대 순번 조회
const allRes = await apiClient.post(`/table-management/tables/${CUSTOMER_TABLE}/data`, {
page: 1, size: 500, autoFilter: true,
page: 1, size: 0, autoFilter: true,
sort: { columnName: "customer_code", order: "desc" },
});
const allRows = allRes.data?.data?.data || allRes.data?.data?.rows || [];
@@ -772,7 +772,7 @@ export default function CustomerManagementPage() {
const ruleData = ruleRes.data;
if (ruleData?.success && ruleData?.data?.ruleId) {
const ruleId = ruleData.data.ruleId;
const allRes = await apiClient.post(`/table-management/tables/${CUSTOMER_TABLE}/data`, { page: 1, size: 500, autoFilter: true, sort: { columnName: "customer_code", order: "desc" } });
const allRes = await apiClient.post(`/table-management/tables/${CUSTOMER_TABLE}/data`, { page: 1, size: 0, autoFilter: true, sort: { columnName: "customer_code", order: "desc" } });
const allRows = allRes.data?.data?.data || allRes.data?.data?.rows || [];
let maxSeq = 0;
for (const row of allRows) { const match = (row.customer_code || "").match(/(\d+)$/); if (match) { const seq = parseInt(match[1], 10); if (seq > maxSeq) maxSeq = seq; } }
@@ -827,7 +827,7 @@ export default function CustomerManagementPage() {
? [{ columnName: "division", operator: "contains", value: salesCode }]
: [];
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 5000,
page: 1, size: 0,
dataFilter: { enabled: true, filters },
autoFilter: true,
});
@@ -1252,7 +1252,7 @@ export default function CustomerManagementPage() {
for (const itemId of itemIds) {
// 해당 품목의 모든 매핑 조회 → customer_id null 처리
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [
{ columnName: "customer_id", operator: "equals", value: selectedCustomer!.customer_code },
{ columnName: "item_id", operator: "equals", value: itemId },
@@ -1269,7 +1269,7 @@ export default function CustomerManagementPage() {
// 해당 품목의 모든 단가 조회 → customer_id null 처리
try {
const priceRes = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [
{ columnName: "customer_id", operator: "equals", value: selectedCustomer!.customer_code },
{ columnName: "item_id", operator: "equals", value: itemId },
@@ -1344,7 +1344,7 @@ export default function CustomerManagementPage() {
try {
const allMappings: any[] = [];
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
page: 1, size: 5000, autoFilter: true,
page: 1, size: 0, autoFilter: true,
});
const mappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
const itemIds = [...new Set(mappings.map((m: any) => m.item_id).filter(Boolean))];
@@ -28,6 +28,7 @@ import { ShippingPlanBatchModal } from "@/components/common/ShippingPlanBatchMod
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { SmartSelect } from "@/components/common/SmartSelect";
import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
const DETAIL_TABLE = "sales_order_detail";
@@ -277,7 +278,7 @@ export default function SalesOrderPage() {
// 거래처 목록
try {
const custRes = await apiClient.post(`/table-management/tables/customer_mng/data`, {
page: 1, size: 500, autoFilter: true,
page: 1, size: 0, autoFilter: true,
});
const custs = custRes.data?.data?.data || custRes.data?.data?.rows || [];
optMap["partner_id"] = custs.map((c: any) => ({ code: c.customer_code, label: c.customer_name }));
@@ -285,7 +286,7 @@ export default function SalesOrderPage() {
// 사용자 목록
try {
const userRes = await apiClient.post(`/table-management/tables/user_info/data`, {
page: 1, size: 500, autoFilter: true,
page: 1, size: 0, autoFilter: true,
});
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
optMap["manager_id"] = users.map((u: any) => ({
@@ -330,7 +331,7 @@ export default function SalesOrderPage() {
}));
const res = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
sort: { columnName: "order_no", order: "desc" },
@@ -770,7 +771,7 @@ export default function SalesOrderPage() {
if (isCustomerPrice && partnerId) {
try {
const mappingRes = await apiClient.post(`/table-management/tables/customer_item_mapping/data`, {
page: 1, size: 5000,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [{ columnName: "customer_id", operator: "equals", value: partnerId }] },
autoFilter: true,
});
@@ -860,7 +861,7 @@ export default function SalesOrderPage() {
try {
const itemIds = selected.map((item) => item.item_number || item.id);
const res = await apiClient.post(`/table-management/tables/customer_item_prices/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: {
enabled: true,
filters: [
@@ -931,7 +932,7 @@ export default function SalesOrderPage() {
if (itemCodes.length === 0) return;
try {
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: itemCodes }] },
autoFilter: true,
});
@@ -954,7 +955,7 @@ export default function SalesOrderPage() {
if (itemCodes.length === 0) return;
try {
const res = await apiClient.post(`/table-management/tables/customer_item_prices/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [
{ columnName: "customer_id", operator: "equals", value: partnerId },
{ columnName: "item_id", operator: "in", value: itemCodes },
@@ -1481,17 +1482,12 @@ export default function SalesOrderPage() {
<div className="grid grid-cols-1 gap-3.5 sm:grid-cols-2 lg:grid-cols-4">
<div className="space-y-1">
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select
<SmartSelect
options={categoryOptions["partner_id"] || []}
value={masterForm.partner_id || ""}
onValueChange={(v) => { setMasterForm((p) => ({ ...p, partner_id: v, delivery_partner_id: "" })); loadDeliveryOptions(v); recalcPrices(masterForm.price_mode || "", v); }}
>
<SelectTrigger className="h-9"><SelectValue placeholder="거래처 선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["partner_id"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
placeholder="거래처 선택"
/>
</div>
<div className="space-y-1">
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></Label>
@@ -1642,10 +1638,8 @@ export default function SalesOrderPage() {
<TableHead className="min-w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[90px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[100px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[120px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[100px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[160px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
@@ -1664,14 +1658,6 @@ export default function SalesOrderPage() {
</TableCell>
<TableCell className="text-[13px] text-muted-foreground whitespace-nowrap">{row.spec}</TableCell>
<TableCell className="text-[13px] text-muted-foreground whitespace-nowrap">{row.material}</TableCell>
<TableCell>
<Input
value={row.packing_material || ""}
onChange={(e) => updateDetailRow(idx, "packing_material", e.target.value)}
placeholder="포장재"
className="h-8 text-xs w-full"
/>
</TableCell>
<TableCell>
<Select value={row.unit || ""} onValueChange={(v) => updateDetailRow(idx, "unit", v)}>
<SelectTrigger className="h-8 text-xs w-full"><SelectValue placeholder="단위" /></SelectTrigger>
@@ -1692,15 +1678,6 @@ export default function SalesOrderPage() {
className="h-8 text-xs text-right font-mono w-full"
/>
</TableCell>
<TableCell>
<Input
type="number"
min="0"
value={row.pack_qty || "0"}
onChange={(e) => updateDetailRow(idx, "pack_qty", e.target.value)}
className="h-8 text-xs text-right font-mono w-full"
/>
</TableCell>
<TableCell>
<Input
value={formatNumber(row.unit_price || "")}
@@ -16,6 +16,7 @@ import {
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { reportApi } from "@/lib/api/reportApi";
import { useCurrent2ndLevelMenuObjid } from "@/hooks/useCurrent2ndLevelMenuObjid";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { exportToExcel } from "@/lib/utils/excelExport";
import { useAuth } from "@/hooks/useAuth";
@@ -28,7 +29,7 @@ import {
ResizablePanelGroup, ResizablePanel, ResizableHandle,
} from "@/components/ui/resizable";
import { ReportInlineViewer } from "@/components/report/ReportInlineViewer";
import { ReportMaster, ComponentConfig } from "@/types/report";
import { ReportMaster, ComponentConfig, GridCell } from "@/types/report";
const MASTER_TABLE = "quote_mng";
@@ -82,6 +83,12 @@ export default function QuoteManagementPage() {
const [basicInfoOpen, setBasicInfoOpen] = useState(false);
const [basicForm, setBasicForm] = useState({ quote_date: "", valid_until: "", status: "draft" });
// 리포트 셀 input 오버라이드
const [cellOverrides, setCellOverrides] = useState<Record<string, Record<string, string>>>({});
const [inputCellOpen, setInputCellOpen] = useState(false);
const [inputCellCtx, setInputCellCtx] = useState<{ comp: ComponentConfig; cells: GridCell[] } | null>(null);
const [inputCellValues, setInputCellValues] = useState<Record<string, string>>({});
// 엑셀 / 리포트
const [excelOpen, setExcelOpen] = useState(false);
const [reportList, setReportList] = useState<ReportMaster[]>([]);
@@ -153,10 +160,13 @@ export default function QuoteManagementPage() {
useEffect(() => { fetchQuotes(); }, [fetchQuotes]);
const current2ndLevelMenuObjid = useCurrent2ndLevelMenuObjid();
useEffect(() => {
if (current2ndLevelMenuObjid === null) return;
(async () => {
try {
const res = await reportApi.getReports({ page: 1, limit: 100 });
const res = await reportApi.getReportsByMenuObjid(current2ndLevelMenuObjid);
if (res.success) {
const items = res.data.items ?? [];
setReportList(items);
@@ -164,7 +174,105 @@ export default function QuoteManagementPage() {
}
} catch { /* 무시 */ }
})();
}, []);
}, [current2ndLevelMenuObjid]);
// ── 리포트 셀 오버라이드: 견적/리포트 변경 시 로드 ──
useEffect(() => {
if (!selectedRow?.objid || !selectedReportId) {
setCellOverrides({});
return;
}
(async () => {
try {
const res = await apiClient.get("/report-cell-values", {
params: { report_id: selectedReportId, target_type: "quote", target_id: String(selectedRow.objid) },
});
const rows = res.data?.data || [];
const map: Record<string, Record<string, string>> = {};
for (const r of rows) {
if (!map[r.component_id]) map[r.component_id] = {};
map[r.component_id][r.cell_id] = r.value ?? "";
}
setCellOverrides(map);
} catch {
setCellOverrides({});
}
})();
}, [selectedRow?.objid, selectedReportId]);
// ── input 셀 클릭 → 해당 테이블의 모든 input 셀을 모아 한 모달에 표시 ──
const handleInputCellClick = (comp: ComponentConfig, _cell: GridCell) => {
const allCells = ((comp as any).gridCells || []) as GridCell[];
const inputCells = allCells
.filter((c) => c.cellType === "input" && !c.merged)
.sort((a, b) => (a.row - b.row) || (a.col - b.col));
const vals: Record<string, string> = {};
for (const c of inputCells) {
vals[c.id] = cellOverrides[comp.id]?.[c.id] ?? "";
}
setInputCellCtx({ comp, cells: inputCells });
setInputCellValues(vals);
setInputCellOpen(true);
};
// ── input 셀 라벨 찾기: 같은 행의 static 라벨 셀 값 → 없으면 placeholder ──
const getInputCellLabel = (comp: ComponentConfig, cell: GridCell): string => {
const allCells = ((comp as any).gridCells || []) as GridCell[];
const labelCell = allCells
.filter((c) => c.row === cell.row && c.col < cell.col && c.cellType === "static" && c.value && !c.merged)
.sort((a, b) => b.col - a.col)[0];
if (labelCell?.value) return String(labelCell.value).trim();
return cell.inputPlaceholder || "값";
};
// ── input 셀 저장: 변경된 셀들만 일괄 저장 ──
const handleInputCellSave = async () => {
if (!inputCellCtx || !selectedRow?.objid || !selectedReportId) return;
const { comp, cells } = inputCellCtx;
const existing = cellOverrides[comp.id] || {};
const toSave: { cellId: string; value: string }[] = [];
for (const c of cells) {
const newVal = inputCellValues[c.id] ?? "";
const oldVal = existing[c.id] ?? "";
if (newVal !== oldVal) toSave.push({ cellId: c.id, value: newVal });
}
if (toSave.length === 0) {
setInputCellOpen(false);
setInputCellCtx(null);
return;
}
try {
await Promise.all(
toSave.map((t) =>
apiClient.post("/report-cell-values", {
report_id: selectedReportId,
target_type: "quote",
target_id: String(selectedRow.objid),
component_id: comp.id,
cell_id: t.cellId,
value: t.value,
})
)
);
setCellOverrides((prev) => {
const next = { ...prev };
const curr = { ...(next[comp.id] || {}) };
for (const c of cells) {
const v = inputCellValues[c.id] ?? "";
if (v === "") delete curr[c.id];
else curr[c.id] = v;
}
if (Object.keys(curr).length === 0) delete next[comp.id];
else next[comp.id] = curr;
return next;
});
toast.success(`${toSave.length}개 항목이 저장됐어요`);
setInputCellOpen(false);
setInputCellCtx(null);
} catch {
toast.error("저장 실패");
}
};
// ── "신규" → DB에 draft 즉시 생성 → 자동 선택 ──
@@ -214,6 +322,15 @@ export default function QuoteManagementPage() {
setEditComp(comp);
if (comp.type === "table") {
// 품목 테이블 판별: tableColumns 중 품목 관련 필드가 포함되어야 편집 대상
const cols = (comp as any).tableColumns || [];
const ITEM_FIELDS = new Set(["item_code", "item_name", "qty", "unit_price", "spec", "total_amount", "supply_amount", "vat_amount"]);
const isItemTable = cols.some((c: any) => ITEM_FIELDS.has((c.field || "").toLowerCase()));
if (!isItemTable) {
toast.info("이 테이블의 각 셀에서 직접 입력하세요 (input 셀로 지정된 곳만 편집 가능)");
setEditComp(null);
return;
}
// 테이블 → 품목 편집
try {
const res = await apiClient.get(`/quotes/${selectedRow.objid}`);
@@ -692,6 +809,8 @@ export default function QuoteManagementPage() {
reportId={selectedReportId}
contextParams={contextParams}
onComponentClick={handleComponentClick}
cellOverrides={cellOverrides}
onInputCellClick={handleInputCellClick}
/>
)}
</div>
@@ -848,6 +967,42 @@ export default function QuoteManagementPage() {
</DialogContent>
</Dialog>
{/* ═══ 리포트 input 셀 입력 모달 (테이블 내 모든 input 셀을 한 번에 편집) ═══ */}
<Dialog open={inputCellOpen} onOpenChange={(o) => { if (!o) { setInputCellOpen(false); setInputCellCtx(null); } }}>
<DialogContent className="max-h-[85vh] max-w-lg overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription>
. .
</DialogDescription>
</DialogHeader>
<div className="flex-1 space-y-3 overflow-auto py-2">
{inputCellCtx?.cells.map((c) => (
<div key={c.id} className="space-y-1">
<Label className="text-xs font-semibold">
{inputCellCtx ? getInputCellLabel(inputCellCtx.comp, c) : ""}
</Label>
<Textarea
value={inputCellValues[c.id] ?? ""}
onChange={(e) => setInputCellValues((prev) => ({ ...prev, [c.id]: e.target.value }))}
placeholder={c.inputPlaceholder || "값"}
rows={2}
/>
</div>
))}
{inputCellCtx?.cells.length === 0 && (
<p className="text-center text-xs text-muted-foreground"> </p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => { setInputCellOpen(false); setInputCellCtx(null); }}></Button>
<Button onClick={handleInputCellSave} className="gap-1.5">
<Save className="h-4 w-4" />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ═══ 담당자(사원) 검색 모달 ═══ */}
<Dialog open={userSearchOpen} onOpenChange={setUserSearchOpen}>
<DialogContent className="flex max-h-[70vh] max-w-lg flex-col overflow-hidden">
@@ -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 }[] = [];
@@ -327,7 +332,7 @@ export default function SalesItemPage() {
}
const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
page: 1, size: 5000,
page: 1, size: 0,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
@@ -373,7 +378,7 @@ export default function SalesItemPage() {
try {
// 1. customer_item_mapping에서 해당 품목의 매핑 조회
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [{ columnName: "item_id", operator: "equals", value: itemKey }] },
autoFilter: true,
sort: { columnName: "created_date", order: "asc" },
@@ -401,7 +406,7 @@ export default function SalesItemPage() {
if (mappings.length > 0) {
try {
const priceRes = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [
{ columnName: "item_id", operator: "equals", value: itemKey },
]},
@@ -1111,7 +1116,7 @@ export default function SalesItemPage() {
for (const custCode of customerCodes) {
// 해당 거래처의 모든 매핑 조회 → item_id null 처리
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [
{ columnName: "item_id", operator: "equals", value: selectedItem!.item_number },
{ columnName: "customer_id", operator: "equals", value: custCode },
@@ -1128,7 +1133,7 @@ export default function SalesItemPage() {
// 해당 거래처의 모든 단가 조회 → item_id null 처리
try {
const priceRes = await apiClient.post(`/table-management/tables/customer_item_prices/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [
{ columnName: "item_id", operator: "equals", value: selectedItem!.item_number },
{ columnName: "customer_id", operator: "equals", value: custCode },
@@ -86,6 +86,7 @@ export default function EquipmentInfoPage() {
const [inspectionForm, setInspectionForm] = useState<Record<string, any>>({});
const [inspectionContinuous, setInspectionContinuous] = useState(false);
const [inspectionEditMode, setInspectionEditMode] = useState(false);
const [checkedInspectionIds, setCheckedInspectionIds] = useState<Set<string>>(new Set());
// 소모품 추가/수정 모달
const [consumableModalOpen, setConsumableModalOpen] = useState(false);
@@ -93,6 +94,7 @@ export default function EquipmentInfoPage() {
const [consumableContinuous, setConsumableContinuous] = useState(false);
const [consumableEditMode, setConsumableEditMode] = useState(false);
const [consumableItemOptions, setConsumableItemOptions] = useState<any[]>([]);
const [checkedConsumableIds, setCheckedConsumableIds] = useState<Set<string>>(new Set());
// 점검항목 복사
const [copyModalOpen, setCopyModalOpen] = useState(false);
@@ -147,17 +149,17 @@ export default function EquipmentInfoPage() {
const colProps: Record<string, Partial<EDataTableColumn>> = {
equipment_code: { width: "w-[110px]" },
equipment_name: { minWidth: "min-w-[130px]", truncate: true, render: (v) => v || "-" },
equipment_type: { width: "w-[90px]", render: (v) => v || "-" },
equipment_type: { width: "w-[90px]", render: (v) => resolve("equipment_type", v) || v || "-" },
manufacturer: { width: "w-[100px]", render: (v) => v || "-" },
installation_location: { width: "w-[100px]", render: (v) => v || "-" },
operation_status: { width: "w-[80px]", render: (v) => v || "-" },
operation_status: { width: "w-[80px]", render: (v) => resolve("operation_status", v) || v || "-" },
};
return ts.visibleColumns.map((col) => ({
key: col.key,
label: col.label,
...colProps[col.key],
}));
}, [ts.visibleColumns]);
}, [ts.visibleColumns, catOptions]);
// 설비 조회
const fetchEquipments = useCallback(async () => {
@@ -165,16 +167,12 @@ export default function EquipmentInfoPage() {
try {
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
const res = await apiClient.post(`/table-management/tables/${EQUIP_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const raw = res.data?.data?.data || res.data?.data?.rows || [];
setEquipments(raw.map((r: any) => ({
...r,
equipment_type: resolve("equipment_type", r.equipment_type),
operation_status: resolve("operation_status", r.operation_status),
})));
setEquipments(raw);
setEquipCount(res.data?.data?.total || raw.length);
} catch { toast.error("설비 목록 조회 실패"); } finally { setEquipLoading(false); }
}, [searchFilters, catOptions]);
@@ -204,12 +202,13 @@ export default function EquipmentInfoPage() {
// 우측: 점검항목 조회
useEffect(() => {
setCheckedInspectionIds(new Set());
if (!selectedEquip?.equipment_code) { setInspections([]); return; }
const fetchData = async () => {
setInspectionLoading(true);
try {
const res = await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "equals", value: selectedEquip.equipment_code }] },
autoFilter: true,
});
@@ -221,12 +220,13 @@ export default function EquipmentInfoPage() {
// 우측: 소모품 조회
useEffect(() => {
setCheckedConsumableIds(new Set());
if (!selectedEquip?.equipment_code) { setConsumables([]); return; }
const fetchData = async () => {
setConsumableLoading(true);
try {
const res = await apiClient.post(`/table-management/tables/${CONSUMABLE_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "equals", value: selectedEquip.equipment_code }] },
autoFilter: true,
});
@@ -296,6 +296,34 @@ export default function EquipmentInfoPage() {
} catch { toast.error("삭제 실패"); }
};
// 점검항목 삭제
const handleInspectionDelete = async () => {
const ids = Array.from(checkedInspectionIds);
if (ids.length === 0) { toast.error("삭제할 점검항목을 선택해주세요."); return; }
const ok = await confirm(`선택한 ${ids.length}건의 점검항목을 삭제하시겠습니까?`, { variant: "destructive", confirmText: "삭제" });
if (!ok) return;
try {
await apiClient.delete(`/table-management/tables/${INSPECTION_TABLE}/delete`, { data: ids.map((id) => ({ id })) });
toast.success("삭제되었습니다.");
setCheckedInspectionIds(new Set());
refreshRight();
} catch { toast.error("삭제 실패"); }
};
// 소모품 삭제
const handleConsumableDelete = async () => {
const ids = Array.from(checkedConsumableIds);
if (ids.length === 0) { toast.error("삭제할 소모품을 선택해주세요."); return; }
const ok = await confirm(`선택한 ${ids.length}건의 소모품을 삭제하시겠습니까?`, { variant: "destructive", confirmText: "삭제" });
if (!ok) return;
try {
await apiClient.delete(`/table-management/tables/${CONSUMABLE_TABLE}/delete`, { data: ids.map((id) => ({ id })) });
toast.success("삭제되었습니다.");
setCheckedConsumableIds(new Set());
refreshRight();
} catch { toast.error("삭제 실패"); }
};
// 점검항목 추가
const handleInspectionSave = async () => {
if (!inspectionForm.inspection_item) { toast.error("점검항목명은 필수입니다."); return; }
@@ -362,7 +390,7 @@ export default function EquipmentInfoPage() {
if (consumableDiv) filters.push({ columnName: "division", operator: "equals", value: consumableDiv.valueCode });
const results = await Promise.all(filters.map((f) =>
apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [f] },
autoFilter: true,
})
@@ -409,7 +437,7 @@ export default function EquipmentInfoPage() {
setCopyLoading(true);
try {
const res = await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "equals", value: equipCode }] },
autoFilter: true,
});
@@ -437,9 +465,9 @@ export default function EquipmentInfoPage() {
const handleExcelDownload = async () => {
if (equipments.length === 0) return;
await exportToExcel(equipments.map((e) => ({
설비코드: e.equipment_code, 설비명: e.equipment_name, 설비유형: e.equipment_type,
설비코드: e.equipment_code, 설비명: e.equipment_name, 설비유형: resolve("equipment_type", e.equipment_type),
제조사: e.manufacturer, 모델명: e.model_name, 설치장소: e.installation_location,
도입일자: e.introduction_date, 가동상태: e.operation_status,
도입일자: e.introduction_date, 가동상태: resolve("operation_status", e.operation_status),
})), "설비정보.xlsx", "설비");
toast.success("다운로드 완료");
};
@@ -550,15 +578,23 @@ export default function EquipmentInfoPage() {
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setInspectionForm({}); setInspectionEditMode(false); setInspectionModalOpen(true); }}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" disabled={checkedInspectionIds.size === 0} onClick={handleInspectionDelete} className="text-destructive border-destructive/20 bg-destructive/5 hover:bg-destructive/10">
<Trash2 className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setCopySourceEquip(""); setCopyItems([]); setCopyChecked(new Set()); setCopyModalOpen(true); }}>
<Copy className="w-3.5 h-3.5 mr-1" />
</Button>
</>
)}
{rightTab === "consumable" && (
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setConsumableForm({}); setConsumableEditMode(false); loadConsumableItems(); setConsumableModalOpen(true); }}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
<>
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setConsumableForm({}); setConsumableEditMode(false); loadConsumableItems(); setConsumableModalOpen(true); }}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" disabled={checkedConsumableIds.size === 0} onClick={handleConsumableDelete} className="text-destructive border-destructive/20 bg-destructive/5 hover:bg-destructive/10">
<Trash2 className="w-3.5 h-3.5 mr-1" />
</Button>
</>
)}
</div>
</div>
@@ -637,6 +673,16 @@ export default function EquipmentInfoPage() {
<Table noWrapper>
<thead className="sticky top-0 z-10 bg-card">
<TableRow>
<TableHead
className="w-[40px] text-center cursor-pointer"
onClick={() => {
const allChecked = inspections.length > 0 && checkedInspectionIds.size === inspections.length;
if (allChecked) setCheckedInspectionIds(new Set());
else setCheckedInspectionIds(new Set(inspections.map((i) => i.id)));
}}
>
<Checkbox checked={inspections.length > 0 && checkedInspectionIds.size === inspections.length} />
</TableHead>
<TableHead className="min-w-[120px] 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-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
@@ -664,6 +710,20 @@ export default function EquipmentInfoPage() {
setInspectionEditMode(true);
setInspectionModalOpen(true);
}}>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedInspectionIds((prev) => {
const next = new Set(prev);
if (next.has(item.id)) next.delete(item.id); else next.add(item.id);
return next;
});
}}
onDoubleClick={(e) => e.stopPropagation()}
>
<Checkbox checked={checkedInspectionIds.has(item.id)} />
</TableCell>
<TableCell className="text-sm">{item.inspection_item || "-"}</TableCell>
<TableCell className="text-[13px]">{resolve("inspection_cycle", item.inspection_cycle)}</TableCell>
<TableCell className="text-[13px]">{resolve("inspection_method", item.inspection_method)}</TableCell>
@@ -692,6 +752,16 @@ export default function EquipmentInfoPage() {
<Table noWrapper>
<thead className="sticky top-0 z-10 bg-card">
<TableRow>
<TableHead
className="w-[40px] text-center cursor-pointer"
onClick={() => {
const allChecked = consumables.length > 0 && checkedConsumableIds.size === consumables.length;
if (allChecked) setCheckedConsumableIds(new Set());
else setCheckedConsumableIds(new Set(consumables.map((i) => i.id)));
}}
>
<Checkbox checked={consumables.length > 0 && checkedConsumableIds.size === consumables.length} />
</TableHead>
<TableHead className="min-w-[120px] 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-[60px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
@@ -707,6 +777,20 @@ export default function EquipmentInfoPage() {
loadConsumableItems();
setConsumableModalOpen(true);
}}>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedConsumableIds((prev) => {
const next = new Set(prev);
if (next.has(item.id)) next.delete(item.id); else next.add(item.id);
return next;
});
}}
onDoubleClick={(e) => e.stopPropagation()}
>
<Checkbox checked={checkedConsumableIds.has(item.id)} />
</TableCell>
<TableCell className="text-sm">{item.consumable_name || "-"}</TableCell>
<TableCell className="text-[13px]">{item.replacement_cycle || "-"}</TableCell>
<TableCell className="text-[13px]">{item.unit || "-"}</TableCell>
@@ -83,7 +83,7 @@ export default function EquipmentInspectionRecordPage() {
}).catch(() => ({ data: { data: { data: [] } } })),
apiClient.post(`/table-management/tables/equipment_mng/data`, {
page: 1,
size: 500,
size: 0,
autoFilter: true,
}).catch(() => ({ data: { data: { data: [] } } })),
]);
@@ -100,7 +100,7 @@ export default function PlcSettingsPage() {
useEffect(() => {
const load = async () => {
try {
const eqRes = await apiClient.post(`/table-management/tables/${EQUIPMENT_TABLE}/data`, { page: 1, size: 500, autoFilter: true });
const eqRes = await apiClient.post(`/table-management/tables/${EQUIPMENT_TABLE}/data`, { page: 1, size: 0, autoFilter: true });
const eqs = eqRes.data?.data?.data || eqRes.data?.data?.rows || [];
setEquipOptions(eqs.map((r: any) => ({ code: r.equipment_code, label: `${r.equipment_code} - ${r.equipment_name || ""}` })));
} catch { /* skip */ }
@@ -122,7 +122,7 @@ export default function PlcSettingsPage() {
const filters: any[] = [];
if (kw.trim()) filters.push({ columnName: "equipment_code", operator: "contains", value: kw.trim() });
const res = await apiClient.post(`/table-management/tables/${DATATYPE_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
@@ -140,7 +140,7 @@ export default function PlcSettingsPage() {
const filters: any[] = [];
if (kw.trim()) filters.push({ columnName: "config_name", operator: "contains", value: kw.trim() });
const res = await apiClient.post(`/table-management/tables/${COLLECTION_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
@@ -150,7 +150,7 @@ export default function InboundOutboundPage() {
if (writerIds.length > 0) {
try {
const userRes = await apiClient.post(`/table-management/tables/user_info/data`, {
page: 1, size: 500, autoFilter: true,
page: 1, size: 0, autoFilter: true,
});
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
const uMap: Record<string, string> = {};
@@ -327,11 +327,11 @@ export default function LogisticsInfoPage() {
try {
const [carrierRes, routeRes] = await Promise.all([
apiClient.post("/table-management/tables/carrier_mng/data", {
page: 1, size: 500, autoFilter: true,
page: 1, size: 0, autoFilter: true,
sort: { columnName: "carrier_code", order: "asc" },
}),
apiClient.post("/table-management/tables/delivery_route_mng/data", {
page: 1, size: 500, autoFilter: true,
page: 1, size: 0, autoFilter: true,
sort: { columnName: "route_code", order: "asc" },
}),
]);
@@ -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,
@@ -393,7 +395,7 @@ export default function LogisticsInfoPage() {
const res = await apiClient.post(
`/table-management/tables/${config.tableName}/data`,
{
page: 1, size: 500, autoFilter: true,
page: 1, size: 0, autoFilter: true,
sort: { columnName: config.defaultSortColumn, order: "asc" },
}
);
@@ -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]}

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