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

This commit is contained in:
kmh
2026-04-13 11:17:55 +09:00
173 changed files with 26686 additions and 10499 deletions
+2
View File
@@ -132,6 +132,7 @@ import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면
import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관리
import popActionRoutes from "./routes/popActionRoutes"; // POP 액션 실행
import popProductionRoutes from "./routes/popProductionRoutes"; // POP 생산 관리 (공정 생성/타이머)
import popInventoryRoutes from "./routes/popInventoryRoutes"; // POP 재고 조정/이동
import inspectionResultRoutes from "./routes/inspectionResultRoutes"; // POP 검사 결과 관리
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
import approvalRoutes from "./routes/approvalRoutes"; // 결재 시스템
@@ -297,6 +298,7 @@ app.use("/api/screen-management", screenManagementRoutes);
app.use("/api/screen-groups", screenGroupRoutes); // 화면 그룹 관리
app.use("/api/pop", popActionRoutes); // POP 액션 실행
app.use("/api/pop/production", popProductionRoutes); // POP 생산 관리
app.use("/api/pop/inventory", popInventoryRoutes); // POP 재고 조정/이동
app.use("/api/pop/inspection-result", inspectionResultRoutes); // POP 검사 결과 관리
app.use("/api/common-codes", commonCodeRoutes);
app.use("/api/dynamic-form", dynamicFormRoutes);
+37 -1
View File
@@ -924,12 +924,26 @@ export const previewFile = async (
);
res.setHeader("Access-Control-Allow-Credentials", "true");
// 캐시 헤더 설정
// Cross-Origin-Resource-Policy: cross-origin 설정
// helmet 기본값(same-origin)을 오버라이드하여 v1.vexplor.com에서 api.vexplor.com 이미지 로드 허용
res.setHeader("Cross-Origin-Resource-Policy", "cross-origin");
// 파일 크기 및 캐시 헤더 설정
const stat = fs.statSync(finalPath);
res.setHeader("Content-Length", stat.size);
res.setHeader("Cache-Control", "public, max-age=3600");
res.setHeader("Content-Type", mimeType);
// 파일 스트림으로 전송
const fileStream = fs.createReadStream(finalPath);
fileStream.on("error", (err) => {
console.error("파일 스트림 오류:", err);
if (!res.headersSent) {
res.status(500).json({ success: false, message: "파일 읽기 오류" });
} else {
res.end();
}
});
fileStream.pipe(res);
} catch (error) {
console.error("파일 미리보기 오류:", error);
@@ -1031,9 +1045,20 @@ export const downloadFile = async (
`attachment; filename="${encodeURIComponent(fileRecord.real_file_name!)}"`
);
res.setHeader("Content-Type", "application/octet-stream");
res.setHeader("Cross-Origin-Resource-Policy", "cross-origin");
const stat = fs.statSync(filePath);
res.setHeader("Content-Length", stat.size);
// 파일 스트림 전송
const fileStream = fs.createReadStream(filePath);
fileStream.on("error", (err) => {
console.error("파일 스트림 오류:", err);
if (!res.headersSent) {
res.status(500).json({ success: false, message: "파일 읽기 오류" });
} else {
res.end();
}
});
fileStream.pipe(res);
} catch (error) {
console.error("파일 다운로드 오류:", error);
@@ -1218,10 +1243,21 @@ export const getFileByToken = async (req: Request, res: Response) => {
"Content-Disposition",
`inline; filename="${encodeURIComponent(fileRecord.real_file_name!)}"`
);
res.setHeader("Cross-Origin-Resource-Policy", "cross-origin");
const stat = fs.statSync(filePath);
res.setHeader("Content-Length", stat.size);
res.setHeader("Cache-Control", "public, max-age=300"); // 5분 캐시
// 파일 스트림 전송
const fileStream = fs.createReadStream(filePath);
fileStream.on("error", (err) => {
console.error("파일 스트림 오류:", err);
if (!res.headersSent) {
res.status(500).json({ success: false, message: "파일 읽기 오류" });
} else {
res.end();
}
});
fileStream.pipe(res);
} catch (error) {
console.error("❌ 토큰 파일 접근 오류:", error);
@@ -226,11 +226,12 @@ export async function getMaterialStatus(
return res.json({ success: true, data: [] });
}
// 4) 재고 조회 (창고/위치별)
const stockPlaceholders = materialIds
// 4) 재고 조회 (창고/위치별) — inventory_stock.item_code는 item_number 기준
const materialCodes = materialIds.map((id) => materialMap[id].materialCode);
const stockPlaceholders = materialCodes
.map((_, i) => `$${i + 1}`)
.join(",");
const stockParams: any[] = [...materialIds];
const stockParams: any[] = [...materialCodes];
let stockParamIdx = materialIds.length + 1;
const stockConditions: string[] = [
@@ -94,7 +94,7 @@ export async function createMold(req: AuthenticatedRequest, res: Response): Prom
mold_code, mold_name, mold_type, category, manufacturer,
manufacturing_number, manufacturing_date, cavity_count,
shot_count, mold_quantity, base_input_qty, operation_status,
remarks, image_path, memo,
remarks, image_path, memo, warranty_shot_count,
} = req.body;
if (!mold_code || !mold_name) {
@@ -107,15 +107,16 @@ export async function createMold(req: AuthenticatedRequest, res: Response): Prom
id, company_code, mold_code, mold_name, mold_type, category,
manufacturer, manufacturing_number, manufacturing_date,
cavity_count, shot_count, mold_quantity, base_input_qty,
operation_status, remarks, image_path, memo, writer, created_date
) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,NOW())
operation_status, remarks, image_path, memo, warranty_shot_count, writer, created_date
) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,NOW())
RETURNING *
`;
const params = [
companyCode, mold_code, mold_name, mold_type || null, category || null,
manufacturer || null, manufacturing_number || null, manufacturing_date || null,
cavity_count || 0, shot_count || 0, mold_quantity || 1, base_input_qty || 0,
operation_status || "ACTIVE", remarks || null, image_path || null, memo || null, userId,
operation_status || "ACTIVE", remarks || null, image_path || null, memo || null,
warranty_shot_count || 0, userId,
];
const result = await query(sql, params);
@@ -139,7 +140,7 @@ export async function updateMold(req: AuthenticatedRequest, res: Response): Prom
mold_name, mold_type, category, manufacturer,
manufacturing_number, manufacturing_date, cavity_count,
shot_count, mold_quantity, base_input_qty, operation_status,
remarks, image_path, memo,
remarks, image_path, memo, warranty_shot_count,
} = req.body;
const sql = `
@@ -153,8 +154,9 @@ export async function updateMold(req: AuthenticatedRequest, res: Response): Prom
base_input_qty = COALESCE($10, base_input_qty),
operation_status = COALESCE($11, operation_status),
remarks = $12, image_path = $13, memo = $14,
warranty_shot_count = $15,
updated_date = NOW()
WHERE mold_code = $15 AND company_code = $16
WHERE mold_code = $16 AND company_code = $17
RETURNING *
`;
const params = [
@@ -162,7 +164,7 @@ export async function updateMold(req: AuthenticatedRequest, res: Response): Prom
manufacturing_number, manufacturing_date,
cavity_count, shot_count, mold_quantity, base_input_qty,
operation_status, remarks, image_path, memo,
moldCode, companyCode,
warranty_shot_count || 0, moldCode, companyCode,
];
const result = await query(sql, params);
File diff suppressed because it is too large Load Diff
@@ -162,6 +162,7 @@ async function generateWorkProcessesForInstruction(
planQty: string | null,
companyCode: string,
userId: string,
batchId?: string | null,
): Promise<{
processes: Array<{
id: string;
@@ -171,14 +172,27 @@ async function generateWorkProcessesForInstruction(
}>;
total_checklists: number;
} | null> {
// 중복 호출 방지: 이미 생성된 공정이 있는지 확인
const existCheck = await client.query(
`SELECT COUNT(*) as cnt FROM work_order_process
WHERE wo_id = $1 AND company_code = $2`,
[workInstructionId, companyCode],
);
if (parseInt(existCheck.rows[0].cnt, 10) > 0) {
return null; // 이미 존재
// 중복 호출 방지: 이미 생성된 공정이 있는지 확인 (batch_id 기준 분리)
if (batchId) {
// 다중 품목: 같은 wo_id + 같은 batch_id에 대해 이미 공정이 있으면 skip
const existCheck = await client.query(
`SELECT COUNT(*) as cnt FROM work_order_process
WHERE wo_id = $1 AND company_code = $2 AND batch_id = $3`,
[workInstructionId, companyCode, batchId],
);
if (parseInt(existCheck.rows[0].cnt, 10) > 0) {
return null; // 이미 존재
}
} else {
// 기존 동작: batch_id 없으면 wo_id 전체로 체크
const existCheck = await client.query(
`SELECT COUNT(*) as cnt FROM work_order_process
WHERE wo_id = $1 AND company_code = $2`,
[workInstructionId, companyCode],
);
if (parseInt(existCheck.rows[0].cnt, 10) > 0) {
return null; // 이미 존재
}
}
// 1. item_routing_detail + process_mng JOIN (공정 목록 + 공정명)
@@ -207,13 +221,13 @@ async function generateWorkProcessesForInstruction(
let totalChecklists = 0;
for (const rd of routingDetails.rows) {
// 2. work_order_process INSERT
// 2. work_order_process INSERT (batch_id 포함)
const wopResult = await client.query(
`INSERT INTO work_order_process (
id, company_code, wo_id, seq_no, process_code, process_name,
is_required, is_fixed_order, standard_time, plan_qty,
status, routing_detail_id, writer
) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
status, routing_detail_id, batch_id, writer
) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
RETURNING id`,
[
companyCode,
@@ -229,6 +243,7 @@ async function generateWorkProcessesForInstruction(
? "acceptable"
: "waiting",
rd.id,
batchId || null,
userId,
],
);
@@ -358,45 +373,42 @@ export const syncWorkInstructions = async (
userId,
});
// 미동기화 작업지시 조회: routing이 있지만 work_order_process가 없는 항목
// header routing 없으면 detail에서 가져옴 (PC가 detail에만 저장하는 경우 대응)
// 미동기화 작업지시 조회 — 다중 품목(detail) 지원
// 1단계: header routing이 있고 아직 공정이 없는 작업지시 (기존 호환)
// 2단계: detail별 routing이 있고 해당 detail의 공정(batch_id)이 없는 항목
const unsyncedResult = await pool.query(
`SELECT wi.id, wi.work_instruction_no,
COALESCE(wi.routing, wid.routing_version_id) AS routing,
COALESCE(NULLIF(wi.qty, ''), wid.qty) AS qty,
COALESCE(wi.item_id, (SELECT id FROM item_info WHERE item_number = wid.item_number AND company_code = $1 LIMIT 1)) AS item_id
wi.routing AS header_routing,
wi.qty AS header_qty,
wi.item_id AS header_item_id
FROM work_instruction wi
LEFT JOIN LATERAL (
SELECT routing_version_id, qty, item_number
FROM work_instruction_detail
WHERE work_instruction_no = wi.work_instruction_no AND company_code = $1
LIMIT 1
) wid ON true
WHERE wi.company_code = $1
AND COALESCE(wi.routing, wid.routing_version_id) IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM work_order_process wop
WHERE wop.wo_id = wi.id AND wop.company_code = $1
AND (
-- header routing이 있는데 공정이 아예 없는 경우
(wi.routing IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM work_order_process wop
WHERE wop.wo_id = wi.id AND wop.company_code = $1
))
OR
-- detail에 routing이 있는 경우 (다중 품목 지원)
EXISTS (
SELECT 1 FROM work_instruction_detail wid
WHERE wid.work_instruction_no = wi.work_instruction_no
AND wid.company_code = $1
AND wid.routing_version_id IS NOT NULL
AND CAST(COALESCE(NULLIF(wid.qty, ''), '0') AS numeric) > 0
AND NOT EXISTS (
SELECT 1 FROM work_order_process wop
WHERE wop.wo_id = wi.id AND wop.company_code = $1
AND wop.batch_id = wid.item_number
)
)
)`,
[companyCode],
);
const unsynced = unsyncedResult.rows;
// header에 routing/qty/item_id가 비어있으면 자동 보정 (detail → header 동기화)
for (const wi of unsynced) {
await pool.query(
`UPDATE work_instruction SET
routing = COALESCE(routing, $2),
qty = COALESCE(NULLIF(qty, ''), $3),
item_id = COALESCE(item_id, $4),
updated_date = NOW()
WHERE id = $1 AND company_code = $5
AND (routing IS NULL OR qty IS NULL OR qty = '' OR item_id IS NULL)`,
[wi.id, wi.routing, wi.qty, wi.item_id, companyCode],
);
}
if (unsynced.length === 0) {
return res.json({
success: true,
@@ -410,64 +422,178 @@ export const syncWorkInstructions = async (
const details: Array<{
work_instruction_id: string;
work_instruction_no: string;
item_number?: string;
status: "synced" | "skipped" | "error";
process_count?: number;
error?: string;
}> = [];
for (const wi of unsynced) {
const client = await pool.connect();
try {
await client.query("BEGIN");
// detail 목록 조회: routing_version_id가 있고 qty > 0인 것
const detailResult = await pool.query(
`SELECT wid.item_number, wid.routing_version_id, wid.qty
FROM work_instruction_detail wid
WHERE wid.work_instruction_no = $1 AND wid.company_code = $2
AND wid.routing_version_id IS NOT NULL
AND CAST(COALESCE(NULLIF(wid.qty, ''), '0') AS numeric) > 0
ORDER BY wid.created_date ASC`,
[wi.work_instruction_no, companyCode],
);
const result = await generateWorkProcessesForInstruction(
client,
wi.id,
wi.routing,
wi.qty || null,
companyCode,
userId,
const detailRows = detailResult.rows;
if (detailRows.length === 0 && wi.header_routing) {
// detail이 없지만 header routing이 있는 경우: 기존 방식 (단일 품목)
// header에 routing/qty/item_id 자동 보정
const firstDetail = await pool.query(
`SELECT routing_version_id, qty, item_number
FROM work_instruction_detail
WHERE work_instruction_no = $1 AND company_code = $2
LIMIT 1`,
[wi.work_instruction_no, companyCode],
);
const wid = firstDetail.rows[0];
if (wid) {
await pool.query(
`UPDATE work_instruction SET
routing = COALESCE(routing, $2),
qty = COALESCE(NULLIF(qty, ''), $3),
item_id = COALESCE(item_id, (SELECT id FROM item_info WHERE item_number = $4 AND company_code = $5 LIMIT 1)),
updated_date = NOW()
WHERE id = $1 AND company_code = $5
AND (routing IS NULL OR qty IS NULL OR qty = '' OR item_id IS NULL)`,
[wi.id, wid.routing_version_id, wid.qty, wid.item_number, companyCode],
);
}
if (!result) {
const client = await pool.connect();
try {
await client.query("BEGIN");
const result = await generateWorkProcessesForInstruction(
client,
wi.id,
wi.header_routing,
wi.header_qty || null,
companyCode,
userId,
);
if (!result) {
await client.query("ROLLBACK");
skipped++;
details.push({
work_instruction_id: wi.id,
work_instruction_no: wi.work_instruction_no,
status: "skipped",
});
} else {
await client.query("COMMIT");
synced++;
details.push({
work_instruction_id: wi.id,
work_instruction_no: wi.work_instruction_no,
status: "synced",
process_count: result.processes.length,
});
logger.info("[pop/production] sync: 공정 생성 완료 (header routing)", {
work_instruction_no: wi.work_instruction_no,
process_count: result.processes.length,
});
}
} catch (err: any) {
await client.query("ROLLBACK");
skipped++;
errors++;
details.push({
work_instruction_id: wi.id,
work_instruction_no: wi.work_instruction_no,
status: "skipped",
status: "error",
error: err.message || "알 수 없는 오류",
});
continue;
logger.error("[pop/production] sync: header routing 오류", {
work_instruction_no: wi.work_instruction_no,
error: err.message,
});
} finally {
client.release();
}
continue;
}
await client.query("COMMIT");
synced++;
details.push({
work_instruction_id: wi.id,
work_instruction_no: wi.work_instruction_no,
status: "synced",
process_count: result.processes.length,
});
// 다중 품목: 각 detail별로 공정 생성 (batch_id = item_number)
// header routing/item_id도 첫 번째 detail 기준 보정
if (detailRows.length > 0) {
const first = detailRows[0];
await pool.query(
`UPDATE work_instruction SET
routing = COALESCE(routing, $2),
qty = COALESCE(NULLIF(qty, ''), $3),
item_id = COALESCE(item_id, (SELECT id FROM item_info WHERE item_number = $4 AND company_code = $5 LIMIT 1)),
updated_date = NOW()
WHERE id = $1 AND company_code = $5
AND (routing IS NULL OR qty IS NULL OR qty = '' OR item_id IS NULL)`,
[wi.id, first.routing_version_id, first.qty, first.item_number, companyCode],
);
}
logger.info("[pop/production] sync: 공정 생성 완료", {
work_instruction_no: wi.work_instruction_no,
process_count: result.processes.length,
});
} catch (err: any) {
await client.query("ROLLBACK");
errors++;
details.push({
work_instruction_id: wi.id,
work_instruction_no: wi.work_instruction_no,
status: "error",
error: err.message || "알 수 없는 오류",
});
logger.error("[pop/production] sync: 개별 오류", {
work_instruction_no: wi.work_instruction_no,
error: err.message,
});
} finally {
client.release();
for (const detail of detailRows) {
const client = await pool.connect();
try {
await client.query("BEGIN");
const result = await generateWorkProcessesForInstruction(
client,
wi.id,
detail.routing_version_id,
detail.qty || null,
companyCode,
userId,
detail.item_number, // batch_id = item_number
);
if (!result) {
await client.query("ROLLBACK");
skipped++;
details.push({
work_instruction_id: wi.id,
work_instruction_no: wi.work_instruction_no,
item_number: detail.item_number,
status: "skipped",
});
continue;
}
await client.query("COMMIT");
synced++;
details.push({
work_instruction_id: wi.id,
work_instruction_no: wi.work_instruction_no,
item_number: detail.item_number,
status: "synced",
process_count: result.processes.length,
});
logger.info("[pop/production] sync: 다중품목 공정 생성 완료", {
work_instruction_no: wi.work_instruction_no,
item_number: detail.item_number,
process_count: result.processes.length,
});
} catch (err: any) {
await client.query("ROLLBACK");
errors++;
details.push({
work_instruction_id: wi.id,
work_instruction_no: wi.work_instruction_no,
item_number: detail.item_number,
status: "error",
error: err.message || "알 수 없는 오류",
});
logger.error("[pop/production] sync: 다중품목 개별 오류", {
work_instruction_no: wi.work_instruction_no,
item_number: detail.item_number,
error: err.message,
});
} finally {
client.release();
}
}
}
@@ -116,12 +116,7 @@ export const addCategoryValue = async (req: AuthenticatedRequest, res: Response)
const userId = req.user!.userId;
const { menuObjid, ...value } = req.body;
if (!menuObjid) {
return res.status(400).json({
success: false,
message: "menuObjid는 필수입니다",
});
}
// menuObjid는 선택사항 — 옵션설정 등 전역 관리 화면에서는 없을 수 있음
logger.info("카테고리 값 추가 요청", {
tableName: value.tableName,
@@ -134,7 +129,7 @@ export const addCategoryValue = async (req: AuthenticatedRequest, res: Response)
value,
companyCode,
userId,
Number(menuObjid) // ← menuObjid 전달
menuObjid ? Number(menuObjid) : null
);
return res.status(201).json({
@@ -86,6 +86,7 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
d.source_id,
d.routing_version_id AS detail_routing_version_id,
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,
@@ -97,7 +98,7 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
INNER JOIN work_instruction_detail d
ON d.work_instruction_no = wi.work_instruction_no AND d.company_code = wi.company_code
LEFT JOIN LATERAL (
SELECT item_name, size FROM item_info
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 equipment_mng e ON wi.equipment_id = e.id AND wi.company_code = e.company_code
@@ -0,0 +1,40 @@
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import { adjustBatch, getAdjustHistory, getStockDetail, loadTempCart, clearTempCart, updateCartStatus, getLocations, locationLookup, getProcessStock, getProcessStockV2, getItemHistory, moveBatch } from "../controllers/popInventoryController";
const router = Router();
router.use(authenticateToken);
// 재고 목록 + 품목상세 JOIN 조회
router.get("/stock-detail", getStockDetail);
// 임시저장 불러오기/삭제/상태변경
router.get("/temp-load", loadTempCart);
router.delete("/temp-clear", clearTempCart);
router.post("/temp-status", updateCartStatus);
// 재고 조정 일괄 확정
router.post("/adjust-batch", adjustBatch);
// 재고 조정 이력 조회
router.get("/adjust-history", getAdjustHistory);
// 창고별 위치 목록 조회
router.get("/locations", getLocations);
// 위치코드로 창고+위치 조회 (QR 스캔)
router.get("/location-lookup", locationLookup);
// 공정 진행 중 수량 조회
router.get("/process-stock", getProcessStock);
// 공정별 대기수량/미입고 조회 (v2)
router.get("/process-stock-v2", getProcessStockV2);
// 품목별 재고 이력 조회
router.get("/item-history", getItemHistory);
// 재고 이동 일괄 실행
router.post("/move-batch", moveBatch);
export default router;
@@ -269,7 +269,7 @@ class TableCategoryValueService {
value: TableCategoryValue,
companyCode: string,
userId: string,
menuObjid: number
menuObjid: number | null
): Promise<TableCategoryValue> {
const pool = getPool();
@@ -286,29 +286,35 @@ class TableCategoryValueService {
let duplicateQuery: string;
let duplicateParams: any[];
const menuCondition = menuObjid
? "AND menu_objid = $4"
: "AND menu_objid IS NULL";
const baseParams = menuObjid
? [value.tableName, value.columnName, value.valueCode, menuObjid]
: [value.tableName, value.columnName, value.valueCode];
if (companyCode === "*") {
// 최고 관리자: 모든 회사에서 중복 체크
duplicateQuery = `
SELECT value_id
SELECT value_id
FROM category_values
WHERE table_name = $1
AND column_name = $2
AND value_code = $3
AND menu_objid = $4
${menuCondition}
`;
duplicateParams = [value.tableName, value.columnName, value.valueCode, menuObjid];
duplicateParams = baseParams;
} else {
// 일반 회사: 자신의 회사에서만 중복 체크
const companyIdx = menuObjid ? "$5" : "$4";
duplicateQuery = `
SELECT value_id
SELECT value_id
FROM category_values
WHERE table_name = $1
AND column_name = $2
AND value_code = $3
AND menu_objid = $4
AND company_code = $5
${menuCondition}
AND company_code = ${companyIdx}
`;
duplicateParams = [value.tableName, value.columnName, value.valueCode, menuObjid, companyCode];
duplicateParams = [...baseParams, companyCode];
}
const duplicateResult = await pool.query(duplicateQuery, duplicateParams);
@@ -352,11 +358,11 @@ class TableCategoryValueService {
const insertQuery = `
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, description, color, icon,
is_active, is_default, company_code, menu_objid, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
RETURNING
) 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)
RETURNING
value_id AS "valueId",
table_name AS "tableName",
column_name AS "columnName",
+1
View File
@@ -42,6 +42,7 @@ services:
dockerfile: ../docker/deploy/frontend.Dockerfile
args:
- NEXT_PUBLIC_API_URL=https://api.vexplor.com/api
- SERVER_API_URL=http://backend:3001
container_name: pms-frontend-prod
restart: always
environment:
+4
View File
@@ -23,6 +23,10 @@ ENV NEXT_TELEMETRY_DISABLED 1
ARG NEXT_PUBLIC_API_URL=https://api.vexplor.com/api
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
# Next.js rewrites 프록시용 (빌드 시 routes-manifest.json에 포함됨)
ARG SERVER_API_URL=http://backend:3001
ENV SERVER_API_URL=$SERVER_API_URL
# Build the application
ENV DISABLE_ESLINT_PLUGIN=true
RUN npm run build
@@ -38,6 +38,30 @@ const fmtDate = (v: any) => {
return m ? `${m[1]}-${m[2]}-${m[3]}` : s.substring(0, 10);
};
/** remark가 JSON이면 사람이 읽을 수 있는 텍스트로 변환 */
const parseRemark = (remark: string | null | undefined): string => {
if (!remark) return "";
const trimmed = remark.trim();
if (!trimmed.startsWith("{")) return trimmed;
try {
const d = JSON.parse(trimmed);
switch (d.type) {
case "move":
return `창고이동 (${d.from_warehouse}${d.to_warehouse})`;
case "adjust":
return `재고조정 (${d.reason || "사유 없음"}, ${d.system_qty}${d.actual_qty}, 차이:${d.diff >= 0 ? "+" : ""}${d.diff})`;
case "confirm":
return `재고확인 (${d.reason || "이상없음"})`;
case "process_inbound":
return "공정입고";
default:
return d.reason || d.memo || trimmed;
}
} catch {
return trimmed;
}
};
export default function InboundOutboundPage() {
const { user } = useAuth();
@@ -62,7 +86,6 @@ export default function InboundOutboundPage() {
try {
const filters: any[] = [];
if (typeFilter !== "all") filters.push({ columnName: "transaction_type", operator: "equals", value: typeFilter });
if (categoryFilter !== "all") filters.push({ columnName: "remark", operator: "equals", value: categoryFilter });
for (const f of searchFilters) {
if (f.value) filters.push({ columnName: f.columnName, operator: f.operator, value: f.value });
@@ -81,6 +104,21 @@ export default function InboundOutboundPage() {
const itemCodes = [...new Set(rows.map((r: any) => r.item_code).filter(Boolean))];
if (itemCodes.length > 0) {
try {
// 단위 카테고리 코드→라벨 매핑 로드
let unitLabelMap: Record<string, string> = {};
try {
const catRes = await apiClient.get("/table-categories/item_info/unit/values");
if (catRes.data?.success && catRes.data.data?.length > 0) {
const flatten = (vals: any[]) => {
for (const v of vals) {
unitLabelMap[v.valueCode] = v.valueLabel;
if (v.children?.length) flatten(v.children);
}
};
flatten(catRes.data.data);
}
} catch { /* skip */ }
const itemRes = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: itemCodes.length + 10,
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: itemCodes }] },
@@ -89,7 +127,8 @@ export default function InboundOutboundPage() {
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
const map: Record<string, { item_name: string; unit: string }> = {};
for (const i of items) {
if (!map[i.item_number]) map[i.item_number] = { item_name: i.item_name || "", unit: i.unit || "" };
const rawUnit = i.unit || "";
if (!map[i.item_number]) map[i.item_number] = { item_name: i.item_name || "", unit: unitLabelMap[rawUnit] || rawUnit };
}
setItemMap(map);
} catch { /* skip */ }
@@ -124,7 +163,7 @@ export default function InboundOutboundPage() {
} finally {
setLoading(false);
}
}, [searchFilters, typeFilter, categoryFilter]);
}, [searchFilters, typeFilter]);
useEffect(() => { fetchData(); }, [fetchData]);
@@ -132,20 +171,26 @@ export default function InboundOutboundPage() {
const categoryOptions = useMemo(() => {
const set = new Set<string>();
data.forEach((r) => { if (r.remark) set.add(r.remark); });
data.forEach((r) => { if (r.remark) set.add(parseRemark(r.remark)); });
return Array.from(set).sort();
}, [data]);
// ════════ 그룹핑 ════════
// 카테고리 필터 (클라이언트)
const filteredData = useMemo(() => {
if (categoryFilter === "all") return data;
return data.filter((r) => parseRemark(r.remark) === categoryFilter);
}, [data, categoryFilter]);
const groupedData = useMemo(() => {
if (groupBy === "none") return data;
if (groupBy === "none") return filteredData;
const groups = new Map<string, any[]>();
for (const row of data) {
for (const row of filteredData) {
let key: string;
switch (groupBy) {
case "transaction_type": key = row.transaction_type || "미지정"; break;
case "remark": key = row.remark || "미지정"; break;
case "remark": key = parseRemark(row.remark) || "미지정"; break;
case "warehouse_code": key = warehouseMap[row.warehouse_code] || row.warehouse_code || "미지정"; break;
case "item_code": key = `${row.item_code} ${itemMap[row.item_code]?.item_name || ""}`.trim() || "미지정"; break;
default: key = row[groupBy] || "미지정";
@@ -191,7 +236,7 @@ export default function InboundOutboundPage() {
const rows = data.map((r, i) => ({
No: i + 1,
입출고구분: r.transaction_type || "",
카테고리: r.remark || "",
카테고리: parseRemark(r.remark),
처리일자: fmtDate(r.transaction_date),
창고: warehouseMap[r.warehouse_code] || r.warehouse_code || "",
위치: r.location_code || "",
@@ -252,7 +297,7 @@ export default function InboundOutboundPage() {
<div className="flex items-center gap-2">
<Package className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-semibold"> </span>
<Badge variant="secondary" className="font-mono text-xs">{data.length}</Badge>
<Badge variant="secondary" className="font-mono text-xs">{filteredData.length}</Badge>
</div>
<div className="flex items-center gap-2">
<Select value={groupBy} onValueChange={(v) => { setGroupBy(v); setExpandedGroups(new Set()); }}>
@@ -353,7 +398,7 @@ export default function InboundOutboundPage() {
{row.transaction_type}
</span>
</TableCell>
<TableCell className="text-center text-[12px]">{row.remark || "-"}</TableCell>
<TableCell className="text-center text-[12px]">{parseRemark(row.remark) || "-"}</TableCell>
<TableCell className="text-center text-[12px]">{fmtDate(row.transaction_date)}</TableCell>
<TableCell className="text-[12px]">{warehouseMap[row.warehouse_code] || row.warehouse_code || "-"}</TableCell>
<TableCell className="text-center text-[12px]">{row.location_code || "-"}</TableCell>
@@ -230,11 +230,10 @@ function flattenCategories(items: any[]): { value: string; label: string }[] {
const result: { value: string; label: string }[] = [];
function walk(arr: any[]) {
for (const item of arr) {
if (item.value || item.name) {
result.push({
value: item.value || item.name,
label: item.label || item.name || item.value,
});
const val = item.valueCode || item.value || item.name;
const lbl = item.valueLabel || item.label || item.name || val;
if (val) {
result.push({ value: val, label: lbl });
}
if (item.children?.length) walk(item.children);
}
@@ -140,31 +140,47 @@ export default function InventoryStatusPage() {
Record<string, { code: string; label: string }[]>
>({});
// 카테고리 로드
// 사용자 맵 (writer → 이름)
const [userMap, setUserMap] = useState<Record<string, string>>({});
// 카테고리 + 사용자 로드
useEffect(() => {
const load = async () => {
const optMap: Record<string, { code: string; label: string }[]> = {};
const flatten = (vals: any[]): { code: string; label: string }[] => {
const result: { code: string; label: string }[] = [];
for (const v of vals) {
result.push({ code: v.valueCode, label: v.valueLabel });
result.push({ code: v.valueCode || v.value_code || v.code, label: v.valueLabel || v.value_label || v.label });
if (v.children?.length) result.push(...flatten(v.children));
}
return result;
};
for (const col of ["status", "unit"]) {
// inventory_stock 카테고리
for (const col of ["status"]) {
try {
const res = await apiClient.get(
`/table-categories/${STOCK_TABLE}/${col}/values`
);
const res = await apiClient.get(`/table-categories/${STOCK_TABLE}/${col}/values`);
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
} catch {
/* skip */
}
} catch { /* skip */ }
}
// item_info 단위 카테고리
try {
const res = await apiClient.get("/table-categories/item_info/unit/values?filterCompanyCode=COMPANY_10");
if (res.data?.success) optMap["item_unit"] = flatten(res.data.data || []);
} catch { /* skip */ }
setCategoryOptions(optMap);
};
load();
// 사용자 목록 로드
apiClient.get("/admin/users", { params: { size: 9999 } }).then((res) => {
const users = res.data?.data || res.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;
if (id) map[id] = name;
}
setUserMap(map);
}).catch(() => {});
}, []);
// 재고 목록 조회
@@ -193,10 +209,11 @@ export default function InventoryStatusPage() {
};
const data = raw.map((r: any) => {
const itemInfo = itemMap.get(r.item_code) as any;
const rawUnit = itemInfo?.unit || r.unit || "";
return {
...r,
item_name: itemInfo?.name || "",
unit: itemInfo?.unit || resolve("unit", r.unit),
unit: resolve("item_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),
@@ -332,6 +349,12 @@ export default function InventoryStatusPage() {
),
};
}
if (col.key === "warehouse_code") {
return {
...base,
render: (_val: any, row: any) => row.warehouse_name || row.warehouse_code || "",
};
}
if (col.key === "safety_qty") {
return {
...base,
@@ -613,7 +636,7 @@ export default function InventoryStatusPage() {
<TableCell className="truncate max-w-[150px]">
{h.remark || h.reason || ""}
</TableCell>
<TableCell>{h.writer || h.created_by || ""}</TableCell>
<TableCell>{userMap[h.writer] || userMap[h.created_by] || h.writer || h.created_by || ""}</TableCell>
</TableRow>
))}
</TableBody>
@@ -43,6 +43,7 @@ import {
} from "@/lib/api/materialStatus";
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { apiClient } from "@/lib/api/client";
const GRID_COLUMNS = [
{ key: "plan_no", label: "계획번호" },
@@ -60,26 +61,31 @@ const formatDate = (date: Date) => {
return `${y}-${m}-${d}`;
};
const getStatusLabel = (status: string) => {
const map: Record<string, string> = {
planned: "계획",
in_progress: "진행중",
completed: "완료",
pending: "대기",
cancelled: "취소",
};
return map[status] || status;
const FALLBACK_STATUS_MAP: Record<string, string> = {
planned: "계획",
in_progress: "진행중",
completed: "완료",
pending: "대기",
cancelled: "취소",
};
const getStatusStyle = (status: string) => {
const map: Record<string, string> = {
planned: "bg-secondary text-secondary-foreground border-border",
pending: "bg-secondary text-secondary-foreground border-border",
in_progress: "bg-primary/10 text-primary border-primary/20",
completed: "bg-accent text-accent-foreground border-accent/50",
cancelled: "bg-muted text-muted-foreground border-border",
};
return map[status] || "bg-muted text-muted-foreground border-border";
const STATUS_STYLE_MAP: Record<string, string> = {
planned: "bg-secondary text-secondary-foreground border-border",
pending: "bg-secondary text-secondary-foreground border-border",
in_progress: "bg-primary/10 text-primary border-primary/20",
completed: "bg-accent text-accent-foreground border-accent/50",
cancelled: "bg-muted text-muted-foreground border-border",
};
// 카테고리 라벨 기반으로 스타일 매칭
const LABEL_STYLE_MAP: Record<string, string> = {
"일반": "bg-secondary text-secondary-foreground border-border",
"긴급": "bg-destructive/10 text-destructive border-destructive/20",
"계획": "bg-secondary text-secondary-foreground border-border",
"대기": "bg-secondary text-secondary-foreground border-border",
"진행중": "bg-primary/10 text-primary border-primary/20",
"완료": "bg-accent text-accent-foreground border-accent/50",
"취소": "bg-muted text-muted-foreground border-border",
};
export default function MaterialStatusPage() {
@@ -93,6 +99,32 @@ export default function MaterialStatusPage() {
const [searchItemCode, setSearchItemCode] = useState("");
const [searchItemName, setSearchItemName] = useState("");
// 카테고리 코드→라벨 매핑
const [statusMap, setStatusMap] = useState<Record<string, string>>({});
useEffect(() => {
(async () => {
try {
const res = await apiClient.get("/table-categories/work_instruction/status/values");
if (res.data?.success && res.data.data?.length > 0) {
const map: Record<string, string> = {};
const flatten = (vals: any[]) => {
for (const v of vals) {
map[v.valueCode] = v.valueLabel;
if (v.children?.length) flatten(v.children);
}
};
flatten(res.data.data);
setStatusMap(map);
}
} catch { /* ignore */ }
})();
}, []);
const getStatusLabel = useCallback((status: string) => {
return statusMap[status] || FALLBACK_STATUS_MAP[status] || status;
}, [statusMap]);
const [workOrders, setWorkOrders] = useState<WorkOrder[]>([]);
const [workOrdersLoading, setWorkOrdersLoading] = useState(false);
const [checkedWoIds, setCheckedWoIds] = useState<string[]>([]);
@@ -369,7 +401,7 @@ export default function MaterialStatusPage() {
<span
className={cn(
"ml-auto rounded-md border px-2 py-0.5 text-[11px] font-semibold",
getStatusStyle(wo.status)
STATUS_STYLE_MAP[wo.status] || LABEL_STYLE_MAP[getStatusLabel(wo.status)] || "bg-muted text-muted-foreground border-border"
)}
>
{getStatusLabel(wo.status)}
@@ -1,6 +1,7 @@
"use client";
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { apiClient } from "@/lib/api/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
@@ -311,6 +312,42 @@ export default function OutboundPage() {
const [editMode, setEditMode] = useState(false);
const [editItemIds, setEditItemIds] = useState<string[]>([]);
// 카테고리 코드→라벨 매핑 (재질, 단위)
const [catMap, setCatMap] = useState<Record<string, Record<string, 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 || v.value_code || v.code, label: v.valueLabel || v.value_label || v.label });
if (v.children?.length) result.push(...flatten(v.children));
}
return result;
};
const map: Record<string, Record<string, string>> = {};
Promise.all([
...["material", "unit"].map(async (col) => {
try {
const res = await apiClient.get(`/table-categories/item_info/${col}/values?filterCompanyCode=COMPANY_7`);
const items = flatten(res.data?.data || []);
map[col] = {};
for (const item of items) map[col][item.code] = item.label;
} catch { /* skip */ }
}),
(async () => {
try {
const res = await apiClient.get(`/table-categories/outbound_mng/outbound_type/values`);
const items = flatten(res.data?.data || []);
map["outbound_type"] = {};
for (const item of items) map["outbound_type"][item.code] = item.label;
} catch { /* skip */ }
})(),
]).then(() => setCatMap(map));
}, []);
const resolveCat = useCallback((col: string, code: string) => {
if (!code) return "";
return catMap[col]?.[code] || code;
}, [catMap]);
// 소스 데이터
const [sourceKeyword, setSourceKeyword] = useState("");
const [sourceLoading, setSourceLoading] = useState(false);
@@ -871,8 +908,9 @@ export default function OutboundPage() {
fetchList();
}
}
} catch {
toast.error(editMode ? "수정에 실패했어요" : "출고 등록 중 오류가 발생했습니다.");
} catch (err: any) {
const msg = err?.response?.data?.message || (editMode ? "수정에 실패했어요" : "출고 등록 중 오류가 발생했습니다.");
toast.error(msg);
} finally {
setSaving(false);
}
@@ -1085,7 +1123,7 @@ export default function OutboundPage() {
case "outbound_type": return (
<TableCell key={col.key}>
<Badge variant="outline" className={cn("text-[11px]", getTypeColor(master.outbound_type))}>
{master.outbound_type || "-"}
{resolveCat("outbound_type", master.outbound_type) || "-"}
</Badge>
</TableCell>
);
@@ -1337,6 +1375,7 @@ export default function OutboundPage() {
data={pagedItems}
onAdd={addItem}
selectedKeys={selectedItems.map((s) => s.key)}
resolveCat={resolveCat}
/>
)}
</div>
@@ -1502,7 +1541,7 @@ export default function OutboundPage() {
<TableCell className="p-2 text-center">{idx + 1}</TableCell>
<TableCell className="p-2">
<Badge variant="outline" className={cn("text-[10px]", getTypeColor(item.outbound_type))}>
{item.outbound_type || "-"}
{resolveCat("outbound_type", item.outbound_type) || "-"}
</Badge>
</TableCell>
<TableCell className="max-w-[180px] p-2">
@@ -1772,10 +1811,12 @@ function SourceItemTable({
data,
onAdd,
selectedKeys,
resolveCat,
}: {
data: ItemSource[];
onAdd: (item: ItemSource) => void;
selectedKeys: string[];
resolveCat: (col: string, code: string) => string;
}) {
if (data.length === 0) {
return (
@@ -1826,8 +1867,8 @@ function SourceItemTable({
</div>
</TableCell>
<TableCell className="max-w-[100px] truncate p-2" title={item.spec || "-"}>{item.spec || "-"}</TableCell>
<TableCell className="max-w-[100px] truncate p-2" title={item.material || "-"}>{item.material || "-"}</TableCell>
<TableCell className="p-2">{item.unit || "-"}</TableCell>
<TableCell className="max-w-[100px] truncate p-2" title={resolveCat("material", item.material) || "-"}>{resolveCat("material", item.material) || "-"}</TableCell>
<TableCell className="p-2">{resolveCat("unit", item.unit) || "-"}</TableCell>
<TableCell className="p-2 text-right">
{Number(item.standard_price).toLocaleString()}
</TableCell>
@@ -353,17 +353,45 @@ export default function ReceivingPage() {
// 구매관리 division 코드 (라벨 기준 조회)
const [purchaseDivisionCode, setPurchaseDivisionCode] = useState<string>("");
// 카테고리 코드→라벨 매핑 (재질, 단위)
const [catMap, setCatMap] = useState<Record<string, Record<string, string>>>({});
// 구매관리 division 코드 로드
// 구매관리 division 코드 + 재질/단위 카테고리 로드
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 || v.value_code || v.code, label: v.valueLabel || v.value_label || v.label });
if (v.children?.length) result.push(...flatten(v.children));
}
return result;
};
// division 카테고리에서 "구매관리" 라벨의 코드 조회
apiClient.get("/table-categories/item_info/division/values").then((res) => {
const vals = res.data?.data || [];
const found = vals.find((v: any) => (v.value_label || v.label) === "구매관리");
const found = vals.find((v: any) => (v.valueLabel || v.value_label || v.label) === "구매관리");
if (found) setPurchaseDivisionCode(found.value_code || found.code);
}).catch(() => {});
// 재질, 단위 카테고리
const map: Record<string, Record<string, string>> = {};
Promise.all(
["material", "unit"].map(async (col) => {
try {
const res = await apiClient.get(`/table-categories/item_info/${col}/values?filterCompanyCode=COMPANY_10`);
const items = flatten(res.data?.data || []);
map[col] = {};
for (const item of items) map[col][item.code] = item.label;
} catch { /* skip */ }
})
).then(() => setCatMap(map));
}, []);
// 카테고리 코드→라벨 변환
const resolveCat = useCallback((col: string, code: string) => {
if (!code) return "";
return catMap[col]?.[code] || code;
}, [catMap]);
// 목록 조회
const fetchList = useCallback(async () => {
setLoading(true);
@@ -777,12 +805,16 @@ export default function ReceivingPage() {
return;
}
if (!modalWarehouse) {
toast.error("창고를 선택해주세요.");
return;
}
setSaving(true);
try {
const res = await createReceiving({
inbound_number: modalInboundNo,
inbound_date: modalInboundDate,
warehouse_code: modalWarehouse || undefined,
warehouse_code: modalWarehouse,
location_code: modalLocation || undefined,
inspector: modalInspector || undefined,
manager: modalManager || undefined,
@@ -1283,6 +1315,7 @@ export default function ReceivingPage() {
data={items}
onAdd={addItem}
selectedKeys={selectedItems.map((s) => s.key)}
resolveCat={resolveCat}
/>
)}
</div>
@@ -1738,10 +1771,12 @@ function SourceItemTable({
data,
onAdd,
selectedKeys,
resolveCat,
}: {
data: ItemSource[];
onAdd: (item: ItemSource) => void;
selectedKeys: string[];
resolveCat: (col: string, code: string) => string;
}) {
if (data.length === 0) {
return (
@@ -1794,8 +1829,8 @@ function SourceItemTable({
</div>
</TableCell>
<TableCell className="max-w-[100px] truncate p-2" title={item.spec || "-"}>{item.spec || "-"}</TableCell>
<TableCell className="max-w-[100px] truncate p-2" title={item.material || "-"}>{item.material || "-"}</TableCell>
<TableCell className="p-2">{item.unit || "-"}</TableCell>
<TableCell className="max-w-[100px] truncate p-2" title={resolveCat("material", item.material) || "-"}>{resolveCat("material", item.material) || "-"}</TableCell>
<TableCell className="p-2">{resolveCat("unit", item.unit) || "-"}</TableCell>
<TableCell className="p-2 text-right">
{Number(item.standard_price).toLocaleString()}
</TableCell>
@@ -26,6 +26,7 @@ import {
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
import { ImageUpload } from "@/components/common/ImageUpload";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
@@ -102,8 +103,8 @@ export default function MoldInfoPage() {
const [numberingRuleId, setNumberingRuleId] = useState<string | null>(null);
const [previewCode, setPreviewCode] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
// moldImagePreview는 상세 표시용으로 유지 (ImageUpload는 모달에서만 사용)
const [moldImagePreview, setMoldImagePreview] = useState<string | null>(null);
const moldImageRef = React.useRef<HTMLInputElement>(null);
const [serialModalOpen, setSerialModalOpen] = useState(false);
const [serialForm, setSerialForm] = useState<Record<string, any>>({});
@@ -240,17 +241,7 @@ export default function MoldInfoPage() {
setMoldModalOpen(true);
};
const handleMoldImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onloadend = () => {
const result = reader.result as string;
setMoldImagePreview(result);
setMoldForm((prev) => ({ ...prev, image_path: result }));
};
reader.readAsDataURL(file);
};
// 이미지 업로드는 ImageUpload 컴포넌트가 처리 → objid를 image_path에 저장
const handleSaveMold = async () => {
// 등록 모드에서 채번이 없으면 수동 입력 필수
@@ -460,9 +451,10 @@ export default function MoldInfoPage() {
)}>
{mold.image_path ? (
<img
src={mold.image_path}
src={String(mold.image_path).startsWith("http") || String(mold.image_path).startsWith("/") ? mold.image_path : `/api/files/preview/${mold.image_path}`}
alt={mold.mold_name}
className="w-full h-full object-cover"
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
) : (
<Box className="w-8 h-8 text-muted-foreground/50" />
@@ -662,9 +654,10 @@ export default function MoldInfoPage() {
<div className="w-[200px] min-w-[200px] h-[200px] bg-muted rounded-lg flex items-center justify-center border">
{selectedMold.image_path ? (
<img
src={selectedMold.image_path}
src={String(selectedMold.image_path).startsWith("http") || String(selectedMold.image_path).startsWith("/") ? selectedMold.image_path : `/api/files/preview/${selectedMold.image_path}`}
alt={selectedMold.mold_name}
className="w-full h-full object-cover rounded-lg"
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
) : (
<ImageIcon className="w-16 h-16 text-muted-foreground/40" />
@@ -1143,39 +1136,13 @@ export default function MoldInfoPage() {
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs"> </Label>
<div className="relative w-full h-32 rounded-lg border-2 border-dashed border-border bg-muted/50 flex items-center justify-center overflow-hidden group">
{moldImagePreview ? (
<>
<img src={moldImagePreview} alt="금형 이미지" className="w-full h-full object-contain" />
<div className="absolute inset-0 bg-foreground/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
<Button size="sm" variant="secondary" className="h-7 text-xs" type="button" onClick={() => moldImageRef.current?.click()}>
<Upload className="w-3 h-3 mr-1" />
</Button>
<Button size="sm" variant="destructive" className="h-7 text-xs" type="button" onClick={() => {
setMoldImagePreview(null);
setMoldForm((prev) => ({ ...prev, image_path: null }));
}}>
<XIcon className="w-3 h-3" />
</Button>
</div>
</>
) : (
<button
type="button"
className="flex flex-col items-center gap-1.5 text-muted-foreground"
onClick={() => moldImageRef.current?.click()}
>
<ImageIcon className="w-8 h-8" />
<span className="text-xs"> </span>
</button>
)}
</div>
<input
ref={moldImageRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleMoldImageUpload}
<ImageUpload
value={moldForm.image_path || ""}
onChange={(v) => setMoldForm((prev) => ({ ...prev, image_path: v }))}
tableName="mold_mng"
recordId={moldForm.id || ""}
columnName="image_path"
height="h-32"
/>
</div>
</div>
@@ -142,7 +142,7 @@ export default function SubcontractorItemPage() {
try {
const filters: any[] = [];
if (outsourcingDivisionCode) {
filters.push({ columnName: "division", operator: "equals", value: outsourcingDivisionCode });
filters.push({ columnName: "division", operator: "contains", value: outsourcingDivisionCode });
}
if (searchKeyword) {
filters.push({ columnName: "item_name", operator: "contains", value: searchKeyword });
@@ -141,6 +141,12 @@ export default function SubcontractorManagementPage() {
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
} catch { /* skip */ }
}
for (const col of ["division", "unit", "material"]) {
try {
const res = await apiClient.get(`/table-categories/item_info/${col}/values`);
if (res.data?.success) optMap[`item_${col}`] = flatten(res.data.data || []);
} catch { /* skip */ }
}
setCategoryOptions(optMap);
const priceOpts: Record<string, { code: string; label: string }[]> = {};
@@ -413,11 +419,12 @@ export default function SubcontractorManagementPage() {
});
const allItems = res.data?.data?.data || res.data?.data?.rows || [];
const existingItemIds = new Set(priceItems.map((p: any) => p.item_id || p.item_number));
const OUTSOURCING_CODE = "CAT_MMDJB7R4_TO3T";
const outsourcingCode = categoryOptions["item_division"]?.find((o) => o.label === "외주관리")?.code;
setItemSearchResults(allItems.filter((item: any) => {
if (existingItemIds.has(item.item_number) || existingItemIds.has(item.id)) return false;
if (!outsourcingCode) return true;
const div = item.division || "";
return div.includes(OUTSOURCING_CODE) || div.includes("외주");
return div.includes(outsourcingCode);
}));
} catch { /* skip */ } finally { setItemSearchLoading(false); }
};
@@ -1176,8 +1183,8 @@ export default function SubcontractorManagementPage() {
<TableCell className="text-[13px]">{item.item_number}</TableCell>
<TableCell className="text-[13px]">{item.item_name}</TableCell>
<TableCell className="text-[13px]">{item.size}</TableCell>
<TableCell className="text-[13px]">{item.material}</TableCell>
<TableCell className="text-[13px]">{item.unit}</TableCell>
<TableCell className="text-[13px]">{categoryOptions["item_material"]?.find((o) => o.code === item.material)?.label || item.material}</TableCell>
<TableCell className="text-[13px]">{categoryOptions["item_unit"]?.find((o) => o.code === item.unit)?.label || item.unit}</TableCell>
</TableRow>
))}
</TableBody>
@@ -1221,7 +1228,7 @@ export default function SubcontractorManagementPage() {
{/* 품목 헤더 */}
<div className="px-5 py-3 bg-muted/30 border-b">
<div className="font-semibold text-sm">{idx + 1}. {item.item_name || itemKey}</div>
<div className="text-xs text-muted-foreground">{itemKey} | {item.size || ""} | {item.unit || ""}</div>
<div className="text-xs text-muted-foreground">{itemKey} | {item.size || ""} | {categoryOptions["item_unit"]?.find((o) => o.code === item.unit)?.label || item.unit || ""}</div>
</div>
<div className="flex gap-4 p-4">
@@ -309,6 +309,8 @@ export default function BomManagementPage() {
// 카테고리 옵션
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
// 사용자 맵 (userId → userName)
const [userMap, setUserMap] = useState<Record<string, string>>({});
// 트리 편집 state
const [editingTree, setEditingTree] = useState<TreeNode[]>([]);
@@ -433,6 +435,16 @@ export default function BomManagementPage() {
} catch {}
};
loadCategories();
// 사용자 목록 로드
apiClient.get("/admin/users", { params: { size: 9999 } }).then((res) => {
const users = res.data?.data || res.data || [];
const map: Record<string, string> = {};
for (const u of users) {
const id = u.userId || u.user_id || u.id;
if (id) map[id] = u.userName || u.user_name || u.name || id;
}
setUserMap(map);
}).catch(() => {});
}, []);
// ─── BOM 상세 로드 ────────────────────────────
@@ -1802,7 +1814,7 @@ export default function BomManagementPage() {
{/* 비고 */}
<td className="overflow-hidden text-ellipsis whitespace-nowrap px-3 py-2">{isVirtualRoot ? "-" : (node.remark || "-")}</td>
{/* 작성자 */}
<td className="overflow-hidden text-ellipsis whitespace-nowrap px-3 py-2 text-muted-foreground">{isVirtualRoot ? "-" : (node.writer || "-")}</td>
<td className="overflow-hidden text-ellipsis whitespace-nowrap px-3 py-2 text-muted-foreground">{isVirtualRoot ? "-" : (userMap[node.writer] || node.writer || "-")}</td>
{/* 수정일시 */}
<td className="overflow-hidden text-ellipsis whitespace-nowrap px-3 py-2 text-muted-foreground">
{isVirtualRoot ? "-" : (node.updated_date ? new Date(node.updated_date).toLocaleString("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" }) : "-")}
@@ -1052,17 +1052,17 @@ export default function ProductionPlanManagementPage() {
};
return (
<Table style={{ tableLayout: "fixed" }}>
<Table style={{ minWidth: "900px" }}>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[30px]">
<TableHead style={{ width: "30px", minWidth: "30px" }}>
<Checkbox checked={selectedItemGroups.size === orderItems.length && orderItems.length > 0} onCheckedChange={(c) => toggleAllItemGroups(!!c)} className="h-4 w-4" />
</TableHead>
<TableHead className="w-[40px]" />
<TableHead style={{ width: "40px", minWidth: "40px" }} />
<TableHead style={{ width: "140px", minWidth: "140px" }} className="text-xs font-bold whitespace-nowrap text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead style={{ width: "140px", minWidth: "140px" }} className="text-xs font-bold whitespace-nowrap text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
{ts.visibleColumns.map((col) => (
<TableHead key={col.key} style={ts.thStyle(col.key)} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">
<TableHead key={col.key} style={{ ...ts.thStyle(col.key), minWidth: "80px" }} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">
{col.label}
</TableHead>
))}
@@ -349,7 +349,14 @@ export default function WorkInstructionPage() {
const t = Number(o.total_qty || 0), c = Number(o.completed_qty || 0);
return t === 0 ? 0 : Math.min(100, Math.round((c / t) * 100));
};
const getProgressLabel = (o: any) => { const p = getProgress(o); if (o.progress_status) return o.progress_status; if (p >= 100) return "완료"; if (p > 0) return "진행중"; return "대기"; };
const getProgressLabel = (o: any) => {
const p = getProgress(o);
if (o.progress_status) {
const map: Record<string, string> = { completed: "완료", in_progress: "진행중", pending: "대기" };
return map[o.progress_status] || o.progress_status;
}
if (p >= 100) return "완료"; if (p > 0) return "진행중"; return "대기";
};
const totalRegPages = Math.max(1, Math.ceil(regTotalCount / regPageSize));
const getDisplayNo = (o: any) => {
@@ -15,9 +15,12 @@ import {
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download,
ClipboardList, Pencil, Search, X, Package, ChevronDown,
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight,
Settings2,
Settings2, GripVertical,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { DndContext, PointerSensor, closestCenter, useSensor, useSensors, type DragEndEvent } from "@dnd-kit/core";
import { SortableContext, arrayMove, horizontalListSortingStrategy, useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { apiClient } from "@/lib/api/client";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
@@ -84,6 +87,49 @@ const GRID_COLUMNS_CONFIG = [
{ key: "memo", label: "메모" },
];
const MODAL_DETAIL_COLUMNS = [
{ key: "item_code", label: "품번", width: "w-[120px]" },
{ key: "item_name", label: "품명", width: "w-[120px]" },
{ key: "supplier", label: "공급업체", width: "w-[150px]" },
{ key: "spec", label: "규격", width: "w-[80px]" },
{ key: "unit", label: "단위", width: "w-[60px]" },
{ key: "order_qty", label: "발주수량", width: "w-[90px]" },
{ key: "received_qty", label: "입고수량", width: "w-[90px]" },
{ key: "remain_qty", label: "잔량", width: "w-[80px]" },
{ key: "unit_price", label: "단가", width: "w-[100px]" },
{ key: "amount", label: "금액", width: "w-[100px]" },
{ key: "due_date", label: "납기일", width: "w-[160px]" },
{ key: "memo", label: "메모", width: "w-[120px]" },
];
const MODAL_COL_ORDER_KEY = "purchase_order_modal_col_order_c16";
function SortableModalHead({ col }: { col: { key: string; label: string; width: string } }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: col.key });
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<TableHead
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className={cn(
col.width,
"text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none cursor-grab active:cursor-grabbing hover:bg-muted-foreground/5 transition-colors"
)}
>
<div className="inline-flex items-center gap-1">
<GripVertical className="h-3.5 w-3.5 text-muted-foreground/70 shrink-0" />
<span className="truncate">{col.label}</span>
</div>
</TableHead>
);
}
export default function PurchaseOrderPage() {
const { user } = useAuth();
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
@@ -121,8 +167,43 @@ export default function PurchaseOrderPage() {
// 테이블 설정
const ts = useTableSettings("c16-purchase-order", DETAIL_TABLE, GRID_COLUMNS_CONFIG);
// 모달 품목 테이블 컬럼 순서 (드래그 재정렬)
const [modalColumns, setModalColumns] = useState(MODAL_DETAIL_COLUMNS);
const modalSensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }));
useEffect(() => {
const saved = localStorage.getItem(MODAL_COL_ORDER_KEY);
if (saved) {
try {
const order = JSON.parse(saved) as string[];
const reordered = order.map((key) => MODAL_DETAIL_COLUMNS.find((c) => c.key === key)).filter(Boolean) as typeof MODAL_DETAIL_COLUMNS;
const remaining = MODAL_DETAIL_COLUMNS.filter((c) => !order.includes(c.key));
setModalColumns([...reordered, ...remaining]);
} catch { /* skip */ }
}
}, []);
const handleModalDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
setModalColumns((prev) => {
const oldIndex = prev.findIndex((c) => c.key === active.id);
const newIndex = prev.findIndex((c) => c.key === over.id);
const next = arrayMove(prev, oldIndex, newIndex);
localStorage.setItem(MODAL_COL_ORDER_KEY, JSON.stringify(next.map((c) => c.key)));
return next;
});
};
const isReadOnly = masterForm.status === "입고완료" || masterForm.status === "취소";
const visibleModalColumns = useMemo(() => {
return modalColumns.filter((col) => {
if (col.key === "supplier" && masterForm.input_mode !== "itemFirst") return false;
return true;
});
}, [modalColumns, masterForm.input_mode]);
// 카테고리 로드
useEffect(() => {
const loadCategories = async () => {
@@ -1012,96 +1093,115 @@ export default function PurchaseOrderPage() {
</div>
) : (
<div className="border rounded-lg overflow-x-auto">
<Table className="table-fixed">
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
{!isReadOnly && <TableHead className="w-[40px] 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-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
{masterForm.input_mode === "itemFirst" && (
<TableHead className="w-[150px] 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-[60px] 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-[90px] 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-[100px] 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="w-[160px] 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>
</TableRow>
</TableHeader>
<TableBody>
{detailRows.map((row, idx) => (
<TableRow key={row._id || idx}>
{!isReadOnly && (
<TableCell>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-muted-foreground/50 hover:text-destructive hover:bg-destructive/5" onClick={() => removeDetailRow(idx)}>
<X className="w-3.5 h-3.5" />
</Button>
</TableCell>
)}
<TableCell className="text-[13px] font-mono max-w-[120px]"><span className="block truncate" title={row.item_code}>{row.item_code}</span></TableCell>
<TableCell className="text-[13px] max-w-[120px]"><span className="block truncate" title={row.item_name}>{row.item_name}</span></TableCell>
{masterForm.input_mode === "itemFirst" && (
<TableCell>
{isReadOnly ? (
<span className="text-xs">{(categoryOptions["supplier_code"] || []).find(o => o.code === row.supplier_code)?.label || row.supplier_code || "-"}</span>
) : (
<Select value={row.supplier_code || ""} onValueChange={(v) => {
const supp = categoryOptions["supplier_code"]?.find(o => o.code === v);
const name = supp?.label.replace(` (${v})`, "") || "";
updateDetailRow(idx, "supplier_code", v);
updateDetailRow(idx, "supplier_name", name);
}}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="공급업체" /></SelectTrigger>
<SelectContent>
{(categoryOptions["supplier_code"] || []).map(o => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
)}
</TableCell>
)}
<TableCell className="text-[13px] text-muted-foreground">{row.spec}</TableCell>
<TableCell className="text-[13px]">{row.unit}</TableCell>
<TableCell>
{isReadOnly ? (
<span className="text-xs text-right font-mono block">{row.order_qty ? Number(row.order_qty).toLocaleString() : ""}</span>
) : (
<Input value={formatNumber(row.order_qty || "")} onChange={(e) => updateDetailRow(idx, "order_qty", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono" />
<DndContext sensors={modalSensors} collisionDetection={closestCenter} onDragEnd={handleModalDragEnd}>
<Table className="table-fixed">
<TableHeader className="sticky top-0 z-10">
<SortableContext items={visibleModalColumns.map((c) => c.key)} strategy={horizontalListSortingStrategy}>
<TableRow className="bg-muted hover:bg-muted">
{!isReadOnly && <TableHead className="w-[40px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
{visibleModalColumns.map((col) => (
<SortableModalHead key={col.key} col={col} />
))}
</TableRow>
</SortableContext>
</TableHeader>
<TableBody>
{detailRows.map((row, idx) => (
<TableRow key={row._id || idx}>
{!isReadOnly && (
<TableCell>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-muted-foreground/50 hover:text-destructive hover:bg-destructive/5" onClick={() => removeDetailRow(idx)}>
<X className="w-3.5 h-3.5" />
</Button>
</TableCell>
)}
</TableCell>
<TableCell className="text-[13px] text-right font-mono text-muted-foreground">{row.received_qty ? Number(row.received_qty).toLocaleString() : "0"}</TableCell>
<TableCell className="text-[13px] text-right font-mono">{row.remain_qty ? Number(row.remain_qty).toLocaleString() : "0"}</TableCell>
<TableCell>
{isReadOnly ? (
<span className="text-xs text-right font-mono block">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</span>
) : (
<Input value={formatNumber(row.unit_price || "")} onChange={(e) => updateDetailRow(idx, "unit_price", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono" />
)}
</TableCell>
<TableCell className="text-[13px] text-right font-mono font-semibold">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>
<TableCell>
{isReadOnly ? (
<span className="text-xs">{row.due_date}</span>
) : (
<Input type="date" value={row.due_date || ""} onChange={(e) => updateDetailRow(idx, "due_date", e.target.value)} className="h-8 text-xs" />
)}
</TableCell>
<TableCell>
{isReadOnly ? (
<span className="text-xs">{row.memo}</span>
) : (
<Input value={row.memo || ""} onChange={(e) => updateDetailRow(idx, "memo", e.target.value)} className="h-8 text-xs" />
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{visibleModalColumns.map((col) => {
switch (col.key) {
case "item_code":
return <TableCell key={col.key} className="text-[13px] font-mono max-w-[120px]"><span className="block truncate" title={row.item_code}>{row.item_code}</span></TableCell>;
case "item_name":
return <TableCell key={col.key} className="text-[13px] max-w-[120px]"><span className="block truncate" title={row.item_name}>{row.item_name}</span></TableCell>;
case "supplier":
return (
<TableCell key={col.key}>
{isReadOnly ? (
<span className="text-xs">{(categoryOptions["supplier_code"] || []).find(o => o.code === row.supplier_code)?.label || row.supplier_code || "-"}</span>
) : (
<Select value={row.supplier_code || ""} onValueChange={(v) => {
const supp = categoryOptions["supplier_code"]?.find(o => o.code === v);
const name = supp?.label.replace(` (${v})`, "") || "";
updateDetailRow(idx, "supplier_code", v);
updateDetailRow(idx, "supplier_name", name);
}}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="공급업체" /></SelectTrigger>
<SelectContent>
{(categoryOptions["supplier_code"] || []).map(o => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
)}
</TableCell>
);
case "spec":
return <TableCell key={col.key} className="text-[13px] text-muted-foreground">{row.spec}</TableCell>;
case "unit":
return <TableCell key={col.key} className="text-[13px]">{row.unit}</TableCell>;
case "order_qty":
return (
<TableCell key={col.key}>
{isReadOnly ? (
<span className="text-xs text-right font-mono block">{row.order_qty ? Number(row.order_qty).toLocaleString() : ""}</span>
) : (
<Input value={formatNumber(row.order_qty || "")} onChange={(e) => updateDetailRow(idx, "order_qty", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono" />
)}
</TableCell>
);
case "received_qty":
return <TableCell key={col.key} className="text-[13px] text-right font-mono text-muted-foreground">{row.received_qty ? Number(row.received_qty).toLocaleString() : "0"}</TableCell>;
case "remain_qty":
return <TableCell key={col.key} className="text-[13px] text-right font-mono">{row.remain_qty ? Number(row.remain_qty).toLocaleString() : "0"}</TableCell>;
case "unit_price":
return (
<TableCell key={col.key}>
{isReadOnly ? (
<span className="text-xs text-right font-mono block">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</span>
) : (
<Input value={formatNumber(row.unit_price || "")} onChange={(e) => updateDetailRow(idx, "unit_price", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono" />
)}
</TableCell>
);
case "amount":
return <TableCell key={col.key} className="text-[13px] text-right font-mono font-semibold">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>;
case "due_date":
return (
<TableCell key={col.key}>
{isReadOnly ? (
<span className="text-xs">{row.due_date}</span>
) : (
<Input type="date" value={row.due_date || ""} onChange={(e) => updateDetailRow(idx, "due_date", e.target.value)} className="h-8 text-xs" />
)}
</TableCell>
);
case "memo":
return (
<TableCell key={col.key}>
{isReadOnly ? (
<span className="text-xs">{row.memo}</span>
) : (
<Input value={row.memo || ""} onChange={(e) => updateDetailRow(idx, "memo", e.target.value)} className="h-8 text-xs" />
)}
</TableCell>
);
default:
return <TableCell key={col.key} />;
}
})}
</TableRow>
))}
</TableBody>
</Table>
</DndContext>
</div>
)}
</div>
File diff suppressed because it is too large Load Diff
@@ -195,6 +195,12 @@ export default function SupplierManagementPage() {
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
} catch { /* skip */ }
}
for (const col of ["division", "unit", "material"]) {
try {
const res = await apiClient.get(`/table-categories/item_info/${col}/values`);
if (res.data?.success) optMap[`item_${col}`] = flatten(res.data.data || []);
} catch { /* skip */ }
}
setCategoryOptions(optMap);
const priceOpts: Record<string, { code: string; label: string }[]> = {};
@@ -807,27 +813,35 @@ export default function SupplierManagementPage() {
};
// 품목 검색
const searchItems = async () => {
const searchItems = useCallback(async () => {
setItemSearchLoading(true);
try {
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: 5000,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const allItems = res.data?.data?.data || res.data?.data?.rows || [];
setItemTotalCount(allItems.length);
const existingItemIds = new Set(priceItems.map((p: any) => p.item_id || p.item_number));
const PURCHASE_CODES = ["s"]; // 구매관리 카테고리 코드
const purchaseCode = categoryOptions["item_division"]?.find((o) => o.label === "구매관리")?.code;
setItemSearchResults(allItems.filter((item: any) => {
if (existingItemIds.has(item.item_number) || existingItemIds.has(item.id)) return false;
const divCodes = (item.division || "").split(",").map((c: string) => c.trim());
return divCodes.some((code: string) => PURCHASE_CODES.includes(code));
if (!purchaseCode) return true;
const div = item.division || "";
return div.includes(purchaseCode);
}));
} catch { /* skip */ } finally { setItemSearchLoading(false); }
};
}, [itemSearchKeyword, priceItems]);
// 실간 검색 (2글자 이상)
useEffect(() => {
if (!itemSelectOpen) return;
if (itemSearchKeyword.length > 0 && itemSearchKeyword.length < 2) return;
searchItems();
}, [itemSearchKeyword, itemSelectOpen]); // eslint-disable-line react-hooks/exhaustive-deps
// 품목 선택 완료 → 상세 입력 모달로 전환
const goToItemDetail = () => {
@@ -964,6 +978,7 @@ export default function SupplierManagementPage() {
{ columnName: "supplier_id", operator: "equals", value: selectedSupplier!.supplier_code },
{ columnName: "item_id", operator: "equals", value: itemKey },
]}, autoFilter: true,
sort: { columnName: "created_date", order: "asc" },
});
const allMappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
mappingRows = allMappings
@@ -984,7 +999,8 @@ export default function SupplierManagementPage() {
{ columnName: "item_id", operator: "equals", value: itemKey },
]}, autoFilter: true,
});
const allPriceData = priceRes.data?.data?.data || priceRes.data?.data?.rows || [];
const allPriceData = (priceRes.data?.data?.data || priceRes.data?.data?.rows || [])
.sort((a: any, b: any) => (a.start_date || "").localeCompare(b.start_date || ""));
priceRows = allPriceData.map((p: any) => ({
_id: `p_existing_${p.id}`,
start_date: p.start_date ? String(p.start_date).split("T")[0] : "",
@@ -1036,6 +1052,7 @@ export default function SupplierManagementPage() {
{ columnName: "supplier_id", operator: "equals", value: selectedSupplier.supplier_code },
{ columnName: "item_id", operator: "equals", value: itemKey },
]}, autoFilter: true,
sort: { columnName: "created_date", order: "asc" },
});
existingMaps = existingMappings.data?.data?.data || existingMappings.data?.data?.rows || [];
} catch { /* skip */ }
@@ -1085,12 +1102,13 @@ export default function SupplierManagementPage() {
{ columnName: "item_id", operator: "equals", value: itemKey },
]}, autoFilter: true,
});
existingPriceRows = existingPrices.data?.data?.data || existingPrices.data?.data?.rows || [];
existingPriceRows = (existingPrices.data?.data?.data || existingPrices.data?.data?.rows || [])
.sort((a: any, b: any) => (a.start_date || "").localeCompare(b.start_date || ""));
} catch { /* skip */ }
// 단가 upsert
const priceRows = (itemPrices[itemKey] || []).filter((p) =>
(p.base_price && Number(p.base_price) > 0) || p.start_date
p.base_price || p.start_date || p.currency_code || p.base_price_type
);
const usedPriceIds = new Set<string>();
for (let pi = 0; pi < priceRows.length; pi++) {
@@ -1160,7 +1178,7 @@ export default function SupplierManagementPage() {
}
const priceRows = (itemPrices[itemKey] || []).filter((p) =>
(p.base_price && Number(p.base_price) > 0) || p.start_date
p.base_price || p.start_date || p.currency_code || p.base_price_type
);
for (const price of priceRows) {
await apiClient.post(`/table-management/tables/${PRICE_TABLE}/add`, {
@@ -1192,40 +1210,63 @@ export default function SupplierManagementPage() {
}
};
// 품목 매핑 삭제
// 품목 매핑 해제 (소프트 삭제 — supplier_id를 null 처리)
const handlePriceItemDelete = async () => {
if (priceCheckedIds.length === 0) return;
const ok = await confirm(`선택한 ${priceCheckedIds.length}개 품목 매핑제하시겠습니까?`, {
description: "관련된 단가 정보도 함께 삭제됩니다.",
variant: "destructive", confirmText: "제",
const ok = await confirm(`선택한 ${priceCheckedIds.length}개 품목의 연결제하시겠습니까?`, {
description: "해당 품목의 공급업체 연결이 해제됩니다. (데이터는 유지)",
variant: "destructive", confirmText: "제",
});
if (!ok) return;
try {
for (const mappingId of priceCheckedIds) {
const itemIds = priceCheckedIds.map((mid) => {
const group = Object.values(priceGroups).find((g) => g.master.id === mid);
return group?.master.item_id || group?.master.item_number || "";
}).filter(Boolean);
for (const itemId of itemIds) {
// 해당 품목의 모든 매핑 조회 → supplier_id null 처리
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [
{ columnName: "supplier_id", operator: "equals", value: selectedSupplier!.supplier_code },
{ columnName: "item_id", operator: "equals", value: itemId },
]}, autoFilter: true,
});
const allMappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
for (const m of allMappings) {
await apiClient.put(`/table-management/tables/${MAPPING_TABLE}/edit`, {
originalData: { id: m.id },
updatedData: { supplier_id: null },
});
}
// 해당 품목의 모든 단가 조회 → supplier_id null 처리
try {
const priceRes = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "mapping_id", operator: "equals", value: mappingId }] },
autoFilter: true,
dataFilter: { enabled: true, filters: [
{ columnName: "supplier_id", operator: "equals", value: selectedSupplier!.supplier_code },
{ columnName: "item_id", operator: "equals", value: itemId },
]}, autoFilter: true,
});
const prices = priceRes.data?.data?.data || priceRes.data?.data?.rows || [];
if (prices.length > 0) {
await apiClient.delete(`/table-management/tables/${PRICE_TABLE}/delete`, {
data: prices.map((p: any) => ({ id: p.id })),
for (const p of prices) {
await apiClient.put(`/table-management/tables/${PRICE_TABLE}/edit`, {
originalData: { id: p.id },
updatedData: { supplier_id: null },
});
}
} catch { /* skip */ }
}
await apiClient.delete(`/table-management/tables/${MAPPING_TABLE}/delete`, {
data: priceCheckedIds.map((id) => ({ id })),
});
toast.success(`${priceCheckedIds.length}개 품목 매핑이 삭제되었습니다.`);
toast.success(`${priceCheckedIds.length}개 품목의 연결이 해제되었습니다.`);
setPriceCheckedIds([]);
const cid = selectedSupplierId;
setSelectedSupplierId(null);
setTimeout(() => setSelectedSupplierId(cid), 50);
} catch {
toast.error("제에 실패했습니다.");
toast.error("연결 해제에 실패했습니다.");
}
};
@@ -2430,8 +2471,8 @@ export default function SupplierManagementPage() {
</TableCell>
<TableCell className="text-sm">{item.item_name}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{item.size}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{item.material}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{item.unit}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{categoryOptions["item_material"]?.find((o) => o.code === item.material)?.label || item.material}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{categoryOptions["item_unit"]?.find((o) => o.code === item.unit)?.label || item.unit}</TableCell>
</TableRow>
))}
</TableBody>
@@ -2473,7 +2514,7 @@ export default function SupplierManagementPage() {
{/* 품목 헤더 */}
<div className="px-5 py-3 bg-muted/30 border-b">
<div className="font-bold">{idx + 1}. {item.item_name || itemKey}</div>
<div className="text-xs text-muted-foreground">{itemKey} | {item.size || ""} | {item.unit || ""}</div>
<div className="text-xs text-muted-foreground">{itemKey} | {item.size || ""} | {categoryOptions["item_unit"]?.find((o) => o.code === item.unit)?.label || item.unit || ""}</div>
</div>
<div className="flex gap-4 p-5 items-stretch">
@@ -188,9 +188,12 @@ export default function InspectionManagementPage() {
if (["inspection_type", "inspection_method", "judgment_criteria", "unit", "apply_type"].includes(col.key)) {
base.render = (v: any, row: any) => getCatLabel(INSPECTION_TABLE, col.key, row[col.key]);
}
if (col.key === "manager") {
base.render = (v: any, row: any) => userOptions.find((u) => u.code === row.manager)?.label || row.manager || "-";
}
return base;
});
}, [ts.visibleColumns, catOptions]); // eslint-disable-line react-hooks/exhaustive-deps
}, [ts.visibleColumns, catOptions, userOptions]); // eslint-disable-line react-hooks/exhaustive-deps
/* ═══════════════════ 데이터 조회 ═══════════════════ */
// 다중값 컬럼 (쉼표 구분 저장) — 서버 equals 대신 contains 사용
@@ -385,7 +388,7 @@ export default function InspectionManagementPage() {
/* ═══════════════════ 불량관리 CRUD ═══════════════════ */
const openDefCreate = async () => {
setDefForm({});
setDefForm({ is_active: "사용" });
setDefEditMode(false);
setNumberingRuleId(null);
setPreviewCode(null);
@@ -867,20 +870,16 @@ export default function InspectionManagementPage() {
</TableCell>
<TableCell>
<Badge
variant={
getCatLabel(DEFECT_TABLE, "is_active", row.is_active) === "사용"
? "default"
: "secondary"
}
variant={row.is_active === "사용" || row.is_active === "true" ? "default" : "secondary"}
className="text-[10px]"
>
{getCatLabel(DEFECT_TABLE, "is_active", row.is_active) || "-"}
{row.is_active === "사용" || row.is_active === "true" ? "사용" : row.is_active || "미사용"}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground">
{row.reg_date || (row.created_date ? row.created_date.slice(0, 10) : "-")}
</TableCell>
<TableCell>{row.manager_id || "-"}</TableCell>
<TableCell>{userOptions.find((u) => u.code === row.manager_id)?.label || row.manager_id || "-"}</TableCell>
<TableCell className="text-muted-foreground">{row.remarks || "-"}</TableCell>
</TableRow>
);
@@ -1442,19 +1441,15 @@ export default function InspectionManagementPage() {
<div className="space-y-1.5">
<Label className="text-xs font-semibold"></Label>
<Select
value={defForm.is_active || "__none__"}
onValueChange={(v) => setDefForm((p) => ({ ...p, is_active: v === "__none__" ? "" : v }))}
value={defForm.is_active || "사용"}
onValueChange={(v) => setDefForm((p) => ({ ...p, is_active: v }))}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> </SelectItem>
{(catOptions[`${DEFECT_TABLE}.is_active`] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>
{o.label}
</SelectItem>
))}
<SelectItem value="사용"></SelectItem>
<SelectItem value="미사용"></SelectItem>
</SelectContent>
</Select>
</div>
@@ -9,8 +9,9 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import {
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ChevronDown,
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ClipboardList,
} from "lucide-react";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { cn } from "@/lib/utils";
@@ -20,19 +21,17 @@ import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { toast } from "sonner";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
const TABLE_NAME = "item_inspection_info";
const ITEM_TABLE = "item_info";
const INSPECTION_TABLE = "inspection_standard";
const GRID_COLUMNS = [
{ key: "item_code", label: "품목코드" },
{ key: "item_name", label: "품명" },
{ key: "item_name", label: "품명" },
{ key: "inspection_type", label: "검사유형" },
{ key: "item_count", label: "항목수" },
{ key: "is_active", label: "사용여부" },
];
const ITEM_TABLE = "item_info";
const INSPECTION_TABLE = "inspection_standard";
const INSPECTION_TYPES = [
{ key: "incoming_inspection", label: "수입검사", matchLabels: ["수입검사", "입고검사", "수입", "입고"] },
@@ -61,24 +60,29 @@ export default function ItemInspectionInfoPage() {
const [totalCount, setTotalCount] = useState(0);
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
const [checkedIds, setCheckedIds] = useState<string[]>([]);
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
// 우측 패널: 선택된 품목
const [selectedItemCode, setSelectedItemCode] = useState<string | null>(null);
const [selectedTypeTab, setSelectedTypeTab] = useState<string>("");
// 모달
const [modalOpen, setModalOpen] = useState(false);
const [editMode, setEditMode] = useState(false);
const [form, setForm] = useState<Record<string, any>>({});
const [saving, setSaving] = useState(false);
/* FK 옵션 */
// FK 옵션
const [itemOptions, setItemOptions] = useState<{ code: string; name: string; item_type: string; unit: string }[]>([]);
const [inspOptions, setInspOptions] = useState<{ code: string; label: string; detail: string; method: string; types: string[] }[]>([]);
const [inspTypeCatOptions, setInspTypeCatOptions] = useState<{ code: string; label: string }[]>([]);
const [inspMethodCatOptions, setInspMethodCatOptions] = useState<{ code: string; label: string }[]>([]);
const [userOptions, setUserOptions] = useState<{ code: string; label: string }[]>([]);
/* 검사유형별 검사항목 rows */
// 검사유형별 검사항목 rows (모달용)
const [inspectionRows, setInspectionRows] = useState<Record<string, InspectionRow[]>>({});
const [collapsedTypes, setCollapsedTypes] = useState<Record<string, boolean>>({});
/* 품목 선택 모달 */
// 품목 선택 모달
const [itemModalOpen, setItemModalOpen] = useState(false);
const [itemSearchKeyword, setItemSearchKeyword] = useState("");
const [filteredItems, setFilteredItems] = useState<typeof itemOptions>([]);
@@ -109,7 +113,7 @@ export default function ItemInspectionInfoPage() {
types: r.inspection_type ? r.inspection_type.split(",").filter(Boolean) : [],
})));
// 검사유형 카테고리 값 로드 (코드→라벨 매핑용)
// 검사유형 카테고리
try {
const catRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/inspection_type/values`);
const flatCats: { code: string; label: string }[] = [];
@@ -118,6 +122,15 @@ export default function ItemInspectionInfoPage() {
setInspTypeCatOptions(flatCats);
} catch { /* skip */ }
// 검사방법 카테고리
try {
const methodRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/inspection_method/values`);
const flatMethods: { code: string; label: string }[] = [];
const flattenM = (arr: any[]) => { for (const v of arr) { flatMethods.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) flattenM(v.children); } };
if (methodRes.data?.data?.length) flattenM(methodRes.data.data);
setInspMethodCatOptions(flatMethods);
} catch { /* skip */ }
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
setUserOptions(users.map((u: any) => ({
code: u.user_id || u.id,
@@ -129,20 +142,12 @@ export default function ItemInspectionInfoPage() {
}, []);
/* ═══════════════════ 품목 선택 모달 ═══════════════════ */
const openItemModal = () => {
setItemSearchKeyword("");
setFilteredItems(itemOptions);
setItemModalOpen(true);
};
const openItemModal = () => { setItemSearchKeyword(""); setFilteredItems(itemOptions); setItemModalOpen(true); };
const handleItemSearch = () => {
const kw = itemSearchKeyword.trim().toLowerCase();
if (!kw) { setFilteredItems(itemOptions); return; }
setFilteredItems(itemOptions.filter(o =>
o.code.toLowerCase().includes(kw) || o.name.toLowerCase().includes(kw)
));
setFilteredItems(itemOptions.filter(o => o.code.toLowerCase().includes(kw) || o.name.toLowerCase().includes(kw)));
};
const selectItem = (item: typeof itemOptions[0]) => {
setForm(p => ({ ...p, item_code: item.code, item_name: item.name }));
setItemModalOpen(false);
@@ -186,24 +191,45 @@ export default function ItemInspectionInfoPage() {
return Object.values(map);
}, [data]);
// 검사기준 ID → 라벨 resolve
// 선택된 품목의 그룹 데이터
const selectedGroup = useMemo(() => {
if (!selectedItemCode) return null;
return groupedData.find(g => g.item_code === selectedItemCode) || null;
}, [selectedItemCode, groupedData]);
// 선택된 탭의 검사항목 행
const selectedTabRows = useMemo(() => {
if (!selectedGroup || !selectedTypeTab) return [];
return selectedGroup.rows.filter((r: any) => r.inspection_type === selectedTypeTab && r.inspection_standard_id);
}, [selectedGroup, selectedTypeTab]);
// 검사기준 ID → 라벨
const resolveInspLabel = useCallback((id: string) => {
const opt = inspOptions.find(o => o.code === id);
return opt?.label || id || "-";
return inspOptions.find(o => o.code === id)?.label || id || "-";
}, [inspOptions]);
// 검사방법 코드 → 라벨
const resolveMethodLabel = useCallback((code: string) => {
return inspMethodCatOptions.find(o => o.code === code)?.label || code || "-";
}, [inspMethodCatOptions]);
/* ═══════════════════ CRUD ═══════════════════ */
const openCreate = () => { setForm({}); setEditMode(false); setInspectionRows({}); setCollapsedTypes({}); setModalOpen(true); };
const openEdit = async (row: any) => {
const openEdit = async (itemCode?: string) => {
const code = itemCode || selectedItemCode;
if (!code) { toast.error("수정할 항목을 선택해주세요"); return; }
const group = groupedData.find(g => g.item_code === code);
if (!group) return;
const row = group.rows[0];
setForm({ ...row });
setEditMode(true);
setCollapsedTypes({});
// 같은 item_code의 모든 검사항목 행을 조회하여 유형별로 분류
try {
const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: row.item_code }] },
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: code }] },
autoFilter: true,
});
const allRows = res.data?.data?.data || res.data?.data?.rows || [];
@@ -212,7 +238,6 @@ export default function ItemInspectionInfoPage() {
for (const r of allRows) {
const inspType = r.inspection_type || "";
// 카테고리 코드/라벨로 INSPECTION_TYPES 키 매칭
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)))
@@ -221,48 +246,34 @@ export default function ItemInspectionInfoPage() {
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;
rowMap[typeKey].push({
id: r.id,
inspection_standard_id: r.inspection_standard_id || "",
inspection_detail: r.inspection_item_name || r.inspection_standard || "",
inspection_method: r.inspection_method || "",
inspection_method: mLabel,
apply_process: "",
acceptance_criteria: r.pass_criteria || "",
is_required: r.is_required === "true" || r.is_required === true,
});
}
setInspectionRows(rowMap);
setForm(p => ({ ...p, ...typeFlags }));
} catch {
setInspectionRows({});
}
} catch { setInspectionRows({}); }
setModalOpen(true);
};
/* ═══════════════════ 검사항목 행 관리 ═══════════════════ */
/* ═══════════════════ 검사항목 행 관리 (모달) ═══════════════════ */
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: "", acceptance_criteria: "", is_required: false }],
}));
};
const removeInspRow = (typeKey: string, rowId: string) => {
setInspectionRows(prev => ({
...prev,
[typeKey]: (prev[typeKey] || []).filter(r => r.id !== rowId),
}));
setInspectionRows(prev => ({ ...prev, [typeKey]: (prev[typeKey] || []).filter(r => r.id !== rowId) }));
};
const updateInspRow = (typeKey: string, rowId: string, field: string, value: any) => {
setInspectionRows(prev => ({
...prev,
@@ -270,34 +281,27 @@ export default function ItemInspectionInfoPage() {
if (r.id !== rowId) return r;
if (field === "inspection_standard_id") {
const opt = inspOptions.find(o => o.code === value);
return { ...r, inspection_standard_id: value, inspection_detail: opt?.detail || "", inspection_method: opt?.method || "" };
const methodCode = opt?.method || "";
const methodLabel = inspMethodCatOptions.find(o => o.code === methodCode)?.label || methodCode;
return { ...r, inspection_standard_id: value, inspection_detail: opt?.detail || "", inspection_method: methodLabel };
}
return { ...r, [field]: value };
}),
}));
};
/** 검사유형 키에 매칭되는 검사기준만 필터링 */
const getFilteredInspOptions = (typeKey: string) => {
const typeDef = INSPECTION_TYPES.find(t => t.key === typeKey);
if (!typeDef) return inspOptions;
// matchLabels와 카테고리 라벨을 비교하여 해당 카테고리 코드를 찾음
const matchCodes = inspTypeCatOptions
.filter(cat => typeDef.matchLabels.some(ml => cat.label.includes(ml)))
.map(cat => cat.code);
const matchCodes = inspTypeCatOptions.filter(cat => typeDef.matchLabels.some(ml => cat.label.includes(ml))).map(cat => cat.code);
if (matchCodes.length === 0) return inspOptions;
return inspOptions.filter(opt => opt.types.some(t => matchCodes.includes(t)));
};
const toggleCollapse = (typeKey: string) => {
setCollapsedTypes(prev => ({ ...prev, [typeKey]: !prev[typeKey] }));
};
const toggleCollapse = (typeKey: string) => { setCollapsedTypes(prev => ({ ...prev, [typeKey]: !prev[typeKey] })); };
const handleSave = async () => {
if (!form.item_code) { toast.error("품목코드는 필수 입력이에요"); return; }
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,
@@ -306,54 +310,29 @@ export default function ItemInspectionInfoPage() {
});
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 })),
});
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: existing.map((r: any) => ({ id: r.id })) });
}
}
// 검사유형별 항목을 개별 행으로 INSERT
const enabledTypes = INSPECTION_TYPES.filter(t => !!form[t.key]);
const rows: any[] = [];
for (const t of enabledTypes) {
const typeLabel = t.label;
const typeRows = inspectionRows[t.key] || [];
if (typeRows.length === 0) {
// 유형만 체크하고 항목 없는 경우에도 1행 생성
rows.push({
id: crypto.randomUUID(),
item_code: form.item_code,
item_name: form.item_name,
inspection_type: typeLabel,
is_active: form.is_active || "사용",
manager_id: form.manager_id || "",
memo: form.remarks || "",
});
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 || "" });
} else {
for (const r of typeRows) {
rows.push({
id: crypto.randomUUID(),
item_code: form.item_code,
item_name: form.item_name,
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: form.is_active || "사용",
manager_id: form.manager_id || "",
memo: form.remarks || "",
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 || "",
is_required: r.is_required ? "true" : "false", is_active: form.is_active || "사용",
manager_id: form.manager_id || "", memo: form.remarks || "",
});
}
}
}
for (const row of rows) {
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, row);
}
toast.success(editMode ? "품목검사정보를 수정했어요" : "품목검사정보를 등록했어요");
for (const row of rows) { await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, row); }
toast.success(editMode ? "수정했어요" : "등록했어요");
setModalOpen(false);
fetchData();
} catch { toast.error("저장에 실패했어요"); }
@@ -361,138 +340,231 @@ export default function ItemInspectionInfoPage() {
};
const handleDelete = async () => {
if (checkedIds.length === 0) { toast.error("삭제할 목을 선택해주세요"); return; }
const ok = await confirm("품목검사정보 삭제", {
description: `선택한 ${checkedIds.length}건을 삭제할까요? 이 작업은 되돌릴 수 없어요.`,
});
if (!selectedItemCode) { toast.error("삭제할 목을 선택해주세요"); return; }
const group = groupedData.find(g => g.item_code === selectedItemCode);
if (!group) return;
const ok = await confirm(`${selectedItemCode} 검사정보 삭제`, { description: "선택한 품목의 검사정보를 모두 삭제할까요?", variant: "destructive", confirmText: "삭제" });
if (!ok) return;
try {
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, {
data: checkedIds.map(id => ({ id })),
});
toast.success(`${checkedIds.length}건을 삭제했어요`);
setCheckedIds([]);
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: group.rows.map((r: any) => ({ id: r.id })) });
toast.success("삭제했어요");
setSelectedItemCode(null);
fetchData();
} catch { toast.error("삭제에 실패했어요"); }
};
/* ═══════════════════ JSX ═══════════════════ */
return (
<div className="flex flex-col gap-3 p-3">
<div className="flex h-full flex-col">
{ConfirmDialogComponent}
<div className="rounded-lg border bg-card">
<div className="px-3 py-2.5 border-b bg-muted/50">
<DynamicSearchFilter
tableName={TABLE_NAME}
filterId="c16-item-inspection"
onFilterChange={setSearchFilters}
externalFilterConfig={ts.filterConfig}
dataCount={totalCount}
extraActions={
<div className="flex items-center gap-2">
<Button size="sm" onClick={openCreate}><Plus className="w-4 h-4 mr-1" /></Button>
<Button size="sm" variant="outline" onClick={() => {
const sel = data.find(r => checkedIds.includes(r.id));
if (sel) {
const group = groupedData.find(g => g.item_code === sel.item_code);
openEdit(group?.rows[0] || sel);
} else toast.error("수정할 항목을 선택해주세요");
}}><Pencil className="w-4 h-4 mr-1" /></Button>
<Button size="sm" variant="destructive" onClick={handleDelete}><Trash2 className="w-4 h-4 mr-1" /></Button>
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
<Settings2 className="h-4 w-4" />
</Button>
</div>
}
/>
</div>
<div className="p-3 overflow-auto">
{loading ? (
<div className="flex items-center justify-center py-16"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
) : groupedData.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Inbox className="h-10 w-10 mb-2 opacity-30" />
<p className="text-sm"> </p>
</div>
) : (
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-10" />
<TableHead className="w-10"><Checkbox checked={groupedData.length > 0 && checkedIds.length === data.length} onCheckedChange={(v) => setCheckedIds(v ? data.map(r => r.id) : [])} /></TableHead>
{ts.visibleColumns.map((col) => (
<TableHead key={col.key} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground" style={ts.thStyle(col.key)}>
{col.label}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{ts.groupData(groupedData).map((group) => {
if ((group as any)._isGroupSummary || (group as any)._isGroupHeader) return null;
const isExpanded = expandedItems.has(group.item_code);
const groupIds = group.rows.map((r: any) => r.id);
const allChecked = groupIds.every((id: string) => checkedIds.includes(id));
const renderCell = (key: string) => {
switch (key) {
case "item_code": return <TableCell key={key} className="text-sm font-medium text-primary">{group.item_code}</TableCell>;
case "item_name": return <TableCell key={key} className="text-sm">{group.item_name}</TableCell>;
case "inspection_type": return (
<TableCell key={key}>
<div className="flex gap-1 flex-wrap">
{group.types.map((t: string) => <Badge key={t} variant="secondary" className="text-[10px]">{t}</Badge>)}
</div>
</TableCell>
);
case "item_count": return <TableCell key={key} className="text-sm text-center">{group.rows.filter((r: any) => r.inspection_standard_id).length}</TableCell>;
case "is_active": return (
<TableCell key={key}>
<Badge variant={group.is_active === "사용" || group.is_active === "true" ? "default" : "secondary"} className="text-[10px]">
{group.is_active === "사용" || group.is_active === "true" ? "사용" : "미사용"}
</Badge>
</TableCell>
);
default: return <TableCell key={key}>{(group as any)[key] ?? ""}</TableCell>;
}
};
return (
<React.Fragment key={group.item_code}>
<TableRow
className={cn("cursor-pointer border-l-2 border-l-transparent", allChecked && "border-l-primary bg-primary/5")}
onClick={() => setExpandedItems(prev => { const next = new Set(prev); if (next.has(group.item_code)) next.delete(group.item_code); else next.add(group.item_code); return next; })}
onDoubleClick={() => openEdit(group.rows[0])}
>
<TableCell className="text-center p-2">
<ChevronDown className={cn("w-4 h-4 transition-transform text-muted-foreground", !isExpanded && "-rotate-90")} />
</TableCell>
<TableCell className="text-center p-2" onClick={(e) => { e.stopPropagation(); setCheckedIds(prev => allChecked ? prev.filter(id => !groupIds.includes(id)) : [...new Set([...prev, ...groupIds])]); }}>
<Checkbox checked={allChecked} onCheckedChange={() => {}} />
</TableCell>
{ts.visibleColumns.map((col) => renderCell(col.key))}
</TableRow>
{isExpanded && group.rows.filter((r: any) => r.inspection_standard_id).map((row: any) => (
<TableRow key={row.id} className="bg-muted/30 text-xs">
<TableCell />
<TableCell />
<TableCell className="pl-6 text-muted-foreground">{row.inspection_type}</TableCell>
<TableCell>{resolveInspLabel(row.inspection_standard_id)}</TableCell>
<TableCell>{row.inspection_item_name || "-"}</TableCell>
<TableCell>{row.inspection_method || "-"}</TableCell>
<TableCell>{row.pass_criteria || "-"}</TableCell>
</TableRow>
))}
</React.Fragment>
);
})}
</TableBody>
</Table>
)}
<div className="mt-2 text-xs text-muted-foreground"> {groupedData.length} ( )</div>
</div>
{/* 검색 필터 */}
<div className="shrink-0 px-3 pt-3 pb-2">
<DynamicSearchFilter
tableName={TABLE_NAME}
filterId="c16-item-inspection"
onFilterChange={setSearchFilters}
externalFilterConfig={ts.filterConfig}
dataCount={totalCount}
/>
</div>
{/* ═══════════════════ 등록/수정 모달 (품목선택 뷰 포함) ═══════════════════ */}
{/* 좌우 분할 패널 */}
<div className="flex-1 min-h-0 px-3 pb-3">
<ResizablePanelGroup direction="horizontal" className="h-full rounded-lg border">
{/* ═══════ 좌측: 품목 목록 ═══════ */}
<ResizablePanel defaultSize={50} minSize={30}>
<div className="flex h-full flex-col">
<div className="shrink-0 flex items-center justify-between px-4 py-3 border-b bg-muted/30">
<div className="flex items-center gap-2">
<ClipboardList className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-semibold"> </span>
<Badge variant="secondary" className="text-[10px]">{groupedData.length}</Badge>
</div>
<div className="flex items-center gap-1.5">
<Button size="sm" className="h-7 text-xs" onClick={openCreate}><Plus 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>
</div>
<div className="flex-1 min-h-0 overflow-auto">
{loading ? (
<div className="flex items-center justify-center py-16"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
) : groupedData.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Inbox className="h-10 w-10 mb-2 opacity-30" />
<p className="text-sm"> </p>
</div>
) : (
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
{ts.visibleColumns.map((col) => (
<TableHead key={col.key} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground" style={ts.thStyle(col.key)}>
{col.label}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{groupedData.map((group) => (
<TableRow
key={group.item_code}
className={cn("cursor-pointer", selectedItemCode === group.item_code ? "bg-primary/10 border-l-2 border-l-primary" : "hover:bg-muted/50")}
onClick={() => {
setSelectedItemCode(group.item_code);
setSelectedTypeTab(group.types[0] || "");
}}
>
{ts.visibleColumns.map((col) => {
switch (col.key) {
case "item_code": return <TableCell key={col.key} className="text-sm font-medium text-primary">{group.item_code}</TableCell>;
case "item_name": return <TableCell key={col.key} className="text-sm">{group.item_name}</TableCell>;
case "inspection_type": return (
<TableCell key={col.key}>
<div className="flex gap-1 flex-wrap">
{group.types.map((t: string) => (
<Badge key={t} variant="secondary" className="text-[10px]">{t}</Badge>
))}
</div>
</TableCell>
);
case "is_active": return (
<TableCell key={col.key}>
<Badge variant={group.is_active === "사용" || group.is_active === "true" ? "default" : "secondary"} className="text-[10px]">
{group.is_active === "사용" || group.is_active === "true" ? "사용" : "미사용"}
</Badge>
</TableCell>
);
default: return <TableCell key={col.key}>{(group as any)[col.key] ?? ""}</TableCell>;
}
})}
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
<div className="shrink-0 px-4 py-2 border-t text-xs text-muted-foreground">
{groupedData.length} ( )
</div>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* ═══════ 우측: 검사유형별 검사항목 ═══════ */}
<ResizablePanel defaultSize={50} minSize={30}>
<div className="flex h-full flex-col">
<div className="shrink-0 flex items-center justify-between px-4 py-3 border-b bg-muted/30">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold"> </span>
</div>
{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="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" /></Button>
</div>
)}
</div>
{!selectedGroup ? (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
<div className="text-center space-y-2">
<ClipboardList className="h-8 w-8 mx-auto opacity-30" />
<p className="text-sm"> </p>
</div>
</div>
) : (
<div className="flex-1 min-h-0 flex flex-col">
{/* 검사유형 탭 */}
<div className="shrink-0 flex items-center gap-2 px-4 py-3">
{selectedGroup.types.map((type: string) => {
const count = selectedGroup.rows.filter((r: any) => r.inspection_type === type && r.inspection_standard_id).length;
return (
<button
key={type}
type="button"
onClick={() => setSelectedTypeTab(type)}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-colors border",
selectedTypeTab === type
? "bg-primary text-primary-foreground border-primary"
: "bg-muted/50 text-muted-foreground border-border hover:bg-muted"
)}
>
{type}
<span className={cn(
"inline-flex items-center justify-center h-4 min-w-[16px] rounded-full text-[10px] font-bold px-1",
selectedTypeTab === type ? "bg-primary-foreground/20 text-primary-foreground" : "bg-muted-foreground/20 text-muted-foreground"
)}>
{count}
</span>
</button>
);
})}
</div>
{/* 검사항목 상세 테이블 */}
<div className="flex-1 min-h-0 overflow-auto px-4 pb-4">
{selectedTypeTab && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Badge variant="default" className="text-xs">{selectedTypeTab}</Badge>
<span className="text-sm font-medium"> </span>
</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 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>
</TableRow>
</TableHeader>
<TableBody>
{selectedTabRows.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-xs text-muted-foreground"> </TableCell>
</TableRow>
) : selectedTabRows.map((row: any) => (
<TableRow key={row.id}>
<TableCell className="text-xs py-2">{row.inspection_item_name || "-"}</TableCell>
<TableCell className="text-xs py-2">{resolveInspLabel(row.inspection_standard_id)}</TableCell>
<TableCell className="text-xs py-2">{resolveMethodLabel(row.inspection_method)}</TableCell>
<TableCell className="text-xs py-2">{row.apply_process || "-"}</TableCell>
<TableCell className="text-xs py-2">
{row.judgment_criteria ? (
<Badge variant="outline" className="text-[10px]">{row.judgment_criteria}</Badge>
) : "-"}
</TableCell>
<TableCell className="text-xs py-2 font-mono">{row.pass_criteria || "-"}</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>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
</div>
</div>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
{/* ═══════════════════ 등록/수정 모달 ═══════════════════ */}
<Dialog open={modalOpen} onOpenChange={(open) => { if (!open) setItemModalOpen(false); setModalOpen(open); }}>
<DialogContent className={cn("max-w-[95vw] max-h-[85vh] overflow-y-auto", itemModalOpen ? "sm:max-w-xl" : "sm:max-w-4xl")}>
{itemModalOpen ? (
@@ -502,16 +574,8 @@ export default function ItemInspectionInfoPage() {
<DialogDescription> </DialogDescription>
</DialogHeader>
<div className="flex gap-2">
<Input
className="h-9 flex-1"
placeholder="품목코드 또는 품목명으로 검색"
value={itemSearchKeyword}
onChange={(e) => setItemSearchKeyword(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleItemSearch(); }}
/>
<Button size="sm" className="h-9" onClick={handleItemSearch}>
<Search className="w-4 h-4 mr-1" />
</Button>
<Input className="h-9 flex-1" placeholder="품목코드 또는 품목명" value={itemSearchKeyword} onChange={(e) => setItemSearchKeyword(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") handleItemSearch(); }} />
<Button size="sm" className="h-9" onClick={handleItemSearch}><Search className="w-4 h-4 mr-1" /></Button>
</div>
<div className="border rounded-lg overflow-auto max-h-[50vh]">
<Table>
@@ -527,11 +591,7 @@ export default function ItemInspectionInfoPage() {
{filteredItems.length === 0 ? (
<TableRow><TableCell colSpan={4} className="text-center py-8 text-muted-foreground text-sm"> </TableCell></TableRow>
) : filteredItems.map((item) => (
<TableRow
key={item.code}
className="cursor-pointer hover:bg-primary/5"
onClick={() => selectItem(item)}
>
<TableRow key={item.code} className="cursor-pointer hover:bg-primary/5" onClick={() => selectItem(item)}>
<TableCell className="text-sm">{item.code}</TableCell>
<TableCell className="text-sm">{item.name}</TableCell>
<TableCell className="text-sm">{item.item_type}</TableCell>
@@ -541,9 +601,7 @@ export default function ItemInspectionInfoPage() {
</TableBody>
</Table>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setItemModalOpen(false)}></Button>
</DialogFooter>
<DialogFooter><Button variant="outline" onClick={() => setItemModalOpen(false)}></Button></DialogFooter>
</>
) : (
<>
@@ -554,14 +612,14 @@ export default function ItemInspectionInfoPage() {
<div className="space-y-4">
{/* 품목 정보 */}
<div className="space-y-3">
<h4 className="text-sm font-semibold flex items-center gap-1.5">📦 </h4>
<h4 className="text-sm font-semibold"> </h4>
<div className="flex items-end gap-2">
<div className="space-y-1.5 flex-1">
<Label className="text-xs font-semibold text-muted-foreground"> <span className="text-destructive">*</span></Label>
<Input className="h-9 bg-muted" value={form.item_code || ""} readOnly placeholder="품목코드" />
</div>
<div className="space-y-1.5 flex-1">
<Label className="text-xs font-semibold text-muted-foreground"> <span className="text-destructive">*</span></Label>
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Input className="h-9 bg-muted" value={form.item_name || ""} readOnly placeholder="품목명" />
</div>
<Button type="button" variant="outline" size="sm" className="h-9 px-3 shrink-0" onClick={openItemModal}>
@@ -571,7 +629,7 @@ export default function ItemInspectionInfoPage() {
<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={form.is_active === false ? "N" : "Y"} onValueChange={(v) => setForm(p => ({ ...p, is_active: v === "Y" }))}>
<Select value={form.is_active === false || form.is_active === "N" ? "N" : "Y"} onValueChange={(v) => setForm(p => ({ ...p, is_active: v === "Y" ? "사용" : "미사용" }))}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="Y"></SelectItem>
@@ -583,50 +641,32 @@ export default function ItemInspectionInfoPage() {
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Select value={form.manager || ""} onValueChange={(v) => setForm(p => ({ ...p, manager: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="관리자 선택" /></SelectTrigger>
<SelectContent>
{userOptions.map(o => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
<SelectContent>{userOptions.map(o => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<textarea
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm min-h-[70px] resize-none focus:outline-none focus:ring-2 focus:ring-ring"
value={form.remarks || ""}
onChange={(e) => setForm(p => ({ ...p, remarks: e.target.value }))}
placeholder="비고 사항"
/>
</div>
</div>
{/* 검사유형 선택 */}
<div className="space-y-3">
<h4 className="text-sm font-semibold flex items-center gap-1.5"> </h4>
<h4 className="text-sm font-semibold"> </h4>
<div className="flex flex-wrap gap-4">
{INSPECTION_TYPES.map(({ key, label }) => (
<div key={key} className="flex items-center gap-1.5">
<Checkbox
checked={!!form[key]}
onCheckedChange={(v) => setForm(p => ({ ...p, [key]: !!v }))}
/>
<Checkbox checked={!!form[key]} onCheckedChange={(v) => setForm(p => ({ ...p, [key]: !!v }))} />
<Label className="text-sm cursor-pointer">{label}</Label>
</div>
))}
</div>
</div>
{/* 검사유형별 검사항목 설정 */}
{INSPECTION_TYPES.filter(t => !!form[t.key]).map(({ key, label }) => (
<div key={key} className="space-y-2">
<button
type="button"
className="w-full flex items-center gap-2 py-2 px-3 rounded-md border bg-muted/50 hover:bg-muted text-left"
onClick={() => toggleCollapse(key)}
>
<button type="button" className="w-full flex items-center gap-2 py-2 px-3 rounded-md border bg-muted/50 hover:bg-muted text-left" onClick={() => toggleCollapse(key)}>
<Badge variant="default" className="text-xs">{label}</Badge>
<span className="text-sm font-medium"> </span>
<ChevronDown className={cn("w-4 h-4 ml-auto transition-transform", collapsedTypes[key] && "-rotate-90")} />
<span className="text-xs text-muted-foreground ml-auto">{(inspectionRows[key] || []).length}</span>
</button>
{!collapsedTypes[key] && (
<div className="space-y-2 pl-1">
@@ -641,10 +681,10 @@ export default function ItemInspectionInfoPage() {
<TableHeader>
<TableRow className="bg-muted/50 hover:bg-muted/50">
<TableHead className="text-[10px] font-bold w-[170px]"> </TableHead>
<TableHead className="text-[10px] font-bold w-[130px]"> </TableHead>
<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-[100px]"></TableHead>
<TableHead className="text-[10px] font-bold"> ()</TableHead>
<TableHead className="text-[10px] font-bold"></TableHead>
<TableHead className="text-[10px] font-bold w-[40px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[36px]" />
</TableRow>
@@ -657,46 +697,20 @@ export default function ItemInspectionInfoPage() {
<TableCell className="p-1">
<Select value={row.inspection_standard_id || ""} onValueChange={(v) => updateInspRow(key, row.id, "inspection_standard_id", v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="검사기준 선택" /></SelectTrigger>
<SelectContent>
{getFilteredInspOptions(key).map(o => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
<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-8 text-xs bg-muted" value={row.inspection_detail} readOnly placeholder="자동" /></TableCell>
<TableCell className="p-1"><Input className="h-8 text-xs bg-muted" value={row.inspection_method} readOnly placeholder="자동" /></TableCell>
<TableCell className="p-1">
<Input className="h-8 text-xs bg-muted" value={row.inspection_detail} readOnly placeholder="검사기준 상세" />
<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 bg-muted" value={row.inspection_method} readOnly placeholder="선택" />
<Input className="h-8 text-xs" value={row.acceptance_criteria} onChange={(e) => updateInspRow(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) => updateInspRow(key, row.id, "is_required", !!v)} /></TableCell>
<TableCell className="p-1">
<Select value={row.apply_process || ""} onValueChange={(v) => updateInspRow(key, row.id, "apply_process", v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="공정 선택" /></SelectTrigger>
<SelectContent>
<SelectItem value="incoming"></SelectItem>
<SelectItem value="process"></SelectItem>
<SelectItem value="outgoing"></SelectItem>
<SelectItem value="final"></SelectItem>
</SelectContent>
</Select>
</TableCell>
<TableCell className="p-1">
<Input
className="h-8 text-xs"
value={row.acceptance_criteria}
onChange={(e) => updateInspRow(key, row.id, "acceptance_criteria", e.target.value)}
placeholder={row.inspection_standard_id ? "합격기준 입력" : "검사기준을 먼저 선택하세요"}
disabled={!row.inspection_standard_id}
/>
</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">
<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>
<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>
</TableRow>
))}
@@ -711,8 +725,7 @@ export default function ItemInspectionInfoPage() {
<DialogFooter>
<Button variant="outline" onClick={() => setModalOpen(false)}></Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Save className="w-4 h-4 mr-1" />}
{saving ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Save className="w-4 h-4 mr-1" />}
</Button>
</DialogFooter>
</>
@@ -720,14 +733,7 @@ export default function ItemInspectionInfoPage() {
</DialogContent>
</Dialog>
<TableSettingsModal
open={ts.open}
onOpenChange={ts.setOpen}
tableName={ts.tableName}
settingsId={ts.settingsId}
defaultVisibleKeys={ts.defaultVisibleKeys}
onSave={ts.applySettings}
/>
<TableSettingsModal open={ts.open} onOpenChange={ts.setOpen} tableName={ts.tableName} settingsId={ts.settingsId} defaultVisibleKeys={ts.defaultVisibleKeys} onSave={ts.applySettings} />
</div>
);
}
@@ -195,6 +195,12 @@ export default function CustomerManagementPage() {
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
} catch { /* skip */ }
}
for (const col of ["division", "unit", "material"]) {
try {
const res = await apiClient.get(`/table-categories/item_info/${col}/values`);
if (res.data?.success) optMap[`item_${col}`] = flatten(res.data.data || []);
} catch { /* skip */ }
}
setCategoryOptions(optMap);
const priceOpts: Record<string, { code: string; label: string }[]> = {};
@@ -709,7 +715,7 @@ export default function CustomerManagementPage() {
const handleCustomerSave = async () => {
if (!customerForm.customer_name) { toast.error("거래처명은 필수입니다."); return; }
if (!customerForm.status) { toast.error("상태는 필수입니다."); return; }
const errors = validateForm(customerForm, ["contact_phone", "email", "business_number"]);
const errors = validateForm(customerForm, ["business_number"]);
setFormErrors(errors);
if (Object.keys(errors).length > 0) {
toast.error("입력 형식을 확인해주세요.");
@@ -808,36 +814,56 @@ export default function CustomerManagementPage() {
};
// 품목 검색
const searchItems = async () => {
const searchItems = useCallback(async () => {
setItemSearchLoading(true);
try {
const filters: any[] = [
{ columnName: "division", operator: "contains", value: "CAT_ML8ZFVEL_1TOR" },
];
if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
const salesCode = categoryOptions["item_division"]?.find((o) => o.label === "영업관리")?.code;
const filters: any[] = salesCode
? [{ columnName: "division", operator: "contains", value: salesCode }]
: [];
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 500,
page: 1, size: 5000,
dataFilter: { enabled: true, filters },
autoFilter: true,
});
const allItems = res.data?.data?.data || res.data?.data?.rows || [];
setItemTotalCount(allItems.length);
const existingItemIds = new Set(priceItems.map((p: any) => p.item_id || p.item_number));
const kw = itemSearchKeyword.toLowerCase();
const seenNumbers = new Set<string>();
const deduped = allItems.filter((item: any) => {
if (existingItemIds.has(item.item_number) || existingItemIds.has(item.id)) return false;
if (item.item_number && seenNumbers.has(item.item_number)) return false;
if (item.item_number) seenNumbers.add(item.item_number);
if (kw) {
const name = (item.item_name || "").toLowerCase();
const code = (item.item_number || "").toLowerCase();
if (!name.includes(kw) && !code.includes(kw)) return false;
}
return true;
});
setItemSearchResults(deduped);
} catch { /* skip */ } finally { setItemSearchLoading(false); }
};
}, [itemSearchKeyword, priceItems]);
// 실시간 검색 (2글자 이상)
useEffect(() => {
if (!itemSelectOpen) return;
if (itemSearchKeyword.length > 0 && itemSearchKeyword.length < 2) return;
searchItems();
}, [itemSearchKeyword, itemSelectOpen]);
// 품목 선택 완료 → 상세 입력 모달로 전환
const goToItemDetail = () => {
const selected = itemSearchResults.filter((i) => itemCheckedIds.has(i.id));
if (selected.length === 0) { toast.error("품목을 선택해주세요."); return; }
const raw = itemSearchResults.filter((i) => itemCheckedIds.has(i.id));
if (raw.length === 0) { toast.error("품목을 선택해주세요."); return; }
const seenKeys = new Set<string>();
const selected = raw.filter((i) => {
const k = i.item_number || i.id;
if (seenKeys.has(k)) return false;
seenKeys.add(k);
return true;
});
setSelectedItemsForDetail(selected);
const mappings: typeof itemMappings = {};
const prices: typeof itemPrices = {};
@@ -969,6 +995,7 @@ export default function CustomerManagementPage() {
{ columnName: "customer_id", operator: "equals", value: selectedCustomer!.customer_code },
{ columnName: "item_id", operator: "equals", value: itemKey },
]}, autoFilter: true,
sort: { columnName: "created_date", order: "asc" },
});
const allMappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
mappingRows = allMappings
@@ -989,7 +1016,8 @@ export default function CustomerManagementPage() {
{ columnName: "item_id", operator: "equals", value: itemKey },
]}, autoFilter: true,
});
const allPriceData = priceRes.data?.data?.data || priceRes.data?.data?.rows || [];
const allPriceData = (priceRes.data?.data?.data || priceRes.data?.data?.rows || [])
.sort((a: any, b: any) => (a.start_date || "").localeCompare(b.start_date || ""));
priceRows = allPriceData.map((p: any) => ({
_id: `p_existing_${p.id}`,
start_date: p.start_date ? String(p.start_date).split("T")[0] : "",
@@ -1027,8 +1055,11 @@ export default function CustomerManagementPage() {
const isEditingExisting = !!editItemData;
setSaving(true);
try {
const processedKeys = new Set<string>();
for (const item of selectedItemsForDetail) {
const itemKey = item.item_number || item.id;
if (processedKeys.has(itemKey)) continue;
processedKeys.add(itemKey);
const mappingRows = itemMappings[itemKey] || [];
if (isEditingExisting && editItemData?.id) {
@@ -1041,6 +1072,7 @@ export default function CustomerManagementPage() {
{ columnName: "customer_id", operator: "equals", value: selectedCustomer.customer_code },
{ columnName: "item_id", operator: "equals", value: itemKey },
]}, autoFilter: true,
sort: { columnName: "created_date", order: "asc" },
});
existingMaps = existingMappings.data?.data?.data || existingMappings.data?.data?.rows || [];
} catch { /* skip */ }
@@ -1090,12 +1122,13 @@ export default function CustomerManagementPage() {
{ columnName: "item_id", operator: "equals", value: itemKey },
]}, autoFilter: true,
});
existingPriceRows = existingPrices.data?.data?.data || existingPrices.data?.data?.rows || [];
existingPriceRows = (existingPrices.data?.data?.data || existingPrices.data?.data?.rows || [])
.sort((a: any, b: any) => (a.start_date || "").localeCompare(b.start_date || ""));
} catch { /* skip */ }
// 단가 upsert
const priceRows = (itemPrices[itemKey] || []).filter((p) =>
(p.base_price && Number(p.base_price) > 0) || p.start_date
p.base_price || p.start_date || p.currency_code || p.base_price_type
);
const usedPriceIds = new Set<string>();
for (let pi = 0; pi < priceRows.length; pi++) {
@@ -1165,7 +1198,7 @@ export default function CustomerManagementPage() {
}
const priceRows = (itemPrices[itemKey] || []).filter((p) =>
(p.base_price && Number(p.base_price) > 0) || p.start_date
p.base_price || p.start_date || p.currency_code || p.base_price_type
);
for (const price of priceRows) {
await apiClient.post(`/table-management/tables/${PRICE_TABLE}/add`, {
@@ -1197,40 +1230,63 @@ export default function CustomerManagementPage() {
}
};
// 품목 매핑 삭제
// 품목 매핑 해제 — 선택한 품목의 모든 매핑 + 단가에서 customer_id를 null 처리
const handlePriceItemDelete = async () => {
if (priceCheckedIds.length === 0) return;
const ok = await confirm(`선택한 ${priceCheckedIds.length}개 품목 매핑제하시겠습니까?`, {
description: "관련된 단가 정보도 함께 삭제됩니다.",
variant: "destructive", confirmText: "제",
const ok = await confirm(`선택한 ${priceCheckedIds.length}개 품목의 연결제하시겠습니까?`, {
description: "해당 품목의 거래처 연결이 해제됩니다. (데이터는 유지)",
variant: "destructive", confirmText: "제",
});
if (!ok) return;
try {
for (const mappingId of priceCheckedIds) {
const itemIds = priceCheckedIds.map((mid) => {
const group = Object.values(priceGroups).find((g) => g.master.id === mid);
return group?.master.item_id || group?.master.item_number || "";
}).filter(Boolean);
for (const itemId of itemIds) {
// 해당 품목의 모든 매핑 조회 → customer_id null 처리
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [
{ columnName: "customer_id", operator: "equals", value: selectedCustomer!.customer_code },
{ columnName: "item_id", operator: "equals", value: itemId },
]}, autoFilter: true,
});
const allMappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
for (const m of allMappings) {
await apiClient.put(`/table-management/tables/${MAPPING_TABLE}/edit`, {
originalData: { id: m.id },
updatedData: { customer_id: null },
});
}
// 해당 품목의 모든 단가 조회 → customer_id null 처리
try {
const priceRes = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "mapping_id", operator: "equals", value: mappingId }] },
autoFilter: true,
dataFilter: { enabled: true, filters: [
{ columnName: "customer_id", operator: "equals", value: selectedCustomer!.customer_code },
{ columnName: "item_id", operator: "equals", value: itemId },
]}, autoFilter: true,
});
const prices = priceRes.data?.data?.data || priceRes.data?.data?.rows || [];
if (prices.length > 0) {
await apiClient.delete(`/table-management/tables/${PRICE_TABLE}/delete`, {
data: prices.map((p: any) => ({ id: p.id })),
for (const p of prices) {
await apiClient.put(`/table-management/tables/${PRICE_TABLE}/edit`, {
originalData: { id: p.id },
updatedData: { customer_id: null },
});
}
} catch { /* skip */ }
}
await apiClient.delete(`/table-management/tables/${MAPPING_TABLE}/delete`, {
data: priceCheckedIds.map((id) => ({ id })),
});
toast.success(`${priceCheckedIds.length}개 품목 매핑이 삭제되었습니다.`);
toast.success(`${priceCheckedIds.length}개 품목의 연결이 해제되었습니다.`);
setPriceCheckedIds([]);
const cid = selectedCustomerId;
setSelectedCustomerId(null);
setTimeout(() => setSelectedCustomerId(cid), 50);
} catch {
toast.error("제에 실패했습니다.");
toast.error("연결 해제에 실패했습니다.");
}
};
@@ -1821,35 +1877,6 @@ export default function CustomerManagementPage() {
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input
value={customerForm.contact_person || ""}
onChange={(e) => setCustomerForm((p) => ({ ...p, contact_person: e.target.value }))}
placeholder="거래처담당자"
className="h-9"
/>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input
value={customerForm.contact_phone || ""}
onChange={(e) => handleFormChange("contact_phone", e.target.value)}
placeholder="010-0000-0000"
className={cn("h-9", formErrors.contact_phone && "border-destructive")}
/>
{formErrors.contact_phone && <p className="text-xs text-destructive">{formErrors.contact_phone}</p>}
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input
value={customerForm.email || ""}
onChange={(e) => handleFormChange("email", e.target.value)}
placeholder="example@email.com"
className={cn("h-9", formErrors.email && "border-destructive")}
/>
{formErrors.email && <p className="text-xs text-destructive">{formErrors.email}</p>}
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input
@@ -2386,7 +2413,7 @@ export default function CustomerManagementPage() {
{itemSearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" /> </>}
</Button>
</div>
<div className="overflow-auto max-h-[350px] border rounded-lg">
<div className="overflow-auto h-[350px] border rounded-lg">
<Table noWrapper>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted h-10">
@@ -2433,8 +2460,8 @@ export default function CustomerManagementPage() {
</TableCell>
<TableCell className="text-sm">{item.item_name}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{item.size}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{item.material}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{item.unit}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{categoryOptions["item_material"]?.find((o) => o.code === item.material)?.label || item.material}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{categoryOptions["item_unit"]?.find((o) => o.code === item.unit)?.label || item.unit}</TableCell>
</TableRow>
))}
</TableBody>
@@ -2476,7 +2503,7 @@ export default function CustomerManagementPage() {
{/* 품목 헤더 */}
<div className="px-5 py-3 bg-muted/30 border-b">
<div className="font-bold">{idx + 1}. {item.item_name || itemKey}</div>
<div className="text-xs text-muted-foreground">{itemKey} | {item.size || ""} | {item.unit || ""}</div>
<div className="text-xs text-muted-foreground">{itemKey} | {item.size || ""} | {categoryOptions["item_unit"]?.find((o) => o.code === item.unit)?.label || item.unit || ""}</div>
</div>
<div className="flex gap-4 p-5 items-stretch">
@@ -359,7 +359,7 @@ export default function SalesOrderPage() {
} finally {
setLoading(false);
}
}, [searchFilters]);
}, [searchFilters, categoryOptions]);
useEffect(() => { fetchOrders(); }, [fetchOrders]);
@@ -517,29 +517,44 @@ export default function SalesOrderPage() {
}
};
// 삭제 (마스터 단위)
// 삭제 (선택한 디테일 삭제 → 디테일 0건인 마스터 자동 삭제)
const handleDelete = async () => {
if (checkedIds.length === 0) { toast.error("삭제할 수주를 선택해주세요."); return; }
const selectedItems = orders.filter((o) => checkedIds.includes(o.id));
const orderNos = [...new Set(selectedItems.map((o) => o.order_no))];
const ok = await confirm(`${orderNos.length}건의 수주를 삭제하시겠습니까?`, {
if (checkedIds.length === 0) { toast.error("삭제할 항목을 선택해주세요."); return; }
const ok = await confirm(`${checkedIds.length}건의 수주 항목을 삭제하시겠습니까?`, {
description: "삭제된 데이터는 복구할 수 없습니다.",
variant: "destructive",
confirmText: "삭제",
});
if (!ok) return;
try {
// 1. 선택한 디테일 삭제
await apiClient.delete(`/table-management/tables/${DETAIL_TABLE}/delete`, {
data: checkedIds.map((id) => ({ id })),
});
// 2. 영향받는 수주번호의 잔여 디테일 확인 → 0건이면 마스터도 삭제
const selectedItems = orders.filter((o) => checkedIds.includes(o.id));
const orderNos = [...new Set(selectedItems.map((o) => o.order_no))];
for (const orderNo of orderNos) {
const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, {
const remainRes = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
page: 1, size: 1,
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] },
autoFilter: true,
});
const masters = masterRes.data?.data?.data || masterRes.data?.data?.rows || [];
if (masters.length > 0) {
await apiClient.delete(`/table-management/tables/${MASTER_TABLE}/delete`, {
data: masters.map((m: any) => ({ id: m.id })),
const remaining = remainRes.data?.data?.data || remainRes.data?.data?.rows || [];
if (remaining.length === 0) {
// 디테일 0건 → 마스터 삭제
const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, {
page: 1, size: 1,
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] },
autoFilter: true,
});
const masters = masterRes.data?.data?.data || masterRes.data?.data?.rows || [];
if (masters.length > 0) {
await apiClient.delete(`/table-management/tables/${MASTER_TABLE}/delete`, {
data: masters.map((m: any) => ({ id: m.id })),
});
}
}
}
toast.success("삭제되었습니다.");
@@ -918,7 +933,7 @@ export default function SalesOrderPage() {
{/* 데이터 테이블 (플랫 리스트) */}
<div className="flex-1 overflow-hidden rounded-lg border border-border bg-card">
<div className="h-full overflow-auto">
<Table style={{ minWidth: "1500px" }}>
<Table noWrapper style={{ minWidth: "1500px" }}>
<colgroup>
<col style={{ width: "40px" }} />
<col style={{ width: "140px" }} />
@@ -1107,6 +1122,7 @@ export default function SalesOrderPage() {
<Select value={masterForm.input_mode || ""} onValueChange={(v) => {
setMasterForm((p) => {
const next = { ...p, input_mode: v };
// 입력방식 변경 시 거래처 관련 값 초기화
delete next.partner_id;
delete next.delivery_partner_id;
delete next.delivery_address;
File diff suppressed because it is too large Load Diff
@@ -38,6 +38,30 @@ const fmtDate = (v: any) => {
return m ? `${m[1]}-${m[2]}-${m[3]}` : s.substring(0, 10);
};
/** remark가 JSON이면 사람이 읽을 수 있는 텍스트로 변환 */
const parseRemark = (remark: string | null | undefined): string => {
if (!remark) return "";
const trimmed = remark.trim();
if (!trimmed.startsWith("{")) return trimmed;
try {
const d = JSON.parse(trimmed);
switch (d.type) {
case "move":
return `창고이동 (${d.from_warehouse}${d.to_warehouse})`;
case "adjust":
return `재고조정 (${d.reason || "사유 없음"}, ${d.system_qty}${d.actual_qty}, 차이:${d.diff >= 0 ? "+" : ""}${d.diff})`;
case "confirm":
return `재고확인 (${d.reason || "이상없음"})`;
case "process_inbound":
return "공정입고";
default:
return d.reason || d.memo || trimmed;
}
} catch {
return trimmed;
}
};
export default function InboundOutboundPage() {
const { user } = useAuth();
@@ -62,7 +86,6 @@ export default function InboundOutboundPage() {
try {
const filters: any[] = [];
if (typeFilter !== "all") filters.push({ columnName: "transaction_type", operator: "equals", value: typeFilter });
if (categoryFilter !== "all") filters.push({ columnName: "remark", operator: "equals", value: categoryFilter });
for (const f of searchFilters) {
if (f.value) filters.push({ columnName: f.columnName, operator: f.operator, value: f.value });
@@ -81,6 +104,21 @@ export default function InboundOutboundPage() {
const itemCodes = [...new Set(rows.map((r: any) => r.item_code).filter(Boolean))];
if (itemCodes.length > 0) {
try {
// 단위 카테고리 코드→라벨 매핑 로드
let unitLabelMap: Record<string, string> = {};
try {
const catRes = await apiClient.get("/table-categories/item_info/unit/values");
if (catRes.data?.success && catRes.data.data?.length > 0) {
const flatten = (vals: any[]) => {
for (const v of vals) {
unitLabelMap[v.valueCode] = v.valueLabel;
if (v.children?.length) flatten(v.children);
}
};
flatten(catRes.data.data);
}
} catch { /* skip */ }
const itemRes = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: itemCodes.length + 10,
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: itemCodes }] },
@@ -89,7 +127,8 @@ export default function InboundOutboundPage() {
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
const map: Record<string, { item_name: string; unit: string }> = {};
for (const i of items) {
if (!map[i.item_number]) map[i.item_number] = { item_name: i.item_name || "", unit: i.unit || "" };
const rawUnit = i.unit || "";
if (!map[i.item_number]) map[i.item_number] = { item_name: i.item_name || "", unit: unitLabelMap[rawUnit] || rawUnit };
}
setItemMap(map);
} catch { /* skip */ }
@@ -124,7 +163,7 @@ export default function InboundOutboundPage() {
} finally {
setLoading(false);
}
}, [searchFilters, typeFilter, categoryFilter]);
}, [searchFilters, typeFilter]);
useEffect(() => { fetchData(); }, [fetchData]);
@@ -132,20 +171,26 @@ export default function InboundOutboundPage() {
const categoryOptions = useMemo(() => {
const set = new Set<string>();
data.forEach((r) => { if (r.remark) set.add(r.remark); });
data.forEach((r) => { if (r.remark) set.add(parseRemark(r.remark)); });
return Array.from(set).sort();
}, [data]);
// ════════ 그룹핑 ════════
// 카테고리 필터 (클라이언트)
const filteredData = useMemo(() => {
if (categoryFilter === "all") return data;
return data.filter((r) => parseRemark(r.remark) === categoryFilter);
}, [data, categoryFilter]);
const groupedData = useMemo(() => {
if (groupBy === "none") return data;
if (groupBy === "none") return filteredData;
const groups = new Map<string, any[]>();
for (const row of data) {
for (const row of filteredData) {
let key: string;
switch (groupBy) {
case "transaction_type": key = row.transaction_type || "미지정"; break;
case "remark": key = row.remark || "미지정"; break;
case "remark": key = parseRemark(row.remark) || "미지정"; break;
case "warehouse_code": key = warehouseMap[row.warehouse_code] || row.warehouse_code || "미지정"; break;
case "item_code": key = `${row.item_code} ${itemMap[row.item_code]?.item_name || ""}`.trim() || "미지정"; break;
default: key = row[groupBy] || "미지정";
@@ -191,7 +236,7 @@ export default function InboundOutboundPage() {
const rows = data.map((r, i) => ({
No: i + 1,
입출고구분: r.transaction_type || "",
카테고리: r.remark || "",
카테고리: parseRemark(r.remark),
처리일자: fmtDate(r.transaction_date),
창고: warehouseMap[r.warehouse_code] || r.warehouse_code || "",
위치: r.location_code || "",
@@ -252,7 +297,7 @@ export default function InboundOutboundPage() {
<div className="flex items-center gap-2">
<Package className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-semibold"> </span>
<Badge variant="secondary" className="font-mono text-xs">{data.length}</Badge>
<Badge variant="secondary" className="font-mono text-xs">{filteredData.length}</Badge>
</div>
<div className="flex items-center gap-2">
<Select value={groupBy} onValueChange={(v) => { setGroupBy(v); setExpandedGroups(new Set()); }}>
@@ -353,7 +398,7 @@ export default function InboundOutboundPage() {
{row.transaction_type}
</span>
</TableCell>
<TableCell className="text-center text-[12px]">{row.remark || "-"}</TableCell>
<TableCell className="text-center text-[12px]">{parseRemark(row.remark) || "-"}</TableCell>
<TableCell className="text-center text-[12px]">{fmtDate(row.transaction_date)}</TableCell>
<TableCell className="text-[12px]">{warehouseMap[row.warehouse_code] || row.warehouse_code || "-"}</TableCell>
<TableCell className="text-center text-[12px]">{row.location_code || "-"}</TableCell>
@@ -230,11 +230,10 @@ function flattenCategories(items: any[]): { value: string; label: string }[] {
const result: { value: string; label: string }[] = [];
function walk(arr: any[]) {
for (const item of arr) {
if (item.value || item.name) {
result.push({
value: item.value || item.name,
label: item.label || item.name || item.value,
});
const val = item.valueCode || item.value || item.name;
const lbl = item.valueLabel || item.label || item.name || val;
if (val) {
result.push({ value: val, label: lbl });
}
if (item.children?.length) walk(item.children);
}
@@ -140,31 +140,47 @@ export default function InventoryStatusPage() {
Record<string, { code: string; label: string }[]>
>({});
// 카테고리 로드
// 사용자 맵 (writer → 이름)
const [userMap, setUserMap] = useState<Record<string, string>>({});
// 카테고리 + 사용자 로드
useEffect(() => {
const load = async () => {
const optMap: Record<string, { code: string; label: string }[]> = {};
const flatten = (vals: any[]): { code: string; label: string }[] => {
const result: { code: string; label: string }[] = [];
for (const v of vals) {
result.push({ code: v.valueCode, label: v.valueLabel });
result.push({ code: v.valueCode || v.value_code || v.code, label: v.valueLabel || v.value_label || v.label });
if (v.children?.length) result.push(...flatten(v.children));
}
return result;
};
for (const col of ["status", "unit"]) {
// inventory_stock 카테고리
for (const col of ["status"]) {
try {
const res = await apiClient.get(
`/table-categories/${STOCK_TABLE}/${col}/values`
);
const res = await apiClient.get(`/table-categories/${STOCK_TABLE}/${col}/values`);
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
} catch {
/* skip */
}
} catch { /* skip */ }
}
// item_info 단위 카테고리
try {
const res = await apiClient.get("/table-categories/item_info/unit/values?filterCompanyCode=COMPANY_16");
if (res.data?.success) optMap["item_unit"] = flatten(res.data.data || []);
} catch { /* skip */ }
setCategoryOptions(optMap);
};
load();
// 사용자 목록 로드
apiClient.get("/admin/users", { params: { size: 9999 } }).then((res) => {
const users = res.data?.data || res.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;
if (id) map[id] = name;
}
setUserMap(map);
}).catch(() => {});
}, []);
// 재고 목록 조회
@@ -193,10 +209,11 @@ export default function InventoryStatusPage() {
};
const data = raw.map((r: any) => {
const itemInfo = itemMap.get(r.item_code) as any;
const rawUnit = itemInfo?.unit || r.unit || "";
return {
...r,
item_name: itemInfo?.name || "",
unit: itemInfo?.unit || resolve("unit", r.unit),
unit: resolve("item_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),
@@ -332,6 +349,12 @@ export default function InventoryStatusPage() {
),
};
}
if (col.key === "warehouse_code") {
return {
...base,
render: (_val: any, row: any) => row.warehouse_name || row.warehouse_code || "",
};
}
if (col.key === "safety_qty") {
return {
...base,
@@ -613,7 +636,7 @@ export default function InventoryStatusPage() {
<TableCell className="truncate max-w-[150px]">
{h.remark || h.reason || ""}
</TableCell>
<TableCell>{h.writer || h.created_by || ""}</TableCell>
<TableCell>{userMap[h.writer] || userMap[h.created_by] || h.writer || h.created_by || ""}</TableCell>
</TableRow>
))}
</TableBody>
@@ -43,6 +43,7 @@ import {
} from "@/lib/api/materialStatus";
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { apiClient } from "@/lib/api/client";
const GRID_COLUMNS = [
{ key: "plan_no", label: "계획번호" },
@@ -60,26 +61,31 @@ const formatDate = (date: Date) => {
return `${y}-${m}-${d}`;
};
const getStatusLabel = (status: string) => {
const map: Record<string, string> = {
planned: "계획",
in_progress: "진행중",
completed: "완료",
pending: "대기",
cancelled: "취소",
};
return map[status] || status;
const FALLBACK_STATUS_MAP: Record<string, string> = {
planned: "계획",
in_progress: "진행중",
completed: "완료",
pending: "대기",
cancelled: "취소",
};
const getStatusStyle = (status: string) => {
const map: Record<string, string> = {
planned: "bg-secondary text-secondary-foreground border-border",
pending: "bg-secondary text-secondary-foreground border-border",
in_progress: "bg-primary/10 text-primary border-primary/20",
completed: "bg-accent text-accent-foreground border-accent/50",
cancelled: "bg-muted text-muted-foreground border-border",
};
return map[status] || "bg-muted text-muted-foreground border-border";
const STATUS_STYLE_MAP: Record<string, string> = {
planned: "bg-secondary text-secondary-foreground border-border",
pending: "bg-secondary text-secondary-foreground border-border",
in_progress: "bg-primary/10 text-primary border-primary/20",
completed: "bg-accent text-accent-foreground border-accent/50",
cancelled: "bg-muted text-muted-foreground border-border",
};
// 카테고리 라벨 기반으로 스타일 매칭
const LABEL_STYLE_MAP: Record<string, string> = {
"일반": "bg-secondary text-secondary-foreground border-border",
"긴급": "bg-destructive/10 text-destructive border-destructive/20",
"계획": "bg-secondary text-secondary-foreground border-border",
"대기": "bg-secondary text-secondary-foreground border-border",
"진행중": "bg-primary/10 text-primary border-primary/20",
"완료": "bg-accent text-accent-foreground border-accent/50",
"취소": "bg-muted text-muted-foreground border-border",
};
export default function MaterialStatusPage() {
@@ -93,6 +99,32 @@ export default function MaterialStatusPage() {
const [searchItemCode, setSearchItemCode] = useState("");
const [searchItemName, setSearchItemName] = useState("");
// 카테고리 코드→라벨 매핑
const [statusMap, setStatusMap] = useState<Record<string, string>>({});
useEffect(() => {
(async () => {
try {
const res = await apiClient.get("/table-categories/work_instruction/status/values");
if (res.data?.success && res.data.data?.length > 0) {
const map: Record<string, string> = {};
const flatten = (vals: any[]) => {
for (const v of vals) {
map[v.valueCode] = v.valueLabel;
if (v.children?.length) flatten(v.children);
}
};
flatten(res.data.data);
setStatusMap(map);
}
} catch { /* ignore */ }
})();
}, []);
const getStatusLabel = useCallback((status: string) => {
return statusMap[status] || FALLBACK_STATUS_MAP[status] || status;
}, [statusMap]);
const [workOrders, setWorkOrders] = useState<WorkOrder[]>([]);
const [workOrdersLoading, setWorkOrdersLoading] = useState(false);
const [checkedWoIds, setCheckedWoIds] = useState<string[]>([]);
@@ -369,7 +401,7 @@ export default function MaterialStatusPage() {
<span
className={cn(
"ml-auto rounded-md border px-2 py-0.5 text-[11px] font-semibold",
getStatusStyle(wo.status)
STATUS_STYLE_MAP[wo.status] || LABEL_STYLE_MAP[getStatusLabel(wo.status)] || "bg-muted text-muted-foreground border-border"
)}
>
{getStatusLabel(wo.status)}
@@ -1,6 +1,7 @@
"use client";
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { apiClient } from "@/lib/api/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
@@ -311,6 +312,42 @@ export default function OutboundPage() {
const [editMode, setEditMode] = useState(false);
const [editItemIds, setEditItemIds] = useState<string[]>([]);
// 카테고리 코드→라벨 매핑 (재질, 단위)
const [catMap, setCatMap] = useState<Record<string, Record<string, 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 || v.value_code || v.code, label: v.valueLabel || v.value_label || v.label });
if (v.children?.length) result.push(...flatten(v.children));
}
return result;
};
const map: Record<string, Record<string, string>> = {};
Promise.all([
...["material", "unit"].map(async (col) => {
try {
const res = await apiClient.get(`/table-categories/item_info/${col}/values?filterCompanyCode=COMPANY_7`);
const items = flatten(res.data?.data || []);
map[col] = {};
for (const item of items) map[col][item.code] = item.label;
} catch { /* skip */ }
}),
(async () => {
try {
const res = await apiClient.get(`/table-categories/outbound_mng/outbound_type/values`);
const items = flatten(res.data?.data || []);
map["outbound_type"] = {};
for (const item of items) map["outbound_type"][item.code] = item.label;
} catch { /* skip */ }
})(),
]).then(() => setCatMap(map));
}, []);
const resolveCat = useCallback((col: string, code: string) => {
if (!code) return "";
return catMap[col]?.[code] || code;
}, [catMap]);
// 소스 데이터
const [sourceKeyword, setSourceKeyword] = useState("");
const [sourceLoading, setSourceLoading] = useState(false);
@@ -871,8 +908,9 @@ export default function OutboundPage() {
fetchList();
}
}
} catch {
toast.error(editMode ? "수정에 실패했어요" : "출고 등록 중 오류가 발생했습니다.");
} catch (err: any) {
const msg = err?.response?.data?.message || (editMode ? "수정에 실패했어요" : "출고 등록 중 오류가 발생했습니다.");
toast.error(msg);
} finally {
setSaving(false);
}
@@ -1085,7 +1123,7 @@ export default function OutboundPage() {
case "outbound_type": return (
<TableCell key={col.key}>
<Badge variant="outline" className={cn("text-[11px]", getTypeColor(master.outbound_type))}>
{master.outbound_type || "-"}
{resolveCat("outbound_type", master.outbound_type) || "-"}
</Badge>
</TableCell>
);
@@ -1337,6 +1375,7 @@ export default function OutboundPage() {
data={pagedItems}
onAdd={addItem}
selectedKeys={selectedItems.map((s) => s.key)}
resolveCat={resolveCat}
/>
)}
</div>
@@ -1502,7 +1541,7 @@ export default function OutboundPage() {
<TableCell className="p-2 text-center">{idx + 1}</TableCell>
<TableCell className="p-2">
<Badge variant="outline" className={cn("text-[10px]", getTypeColor(item.outbound_type))}>
{item.outbound_type || "-"}
{resolveCat("outbound_type", item.outbound_type) || "-"}
</Badge>
</TableCell>
<TableCell className="max-w-[180px] p-2">
@@ -1772,10 +1811,12 @@ function SourceItemTable({
data,
onAdd,
selectedKeys,
resolveCat,
}: {
data: ItemSource[];
onAdd: (item: ItemSource) => void;
selectedKeys: string[];
resolveCat: (col: string, code: string) => string;
}) {
if (data.length === 0) {
return (
@@ -1826,8 +1867,8 @@ function SourceItemTable({
</div>
</TableCell>
<TableCell className="max-w-[100px] truncate p-2" title={item.spec || "-"}>{item.spec || "-"}</TableCell>
<TableCell className="max-w-[100px] truncate p-2" title={item.material || "-"}>{item.material || "-"}</TableCell>
<TableCell className="p-2">{item.unit || "-"}</TableCell>
<TableCell className="max-w-[100px] truncate p-2" title={resolveCat("material", item.material) || "-"}>{resolveCat("material", item.material) || "-"}</TableCell>
<TableCell className="p-2">{resolveCat("unit", item.unit) || "-"}</TableCell>
<TableCell className="p-2 text-right">
{Number(item.standard_price).toLocaleString()}
</TableCell>
@@ -353,17 +353,45 @@ export default function ReceivingPage() {
// 구매관리 division 코드 (라벨 기준 조회)
const [purchaseDivisionCode, setPurchaseDivisionCode] = useState<string>("");
// 카테고리 코드→라벨 매핑 (재질, 단위)
const [catMap, setCatMap] = useState<Record<string, Record<string, string>>>({});
// 구매관리 division 코드 로드
// 구매관리 division 코드 + 재질/단위 카테고리 로드
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 || v.value_code || v.code, label: v.valueLabel || v.value_label || v.label });
if (v.children?.length) result.push(...flatten(v.children));
}
return result;
};
// division 카테고리에서 "구매관리" 라벨의 코드 조회
apiClient.get("/table-categories/item_info/division/values").then((res) => {
const vals = res.data?.data || [];
const found = vals.find((v: any) => (v.value_label || v.label) === "구매관리");
const found = vals.find((v: any) => (v.valueLabel || v.value_label || v.label) === "구매관리");
if (found) setPurchaseDivisionCode(found.value_code || found.code);
}).catch(() => {});
// 재질, 단위 카테고리
const map: Record<string, Record<string, string>> = {};
Promise.all(
["material", "unit"].map(async (col) => {
try {
const res = await apiClient.get(`/table-categories/item_info/${col}/values?filterCompanyCode=COMPANY_16`);
const items = flatten(res.data?.data || []);
map[col] = {};
for (const item of items) map[col][item.code] = item.label;
} catch { /* skip */ }
})
).then(() => setCatMap(map));
}, []);
// 카테고리 코드→라벨 변환
const resolveCat = useCallback((col: string, code: string) => {
if (!code) return "";
return catMap[col]?.[code] || code;
}, [catMap]);
// 목록 조회
const fetchList = useCallback(async () => {
setLoading(true);
@@ -777,12 +805,16 @@ export default function ReceivingPage() {
return;
}
if (!modalWarehouse) {
toast.error("창고를 선택해주세요.");
return;
}
setSaving(true);
try {
const res = await createReceiving({
inbound_number: modalInboundNo,
inbound_date: modalInboundDate,
warehouse_code: modalWarehouse || undefined,
warehouse_code: modalWarehouse,
location_code: modalLocation || undefined,
inspector: modalInspector || undefined,
manager: modalManager || undefined,
@@ -1283,6 +1315,7 @@ export default function ReceivingPage() {
data={items}
onAdd={addItem}
selectedKeys={selectedItems.map((s) => s.key)}
resolveCat={resolveCat}
/>
)}
</div>
@@ -1738,10 +1771,12 @@ function SourceItemTable({
data,
onAdd,
selectedKeys,
resolveCat,
}: {
data: ItemSource[];
onAdd: (item: ItemSource) => void;
selectedKeys: string[];
resolveCat: (col: string, code: string) => string;
}) {
if (data.length === 0) {
return (
@@ -1794,8 +1829,8 @@ function SourceItemTable({
</div>
</TableCell>
<TableCell className="max-w-[100px] truncate p-2" title={item.spec || "-"}>{item.spec || "-"}</TableCell>
<TableCell className="max-w-[100px] truncate p-2" title={item.material || "-"}>{item.material || "-"}</TableCell>
<TableCell className="p-2">{item.unit || "-"}</TableCell>
<TableCell className="max-w-[100px] truncate p-2" title={resolveCat("material", item.material) || "-"}>{resolveCat("material", item.material) || "-"}</TableCell>
<TableCell className="p-2">{resolveCat("unit", item.unit) || "-"}</TableCell>
<TableCell className="p-2 text-right">
{Number(item.standard_price).toLocaleString()}
</TableCell>
@@ -26,6 +26,7 @@ import {
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
import { ImageUpload } from "@/components/common/ImageUpload";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
@@ -102,8 +103,8 @@ export default function MoldInfoPage() {
const [numberingRuleId, setNumberingRuleId] = useState<string | null>(null);
const [previewCode, setPreviewCode] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
// moldImagePreview는 상세 표시용으로 유지 (ImageUpload는 모달에서만 사용)
const [moldImagePreview, setMoldImagePreview] = useState<string | null>(null);
const moldImageRef = React.useRef<HTMLInputElement>(null);
const [serialModalOpen, setSerialModalOpen] = useState(false);
const [serialForm, setSerialForm] = useState<Record<string, any>>({});
@@ -240,17 +241,7 @@ export default function MoldInfoPage() {
setMoldModalOpen(true);
};
const handleMoldImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onloadend = () => {
const result = reader.result as string;
setMoldImagePreview(result);
setMoldForm((prev) => ({ ...prev, image_path: result }));
};
reader.readAsDataURL(file);
};
// 이미지 업로드는 ImageUpload 컴포넌트가 처리 → objid를 image_path에 저장
const handleSaveMold = async () => {
// 등록 모드에서 채번이 없으면 수동 입력 필수
@@ -460,9 +451,10 @@ export default function MoldInfoPage() {
)}>
{mold.image_path ? (
<img
src={mold.image_path}
src={String(mold.image_path).startsWith("http") || String(mold.image_path).startsWith("/") ? mold.image_path : `/api/files/preview/${mold.image_path}`}
alt={mold.mold_name}
className="w-full h-full object-cover"
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
) : (
<Box className="w-8 h-8 text-muted-foreground/50" />
@@ -662,9 +654,10 @@ export default function MoldInfoPage() {
<div className="w-[200px] min-w-[200px] h-[200px] bg-muted rounded-lg flex items-center justify-center border">
{selectedMold.image_path ? (
<img
src={selectedMold.image_path}
src={String(selectedMold.image_path).startsWith("http") || String(selectedMold.image_path).startsWith("/") ? selectedMold.image_path : `/api/files/preview/${selectedMold.image_path}`}
alt={selectedMold.mold_name}
className="w-full h-full object-cover rounded-lg"
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
) : (
<ImageIcon className="w-16 h-16 text-muted-foreground/40" />
@@ -1143,39 +1136,13 @@ export default function MoldInfoPage() {
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs"> </Label>
<div className="relative w-full h-32 rounded-lg border-2 border-dashed border-border bg-muted/50 flex items-center justify-center overflow-hidden group">
{moldImagePreview ? (
<>
<img src={moldImagePreview} alt="금형 이미지" className="w-full h-full object-contain" />
<div className="absolute inset-0 bg-foreground/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
<Button size="sm" variant="secondary" className="h-7 text-xs" type="button" onClick={() => moldImageRef.current?.click()}>
<Upload className="w-3 h-3 mr-1" />
</Button>
<Button size="sm" variant="destructive" className="h-7 text-xs" type="button" onClick={() => {
setMoldImagePreview(null);
setMoldForm((prev) => ({ ...prev, image_path: null }));
}}>
<XIcon className="w-3 h-3" />
</Button>
</div>
</>
) : (
<button
type="button"
className="flex flex-col items-center gap-1.5 text-muted-foreground"
onClick={() => moldImageRef.current?.click()}
>
<ImageIcon className="w-8 h-8" />
<span className="text-xs"> </span>
</button>
)}
</div>
<input
ref={moldImageRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleMoldImageUpload}
<ImageUpload
value={moldForm.image_path || ""}
onChange={(v) => setMoldForm((prev) => ({ ...prev, image_path: v }))}
tableName="mold_mng"
recordId={moldForm.id || ""}
columnName="image_path"
height="h-32"
/>
</div>
</div>
@@ -142,7 +142,7 @@ export default function SubcontractorItemPage() {
try {
const filters: any[] = [];
if (outsourcingDivisionCode) {
filters.push({ columnName: "division", operator: "equals", value: outsourcingDivisionCode });
filters.push({ columnName: "division", operator: "contains", value: outsourcingDivisionCode });
}
if (searchKeyword) {
filters.push({ columnName: "item_name", operator: "contains", value: searchKeyword });
@@ -141,6 +141,12 @@ export default function SubcontractorManagementPage() {
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
} catch { /* skip */ }
}
for (const col of ["division", "unit", "material"]) {
try {
const res = await apiClient.get(`/table-categories/item_info/${col}/values`);
if (res.data?.success) optMap[`item_${col}`] = flatten(res.data.data || []);
} catch { /* skip */ }
}
setCategoryOptions(optMap);
const priceOpts: Record<string, { code: string; label: string }[]> = {};
@@ -413,11 +419,12 @@ export default function SubcontractorManagementPage() {
});
const allItems = res.data?.data?.data || res.data?.data?.rows || [];
const existingItemIds = new Set(priceItems.map((p: any) => p.item_id || p.item_number));
const OUTSOURCING_CODE = "CAT_MMDJB7R4_TO3T";
const outsourcingCode = categoryOptions["item_division"]?.find((o) => o.label === "외주관리")?.code;
setItemSearchResults(allItems.filter((item: any) => {
if (existingItemIds.has(item.item_number) || existingItemIds.has(item.id)) return false;
if (!outsourcingCode) return true;
const div = item.division || "";
return div.includes(OUTSOURCING_CODE) || div.includes("외주");
return div.includes(outsourcingCode);
}));
} catch { /* skip */ } finally { setItemSearchLoading(false); }
};
@@ -1176,8 +1183,8 @@ export default function SubcontractorManagementPage() {
<TableCell className="text-[13px]">{item.item_number}</TableCell>
<TableCell className="text-[13px]">{item.item_name}</TableCell>
<TableCell className="text-[13px]">{item.size}</TableCell>
<TableCell className="text-[13px]">{item.material}</TableCell>
<TableCell className="text-[13px]">{item.unit}</TableCell>
<TableCell className="text-[13px]">{categoryOptions["item_material"]?.find((o) => o.code === item.material)?.label || item.material}</TableCell>
<TableCell className="text-[13px]">{categoryOptions["item_unit"]?.find((o) => o.code === item.unit)?.label || item.unit}</TableCell>
</TableRow>
))}
</TableBody>
@@ -1221,7 +1228,7 @@ export default function SubcontractorManagementPage() {
{/* 품목 헤더 */}
<div className="px-5 py-3 bg-muted/30 border-b">
<div className="font-semibold text-sm">{idx + 1}. {item.item_name || itemKey}</div>
<div className="text-xs text-muted-foreground">{itemKey} | {item.size || ""} | {item.unit || ""}</div>
<div className="text-xs text-muted-foreground">{itemKey} | {item.size || ""} | {categoryOptions["item_unit"]?.find((o) => o.code === item.unit)?.label || item.unit || ""}</div>
</div>
<div className="flex gap-4 p-4">
@@ -309,6 +309,8 @@ export default function BomManagementPage() {
// 카테고리 옵션
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
// 사용자 맵 (userId → userName)
const [userMap, setUserMap] = useState<Record<string, string>>({});
// 트리 편집 state
const [editingTree, setEditingTree] = useState<TreeNode[]>([]);
@@ -434,6 +436,16 @@ export default function BomManagementPage() {
} catch {}
};
loadCategories();
// 사용자 목록 로드
apiClient.get("/admin/users", { params: { size: 9999 } }).then((res) => {
const users = res.data?.data || res.data || [];
const map: Record<string, string> = {};
for (const u of users) {
const id = u.userId || u.user_id || u.id;
if (id) map[id] = u.userName || u.user_name || u.name || id;
}
setUserMap(map);
}).catch(() => {});
}, []);
// ─── BOM 상세 로드 ────────────────────────────
@@ -1804,7 +1816,7 @@ export default function BomManagementPage() {
{/* 비고 */}
<td className="overflow-hidden text-ellipsis whitespace-nowrap px-3 py-2">{isVirtualRoot ? "-" : (node.remark || "-")}</td>
{/* 작성자 */}
<td className="overflow-hidden text-ellipsis whitespace-nowrap px-3 py-2 text-muted-foreground">{isVirtualRoot ? "-" : (node.writer || "-")}</td>
<td className="overflow-hidden text-ellipsis whitespace-nowrap px-3 py-2 text-muted-foreground">{isVirtualRoot ? "-" : (userMap[node.writer] || node.writer || "-")}</td>
{/* 수정일시 */}
<td className="overflow-hidden text-ellipsis whitespace-nowrap px-3 py-2 text-muted-foreground">
{isVirtualRoot ? "-" : (node.updated_date ? new Date(node.updated_date).toLocaleString("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" }) : "-")}
@@ -1052,17 +1052,17 @@ export default function ProductionPlanManagementPage() {
};
return (
<Table style={{ tableLayout: "fixed" }}>
<Table style={{ minWidth: "900px" }}>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[30px]">
<TableHead style={{ width: "30px", minWidth: "30px" }}>
<Checkbox checked={selectedItemGroups.size === orderItems.length && orderItems.length > 0} onCheckedChange={(c) => toggleAllItemGroups(!!c)} className="h-4 w-4" />
</TableHead>
<TableHead className="w-[40px]" />
<TableHead style={{ width: "40px", minWidth: "40px" }} />
<TableHead style={{ width: "140px", minWidth: "140px" }} className="text-xs font-bold whitespace-nowrap text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead style={{ width: "140px", minWidth: "140px" }} className="text-xs font-bold whitespace-nowrap text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
{ts.visibleColumns.map((col) => (
<TableHead key={col.key} style={ts.thStyle(col.key)} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">
<TableHead key={col.key} style={{ ...ts.thStyle(col.key), minWidth: "80px" }} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">
{col.label}
</TableHead>
))}
@@ -349,7 +349,14 @@ export default function WorkInstructionPage() {
const t = Number(o.total_qty || 0), c = Number(o.completed_qty || 0);
return t === 0 ? 0 : Math.min(100, Math.round((c / t) * 100));
};
const getProgressLabel = (o: any) => { const p = getProgress(o); if (o.progress_status) return o.progress_status; if (p >= 100) return "완료"; if (p > 0) return "진행중"; return "대기"; };
const getProgressLabel = (o: any) => {
const p = getProgress(o);
if (o.progress_status) {
const map: Record<string, string> = { completed: "완료", in_progress: "진행중", pending: "대기" };
return map[o.progress_status] || o.progress_status;
}
if (p >= 100) return "완료"; if (p > 0) return "진행중"; return "대기";
};
const totalRegPages = Math.max(1, Math.ceil(regTotalCount / regPageSize));
const getDisplayNo = (o: any) => {
@@ -15,9 +15,12 @@ import {
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download,
ClipboardList, Pencil, Search, X, Package, ChevronDown,
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight,
Settings2,
Settings2, GripVertical,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { DndContext, PointerSensor, closestCenter, useSensor, useSensors, type DragEndEvent } from "@dnd-kit/core";
import { SortableContext, arrayMove, horizontalListSortingStrategy, useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { apiClient } from "@/lib/api/client";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
@@ -84,6 +87,49 @@ const GRID_COLUMNS_CONFIG = [
{ key: "memo", label: "메모" },
];
const MODAL_DETAIL_COLUMNS = [
{ key: "item_code", label: "품번", width: "w-[120px]" },
{ key: "item_name", label: "품명", width: "w-[120px]" },
{ key: "supplier", label: "공급업체", width: "w-[150px]" },
{ key: "spec", label: "규격", width: "w-[80px]" },
{ key: "unit", label: "단위", width: "w-[60px]" },
{ key: "order_qty", label: "발주수량", width: "w-[90px]" },
{ key: "received_qty", label: "입고수량", width: "w-[90px]" },
{ key: "remain_qty", label: "잔량", width: "w-[80px]" },
{ key: "unit_price", label: "단가", width: "w-[100px]" },
{ key: "amount", label: "금액", width: "w-[100px]" },
{ key: "due_date", label: "납기일", width: "w-[160px]" },
{ key: "memo", label: "메모", width: "w-[120px]" },
];
const MODAL_COL_ORDER_KEY = "purchase_order_modal_col_order_c16";
function SortableModalHead({ col }: { col: { key: string; label: string; width: string } }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: col.key });
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<TableHead
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className={cn(
col.width,
"text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none cursor-grab active:cursor-grabbing hover:bg-muted-foreground/5 transition-colors"
)}
>
<div className="inline-flex items-center gap-1">
<GripVertical className="h-3.5 w-3.5 text-muted-foreground/70 shrink-0" />
<span className="truncate">{col.label}</span>
</div>
</TableHead>
);
}
export default function PurchaseOrderPage() {
const { user } = useAuth();
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
@@ -121,8 +167,43 @@ export default function PurchaseOrderPage() {
// 테이블 설정
const ts = useTableSettings("c16-purchase-order", DETAIL_TABLE, GRID_COLUMNS_CONFIG);
// 모달 품목 테이블 컬럼 순서 (드래그 재정렬)
const [modalColumns, setModalColumns] = useState(MODAL_DETAIL_COLUMNS);
const modalSensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }));
useEffect(() => {
const saved = localStorage.getItem(MODAL_COL_ORDER_KEY);
if (saved) {
try {
const order = JSON.parse(saved) as string[];
const reordered = order.map((key) => MODAL_DETAIL_COLUMNS.find((c) => c.key === key)).filter(Boolean) as typeof MODAL_DETAIL_COLUMNS;
const remaining = MODAL_DETAIL_COLUMNS.filter((c) => !order.includes(c.key));
setModalColumns([...reordered, ...remaining]);
} catch { /* skip */ }
}
}, []);
const handleModalDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
setModalColumns((prev) => {
const oldIndex = prev.findIndex((c) => c.key === active.id);
const newIndex = prev.findIndex((c) => c.key === over.id);
const next = arrayMove(prev, oldIndex, newIndex);
localStorage.setItem(MODAL_COL_ORDER_KEY, JSON.stringify(next.map((c) => c.key)));
return next;
});
};
const isReadOnly = masterForm.status === "입고완료" || masterForm.status === "취소";
const visibleModalColumns = useMemo(() => {
return modalColumns.filter((col) => {
if (col.key === "supplier" && masterForm.input_mode !== "itemFirst") return false;
return true;
});
}, [modalColumns, masterForm.input_mode]);
// 카테고리 로드
useEffect(() => {
const loadCategories = async () => {
@@ -1012,96 +1093,115 @@ export default function PurchaseOrderPage() {
</div>
) : (
<div className="border rounded-lg overflow-x-auto">
<Table className="table-fixed">
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
{!isReadOnly && <TableHead className="w-[40px] 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-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
{masterForm.input_mode === "itemFirst" && (
<TableHead className="w-[150px] 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-[60px] 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-[90px] 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-[100px] 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="w-[160px] 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>
</TableRow>
</TableHeader>
<TableBody>
{detailRows.map((row, idx) => (
<TableRow key={row._id || idx}>
{!isReadOnly && (
<TableCell>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-muted-foreground/50 hover:text-destructive hover:bg-destructive/5" onClick={() => removeDetailRow(idx)}>
<X className="w-3.5 h-3.5" />
</Button>
</TableCell>
)}
<TableCell className="text-[13px] font-mono max-w-[120px]"><span className="block truncate" title={row.item_code}>{row.item_code}</span></TableCell>
<TableCell className="text-[13px] max-w-[120px]"><span className="block truncate" title={row.item_name}>{row.item_name}</span></TableCell>
{masterForm.input_mode === "itemFirst" && (
<TableCell>
{isReadOnly ? (
<span className="text-xs">{(categoryOptions["supplier_code"] || []).find(o => o.code === row.supplier_code)?.label || row.supplier_code || "-"}</span>
) : (
<Select value={row.supplier_code || ""} onValueChange={(v) => {
const supp = categoryOptions["supplier_code"]?.find(o => o.code === v);
const name = supp?.label.replace(` (${v})`, "") || "";
updateDetailRow(idx, "supplier_code", v);
updateDetailRow(idx, "supplier_name", name);
}}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="공급업체" /></SelectTrigger>
<SelectContent>
{(categoryOptions["supplier_code"] || []).map(o => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
)}
</TableCell>
)}
<TableCell className="text-[13px] text-muted-foreground">{row.spec}</TableCell>
<TableCell className="text-[13px]">{row.unit}</TableCell>
<TableCell>
{isReadOnly ? (
<span className="text-xs text-right font-mono block">{row.order_qty ? Number(row.order_qty).toLocaleString() : ""}</span>
) : (
<Input value={formatNumber(row.order_qty || "")} onChange={(e) => updateDetailRow(idx, "order_qty", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono" />
<DndContext sensors={modalSensors} collisionDetection={closestCenter} onDragEnd={handleModalDragEnd}>
<Table className="table-fixed">
<TableHeader className="sticky top-0 z-10">
<SortableContext items={visibleModalColumns.map((c) => c.key)} strategy={horizontalListSortingStrategy}>
<TableRow className="bg-muted hover:bg-muted">
{!isReadOnly && <TableHead className="w-[40px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
{visibleModalColumns.map((col) => (
<SortableModalHead key={col.key} col={col} />
))}
</TableRow>
</SortableContext>
</TableHeader>
<TableBody>
{detailRows.map((row, idx) => (
<TableRow key={row._id || idx}>
{!isReadOnly && (
<TableCell>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-muted-foreground/50 hover:text-destructive hover:bg-destructive/5" onClick={() => removeDetailRow(idx)}>
<X className="w-3.5 h-3.5" />
</Button>
</TableCell>
)}
</TableCell>
<TableCell className="text-[13px] text-right font-mono text-muted-foreground">{row.received_qty ? Number(row.received_qty).toLocaleString() : "0"}</TableCell>
<TableCell className="text-[13px] text-right font-mono">{row.remain_qty ? Number(row.remain_qty).toLocaleString() : "0"}</TableCell>
<TableCell>
{isReadOnly ? (
<span className="text-xs text-right font-mono block">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</span>
) : (
<Input value={formatNumber(row.unit_price || "")} onChange={(e) => updateDetailRow(idx, "unit_price", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono" />
)}
</TableCell>
<TableCell className="text-[13px] text-right font-mono font-semibold">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>
<TableCell>
{isReadOnly ? (
<span className="text-xs">{row.due_date}</span>
) : (
<Input type="date" value={row.due_date || ""} onChange={(e) => updateDetailRow(idx, "due_date", e.target.value)} className="h-8 text-xs" />
)}
</TableCell>
<TableCell>
{isReadOnly ? (
<span className="text-xs">{row.memo}</span>
) : (
<Input value={row.memo || ""} onChange={(e) => updateDetailRow(idx, "memo", e.target.value)} className="h-8 text-xs" />
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{visibleModalColumns.map((col) => {
switch (col.key) {
case "item_code":
return <TableCell key={col.key} className="text-[13px] font-mono max-w-[120px]"><span className="block truncate" title={row.item_code}>{row.item_code}</span></TableCell>;
case "item_name":
return <TableCell key={col.key} className="text-[13px] max-w-[120px]"><span className="block truncate" title={row.item_name}>{row.item_name}</span></TableCell>;
case "supplier":
return (
<TableCell key={col.key}>
{isReadOnly ? (
<span className="text-xs">{(categoryOptions["supplier_code"] || []).find(o => o.code === row.supplier_code)?.label || row.supplier_code || "-"}</span>
) : (
<Select value={row.supplier_code || ""} onValueChange={(v) => {
const supp = categoryOptions["supplier_code"]?.find(o => o.code === v);
const name = supp?.label.replace(` (${v})`, "") || "";
updateDetailRow(idx, "supplier_code", v);
updateDetailRow(idx, "supplier_name", name);
}}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="공급업체" /></SelectTrigger>
<SelectContent>
{(categoryOptions["supplier_code"] || []).map(o => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
)}
</TableCell>
);
case "spec":
return <TableCell key={col.key} className="text-[13px] text-muted-foreground">{row.spec}</TableCell>;
case "unit":
return <TableCell key={col.key} className="text-[13px]">{row.unit}</TableCell>;
case "order_qty":
return (
<TableCell key={col.key}>
{isReadOnly ? (
<span className="text-xs text-right font-mono block">{row.order_qty ? Number(row.order_qty).toLocaleString() : ""}</span>
) : (
<Input value={formatNumber(row.order_qty || "")} onChange={(e) => updateDetailRow(idx, "order_qty", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono" />
)}
</TableCell>
);
case "received_qty":
return <TableCell key={col.key} className="text-[13px] text-right font-mono text-muted-foreground">{row.received_qty ? Number(row.received_qty).toLocaleString() : "0"}</TableCell>;
case "remain_qty":
return <TableCell key={col.key} className="text-[13px] text-right font-mono">{row.remain_qty ? Number(row.remain_qty).toLocaleString() : "0"}</TableCell>;
case "unit_price":
return (
<TableCell key={col.key}>
{isReadOnly ? (
<span className="text-xs text-right font-mono block">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</span>
) : (
<Input value={formatNumber(row.unit_price || "")} onChange={(e) => updateDetailRow(idx, "unit_price", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono" />
)}
</TableCell>
);
case "amount":
return <TableCell key={col.key} className="text-[13px] text-right font-mono font-semibold">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>;
case "due_date":
return (
<TableCell key={col.key}>
{isReadOnly ? (
<span className="text-xs">{row.due_date}</span>
) : (
<Input type="date" value={row.due_date || ""} onChange={(e) => updateDetailRow(idx, "due_date", e.target.value)} className="h-8 text-xs" />
)}
</TableCell>
);
case "memo":
return (
<TableCell key={col.key}>
{isReadOnly ? (
<span className="text-xs">{row.memo}</span>
) : (
<Input value={row.memo || ""} onChange={(e) => updateDetailRow(idx, "memo", e.target.value)} className="h-8 text-xs" />
)}
</TableCell>
);
default:
return <TableCell key={col.key} />;
}
})}
</TableRow>
))}
</TableBody>
</Table>
</DndContext>
</div>
)}
</div>
@@ -176,8 +176,7 @@ const ITEM_GRID_COLUMNS = [
{ key: "item_name", label: "품명" },
{ key: "size", label: "규격" },
{ key: "unit", label: "단위" },
{ key: "standard_price", label: "기준단가" },
{ key: "standard_price", label: "구매단가" },
{ key: "standard_price", label: "기준단가/구매단가" },
{ key: "currency_code", label: "통화" },
{ key: "status", label: "상태" },
];
@@ -313,8 +312,11 @@ export default function PurchaseItemPage() {
try {
const filters: { columnName: string; operator: string; value: any }[] = [];
// 구매품목/영업관리 division 필터 (다중값 컬럼이므로 contains로 매칭)
filters.push({ columnName: "division", operator: "contains", value: "s" });
// 구매관리 division 필터: 카테고리에서 "구매관리" 라벨의 코드를 찾아서 필터링
const purchaseCode = categoryOptions["division"]?.find((o) => o.label === "구매관리")?.code;
if (purchaseCode) {
filters.push({ columnName: "division", operator: "contains", value: purchaseCode });
}
// DynamicSearchFilter에서 전달된 필터 추가
for (const f of searchFilters) {
@@ -322,7 +324,7 @@ export default function PurchaseItemPage() {
}
const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 5000,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
@@ -616,6 +618,7 @@ export default function PurchaseItemPage() {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "item_id", operator: "equals", value: itemKey }] },
autoFilter: true,
sort: { columnName: "created_date", order: "asc" },
});
const mappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
@@ -866,6 +869,7 @@ export default function PurchaseItemPage() {
{ columnName: "supplier_id", operator: "equals", value: custKey },
{ columnName: "item_id", operator: "equals", value: selectedItem!.item_number },
]}, autoFilter: true,
sort: { columnName: "created_date", order: "asc" },
});
const allMappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
mappingRows = allMappings
@@ -887,7 +891,8 @@ export default function PurchaseItemPage() {
{ columnName: "item_id", operator: "equals", value: selectedItem!.item_number },
]}, autoFilter: true,
});
const allPriceData = priceRes.data?.data?.data || priceRes.data?.data?.rows || [];
const allPriceData = (priceRes.data?.data?.data || priceRes.data?.data?.rows || [])
.sort((a: any, b: any) => (a.start_date || "").localeCompare(b.start_date || ""));
priceRows = allPriceData.map((p: any) => ({
_id: `p_existing_${p.id}`,
start_date: p.start_date ? String(p.start_date).split("T")[0] : "",
@@ -928,104 +933,104 @@ export default function PurchaseItemPage() {
const mappingRows = suppMappings[custKey] || [];
if (isEditingExisting && editSuppData?.id) {
// 매핑: 기존→UPDATE, 신규→INSERT, 삭제된→DELETE
const keptIds = new Set<string>();
let firstMappingId: string | null = null;
// 기존 매핑 조회
let existingMaps: any[] = [];
try {
const existingMappings = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
page: 1, size: 100,
dataFilter: { enabled: true, filters: [
{ columnName: "supplier_id", operator: "equals", value: custKey },
{ columnName: "item_id", operator: "equals", value: selectedItem.item_number },
]}, autoFilter: true,
sort: { columnName: "created_date", order: "asc" },
});
existingMaps = existingMappings.data?.data?.data || existingMappings.data?.data?.rows || [];
} catch { /* skip */ }
// 매핑 upsert: 인덱스 기반
const usedExistingIds = new Set<string>();
let firstMappingId: string | null = editSuppData.id;
for (let mi = 0; mi < mappingRows.length; mi++) {
const m = mappingRows[mi];
if (m._id?.startsWith("m_existing_")) {
const realId = m._id.replace("m_existing_", "");
keptIds.add(realId);
if (mi === 0) firstMappingId = realId;
const existMap = existingMaps[mi];
if (existMap) {
await apiClient.put(`/table-management/tables/${MAPPING_TABLE}/edit`, {
originalData: { id: realId },
originalData: { id: existMap.id },
updatedData: {
supplier_item_code: m.supplier_item_code || "",
supplier_item_name: m.supplier_item_name || "",
supplier_item_code: mappingRows[mi].supplier_item_code || "",
supplier_item_name: mappingRows[mi].supplier_item_name || "",
},
});
usedExistingIds.add(existMap.id);
if (mi === 0) firstMappingId = existMap.id;
} else {
const res = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, {
const mRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, {
id: crypto.randomUUID(),
supplier_id: custKey, item_id: selectedItem.item_number,
supplier_item_code: m.supplier_item_code || "",
supplier_item_name: m.supplier_item_name || "",
supplier_item_code: mappingRows[mi].supplier_item_code || "",
supplier_item_name: mappingRows[mi].supplier_item_name || "",
});
if (mi === 0) firstMappingId = res.data?.data?.id || null;
if (mi === 0 && !firstMappingId) firstMappingId = mRes.data?.data?.id || null;
}
}
// 폼에서 삭제된 매핑 → DB 삭제
// 초과분 delete
const toDeleteMaps = existingMaps.filter((m) => !usedExistingIds.has(m.id));
if (toDeleteMaps.length > 0) {
await apiClient.delete(`/table-management/tables/${MAPPING_TABLE}/delete`, {
data: toDeleteMaps.map((m: any) => ({ id: m.id })),
});
}
// 기존 단가 조회
let existingPriceRows: any[] = [];
try {
const dbMappings = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
const existingPrices = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, {
page: 1, size: 100,
dataFilter: { enabled: true, filters: [
{ columnName: "supplier_id", operator: "equals", value: custKey },
{ columnName: "item_id", operator: "equals", value: selectedItem.item_number },
]}, autoFilter: true,
});
const toDelete = (dbMappings.data?.data?.data || dbMappings.data?.data?.rows || [])
.filter((m: any) => !keptIds.has(m.id));
if (toDelete.length > 0) {
await apiClient.delete(`/table-management/tables/${MAPPING_TABLE}/delete`, {
data: toDelete.map((m: any) => ({ id: m.id })),
});
}
existingPriceRows = existingPrices.data?.data?.data || existingPrices.data?.data?.rows || [];
} catch { /* skip */ }
if (!firstMappingId) firstMappingId = editSuppData.id;
// 단가: 기존→UPDATE, 신규→INSERT, 삭제된→DELETE
const keptPriceIds = new Set<string>();
// 단가 upsert: 인덱스 기반
const priceRows = (suppPrices[custKey] || []).filter((p) =>
p.base_price || p.start_date || p.currency_code || p.base_price_type
);
for (const price of priceRows) {
if (price._id?.startsWith("p_existing_")) {
const realId = price._id.replace("p_existing_", "");
keptPriceIds.add(realId);
const usedPriceIds = new Set<string>();
for (let pi = 0; pi < priceRows.length; pi++) {
const price = priceRows[pi];
const priceData = {
mapping_id: firstMappingId || editSuppData.id,
supplier_id: custKey, item_id: selectedItem.item_number,
start_date: price.start_date || null, end_date: price.end_date || null,
currency_code: price.currency_code || null, base_price_type: price.base_price_type || null,
base_price: price.base_price ? Number(price.base_price) : null,
unit_price: price.calculated_price ? Number(price.calculated_price) : (price.base_price ? Number(price.base_price) : null),
discount_type: price.discount_type || null, discount_value: price.discount_value ? Number(price.discount_value) : null,
rounding_type: price.rounding_type || null, rounding_unit_value: price.rounding_unit_value || null,
calculated_price: price.calculated_price ? Number(price.calculated_price) : null,
};
const existPrice = existingPriceRows[pi];
if (existPrice) {
await apiClient.put(`/table-management/tables/supplier_item_prices/edit`, {
originalData: { id: realId },
updatedData: {
mapping_id: firstMappingId || "",
start_date: price.start_date || null, end_date: price.end_date || null,
currency_code: price.currency_code || null, base_price_type: price.base_price_type || null,
base_price: price.base_price ? Number(price.base_price) : null,
discount_type: price.discount_type || null, discount_value: price.discount_value ? Number(price.discount_value) : null,
rounding_type: price.rounding_type || null, rounding_unit_value: price.rounding_unit_value || null,
calculated_price: price.calculated_price ? Number(price.calculated_price) : null,
},
originalData: { id: existPrice.id },
updatedData: priceData,
});
usedPriceIds.add(existPrice.id);
} else {
await apiClient.post(`/table-management/tables/supplier_item_prices/add`, {
id: crypto.randomUUID(),
mapping_id: firstMappingId || "",
supplier_id: custKey, item_id: selectedItem.item_number,
start_date: price.start_date || null, end_date: price.end_date || null,
currency_code: price.currency_code || null, base_price_type: price.base_price_type || null,
base_price: price.base_price ? Number(price.base_price) : null,
discount_type: price.discount_type || null, discount_value: price.discount_value ? Number(price.discount_value) : null,
rounding_type: price.rounding_type || null, rounding_unit_value: price.rounding_unit_value || null,
calculated_price: price.calculated_price ? Number(price.calculated_price) : null,
id: crypto.randomUUID(), ...priceData,
});
}
}
// 폼에서 삭제된 단가 → DB 삭제
try {
const dbPrices = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, {
page: 1, size: 100,
dataFilter: { enabled: true, filters: [
{ columnName: "supplier_id", operator: "equals", value: custKey },
{ columnName: "item_id", operator: "equals", value: selectedItem.item_number },
]}, autoFilter: true,
// 초과분 delete
const toDeletePrices = existingPriceRows.filter((p) => !usedPriceIds.has(p.id));
if (toDeletePrices.length > 0) {
await apiClient.delete(`/table-management/tables/supplier_item_prices/delete`, {
data: toDeletePrices.map((p: any) => ({ id: p.id })),
});
const toDeletePrices = (dbPrices.data?.data?.data || dbPrices.data?.data?.rows || [])
.filter((p: any) => !keptPriceIds.has(p.id));
if (toDeletePrices.length > 0) {
await apiClient.delete(`/table-management/tables/supplier_item_prices/delete`, {
data: toDeletePrices.map((p: any) => ({ id: p.id })),
});
}
} catch { /* skip */ }
}
} else {
// 신규 등록
const mappingRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, {
@@ -1055,6 +1060,7 @@ export default function PurchaseItemPage() {
start_date: price.start_date || null, end_date: price.end_date || null,
currency_code: price.currency_code || null, base_price_type: price.base_price_type || null,
base_price: price.base_price ? Number(price.base_price) : null,
unit_price: price.calculated_price ? Number(price.calculated_price) : (price.base_price ? Number(price.base_price) : null),
discount_type: price.discount_type || null, discount_value: price.discount_value ? Number(price.discount_value) : null,
rounding_type: price.rounding_type || null, rounding_unit_value: price.rounding_unit_value || null,
calculated_price: price.calculated_price ? Number(price.calculated_price) : null,
@@ -1719,7 +1725,7 @@ export default function PurchaseItemPage() {
{/* ── 공급업체 상세 입력/수정 모달 ── */}
<Dialog open={suppDetailOpen} onOpenChange={setSuppDetailOpen}>
<DialogContent className="max-w-[1100px] max-h-[90vh] overflow-y-auto">
<DialogContent className="max-w-[1100px] h-[85vh] flex flex-col overflow-hidden">
<DialogHeader>
<DialogTitle>
{editSuppData ? "수정" : "입력"} {selectedItem?.item_name || ""}
@@ -1729,7 +1735,7 @@ export default function PurchaseItemPage() {
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-2">
<div className="space-y-6 py-2 flex-1 overflow-y-auto">
{selectedSuppsForDetail.map((cust, idx) => {
const custKey = cust.supplier_code || cust.id;
const mappingRows = suppMappings[custKey] || [];
@@ -195,6 +195,12 @@ export default function SupplierManagementPage() {
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
} catch { /* skip */ }
}
for (const col of ["division", "unit", "material"]) {
try {
const res = await apiClient.get(`/table-categories/item_info/${col}/values`);
if (res.data?.success) optMap[`item_${col}`] = flatten(res.data.data || []);
} catch { /* skip */ }
}
setCategoryOptions(optMap);
const priceOpts: Record<string, { code: string; label: string }[]> = {};
@@ -813,18 +819,19 @@ 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: 500,
page: 1, size: 5000,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const allItems = res.data?.data?.data || res.data?.data?.rows || [];
setItemTotalCount(allItems.length);
const existingItemIds = new Set(priceItems.map((p: any) => p.item_id || p.item_number));
const PURCHASE_CODES = ["s"]; // 구매관리 카테고리 코드
const purchaseCode = categoryOptions["item_division"]?.find((o) => o.label === "구매관리")?.code;
setItemSearchResults(allItems.filter((item: any) => {
if (existingItemIds.has(item.item_number) || existingItemIds.has(item.id)) return false;
const divCodes = (item.division || "").split(",").map((c: string) => c.trim());
return divCodes.some((code: string) => PURCHASE_CODES.includes(code));
if (!purchaseCode) return true;
const div = item.division || "";
return div.includes(purchaseCode);
}));
} catch { /* skip */ } finally { setItemSearchLoading(false); }
}, [itemSearchKeyword, priceItems]);
@@ -971,6 +978,7 @@ export default function SupplierManagementPage() {
{ columnName: "supplier_id", operator: "equals", value: selectedSupplier!.supplier_code },
{ columnName: "item_id", operator: "equals", value: itemKey },
]}, autoFilter: true,
sort: { columnName: "created_date", order: "asc" },
});
const allMappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
mappingRows = allMappings
@@ -991,7 +999,8 @@ export default function SupplierManagementPage() {
{ columnName: "item_id", operator: "equals", value: itemKey },
]}, autoFilter: true,
});
const allPriceData = priceRes.data?.data?.data || priceRes.data?.data?.rows || [];
const allPriceData = (priceRes.data?.data?.data || priceRes.data?.data?.rows || [])
.sort((a: any, b: any) => (a.start_date || "").localeCompare(b.start_date || ""));
priceRows = allPriceData.map((p: any) => ({
_id: `p_existing_${p.id}`,
start_date: p.start_date ? String(p.start_date).split("T")[0] : "",
@@ -1043,6 +1052,7 @@ export default function SupplierManagementPage() {
{ columnName: "supplier_id", operator: "equals", value: selectedSupplier.supplier_code },
{ columnName: "item_id", operator: "equals", value: itemKey },
]}, autoFilter: true,
sort: { columnName: "created_date", order: "asc" },
});
existingMaps = existingMappings.data?.data?.data || existingMappings.data?.data?.rows || [];
} catch { /* skip */ }
@@ -1092,7 +1102,8 @@ export default function SupplierManagementPage() {
{ columnName: "item_id", operator: "equals", value: itemKey },
]}, autoFilter: true,
});
existingPriceRows = existingPrices.data?.data?.data || existingPrices.data?.data?.rows || [];
existingPriceRows = (existingPrices.data?.data?.data || existingPrices.data?.data?.rows || [])
.sort((a: any, b: any) => (a.start_date || "").localeCompare(b.start_date || ""));
} catch { /* skip */ }
// 단가 upsert
@@ -2460,8 +2471,8 @@ export default function SupplierManagementPage() {
</TableCell>
<TableCell className="text-sm">{item.item_name}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{item.size}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{item.material}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{item.unit}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{categoryOptions["item_material"]?.find((o) => o.code === item.material)?.label || item.material}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{categoryOptions["item_unit"]?.find((o) => o.code === item.unit)?.label || item.unit}</TableCell>
</TableRow>
))}
</TableBody>
@@ -2503,7 +2514,7 @@ export default function SupplierManagementPage() {
{/* 품목 헤더 */}
<div className="px-5 py-3 bg-muted/30 border-b">
<div className="font-bold">{idx + 1}. {item.item_name || itemKey}</div>
<div className="text-xs text-muted-foreground">{itemKey} | {item.size || ""} | {item.unit || ""}</div>
<div className="text-xs text-muted-foreground">{itemKey} | {item.size || ""} | {categoryOptions["item_unit"]?.find((o) => o.code === item.unit)?.label || item.unit || ""}</div>
</div>
<div className="flex gap-4 p-5 items-stretch">
@@ -188,9 +188,12 @@ export default function InspectionManagementPage() {
if (["inspection_type", "inspection_method", "judgment_criteria", "unit", "apply_type"].includes(col.key)) {
base.render = (v: any, row: any) => getCatLabel(INSPECTION_TABLE, col.key, row[col.key]);
}
if (col.key === "manager") {
base.render = (v: any, row: any) => userOptions.find((u) => u.code === row.manager)?.label || row.manager || "-";
}
return base;
});
}, [ts.visibleColumns, catOptions]); // eslint-disable-line react-hooks/exhaustive-deps
}, [ts.visibleColumns, catOptions, userOptions]); // eslint-disable-line react-hooks/exhaustive-deps
/* ═══════════════════ 데이터 조회 ═══════════════════ */
// 다중값 컬럼 (쉼표 구분 저장) — 서버 equals 대신 contains 사용
@@ -385,7 +388,7 @@ export default function InspectionManagementPage() {
/* ═══════════════════ 불량관리 CRUD ═══════════════════ */
const openDefCreate = async () => {
setDefForm({});
setDefForm({ is_active: "사용" });
setDefEditMode(false);
setNumberingRuleId(null);
setPreviewCode(null);
@@ -867,20 +870,16 @@ export default function InspectionManagementPage() {
</TableCell>
<TableCell>
<Badge
variant={
getCatLabel(DEFECT_TABLE, "is_active", row.is_active) === "사용"
? "default"
: "secondary"
}
variant={row.is_active === "사용" || row.is_active === "true" ? "default" : "secondary"}
className="text-[10px]"
>
{getCatLabel(DEFECT_TABLE, "is_active", row.is_active) || "-"}
{row.is_active === "사용" || row.is_active === "true" ? "사용" : row.is_active || "미사용"}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground">
{row.reg_date || (row.created_date ? row.created_date.slice(0, 10) : "-")}
</TableCell>
<TableCell>{row.manager_id || "-"}</TableCell>
<TableCell>{userOptions.find((u) => u.code === row.manager_id)?.label || row.manager_id || "-"}</TableCell>
<TableCell className="text-muted-foreground">{row.remarks || "-"}</TableCell>
</TableRow>
);
@@ -1442,19 +1441,15 @@ export default function InspectionManagementPage() {
<div className="space-y-1.5">
<Label className="text-xs font-semibold"></Label>
<Select
value={defForm.is_active || "__none__"}
onValueChange={(v) => setDefForm((p) => ({ ...p, is_active: v === "__none__" ? "" : v }))}
value={defForm.is_active || "사용"}
onValueChange={(v) => setDefForm((p) => ({ ...p, is_active: v }))}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> </SelectItem>
{(catOptions[`${DEFECT_TABLE}.is_active`] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>
{o.label}
</SelectItem>
))}
<SelectItem value="사용"></SelectItem>
<SelectItem value="미사용"></SelectItem>
</SelectContent>
</Select>
</div>
@@ -9,8 +9,9 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import {
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ChevronDown,
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ClipboardList,
} from "lucide-react";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { cn } from "@/lib/utils";
@@ -20,19 +21,17 @@ import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { toast } from "sonner";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
const TABLE_NAME = "item_inspection_info";
const ITEM_TABLE = "item_info";
const INSPECTION_TABLE = "inspection_standard";
const GRID_COLUMNS = [
{ key: "item_code", label: "품목코드" },
{ key: "item_name", label: "품명" },
{ key: "item_name", label: "품명" },
{ key: "inspection_type", label: "검사유형" },
{ key: "item_count", label: "항목수" },
{ key: "is_active", label: "사용여부" },
];
const ITEM_TABLE = "item_info";
const INSPECTION_TABLE = "inspection_standard";
const INSPECTION_TYPES = [
{ key: "incoming_inspection", label: "수입검사", matchLabels: ["수입검사", "입고검사", "수입", "입고"] },
@@ -61,24 +60,29 @@ export default function ItemInspectionInfoPage() {
const [totalCount, setTotalCount] = useState(0);
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
const [checkedIds, setCheckedIds] = useState<string[]>([]);
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
// 우측 패널: 선택된 품목
const [selectedItemCode, setSelectedItemCode] = useState<string | null>(null);
const [selectedTypeTab, setSelectedTypeTab] = useState<string>("");
// 모달
const [modalOpen, setModalOpen] = useState(false);
const [editMode, setEditMode] = useState(false);
const [form, setForm] = useState<Record<string, any>>({});
const [saving, setSaving] = useState(false);
/* FK 옵션 */
// FK 옵션
const [itemOptions, setItemOptions] = useState<{ code: string; name: string; item_type: string; unit: string }[]>([]);
const [inspOptions, setInspOptions] = useState<{ code: string; label: string; detail: string; method: string; types: string[] }[]>([]);
const [inspTypeCatOptions, setInspTypeCatOptions] = useState<{ code: string; label: string }[]>([]);
const [inspMethodCatOptions, setInspMethodCatOptions] = useState<{ code: string; label: string }[]>([]);
const [userOptions, setUserOptions] = useState<{ code: string; label: string }[]>([]);
/* 검사유형별 검사항목 rows */
// 검사유형별 검사항목 rows (모달용)
const [inspectionRows, setInspectionRows] = useState<Record<string, InspectionRow[]>>({});
const [collapsedTypes, setCollapsedTypes] = useState<Record<string, boolean>>({});
/* 품목 선택 모달 */
// 품목 선택 모달
const [itemModalOpen, setItemModalOpen] = useState(false);
const [itemSearchKeyword, setItemSearchKeyword] = useState("");
const [filteredItems, setFilteredItems] = useState<typeof itemOptions>([]);
@@ -109,7 +113,7 @@ export default function ItemInspectionInfoPage() {
types: r.inspection_type ? r.inspection_type.split(",").filter(Boolean) : [],
})));
// 검사유형 카테고리 값 로드 (코드→라벨 매핑용)
// 검사유형 카테고리
try {
const catRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/inspection_type/values`);
const flatCats: { code: string; label: string }[] = [];
@@ -118,6 +122,15 @@ export default function ItemInspectionInfoPage() {
setInspTypeCatOptions(flatCats);
} catch { /* skip */ }
// 검사방법 카테고리
try {
const methodRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/inspection_method/values`);
const flatMethods: { code: string; label: string }[] = [];
const flattenM = (arr: any[]) => { for (const v of arr) { flatMethods.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) flattenM(v.children); } };
if (methodRes.data?.data?.length) flattenM(methodRes.data.data);
setInspMethodCatOptions(flatMethods);
} catch { /* skip */ }
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
setUserOptions(users.map((u: any) => ({
code: u.user_id || u.id,
@@ -129,20 +142,12 @@ export default function ItemInspectionInfoPage() {
}, []);
/* ═══════════════════ 품목 선택 모달 ═══════════════════ */
const openItemModal = () => {
setItemSearchKeyword("");
setFilteredItems(itemOptions);
setItemModalOpen(true);
};
const openItemModal = () => { setItemSearchKeyword(""); setFilteredItems(itemOptions); setItemModalOpen(true); };
const handleItemSearch = () => {
const kw = itemSearchKeyword.trim().toLowerCase();
if (!kw) { setFilteredItems(itemOptions); return; }
setFilteredItems(itemOptions.filter(o =>
o.code.toLowerCase().includes(kw) || o.name.toLowerCase().includes(kw)
));
setFilteredItems(itemOptions.filter(o => o.code.toLowerCase().includes(kw) || o.name.toLowerCase().includes(kw)));
};
const selectItem = (item: typeof itemOptions[0]) => {
setForm(p => ({ ...p, item_code: item.code, item_name: item.name }));
setItemModalOpen(false);
@@ -186,24 +191,45 @@ export default function ItemInspectionInfoPage() {
return Object.values(map);
}, [data]);
// 검사기준 ID → 라벨 resolve
// 선택된 품목의 그룹 데이터
const selectedGroup = useMemo(() => {
if (!selectedItemCode) return null;
return groupedData.find(g => g.item_code === selectedItemCode) || null;
}, [selectedItemCode, groupedData]);
// 선택된 탭의 검사항목 행
const selectedTabRows = useMemo(() => {
if (!selectedGroup || !selectedTypeTab) return [];
return selectedGroup.rows.filter((r: any) => r.inspection_type === selectedTypeTab && r.inspection_standard_id);
}, [selectedGroup, selectedTypeTab]);
// 검사기준 ID → 라벨
const resolveInspLabel = useCallback((id: string) => {
const opt = inspOptions.find(o => o.code === id);
return opt?.label || id || "-";
return inspOptions.find(o => o.code === id)?.label || id || "-";
}, [inspOptions]);
// 검사방법 코드 → 라벨
const resolveMethodLabel = useCallback((code: string) => {
return inspMethodCatOptions.find(o => o.code === code)?.label || code || "-";
}, [inspMethodCatOptions]);
/* ═══════════════════ CRUD ═══════════════════ */
const openCreate = () => { setForm({}); setEditMode(false); setInspectionRows({}); setCollapsedTypes({}); setModalOpen(true); };
const openEdit = async (row: any) => {
const openEdit = async (itemCode?: string) => {
const code = itemCode || selectedItemCode;
if (!code) { toast.error("수정할 항목을 선택해주세요"); return; }
const group = groupedData.find(g => g.item_code === code);
if (!group) return;
const row = group.rows[0];
setForm({ ...row });
setEditMode(true);
setCollapsedTypes({});
// 같은 item_code의 모든 검사항목 행을 조회하여 유형별로 분류
try {
const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: row.item_code }] },
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: code }] },
autoFilter: true,
});
const allRows = res.data?.data?.data || res.data?.data?.rows || [];
@@ -212,7 +238,6 @@ export default function ItemInspectionInfoPage() {
for (const r of allRows) {
const inspType = r.inspection_type || "";
// 카테고리 코드/라벨로 INSPECTION_TYPES 키 매칭
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)))
@@ -221,48 +246,34 @@ export default function ItemInspectionInfoPage() {
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;
rowMap[typeKey].push({
id: r.id,
inspection_standard_id: r.inspection_standard_id || "",
inspection_detail: r.inspection_item_name || r.inspection_standard || "",
inspection_method: r.inspection_method || "",
inspection_method: mLabel,
apply_process: "",
acceptance_criteria: r.pass_criteria || "",
is_required: r.is_required === "true" || r.is_required === true,
});
}
setInspectionRows(rowMap);
setForm(p => ({ ...p, ...typeFlags }));
} catch {
setInspectionRows({});
}
} catch { setInspectionRows({}); }
setModalOpen(true);
};
/* ═══════════════════ 검사항목 행 관리 ═══════════════════ */
/* ═══════════════════ 검사항목 행 관리 (모달) ═══════════════════ */
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: "", acceptance_criteria: "", is_required: false }],
}));
};
const removeInspRow = (typeKey: string, rowId: string) => {
setInspectionRows(prev => ({
...prev,
[typeKey]: (prev[typeKey] || []).filter(r => r.id !== rowId),
}));
setInspectionRows(prev => ({ ...prev, [typeKey]: (prev[typeKey] || []).filter(r => r.id !== rowId) }));
};
const updateInspRow = (typeKey: string, rowId: string, field: string, value: any) => {
setInspectionRows(prev => ({
...prev,
@@ -270,34 +281,27 @@ export default function ItemInspectionInfoPage() {
if (r.id !== rowId) return r;
if (field === "inspection_standard_id") {
const opt = inspOptions.find(o => o.code === value);
return { ...r, inspection_standard_id: value, inspection_detail: opt?.detail || "", inspection_method: opt?.method || "" };
const methodCode = opt?.method || "";
const methodLabel = inspMethodCatOptions.find(o => o.code === methodCode)?.label || methodCode;
return { ...r, inspection_standard_id: value, inspection_detail: opt?.detail || "", inspection_method: methodLabel };
}
return { ...r, [field]: value };
}),
}));
};
/** 검사유형 키에 매칭되는 검사기준만 필터링 */
const getFilteredInspOptions = (typeKey: string) => {
const typeDef = INSPECTION_TYPES.find(t => t.key === typeKey);
if (!typeDef) return inspOptions;
// matchLabels와 카테고리 라벨을 비교하여 해당 카테고리 코드를 찾음
const matchCodes = inspTypeCatOptions
.filter(cat => typeDef.matchLabels.some(ml => cat.label.includes(ml)))
.map(cat => cat.code);
const matchCodes = inspTypeCatOptions.filter(cat => typeDef.matchLabels.some(ml => cat.label.includes(ml))).map(cat => cat.code);
if (matchCodes.length === 0) return inspOptions;
return inspOptions.filter(opt => opt.types.some(t => matchCodes.includes(t)));
};
const toggleCollapse = (typeKey: string) => {
setCollapsedTypes(prev => ({ ...prev, [typeKey]: !prev[typeKey] }));
};
const toggleCollapse = (typeKey: string) => { setCollapsedTypes(prev => ({ ...prev, [typeKey]: !prev[typeKey] })); };
const handleSave = async () => {
if (!form.item_code) { toast.error("품목코드는 필수 입력이에요"); return; }
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,
@@ -306,54 +310,29 @@ export default function ItemInspectionInfoPage() {
});
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 })),
});
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: existing.map((r: any) => ({ id: r.id })) });
}
}
// 검사유형별 항목을 개별 행으로 INSERT
const enabledTypes = INSPECTION_TYPES.filter(t => !!form[t.key]);
const rows: any[] = [];
for (const t of enabledTypes) {
const typeLabel = t.label;
const typeRows = inspectionRows[t.key] || [];
if (typeRows.length === 0) {
// 유형만 체크하고 항목 없는 경우에도 1행 생성
rows.push({
id: crypto.randomUUID(),
item_code: form.item_code,
item_name: form.item_name,
inspection_type: typeLabel,
is_active: form.is_active || "사용",
manager_id: form.manager_id || "",
memo: form.remarks || "",
});
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 || "" });
} else {
for (const r of typeRows) {
rows.push({
id: crypto.randomUUID(),
item_code: form.item_code,
item_name: form.item_name,
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: form.is_active || "사용",
manager_id: form.manager_id || "",
memo: form.remarks || "",
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 || "",
is_required: r.is_required ? "true" : "false", is_active: form.is_active || "사용",
manager_id: form.manager_id || "", memo: form.remarks || "",
});
}
}
}
for (const row of rows) {
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, row);
}
toast.success(editMode ? "품목검사정보를 수정했어요" : "품목검사정보를 등록했어요");
for (const row of rows) { await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, row); }
toast.success(editMode ? "수정했어요" : "등록했어요");
setModalOpen(false);
fetchData();
} catch { toast.error("저장에 실패했어요"); }
@@ -361,138 +340,231 @@ export default function ItemInspectionInfoPage() {
};
const handleDelete = async () => {
if (checkedIds.length === 0) { toast.error("삭제할 목을 선택해주세요"); return; }
const ok = await confirm("품목검사정보 삭제", {
description: `선택한 ${checkedIds.length}건을 삭제할까요? 이 작업은 되돌릴 수 없어요.`,
});
if (!selectedItemCode) { toast.error("삭제할 목을 선택해주세요"); return; }
const group = groupedData.find(g => g.item_code === selectedItemCode);
if (!group) return;
const ok = await confirm(`${selectedItemCode} 검사정보 삭제`, { description: "선택한 품목의 검사정보를 모두 삭제할까요?", variant: "destructive", confirmText: "삭제" });
if (!ok) return;
try {
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, {
data: checkedIds.map(id => ({ id })),
});
toast.success(`${checkedIds.length}건을 삭제했어요`);
setCheckedIds([]);
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: group.rows.map((r: any) => ({ id: r.id })) });
toast.success("삭제했어요");
setSelectedItemCode(null);
fetchData();
} catch { toast.error("삭제에 실패했어요"); }
};
/* ═══════════════════ JSX ═══════════════════ */
return (
<div className="flex flex-col gap-3 p-3">
<div className="flex h-full flex-col">
{ConfirmDialogComponent}
<div className="rounded-lg border bg-card">
<div className="px-3 py-2.5 border-b bg-muted/50">
<DynamicSearchFilter
tableName={TABLE_NAME}
filterId="c16-item-inspection"
onFilterChange={setSearchFilters}
externalFilterConfig={ts.filterConfig}
dataCount={totalCount}
extraActions={
<div className="flex items-center gap-2">
<Button size="sm" onClick={openCreate}><Plus className="w-4 h-4 mr-1" /></Button>
<Button size="sm" variant="outline" onClick={() => {
const sel = data.find(r => checkedIds.includes(r.id));
if (sel) {
const group = groupedData.find(g => g.item_code === sel.item_code);
openEdit(group?.rows[0] || sel);
} else toast.error("수정할 항목을 선택해주세요");
}}><Pencil className="w-4 h-4 mr-1" /></Button>
<Button size="sm" variant="destructive" onClick={handleDelete}><Trash2 className="w-4 h-4 mr-1" /></Button>
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
<Settings2 className="h-4 w-4" />
</Button>
</div>
}
/>
</div>
<div className="p-3 overflow-auto">
{loading ? (
<div className="flex items-center justify-center py-16"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
) : groupedData.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Inbox className="h-10 w-10 mb-2 opacity-30" />
<p className="text-sm"> </p>
</div>
) : (
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-10" />
<TableHead className="w-10"><Checkbox checked={groupedData.length > 0 && checkedIds.length === data.length} onCheckedChange={(v) => setCheckedIds(v ? data.map(r => r.id) : [])} /></TableHead>
{ts.visibleColumns.map((col) => (
<TableHead key={col.key} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground" style={ts.thStyle(col.key)}>
{col.label}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{ts.groupData(groupedData).map((group) => {
if ((group as any)._isGroupSummary || (group as any)._isGroupHeader) return null;
const isExpanded = expandedItems.has(group.item_code);
const groupIds = group.rows.map((r: any) => r.id);
const allChecked = groupIds.every((id: string) => checkedIds.includes(id));
const renderCell = (key: string) => {
switch (key) {
case "item_code": return <TableCell key={key} className="text-sm font-medium text-primary">{group.item_code}</TableCell>;
case "item_name": return <TableCell key={key} className="text-sm">{group.item_name}</TableCell>;
case "inspection_type": return (
<TableCell key={key}>
<div className="flex gap-1 flex-wrap">
{group.types.map((t: string) => <Badge key={t} variant="secondary" className="text-[10px]">{t}</Badge>)}
</div>
</TableCell>
);
case "item_count": return <TableCell key={key} className="text-sm text-center">{group.rows.filter((r: any) => r.inspection_standard_id).length}</TableCell>;
case "is_active": return (
<TableCell key={key}>
<Badge variant={group.is_active === "사용" || group.is_active === "true" ? "default" : "secondary"} className="text-[10px]">
{group.is_active === "사용" || group.is_active === "true" ? "사용" : "미사용"}
</Badge>
</TableCell>
);
default: return <TableCell key={key}>{(group as any)[key] ?? ""}</TableCell>;
}
};
return (
<React.Fragment key={group.item_code}>
<TableRow
className={cn("cursor-pointer border-l-2 border-l-transparent", allChecked && "border-l-primary bg-primary/5")}
onClick={() => setExpandedItems(prev => { const next = new Set(prev); if (next.has(group.item_code)) next.delete(group.item_code); else next.add(group.item_code); return next; })}
onDoubleClick={() => openEdit(group.rows[0])}
>
<TableCell className="text-center p-2">
<ChevronDown className={cn("w-4 h-4 transition-transform text-muted-foreground", !isExpanded && "-rotate-90")} />
</TableCell>
<TableCell className="text-center p-2" onClick={(e) => { e.stopPropagation(); setCheckedIds(prev => allChecked ? prev.filter(id => !groupIds.includes(id)) : [...new Set([...prev, ...groupIds])]); }}>
<Checkbox checked={allChecked} onCheckedChange={() => {}} />
</TableCell>
{ts.visibleColumns.map((col) => renderCell(col.key))}
</TableRow>
{isExpanded && group.rows.filter((r: any) => r.inspection_standard_id).map((row: any) => (
<TableRow key={row.id} className="bg-muted/30 text-xs">
<TableCell />
<TableCell />
<TableCell className="pl-6 text-muted-foreground">{row.inspection_type}</TableCell>
<TableCell>{resolveInspLabel(row.inspection_standard_id)}</TableCell>
<TableCell>{row.inspection_item_name || "-"}</TableCell>
<TableCell>{row.inspection_method || "-"}</TableCell>
<TableCell>{row.pass_criteria || "-"}</TableCell>
</TableRow>
))}
</React.Fragment>
);
})}
</TableBody>
</Table>
)}
<div className="mt-2 text-xs text-muted-foreground"> {groupedData.length} ( )</div>
</div>
{/* 검색 필터 */}
<div className="shrink-0 px-3 pt-3 pb-2">
<DynamicSearchFilter
tableName={TABLE_NAME}
filterId="c16-item-inspection"
onFilterChange={setSearchFilters}
externalFilterConfig={ts.filterConfig}
dataCount={totalCount}
/>
</div>
{/* ═══════════════════ 등록/수정 모달 (품목선택 뷰 포함) ═══════════════════ */}
{/* 좌우 분할 패널 */}
<div className="flex-1 min-h-0 px-3 pb-3">
<ResizablePanelGroup direction="horizontal" className="h-full rounded-lg border">
{/* ═══════ 좌측: 품목 목록 ═══════ */}
<ResizablePanel defaultSize={50} minSize={30}>
<div className="flex h-full flex-col">
<div className="shrink-0 flex items-center justify-between px-4 py-3 border-b bg-muted/30">
<div className="flex items-center gap-2">
<ClipboardList className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-semibold"> </span>
<Badge variant="secondary" className="text-[10px]">{groupedData.length}</Badge>
</div>
<div className="flex items-center gap-1.5">
<Button size="sm" className="h-7 text-xs" onClick={openCreate}><Plus 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>
</div>
<div className="flex-1 min-h-0 overflow-auto">
{loading ? (
<div className="flex items-center justify-center py-16"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
) : groupedData.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Inbox className="h-10 w-10 mb-2 opacity-30" />
<p className="text-sm"> </p>
</div>
) : (
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
{ts.visibleColumns.map((col) => (
<TableHead key={col.key} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground" style={ts.thStyle(col.key)}>
{col.label}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{groupedData.map((group) => (
<TableRow
key={group.item_code}
className={cn("cursor-pointer", selectedItemCode === group.item_code ? "bg-primary/10 border-l-2 border-l-primary" : "hover:bg-muted/50")}
onClick={() => {
setSelectedItemCode(group.item_code);
setSelectedTypeTab(group.types[0] || "");
}}
>
{ts.visibleColumns.map((col) => {
switch (col.key) {
case "item_code": return <TableCell key={col.key} className="text-sm font-medium text-primary">{group.item_code}</TableCell>;
case "item_name": return <TableCell key={col.key} className="text-sm">{group.item_name}</TableCell>;
case "inspection_type": return (
<TableCell key={col.key}>
<div className="flex gap-1 flex-wrap">
{group.types.map((t: string) => (
<Badge key={t} variant="secondary" className="text-[10px]">{t}</Badge>
))}
</div>
</TableCell>
);
case "is_active": return (
<TableCell key={col.key}>
<Badge variant={group.is_active === "사용" || group.is_active === "true" ? "default" : "secondary"} className="text-[10px]">
{group.is_active === "사용" || group.is_active === "true" ? "사용" : "미사용"}
</Badge>
</TableCell>
);
default: return <TableCell key={col.key}>{(group as any)[col.key] ?? ""}</TableCell>;
}
})}
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
<div className="shrink-0 px-4 py-2 border-t text-xs text-muted-foreground">
{groupedData.length} ( )
</div>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* ═══════ 우측: 검사유형별 검사항목 ═══════ */}
<ResizablePanel defaultSize={50} minSize={30}>
<div className="flex h-full flex-col">
<div className="shrink-0 flex items-center justify-between px-4 py-3 border-b bg-muted/30">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold"> </span>
</div>
{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="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" /></Button>
</div>
)}
</div>
{!selectedGroup ? (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
<div className="text-center space-y-2">
<ClipboardList className="h-8 w-8 mx-auto opacity-30" />
<p className="text-sm"> </p>
</div>
</div>
) : (
<div className="flex-1 min-h-0 flex flex-col">
{/* 검사유형 탭 */}
<div className="shrink-0 flex items-center gap-2 px-4 py-3">
{selectedGroup.types.map((type: string) => {
const count = selectedGroup.rows.filter((r: any) => r.inspection_type === type && r.inspection_standard_id).length;
return (
<button
key={type}
type="button"
onClick={() => setSelectedTypeTab(type)}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-colors border",
selectedTypeTab === type
? "bg-primary text-primary-foreground border-primary"
: "bg-muted/50 text-muted-foreground border-border hover:bg-muted"
)}
>
{type}
<span className={cn(
"inline-flex items-center justify-center h-4 min-w-[16px] rounded-full text-[10px] font-bold px-1",
selectedTypeTab === type ? "bg-primary-foreground/20 text-primary-foreground" : "bg-muted-foreground/20 text-muted-foreground"
)}>
{count}
</span>
</button>
);
})}
</div>
{/* 검사항목 상세 테이블 */}
<div className="flex-1 min-h-0 overflow-auto px-4 pb-4">
{selectedTypeTab && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Badge variant="default" className="text-xs">{selectedTypeTab}</Badge>
<span className="text-sm font-medium"> </span>
</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 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>
</TableRow>
</TableHeader>
<TableBody>
{selectedTabRows.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-xs text-muted-foreground"> </TableCell>
</TableRow>
) : selectedTabRows.map((row: any) => (
<TableRow key={row.id}>
<TableCell className="text-xs py-2">{row.inspection_item_name || "-"}</TableCell>
<TableCell className="text-xs py-2">{resolveInspLabel(row.inspection_standard_id)}</TableCell>
<TableCell className="text-xs py-2">{resolveMethodLabel(row.inspection_method)}</TableCell>
<TableCell className="text-xs py-2">{row.apply_process || "-"}</TableCell>
<TableCell className="text-xs py-2">
{row.judgment_criteria ? (
<Badge variant="outline" className="text-[10px]">{row.judgment_criteria}</Badge>
) : "-"}
</TableCell>
<TableCell className="text-xs py-2 font-mono">{row.pass_criteria || "-"}</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>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
</div>
</div>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
{/* ═══════════════════ 등록/수정 모달 ═══════════════════ */}
<Dialog open={modalOpen} onOpenChange={(open) => { if (!open) setItemModalOpen(false); setModalOpen(open); }}>
<DialogContent className={cn("max-w-[95vw] max-h-[85vh] overflow-y-auto", itemModalOpen ? "sm:max-w-xl" : "sm:max-w-4xl")}>
{itemModalOpen ? (
@@ -502,16 +574,8 @@ export default function ItemInspectionInfoPage() {
<DialogDescription> </DialogDescription>
</DialogHeader>
<div className="flex gap-2">
<Input
className="h-9 flex-1"
placeholder="품목코드 또는 품목명으로 검색"
value={itemSearchKeyword}
onChange={(e) => setItemSearchKeyword(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleItemSearch(); }}
/>
<Button size="sm" className="h-9" onClick={handleItemSearch}>
<Search className="w-4 h-4 mr-1" />
</Button>
<Input className="h-9 flex-1" placeholder="품목코드 또는 품목명" value={itemSearchKeyword} onChange={(e) => setItemSearchKeyword(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") handleItemSearch(); }} />
<Button size="sm" className="h-9" onClick={handleItemSearch}><Search className="w-4 h-4 mr-1" /></Button>
</div>
<div className="border rounded-lg overflow-auto max-h-[50vh]">
<Table>
@@ -527,11 +591,7 @@ export default function ItemInspectionInfoPage() {
{filteredItems.length === 0 ? (
<TableRow><TableCell colSpan={4} className="text-center py-8 text-muted-foreground text-sm"> </TableCell></TableRow>
) : filteredItems.map((item) => (
<TableRow
key={item.code}
className="cursor-pointer hover:bg-primary/5"
onClick={() => selectItem(item)}
>
<TableRow key={item.code} className="cursor-pointer hover:bg-primary/5" onClick={() => selectItem(item)}>
<TableCell className="text-sm">{item.code}</TableCell>
<TableCell className="text-sm">{item.name}</TableCell>
<TableCell className="text-sm">{item.item_type}</TableCell>
@@ -541,9 +601,7 @@ export default function ItemInspectionInfoPage() {
</TableBody>
</Table>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setItemModalOpen(false)}></Button>
</DialogFooter>
<DialogFooter><Button variant="outline" onClick={() => setItemModalOpen(false)}></Button></DialogFooter>
</>
) : (
<>
@@ -554,14 +612,14 @@ export default function ItemInspectionInfoPage() {
<div className="space-y-4">
{/* 품목 정보 */}
<div className="space-y-3">
<h4 className="text-sm font-semibold flex items-center gap-1.5">📦 </h4>
<h4 className="text-sm font-semibold"> </h4>
<div className="flex items-end gap-2">
<div className="space-y-1.5 flex-1">
<Label className="text-xs font-semibold text-muted-foreground"> <span className="text-destructive">*</span></Label>
<Input className="h-9 bg-muted" value={form.item_code || ""} readOnly placeholder="품목코드" />
</div>
<div className="space-y-1.5 flex-1">
<Label className="text-xs font-semibold text-muted-foreground"> <span className="text-destructive">*</span></Label>
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Input className="h-9 bg-muted" value={form.item_name || ""} readOnly placeholder="품목명" />
</div>
<Button type="button" variant="outline" size="sm" className="h-9 px-3 shrink-0" onClick={openItemModal}>
@@ -571,7 +629,7 @@ export default function ItemInspectionInfoPage() {
<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={form.is_active === false ? "N" : "Y"} onValueChange={(v) => setForm(p => ({ ...p, is_active: v === "Y" }))}>
<Select value={form.is_active === false || form.is_active === "N" ? "N" : "Y"} onValueChange={(v) => setForm(p => ({ ...p, is_active: v === "Y" ? "사용" : "미사용" }))}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="Y"></SelectItem>
@@ -583,50 +641,32 @@ export default function ItemInspectionInfoPage() {
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Select value={form.manager || ""} onValueChange={(v) => setForm(p => ({ ...p, manager: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="관리자 선택" /></SelectTrigger>
<SelectContent>
{userOptions.map(o => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
<SelectContent>{userOptions.map(o => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<textarea
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm min-h-[70px] resize-none focus:outline-none focus:ring-2 focus:ring-ring"
value={form.remarks || ""}
onChange={(e) => setForm(p => ({ ...p, remarks: e.target.value }))}
placeholder="비고 사항"
/>
</div>
</div>
{/* 검사유형 선택 */}
<div className="space-y-3">
<h4 className="text-sm font-semibold flex items-center gap-1.5"> </h4>
<h4 className="text-sm font-semibold"> </h4>
<div className="flex flex-wrap gap-4">
{INSPECTION_TYPES.map(({ key, label }) => (
<div key={key} className="flex items-center gap-1.5">
<Checkbox
checked={!!form[key]}
onCheckedChange={(v) => setForm(p => ({ ...p, [key]: !!v }))}
/>
<Checkbox checked={!!form[key]} onCheckedChange={(v) => setForm(p => ({ ...p, [key]: !!v }))} />
<Label className="text-sm cursor-pointer">{label}</Label>
</div>
))}
</div>
</div>
{/* 검사유형별 검사항목 설정 */}
{INSPECTION_TYPES.filter(t => !!form[t.key]).map(({ key, label }) => (
<div key={key} className="space-y-2">
<button
type="button"
className="w-full flex items-center gap-2 py-2 px-3 rounded-md border bg-muted/50 hover:bg-muted text-left"
onClick={() => toggleCollapse(key)}
>
<button type="button" className="w-full flex items-center gap-2 py-2 px-3 rounded-md border bg-muted/50 hover:bg-muted text-left" onClick={() => toggleCollapse(key)}>
<Badge variant="default" className="text-xs">{label}</Badge>
<span className="text-sm font-medium"> </span>
<ChevronDown className={cn("w-4 h-4 ml-auto transition-transform", collapsedTypes[key] && "-rotate-90")} />
<span className="text-xs text-muted-foreground ml-auto">{(inspectionRows[key] || []).length}</span>
</button>
{!collapsedTypes[key] && (
<div className="space-y-2 pl-1">
@@ -641,10 +681,10 @@ export default function ItemInspectionInfoPage() {
<TableHeader>
<TableRow className="bg-muted/50 hover:bg-muted/50">
<TableHead className="text-[10px] font-bold w-[170px]"> </TableHead>
<TableHead className="text-[10px] font-bold w-[130px]"> </TableHead>
<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-[100px]"></TableHead>
<TableHead className="text-[10px] font-bold"> ()</TableHead>
<TableHead className="text-[10px] font-bold"></TableHead>
<TableHead className="text-[10px] font-bold w-[40px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[36px]" />
</TableRow>
@@ -657,46 +697,20 @@ export default function ItemInspectionInfoPage() {
<TableCell className="p-1">
<Select value={row.inspection_standard_id || ""} onValueChange={(v) => updateInspRow(key, row.id, "inspection_standard_id", v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="검사기준 선택" /></SelectTrigger>
<SelectContent>
{getFilteredInspOptions(key).map(o => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
<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-8 text-xs bg-muted" value={row.inspection_detail} readOnly placeholder="자동" /></TableCell>
<TableCell className="p-1"><Input className="h-8 text-xs bg-muted" value={row.inspection_method} readOnly placeholder="자동" /></TableCell>
<TableCell className="p-1">
<Input className="h-8 text-xs bg-muted" value={row.inspection_detail} readOnly placeholder="검사기준 상세" />
<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 bg-muted" value={row.inspection_method} readOnly placeholder="선택" />
<Input className="h-8 text-xs" value={row.acceptance_criteria} onChange={(e) => updateInspRow(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) => updateInspRow(key, row.id, "is_required", !!v)} /></TableCell>
<TableCell className="p-1">
<Select value={row.apply_process || ""} onValueChange={(v) => updateInspRow(key, row.id, "apply_process", v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="공정 선택" /></SelectTrigger>
<SelectContent>
<SelectItem value="incoming"></SelectItem>
<SelectItem value="process"></SelectItem>
<SelectItem value="outgoing"></SelectItem>
<SelectItem value="final"></SelectItem>
</SelectContent>
</Select>
</TableCell>
<TableCell className="p-1">
<Input
className="h-8 text-xs"
value={row.acceptance_criteria}
onChange={(e) => updateInspRow(key, row.id, "acceptance_criteria", e.target.value)}
placeholder={row.inspection_standard_id ? "합격기준 입력" : "검사기준을 먼저 선택하세요"}
disabled={!row.inspection_standard_id}
/>
</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">
<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>
<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>
</TableRow>
))}
@@ -711,8 +725,7 @@ export default function ItemInspectionInfoPage() {
<DialogFooter>
<Button variant="outline" onClick={() => setModalOpen(false)}></Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Save className="w-4 h-4 mr-1" />}
{saving ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Save className="w-4 h-4 mr-1" />}
</Button>
</DialogFooter>
</>
@@ -720,14 +733,7 @@ export default function ItemInspectionInfoPage() {
</DialogContent>
</Dialog>
<TableSettingsModal
open={ts.open}
onOpenChange={ts.setOpen}
tableName={ts.tableName}
settingsId={ts.settingsId}
defaultVisibleKeys={ts.defaultVisibleKeys}
onSave={ts.applySettings}
/>
<TableSettingsModal open={ts.open} onOpenChange={ts.setOpen} tableName={ts.tableName} settingsId={ts.settingsId} defaultVisibleKeys={ts.defaultVisibleKeys} onSave={ts.applySettings} />
</div>
);
}
@@ -195,6 +195,12 @@ export default function CustomerManagementPage() {
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
} catch { /* skip */ }
}
for (const col of ["division", "unit", "material"]) {
try {
const res = await apiClient.get(`/table-categories/item_info/${col}/values`);
if (res.data?.success) optMap[`item_${col}`] = flatten(res.data.data || []);
} catch { /* skip */ }
}
setCategoryOptions(optMap);
const priceOpts: Record<string, { code: string; label: string }[]> = {};
@@ -709,7 +715,7 @@ export default function CustomerManagementPage() {
const handleCustomerSave = async () => {
if (!customerForm.customer_name) { toast.error("거래처명은 필수입니다."); return; }
if (!customerForm.status) { toast.error("상태는 필수입니다."); return; }
const errors = validateForm(customerForm, ["contact_phone", "email", "business_number"]);
const errors = validateForm(customerForm, ["business_number"]);
setFormErrors(errors);
if (Object.keys(errors).length > 0) {
toast.error("입력 형식을 확인해주세요.");
@@ -811,11 +817,12 @@ export default function CustomerManagementPage() {
const searchItems = useCallback(async () => {
setItemSearchLoading(true);
try {
const filters: any[] = [
{ columnName: "division", operator: "contains", value: "CAT_ML8ZFVEL_1TOR" },
];
const salesCode = categoryOptions["item_division"]?.find((o) => o.label === "영업관리")?.code;
const filters: any[] = salesCode
? [{ columnName: "division", operator: "contains", value: salesCode }]
: [];
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 500,
page: 1, size: 5000,
dataFilter: { enabled: true, filters },
autoFilter: true,
});
@@ -988,6 +995,7 @@ export default function CustomerManagementPage() {
{ columnName: "customer_id", operator: "equals", value: selectedCustomer!.customer_code },
{ columnName: "item_id", operator: "equals", value: itemKey },
]}, autoFilter: true,
sort: { columnName: "created_date", order: "asc" },
});
const allMappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
mappingRows = allMappings
@@ -1008,7 +1016,8 @@ export default function CustomerManagementPage() {
{ columnName: "item_id", operator: "equals", value: itemKey },
]}, autoFilter: true,
});
const allPriceData = priceRes.data?.data?.data || priceRes.data?.data?.rows || [];
const allPriceData = (priceRes.data?.data?.data || priceRes.data?.data?.rows || [])
.sort((a: any, b: any) => (a.start_date || "").localeCompare(b.start_date || ""));
priceRows = allPriceData.map((p: any) => ({
_id: `p_existing_${p.id}`,
start_date: p.start_date ? String(p.start_date).split("T")[0] : "",
@@ -1063,6 +1072,7 @@ export default function CustomerManagementPage() {
{ columnName: "customer_id", operator: "equals", value: selectedCustomer.customer_code },
{ columnName: "item_id", operator: "equals", value: itemKey },
]}, autoFilter: true,
sort: { columnName: "created_date", order: "asc" },
});
existingMaps = existingMappings.data?.data?.data || existingMappings.data?.data?.rows || [];
} catch { /* skip */ }
@@ -1112,7 +1122,8 @@ export default function CustomerManagementPage() {
{ columnName: "item_id", operator: "equals", value: itemKey },
]}, autoFilter: true,
});
existingPriceRows = existingPrices.data?.data?.data || existingPrices.data?.data?.rows || [];
existingPriceRows = (existingPrices.data?.data?.data || existingPrices.data?.data?.rows || [])
.sort((a: any, b: any) => (a.start_date || "").localeCompare(b.start_date || ""));
} catch { /* skip */ }
// 단가 upsert
@@ -1866,35 +1877,6 @@ export default function CustomerManagementPage() {
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input
value={customerForm.contact_person || ""}
onChange={(e) => setCustomerForm((p) => ({ ...p, contact_person: e.target.value }))}
placeholder="거래처담당자"
className="h-9"
/>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input
value={customerForm.contact_phone || ""}
onChange={(e) => handleFormChange("contact_phone", e.target.value)}
placeholder="010-0000-0000"
className={cn("h-9", formErrors.contact_phone && "border-destructive")}
/>
{formErrors.contact_phone && <p className="text-xs text-destructive">{formErrors.contact_phone}</p>}
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input
value={customerForm.email || ""}
onChange={(e) => handleFormChange("email", e.target.value)}
placeholder="example@email.com"
className={cn("h-9", formErrors.email && "border-destructive")}
/>
{formErrors.email && <p className="text-xs text-destructive">{formErrors.email}</p>}
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input
@@ -2478,8 +2460,8 @@ export default function CustomerManagementPage() {
</TableCell>
<TableCell className="text-sm">{item.item_name}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{item.size}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{item.material}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{item.unit}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{categoryOptions["item_material"]?.find((o) => o.code === item.material)?.label || item.material}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{categoryOptions["item_unit"]?.find((o) => o.code === item.unit)?.label || item.unit}</TableCell>
</TableRow>
))}
</TableBody>
@@ -2521,7 +2503,7 @@ export default function CustomerManagementPage() {
{/* 품목 헤더 */}
<div className="px-5 py-3 bg-muted/30 border-b">
<div className="font-bold">{idx + 1}. {item.item_name || itemKey}</div>
<div className="text-xs text-muted-foreground">{itemKey} | {item.size || ""} | {item.unit || ""}</div>
<div className="text-xs text-muted-foreground">{itemKey} | {item.size || ""} | {categoryOptions["item_unit"]?.find((o) => o.code === item.unit)?.label || item.unit || ""}</div>
</div>
<div className="flex gap-4 p-5 items-stretch">
@@ -359,7 +359,7 @@ export default function SalesOrderPage() {
} finally {
setLoading(false);
}
}, [searchFilters]);
}, [searchFilters, categoryOptions]);
useEffect(() => { fetchOrders(); }, [fetchOrders]);
@@ -517,29 +517,44 @@ export default function SalesOrderPage() {
}
};
// 삭제 (마스터 단위)
// 삭제 (선택한 디테일 삭제 → 디테일 0건인 마스터 자동 삭제)
const handleDelete = async () => {
if (checkedIds.length === 0) { toast.error("삭제할 수주를 선택해주세요."); return; }
const selectedItems = orders.filter((o) => checkedIds.includes(o.id));
const orderNos = [...new Set(selectedItems.map((o) => o.order_no))];
const ok = await confirm(`${orderNos.length}건의 수주를 삭제하시겠습니까?`, {
if (checkedIds.length === 0) { toast.error("삭제할 항목을 선택해주세요."); return; }
const ok = await confirm(`${checkedIds.length}건의 수주 항목을 삭제하시겠습니까?`, {
description: "삭제된 데이터는 복구할 수 없습니다.",
variant: "destructive",
confirmText: "삭제",
});
if (!ok) return;
try {
// 1. 선택한 디테일 삭제
await apiClient.delete(`/table-management/tables/${DETAIL_TABLE}/delete`, {
data: checkedIds.map((id) => ({ id })),
});
// 2. 영향받는 수주번호의 잔여 디테일 확인 → 0건이면 마스터도 삭제
const selectedItems = orders.filter((o) => checkedIds.includes(o.id));
const orderNos = [...new Set(selectedItems.map((o) => o.order_no))];
for (const orderNo of orderNos) {
const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, {
const remainRes = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
page: 1, size: 1,
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] },
autoFilter: true,
});
const masters = masterRes.data?.data?.data || masterRes.data?.data?.rows || [];
if (masters.length > 0) {
await apiClient.delete(`/table-management/tables/${MASTER_TABLE}/delete`, {
data: masters.map((m: any) => ({ id: m.id })),
const remaining = remainRes.data?.data?.data || remainRes.data?.data?.rows || [];
if (remaining.length === 0) {
// 디테일 0건 → 마스터 삭제
const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, {
page: 1, size: 1,
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] },
autoFilter: true,
});
const masters = masterRes.data?.data?.data || masterRes.data?.data?.rows || [];
if (masters.length > 0) {
await apiClient.delete(`/table-management/tables/${MASTER_TABLE}/delete`, {
data: masters.map((m: any) => ({ id: m.id })),
});
}
}
}
toast.success("삭제되었습니다.");
@@ -918,7 +933,7 @@ export default function SalesOrderPage() {
{/* 데이터 테이블 (플랫 리스트) */}
<div className="flex-1 overflow-hidden rounded-lg border border-border bg-card">
<div className="h-full overflow-auto">
<Table style={{ minWidth: "1500px" }}>
<Table noWrapper style={{ minWidth: "1500px" }}>
<colgroup>
<col style={{ width: "40px" }} />
<col style={{ width: "140px" }} />
@@ -1107,6 +1122,7 @@ export default function SalesOrderPage() {
<Select value={masterForm.input_mode || ""} onValueChange={(v) => {
setMasterForm((p) => {
const next = { ...p, input_mode: v };
// 입력방식 변경 시 거래처 관련 값 초기화
delete next.partner_id;
delete next.delivery_partner_id;
delete next.delivery_address;
@@ -264,6 +264,7 @@ export default function SalesItemPage() {
calculated_price: string;
}>>>({});
const [editCustData, setEditCustData] = useState<any>(null);
const [collapsedPriceCards, setCollapsedPriceCards] = useState<Set<string>>(new Set());
// 카테고리 로드
@@ -310,8 +311,11 @@ export default function SalesItemPage() {
try {
const filters: { columnName: string; operator: string; value: any }[] = [];
// 판매품목/영업관리 division 필터 (다중값 컬럼이므로 contains로 매칭)
filters.push({ columnName: "division", operator: "contains", value: "CAT_ML8ZFVEL_1TOR" });
// 영업관리 division 필터: 카테고리에서 "영업관리" 라벨의 코드를 찾아서 필터링
const salesCode = categoryOptions["division"]?.find((o) => o.label === "영업관리")?.code;
if (salesCode) {
filters.push({ columnName: "division", operator: "contains", value: salesCode });
}
// DynamicSearchFilter에서 전달된 필터 추가
for (const f of searchFilters) {
@@ -319,7 +323,7 @@ export default function SalesItemPage() {
}
const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
page: 1, size: 500,
page: 1, size: 5000,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
@@ -368,6 +372,7 @@ export default function SalesItemPage() {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "item_id", operator: "equals", value: itemKey }] },
autoFilter: true,
sort: { columnName: "created_date", order: "asc" },
});
const mappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
@@ -618,6 +623,7 @@ export default function SalesItemPage() {
{ columnName: "customer_id", operator: "equals", value: custKey },
{ columnName: "item_id", operator: "equals", value: selectedItem!.item_number },
]}, autoFilter: true,
sort: { columnName: "created_date", order: "asc" },
});
const allMappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
mappingRows = allMappings
@@ -639,7 +645,8 @@ export default function SalesItemPage() {
{ columnName: "item_id", operator: "equals", value: selectedItem!.item_number },
]}, autoFilter: true,
});
const allPriceData = priceRes.data?.data?.data || priceRes.data?.data?.rows || [];
const allPriceData = (priceRes.data?.data?.data || priceRes.data?.data?.rows || [])
.sort((a: any, b: any) => (a.start_date || "").localeCompare(b.start_date || ""));
priceRows = allPriceData.map((p: any) => ({
_id: `p_existing_${p.id}`,
start_date: p.start_date ? String(p.start_date).split("T")[0] : "",
@@ -680,104 +687,104 @@ export default function SalesItemPage() {
const mappingRows = custMappings[custKey] || [];
if (isEditingExisting && editCustData?.id) {
// 매핑: 기존→UPDATE, 신규→INSERT, 삭제된→DELETE
const keptIds = new Set<string>();
let firstMappingId: string | null = null;
// 기존 매핑 조회
let existingMaps: any[] = [];
try {
const existingMappings = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
page: 1, size: 100,
dataFilter: { enabled: true, filters: [
{ columnName: "customer_id", operator: "equals", value: custKey },
{ columnName: "item_id", operator: "equals", value: selectedItem.item_number },
]}, autoFilter: true,
sort: { columnName: "created_date", order: "asc" },
});
existingMaps = existingMappings.data?.data?.data || existingMappings.data?.data?.rows || [];
} catch { /* skip */ }
// 매핑 upsert: 인덱스 기반
const usedExistingIds = new Set<string>();
let firstMappingId: string | null = editCustData.id;
for (let mi = 0; mi < mappingRows.length; mi++) {
const m = mappingRows[mi];
if (m._id?.startsWith("m_existing_")) {
const realId = m._id.replace("m_existing_", "");
keptIds.add(realId);
if (mi === 0) firstMappingId = realId;
const existMap = existingMaps[mi];
if (existMap) {
await apiClient.put(`/table-management/tables/${MAPPING_TABLE}/edit`, {
originalData: { id: realId },
originalData: { id: existMap.id },
updatedData: {
customer_item_code: m.customer_item_code || "",
customer_item_name: m.customer_item_name || "",
customer_item_code: mappingRows[mi].customer_item_code || "",
customer_item_name: mappingRows[mi].customer_item_name || "",
},
});
usedExistingIds.add(existMap.id);
if (mi === 0) firstMappingId = existMap.id;
} else {
const res = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, {
const mRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, {
id: crypto.randomUUID(),
customer_id: custKey, item_id: selectedItem.item_number,
customer_item_code: m.customer_item_code || "",
customer_item_name: m.customer_item_name || "",
customer_item_code: mappingRows[mi].customer_item_code || "",
customer_item_name: mappingRows[mi].customer_item_name || "",
});
if (mi === 0) firstMappingId = res.data?.data?.id || null;
if (mi === 0 && !firstMappingId) firstMappingId = mRes.data?.data?.id || null;
}
}
// 폼에서 삭제된 매핑 → DB 삭제
// 초과분 delete
const toDeleteMaps = existingMaps.filter((m) => !usedExistingIds.has(m.id));
if (toDeleteMaps.length > 0) {
await apiClient.delete(`/table-management/tables/${MAPPING_TABLE}/delete`, {
data: toDeleteMaps.map((m: any) => ({ id: m.id })),
});
}
// 기존 단가 조회
let existingPriceRows: any[] = [];
try {
const dbMappings = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
const existingPrices = await apiClient.post(`/table-management/tables/customer_item_prices/data`, {
page: 1, size: 100,
dataFilter: { enabled: true, filters: [
{ columnName: "customer_id", operator: "equals", value: custKey },
{ columnName: "item_id", operator: "equals", value: selectedItem.item_number },
]}, autoFilter: true,
});
const toDelete = (dbMappings.data?.data?.data || dbMappings.data?.data?.rows || [])
.filter((m: any) => !keptIds.has(m.id));
if (toDelete.length > 0) {
await apiClient.delete(`/table-management/tables/${MAPPING_TABLE}/delete`, {
data: toDelete.map((m: any) => ({ id: m.id })),
});
}
existingPriceRows = existingPrices.data?.data?.data || existingPrices.data?.data?.rows || [];
} catch { /* skip */ }
if (!firstMappingId) firstMappingId = editCustData.id;
// 단가: 기존→UPDATE, 신규→INSERT, 삭제된→DELETE
const keptPriceIds = new Set<string>();
// 단가 upsert: 인덱스 기반
const priceRows = (custPrices[custKey] || []).filter((p) =>
p.base_price || p.start_date || p.currency_code || p.base_price_type
);
for (const price of priceRows) {
if (price._id?.startsWith("p_existing_")) {
const realId = price._id.replace("p_existing_", "");
keptPriceIds.add(realId);
const usedPriceIds = new Set<string>();
for (let pi = 0; pi < priceRows.length; pi++) {
const price = priceRows[pi];
const priceData = {
mapping_id: firstMappingId || editCustData.id,
customer_id: custKey, item_id: selectedItem.item_number,
start_date: price.start_date || null, end_date: price.end_date || null,
currency_code: price.currency_code || null, base_price_type: price.base_price_type || null,
base_price: price.base_price ? Number(price.base_price) : null,
unit_price: price.calculated_price ? Number(price.calculated_price) : (price.base_price ? Number(price.base_price) : null),
discount_type: price.discount_type || null, discount_value: price.discount_value ? Number(price.discount_value) : null,
rounding_type: price.rounding_type || null, rounding_unit_value: price.rounding_unit_value || null,
calculated_price: price.calculated_price ? Number(price.calculated_price) : null,
};
const existPrice = existingPriceRows[pi];
if (existPrice) {
await apiClient.put(`/table-management/tables/customer_item_prices/edit`, {
originalData: { id: realId },
updatedData: {
mapping_id: firstMappingId || "",
start_date: price.start_date || null, end_date: price.end_date || null,
currency_code: price.currency_code || null, base_price_type: price.base_price_type || null,
base_price: price.base_price ? Number(price.base_price) : null,
discount_type: price.discount_type || null, discount_value: price.discount_value ? Number(price.discount_value) : null,
rounding_type: price.rounding_type || null, rounding_unit_value: price.rounding_unit_value || null,
calculated_price: price.calculated_price ? Number(price.calculated_price) : null,
},
originalData: { id: existPrice.id },
updatedData: priceData,
});
usedPriceIds.add(existPrice.id);
} else {
await apiClient.post(`/table-management/tables/customer_item_prices/add`, {
id: crypto.randomUUID(),
mapping_id: firstMappingId || "",
customer_id: custKey, item_id: selectedItem.item_number,
start_date: price.start_date || null, end_date: price.end_date || null,
currency_code: price.currency_code || null, base_price_type: price.base_price_type || null,
base_price: price.base_price ? Number(price.base_price) : null,
discount_type: price.discount_type || null, discount_value: price.discount_value ? Number(price.discount_value) : null,
rounding_type: price.rounding_type || null, rounding_unit_value: price.rounding_unit_value || null,
calculated_price: price.calculated_price ? Number(price.calculated_price) : null,
id: crypto.randomUUID(), ...priceData,
});
}
}
// 폼에서 삭제된 단가 → DB 삭제
try {
const dbPrices = await apiClient.post(`/table-management/tables/customer_item_prices/data`, {
page: 1, size: 100,
dataFilter: { enabled: true, filters: [
{ columnName: "customer_id", operator: "equals", value: custKey },
{ columnName: "item_id", operator: "equals", value: selectedItem.item_number },
]}, autoFilter: true,
// 초과분 delete
const toDeletePrices = existingPriceRows.filter((p) => !usedPriceIds.has(p.id));
if (toDeletePrices.length > 0) {
await apiClient.delete(`/table-management/tables/customer_item_prices/delete`, {
data: toDeletePrices.map((p: any) => ({ id: p.id })),
});
const toDeletePrices = (dbPrices.data?.data?.data || dbPrices.data?.data?.rows || [])
.filter((p: any) => !keptPriceIds.has(p.id));
if (toDeletePrices.length > 0) {
await apiClient.delete(`/table-management/tables/customer_item_prices/delete`, {
data: toDeletePrices.map((p: any) => ({ id: p.id })),
});
}
} catch { /* skip */ }
}
} else {
// 신규 등록
const mappingRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, {
@@ -807,6 +814,7 @@ export default function SalesItemPage() {
start_date: price.start_date || null, end_date: price.end_date || null,
currency_code: price.currency_code || null, base_price_type: price.base_price_type || null,
base_price: price.base_price ? Number(price.base_price) : null,
unit_price: price.calculated_price ? Number(price.calculated_price) : (price.base_price ? Number(price.base_price) : null),
discount_type: price.discount_type || null, discount_value: price.discount_value ? Number(price.discount_value) : null,
rounding_type: price.rounding_type || null, rounding_unit_value: price.rounding_unit_value || null,
calculated_price: price.calculated_price ? Number(price.calculated_price) : null,
@@ -823,7 +831,10 @@ export default function SalesItemPage() {
setSelectedItemId(null);
setTimeout(() => setSelectedItemId(sid), 50);
} catch (err: any) {
toast.error(err.response?.data?.message || "저장 실패했습니다.");
console.error("거래처 상세 저장 실패:", err.response?.data);
const detail = err.response?.data?.error?.details;
const msg = err.response?.data?.message || "저장에 실패했습니다.";
toast.error(detail ? `${msg} (${typeof detail === "string" ? detail : JSON.stringify(detail)})` : msg);
} finally {
setSaving(false);
}
@@ -1716,7 +1727,7 @@ export default function SalesItemPage() {
{/* ── 거래처 상세 입력/수정 모달 ── */}
<Dialog open={custDetailOpen} onOpenChange={setCustDetailOpen}>
<DialogContent className="max-w-[1100px] max-h-[90vh] overflow-y-auto">
<DialogContent className="max-w-[1100px] h-[85vh] flex flex-col overflow-hidden">
<DialogHeader>
<DialogTitle>
{editCustData ? "수정" : "입력"} {selectedItem?.item_name || ""}
@@ -1726,7 +1737,7 @@ export default function SalesItemPage() {
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-2">
<div className="space-y-6 py-2 flex-1 overflow-y-auto">
{selectedCustsForDetail.map((cust, idx) => {
const custKey = cust.customer_code || cust.id;
const mappingRows = custMappings[custKey] || [];
@@ -1742,17 +1753,17 @@ export default function SalesItemPage() {
</span>
</div>
<div className="flex gap-4 p-4 bg-card">
<div className="flex gap-4 p-4 bg-card items-stretch">
{/* 좌: 거래처 품번/품명 */}
<div className="flex-1 border rounded-lg p-4 bg-muted/50">
<div className="flex items-center justify-between mb-3">
<div className="flex-1 border rounded-lg p-4 bg-muted/50 flex flex-col">
<div className="flex items-center justify-between mb-3 shrink-0">
<span className="text-sm font-semibold text-foreground"> / </span>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => addMappingRow(custKey)}>
<Plus className="h-3 w-3" />
</Button>
</div>
<div className="space-y-2">
<div className="space-y-2 min-h-[200px] max-h-[350px] overflow-y-auto flex-1">
{mappingRows.length === 0 ? (
<div className="text-xs text-muted-foreground py-2"> </div>
) : (
@@ -1792,35 +1803,61 @@ export default function SalesItemPage() {
</div>
{/* 우: 기간별 단가 */}
<div className="flex-1 border rounded-lg p-4 bg-muted/30">
<div className="flex items-center justify-between mb-3">
<div className="flex-1 border rounded-lg p-4 bg-muted/30 flex flex-col">
<div className="flex items-center justify-between mb-3 shrink-0">
<span className="text-sm font-semibold text-foreground"> </span>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => addPriceRow(custKey)}>
<Plus className="h-3 w-3" />
</Button>
</div>
<div className="space-y-3">
<div className="space-y-3 flex-1 overflow-y-auto max-h-[350px]">
{prices.map((price, pIdx) => (
<div key={price._id} className="border rounded-lg p-3 bg-background space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground"> {pIdx + 1}</span>
<div key={price._id} className="border rounded-lg bg-background overflow-hidden">
<div
className="flex items-center justify-between px-3 py-2 cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => setCollapsedPriceCards((prev) => {
const next = new Set(prev);
if (next.has(price._id)) next.delete(price._id); else next.add(price._id);
return next;
})}
>
<div className="flex items-center gap-2">
{collapsedPriceCards.has(price._id)
? <ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
: <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
}
<span className="text-xs font-medium text-muted-foreground"> {pIdx + 1}</span>
{collapsedPriceCards.has(price._id) && price.calculated_price && (
<span className="text-xs text-muted-foreground ml-2">
{price.start_date || "—"} ~ {price.end_date || "—"} · <span className="font-mono font-bold text-foreground">{Number(price.calculated_price).toLocaleString()}</span> {priceCategoryOptions["currency_code"]?.find((o) => o.code === price.currency_code)?.label || ""}
</span>
)}
</div>
{prices.length > 1 && (
<Button
variant="ghost" size="sm"
className="h-6 w-6 p-0 text-destructive"
onClick={() => removePriceRow(custKey, price._id)}
onClick={(e) => { e.stopPropagation(); removePriceRow(custKey, price._id); }}
>
<X className="h-3 w-3" />
</Button>
)}
</div>
{!collapsedPriceCards.has(price._id) && <div className="px-3 pb-3 space-y-2">
{/* 기간 + 통화 */}
<div className="flex gap-2 items-center">
<Input
type="date"
value={price.start_date}
onChange={(e) => updatePriceRow(custKey, price._id, "start_date", e.target.value)}
onChange={(e) => {
const v = e.target.value;
updatePriceRow(custKey, price._id, "start_date", v);
if (price.end_date && v > price.end_date) {
updatePriceRow(custKey, price._id, "end_date", v);
}
}}
max={price.end_date || undefined}
className="h-8 text-xs flex-1"
/>
<span className="text-xs text-muted-foreground">~</span>
@@ -1828,6 +1865,7 @@ export default function SalesItemPage() {
type="date"
value={price.end_date}
onChange={(e) => updatePriceRow(custKey, price._id, "end_date", e.target.value)}
min={price.start_date || undefined}
className="h-8 text-xs flex-1"
/>
<div className="w-[80px]">
@@ -1922,6 +1960,7 @@ export default function SalesItemPage() {
{price.calculated_price ? Number(price.calculated_price).toLocaleString() : "-"}
</span>
</div>
</div>}
</div>
))}
</div>
@@ -38,6 +38,30 @@ const fmtDate = (v: any) => {
return m ? `${m[1]}-${m[2]}-${m[3]}` : s.substring(0, 10);
};
/** remark가 JSON이면 사람이 읽을 수 있는 텍스트로 변환 */
const parseRemark = (remark: string | null | undefined): string => {
if (!remark) return "";
const trimmed = remark.trim();
if (!trimmed.startsWith("{")) return trimmed;
try {
const d = JSON.parse(trimmed);
switch (d.type) {
case "move":
return `창고이동 (${d.from_warehouse}${d.to_warehouse})`;
case "adjust":
return `재고조정 (${d.reason || "사유 없음"}, ${d.system_qty}${d.actual_qty}, 차이:${d.diff >= 0 ? "+" : ""}${d.diff})`;
case "confirm":
return `재고확인 (${d.reason || "이상없음"})`;
case "process_inbound":
return "공정입고";
default:
return d.reason || d.memo || trimmed;
}
} catch {
return trimmed;
}
};
export default function InboundOutboundPage() {
const { user } = useAuth();
@@ -62,7 +86,6 @@ export default function InboundOutboundPage() {
try {
const filters: any[] = [];
if (typeFilter !== "all") filters.push({ columnName: "transaction_type", operator: "equals", value: typeFilter });
if (categoryFilter !== "all") filters.push({ columnName: "remark", operator: "equals", value: categoryFilter });
for (const f of searchFilters) {
if (f.value) filters.push({ columnName: f.columnName, operator: f.operator, value: f.value });
@@ -81,6 +104,21 @@ export default function InboundOutboundPage() {
const itemCodes = [...new Set(rows.map((r: any) => r.item_code).filter(Boolean))];
if (itemCodes.length > 0) {
try {
// 단위 카테고리 코드→라벨 매핑 로드
let unitLabelMap: Record<string, string> = {};
try {
const catRes = await apiClient.get("/table-categories/item_info/unit/values");
if (catRes.data?.success && catRes.data.data?.length > 0) {
const flatten = (vals: any[]) => {
for (const v of vals) {
unitLabelMap[v.valueCode] = v.valueLabel;
if (v.children?.length) flatten(v.children);
}
};
flatten(catRes.data.data);
}
} catch { /* skip */ }
const itemRes = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: itemCodes.length + 10,
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: itemCodes }] },
@@ -89,7 +127,8 @@ export default function InboundOutboundPage() {
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
const map: Record<string, { item_name: string; unit: string }> = {};
for (const i of items) {
if (!map[i.item_number]) map[i.item_number] = { item_name: i.item_name || "", unit: i.unit || "" };
const rawUnit = i.unit || "";
if (!map[i.item_number]) map[i.item_number] = { item_name: i.item_name || "", unit: unitLabelMap[rawUnit] || rawUnit };
}
setItemMap(map);
} catch { /* skip */ }
@@ -124,7 +163,7 @@ export default function InboundOutboundPage() {
} finally {
setLoading(false);
}
}, [searchFilters, typeFilter, categoryFilter]);
}, [searchFilters, typeFilter]);
useEffect(() => { fetchData(); }, [fetchData]);
@@ -132,20 +171,26 @@ export default function InboundOutboundPage() {
const categoryOptions = useMemo(() => {
const set = new Set<string>();
data.forEach((r) => { if (r.remark) set.add(r.remark); });
data.forEach((r) => { if (r.remark) set.add(parseRemark(r.remark)); });
return Array.from(set).sort();
}, [data]);
// ════════ 그룹핑 ════════
// 카테고리 필터 (클라이언트)
const filteredData = useMemo(() => {
if (categoryFilter === "all") return data;
return data.filter((r) => parseRemark(r.remark) === categoryFilter);
}, [data, categoryFilter]);
const groupedData = useMemo(() => {
if (groupBy === "none") return data;
if (groupBy === "none") return filteredData;
const groups = new Map<string, any[]>();
for (const row of data) {
for (const row of filteredData) {
let key: string;
switch (groupBy) {
case "transaction_type": key = row.transaction_type || "미지정"; break;
case "remark": key = row.remark || "미지정"; break;
case "remark": key = parseRemark(row.remark) || "미지정"; break;
case "warehouse_code": key = warehouseMap[row.warehouse_code] || row.warehouse_code || "미지정"; break;
case "item_code": key = `${row.item_code} ${itemMap[row.item_code]?.item_name || ""}`.trim() || "미지정"; break;
default: key = row[groupBy] || "미지정";
@@ -191,7 +236,7 @@ export default function InboundOutboundPage() {
const rows = data.map((r, i) => ({
No: i + 1,
입출고구분: r.transaction_type || "",
카테고리: r.remark || "",
카테고리: parseRemark(r.remark),
처리일자: fmtDate(r.transaction_date),
창고: warehouseMap[r.warehouse_code] || r.warehouse_code || "",
위치: r.location_code || "",
@@ -252,7 +297,7 @@ export default function InboundOutboundPage() {
<div className="flex items-center gap-2">
<Package className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-semibold"> </span>
<Badge variant="secondary" className="font-mono text-xs">{data.length}</Badge>
<Badge variant="secondary" className="font-mono text-xs">{filteredData.length}</Badge>
</div>
<div className="flex items-center gap-2">
<Select value={groupBy} onValueChange={(v) => { setGroupBy(v); setExpandedGroups(new Set()); }}>
@@ -353,7 +398,7 @@ export default function InboundOutboundPage() {
{row.transaction_type}
</span>
</TableCell>
<TableCell className="text-center text-[12px]">{row.remark || "-"}</TableCell>
<TableCell className="text-center text-[12px]">{parseRemark(row.remark) || "-"}</TableCell>
<TableCell className="text-center text-[12px]">{fmtDate(row.transaction_date)}</TableCell>
<TableCell className="text-[12px]">{warehouseMap[row.warehouse_code] || row.warehouse_code || "-"}</TableCell>
<TableCell className="text-center text-[12px]">{row.location_code || "-"}</TableCell>
@@ -230,11 +230,10 @@ function flattenCategories(items: any[]): { value: string; label: string }[] {
const result: { value: string; label: string }[] = [];
function walk(arr: any[]) {
for (const item of arr) {
if (item.value || item.name) {
result.push({
value: item.value || item.name,
label: item.label || item.name || item.value,
});
const val = item.valueCode || item.value || item.name;
const lbl = item.valueLabel || item.label || item.name || val;
if (val) {
result.push({ value: val, label: lbl });
}
if (item.children?.length) walk(item.children);
}
@@ -140,31 +140,47 @@ export default function InventoryStatusPage() {
Record<string, { code: string; label: string }[]>
>({});
// 카테고리 로드
// 사용자 맵 (writer → 이름)
const [userMap, setUserMap] = useState<Record<string, string>>({});
// 카테고리 + 사용자 로드
useEffect(() => {
const load = async () => {
const optMap: Record<string, { code: string; label: string }[]> = {};
const flatten = (vals: any[]): { code: string; label: string }[] => {
const result: { code: string; label: string }[] = [];
for (const v of vals) {
result.push({ code: v.valueCode, label: v.valueLabel });
result.push({ code: v.valueCode || v.value_code || v.code, label: v.valueLabel || v.value_label || v.label });
if (v.children?.length) result.push(...flatten(v.children));
}
return result;
};
for (const col of ["status", "unit"]) {
// inventory_stock 카테고리
for (const col of ["status"]) {
try {
const res = await apiClient.get(
`/table-categories/${STOCK_TABLE}/${col}/values`
);
const res = await apiClient.get(`/table-categories/${STOCK_TABLE}/${col}/values`);
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
} catch {
/* skip */
}
} catch { /* skip */ }
}
// item_info 단위 카테고리
try {
const res = await apiClient.get("/table-categories/item_info/unit/values?filterCompanyCode=COMPANY_29");
if (res.data?.success) optMap["item_unit"] = flatten(res.data.data || []);
} catch { /* skip */ }
setCategoryOptions(optMap);
};
load();
// 사용자 목록 로드
apiClient.get("/admin/users", { params: { size: 9999 } }).then((res) => {
const users = res.data?.data || res.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;
if (id) map[id] = name;
}
setUserMap(map);
}).catch(() => {});
}, []);
// 재고 목록 조회
@@ -193,10 +209,11 @@ export default function InventoryStatusPage() {
};
const data = raw.map((r: any) => {
const itemInfo = itemMap.get(r.item_code) as any;
const rawUnit = itemInfo?.unit || r.unit || "";
return {
...r,
item_name: itemInfo?.name || "",
unit: itemInfo?.unit || resolve("unit", r.unit),
unit: resolve("item_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),
@@ -332,6 +349,12 @@ export default function InventoryStatusPage() {
),
};
}
if (col.key === "warehouse_code") {
return {
...base,
render: (_val: any, row: any) => row.warehouse_name || row.warehouse_code || "",
};
}
if (col.key === "safety_qty") {
return {
...base,
@@ -613,7 +636,7 @@ export default function InventoryStatusPage() {
<TableCell className="truncate max-w-[150px]">
{h.remark || h.reason || ""}
</TableCell>
<TableCell>{h.writer || h.created_by || ""}</TableCell>
<TableCell>{userMap[h.writer] || userMap[h.created_by] || h.writer || h.created_by || ""}</TableCell>
</TableRow>
))}
</TableBody>
@@ -43,6 +43,7 @@ import {
} from "@/lib/api/materialStatus";
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { apiClient } from "@/lib/api/client";
const GRID_COLUMNS = [
{ key: "plan_no", label: "계획번호" },
@@ -60,26 +61,31 @@ const formatDate = (date: Date) => {
return `${y}-${m}-${d}`;
};
const getStatusLabel = (status: string) => {
const map: Record<string, string> = {
planned: "계획",
in_progress: "진행중",
completed: "완료",
pending: "대기",
cancelled: "취소",
};
return map[status] || status;
const FALLBACK_STATUS_MAP: Record<string, string> = {
planned: "계획",
in_progress: "진행중",
completed: "완료",
pending: "대기",
cancelled: "취소",
};
const getStatusStyle = (status: string) => {
const map: Record<string, string> = {
planned: "bg-secondary text-secondary-foreground border-border",
pending: "bg-secondary text-secondary-foreground border-border",
in_progress: "bg-primary/10 text-primary border-primary/20",
completed: "bg-accent text-accent-foreground border-accent/50",
cancelled: "bg-muted text-muted-foreground border-border",
};
return map[status] || "bg-muted text-muted-foreground border-border";
const STATUS_STYLE_MAP: Record<string, string> = {
planned: "bg-secondary text-secondary-foreground border-border",
pending: "bg-secondary text-secondary-foreground border-border",
in_progress: "bg-primary/10 text-primary border-primary/20",
completed: "bg-accent text-accent-foreground border-accent/50",
cancelled: "bg-muted text-muted-foreground border-border",
};
// 카테고리 라벨 기반으로 스타일 매칭
const LABEL_STYLE_MAP: Record<string, string> = {
"일반": "bg-secondary text-secondary-foreground border-border",
"긴급": "bg-destructive/10 text-destructive border-destructive/20",
"계획": "bg-secondary text-secondary-foreground border-border",
"대기": "bg-secondary text-secondary-foreground border-border",
"진행중": "bg-primary/10 text-primary border-primary/20",
"완료": "bg-accent text-accent-foreground border-accent/50",
"취소": "bg-muted text-muted-foreground border-border",
};
export default function MaterialStatusPage() {
@@ -93,6 +99,32 @@ export default function MaterialStatusPage() {
const [searchItemCode, setSearchItemCode] = useState("");
const [searchItemName, setSearchItemName] = useState("");
// 카테고리 코드→라벨 매핑
const [statusMap, setStatusMap] = useState<Record<string, string>>({});
useEffect(() => {
(async () => {
try {
const res = await apiClient.get("/table-categories/work_instruction/status/values");
if (res.data?.success && res.data.data?.length > 0) {
const map: Record<string, string> = {};
const flatten = (vals: any[]) => {
for (const v of vals) {
map[v.valueCode] = v.valueLabel;
if (v.children?.length) flatten(v.children);
}
};
flatten(res.data.data);
setStatusMap(map);
}
} catch { /* ignore */ }
})();
}, []);
const getStatusLabel = useCallback((status: string) => {
return statusMap[status] || FALLBACK_STATUS_MAP[status] || status;
}, [statusMap]);
const [workOrders, setWorkOrders] = useState<WorkOrder[]>([]);
const [workOrdersLoading, setWorkOrdersLoading] = useState(false);
const [checkedWoIds, setCheckedWoIds] = useState<string[]>([]);
@@ -369,7 +401,7 @@ export default function MaterialStatusPage() {
<span
className={cn(
"ml-auto rounded-md border px-2 py-0.5 text-[11px] font-semibold",
getStatusStyle(wo.status)
STATUS_STYLE_MAP[wo.status] || LABEL_STYLE_MAP[getStatusLabel(wo.status)] || "bg-muted text-muted-foreground border-border"
)}
>
{getStatusLabel(wo.status)}
@@ -1,6 +1,7 @@
"use client";
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { apiClient } from "@/lib/api/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
@@ -311,6 +312,42 @@ export default function OutboundPage() {
const [editMode, setEditMode] = useState(false);
const [editItemIds, setEditItemIds] = useState<string[]>([]);
// 카테고리 코드→라벨 매핑 (재질, 단위)
const [catMap, setCatMap] = useState<Record<string, Record<string, 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 || v.value_code || v.code, label: v.valueLabel || v.value_label || v.label });
if (v.children?.length) result.push(...flatten(v.children));
}
return result;
};
const map: Record<string, Record<string, string>> = {};
Promise.all([
...["material", "unit"].map(async (col) => {
try {
const res = await apiClient.get(`/table-categories/item_info/${col}/values?filterCompanyCode=COMPANY_7`);
const items = flatten(res.data?.data || []);
map[col] = {};
for (const item of items) map[col][item.code] = item.label;
} catch { /* skip */ }
}),
(async () => {
try {
const res = await apiClient.get(`/table-categories/outbound_mng/outbound_type/values`);
const items = flatten(res.data?.data || []);
map["outbound_type"] = {};
for (const item of items) map["outbound_type"][item.code] = item.label;
} catch { /* skip */ }
})(),
]).then(() => setCatMap(map));
}, []);
const resolveCat = useCallback((col: string, code: string) => {
if (!code) return "";
return catMap[col]?.[code] || code;
}, [catMap]);
// 소스 데이터
const [sourceKeyword, setSourceKeyword] = useState("");
const [sourceLoading, setSourceLoading] = useState(false);
@@ -871,8 +908,9 @@ export default function OutboundPage() {
fetchList();
}
}
} catch {
toast.error(editMode ? "수정에 실패했어요" : "출고 등록 중 오류가 발생했습니다.");
} catch (err: any) {
const msg = err?.response?.data?.message || (editMode ? "수정에 실패했어요" : "출고 등록 중 오류가 발생했습니다.");
toast.error(msg);
} finally {
setSaving(false);
}
@@ -1085,7 +1123,7 @@ export default function OutboundPage() {
case "outbound_type": return (
<TableCell key={col.key}>
<Badge variant="outline" className={cn("text-[11px]", getTypeColor(master.outbound_type))}>
{master.outbound_type || "-"}
{resolveCat("outbound_type", master.outbound_type) || "-"}
</Badge>
</TableCell>
);
@@ -1337,6 +1375,7 @@ export default function OutboundPage() {
data={pagedItems}
onAdd={addItem}
selectedKeys={selectedItems.map((s) => s.key)}
resolveCat={resolveCat}
/>
)}
</div>
@@ -1502,7 +1541,7 @@ export default function OutboundPage() {
<TableCell className="p-2 text-center">{idx + 1}</TableCell>
<TableCell className="p-2">
<Badge variant="outline" className={cn("text-[10px]", getTypeColor(item.outbound_type))}>
{item.outbound_type || "-"}
{resolveCat("outbound_type", item.outbound_type) || "-"}
</Badge>
</TableCell>
<TableCell className="max-w-[180px] p-2">
@@ -1772,10 +1811,12 @@ function SourceItemTable({
data,
onAdd,
selectedKeys,
resolveCat,
}: {
data: ItemSource[];
onAdd: (item: ItemSource) => void;
selectedKeys: string[];
resolveCat: (col: string, code: string) => string;
}) {
if (data.length === 0) {
return (
@@ -1826,8 +1867,8 @@ function SourceItemTable({
</div>
</TableCell>
<TableCell className="max-w-[100px] truncate p-2" title={item.spec || "-"}>{item.spec || "-"}</TableCell>
<TableCell className="max-w-[100px] truncate p-2" title={item.material || "-"}>{item.material || "-"}</TableCell>
<TableCell className="p-2">{item.unit || "-"}</TableCell>
<TableCell className="max-w-[100px] truncate p-2" title={resolveCat("material", item.material) || "-"}>{resolveCat("material", item.material) || "-"}</TableCell>
<TableCell className="p-2">{resolveCat("unit", item.unit) || "-"}</TableCell>
<TableCell className="p-2 text-right">
{Number(item.standard_price).toLocaleString()}
</TableCell>
@@ -353,17 +353,45 @@ export default function ReceivingPage() {
// 구매관리 division 코드 (라벨 기준 조회)
const [purchaseDivisionCode, setPurchaseDivisionCode] = useState<string>("");
// 카테고리 코드→라벨 매핑 (재질, 단위)
const [catMap, setCatMap] = useState<Record<string, Record<string, string>>>({});
// 구매관리 division 코드 로드
// 구매관리 division 코드 + 재질/단위 카테고리 로드
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 || v.value_code || v.code, label: v.valueLabel || v.value_label || v.label });
if (v.children?.length) result.push(...flatten(v.children));
}
return result;
};
// division 카테고리에서 "구매관리" 라벨의 코드 조회
apiClient.get("/table-categories/item_info/division/values").then((res) => {
const vals = res.data?.data || [];
const found = vals.find((v: any) => (v.value_label || v.label) === "구매관리");
const found = vals.find((v: any) => (v.valueLabel || v.value_label || v.label) === "구매관리");
if (found) setPurchaseDivisionCode(found.value_code || found.code);
}).catch(() => {});
// 재질, 단위 카테고리
const map: Record<string, Record<string, string>> = {};
Promise.all(
["material", "unit"].map(async (col) => {
try {
const res = await apiClient.get(`/table-categories/item_info/${col}/values?filterCompanyCode=COMPANY_29`);
const items = flatten(res.data?.data || []);
map[col] = {};
for (const item of items) map[col][item.code] = item.label;
} catch { /* skip */ }
})
).then(() => setCatMap(map));
}, []);
// 카테고리 코드→라벨 변환
const resolveCat = useCallback((col: string, code: string) => {
if (!code) return "";
return catMap[col]?.[code] || code;
}, [catMap]);
// 목록 조회
const fetchList = useCallback(async () => {
setLoading(true);
@@ -777,12 +805,16 @@ export default function ReceivingPage() {
return;
}
if (!modalWarehouse) {
toast.error("창고를 선택해주세요.");
return;
}
setSaving(true);
try {
const res = await createReceiving({
inbound_number: modalInboundNo,
inbound_date: modalInboundDate,
warehouse_code: modalWarehouse || undefined,
warehouse_code: modalWarehouse,
location_code: modalLocation || undefined,
inspector: modalInspector || undefined,
manager: modalManager || undefined,
@@ -1283,6 +1315,7 @@ export default function ReceivingPage() {
data={items}
onAdd={addItem}
selectedKeys={selectedItems.map((s) => s.key)}
resolveCat={resolveCat}
/>
)}
</div>
@@ -1738,10 +1771,12 @@ function SourceItemTable({
data,
onAdd,
selectedKeys,
resolveCat,
}: {
data: ItemSource[];
onAdd: (item: ItemSource) => void;
selectedKeys: string[];
resolveCat: (col: string, code: string) => string;
}) {
if (data.length === 0) {
return (
@@ -1794,8 +1829,8 @@ function SourceItemTable({
</div>
</TableCell>
<TableCell className="max-w-[100px] truncate p-2" title={item.spec || "-"}>{item.spec || "-"}</TableCell>
<TableCell className="max-w-[100px] truncate p-2" title={item.material || "-"}>{item.material || "-"}</TableCell>
<TableCell className="p-2">{item.unit || "-"}</TableCell>
<TableCell className="max-w-[100px] truncate p-2" title={resolveCat("material", item.material) || "-"}>{resolveCat("material", item.material) || "-"}</TableCell>
<TableCell className="p-2">{resolveCat("unit", item.unit) || "-"}</TableCell>
<TableCell className="p-2 text-right">
{Number(item.standard_price).toLocaleString()}
</TableCell>
@@ -26,6 +26,7 @@ import {
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
import { ImageUpload } from "@/components/common/ImageUpload";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
@@ -102,8 +103,8 @@ export default function MoldInfoPage() {
const [numberingRuleId, setNumberingRuleId] = useState<string | null>(null);
const [previewCode, setPreviewCode] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
// moldImagePreview는 상세 표시용으로 유지 (ImageUpload는 모달에서만 사용)
const [moldImagePreview, setMoldImagePreview] = useState<string | null>(null);
const moldImageRef = React.useRef<HTMLInputElement>(null);
const [serialModalOpen, setSerialModalOpen] = useState(false);
const [serialForm, setSerialForm] = useState<Record<string, any>>({});
@@ -240,17 +241,7 @@ export default function MoldInfoPage() {
setMoldModalOpen(true);
};
const handleMoldImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onloadend = () => {
const result = reader.result as string;
setMoldImagePreview(result);
setMoldForm((prev) => ({ ...prev, image_path: result }));
};
reader.readAsDataURL(file);
};
// 이미지 업로드는 ImageUpload 컴포넌트가 처리 → objid를 image_path에 저장
const handleSaveMold = async () => {
// 등록 모드에서 채번이 없으면 수동 입력 필수
@@ -460,9 +451,10 @@ export default function MoldInfoPage() {
)}>
{mold.image_path ? (
<img
src={mold.image_path}
src={String(mold.image_path).startsWith("http") || String(mold.image_path).startsWith("/") ? mold.image_path : `/api/files/preview/${mold.image_path}`}
alt={mold.mold_name}
className="w-full h-full object-cover"
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
) : (
<Box className="w-8 h-8 text-muted-foreground/50" />
@@ -662,9 +654,10 @@ export default function MoldInfoPage() {
<div className="w-[200px] min-w-[200px] h-[200px] bg-muted rounded-lg flex items-center justify-center border">
{selectedMold.image_path ? (
<img
src={selectedMold.image_path}
src={String(selectedMold.image_path).startsWith("http") || String(selectedMold.image_path).startsWith("/") ? selectedMold.image_path : `/api/files/preview/${selectedMold.image_path}`}
alt={selectedMold.mold_name}
className="w-full h-full object-cover rounded-lg"
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
) : (
<ImageIcon className="w-16 h-16 text-muted-foreground/40" />
@@ -1143,39 +1136,13 @@ export default function MoldInfoPage() {
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs"> </Label>
<div className="relative w-full h-32 rounded-lg border-2 border-dashed border-border bg-muted/50 flex items-center justify-center overflow-hidden group">
{moldImagePreview ? (
<>
<img src={moldImagePreview} alt="금형 이미지" className="w-full h-full object-contain" />
<div className="absolute inset-0 bg-foreground/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
<Button size="sm" variant="secondary" className="h-7 text-xs" type="button" onClick={() => moldImageRef.current?.click()}>
<Upload className="w-3 h-3 mr-1" />
</Button>
<Button size="sm" variant="destructive" className="h-7 text-xs" type="button" onClick={() => {
setMoldImagePreview(null);
setMoldForm((prev) => ({ ...prev, image_path: null }));
}}>
<XIcon className="w-3 h-3" />
</Button>
</div>
</>
) : (
<button
type="button"
className="flex flex-col items-center gap-1.5 text-muted-foreground"
onClick={() => moldImageRef.current?.click()}
>
<ImageIcon className="w-8 h-8" />
<span className="text-xs"> </span>
</button>
)}
</div>
<input
ref={moldImageRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleMoldImageUpload}
<ImageUpload
value={moldForm.image_path || ""}
onChange={(v) => setMoldForm((prev) => ({ ...prev, image_path: v }))}
tableName="mold_mng"
recordId={moldForm.id || ""}
columnName="image_path"
height="h-32"
/>
</div>
</div>
@@ -142,7 +142,7 @@ export default function SubcontractorItemPage() {
try {
const filters: any[] = [];
if (outsourcingDivisionCode) {
filters.push({ columnName: "division", operator: "equals", value: outsourcingDivisionCode });
filters.push({ columnName: "division", operator: "contains", value: outsourcingDivisionCode });
}
if (searchKeyword) {
filters.push({ columnName: "item_name", operator: "contains", value: searchKeyword });
@@ -141,6 +141,12 @@ export default function SubcontractorManagementPage() {
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
} catch { /* skip */ }
}
for (const col of ["division", "unit", "material"]) {
try {
const res = await apiClient.get(`/table-categories/item_info/${col}/values`);
if (res.data?.success) optMap[`item_${col}`] = flatten(res.data.data || []);
} catch { /* skip */ }
}
setCategoryOptions(optMap);
const priceOpts: Record<string, { code: string; label: string }[]> = {};
@@ -413,11 +419,12 @@ export default function SubcontractorManagementPage() {
});
const allItems = res.data?.data?.data || res.data?.data?.rows || [];
const existingItemIds = new Set(priceItems.map((p: any) => p.item_id || p.item_number));
const OUTSOURCING_CODE = "CAT_MMDJB7R4_TO3T";
const outsourcingCode = categoryOptions["item_division"]?.find((o) => o.label === "외주관리")?.code;
setItemSearchResults(allItems.filter((item: any) => {
if (existingItemIds.has(item.item_number) || existingItemIds.has(item.id)) return false;
if (!outsourcingCode) return true;
const div = item.division || "";
return div.includes(OUTSOURCING_CODE) || div.includes("외주");
return div.includes(outsourcingCode);
}));
} catch { /* skip */ } finally { setItemSearchLoading(false); }
};
@@ -1176,8 +1183,8 @@ export default function SubcontractorManagementPage() {
<TableCell className="text-[13px]">{item.item_number}</TableCell>
<TableCell className="text-[13px]">{item.item_name}</TableCell>
<TableCell className="text-[13px]">{item.size}</TableCell>
<TableCell className="text-[13px]">{item.material}</TableCell>
<TableCell className="text-[13px]">{item.unit}</TableCell>
<TableCell className="text-[13px]">{categoryOptions["item_material"]?.find((o) => o.code === item.material)?.label || item.material}</TableCell>
<TableCell className="text-[13px]">{categoryOptions["item_unit"]?.find((o) => o.code === item.unit)?.label || item.unit}</TableCell>
</TableRow>
))}
</TableBody>
@@ -1221,7 +1228,7 @@ export default function SubcontractorManagementPage() {
{/* 품목 헤더 */}
<div className="px-5 py-3 bg-muted/30 border-b">
<div className="font-semibold text-sm">{idx + 1}. {item.item_name || itemKey}</div>
<div className="text-xs text-muted-foreground">{itemKey} | {item.size || ""} | {item.unit || ""}</div>
<div className="text-xs text-muted-foreground">{itemKey} | {item.size || ""} | {categoryOptions["item_unit"]?.find((o) => o.code === item.unit)?.label || item.unit || ""}</div>
</div>
<div className="flex gap-4 p-4">
@@ -309,6 +309,8 @@ export default function BomManagementPage() {
// 카테고리 옵션
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
// 사용자 맵 (userId → userName)
const [userMap, setUserMap] = useState<Record<string, string>>({});
// 트리 편집 state
const [editingTree, setEditingTree] = useState<TreeNode[]>([]);
@@ -433,6 +435,16 @@ export default function BomManagementPage() {
} catch {}
};
loadCategories();
// 사용자 목록 로드
apiClient.get("/admin/users", { params: { size: 9999 } }).then((res) => {
const users = res.data?.data || res.data || [];
const map: Record<string, string> = {};
for (const u of users) {
const id = u.userId || u.user_id || u.id;
if (id) map[id] = u.userName || u.user_name || u.name || id;
}
setUserMap(map);
}).catch(() => {});
}, []);
// ─── BOM 상세 로드 ────────────────────────────
@@ -1802,7 +1814,7 @@ export default function BomManagementPage() {
{/* 비고 */}
<td className="overflow-hidden text-ellipsis whitespace-nowrap px-3 py-2">{isVirtualRoot ? "-" : (node.remark || "-")}</td>
{/* 작성자 */}
<td className="overflow-hidden text-ellipsis whitespace-nowrap px-3 py-2 text-muted-foreground">{isVirtualRoot ? "-" : (node.writer || "-")}</td>
<td className="overflow-hidden text-ellipsis whitespace-nowrap px-3 py-2 text-muted-foreground">{isVirtualRoot ? "-" : (userMap[node.writer] || node.writer || "-")}</td>
{/* 수정일시 */}
<td className="overflow-hidden text-ellipsis whitespace-nowrap px-3 py-2 text-muted-foreground">
{isVirtualRoot ? "-" : (node.updated_date ? new Date(node.updated_date).toLocaleString("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" }) : "-")}
@@ -1052,17 +1052,17 @@ export default function ProductionPlanManagementPage() {
};
return (
<Table style={{ tableLayout: "fixed" }}>
<Table style={{ minWidth: "900px" }}>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[30px]">
<TableHead style={{ width: "30px", minWidth: "30px" }}>
<Checkbox checked={selectedItemGroups.size === orderItems.length && orderItems.length > 0} onCheckedChange={(c) => toggleAllItemGroups(!!c)} className="h-4 w-4" />
</TableHead>
<TableHead className="w-[40px]" />
<TableHead style={{ width: "40px", minWidth: "40px" }} />
<TableHead style={{ width: "140px", minWidth: "140px" }} className="text-xs font-bold whitespace-nowrap text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead style={{ width: "140px", minWidth: "140px" }} className="text-xs font-bold whitespace-nowrap text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
{ts.visibleColumns.map((col) => (
<TableHead key={col.key} style={ts.thStyle(col.key)} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">
<TableHead key={col.key} style={{ ...ts.thStyle(col.key), minWidth: "80px" }} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">
{col.label}
</TableHead>
))}
@@ -349,7 +349,14 @@ export default function WorkInstructionPage() {
const t = Number(o.total_qty || 0), c = Number(o.completed_qty || 0);
return t === 0 ? 0 : Math.min(100, Math.round((c / t) * 100));
};
const getProgressLabel = (o: any) => { const p = getProgress(o); if (o.progress_status) return o.progress_status; if (p >= 100) return "완료"; if (p > 0) return "진행중"; return "대기"; };
const getProgressLabel = (o: any) => {
const p = getProgress(o);
if (o.progress_status) {
const map: Record<string, string> = { completed: "완료", in_progress: "진행중", pending: "대기" };
return map[o.progress_status] || o.progress_status;
}
if (p >= 100) return "완료"; if (p > 0) return "진행중"; return "대기";
};
const totalRegPages = Math.max(1, Math.ceil(regTotalCount / regPageSize));
const getDisplayNo = (o: any) => {
@@ -15,9 +15,12 @@ import {
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download,
ClipboardList, Pencil, Search, X, Package, ChevronDown,
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight,
Settings2,
Settings2, GripVertical,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { DndContext, PointerSensor, closestCenter, useSensor, useSensors, type DragEndEvent } from "@dnd-kit/core";
import { SortableContext, arrayMove, horizontalListSortingStrategy, useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { apiClient } from "@/lib/api/client";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
@@ -84,6 +87,49 @@ const GRID_COLUMNS_CONFIG = [
{ key: "memo", label: "메모" },
];
const MODAL_DETAIL_COLUMNS = [
{ key: "item_code", label: "품번", width: "w-[120px]" },
{ key: "item_name", label: "품명", width: "w-[120px]" },
{ key: "supplier", label: "공급업체", width: "w-[150px]" },
{ key: "spec", label: "규격", width: "w-[80px]" },
{ key: "unit", label: "단위", width: "w-[60px]" },
{ key: "order_qty", label: "발주수량", width: "w-[90px]" },
{ key: "received_qty", label: "입고수량", width: "w-[90px]" },
{ key: "remain_qty", label: "잔량", width: "w-[80px]" },
{ key: "unit_price", label: "단가", width: "w-[100px]" },
{ key: "amount", label: "금액", width: "w-[100px]" },
{ key: "due_date", label: "납기일", width: "w-[160px]" },
{ key: "memo", label: "메모", width: "w-[120px]" },
];
const MODAL_COL_ORDER_KEY = "purchase_order_modal_col_order_c16";
function SortableModalHead({ col }: { col: { key: string; label: string; width: string } }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: col.key });
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<TableHead
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className={cn(
col.width,
"text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none cursor-grab active:cursor-grabbing hover:bg-muted-foreground/5 transition-colors"
)}
>
<div className="inline-flex items-center gap-1">
<GripVertical className="h-3.5 w-3.5 text-muted-foreground/70 shrink-0" />
<span className="truncate">{col.label}</span>
</div>
</TableHead>
);
}
export default function PurchaseOrderPage() {
const { user } = useAuth();
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
@@ -121,8 +167,43 @@ export default function PurchaseOrderPage() {
// 테이블 설정
const ts = useTableSettings("c16-purchase-order", DETAIL_TABLE, GRID_COLUMNS_CONFIG);
// 모달 품목 테이블 컬럼 순서 (드래그 재정렬)
const [modalColumns, setModalColumns] = useState(MODAL_DETAIL_COLUMNS);
const modalSensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }));
useEffect(() => {
const saved = localStorage.getItem(MODAL_COL_ORDER_KEY);
if (saved) {
try {
const order = JSON.parse(saved) as string[];
const reordered = order.map((key) => MODAL_DETAIL_COLUMNS.find((c) => c.key === key)).filter(Boolean) as typeof MODAL_DETAIL_COLUMNS;
const remaining = MODAL_DETAIL_COLUMNS.filter((c) => !order.includes(c.key));
setModalColumns([...reordered, ...remaining]);
} catch { /* skip */ }
}
}, []);
const handleModalDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
setModalColumns((prev) => {
const oldIndex = prev.findIndex((c) => c.key === active.id);
const newIndex = prev.findIndex((c) => c.key === over.id);
const next = arrayMove(prev, oldIndex, newIndex);
localStorage.setItem(MODAL_COL_ORDER_KEY, JSON.stringify(next.map((c) => c.key)));
return next;
});
};
const isReadOnly = masterForm.status === "입고완료" || masterForm.status === "취소";
const visibleModalColumns = useMemo(() => {
return modalColumns.filter((col) => {
if (col.key === "supplier" && masterForm.input_mode !== "itemFirst") return false;
return true;
});
}, [modalColumns, masterForm.input_mode]);
// 카테고리 로드
useEffect(() => {
const loadCategories = async () => {
@@ -1012,96 +1093,115 @@ export default function PurchaseOrderPage() {
</div>
) : (
<div className="border rounded-lg overflow-x-auto">
<Table className="table-fixed">
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
{!isReadOnly && <TableHead className="w-[40px] 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-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
{masterForm.input_mode === "itemFirst" && (
<TableHead className="w-[150px] 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-[60px] 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-[90px] 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-[100px] 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="w-[160px] 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>
</TableRow>
</TableHeader>
<TableBody>
{detailRows.map((row, idx) => (
<TableRow key={row._id || idx}>
{!isReadOnly && (
<TableCell>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-muted-foreground/50 hover:text-destructive hover:bg-destructive/5" onClick={() => removeDetailRow(idx)}>
<X className="w-3.5 h-3.5" />
</Button>
</TableCell>
)}
<TableCell className="text-[13px] font-mono max-w-[120px]"><span className="block truncate" title={row.item_code}>{row.item_code}</span></TableCell>
<TableCell className="text-[13px] max-w-[120px]"><span className="block truncate" title={row.item_name}>{row.item_name}</span></TableCell>
{masterForm.input_mode === "itemFirst" && (
<TableCell>
{isReadOnly ? (
<span className="text-xs">{(categoryOptions["supplier_code"] || []).find(o => o.code === row.supplier_code)?.label || row.supplier_code || "-"}</span>
) : (
<Select value={row.supplier_code || ""} onValueChange={(v) => {
const supp = categoryOptions["supplier_code"]?.find(o => o.code === v);
const name = supp?.label.replace(` (${v})`, "") || "";
updateDetailRow(idx, "supplier_code", v);
updateDetailRow(idx, "supplier_name", name);
}}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="공급업체" /></SelectTrigger>
<SelectContent>
{(categoryOptions["supplier_code"] || []).map(o => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
)}
</TableCell>
)}
<TableCell className="text-[13px] text-muted-foreground">{row.spec}</TableCell>
<TableCell className="text-[13px]">{row.unit}</TableCell>
<TableCell>
{isReadOnly ? (
<span className="text-xs text-right font-mono block">{row.order_qty ? Number(row.order_qty).toLocaleString() : ""}</span>
) : (
<Input value={formatNumber(row.order_qty || "")} onChange={(e) => updateDetailRow(idx, "order_qty", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono" />
<DndContext sensors={modalSensors} collisionDetection={closestCenter} onDragEnd={handleModalDragEnd}>
<Table className="table-fixed">
<TableHeader className="sticky top-0 z-10">
<SortableContext items={visibleModalColumns.map((c) => c.key)} strategy={horizontalListSortingStrategy}>
<TableRow className="bg-muted hover:bg-muted">
{!isReadOnly && <TableHead className="w-[40px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
{visibleModalColumns.map((col) => (
<SortableModalHead key={col.key} col={col} />
))}
</TableRow>
</SortableContext>
</TableHeader>
<TableBody>
{detailRows.map((row, idx) => (
<TableRow key={row._id || idx}>
{!isReadOnly && (
<TableCell>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-muted-foreground/50 hover:text-destructive hover:bg-destructive/5" onClick={() => removeDetailRow(idx)}>
<X className="w-3.5 h-3.5" />
</Button>
</TableCell>
)}
</TableCell>
<TableCell className="text-[13px] text-right font-mono text-muted-foreground">{row.received_qty ? Number(row.received_qty).toLocaleString() : "0"}</TableCell>
<TableCell className="text-[13px] text-right font-mono">{row.remain_qty ? Number(row.remain_qty).toLocaleString() : "0"}</TableCell>
<TableCell>
{isReadOnly ? (
<span className="text-xs text-right font-mono block">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</span>
) : (
<Input value={formatNumber(row.unit_price || "")} onChange={(e) => updateDetailRow(idx, "unit_price", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono" />
)}
</TableCell>
<TableCell className="text-[13px] text-right font-mono font-semibold">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>
<TableCell>
{isReadOnly ? (
<span className="text-xs">{row.due_date}</span>
) : (
<Input type="date" value={row.due_date || ""} onChange={(e) => updateDetailRow(idx, "due_date", e.target.value)} className="h-8 text-xs" />
)}
</TableCell>
<TableCell>
{isReadOnly ? (
<span className="text-xs">{row.memo}</span>
) : (
<Input value={row.memo || ""} onChange={(e) => updateDetailRow(idx, "memo", e.target.value)} className="h-8 text-xs" />
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{visibleModalColumns.map((col) => {
switch (col.key) {
case "item_code":
return <TableCell key={col.key} className="text-[13px] font-mono max-w-[120px]"><span className="block truncate" title={row.item_code}>{row.item_code}</span></TableCell>;
case "item_name":
return <TableCell key={col.key} className="text-[13px] max-w-[120px]"><span className="block truncate" title={row.item_name}>{row.item_name}</span></TableCell>;
case "supplier":
return (
<TableCell key={col.key}>
{isReadOnly ? (
<span className="text-xs">{(categoryOptions["supplier_code"] || []).find(o => o.code === row.supplier_code)?.label || row.supplier_code || "-"}</span>
) : (
<Select value={row.supplier_code || ""} onValueChange={(v) => {
const supp = categoryOptions["supplier_code"]?.find(o => o.code === v);
const name = supp?.label.replace(` (${v})`, "") || "";
updateDetailRow(idx, "supplier_code", v);
updateDetailRow(idx, "supplier_name", name);
}}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="공급업체" /></SelectTrigger>
<SelectContent>
{(categoryOptions["supplier_code"] || []).map(o => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
)}
</TableCell>
);
case "spec":
return <TableCell key={col.key} className="text-[13px] text-muted-foreground">{row.spec}</TableCell>;
case "unit":
return <TableCell key={col.key} className="text-[13px]">{row.unit}</TableCell>;
case "order_qty":
return (
<TableCell key={col.key}>
{isReadOnly ? (
<span className="text-xs text-right font-mono block">{row.order_qty ? Number(row.order_qty).toLocaleString() : ""}</span>
) : (
<Input value={formatNumber(row.order_qty || "")} onChange={(e) => updateDetailRow(idx, "order_qty", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono" />
)}
</TableCell>
);
case "received_qty":
return <TableCell key={col.key} className="text-[13px] text-right font-mono text-muted-foreground">{row.received_qty ? Number(row.received_qty).toLocaleString() : "0"}</TableCell>;
case "remain_qty":
return <TableCell key={col.key} className="text-[13px] text-right font-mono">{row.remain_qty ? Number(row.remain_qty).toLocaleString() : "0"}</TableCell>;
case "unit_price":
return (
<TableCell key={col.key}>
{isReadOnly ? (
<span className="text-xs text-right font-mono block">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</span>
) : (
<Input value={formatNumber(row.unit_price || "")} onChange={(e) => updateDetailRow(idx, "unit_price", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono" />
)}
</TableCell>
);
case "amount":
return <TableCell key={col.key} className="text-[13px] text-right font-mono font-semibold">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>;
case "due_date":
return (
<TableCell key={col.key}>
{isReadOnly ? (
<span className="text-xs">{row.due_date}</span>
) : (
<Input type="date" value={row.due_date || ""} onChange={(e) => updateDetailRow(idx, "due_date", e.target.value)} className="h-8 text-xs" />
)}
</TableCell>
);
case "memo":
return (
<TableCell key={col.key}>
{isReadOnly ? (
<span className="text-xs">{row.memo}</span>
) : (
<Input value={row.memo || ""} onChange={(e) => updateDetailRow(idx, "memo", e.target.value)} className="h-8 text-xs" />
)}
</TableCell>
);
default:
return <TableCell key={col.key} />;
}
})}
</TableRow>
))}
</TableBody>
</Table>
</DndContext>
</div>
)}
</div>
File diff suppressed because it is too large Load Diff
@@ -195,6 +195,12 @@ export default function SupplierManagementPage() {
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
} catch { /* skip */ }
}
for (const col of ["division", "unit", "material"]) {
try {
const res = await apiClient.get(`/table-categories/item_info/${col}/values`);
if (res.data?.success) optMap[`item_${col}`] = flatten(res.data.data || []);
} catch { /* skip */ }
}
setCategoryOptions(optMap);
const priceOpts: Record<string, { code: string; label: string }[]> = {};
@@ -807,27 +813,35 @@ export default function SupplierManagementPage() {
};
// 품목 검색
const searchItems = async () => {
const searchItems = useCallback(async () => {
setItemSearchLoading(true);
try {
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: 5000,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const allItems = res.data?.data?.data || res.data?.data?.rows || [];
setItemTotalCount(allItems.length);
const existingItemIds = new Set(priceItems.map((p: any) => p.item_id || p.item_number));
const PURCHASE_CODES = ["s"]; // 구매관리 카테고리 코드
const purchaseCode = categoryOptions["item_division"]?.find((o) => o.label === "구매관리")?.code;
setItemSearchResults(allItems.filter((item: any) => {
if (existingItemIds.has(item.item_number) || existingItemIds.has(item.id)) return false;
const divCodes = (item.division || "").split(",").map((c: string) => c.trim());
return divCodes.some((code: string) => PURCHASE_CODES.includes(code));
if (!purchaseCode) return true;
const div = item.division || "";
return div.includes(purchaseCode);
}));
} catch { /* skip */ } finally { setItemSearchLoading(false); }
};
}, [itemSearchKeyword, priceItems]);
// 실간 검색 (2글자 이상)
useEffect(() => {
if (!itemSelectOpen) return;
if (itemSearchKeyword.length > 0 && itemSearchKeyword.length < 2) return;
searchItems();
}, [itemSearchKeyword, itemSelectOpen]); // eslint-disable-line react-hooks/exhaustive-deps
// 품목 선택 완료 → 상세 입력 모달로 전환
const goToItemDetail = () => {
@@ -964,6 +978,7 @@ export default function SupplierManagementPage() {
{ columnName: "supplier_id", operator: "equals", value: selectedSupplier!.supplier_code },
{ columnName: "item_id", operator: "equals", value: itemKey },
]}, autoFilter: true,
sort: { columnName: "created_date", order: "asc" },
});
const allMappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
mappingRows = allMappings
@@ -984,7 +999,8 @@ export default function SupplierManagementPage() {
{ columnName: "item_id", operator: "equals", value: itemKey },
]}, autoFilter: true,
});
const allPriceData = priceRes.data?.data?.data || priceRes.data?.data?.rows || [];
const allPriceData = (priceRes.data?.data?.data || priceRes.data?.data?.rows || [])
.sort((a: any, b: any) => (a.start_date || "").localeCompare(b.start_date || ""));
priceRows = allPriceData.map((p: any) => ({
_id: `p_existing_${p.id}`,
start_date: p.start_date ? String(p.start_date).split("T")[0] : "",
@@ -1036,6 +1052,7 @@ export default function SupplierManagementPage() {
{ columnName: "supplier_id", operator: "equals", value: selectedSupplier.supplier_code },
{ columnName: "item_id", operator: "equals", value: itemKey },
]}, autoFilter: true,
sort: { columnName: "created_date", order: "asc" },
});
existingMaps = existingMappings.data?.data?.data || existingMappings.data?.data?.rows || [];
} catch { /* skip */ }
@@ -1085,12 +1102,13 @@ export default function SupplierManagementPage() {
{ columnName: "item_id", operator: "equals", value: itemKey },
]}, autoFilter: true,
});
existingPriceRows = existingPrices.data?.data?.data || existingPrices.data?.data?.rows || [];
existingPriceRows = (existingPrices.data?.data?.data || existingPrices.data?.data?.rows || [])
.sort((a: any, b: any) => (a.start_date || "").localeCompare(b.start_date || ""));
} catch { /* skip */ }
// 단가 upsert
const priceRows = (itemPrices[itemKey] || []).filter((p) =>
(p.base_price && Number(p.base_price) > 0) || p.start_date
p.base_price || p.start_date || p.currency_code || p.base_price_type
);
const usedPriceIds = new Set<string>();
for (let pi = 0; pi < priceRows.length; pi++) {
@@ -1160,7 +1178,7 @@ export default function SupplierManagementPage() {
}
const priceRows = (itemPrices[itemKey] || []).filter((p) =>
(p.base_price && Number(p.base_price) > 0) || p.start_date
p.base_price || p.start_date || p.currency_code || p.base_price_type
);
for (const price of priceRows) {
await apiClient.post(`/table-management/tables/${PRICE_TABLE}/add`, {
@@ -1192,40 +1210,63 @@ export default function SupplierManagementPage() {
}
};
// 품목 매핑 삭제
// 품목 매핑 해제 (소프트 삭제 — supplier_id를 null 처리)
const handlePriceItemDelete = async () => {
if (priceCheckedIds.length === 0) return;
const ok = await confirm(`선택한 ${priceCheckedIds.length}개 품목 매핑제하시겠습니까?`, {
description: "관련된 단가 정보도 함께 삭제됩니다.",
variant: "destructive", confirmText: "제",
const ok = await confirm(`선택한 ${priceCheckedIds.length}개 품목의 연결제하시겠습니까?`, {
description: "해당 품목의 공급업체 연결이 해제됩니다. (데이터는 유지)",
variant: "destructive", confirmText: "제",
});
if (!ok) return;
try {
for (const mappingId of priceCheckedIds) {
const itemIds = priceCheckedIds.map((mid) => {
const group = Object.values(priceGroups).find((g) => g.master.id === mid);
return group?.master.item_id || group?.master.item_number || "";
}).filter(Boolean);
for (const itemId of itemIds) {
// 해당 품목의 모든 매핑 조회 → supplier_id null 처리
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [
{ columnName: "supplier_id", operator: "equals", value: selectedSupplier!.supplier_code },
{ columnName: "item_id", operator: "equals", value: itemId },
]}, autoFilter: true,
});
const allMappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
for (const m of allMappings) {
await apiClient.put(`/table-management/tables/${MAPPING_TABLE}/edit`, {
originalData: { id: m.id },
updatedData: { supplier_id: null },
});
}
// 해당 품목의 모든 단가 조회 → supplier_id null 처리
try {
const priceRes = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "mapping_id", operator: "equals", value: mappingId }] },
autoFilter: true,
dataFilter: { enabled: true, filters: [
{ columnName: "supplier_id", operator: "equals", value: selectedSupplier!.supplier_code },
{ columnName: "item_id", operator: "equals", value: itemId },
]}, autoFilter: true,
});
const prices = priceRes.data?.data?.data || priceRes.data?.data?.rows || [];
if (prices.length > 0) {
await apiClient.delete(`/table-management/tables/${PRICE_TABLE}/delete`, {
data: prices.map((p: any) => ({ id: p.id })),
for (const p of prices) {
await apiClient.put(`/table-management/tables/${PRICE_TABLE}/edit`, {
originalData: { id: p.id },
updatedData: { supplier_id: null },
});
}
} catch { /* skip */ }
}
await apiClient.delete(`/table-management/tables/${MAPPING_TABLE}/delete`, {
data: priceCheckedIds.map((id) => ({ id })),
});
toast.success(`${priceCheckedIds.length}개 품목 매핑이 삭제되었습니다.`);
toast.success(`${priceCheckedIds.length}개 품목의 연결이 해제되었습니다.`);
setPriceCheckedIds([]);
const cid = selectedSupplierId;
setSelectedSupplierId(null);
setTimeout(() => setSelectedSupplierId(cid), 50);
} catch {
toast.error("제에 실패했습니다.");
toast.error("연결 해제에 실패했습니다.");
}
};
@@ -2430,8 +2471,8 @@ export default function SupplierManagementPage() {
</TableCell>
<TableCell className="text-sm">{item.item_name}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{item.size}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{item.material}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{item.unit}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{categoryOptions["item_material"]?.find((o) => o.code === item.material)?.label || item.material}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{categoryOptions["item_unit"]?.find((o) => o.code === item.unit)?.label || item.unit}</TableCell>
</TableRow>
))}
</TableBody>
@@ -2473,7 +2514,7 @@ export default function SupplierManagementPage() {
{/* 품목 헤더 */}
<div className="px-5 py-3 bg-muted/30 border-b">
<div className="font-bold">{idx + 1}. {item.item_name || itemKey}</div>
<div className="text-xs text-muted-foreground">{itemKey} | {item.size || ""} | {item.unit || ""}</div>
<div className="text-xs text-muted-foreground">{itemKey} | {item.size || ""} | {categoryOptions["item_unit"]?.find((o) => o.code === item.unit)?.label || item.unit || ""}</div>
</div>
<div className="flex gap-4 p-5 items-stretch">
@@ -188,9 +188,12 @@ export default function InspectionManagementPage() {
if (["inspection_type", "inspection_method", "judgment_criteria", "unit", "apply_type"].includes(col.key)) {
base.render = (v: any, row: any) => getCatLabel(INSPECTION_TABLE, col.key, row[col.key]);
}
if (col.key === "manager") {
base.render = (v: any, row: any) => userOptions.find((u) => u.code === row.manager)?.label || row.manager || "-";
}
return base;
});
}, [ts.visibleColumns, catOptions]); // eslint-disable-line react-hooks/exhaustive-deps
}, [ts.visibleColumns, catOptions, userOptions]); // eslint-disable-line react-hooks/exhaustive-deps
/* ═══════════════════ 데이터 조회 ═══════════════════ */
// 다중값 컬럼 (쉼표 구분 저장) — 서버 equals 대신 contains 사용
@@ -385,7 +388,7 @@ export default function InspectionManagementPage() {
/* ═══════════════════ 불량관리 CRUD ═══════════════════ */
const openDefCreate = async () => {
setDefForm({});
setDefForm({ is_active: "사용" });
setDefEditMode(false);
setNumberingRuleId(null);
setPreviewCode(null);
@@ -867,20 +870,16 @@ export default function InspectionManagementPage() {
</TableCell>
<TableCell>
<Badge
variant={
getCatLabel(DEFECT_TABLE, "is_active", row.is_active) === "사용"
? "default"
: "secondary"
}
variant={row.is_active === "사용" || row.is_active === "true" ? "default" : "secondary"}
className="text-[10px]"
>
{getCatLabel(DEFECT_TABLE, "is_active", row.is_active) || "-"}
{row.is_active === "사용" || row.is_active === "true" ? "사용" : row.is_active || "미사용"}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground">
{row.reg_date || (row.created_date ? row.created_date.slice(0, 10) : "-")}
</TableCell>
<TableCell>{row.manager_id || "-"}</TableCell>
<TableCell>{userOptions.find((u) => u.code === row.manager_id)?.label || row.manager_id || "-"}</TableCell>
<TableCell className="text-muted-foreground">{row.remarks || "-"}</TableCell>
</TableRow>
);
@@ -1442,19 +1441,15 @@ export default function InspectionManagementPage() {
<div className="space-y-1.5">
<Label className="text-xs font-semibold"></Label>
<Select
value={defForm.is_active || "__none__"}
onValueChange={(v) => setDefForm((p) => ({ ...p, is_active: v === "__none__" ? "" : v }))}
value={defForm.is_active || "사용"}
onValueChange={(v) => setDefForm((p) => ({ ...p, is_active: v }))}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> </SelectItem>
{(catOptions[`${DEFECT_TABLE}.is_active`] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>
{o.label}
</SelectItem>
))}
<SelectItem value="사용"></SelectItem>
<SelectItem value="미사용"></SelectItem>
</SelectContent>
</Select>
</div>
@@ -9,8 +9,9 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import {
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ChevronDown,
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ClipboardList,
} from "lucide-react";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { cn } from "@/lib/utils";
@@ -20,19 +21,17 @@ import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { toast } from "sonner";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
const TABLE_NAME = "item_inspection_info";
const ITEM_TABLE = "item_info";
const INSPECTION_TABLE = "inspection_standard";
const GRID_COLUMNS = [
{ key: "item_code", label: "품목코드" },
{ key: "item_name", label: "품명" },
{ key: "item_name", label: "품명" },
{ key: "inspection_type", label: "검사유형" },
{ key: "item_count", label: "항목수" },
{ key: "is_active", label: "사용여부" },
];
const ITEM_TABLE = "item_info";
const INSPECTION_TABLE = "inspection_standard";
const INSPECTION_TYPES = [
{ key: "incoming_inspection", label: "수입검사", matchLabels: ["수입검사", "입고검사", "수입", "입고"] },
@@ -61,24 +60,29 @@ export default function ItemInspectionInfoPage() {
const [totalCount, setTotalCount] = useState(0);
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
const [checkedIds, setCheckedIds] = useState<string[]>([]);
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
// 우측 패널: 선택된 품목
const [selectedItemCode, setSelectedItemCode] = useState<string | null>(null);
const [selectedTypeTab, setSelectedTypeTab] = useState<string>("");
// 모달
const [modalOpen, setModalOpen] = useState(false);
const [editMode, setEditMode] = useState(false);
const [form, setForm] = useState<Record<string, any>>({});
const [saving, setSaving] = useState(false);
/* FK 옵션 */
// FK 옵션
const [itemOptions, setItemOptions] = useState<{ code: string; name: string; item_type: string; unit: string }[]>([]);
const [inspOptions, setInspOptions] = useState<{ code: string; label: string; detail: string; method: string; types: string[] }[]>([]);
const [inspTypeCatOptions, setInspTypeCatOptions] = useState<{ code: string; label: string }[]>([]);
const [inspMethodCatOptions, setInspMethodCatOptions] = useState<{ code: string; label: string }[]>([]);
const [userOptions, setUserOptions] = useState<{ code: string; label: string }[]>([]);
/* 검사유형별 검사항목 rows */
// 검사유형별 검사항목 rows (모달용)
const [inspectionRows, setInspectionRows] = useState<Record<string, InspectionRow[]>>({});
const [collapsedTypes, setCollapsedTypes] = useState<Record<string, boolean>>({});
/* 품목 선택 모달 */
// 품목 선택 모달
const [itemModalOpen, setItemModalOpen] = useState(false);
const [itemSearchKeyword, setItemSearchKeyword] = useState("");
const [filteredItems, setFilteredItems] = useState<typeof itemOptions>([]);
@@ -109,7 +113,7 @@ export default function ItemInspectionInfoPage() {
types: r.inspection_type ? r.inspection_type.split(",").filter(Boolean) : [],
})));
// 검사유형 카테고리 값 로드 (코드→라벨 매핑용)
// 검사유형 카테고리
try {
const catRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/inspection_type/values`);
const flatCats: { code: string; label: string }[] = [];
@@ -118,6 +122,15 @@ export default function ItemInspectionInfoPage() {
setInspTypeCatOptions(flatCats);
} catch { /* skip */ }
// 검사방법 카테고리
try {
const methodRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/inspection_method/values`);
const flatMethods: { code: string; label: string }[] = [];
const flattenM = (arr: any[]) => { for (const v of arr) { flatMethods.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) flattenM(v.children); } };
if (methodRes.data?.data?.length) flattenM(methodRes.data.data);
setInspMethodCatOptions(flatMethods);
} catch { /* skip */ }
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
setUserOptions(users.map((u: any) => ({
code: u.user_id || u.id,
@@ -129,20 +142,12 @@ export default function ItemInspectionInfoPage() {
}, []);
/* ═══════════════════ 품목 선택 모달 ═══════════════════ */
const openItemModal = () => {
setItemSearchKeyword("");
setFilteredItems(itemOptions);
setItemModalOpen(true);
};
const openItemModal = () => { setItemSearchKeyword(""); setFilteredItems(itemOptions); setItemModalOpen(true); };
const handleItemSearch = () => {
const kw = itemSearchKeyword.trim().toLowerCase();
if (!kw) { setFilteredItems(itemOptions); return; }
setFilteredItems(itemOptions.filter(o =>
o.code.toLowerCase().includes(kw) || o.name.toLowerCase().includes(kw)
));
setFilteredItems(itemOptions.filter(o => o.code.toLowerCase().includes(kw) || o.name.toLowerCase().includes(kw)));
};
const selectItem = (item: typeof itemOptions[0]) => {
setForm(p => ({ ...p, item_code: item.code, item_name: item.name }));
setItemModalOpen(false);
@@ -186,24 +191,45 @@ export default function ItemInspectionInfoPage() {
return Object.values(map);
}, [data]);
// 검사기준 ID → 라벨 resolve
// 선택된 품목의 그룹 데이터
const selectedGroup = useMemo(() => {
if (!selectedItemCode) return null;
return groupedData.find(g => g.item_code === selectedItemCode) || null;
}, [selectedItemCode, groupedData]);
// 선택된 탭의 검사항목 행
const selectedTabRows = useMemo(() => {
if (!selectedGroup || !selectedTypeTab) return [];
return selectedGroup.rows.filter((r: any) => r.inspection_type === selectedTypeTab && r.inspection_standard_id);
}, [selectedGroup, selectedTypeTab]);
// 검사기준 ID → 라벨
const resolveInspLabel = useCallback((id: string) => {
const opt = inspOptions.find(o => o.code === id);
return opt?.label || id || "-";
return inspOptions.find(o => o.code === id)?.label || id || "-";
}, [inspOptions]);
// 검사방법 코드 → 라벨
const resolveMethodLabel = useCallback((code: string) => {
return inspMethodCatOptions.find(o => o.code === code)?.label || code || "-";
}, [inspMethodCatOptions]);
/* ═══════════════════ CRUD ═══════════════════ */
const openCreate = () => { setForm({}); setEditMode(false); setInspectionRows({}); setCollapsedTypes({}); setModalOpen(true); };
const openEdit = async (row: any) => {
const openEdit = async (itemCode?: string) => {
const code = itemCode || selectedItemCode;
if (!code) { toast.error("수정할 항목을 선택해주세요"); return; }
const group = groupedData.find(g => g.item_code === code);
if (!group) return;
const row = group.rows[0];
setForm({ ...row });
setEditMode(true);
setCollapsedTypes({});
// 같은 item_code의 모든 검사항목 행을 조회하여 유형별로 분류
try {
const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: row.item_code }] },
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: code }] },
autoFilter: true,
});
const allRows = res.data?.data?.data || res.data?.data?.rows || [];
@@ -212,7 +238,6 @@ export default function ItemInspectionInfoPage() {
for (const r of allRows) {
const inspType = r.inspection_type || "";
// 카테고리 코드/라벨로 INSPECTION_TYPES 키 매칭
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)))
@@ -221,48 +246,34 @@ export default function ItemInspectionInfoPage() {
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;
rowMap[typeKey].push({
id: r.id,
inspection_standard_id: r.inspection_standard_id || "",
inspection_detail: r.inspection_item_name || r.inspection_standard || "",
inspection_method: r.inspection_method || "",
inspection_method: mLabel,
apply_process: "",
acceptance_criteria: r.pass_criteria || "",
is_required: r.is_required === "true" || r.is_required === true,
});
}
setInspectionRows(rowMap);
setForm(p => ({ ...p, ...typeFlags }));
} catch {
setInspectionRows({});
}
} catch { setInspectionRows({}); }
setModalOpen(true);
};
/* ═══════════════════ 검사항목 행 관리 ═══════════════════ */
/* ═══════════════════ 검사항목 행 관리 (모달) ═══════════════════ */
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: "", acceptance_criteria: "", is_required: false }],
}));
};
const removeInspRow = (typeKey: string, rowId: string) => {
setInspectionRows(prev => ({
...prev,
[typeKey]: (prev[typeKey] || []).filter(r => r.id !== rowId),
}));
setInspectionRows(prev => ({ ...prev, [typeKey]: (prev[typeKey] || []).filter(r => r.id !== rowId) }));
};
const updateInspRow = (typeKey: string, rowId: string, field: string, value: any) => {
setInspectionRows(prev => ({
...prev,
@@ -270,34 +281,27 @@ export default function ItemInspectionInfoPage() {
if (r.id !== rowId) return r;
if (field === "inspection_standard_id") {
const opt = inspOptions.find(o => o.code === value);
return { ...r, inspection_standard_id: value, inspection_detail: opt?.detail || "", inspection_method: opt?.method || "" };
const methodCode = opt?.method || "";
const methodLabel = inspMethodCatOptions.find(o => o.code === methodCode)?.label || methodCode;
return { ...r, inspection_standard_id: value, inspection_detail: opt?.detail || "", inspection_method: methodLabel };
}
return { ...r, [field]: value };
}),
}));
};
/** 검사유형 키에 매칭되는 검사기준만 필터링 */
const getFilteredInspOptions = (typeKey: string) => {
const typeDef = INSPECTION_TYPES.find(t => t.key === typeKey);
if (!typeDef) return inspOptions;
// matchLabels와 카테고리 라벨을 비교하여 해당 카테고리 코드를 찾음
const matchCodes = inspTypeCatOptions
.filter(cat => typeDef.matchLabels.some(ml => cat.label.includes(ml)))
.map(cat => cat.code);
const matchCodes = inspTypeCatOptions.filter(cat => typeDef.matchLabels.some(ml => cat.label.includes(ml))).map(cat => cat.code);
if (matchCodes.length === 0) return inspOptions;
return inspOptions.filter(opt => opt.types.some(t => matchCodes.includes(t)));
};
const toggleCollapse = (typeKey: string) => {
setCollapsedTypes(prev => ({ ...prev, [typeKey]: !prev[typeKey] }));
};
const toggleCollapse = (typeKey: string) => { setCollapsedTypes(prev => ({ ...prev, [typeKey]: !prev[typeKey] })); };
const handleSave = async () => {
if (!form.item_code) { toast.error("품목코드는 필수 입력이에요"); return; }
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,
@@ -306,54 +310,29 @@ export default function ItemInspectionInfoPage() {
});
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 })),
});
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: existing.map((r: any) => ({ id: r.id })) });
}
}
// 검사유형별 항목을 개별 행으로 INSERT
const enabledTypes = INSPECTION_TYPES.filter(t => !!form[t.key]);
const rows: any[] = [];
for (const t of enabledTypes) {
const typeLabel = t.label;
const typeRows = inspectionRows[t.key] || [];
if (typeRows.length === 0) {
// 유형만 체크하고 항목 없는 경우에도 1행 생성
rows.push({
id: crypto.randomUUID(),
item_code: form.item_code,
item_name: form.item_name,
inspection_type: typeLabel,
is_active: form.is_active || "사용",
manager_id: form.manager_id || "",
memo: form.remarks || "",
});
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 || "" });
} else {
for (const r of typeRows) {
rows.push({
id: crypto.randomUUID(),
item_code: form.item_code,
item_name: form.item_name,
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: form.is_active || "사용",
manager_id: form.manager_id || "",
memo: form.remarks || "",
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 || "",
is_required: r.is_required ? "true" : "false", is_active: form.is_active || "사용",
manager_id: form.manager_id || "", memo: form.remarks || "",
});
}
}
}
for (const row of rows) {
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, row);
}
toast.success(editMode ? "품목검사정보를 수정했어요" : "품목검사정보를 등록했어요");
for (const row of rows) { await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, row); }
toast.success(editMode ? "수정했어요" : "등록했어요");
setModalOpen(false);
fetchData();
} catch { toast.error("저장에 실패했어요"); }
@@ -361,138 +340,231 @@ export default function ItemInspectionInfoPage() {
};
const handleDelete = async () => {
if (checkedIds.length === 0) { toast.error("삭제할 목을 선택해주세요"); return; }
const ok = await confirm("품목검사정보 삭제", {
description: `선택한 ${checkedIds.length}건을 삭제할까요? 이 작업은 되돌릴 수 없어요.`,
});
if (!selectedItemCode) { toast.error("삭제할 목을 선택해주세요"); return; }
const group = groupedData.find(g => g.item_code === selectedItemCode);
if (!group) return;
const ok = await confirm(`${selectedItemCode} 검사정보 삭제`, { description: "선택한 품목의 검사정보를 모두 삭제할까요?", variant: "destructive", confirmText: "삭제" });
if (!ok) return;
try {
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, {
data: checkedIds.map(id => ({ id })),
});
toast.success(`${checkedIds.length}건을 삭제했어요`);
setCheckedIds([]);
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: group.rows.map((r: any) => ({ id: r.id })) });
toast.success("삭제했어요");
setSelectedItemCode(null);
fetchData();
} catch { toast.error("삭제에 실패했어요"); }
};
/* ═══════════════════ JSX ═══════════════════ */
return (
<div className="flex flex-col gap-3 p-3">
<div className="flex h-full flex-col">
{ConfirmDialogComponent}
<div className="rounded-lg border bg-card">
<div className="px-3 py-2.5 border-b bg-muted/50">
<DynamicSearchFilter
tableName={TABLE_NAME}
filterId="c16-item-inspection"
onFilterChange={setSearchFilters}
externalFilterConfig={ts.filterConfig}
dataCount={totalCount}
extraActions={
<div className="flex items-center gap-2">
<Button size="sm" onClick={openCreate}><Plus className="w-4 h-4 mr-1" /></Button>
<Button size="sm" variant="outline" onClick={() => {
const sel = data.find(r => checkedIds.includes(r.id));
if (sel) {
const group = groupedData.find(g => g.item_code === sel.item_code);
openEdit(group?.rows[0] || sel);
} else toast.error("수정할 항목을 선택해주세요");
}}><Pencil className="w-4 h-4 mr-1" /></Button>
<Button size="sm" variant="destructive" onClick={handleDelete}><Trash2 className="w-4 h-4 mr-1" /></Button>
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
<Settings2 className="h-4 w-4" />
</Button>
</div>
}
/>
</div>
<div className="p-3 overflow-auto">
{loading ? (
<div className="flex items-center justify-center py-16"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
) : groupedData.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Inbox className="h-10 w-10 mb-2 opacity-30" />
<p className="text-sm"> </p>
</div>
) : (
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-10" />
<TableHead className="w-10"><Checkbox checked={groupedData.length > 0 && checkedIds.length === data.length} onCheckedChange={(v) => setCheckedIds(v ? data.map(r => r.id) : [])} /></TableHead>
{ts.visibleColumns.map((col) => (
<TableHead key={col.key} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground" style={ts.thStyle(col.key)}>
{col.label}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{ts.groupData(groupedData).map((group) => {
if ((group as any)._isGroupSummary || (group as any)._isGroupHeader) return null;
const isExpanded = expandedItems.has(group.item_code);
const groupIds = group.rows.map((r: any) => r.id);
const allChecked = groupIds.every((id: string) => checkedIds.includes(id));
const renderCell = (key: string) => {
switch (key) {
case "item_code": return <TableCell key={key} className="text-sm font-medium text-primary">{group.item_code}</TableCell>;
case "item_name": return <TableCell key={key} className="text-sm">{group.item_name}</TableCell>;
case "inspection_type": return (
<TableCell key={key}>
<div className="flex gap-1 flex-wrap">
{group.types.map((t: string) => <Badge key={t} variant="secondary" className="text-[10px]">{t}</Badge>)}
</div>
</TableCell>
);
case "item_count": return <TableCell key={key} className="text-sm text-center">{group.rows.filter((r: any) => r.inspection_standard_id).length}</TableCell>;
case "is_active": return (
<TableCell key={key}>
<Badge variant={group.is_active === "사용" || group.is_active === "true" ? "default" : "secondary"} className="text-[10px]">
{group.is_active === "사용" || group.is_active === "true" ? "사용" : "미사용"}
</Badge>
</TableCell>
);
default: return <TableCell key={key}>{(group as any)[key] ?? ""}</TableCell>;
}
};
return (
<React.Fragment key={group.item_code}>
<TableRow
className={cn("cursor-pointer border-l-2 border-l-transparent", allChecked && "border-l-primary bg-primary/5")}
onClick={() => setExpandedItems(prev => { const next = new Set(prev); if (next.has(group.item_code)) next.delete(group.item_code); else next.add(group.item_code); return next; })}
onDoubleClick={() => openEdit(group.rows[0])}
>
<TableCell className="text-center p-2">
<ChevronDown className={cn("w-4 h-4 transition-transform text-muted-foreground", !isExpanded && "-rotate-90")} />
</TableCell>
<TableCell className="text-center p-2" onClick={(e) => { e.stopPropagation(); setCheckedIds(prev => allChecked ? prev.filter(id => !groupIds.includes(id)) : [...new Set([...prev, ...groupIds])]); }}>
<Checkbox checked={allChecked} onCheckedChange={() => {}} />
</TableCell>
{ts.visibleColumns.map((col) => renderCell(col.key))}
</TableRow>
{isExpanded && group.rows.filter((r: any) => r.inspection_standard_id).map((row: any) => (
<TableRow key={row.id} className="bg-muted/30 text-xs">
<TableCell />
<TableCell />
<TableCell className="pl-6 text-muted-foreground">{row.inspection_type}</TableCell>
<TableCell>{resolveInspLabel(row.inspection_standard_id)}</TableCell>
<TableCell>{row.inspection_item_name || "-"}</TableCell>
<TableCell>{row.inspection_method || "-"}</TableCell>
<TableCell>{row.pass_criteria || "-"}</TableCell>
</TableRow>
))}
</React.Fragment>
);
})}
</TableBody>
</Table>
)}
<div className="mt-2 text-xs text-muted-foreground"> {groupedData.length} ( )</div>
</div>
{/* 검색 필터 */}
<div className="shrink-0 px-3 pt-3 pb-2">
<DynamicSearchFilter
tableName={TABLE_NAME}
filterId="c16-item-inspection"
onFilterChange={setSearchFilters}
externalFilterConfig={ts.filterConfig}
dataCount={totalCount}
/>
</div>
{/* ═══════════════════ 등록/수정 모달 (품목선택 뷰 포함) ═══════════════════ */}
{/* 좌우 분할 패널 */}
<div className="flex-1 min-h-0 px-3 pb-3">
<ResizablePanelGroup direction="horizontal" className="h-full rounded-lg border">
{/* ═══════ 좌측: 품목 목록 ═══════ */}
<ResizablePanel defaultSize={50} minSize={30}>
<div className="flex h-full flex-col">
<div className="shrink-0 flex items-center justify-between px-4 py-3 border-b bg-muted/30">
<div className="flex items-center gap-2">
<ClipboardList className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-semibold"> </span>
<Badge variant="secondary" className="text-[10px]">{groupedData.length}</Badge>
</div>
<div className="flex items-center gap-1.5">
<Button size="sm" className="h-7 text-xs" onClick={openCreate}><Plus 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>
</div>
<div className="flex-1 min-h-0 overflow-auto">
{loading ? (
<div className="flex items-center justify-center py-16"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
) : groupedData.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Inbox className="h-10 w-10 mb-2 opacity-30" />
<p className="text-sm"> </p>
</div>
) : (
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
{ts.visibleColumns.map((col) => (
<TableHead key={col.key} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground" style={ts.thStyle(col.key)}>
{col.label}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{groupedData.map((group) => (
<TableRow
key={group.item_code}
className={cn("cursor-pointer", selectedItemCode === group.item_code ? "bg-primary/10 border-l-2 border-l-primary" : "hover:bg-muted/50")}
onClick={() => {
setSelectedItemCode(group.item_code);
setSelectedTypeTab(group.types[0] || "");
}}
>
{ts.visibleColumns.map((col) => {
switch (col.key) {
case "item_code": return <TableCell key={col.key} className="text-sm font-medium text-primary">{group.item_code}</TableCell>;
case "item_name": return <TableCell key={col.key} className="text-sm">{group.item_name}</TableCell>;
case "inspection_type": return (
<TableCell key={col.key}>
<div className="flex gap-1 flex-wrap">
{group.types.map((t: string) => (
<Badge key={t} variant="secondary" className="text-[10px]">{t}</Badge>
))}
</div>
</TableCell>
);
case "is_active": return (
<TableCell key={col.key}>
<Badge variant={group.is_active === "사용" || group.is_active === "true" ? "default" : "secondary"} className="text-[10px]">
{group.is_active === "사용" || group.is_active === "true" ? "사용" : "미사용"}
</Badge>
</TableCell>
);
default: return <TableCell key={col.key}>{(group as any)[col.key] ?? ""}</TableCell>;
}
})}
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
<div className="shrink-0 px-4 py-2 border-t text-xs text-muted-foreground">
{groupedData.length} ( )
</div>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* ═══════ 우측: 검사유형별 검사항목 ═══════ */}
<ResizablePanel defaultSize={50} minSize={30}>
<div className="flex h-full flex-col">
<div className="shrink-0 flex items-center justify-between px-4 py-3 border-b bg-muted/30">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold"> </span>
</div>
{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="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" /></Button>
</div>
)}
</div>
{!selectedGroup ? (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
<div className="text-center space-y-2">
<ClipboardList className="h-8 w-8 mx-auto opacity-30" />
<p className="text-sm"> </p>
</div>
</div>
) : (
<div className="flex-1 min-h-0 flex flex-col">
{/* 검사유형 탭 */}
<div className="shrink-0 flex items-center gap-2 px-4 py-3">
{selectedGroup.types.map((type: string) => {
const count = selectedGroup.rows.filter((r: any) => r.inspection_type === type && r.inspection_standard_id).length;
return (
<button
key={type}
type="button"
onClick={() => setSelectedTypeTab(type)}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-colors border",
selectedTypeTab === type
? "bg-primary text-primary-foreground border-primary"
: "bg-muted/50 text-muted-foreground border-border hover:bg-muted"
)}
>
{type}
<span className={cn(
"inline-flex items-center justify-center h-4 min-w-[16px] rounded-full text-[10px] font-bold px-1",
selectedTypeTab === type ? "bg-primary-foreground/20 text-primary-foreground" : "bg-muted-foreground/20 text-muted-foreground"
)}>
{count}
</span>
</button>
);
})}
</div>
{/* 검사항목 상세 테이블 */}
<div className="flex-1 min-h-0 overflow-auto px-4 pb-4">
{selectedTypeTab && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Badge variant="default" className="text-xs">{selectedTypeTab}</Badge>
<span className="text-sm font-medium"> </span>
</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 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>
</TableRow>
</TableHeader>
<TableBody>
{selectedTabRows.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-xs text-muted-foreground"> </TableCell>
</TableRow>
) : selectedTabRows.map((row: any) => (
<TableRow key={row.id}>
<TableCell className="text-xs py-2">{row.inspection_item_name || "-"}</TableCell>
<TableCell className="text-xs py-2">{resolveInspLabel(row.inspection_standard_id)}</TableCell>
<TableCell className="text-xs py-2">{resolveMethodLabel(row.inspection_method)}</TableCell>
<TableCell className="text-xs py-2">{row.apply_process || "-"}</TableCell>
<TableCell className="text-xs py-2">
{row.judgment_criteria ? (
<Badge variant="outline" className="text-[10px]">{row.judgment_criteria}</Badge>
) : "-"}
</TableCell>
<TableCell className="text-xs py-2 font-mono">{row.pass_criteria || "-"}</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>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
</div>
</div>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
{/* ═══════════════════ 등록/수정 모달 ═══════════════════ */}
<Dialog open={modalOpen} onOpenChange={(open) => { if (!open) setItemModalOpen(false); setModalOpen(open); }}>
<DialogContent className={cn("max-w-[95vw] max-h-[85vh] overflow-y-auto", itemModalOpen ? "sm:max-w-xl" : "sm:max-w-4xl")}>
{itemModalOpen ? (
@@ -502,16 +574,8 @@ export default function ItemInspectionInfoPage() {
<DialogDescription> </DialogDescription>
</DialogHeader>
<div className="flex gap-2">
<Input
className="h-9 flex-1"
placeholder="품목코드 또는 품목명으로 검색"
value={itemSearchKeyword}
onChange={(e) => setItemSearchKeyword(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleItemSearch(); }}
/>
<Button size="sm" className="h-9" onClick={handleItemSearch}>
<Search className="w-4 h-4 mr-1" />
</Button>
<Input className="h-9 flex-1" placeholder="품목코드 또는 품목명" value={itemSearchKeyword} onChange={(e) => setItemSearchKeyword(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") handleItemSearch(); }} />
<Button size="sm" className="h-9" onClick={handleItemSearch}><Search className="w-4 h-4 mr-1" /></Button>
</div>
<div className="border rounded-lg overflow-auto max-h-[50vh]">
<Table>
@@ -527,11 +591,7 @@ export default function ItemInspectionInfoPage() {
{filteredItems.length === 0 ? (
<TableRow><TableCell colSpan={4} className="text-center py-8 text-muted-foreground text-sm"> </TableCell></TableRow>
) : filteredItems.map((item) => (
<TableRow
key={item.code}
className="cursor-pointer hover:bg-primary/5"
onClick={() => selectItem(item)}
>
<TableRow key={item.code} className="cursor-pointer hover:bg-primary/5" onClick={() => selectItem(item)}>
<TableCell className="text-sm">{item.code}</TableCell>
<TableCell className="text-sm">{item.name}</TableCell>
<TableCell className="text-sm">{item.item_type}</TableCell>
@@ -541,9 +601,7 @@ export default function ItemInspectionInfoPage() {
</TableBody>
</Table>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setItemModalOpen(false)}></Button>
</DialogFooter>
<DialogFooter><Button variant="outline" onClick={() => setItemModalOpen(false)}></Button></DialogFooter>
</>
) : (
<>
@@ -554,14 +612,14 @@ export default function ItemInspectionInfoPage() {
<div className="space-y-4">
{/* 품목 정보 */}
<div className="space-y-3">
<h4 className="text-sm font-semibold flex items-center gap-1.5">📦 </h4>
<h4 className="text-sm font-semibold"> </h4>
<div className="flex items-end gap-2">
<div className="space-y-1.5 flex-1">
<Label className="text-xs font-semibold text-muted-foreground"> <span className="text-destructive">*</span></Label>
<Input className="h-9 bg-muted" value={form.item_code || ""} readOnly placeholder="품목코드" />
</div>
<div className="space-y-1.5 flex-1">
<Label className="text-xs font-semibold text-muted-foreground"> <span className="text-destructive">*</span></Label>
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Input className="h-9 bg-muted" value={form.item_name || ""} readOnly placeholder="품목명" />
</div>
<Button type="button" variant="outline" size="sm" className="h-9 px-3 shrink-0" onClick={openItemModal}>
@@ -571,7 +629,7 @@ export default function ItemInspectionInfoPage() {
<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={form.is_active === false ? "N" : "Y"} onValueChange={(v) => setForm(p => ({ ...p, is_active: v === "Y" }))}>
<Select value={form.is_active === false || form.is_active === "N" ? "N" : "Y"} onValueChange={(v) => setForm(p => ({ ...p, is_active: v === "Y" ? "사용" : "미사용" }))}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="Y"></SelectItem>
@@ -583,50 +641,32 @@ export default function ItemInspectionInfoPage() {
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Select value={form.manager || ""} onValueChange={(v) => setForm(p => ({ ...p, manager: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="관리자 선택" /></SelectTrigger>
<SelectContent>
{userOptions.map(o => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
<SelectContent>{userOptions.map(o => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<textarea
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm min-h-[70px] resize-none focus:outline-none focus:ring-2 focus:ring-ring"
value={form.remarks || ""}
onChange={(e) => setForm(p => ({ ...p, remarks: e.target.value }))}
placeholder="비고 사항"
/>
</div>
</div>
{/* 검사유형 선택 */}
<div className="space-y-3">
<h4 className="text-sm font-semibold flex items-center gap-1.5"> </h4>
<h4 className="text-sm font-semibold"> </h4>
<div className="flex flex-wrap gap-4">
{INSPECTION_TYPES.map(({ key, label }) => (
<div key={key} className="flex items-center gap-1.5">
<Checkbox
checked={!!form[key]}
onCheckedChange={(v) => setForm(p => ({ ...p, [key]: !!v }))}
/>
<Checkbox checked={!!form[key]} onCheckedChange={(v) => setForm(p => ({ ...p, [key]: !!v }))} />
<Label className="text-sm cursor-pointer">{label}</Label>
</div>
))}
</div>
</div>
{/* 검사유형별 검사항목 설정 */}
{INSPECTION_TYPES.filter(t => !!form[t.key]).map(({ key, label }) => (
<div key={key} className="space-y-2">
<button
type="button"
className="w-full flex items-center gap-2 py-2 px-3 rounded-md border bg-muted/50 hover:bg-muted text-left"
onClick={() => toggleCollapse(key)}
>
<button type="button" className="w-full flex items-center gap-2 py-2 px-3 rounded-md border bg-muted/50 hover:bg-muted text-left" onClick={() => toggleCollapse(key)}>
<Badge variant="default" className="text-xs">{label}</Badge>
<span className="text-sm font-medium"> </span>
<ChevronDown className={cn("w-4 h-4 ml-auto transition-transform", collapsedTypes[key] && "-rotate-90")} />
<span className="text-xs text-muted-foreground ml-auto">{(inspectionRows[key] || []).length}</span>
</button>
{!collapsedTypes[key] && (
<div className="space-y-2 pl-1">
@@ -641,10 +681,10 @@ export default function ItemInspectionInfoPage() {
<TableHeader>
<TableRow className="bg-muted/50 hover:bg-muted/50">
<TableHead className="text-[10px] font-bold w-[170px]"> </TableHead>
<TableHead className="text-[10px] font-bold w-[130px]"> </TableHead>
<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-[100px]"></TableHead>
<TableHead className="text-[10px] font-bold"> ()</TableHead>
<TableHead className="text-[10px] font-bold"></TableHead>
<TableHead className="text-[10px] font-bold w-[40px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[36px]" />
</TableRow>
@@ -657,46 +697,20 @@ export default function ItemInspectionInfoPage() {
<TableCell className="p-1">
<Select value={row.inspection_standard_id || ""} onValueChange={(v) => updateInspRow(key, row.id, "inspection_standard_id", v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="검사기준 선택" /></SelectTrigger>
<SelectContent>
{getFilteredInspOptions(key).map(o => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
<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-8 text-xs bg-muted" value={row.inspection_detail} readOnly placeholder="자동" /></TableCell>
<TableCell className="p-1"><Input className="h-8 text-xs bg-muted" value={row.inspection_method} readOnly placeholder="자동" /></TableCell>
<TableCell className="p-1">
<Input className="h-8 text-xs bg-muted" value={row.inspection_detail} readOnly placeholder="검사기준 상세" />
<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 bg-muted" value={row.inspection_method} readOnly placeholder="선택" />
<Input className="h-8 text-xs" value={row.acceptance_criteria} onChange={(e) => updateInspRow(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) => updateInspRow(key, row.id, "is_required", !!v)} /></TableCell>
<TableCell className="p-1">
<Select value={row.apply_process || ""} onValueChange={(v) => updateInspRow(key, row.id, "apply_process", v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="공정 선택" /></SelectTrigger>
<SelectContent>
<SelectItem value="incoming"></SelectItem>
<SelectItem value="process"></SelectItem>
<SelectItem value="outgoing"></SelectItem>
<SelectItem value="final"></SelectItem>
</SelectContent>
</Select>
</TableCell>
<TableCell className="p-1">
<Input
className="h-8 text-xs"
value={row.acceptance_criteria}
onChange={(e) => updateInspRow(key, row.id, "acceptance_criteria", e.target.value)}
placeholder={row.inspection_standard_id ? "합격기준 입력" : "검사기준을 먼저 선택하세요"}
disabled={!row.inspection_standard_id}
/>
</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">
<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>
<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>
</TableRow>
))}
@@ -711,8 +725,7 @@ export default function ItemInspectionInfoPage() {
<DialogFooter>
<Button variant="outline" onClick={() => setModalOpen(false)}></Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Save className="w-4 h-4 mr-1" />}
{saving ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Save className="w-4 h-4 mr-1" />}
</Button>
</DialogFooter>
</>
@@ -720,14 +733,7 @@ export default function ItemInspectionInfoPage() {
</DialogContent>
</Dialog>
<TableSettingsModal
open={ts.open}
onOpenChange={ts.setOpen}
tableName={ts.tableName}
settingsId={ts.settingsId}
defaultVisibleKeys={ts.defaultVisibleKeys}
onSave={ts.applySettings}
/>
<TableSettingsModal open={ts.open} onOpenChange={ts.setOpen} tableName={ts.tableName} settingsId={ts.settingsId} defaultVisibleKeys={ts.defaultVisibleKeys} onSave={ts.applySettings} />
</div>
);
}
@@ -195,6 +195,12 @@ export default function CustomerManagementPage() {
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
} catch { /* skip */ }
}
for (const col of ["division", "unit", "material"]) {
try {
const res = await apiClient.get(`/table-categories/item_info/${col}/values`);
if (res.data?.success) optMap[`item_${col}`] = flatten(res.data.data || []);
} catch { /* skip */ }
}
setCategoryOptions(optMap);
const priceOpts: Record<string, { code: string; label: string }[]> = {};
@@ -709,7 +715,7 @@ export default function CustomerManagementPage() {
const handleCustomerSave = async () => {
if (!customerForm.customer_name) { toast.error("거래처명은 필수입니다."); return; }
if (!customerForm.status) { toast.error("상태는 필수입니다."); return; }
const errors = validateForm(customerForm, ["contact_phone", "email", "business_number"]);
const errors = validateForm(customerForm, ["business_number"]);
setFormErrors(errors);
if (Object.keys(errors).length > 0) {
toast.error("입력 형식을 확인해주세요.");
@@ -808,36 +814,56 @@ export default function CustomerManagementPage() {
};
// 품목 검색
const searchItems = async () => {
const searchItems = useCallback(async () => {
setItemSearchLoading(true);
try {
const filters: any[] = [
{ columnName: "division", operator: "contains", value: "CAT_ML8ZFVEL_1TOR" },
];
if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
const salesCode = categoryOptions["item_division"]?.find((o) => o.label === "영업관리")?.code;
const filters: any[] = salesCode
? [{ columnName: "division", operator: "contains", value: salesCode }]
: [];
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 500,
page: 1, size: 5000,
dataFilter: { enabled: true, filters },
autoFilter: true,
});
const allItems = res.data?.data?.data || res.data?.data?.rows || [];
setItemTotalCount(allItems.length);
const existingItemIds = new Set(priceItems.map((p: any) => p.item_id || p.item_number));
const kw = itemSearchKeyword.toLowerCase();
const seenNumbers = new Set<string>();
const deduped = allItems.filter((item: any) => {
if (existingItemIds.has(item.item_number) || existingItemIds.has(item.id)) return false;
if (item.item_number && seenNumbers.has(item.item_number)) return false;
if (item.item_number) seenNumbers.add(item.item_number);
if (kw) {
const name = (item.item_name || "").toLowerCase();
const code = (item.item_number || "").toLowerCase();
if (!name.includes(kw) && !code.includes(kw)) return false;
}
return true;
});
setItemSearchResults(deduped);
} catch { /* skip */ } finally { setItemSearchLoading(false); }
};
}, [itemSearchKeyword, priceItems]);
// 실시간 검색 (2글자 이상)
useEffect(() => {
if (!itemSelectOpen) return;
if (itemSearchKeyword.length > 0 && itemSearchKeyword.length < 2) return;
searchItems();
}, [itemSearchKeyword, itemSelectOpen]);
// 품목 선택 완료 → 상세 입력 모달로 전환
const goToItemDetail = () => {
const selected = itemSearchResults.filter((i) => itemCheckedIds.has(i.id));
if (selected.length === 0) { toast.error("품목을 선택해주세요."); return; }
const raw = itemSearchResults.filter((i) => itemCheckedIds.has(i.id));
if (raw.length === 0) { toast.error("품목을 선택해주세요."); return; }
const seenKeys = new Set<string>();
const selected = raw.filter((i) => {
const k = i.item_number || i.id;
if (seenKeys.has(k)) return false;
seenKeys.add(k);
return true;
});
setSelectedItemsForDetail(selected);
const mappings: typeof itemMappings = {};
const prices: typeof itemPrices = {};
@@ -969,6 +995,7 @@ export default function CustomerManagementPage() {
{ columnName: "customer_id", operator: "equals", value: selectedCustomer!.customer_code },
{ columnName: "item_id", operator: "equals", value: itemKey },
]}, autoFilter: true,
sort: { columnName: "created_date", order: "asc" },
});
const allMappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
mappingRows = allMappings
@@ -989,7 +1016,8 @@ export default function CustomerManagementPage() {
{ columnName: "item_id", operator: "equals", value: itemKey },
]}, autoFilter: true,
});
const allPriceData = priceRes.data?.data?.data || priceRes.data?.data?.rows || [];
const allPriceData = (priceRes.data?.data?.data || priceRes.data?.data?.rows || [])
.sort((a: any, b: any) => (a.start_date || "").localeCompare(b.start_date || ""));
priceRows = allPriceData.map((p: any) => ({
_id: `p_existing_${p.id}`,
start_date: p.start_date ? String(p.start_date).split("T")[0] : "",
@@ -1027,8 +1055,11 @@ export default function CustomerManagementPage() {
const isEditingExisting = !!editItemData;
setSaving(true);
try {
const processedKeys = new Set<string>();
for (const item of selectedItemsForDetail) {
const itemKey = item.item_number || item.id;
if (processedKeys.has(itemKey)) continue;
processedKeys.add(itemKey);
const mappingRows = itemMappings[itemKey] || [];
if (isEditingExisting && editItemData?.id) {
@@ -1041,6 +1072,7 @@ export default function CustomerManagementPage() {
{ columnName: "customer_id", operator: "equals", value: selectedCustomer.customer_code },
{ columnName: "item_id", operator: "equals", value: itemKey },
]}, autoFilter: true,
sort: { columnName: "created_date", order: "asc" },
});
existingMaps = existingMappings.data?.data?.data || existingMappings.data?.data?.rows || [];
} catch { /* skip */ }
@@ -1090,12 +1122,13 @@ export default function CustomerManagementPage() {
{ columnName: "item_id", operator: "equals", value: itemKey },
]}, autoFilter: true,
});
existingPriceRows = existingPrices.data?.data?.data || existingPrices.data?.data?.rows || [];
existingPriceRows = (existingPrices.data?.data?.data || existingPrices.data?.data?.rows || [])
.sort((a: any, b: any) => (a.start_date || "").localeCompare(b.start_date || ""));
} catch { /* skip */ }
// 단가 upsert
const priceRows = (itemPrices[itemKey] || []).filter((p) =>
(p.base_price && Number(p.base_price) > 0) || p.start_date
p.base_price || p.start_date || p.currency_code || p.base_price_type
);
const usedPriceIds = new Set<string>();
for (let pi = 0; pi < priceRows.length; pi++) {
@@ -1165,7 +1198,7 @@ export default function CustomerManagementPage() {
}
const priceRows = (itemPrices[itemKey] || []).filter((p) =>
(p.base_price && Number(p.base_price) > 0) || p.start_date
p.base_price || p.start_date || p.currency_code || p.base_price_type
);
for (const price of priceRows) {
await apiClient.post(`/table-management/tables/${PRICE_TABLE}/add`, {
@@ -1197,40 +1230,63 @@ export default function CustomerManagementPage() {
}
};
// 품목 매핑 삭제
// 품목 매핑 해제 — 선택한 품목의 모든 매핑 + 단가에서 customer_id를 null 처리
const handlePriceItemDelete = async () => {
if (priceCheckedIds.length === 0) return;
const ok = await confirm(`선택한 ${priceCheckedIds.length}개 품목 매핑제하시겠습니까?`, {
description: "관련된 단가 정보도 함께 삭제됩니다.",
variant: "destructive", confirmText: "제",
const ok = await confirm(`선택한 ${priceCheckedIds.length}개 품목의 연결제하시겠습니까?`, {
description: "해당 품목의 거래처 연결이 해제됩니다. (데이터는 유지)",
variant: "destructive", confirmText: "제",
});
if (!ok) return;
try {
for (const mappingId of priceCheckedIds) {
const itemIds = priceCheckedIds.map((mid) => {
const group = Object.values(priceGroups).find((g) => g.master.id === mid);
return group?.master.item_id || group?.master.item_number || "";
}).filter(Boolean);
for (const itemId of itemIds) {
// 해당 품목의 모든 매핑 조회 → customer_id null 처리
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [
{ columnName: "customer_id", operator: "equals", value: selectedCustomer!.customer_code },
{ columnName: "item_id", operator: "equals", value: itemId },
]}, autoFilter: true,
});
const allMappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
for (const m of allMappings) {
await apiClient.put(`/table-management/tables/${MAPPING_TABLE}/edit`, {
originalData: { id: m.id },
updatedData: { customer_id: null },
});
}
// 해당 품목의 모든 단가 조회 → customer_id null 처리
try {
const priceRes = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "mapping_id", operator: "equals", value: mappingId }] },
autoFilter: true,
dataFilter: { enabled: true, filters: [
{ columnName: "customer_id", operator: "equals", value: selectedCustomer!.customer_code },
{ columnName: "item_id", operator: "equals", value: itemId },
]}, autoFilter: true,
});
const prices = priceRes.data?.data?.data || priceRes.data?.data?.rows || [];
if (prices.length > 0) {
await apiClient.delete(`/table-management/tables/${PRICE_TABLE}/delete`, {
data: prices.map((p: any) => ({ id: p.id })),
for (const p of prices) {
await apiClient.put(`/table-management/tables/${PRICE_TABLE}/edit`, {
originalData: { id: p.id },
updatedData: { customer_id: null },
});
}
} catch { /* skip */ }
}
await apiClient.delete(`/table-management/tables/${MAPPING_TABLE}/delete`, {
data: priceCheckedIds.map((id) => ({ id })),
});
toast.success(`${priceCheckedIds.length}개 품목 매핑이 삭제되었습니다.`);
toast.success(`${priceCheckedIds.length}개 품목의 연결이 해제되었습니다.`);
setPriceCheckedIds([]);
const cid = selectedCustomerId;
setSelectedCustomerId(null);
setTimeout(() => setSelectedCustomerId(cid), 50);
} catch {
toast.error("제에 실패했습니다.");
toast.error("연결 해제에 실패했습니다.");
}
};
@@ -1821,35 +1877,6 @@ export default function CustomerManagementPage() {
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input
value={customerForm.contact_person || ""}
onChange={(e) => setCustomerForm((p) => ({ ...p, contact_person: e.target.value }))}
placeholder="거래처담당자"
className="h-9"
/>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input
value={customerForm.contact_phone || ""}
onChange={(e) => handleFormChange("contact_phone", e.target.value)}
placeholder="010-0000-0000"
className={cn("h-9", formErrors.contact_phone && "border-destructive")}
/>
{formErrors.contact_phone && <p className="text-xs text-destructive">{formErrors.contact_phone}</p>}
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input
value={customerForm.email || ""}
onChange={(e) => handleFormChange("email", e.target.value)}
placeholder="example@email.com"
className={cn("h-9", formErrors.email && "border-destructive")}
/>
{formErrors.email && <p className="text-xs text-destructive">{formErrors.email}</p>}
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input
@@ -2386,7 +2413,7 @@ export default function CustomerManagementPage() {
{itemSearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" /> </>}
</Button>
</div>
<div className="overflow-auto max-h-[350px] border rounded-lg">
<div className="overflow-auto h-[350px] border rounded-lg">
<Table noWrapper>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted h-10">
@@ -2433,8 +2460,8 @@ export default function CustomerManagementPage() {
</TableCell>
<TableCell className="text-sm">{item.item_name}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{item.size}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{item.material}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{item.unit}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{categoryOptions["item_material"]?.find((o) => o.code === item.material)?.label || item.material}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{categoryOptions["item_unit"]?.find((o) => o.code === item.unit)?.label || item.unit}</TableCell>
</TableRow>
))}
</TableBody>
@@ -2476,7 +2503,7 @@ export default function CustomerManagementPage() {
{/* 품목 헤더 */}
<div className="px-5 py-3 bg-muted/30 border-b">
<div className="font-bold">{idx + 1}. {item.item_name || itemKey}</div>
<div className="text-xs text-muted-foreground">{itemKey} | {item.size || ""} | {item.unit || ""}</div>
<div className="text-xs text-muted-foreground">{itemKey} | {item.size || ""} | {categoryOptions["item_unit"]?.find((o) => o.code === item.unit)?.label || item.unit || ""}</div>
</div>
<div className="flex gap-4 p-5 items-stretch">
@@ -359,7 +359,7 @@ export default function SalesOrderPage() {
} finally {
setLoading(false);
}
}, [searchFilters]);
}, [searchFilters, categoryOptions]);
useEffect(() => { fetchOrders(); }, [fetchOrders]);
@@ -517,29 +517,44 @@ export default function SalesOrderPage() {
}
};
// 삭제 (마스터 단위)
// 삭제 (선택한 디테일 삭제 → 디테일 0건인 마스터 자동 삭제)
const handleDelete = async () => {
if (checkedIds.length === 0) { toast.error("삭제할 수주를 선택해주세요."); return; }
const selectedItems = orders.filter((o) => checkedIds.includes(o.id));
const orderNos = [...new Set(selectedItems.map((o) => o.order_no))];
const ok = await confirm(`${orderNos.length}건의 수주를 삭제하시겠습니까?`, {
if (checkedIds.length === 0) { toast.error("삭제할 항목을 선택해주세요."); return; }
const ok = await confirm(`${checkedIds.length}건의 수주 항목을 삭제하시겠습니까?`, {
description: "삭제된 데이터는 복구할 수 없습니다.",
variant: "destructive",
confirmText: "삭제",
});
if (!ok) return;
try {
// 1. 선택한 디테일 삭제
await apiClient.delete(`/table-management/tables/${DETAIL_TABLE}/delete`, {
data: checkedIds.map((id) => ({ id })),
});
// 2. 영향받는 수주번호의 잔여 디테일 확인 → 0건이면 마스터도 삭제
const selectedItems = orders.filter((o) => checkedIds.includes(o.id));
const orderNos = [...new Set(selectedItems.map((o) => o.order_no))];
for (const orderNo of orderNos) {
const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, {
const remainRes = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
page: 1, size: 1,
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] },
autoFilter: true,
});
const masters = masterRes.data?.data?.data || masterRes.data?.data?.rows || [];
if (masters.length > 0) {
await apiClient.delete(`/table-management/tables/${MASTER_TABLE}/delete`, {
data: masters.map((m: any) => ({ id: m.id })),
const remaining = remainRes.data?.data?.data || remainRes.data?.data?.rows || [];
if (remaining.length === 0) {
// 디테일 0건 → 마스터 삭제
const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, {
page: 1, size: 1,
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] },
autoFilter: true,
});
const masters = masterRes.data?.data?.data || masterRes.data?.data?.rows || [];
if (masters.length > 0) {
await apiClient.delete(`/table-management/tables/${MASTER_TABLE}/delete`, {
data: masters.map((m: any) => ({ id: m.id })),
});
}
}
}
toast.success("삭제되었습니다.");
@@ -918,7 +933,7 @@ export default function SalesOrderPage() {
{/* 데이터 테이블 (플랫 리스트) */}
<div className="flex-1 overflow-hidden rounded-lg border border-border bg-card">
<div className="h-full overflow-auto">
<Table style={{ minWidth: "1500px" }}>
<Table noWrapper style={{ minWidth: "1500px" }}>
<colgroup>
<col style={{ width: "40px" }} />
<col style={{ width: "140px" }} />
@@ -1107,6 +1122,7 @@ export default function SalesOrderPage() {
<Select value={masterForm.input_mode || ""} onValueChange={(v) => {
setMasterForm((p) => {
const next = { ...p, input_mode: v };
// 입력방식 변경 시 거래처 관련 값 초기화
delete next.partner_id;
delete next.delivery_partner_id;
delete next.delivery_address;
File diff suppressed because it is too large Load Diff
@@ -38,6 +38,30 @@ const fmtDate = (v: any) => {
return m ? `${m[1]}-${m[2]}-${m[3]}` : s.substring(0, 10);
};
/** remark가 JSON이면 사람이 읽을 수 있는 텍스트로 변환 */
const parseRemark = (remark: string | null | undefined): string => {
if (!remark) return "";
const trimmed = remark.trim();
if (!trimmed.startsWith("{")) return trimmed;
try {
const d = JSON.parse(trimmed);
switch (d.type) {
case "move":
return `창고이동 (${d.from_warehouse}${d.to_warehouse})`;
case "adjust":
return `재고조정 (${d.reason || "사유 없음"}, ${d.system_qty}${d.actual_qty}, 차이:${d.diff >= 0 ? "+" : ""}${d.diff})`;
case "confirm":
return `재고확인 (${d.reason || "이상없음"})`;
case "process_inbound":
return "공정입고";
default:
return d.reason || d.memo || trimmed;
}
} catch {
return trimmed;
}
};
export default function InboundOutboundPage() {
const { user } = useAuth();
@@ -62,7 +86,6 @@ export default function InboundOutboundPage() {
try {
const filters: any[] = [];
if (typeFilter !== "all") filters.push({ columnName: "transaction_type", operator: "equals", value: typeFilter });
if (categoryFilter !== "all") filters.push({ columnName: "remark", operator: "equals", value: categoryFilter });
for (const f of searchFilters) {
if (f.value) filters.push({ columnName: f.columnName, operator: f.operator, value: f.value });
@@ -81,6 +104,21 @@ export default function InboundOutboundPage() {
const itemCodes = [...new Set(rows.map((r: any) => r.item_code).filter(Boolean))];
if (itemCodes.length > 0) {
try {
// 단위 카테고리 코드→라벨 매핑 로드
let unitLabelMap: Record<string, string> = {};
try {
const catRes = await apiClient.get("/table-categories/item_info/unit/values");
if (catRes.data?.success && catRes.data.data?.length > 0) {
const flatten = (vals: any[]) => {
for (const v of vals) {
unitLabelMap[v.valueCode] = v.valueLabel;
if (v.children?.length) flatten(v.children);
}
};
flatten(catRes.data.data);
}
} catch { /* skip */ }
const itemRes = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: itemCodes.length + 10,
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: itemCodes }] },
@@ -89,7 +127,8 @@ export default function InboundOutboundPage() {
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
const map: Record<string, { item_name: string; unit: string }> = {};
for (const i of items) {
if (!map[i.item_number]) map[i.item_number] = { item_name: i.item_name || "", unit: i.unit || "" };
const rawUnit = i.unit || "";
if (!map[i.item_number]) map[i.item_number] = { item_name: i.item_name || "", unit: unitLabelMap[rawUnit] || rawUnit };
}
setItemMap(map);
} catch { /* skip */ }
@@ -124,7 +163,7 @@ export default function InboundOutboundPage() {
} finally {
setLoading(false);
}
}, [searchFilters, typeFilter, categoryFilter]);
}, [searchFilters, typeFilter]);
useEffect(() => { fetchData(); }, [fetchData]);
@@ -132,20 +171,26 @@ export default function InboundOutboundPage() {
const categoryOptions = useMemo(() => {
const set = new Set<string>();
data.forEach((r) => { if (r.remark) set.add(r.remark); });
data.forEach((r) => { if (r.remark) set.add(parseRemark(r.remark)); });
return Array.from(set).sort();
}, [data]);
// ════════ 그룹핑 ════════
// 카테고리 필터 (클라이언트)
const filteredData = useMemo(() => {
if (categoryFilter === "all") return data;
return data.filter((r) => parseRemark(r.remark) === categoryFilter);
}, [data, categoryFilter]);
const groupedData = useMemo(() => {
if (groupBy === "none") return data;
if (groupBy === "none") return filteredData;
const groups = new Map<string, any[]>();
for (const row of data) {
for (const row of filteredData) {
let key: string;
switch (groupBy) {
case "transaction_type": key = row.transaction_type || "미지정"; break;
case "remark": key = row.remark || "미지정"; break;
case "remark": key = parseRemark(row.remark) || "미지정"; break;
case "warehouse_code": key = warehouseMap[row.warehouse_code] || row.warehouse_code || "미지정"; break;
case "item_code": key = `${row.item_code} ${itemMap[row.item_code]?.item_name || ""}`.trim() || "미지정"; break;
default: key = row[groupBy] || "미지정";
@@ -191,7 +236,7 @@ export default function InboundOutboundPage() {
const rows = data.map((r, i) => ({
No: i + 1,
입출고구분: r.transaction_type || "",
카테고리: r.remark || "",
카테고리: parseRemark(r.remark),
처리일자: fmtDate(r.transaction_date),
창고: warehouseMap[r.warehouse_code] || r.warehouse_code || "",
위치: r.location_code || "",
@@ -252,7 +297,7 @@ export default function InboundOutboundPage() {
<div className="flex items-center gap-2">
<Package className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-semibold"> </span>
<Badge variant="secondary" className="font-mono text-xs">{data.length}</Badge>
<Badge variant="secondary" className="font-mono text-xs">{filteredData.length}</Badge>
</div>
<div className="flex items-center gap-2">
<Select value={groupBy} onValueChange={(v) => { setGroupBy(v); setExpandedGroups(new Set()); }}>
@@ -353,7 +398,7 @@ export default function InboundOutboundPage() {
{row.transaction_type}
</span>
</TableCell>
<TableCell className="text-center text-[12px]">{row.remark || "-"}</TableCell>
<TableCell className="text-center text-[12px]">{parseRemark(row.remark) || "-"}</TableCell>
<TableCell className="text-center text-[12px]">{fmtDate(row.transaction_date)}</TableCell>
<TableCell className="text-[12px]">{warehouseMap[row.warehouse_code] || row.warehouse_code || "-"}</TableCell>
<TableCell className="text-center text-[12px]">{row.location_code || "-"}</TableCell>
@@ -230,11 +230,10 @@ function flattenCategories(items: any[]): { value: string; label: string }[] {
const result: { value: string; label: string }[] = [];
function walk(arr: any[]) {
for (const item of arr) {
if (item.value || item.name) {
result.push({
value: item.value || item.name,
label: item.label || item.name || item.value,
});
const val = item.valueCode || item.value || item.name;
const lbl = item.valueLabel || item.label || item.name || val;
if (val) {
result.push({ value: val, label: lbl });
}
if (item.children?.length) walk(item.children);
}
@@ -140,31 +140,47 @@ export default function InventoryStatusPage() {
Record<string, { code: string; label: string }[]>
>({});
// 카테고리 로드
// 사용자 맵 (writer → 이름)
const [userMap, setUserMap] = useState<Record<string, string>>({});
// 카테고리 + 사용자 로드
useEffect(() => {
const load = async () => {
const optMap: Record<string, { code: string; label: string }[]> = {};
const flatten = (vals: any[]): { code: string; label: string }[] => {
const result: { code: string; label: string }[] = [];
for (const v of vals) {
result.push({ code: v.valueCode, label: v.valueLabel });
result.push({ code: v.valueCode || v.value_code || v.code, label: v.valueLabel || v.value_label || v.label });
if (v.children?.length) result.push(...flatten(v.children));
}
return result;
};
for (const col of ["status", "unit"]) {
// inventory_stock 카테고리
for (const col of ["status"]) {
try {
const res = await apiClient.get(
`/table-categories/${STOCK_TABLE}/${col}/values`
);
const res = await apiClient.get(`/table-categories/${STOCK_TABLE}/${col}/values`);
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
} catch {
/* skip */
}
} catch { /* skip */ }
}
// item_info 단위 카테고리
try {
const res = await apiClient.get("/table-categories/item_info/unit/values?filterCompanyCode=COMPANY_30");
if (res.data?.success) optMap["item_unit"] = flatten(res.data.data || []);
} catch { /* skip */ }
setCategoryOptions(optMap);
};
load();
// 사용자 목록 로드
apiClient.get("/admin/users", { params: { size: 9999 } }).then((res) => {
const users = res.data?.data || res.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;
if (id) map[id] = name;
}
setUserMap(map);
}).catch(() => {});
}, []);
// 재고 목록 조회
@@ -193,10 +209,11 @@ export default function InventoryStatusPage() {
};
const data = raw.map((r: any) => {
const itemInfo = itemMap.get(r.item_code) as any;
const rawUnit = itemInfo?.unit || r.unit || "";
return {
...r,
item_name: itemInfo?.name || "",
unit: itemInfo?.unit || resolve("unit", r.unit),
unit: resolve("item_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),
@@ -332,6 +349,12 @@ export default function InventoryStatusPage() {
),
};
}
if (col.key === "warehouse_code") {
return {
...base,
render: (_val: any, row: any) => row.warehouse_name || row.warehouse_code || "",
};
}
if (col.key === "safety_qty") {
return {
...base,
@@ -613,7 +636,7 @@ export default function InventoryStatusPage() {
<TableCell className="truncate max-w-[150px]">
{h.remark || h.reason || ""}
</TableCell>
<TableCell>{h.writer || h.created_by || ""}</TableCell>
<TableCell>{userMap[h.writer] || userMap[h.created_by] || h.writer || h.created_by || ""}</TableCell>
</TableRow>
))}
</TableBody>
@@ -43,6 +43,7 @@ import {
} from "@/lib/api/materialStatus";
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { apiClient } from "@/lib/api/client";
const GRID_COLUMNS = [
{ key: "plan_no", label: "계획번호" },
@@ -60,26 +61,31 @@ const formatDate = (date: Date) => {
return `${y}-${m}-${d}`;
};
const getStatusLabel = (status: string) => {
const map: Record<string, string> = {
planned: "계획",
in_progress: "진행중",
completed: "완료",
pending: "대기",
cancelled: "취소",
};
return map[status] || status;
const FALLBACK_STATUS_MAP: Record<string, string> = {
planned: "계획",
in_progress: "진행중",
completed: "완료",
pending: "대기",
cancelled: "취소",
};
const getStatusStyle = (status: string) => {
const map: Record<string, string> = {
planned: "bg-secondary text-secondary-foreground border-border",
pending: "bg-secondary text-secondary-foreground border-border",
in_progress: "bg-primary/10 text-primary border-primary/20",
completed: "bg-accent text-accent-foreground border-accent/50",
cancelled: "bg-muted text-muted-foreground border-border",
};
return map[status] || "bg-muted text-muted-foreground border-border";
const STATUS_STYLE_MAP: Record<string, string> = {
planned: "bg-secondary text-secondary-foreground border-border",
pending: "bg-secondary text-secondary-foreground border-border",
in_progress: "bg-primary/10 text-primary border-primary/20",
completed: "bg-accent text-accent-foreground border-accent/50",
cancelled: "bg-muted text-muted-foreground border-border",
};
// 카테고리 라벨 기반으로 스타일 매칭
const LABEL_STYLE_MAP: Record<string, string> = {
"일반": "bg-secondary text-secondary-foreground border-border",
"긴급": "bg-destructive/10 text-destructive border-destructive/20",
"계획": "bg-secondary text-secondary-foreground border-border",
"대기": "bg-secondary text-secondary-foreground border-border",
"진행중": "bg-primary/10 text-primary border-primary/20",
"완료": "bg-accent text-accent-foreground border-accent/50",
"취소": "bg-muted text-muted-foreground border-border",
};
export default function MaterialStatusPage() {
@@ -93,6 +99,32 @@ export default function MaterialStatusPage() {
const [searchItemCode, setSearchItemCode] = useState("");
const [searchItemName, setSearchItemName] = useState("");
// 카테고리 코드→라벨 매핑
const [statusMap, setStatusMap] = useState<Record<string, string>>({});
useEffect(() => {
(async () => {
try {
const res = await apiClient.get("/table-categories/work_instruction/status/values");
if (res.data?.success && res.data.data?.length > 0) {
const map: Record<string, string> = {};
const flatten = (vals: any[]) => {
for (const v of vals) {
map[v.valueCode] = v.valueLabel;
if (v.children?.length) flatten(v.children);
}
};
flatten(res.data.data);
setStatusMap(map);
}
} catch { /* ignore */ }
})();
}, []);
const getStatusLabel = useCallback((status: string) => {
return statusMap[status] || FALLBACK_STATUS_MAP[status] || status;
}, [statusMap]);
const [workOrders, setWorkOrders] = useState<WorkOrder[]>([]);
const [workOrdersLoading, setWorkOrdersLoading] = useState(false);
const [checkedWoIds, setCheckedWoIds] = useState<string[]>([]);
@@ -369,7 +401,7 @@ export default function MaterialStatusPage() {
<span
className={cn(
"ml-auto rounded-md border px-2 py-0.5 text-[11px] font-semibold",
getStatusStyle(wo.status)
STATUS_STYLE_MAP[wo.status] || LABEL_STYLE_MAP[getStatusLabel(wo.status)] || "bg-muted text-muted-foreground border-border"
)}
>
{getStatusLabel(wo.status)}
@@ -1,6 +1,7 @@
"use client";
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { apiClient } from "@/lib/api/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
@@ -311,6 +312,42 @@ export default function OutboundPage() {
const [editMode, setEditMode] = useState(false);
const [editItemIds, setEditItemIds] = useState<string[]>([]);
// 카테고리 코드→라벨 매핑 (재질, 단위)
const [catMap, setCatMap] = useState<Record<string, Record<string, 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 || v.value_code || v.code, label: v.valueLabel || v.value_label || v.label });
if (v.children?.length) result.push(...flatten(v.children));
}
return result;
};
const map: Record<string, Record<string, string>> = {};
Promise.all([
...["material", "unit"].map(async (col) => {
try {
const res = await apiClient.get(`/table-categories/item_info/${col}/values?filterCompanyCode=COMPANY_7`);
const items = flatten(res.data?.data || []);
map[col] = {};
for (const item of items) map[col][item.code] = item.label;
} catch { /* skip */ }
}),
(async () => {
try {
const res = await apiClient.get(`/table-categories/outbound_mng/outbound_type/values`);
const items = flatten(res.data?.data || []);
map["outbound_type"] = {};
for (const item of items) map["outbound_type"][item.code] = item.label;
} catch { /* skip */ }
})(),
]).then(() => setCatMap(map));
}, []);
const resolveCat = useCallback((col: string, code: string) => {
if (!code) return "";
return catMap[col]?.[code] || code;
}, [catMap]);
// 소스 데이터
const [sourceKeyword, setSourceKeyword] = useState("");
const [sourceLoading, setSourceLoading] = useState(false);
@@ -871,8 +908,9 @@ export default function OutboundPage() {
fetchList();
}
}
} catch {
toast.error(editMode ? "수정에 실패했어요" : "출고 등록 중 오류가 발생했습니다.");
} catch (err: any) {
const msg = err?.response?.data?.message || (editMode ? "수정에 실패했어요" : "출고 등록 중 오류가 발생했습니다.");
toast.error(msg);
} finally {
setSaving(false);
}
@@ -1085,7 +1123,7 @@ export default function OutboundPage() {
case "outbound_type": return (
<TableCell key={col.key}>
<Badge variant="outline" className={cn("text-[11px]", getTypeColor(master.outbound_type))}>
{master.outbound_type || "-"}
{resolveCat("outbound_type", master.outbound_type) || "-"}
</Badge>
</TableCell>
);
@@ -1337,6 +1375,7 @@ export default function OutboundPage() {
data={pagedItems}
onAdd={addItem}
selectedKeys={selectedItems.map((s) => s.key)}
resolveCat={resolveCat}
/>
)}
</div>
@@ -1502,7 +1541,7 @@ export default function OutboundPage() {
<TableCell className="p-2 text-center">{idx + 1}</TableCell>
<TableCell className="p-2">
<Badge variant="outline" className={cn("text-[10px]", getTypeColor(item.outbound_type))}>
{item.outbound_type || "-"}
{resolveCat("outbound_type", item.outbound_type) || "-"}
</Badge>
</TableCell>
<TableCell className="max-w-[180px] p-2">
@@ -1772,10 +1811,12 @@ function SourceItemTable({
data,
onAdd,
selectedKeys,
resolveCat,
}: {
data: ItemSource[];
onAdd: (item: ItemSource) => void;
selectedKeys: string[];
resolveCat: (col: string, code: string) => string;
}) {
if (data.length === 0) {
return (
@@ -1826,8 +1867,8 @@ function SourceItemTable({
</div>
</TableCell>
<TableCell className="max-w-[100px] truncate p-2" title={item.spec || "-"}>{item.spec || "-"}</TableCell>
<TableCell className="max-w-[100px] truncate p-2" title={item.material || "-"}>{item.material || "-"}</TableCell>
<TableCell className="p-2">{item.unit || "-"}</TableCell>
<TableCell className="max-w-[100px] truncate p-2" title={resolveCat("material", item.material) || "-"}>{resolveCat("material", item.material) || "-"}</TableCell>
<TableCell className="p-2">{resolveCat("unit", item.unit) || "-"}</TableCell>
<TableCell className="p-2 text-right">
{Number(item.standard_price).toLocaleString()}
</TableCell>
@@ -353,17 +353,45 @@ export default function ReceivingPage() {
// 구매관리 division 코드 (라벨 기준 조회)
const [purchaseDivisionCode, setPurchaseDivisionCode] = useState<string>("");
// 카테고리 코드→라벨 매핑 (재질, 단위)
const [catMap, setCatMap] = useState<Record<string, Record<string, string>>>({});
// 구매관리 division 코드 로드
// 구매관리 division 코드 + 재질/단위 카테고리 로드
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 || v.value_code || v.code, label: v.valueLabel || v.value_label || v.label });
if (v.children?.length) result.push(...flatten(v.children));
}
return result;
};
// division 카테고리에서 "구매관리" 라벨의 코드 조회
apiClient.get("/table-categories/item_info/division/values").then((res) => {
const vals = res.data?.data || [];
const found = vals.find((v: any) => (v.value_label || v.label) === "구매관리");
const found = vals.find((v: any) => (v.valueLabel || v.value_label || v.label) === "구매관리");
if (found) setPurchaseDivisionCode(found.value_code || found.code);
}).catch(() => {});
// 재질, 단위 카테고리
const map: Record<string, Record<string, string>> = {};
Promise.all(
["material", "unit"].map(async (col) => {
try {
const res = await apiClient.get(`/table-categories/item_info/${col}/values?filterCompanyCode=COMPANY_30`);
const items = flatten(res.data?.data || []);
map[col] = {};
for (const item of items) map[col][item.code] = item.label;
} catch { /* skip */ }
})
).then(() => setCatMap(map));
}, []);
// 카테고리 코드→라벨 변환
const resolveCat = useCallback((col: string, code: string) => {
if (!code) return "";
return catMap[col]?.[code] || code;
}, [catMap]);
// 목록 조회
const fetchList = useCallback(async () => {
setLoading(true);
@@ -777,12 +805,16 @@ export default function ReceivingPage() {
return;
}
if (!modalWarehouse) {
toast.error("창고를 선택해주세요.");
return;
}
setSaving(true);
try {
const res = await createReceiving({
inbound_number: modalInboundNo,
inbound_date: modalInboundDate,
warehouse_code: modalWarehouse || undefined,
warehouse_code: modalWarehouse,
location_code: modalLocation || undefined,
inspector: modalInspector || undefined,
manager: modalManager || undefined,
@@ -1283,6 +1315,7 @@ export default function ReceivingPage() {
data={items}
onAdd={addItem}
selectedKeys={selectedItems.map((s) => s.key)}
resolveCat={resolveCat}
/>
)}
</div>
@@ -1738,10 +1771,12 @@ function SourceItemTable({
data,
onAdd,
selectedKeys,
resolveCat,
}: {
data: ItemSource[];
onAdd: (item: ItemSource) => void;
selectedKeys: string[];
resolveCat: (col: string, code: string) => string;
}) {
if (data.length === 0) {
return (
@@ -1794,8 +1829,8 @@ function SourceItemTable({
</div>
</TableCell>
<TableCell className="max-w-[100px] truncate p-2" title={item.spec || "-"}>{item.spec || "-"}</TableCell>
<TableCell className="max-w-[100px] truncate p-2" title={item.material || "-"}>{item.material || "-"}</TableCell>
<TableCell className="p-2">{item.unit || "-"}</TableCell>
<TableCell className="max-w-[100px] truncate p-2" title={resolveCat("material", item.material) || "-"}>{resolveCat("material", item.material) || "-"}</TableCell>
<TableCell className="p-2">{resolveCat("unit", item.unit) || "-"}</TableCell>
<TableCell className="p-2 text-right">
{Number(item.standard_price).toLocaleString()}
</TableCell>
@@ -26,6 +26,7 @@ import {
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
import { ImageUpload } from "@/components/common/ImageUpload";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
@@ -102,8 +103,8 @@ export default function MoldInfoPage() {
const [numberingRuleId, setNumberingRuleId] = useState<string | null>(null);
const [previewCode, setPreviewCode] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
// moldImagePreview는 상세 표시용으로 유지 (ImageUpload는 모달에서만 사용)
const [moldImagePreview, setMoldImagePreview] = useState<string | null>(null);
const moldImageRef = React.useRef<HTMLInputElement>(null);
const [serialModalOpen, setSerialModalOpen] = useState(false);
const [serialForm, setSerialForm] = useState<Record<string, any>>({});
@@ -240,17 +241,7 @@ export default function MoldInfoPage() {
setMoldModalOpen(true);
};
const handleMoldImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onloadend = () => {
const result = reader.result as string;
setMoldImagePreview(result);
setMoldForm((prev) => ({ ...prev, image_path: result }));
};
reader.readAsDataURL(file);
};
// 이미지 업로드는 ImageUpload 컴포넌트가 처리 → objid를 image_path에 저장
const handleSaveMold = async () => {
// 등록 모드에서 채번이 없으면 수동 입력 필수
@@ -460,9 +451,10 @@ export default function MoldInfoPage() {
)}>
{mold.image_path ? (
<img
src={mold.image_path}
src={String(mold.image_path).startsWith("http") || String(mold.image_path).startsWith("/") ? mold.image_path : `/api/files/preview/${mold.image_path}`}
alt={mold.mold_name}
className="w-full h-full object-cover"
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
) : (
<Box className="w-8 h-8 text-muted-foreground/50" />
@@ -662,9 +654,10 @@ export default function MoldInfoPage() {
<div className="w-[200px] min-w-[200px] h-[200px] bg-muted rounded-lg flex items-center justify-center border">
{selectedMold.image_path ? (
<img
src={selectedMold.image_path}
src={String(selectedMold.image_path).startsWith("http") || String(selectedMold.image_path).startsWith("/") ? selectedMold.image_path : `/api/files/preview/${selectedMold.image_path}`}
alt={selectedMold.mold_name}
className="w-full h-full object-cover rounded-lg"
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
) : (
<ImageIcon className="w-16 h-16 text-muted-foreground/40" />
@@ -1143,39 +1136,13 @@ export default function MoldInfoPage() {
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs"> </Label>
<div className="relative w-full h-32 rounded-lg border-2 border-dashed border-border bg-muted/50 flex items-center justify-center overflow-hidden group">
{moldImagePreview ? (
<>
<img src={moldImagePreview} alt="금형 이미지" className="w-full h-full object-contain" />
<div className="absolute inset-0 bg-foreground/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
<Button size="sm" variant="secondary" className="h-7 text-xs" type="button" onClick={() => moldImageRef.current?.click()}>
<Upload className="w-3 h-3 mr-1" />
</Button>
<Button size="sm" variant="destructive" className="h-7 text-xs" type="button" onClick={() => {
setMoldImagePreview(null);
setMoldForm((prev) => ({ ...prev, image_path: null }));
}}>
<XIcon className="w-3 h-3" />
</Button>
</div>
</>
) : (
<button
type="button"
className="flex flex-col items-center gap-1.5 text-muted-foreground"
onClick={() => moldImageRef.current?.click()}
>
<ImageIcon className="w-8 h-8" />
<span className="text-xs"> </span>
</button>
)}
</div>
<input
ref={moldImageRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleMoldImageUpload}
<ImageUpload
value={moldForm.image_path || ""}
onChange={(v) => setMoldForm((prev) => ({ ...prev, image_path: v }))}
tableName="mold_mng"
recordId={moldForm.id || ""}
columnName="image_path"
height="h-32"
/>
</div>
</div>
@@ -142,7 +142,7 @@ export default function SubcontractorItemPage() {
try {
const filters: any[] = [];
if (outsourcingDivisionCode) {
filters.push({ columnName: "division", operator: "equals", value: outsourcingDivisionCode });
filters.push({ columnName: "division", operator: "contains", value: outsourcingDivisionCode });
}
if (searchKeyword) {
filters.push({ columnName: "item_name", operator: "contains", value: searchKeyword });
@@ -141,6 +141,12 @@ export default function SubcontractorManagementPage() {
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
} catch { /* skip */ }
}
for (const col of ["division", "unit", "material"]) {
try {
const res = await apiClient.get(`/table-categories/item_info/${col}/values`);
if (res.data?.success) optMap[`item_${col}`] = flatten(res.data.data || []);
} catch { /* skip */ }
}
setCategoryOptions(optMap);
const priceOpts: Record<string, { code: string; label: string }[]> = {};
@@ -413,11 +419,12 @@ export default function SubcontractorManagementPage() {
});
const allItems = res.data?.data?.data || res.data?.data?.rows || [];
const existingItemIds = new Set(priceItems.map((p: any) => p.item_id || p.item_number));
const OUTSOURCING_CODE = "CAT_MMDJB7R4_TO3T";
const outsourcingCode = categoryOptions["item_division"]?.find((o) => o.label === "외주관리")?.code;
setItemSearchResults(allItems.filter((item: any) => {
if (existingItemIds.has(item.item_number) || existingItemIds.has(item.id)) return false;
if (!outsourcingCode) return true;
const div = item.division || "";
return div.includes(OUTSOURCING_CODE) || div.includes("외주");
return div.includes(outsourcingCode);
}));
} catch { /* skip */ } finally { setItemSearchLoading(false); }
};
@@ -1176,8 +1183,8 @@ export default function SubcontractorManagementPage() {
<TableCell className="text-[13px]">{item.item_number}</TableCell>
<TableCell className="text-[13px]">{item.item_name}</TableCell>
<TableCell className="text-[13px]">{item.size}</TableCell>
<TableCell className="text-[13px]">{item.material}</TableCell>
<TableCell className="text-[13px]">{item.unit}</TableCell>
<TableCell className="text-[13px]">{categoryOptions["item_material"]?.find((o) => o.code === item.material)?.label || item.material}</TableCell>
<TableCell className="text-[13px]">{categoryOptions["item_unit"]?.find((o) => o.code === item.unit)?.label || item.unit}</TableCell>
</TableRow>
))}
</TableBody>
@@ -1221,7 +1228,7 @@ export default function SubcontractorManagementPage() {
{/* 품목 헤더 */}
<div className="px-5 py-3 bg-muted/30 border-b">
<div className="font-semibold text-sm">{idx + 1}. {item.item_name || itemKey}</div>
<div className="text-xs text-muted-foreground">{itemKey} | {item.size || ""} | {item.unit || ""}</div>
<div className="text-xs text-muted-foreground">{itemKey} | {item.size || ""} | {categoryOptions["item_unit"]?.find((o) => o.code === item.unit)?.label || item.unit || ""}</div>
</div>
<div className="flex gap-4 p-4">
@@ -309,6 +309,8 @@ export default function BomManagementPage() {
// 카테고리 옵션
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
// 사용자 맵 (userId → userName)
const [userMap, setUserMap] = useState<Record<string, string>>({});
// 트리 편집 state
const [editingTree, setEditingTree] = useState<TreeNode[]>([]);
@@ -433,6 +435,16 @@ export default function BomManagementPage() {
} catch {}
};
loadCategories();
// 사용자 목록 로드
apiClient.get("/admin/users", { params: { size: 9999 } }).then((res) => {
const users = res.data?.data || res.data || [];
const map: Record<string, string> = {};
for (const u of users) {
const id = u.userId || u.user_id || u.id;
if (id) map[id] = u.userName || u.user_name || u.name || id;
}
setUserMap(map);
}).catch(() => {});
}, []);
// ─── BOM 상세 로드 ────────────────────────────
@@ -1802,7 +1814,7 @@ export default function BomManagementPage() {
{/* 비고 */}
<td className="overflow-hidden text-ellipsis whitespace-nowrap px-3 py-2">{isVirtualRoot ? "-" : (node.remark || "-")}</td>
{/* 작성자 */}
<td className="overflow-hidden text-ellipsis whitespace-nowrap px-3 py-2 text-muted-foreground">{isVirtualRoot ? "-" : (node.writer || "-")}</td>
<td className="overflow-hidden text-ellipsis whitespace-nowrap px-3 py-2 text-muted-foreground">{isVirtualRoot ? "-" : (userMap[node.writer] || node.writer || "-")}</td>
{/* 수정일시 */}
<td className="overflow-hidden text-ellipsis whitespace-nowrap px-3 py-2 text-muted-foreground">
{isVirtualRoot ? "-" : (node.updated_date ? new Date(node.updated_date).toLocaleString("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" }) : "-")}
@@ -1052,17 +1052,17 @@ export default function ProductionPlanManagementPage() {
};
return (
<Table style={{ tableLayout: "fixed" }}>
<Table style={{ minWidth: "900px" }}>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[30px]">
<TableHead style={{ width: "30px", minWidth: "30px" }}>
<Checkbox checked={selectedItemGroups.size === orderItems.length && orderItems.length > 0} onCheckedChange={(c) => toggleAllItemGroups(!!c)} className="h-4 w-4" />
</TableHead>
<TableHead className="w-[40px]" />
<TableHead style={{ width: "40px", minWidth: "40px" }} />
<TableHead style={{ width: "140px", minWidth: "140px" }} className="text-xs font-bold whitespace-nowrap text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead style={{ width: "140px", minWidth: "140px" }} className="text-xs font-bold whitespace-nowrap text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
{ts.visibleColumns.map((col) => (
<TableHead key={col.key} style={ts.thStyle(col.key)} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">
<TableHead key={col.key} style={{ ...ts.thStyle(col.key), minWidth: "80px" }} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">
{col.label}
</TableHead>
))}
@@ -349,7 +349,14 @@ export default function WorkInstructionPage() {
const t = Number(o.total_qty || 0), c = Number(o.completed_qty || 0);
return t === 0 ? 0 : Math.min(100, Math.round((c / t) * 100));
};
const getProgressLabel = (o: any) => { const p = getProgress(o); if (o.progress_status) return o.progress_status; if (p >= 100) return "완료"; if (p > 0) return "진행중"; return "대기"; };
const getProgressLabel = (o: any) => {
const p = getProgress(o);
if (o.progress_status) {
const map: Record<string, string> = { completed: "완료", in_progress: "진행중", pending: "대기" };
return map[o.progress_status] || o.progress_status;
}
if (p >= 100) return "완료"; if (p > 0) return "진행중"; return "대기";
};
const totalRegPages = Math.max(1, Math.ceil(regTotalCount / regPageSize));
const getDisplayNo = (o: any) => {
@@ -18,6 +18,9 @@ import {
Settings2, GripVertical,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { DndContext, PointerSensor, closestCenter, useSensor, useSensors, type DragEndEvent } from "@dnd-kit/core";
import { SortableContext, arrayMove, horizontalListSortingStrategy, useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { apiClient } from "@/lib/api/client";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
@@ -28,11 +31,6 @@ import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
import {
DndContext, closestCenter, PointerSensor, useSensor, useSensors, DragEndEvent,
} from "@dnd-kit/core";
import { SortableContext, horizontalListSortingStrategy, useSortable, arrayMove } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
const MASTER_TABLE = "purchase_order_mng";
const DETAIL_TABLE = "purchase_detail";
@@ -104,7 +102,7 @@ const MODAL_DETAIL_COLUMNS = [
{ key: "memo", label: "메모", width: "w-[120px]" },
];
const MODAL_COL_ORDER_KEY = "purchase_order_modal_col_order";
const MODAL_COL_ORDER_KEY = "purchase_order_modal_col_order_c16";
function SortableModalHead({ col }: { col: { key: string; label: string; width: string } }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: col.key });
@@ -114,11 +112,18 @@ function SortableModalHead({ col }: { col: { key: string; label: string; width:
opacity: isDragging ? 0.5 : 1,
};
return (
<TableHead ref={setNodeRef} style={style} className={cn(col.width, "text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none")}>
<TableHead
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className={cn(
col.width,
"text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none cursor-grab active:cursor-grabbing hover:bg-muted-foreground/5 transition-colors"
)}
>
<div className="inline-flex items-center gap-1">
<div {...attributes} {...listeners} className="cursor-grab text-muted-foreground/40 hover:text-muted-foreground shrink-0">
<GripVertical className="h-3 w-3" />
</div>
<GripVertical className="h-3.5 w-3.5 text-muted-foreground/70 shrink-0" />
<span className="truncate">{col.label}</span>
</div>
</TableHead>
@@ -748,89 +753,6 @@ export default function PurchaseOrderPage() {
setDetailRows((prev) => prev.filter((_, i) => i !== idx));
};
const renderDetailCell = (col: { key: string }, row: any, idx: number) => {
switch (col.key) {
case "item_code":
return <TableCell key={col.key} className="text-[13px] font-mono max-w-[120px]"><span className="block truncate" title={row.item_code}>{row.item_code}</span></TableCell>;
case "item_name":
return <TableCell key={col.key} className="text-[13px] max-w-[120px]"><span className="block truncate" title={row.item_name}>{row.item_name}</span></TableCell>;
case "supplier":
return (
<TableCell key={col.key}>
{isReadOnly ? (
<span className="text-xs">{(categoryOptions["supplier_code"] || []).find(o => o.code === row.supplier_code)?.label || row.supplier_code || "-"}</span>
) : (
<Select value={row.supplier_code || ""} onValueChange={(v) => {
const supp = categoryOptions["supplier_code"]?.find(o => o.code === v);
const name = supp?.label.replace(` (${v})`, "") || "";
updateDetailRow(idx, "supplier_code", v);
updateDetailRow(idx, "supplier_name", name);
}}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="공급업체" /></SelectTrigger>
<SelectContent>
{(categoryOptions["supplier_code"] || []).map(o => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
)}
</TableCell>
);
case "spec":
return <TableCell key={col.key} className="text-[13px] text-muted-foreground">{row.spec}</TableCell>;
case "unit":
return <TableCell key={col.key} className="text-[13px]">{row.unit}</TableCell>;
case "order_qty":
return (
<TableCell key={col.key}>
{isReadOnly ? (
<span className="text-xs text-right font-mono block">{row.order_qty ? Number(row.order_qty).toLocaleString() : ""}</span>
) : (
<Input value={formatNumber(row.order_qty || "")} onChange={(e) => updateDetailRow(idx, "order_qty", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono" />
)}
</TableCell>
);
case "received_qty":
return <TableCell key={col.key} className="text-[13px] text-right font-mono text-muted-foreground">{row.received_qty ? Number(row.received_qty).toLocaleString() : "0"}</TableCell>;
case "remain_qty":
return <TableCell key={col.key} className="text-[13px] text-right font-mono">{row.remain_qty ? Number(row.remain_qty).toLocaleString() : "0"}</TableCell>;
case "unit_price":
return (
<TableCell key={col.key}>
{isReadOnly ? (
<span className="text-xs text-right font-mono block">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</span>
) : (
<Input value={formatNumber(row.unit_price || "")} onChange={(e) => updateDetailRow(idx, "unit_price", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono" />
)}
</TableCell>
);
case "amount":
return <TableCell key={col.key} className="text-[13px] text-right font-mono font-semibold">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>;
case "due_date":
return (
<TableCell key={col.key}>
{isReadOnly ? (
<span className="text-xs">{row.due_date}</span>
) : (
<Input type="date" value={row.due_date || ""} onChange={(e) => updateDetailRow(idx, "due_date", e.target.value)} className="h-8 text-xs" />
)}
</TableCell>
);
case "memo":
return (
<TableCell key={col.key}>
{isReadOnly ? (
<span className="text-xs">{row.memo}</span>
) : (
<Input value={row.memo || ""} onChange={(e) => updateDetailRow(idx, "memo", e.target.value)} className="h-8 text-xs" />
)}
</TableCell>
);
default:
return null;
}
};
const handleExcelDownload = async () => {
if (orders.length === 0) { toast.error("다운로드할 데이터가 없어요."); return; }
const data = orders.map((o) => {
@@ -1193,7 +1115,88 @@ export default function PurchaseOrderPage() {
</Button>
</TableCell>
)}
{visibleModalColumns.map((col) => renderDetailCell(col, row, idx))}
{visibleModalColumns.map((col) => {
switch (col.key) {
case "item_code":
return <TableCell key={col.key} className="text-[13px] font-mono max-w-[120px]"><span className="block truncate" title={row.item_code}>{row.item_code}</span></TableCell>;
case "item_name":
return <TableCell key={col.key} className="text-[13px] max-w-[120px]"><span className="block truncate" title={row.item_name}>{row.item_name}</span></TableCell>;
case "supplier":
return (
<TableCell key={col.key}>
{isReadOnly ? (
<span className="text-xs">{(categoryOptions["supplier_code"] || []).find(o => o.code === row.supplier_code)?.label || row.supplier_code || "-"}</span>
) : (
<Select value={row.supplier_code || ""} onValueChange={(v) => {
const supp = categoryOptions["supplier_code"]?.find(o => o.code === v);
const name = supp?.label.replace(` (${v})`, "") || "";
updateDetailRow(idx, "supplier_code", v);
updateDetailRow(idx, "supplier_name", name);
}}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="공급업체" /></SelectTrigger>
<SelectContent>
{(categoryOptions["supplier_code"] || []).map(o => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
)}
</TableCell>
);
case "spec":
return <TableCell key={col.key} className="text-[13px] text-muted-foreground">{row.spec}</TableCell>;
case "unit":
return <TableCell key={col.key} className="text-[13px]">{row.unit}</TableCell>;
case "order_qty":
return (
<TableCell key={col.key}>
{isReadOnly ? (
<span className="text-xs text-right font-mono block">{row.order_qty ? Number(row.order_qty).toLocaleString() : ""}</span>
) : (
<Input value={formatNumber(row.order_qty || "")} onChange={(e) => updateDetailRow(idx, "order_qty", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono" />
)}
</TableCell>
);
case "received_qty":
return <TableCell key={col.key} className="text-[13px] text-right font-mono text-muted-foreground">{row.received_qty ? Number(row.received_qty).toLocaleString() : "0"}</TableCell>;
case "remain_qty":
return <TableCell key={col.key} className="text-[13px] text-right font-mono">{row.remain_qty ? Number(row.remain_qty).toLocaleString() : "0"}</TableCell>;
case "unit_price":
return (
<TableCell key={col.key}>
{isReadOnly ? (
<span className="text-xs text-right font-mono block">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</span>
) : (
<Input value={formatNumber(row.unit_price || "")} onChange={(e) => updateDetailRow(idx, "unit_price", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono" />
)}
</TableCell>
);
case "amount":
return <TableCell key={col.key} className="text-[13px] text-right font-mono font-semibold">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>;
case "due_date":
return (
<TableCell key={col.key}>
{isReadOnly ? (
<span className="text-xs">{row.due_date}</span>
) : (
<Input type="date" value={row.due_date || ""} onChange={(e) => updateDetailRow(idx, "due_date", e.target.value)} className="h-8 text-xs" />
)}
</TableCell>
);
case "memo":
return (
<TableCell key={col.key}>
{isReadOnly ? (
<span className="text-xs">{row.memo}</span>
) : (
<Input value={row.memo || ""} onChange={(e) => updateDetailRow(idx, "memo", e.target.value)} className="h-8 text-xs" />
)}
</TableCell>
);
default:
return <TableCell key={col.key} />;
}
})}
</TableRow>
))}
</TableBody>
File diff suppressed because it is too large Load Diff
@@ -195,6 +195,12 @@ export default function SupplierManagementPage() {
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
} catch { /* skip */ }
}
for (const col of ["division", "unit", "material"]) {
try {
const res = await apiClient.get(`/table-categories/item_info/${col}/values`);
if (res.data?.success) optMap[`item_${col}`] = flatten(res.data.data || []);
} catch { /* skip */ }
}
setCategoryOptions(optMap);
const priceOpts: Record<string, { code: string; label: string }[]> = {};
@@ -807,27 +813,35 @@ export default function SupplierManagementPage() {
};
// 품목 검색
const searchItems = async () => {
const searchItems = useCallback(async () => {
setItemSearchLoading(true);
try {
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: 5000,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const allItems = res.data?.data?.data || res.data?.data?.rows || [];
setItemTotalCount(allItems.length);
const existingItemIds = new Set(priceItems.map((p: any) => p.item_id || p.item_number));
const PURCHASE_CODES = ["s"]; // 구매관리 카테고리 코드
const purchaseCode = categoryOptions["item_division"]?.find((o) => o.label === "구매관리")?.code;
setItemSearchResults(allItems.filter((item: any) => {
if (existingItemIds.has(item.item_number) || existingItemIds.has(item.id)) return false;
const divCodes = (item.division || "").split(",").map((c: string) => c.trim());
return divCodes.some((code: string) => PURCHASE_CODES.includes(code));
if (!purchaseCode) return true;
const div = item.division || "";
return div.includes(purchaseCode);
}));
} catch { /* skip */ } finally { setItemSearchLoading(false); }
};
}, [itemSearchKeyword, priceItems]);
// 실간 검색 (2글자 이상)
useEffect(() => {
if (!itemSelectOpen) return;
if (itemSearchKeyword.length > 0 && itemSearchKeyword.length < 2) return;
searchItems();
}, [itemSearchKeyword, itemSelectOpen]); // eslint-disable-line react-hooks/exhaustive-deps
// 품목 선택 완료 → 상세 입력 모달로 전환
const goToItemDetail = () => {
@@ -964,6 +978,7 @@ export default function SupplierManagementPage() {
{ columnName: "supplier_id", operator: "equals", value: selectedSupplier!.supplier_code },
{ columnName: "item_id", operator: "equals", value: itemKey },
]}, autoFilter: true,
sort: { columnName: "created_date", order: "asc" },
});
const allMappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
mappingRows = allMappings
@@ -984,7 +999,8 @@ export default function SupplierManagementPage() {
{ columnName: "item_id", operator: "equals", value: itemKey },
]}, autoFilter: true,
});
const allPriceData = priceRes.data?.data?.data || priceRes.data?.data?.rows || [];
const allPriceData = (priceRes.data?.data?.data || priceRes.data?.data?.rows || [])
.sort((a: any, b: any) => (a.start_date || "").localeCompare(b.start_date || ""));
priceRows = allPriceData.map((p: any) => ({
_id: `p_existing_${p.id}`,
start_date: p.start_date ? String(p.start_date).split("T")[0] : "",
@@ -1036,6 +1052,7 @@ export default function SupplierManagementPage() {
{ columnName: "supplier_id", operator: "equals", value: selectedSupplier.supplier_code },
{ columnName: "item_id", operator: "equals", value: itemKey },
]}, autoFilter: true,
sort: { columnName: "created_date", order: "asc" },
});
existingMaps = existingMappings.data?.data?.data || existingMappings.data?.data?.rows || [];
} catch { /* skip */ }
@@ -1085,12 +1102,13 @@ export default function SupplierManagementPage() {
{ columnName: "item_id", operator: "equals", value: itemKey },
]}, autoFilter: true,
});
existingPriceRows = existingPrices.data?.data?.data || existingPrices.data?.data?.rows || [];
existingPriceRows = (existingPrices.data?.data?.data || existingPrices.data?.data?.rows || [])
.sort((a: any, b: any) => (a.start_date || "").localeCompare(b.start_date || ""));
} catch { /* skip */ }
// 단가 upsert
const priceRows = (itemPrices[itemKey] || []).filter((p) =>
(p.base_price && Number(p.base_price) > 0) || p.start_date
p.base_price || p.start_date || p.currency_code || p.base_price_type
);
const usedPriceIds = new Set<string>();
for (let pi = 0; pi < priceRows.length; pi++) {
@@ -1160,7 +1178,7 @@ export default function SupplierManagementPage() {
}
const priceRows = (itemPrices[itemKey] || []).filter((p) =>
(p.base_price && Number(p.base_price) > 0) || p.start_date
p.base_price || p.start_date || p.currency_code || p.base_price_type
);
for (const price of priceRows) {
await apiClient.post(`/table-management/tables/${PRICE_TABLE}/add`, {
@@ -1192,40 +1210,63 @@ export default function SupplierManagementPage() {
}
};
// 품목 매핑 삭제
// 품목 매핑 해제 (소프트 삭제 — supplier_id를 null 처리)
const handlePriceItemDelete = async () => {
if (priceCheckedIds.length === 0) return;
const ok = await confirm(`선택한 ${priceCheckedIds.length}개 품목 매핑제하시겠습니까?`, {
description: "관련된 단가 정보도 함께 삭제됩니다.",
variant: "destructive", confirmText: "제",
const ok = await confirm(`선택한 ${priceCheckedIds.length}개 품목의 연결제하시겠습니까?`, {
description: "해당 품목의 공급업체 연결이 해제됩니다. (데이터는 유지)",
variant: "destructive", confirmText: "제",
});
if (!ok) return;
try {
for (const mappingId of priceCheckedIds) {
const itemIds = priceCheckedIds.map((mid) => {
const group = Object.values(priceGroups).find((g) => g.master.id === mid);
return group?.master.item_id || group?.master.item_number || "";
}).filter(Boolean);
for (const itemId of itemIds) {
// 해당 품목의 모든 매핑 조회 → supplier_id null 처리
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [
{ columnName: "supplier_id", operator: "equals", value: selectedSupplier!.supplier_code },
{ columnName: "item_id", operator: "equals", value: itemId },
]}, autoFilter: true,
});
const allMappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
for (const m of allMappings) {
await apiClient.put(`/table-management/tables/${MAPPING_TABLE}/edit`, {
originalData: { id: m.id },
updatedData: { supplier_id: null },
});
}
// 해당 품목의 모든 단가 조회 → supplier_id null 처리
try {
const priceRes = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "mapping_id", operator: "equals", value: mappingId }] },
autoFilter: true,
dataFilter: { enabled: true, filters: [
{ columnName: "supplier_id", operator: "equals", value: selectedSupplier!.supplier_code },
{ columnName: "item_id", operator: "equals", value: itemId },
]}, autoFilter: true,
});
const prices = priceRes.data?.data?.data || priceRes.data?.data?.rows || [];
if (prices.length > 0) {
await apiClient.delete(`/table-management/tables/${PRICE_TABLE}/delete`, {
data: prices.map((p: any) => ({ id: p.id })),
for (const p of prices) {
await apiClient.put(`/table-management/tables/${PRICE_TABLE}/edit`, {
originalData: { id: p.id },
updatedData: { supplier_id: null },
});
}
} catch { /* skip */ }
}
await apiClient.delete(`/table-management/tables/${MAPPING_TABLE}/delete`, {
data: priceCheckedIds.map((id) => ({ id })),
});
toast.success(`${priceCheckedIds.length}개 품목 매핑이 삭제되었습니다.`);
toast.success(`${priceCheckedIds.length}개 품목의 연결이 해제되었습니다.`);
setPriceCheckedIds([]);
const cid = selectedSupplierId;
setSelectedSupplierId(null);
setTimeout(() => setSelectedSupplierId(cid), 50);
} catch {
toast.error("제에 실패했습니다.");
toast.error("연결 해제에 실패했습니다.");
}
};
@@ -2430,8 +2471,8 @@ export default function SupplierManagementPage() {
</TableCell>
<TableCell className="text-sm">{item.item_name}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{item.size}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{item.material}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{item.unit}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{categoryOptions["item_material"]?.find((o) => o.code === item.material)?.label || item.material}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{categoryOptions["item_unit"]?.find((o) => o.code === item.unit)?.label || item.unit}</TableCell>
</TableRow>
))}
</TableBody>
@@ -2473,7 +2514,7 @@ export default function SupplierManagementPage() {
{/* 품목 헤더 */}
<div className="px-5 py-3 bg-muted/30 border-b">
<div className="font-bold">{idx + 1}. {item.item_name || itemKey}</div>
<div className="text-xs text-muted-foreground">{itemKey} | {item.size || ""} | {item.unit || ""}</div>
<div className="text-xs text-muted-foreground">{itemKey} | {item.size || ""} | {categoryOptions["item_unit"]?.find((o) => o.code === item.unit)?.label || item.unit || ""}</div>
</div>
<div className="flex gap-4 p-5 items-stretch">
@@ -188,9 +188,12 @@ export default function InspectionManagementPage() {
if (["inspection_type", "inspection_method", "judgment_criteria", "unit", "apply_type"].includes(col.key)) {
base.render = (v: any, row: any) => getCatLabel(INSPECTION_TABLE, col.key, row[col.key]);
}
if (col.key === "manager") {
base.render = (v: any, row: any) => userOptions.find((u) => u.code === row.manager)?.label || row.manager || "-";
}
return base;
});
}, [ts.visibleColumns, catOptions]); // eslint-disable-line react-hooks/exhaustive-deps
}, [ts.visibleColumns, catOptions, userOptions]); // eslint-disable-line react-hooks/exhaustive-deps
/* ═══════════════════ 데이터 조회 ═══════════════════ */
// 다중값 컬럼 (쉼표 구분 저장) — 서버 equals 대신 contains 사용
@@ -385,7 +388,7 @@ export default function InspectionManagementPage() {
/* ═══════════════════ 불량관리 CRUD ═══════════════════ */
const openDefCreate = async () => {
setDefForm({});
setDefForm({ is_active: "사용" });
setDefEditMode(false);
setNumberingRuleId(null);
setPreviewCode(null);
@@ -867,20 +870,16 @@ export default function InspectionManagementPage() {
</TableCell>
<TableCell>
<Badge
variant={
getCatLabel(DEFECT_TABLE, "is_active", row.is_active) === "사용"
? "default"
: "secondary"
}
variant={row.is_active === "사용" || row.is_active === "true" ? "default" : "secondary"}
className="text-[10px]"
>
{getCatLabel(DEFECT_TABLE, "is_active", row.is_active) || "-"}
{row.is_active === "사용" || row.is_active === "true" ? "사용" : row.is_active || "미사용"}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground">
{row.reg_date || (row.created_date ? row.created_date.slice(0, 10) : "-")}
</TableCell>
<TableCell>{row.manager_id || "-"}</TableCell>
<TableCell>{userOptions.find((u) => u.code === row.manager_id)?.label || row.manager_id || "-"}</TableCell>
<TableCell className="text-muted-foreground">{row.remarks || "-"}</TableCell>
</TableRow>
);
@@ -1442,19 +1441,15 @@ export default function InspectionManagementPage() {
<div className="space-y-1.5">
<Label className="text-xs font-semibold"></Label>
<Select
value={defForm.is_active || "__none__"}
onValueChange={(v) => setDefForm((p) => ({ ...p, is_active: v === "__none__" ? "" : v }))}
value={defForm.is_active || "사용"}
onValueChange={(v) => setDefForm((p) => ({ ...p, is_active: v }))}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> </SelectItem>
{(catOptions[`${DEFECT_TABLE}.is_active`] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>
{o.label}
</SelectItem>
))}
<SelectItem value="사용"></SelectItem>
<SelectItem value="미사용"></SelectItem>
</SelectContent>
</Select>
</div>
@@ -9,8 +9,9 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import {
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ChevronDown,
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ClipboardList,
} from "lucide-react";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { cn } from "@/lib/utils";
@@ -20,19 +21,17 @@ import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { toast } from "sonner";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
const TABLE_NAME = "item_inspection_info";
const ITEM_TABLE = "item_info";
const INSPECTION_TABLE = "inspection_standard";
const GRID_COLUMNS = [
{ key: "item_code", label: "품목코드" },
{ key: "item_name", label: "품명" },
{ key: "item_name", label: "품명" },
{ key: "inspection_type", label: "검사유형" },
{ key: "item_count", label: "항목수" },
{ key: "is_active", label: "사용여부" },
];
const ITEM_TABLE = "item_info";
const INSPECTION_TABLE = "inspection_standard";
const INSPECTION_TYPES = [
{ key: "incoming_inspection", label: "수입검사", matchLabels: ["수입검사", "입고검사", "수입", "입고"] },
@@ -61,24 +60,29 @@ export default function ItemInspectionInfoPage() {
const [totalCount, setTotalCount] = useState(0);
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
const [checkedIds, setCheckedIds] = useState<string[]>([]);
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
// 우측 패널: 선택된 품목
const [selectedItemCode, setSelectedItemCode] = useState<string | null>(null);
const [selectedTypeTab, setSelectedTypeTab] = useState<string>("");
// 모달
const [modalOpen, setModalOpen] = useState(false);
const [editMode, setEditMode] = useState(false);
const [form, setForm] = useState<Record<string, any>>({});
const [saving, setSaving] = useState(false);
/* FK 옵션 */
// FK 옵션
const [itemOptions, setItemOptions] = useState<{ code: string; name: string; item_type: string; unit: string }[]>([]);
const [inspOptions, setInspOptions] = useState<{ code: string; label: string; detail: string; method: string; types: string[] }[]>([]);
const [inspTypeCatOptions, setInspTypeCatOptions] = useState<{ code: string; label: string }[]>([]);
const [inspMethodCatOptions, setInspMethodCatOptions] = useState<{ code: string; label: string }[]>([]);
const [userOptions, setUserOptions] = useState<{ code: string; label: string }[]>([]);
/* 검사유형별 검사항목 rows */
// 검사유형별 검사항목 rows (모달용)
const [inspectionRows, setInspectionRows] = useState<Record<string, InspectionRow[]>>({});
const [collapsedTypes, setCollapsedTypes] = useState<Record<string, boolean>>({});
/* 품목 선택 모달 */
// 품목 선택 모달
const [itemModalOpen, setItemModalOpen] = useState(false);
const [itemSearchKeyword, setItemSearchKeyword] = useState("");
const [filteredItems, setFilteredItems] = useState<typeof itemOptions>([]);
@@ -109,7 +113,7 @@ export default function ItemInspectionInfoPage() {
types: r.inspection_type ? r.inspection_type.split(",").filter(Boolean) : [],
})));
// 검사유형 카테고리 값 로드 (코드→라벨 매핑용)
// 검사유형 카테고리
try {
const catRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/inspection_type/values`);
const flatCats: { code: string; label: string }[] = [];
@@ -118,6 +122,15 @@ export default function ItemInspectionInfoPage() {
setInspTypeCatOptions(flatCats);
} catch { /* skip */ }
// 검사방법 카테고리
try {
const methodRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/inspection_method/values`);
const flatMethods: { code: string; label: string }[] = [];
const flattenM = (arr: any[]) => { for (const v of arr) { flatMethods.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) flattenM(v.children); } };
if (methodRes.data?.data?.length) flattenM(methodRes.data.data);
setInspMethodCatOptions(flatMethods);
} catch { /* skip */ }
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
setUserOptions(users.map((u: any) => ({
code: u.user_id || u.id,
@@ -129,20 +142,12 @@ export default function ItemInspectionInfoPage() {
}, []);
/* ═══════════════════ 품목 선택 모달 ═══════════════════ */
const openItemModal = () => {
setItemSearchKeyword("");
setFilteredItems(itemOptions);
setItemModalOpen(true);
};
const openItemModal = () => { setItemSearchKeyword(""); setFilteredItems(itemOptions); setItemModalOpen(true); };
const handleItemSearch = () => {
const kw = itemSearchKeyword.trim().toLowerCase();
if (!kw) { setFilteredItems(itemOptions); return; }
setFilteredItems(itemOptions.filter(o =>
o.code.toLowerCase().includes(kw) || o.name.toLowerCase().includes(kw)
));
setFilteredItems(itemOptions.filter(o => o.code.toLowerCase().includes(kw) || o.name.toLowerCase().includes(kw)));
};
const selectItem = (item: typeof itemOptions[0]) => {
setForm(p => ({ ...p, item_code: item.code, item_name: item.name }));
setItemModalOpen(false);
@@ -186,24 +191,45 @@ export default function ItemInspectionInfoPage() {
return Object.values(map);
}, [data]);
// 검사기준 ID → 라벨 resolve
// 선택된 품목의 그룹 데이터
const selectedGroup = useMemo(() => {
if (!selectedItemCode) return null;
return groupedData.find(g => g.item_code === selectedItemCode) || null;
}, [selectedItemCode, groupedData]);
// 선택된 탭의 검사항목 행
const selectedTabRows = useMemo(() => {
if (!selectedGroup || !selectedTypeTab) return [];
return selectedGroup.rows.filter((r: any) => r.inspection_type === selectedTypeTab && r.inspection_standard_id);
}, [selectedGroup, selectedTypeTab]);
// 검사기준 ID → 라벨
const resolveInspLabel = useCallback((id: string) => {
const opt = inspOptions.find(o => o.code === id);
return opt?.label || id || "-";
return inspOptions.find(o => o.code === id)?.label || id || "-";
}, [inspOptions]);
// 검사방법 코드 → 라벨
const resolveMethodLabel = useCallback((code: string) => {
return inspMethodCatOptions.find(o => o.code === code)?.label || code || "-";
}, [inspMethodCatOptions]);
/* ═══════════════════ CRUD ═══════════════════ */
const openCreate = () => { setForm({}); setEditMode(false); setInspectionRows({}); setCollapsedTypes({}); setModalOpen(true); };
const openEdit = async (row: any) => {
const openEdit = async (itemCode?: string) => {
const code = itemCode || selectedItemCode;
if (!code) { toast.error("수정할 항목을 선택해주세요"); return; }
const group = groupedData.find(g => g.item_code === code);
if (!group) return;
const row = group.rows[0];
setForm({ ...row });
setEditMode(true);
setCollapsedTypes({});
// 같은 item_code의 모든 검사항목 행을 조회하여 유형별로 분류
try {
const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: row.item_code }] },
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: code }] },
autoFilter: true,
});
const allRows = res.data?.data?.data || res.data?.data?.rows || [];
@@ -212,7 +238,6 @@ export default function ItemInspectionInfoPage() {
for (const r of allRows) {
const inspType = r.inspection_type || "";
// 카테고리 코드/라벨로 INSPECTION_TYPES 키 매칭
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)))
@@ -221,48 +246,34 @@ export default function ItemInspectionInfoPage() {
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;
rowMap[typeKey].push({
id: r.id,
inspection_standard_id: r.inspection_standard_id || "",
inspection_detail: r.inspection_item_name || r.inspection_standard || "",
inspection_method: r.inspection_method || "",
inspection_method: mLabel,
apply_process: "",
acceptance_criteria: r.pass_criteria || "",
is_required: r.is_required === "true" || r.is_required === true,
});
}
setInspectionRows(rowMap);
setForm(p => ({ ...p, ...typeFlags }));
} catch {
setInspectionRows({});
}
} catch { setInspectionRows({}); }
setModalOpen(true);
};
/* ═══════════════════ 검사항목 행 관리 ═══════════════════ */
/* ═══════════════════ 검사항목 행 관리 (모달) ═══════════════════ */
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: "", acceptance_criteria: "", is_required: false }],
}));
};
const removeInspRow = (typeKey: string, rowId: string) => {
setInspectionRows(prev => ({
...prev,
[typeKey]: (prev[typeKey] || []).filter(r => r.id !== rowId),
}));
setInspectionRows(prev => ({ ...prev, [typeKey]: (prev[typeKey] || []).filter(r => r.id !== rowId) }));
};
const updateInspRow = (typeKey: string, rowId: string, field: string, value: any) => {
setInspectionRows(prev => ({
...prev,
@@ -270,34 +281,27 @@ export default function ItemInspectionInfoPage() {
if (r.id !== rowId) return r;
if (field === "inspection_standard_id") {
const opt = inspOptions.find(o => o.code === value);
return { ...r, inspection_standard_id: value, inspection_detail: opt?.detail || "", inspection_method: opt?.method || "" };
const methodCode = opt?.method || "";
const methodLabel = inspMethodCatOptions.find(o => o.code === methodCode)?.label || methodCode;
return { ...r, inspection_standard_id: value, inspection_detail: opt?.detail || "", inspection_method: methodLabel };
}
return { ...r, [field]: value };
}),
}));
};
/** 검사유형 키에 매칭되는 검사기준만 필터링 */
const getFilteredInspOptions = (typeKey: string) => {
const typeDef = INSPECTION_TYPES.find(t => t.key === typeKey);
if (!typeDef) return inspOptions;
// matchLabels와 카테고리 라벨을 비교하여 해당 카테고리 코드를 찾음
const matchCodes = inspTypeCatOptions
.filter(cat => typeDef.matchLabels.some(ml => cat.label.includes(ml)))
.map(cat => cat.code);
const matchCodes = inspTypeCatOptions.filter(cat => typeDef.matchLabels.some(ml => cat.label.includes(ml))).map(cat => cat.code);
if (matchCodes.length === 0) return inspOptions;
return inspOptions.filter(opt => opt.types.some(t => matchCodes.includes(t)));
};
const toggleCollapse = (typeKey: string) => {
setCollapsedTypes(prev => ({ ...prev, [typeKey]: !prev[typeKey] }));
};
const toggleCollapse = (typeKey: string) => { setCollapsedTypes(prev => ({ ...prev, [typeKey]: !prev[typeKey] })); };
const handleSave = async () => {
if (!form.item_code) { toast.error("품목코드는 필수 입력이에요"); return; }
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,
@@ -306,54 +310,29 @@ export default function ItemInspectionInfoPage() {
});
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 })),
});
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: existing.map((r: any) => ({ id: r.id })) });
}
}
// 검사유형별 항목을 개별 행으로 INSERT
const enabledTypes = INSPECTION_TYPES.filter(t => !!form[t.key]);
const rows: any[] = [];
for (const t of enabledTypes) {
const typeLabel = t.label;
const typeRows = inspectionRows[t.key] || [];
if (typeRows.length === 0) {
// 유형만 체크하고 항목 없는 경우에도 1행 생성
rows.push({
id: crypto.randomUUID(),
item_code: form.item_code,
item_name: form.item_name,
inspection_type: typeLabel,
is_active: form.is_active || "사용",
manager_id: form.manager_id || "",
memo: form.remarks || "",
});
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 || "" });
} else {
for (const r of typeRows) {
rows.push({
id: crypto.randomUUID(),
item_code: form.item_code,
item_name: form.item_name,
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: form.is_active || "사용",
manager_id: form.manager_id || "",
memo: form.remarks || "",
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 || "",
is_required: r.is_required ? "true" : "false", is_active: form.is_active || "사용",
manager_id: form.manager_id || "", memo: form.remarks || "",
});
}
}
}
for (const row of rows) {
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, row);
}
toast.success(editMode ? "품목검사정보를 수정했어요" : "품목검사정보를 등록했어요");
for (const row of rows) { await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, row); }
toast.success(editMode ? "수정했어요" : "등록했어요");
setModalOpen(false);
fetchData();
} catch { toast.error("저장에 실패했어요"); }
@@ -361,138 +340,231 @@ export default function ItemInspectionInfoPage() {
};
const handleDelete = async () => {
if (checkedIds.length === 0) { toast.error("삭제할 목을 선택해주세요"); return; }
const ok = await confirm("품목검사정보 삭제", {
description: `선택한 ${checkedIds.length}건을 삭제할까요? 이 작업은 되돌릴 수 없어요.`,
});
if (!selectedItemCode) { toast.error("삭제할 목을 선택해주세요"); return; }
const group = groupedData.find(g => g.item_code === selectedItemCode);
if (!group) return;
const ok = await confirm(`${selectedItemCode} 검사정보 삭제`, { description: "선택한 품목의 검사정보를 모두 삭제할까요?", variant: "destructive", confirmText: "삭제" });
if (!ok) return;
try {
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, {
data: checkedIds.map(id => ({ id })),
});
toast.success(`${checkedIds.length}건을 삭제했어요`);
setCheckedIds([]);
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: group.rows.map((r: any) => ({ id: r.id })) });
toast.success("삭제했어요");
setSelectedItemCode(null);
fetchData();
} catch { toast.error("삭제에 실패했어요"); }
};
/* ═══════════════════ JSX ═══════════════════ */
return (
<div className="flex flex-col gap-3 p-3">
<div className="flex h-full flex-col">
{ConfirmDialogComponent}
<div className="rounded-lg border bg-card">
<div className="px-3 py-2.5 border-b bg-muted/50">
<DynamicSearchFilter
tableName={TABLE_NAME}
filterId="c16-item-inspection"
onFilterChange={setSearchFilters}
externalFilterConfig={ts.filterConfig}
dataCount={totalCount}
extraActions={
<div className="flex items-center gap-2">
<Button size="sm" onClick={openCreate}><Plus className="w-4 h-4 mr-1" /></Button>
<Button size="sm" variant="outline" onClick={() => {
const sel = data.find(r => checkedIds.includes(r.id));
if (sel) {
const group = groupedData.find(g => g.item_code === sel.item_code);
openEdit(group?.rows[0] || sel);
} else toast.error("수정할 항목을 선택해주세요");
}}><Pencil className="w-4 h-4 mr-1" /></Button>
<Button size="sm" variant="destructive" onClick={handleDelete}><Trash2 className="w-4 h-4 mr-1" /></Button>
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
<Settings2 className="h-4 w-4" />
</Button>
</div>
}
/>
</div>
<div className="p-3 overflow-auto">
{loading ? (
<div className="flex items-center justify-center py-16"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
) : groupedData.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Inbox className="h-10 w-10 mb-2 opacity-30" />
<p className="text-sm"> </p>
</div>
) : (
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-10" />
<TableHead className="w-10"><Checkbox checked={groupedData.length > 0 && checkedIds.length === data.length} onCheckedChange={(v) => setCheckedIds(v ? data.map(r => r.id) : [])} /></TableHead>
{ts.visibleColumns.map((col) => (
<TableHead key={col.key} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground" style={ts.thStyle(col.key)}>
{col.label}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{ts.groupData(groupedData).map((group) => {
if ((group as any)._isGroupSummary || (group as any)._isGroupHeader) return null;
const isExpanded = expandedItems.has(group.item_code);
const groupIds = group.rows.map((r: any) => r.id);
const allChecked = groupIds.every((id: string) => checkedIds.includes(id));
const renderCell = (key: string) => {
switch (key) {
case "item_code": return <TableCell key={key} className="text-sm font-medium text-primary">{group.item_code}</TableCell>;
case "item_name": return <TableCell key={key} className="text-sm">{group.item_name}</TableCell>;
case "inspection_type": return (
<TableCell key={key}>
<div className="flex gap-1 flex-wrap">
{group.types.map((t: string) => <Badge key={t} variant="secondary" className="text-[10px]">{t}</Badge>)}
</div>
</TableCell>
);
case "item_count": return <TableCell key={key} className="text-sm text-center">{group.rows.filter((r: any) => r.inspection_standard_id).length}</TableCell>;
case "is_active": return (
<TableCell key={key}>
<Badge variant={group.is_active === "사용" || group.is_active === "true" ? "default" : "secondary"} className="text-[10px]">
{group.is_active === "사용" || group.is_active === "true" ? "사용" : "미사용"}
</Badge>
</TableCell>
);
default: return <TableCell key={key}>{(group as any)[key] ?? ""}</TableCell>;
}
};
return (
<React.Fragment key={group.item_code}>
<TableRow
className={cn("cursor-pointer border-l-2 border-l-transparent", allChecked && "border-l-primary bg-primary/5")}
onClick={() => setExpandedItems(prev => { const next = new Set(prev); if (next.has(group.item_code)) next.delete(group.item_code); else next.add(group.item_code); return next; })}
onDoubleClick={() => openEdit(group.rows[0])}
>
<TableCell className="text-center p-2">
<ChevronDown className={cn("w-4 h-4 transition-transform text-muted-foreground", !isExpanded && "-rotate-90")} />
</TableCell>
<TableCell className="text-center p-2" onClick={(e) => { e.stopPropagation(); setCheckedIds(prev => allChecked ? prev.filter(id => !groupIds.includes(id)) : [...new Set([...prev, ...groupIds])]); }}>
<Checkbox checked={allChecked} onCheckedChange={() => {}} />
</TableCell>
{ts.visibleColumns.map((col) => renderCell(col.key))}
</TableRow>
{isExpanded && group.rows.filter((r: any) => r.inspection_standard_id).map((row: any) => (
<TableRow key={row.id} className="bg-muted/30 text-xs">
<TableCell />
<TableCell />
<TableCell className="pl-6 text-muted-foreground">{row.inspection_type}</TableCell>
<TableCell>{resolveInspLabel(row.inspection_standard_id)}</TableCell>
<TableCell>{row.inspection_item_name || "-"}</TableCell>
<TableCell>{row.inspection_method || "-"}</TableCell>
<TableCell>{row.pass_criteria || "-"}</TableCell>
</TableRow>
))}
</React.Fragment>
);
})}
</TableBody>
</Table>
)}
<div className="mt-2 text-xs text-muted-foreground"> {groupedData.length} ( )</div>
</div>
{/* 검색 필터 */}
<div className="shrink-0 px-3 pt-3 pb-2">
<DynamicSearchFilter
tableName={TABLE_NAME}
filterId="c16-item-inspection"
onFilterChange={setSearchFilters}
externalFilterConfig={ts.filterConfig}
dataCount={totalCount}
/>
</div>
{/* ═══════════════════ 등록/수정 모달 (품목선택 뷰 포함) ═══════════════════ */}
{/* 좌우 분할 패널 */}
<div className="flex-1 min-h-0 px-3 pb-3">
<ResizablePanelGroup direction="horizontal" className="h-full rounded-lg border">
{/* ═══════ 좌측: 품목 목록 ═══════ */}
<ResizablePanel defaultSize={50} minSize={30}>
<div className="flex h-full flex-col">
<div className="shrink-0 flex items-center justify-between px-4 py-3 border-b bg-muted/30">
<div className="flex items-center gap-2">
<ClipboardList className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-semibold"> </span>
<Badge variant="secondary" className="text-[10px]">{groupedData.length}</Badge>
</div>
<div className="flex items-center gap-1.5">
<Button size="sm" className="h-7 text-xs" onClick={openCreate}><Plus 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>
</div>
<div className="flex-1 min-h-0 overflow-auto">
{loading ? (
<div className="flex items-center justify-center py-16"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
) : groupedData.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Inbox className="h-10 w-10 mb-2 opacity-30" />
<p className="text-sm"> </p>
</div>
) : (
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
{ts.visibleColumns.map((col) => (
<TableHead key={col.key} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground" style={ts.thStyle(col.key)}>
{col.label}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{groupedData.map((group) => (
<TableRow
key={group.item_code}
className={cn("cursor-pointer", selectedItemCode === group.item_code ? "bg-primary/10 border-l-2 border-l-primary" : "hover:bg-muted/50")}
onClick={() => {
setSelectedItemCode(group.item_code);
setSelectedTypeTab(group.types[0] || "");
}}
>
{ts.visibleColumns.map((col) => {
switch (col.key) {
case "item_code": return <TableCell key={col.key} className="text-sm font-medium text-primary">{group.item_code}</TableCell>;
case "item_name": return <TableCell key={col.key} className="text-sm">{group.item_name}</TableCell>;
case "inspection_type": return (
<TableCell key={col.key}>
<div className="flex gap-1 flex-wrap">
{group.types.map((t: string) => (
<Badge key={t} variant="secondary" className="text-[10px]">{t}</Badge>
))}
</div>
</TableCell>
);
case "is_active": return (
<TableCell key={col.key}>
<Badge variant={group.is_active === "사용" || group.is_active === "true" ? "default" : "secondary"} className="text-[10px]">
{group.is_active === "사용" || group.is_active === "true" ? "사용" : "미사용"}
</Badge>
</TableCell>
);
default: return <TableCell key={col.key}>{(group as any)[col.key] ?? ""}</TableCell>;
}
})}
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
<div className="shrink-0 px-4 py-2 border-t text-xs text-muted-foreground">
{groupedData.length} ( )
</div>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* ═══════ 우측: 검사유형별 검사항목 ═══════ */}
<ResizablePanel defaultSize={50} minSize={30}>
<div className="flex h-full flex-col">
<div className="shrink-0 flex items-center justify-between px-4 py-3 border-b bg-muted/30">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold"> </span>
</div>
{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="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" /></Button>
</div>
)}
</div>
{!selectedGroup ? (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
<div className="text-center space-y-2">
<ClipboardList className="h-8 w-8 mx-auto opacity-30" />
<p className="text-sm"> </p>
</div>
</div>
) : (
<div className="flex-1 min-h-0 flex flex-col">
{/* 검사유형 탭 */}
<div className="shrink-0 flex items-center gap-2 px-4 py-3">
{selectedGroup.types.map((type: string) => {
const count = selectedGroup.rows.filter((r: any) => r.inspection_type === type && r.inspection_standard_id).length;
return (
<button
key={type}
type="button"
onClick={() => setSelectedTypeTab(type)}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-colors border",
selectedTypeTab === type
? "bg-primary text-primary-foreground border-primary"
: "bg-muted/50 text-muted-foreground border-border hover:bg-muted"
)}
>
{type}
<span className={cn(
"inline-flex items-center justify-center h-4 min-w-[16px] rounded-full text-[10px] font-bold px-1",
selectedTypeTab === type ? "bg-primary-foreground/20 text-primary-foreground" : "bg-muted-foreground/20 text-muted-foreground"
)}>
{count}
</span>
</button>
);
})}
</div>
{/* 검사항목 상세 테이블 */}
<div className="flex-1 min-h-0 overflow-auto px-4 pb-4">
{selectedTypeTab && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Badge variant="default" className="text-xs">{selectedTypeTab}</Badge>
<span className="text-sm font-medium"> </span>
</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 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>
</TableRow>
</TableHeader>
<TableBody>
{selectedTabRows.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-xs text-muted-foreground"> </TableCell>
</TableRow>
) : selectedTabRows.map((row: any) => (
<TableRow key={row.id}>
<TableCell className="text-xs py-2">{row.inspection_item_name || "-"}</TableCell>
<TableCell className="text-xs py-2">{resolveInspLabel(row.inspection_standard_id)}</TableCell>
<TableCell className="text-xs py-2">{resolveMethodLabel(row.inspection_method)}</TableCell>
<TableCell className="text-xs py-2">{row.apply_process || "-"}</TableCell>
<TableCell className="text-xs py-2">
{row.judgment_criteria ? (
<Badge variant="outline" className="text-[10px]">{row.judgment_criteria}</Badge>
) : "-"}
</TableCell>
<TableCell className="text-xs py-2 font-mono">{row.pass_criteria || "-"}</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>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
</div>
</div>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
{/* ═══════════════════ 등록/수정 모달 ═══════════════════ */}
<Dialog open={modalOpen} onOpenChange={(open) => { if (!open) setItemModalOpen(false); setModalOpen(open); }}>
<DialogContent className={cn("max-w-[95vw] max-h-[85vh] overflow-y-auto", itemModalOpen ? "sm:max-w-xl" : "sm:max-w-4xl")}>
{itemModalOpen ? (
@@ -502,16 +574,8 @@ export default function ItemInspectionInfoPage() {
<DialogDescription> </DialogDescription>
</DialogHeader>
<div className="flex gap-2">
<Input
className="h-9 flex-1"
placeholder="품목코드 또는 품목명으로 검색"
value={itemSearchKeyword}
onChange={(e) => setItemSearchKeyword(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleItemSearch(); }}
/>
<Button size="sm" className="h-9" onClick={handleItemSearch}>
<Search className="w-4 h-4 mr-1" />
</Button>
<Input className="h-9 flex-1" placeholder="품목코드 또는 품목명" value={itemSearchKeyword} onChange={(e) => setItemSearchKeyword(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") handleItemSearch(); }} />
<Button size="sm" className="h-9" onClick={handleItemSearch}><Search className="w-4 h-4 mr-1" /></Button>
</div>
<div className="border rounded-lg overflow-auto max-h-[50vh]">
<Table>
@@ -527,11 +591,7 @@ export default function ItemInspectionInfoPage() {
{filteredItems.length === 0 ? (
<TableRow><TableCell colSpan={4} className="text-center py-8 text-muted-foreground text-sm"> </TableCell></TableRow>
) : filteredItems.map((item) => (
<TableRow
key={item.code}
className="cursor-pointer hover:bg-primary/5"
onClick={() => selectItem(item)}
>
<TableRow key={item.code} className="cursor-pointer hover:bg-primary/5" onClick={() => selectItem(item)}>
<TableCell className="text-sm">{item.code}</TableCell>
<TableCell className="text-sm">{item.name}</TableCell>
<TableCell className="text-sm">{item.item_type}</TableCell>
@@ -541,9 +601,7 @@ export default function ItemInspectionInfoPage() {
</TableBody>
</Table>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setItemModalOpen(false)}></Button>
</DialogFooter>
<DialogFooter><Button variant="outline" onClick={() => setItemModalOpen(false)}></Button></DialogFooter>
</>
) : (
<>
@@ -554,14 +612,14 @@ export default function ItemInspectionInfoPage() {
<div className="space-y-4">
{/* 품목 정보 */}
<div className="space-y-3">
<h4 className="text-sm font-semibold flex items-center gap-1.5">📦 </h4>
<h4 className="text-sm font-semibold"> </h4>
<div className="flex items-end gap-2">
<div className="space-y-1.5 flex-1">
<Label className="text-xs font-semibold text-muted-foreground"> <span className="text-destructive">*</span></Label>
<Input className="h-9 bg-muted" value={form.item_code || ""} readOnly placeholder="품목코드" />
</div>
<div className="space-y-1.5 flex-1">
<Label className="text-xs font-semibold text-muted-foreground"> <span className="text-destructive">*</span></Label>
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Input className="h-9 bg-muted" value={form.item_name || ""} readOnly placeholder="품목명" />
</div>
<Button type="button" variant="outline" size="sm" className="h-9 px-3 shrink-0" onClick={openItemModal}>
@@ -571,7 +629,7 @@ export default function ItemInspectionInfoPage() {
<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={form.is_active === false ? "N" : "Y"} onValueChange={(v) => setForm(p => ({ ...p, is_active: v === "Y" }))}>
<Select value={form.is_active === false || form.is_active === "N" ? "N" : "Y"} onValueChange={(v) => setForm(p => ({ ...p, is_active: v === "Y" ? "사용" : "미사용" }))}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="Y"></SelectItem>
@@ -583,50 +641,32 @@ export default function ItemInspectionInfoPage() {
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Select value={form.manager || ""} onValueChange={(v) => setForm(p => ({ ...p, manager: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="관리자 선택" /></SelectTrigger>
<SelectContent>
{userOptions.map(o => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
<SelectContent>{userOptions.map(o => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<textarea
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm min-h-[70px] resize-none focus:outline-none focus:ring-2 focus:ring-ring"
value={form.remarks || ""}
onChange={(e) => setForm(p => ({ ...p, remarks: e.target.value }))}
placeholder="비고 사항"
/>
</div>
</div>
{/* 검사유형 선택 */}
<div className="space-y-3">
<h4 className="text-sm font-semibold flex items-center gap-1.5"> </h4>
<h4 className="text-sm font-semibold"> </h4>
<div className="flex flex-wrap gap-4">
{INSPECTION_TYPES.map(({ key, label }) => (
<div key={key} className="flex items-center gap-1.5">
<Checkbox
checked={!!form[key]}
onCheckedChange={(v) => setForm(p => ({ ...p, [key]: !!v }))}
/>
<Checkbox checked={!!form[key]} onCheckedChange={(v) => setForm(p => ({ ...p, [key]: !!v }))} />
<Label className="text-sm cursor-pointer">{label}</Label>
</div>
))}
</div>
</div>
{/* 검사유형별 검사항목 설정 */}
{INSPECTION_TYPES.filter(t => !!form[t.key]).map(({ key, label }) => (
<div key={key} className="space-y-2">
<button
type="button"
className="w-full flex items-center gap-2 py-2 px-3 rounded-md border bg-muted/50 hover:bg-muted text-left"
onClick={() => toggleCollapse(key)}
>
<button type="button" className="w-full flex items-center gap-2 py-2 px-3 rounded-md border bg-muted/50 hover:bg-muted text-left" onClick={() => toggleCollapse(key)}>
<Badge variant="default" className="text-xs">{label}</Badge>
<span className="text-sm font-medium"> </span>
<ChevronDown className={cn("w-4 h-4 ml-auto transition-transform", collapsedTypes[key] && "-rotate-90")} />
<span className="text-xs text-muted-foreground ml-auto">{(inspectionRows[key] || []).length}</span>
</button>
{!collapsedTypes[key] && (
<div className="space-y-2 pl-1">
@@ -641,10 +681,10 @@ export default function ItemInspectionInfoPage() {
<TableHeader>
<TableRow className="bg-muted/50 hover:bg-muted/50">
<TableHead className="text-[10px] font-bold w-[170px]"> </TableHead>
<TableHead className="text-[10px] font-bold w-[130px]"> </TableHead>
<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-[100px]"></TableHead>
<TableHead className="text-[10px] font-bold"> ()</TableHead>
<TableHead className="text-[10px] font-bold"></TableHead>
<TableHead className="text-[10px] font-bold w-[40px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[36px]" />
</TableRow>
@@ -657,46 +697,20 @@ export default function ItemInspectionInfoPage() {
<TableCell className="p-1">
<Select value={row.inspection_standard_id || ""} onValueChange={(v) => updateInspRow(key, row.id, "inspection_standard_id", v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="검사기준 선택" /></SelectTrigger>
<SelectContent>
{getFilteredInspOptions(key).map(o => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
<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-8 text-xs bg-muted" value={row.inspection_detail} readOnly placeholder="자동" /></TableCell>
<TableCell className="p-1"><Input className="h-8 text-xs bg-muted" value={row.inspection_method} readOnly placeholder="자동" /></TableCell>
<TableCell className="p-1">
<Input className="h-8 text-xs bg-muted" value={row.inspection_detail} readOnly placeholder="검사기준 상세" />
<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 bg-muted" value={row.inspection_method} readOnly placeholder="선택" />
<Input className="h-8 text-xs" value={row.acceptance_criteria} onChange={(e) => updateInspRow(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) => updateInspRow(key, row.id, "is_required", !!v)} /></TableCell>
<TableCell className="p-1">
<Select value={row.apply_process || ""} onValueChange={(v) => updateInspRow(key, row.id, "apply_process", v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="공정 선택" /></SelectTrigger>
<SelectContent>
<SelectItem value="incoming"></SelectItem>
<SelectItem value="process"></SelectItem>
<SelectItem value="outgoing"></SelectItem>
<SelectItem value="final"></SelectItem>
</SelectContent>
</Select>
</TableCell>
<TableCell className="p-1">
<Input
className="h-8 text-xs"
value={row.acceptance_criteria}
onChange={(e) => updateInspRow(key, row.id, "acceptance_criteria", e.target.value)}
placeholder={row.inspection_standard_id ? "합격기준 입력" : "검사기준을 먼저 선택하세요"}
disabled={!row.inspection_standard_id}
/>
</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">
<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>
<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>
</TableRow>
))}
@@ -711,8 +725,7 @@ export default function ItemInspectionInfoPage() {
<DialogFooter>
<Button variant="outline" onClick={() => setModalOpen(false)}></Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Save className="w-4 h-4 mr-1" />}
{saving ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Save className="w-4 h-4 mr-1" />}
</Button>
</DialogFooter>
</>
@@ -720,14 +733,7 @@ export default function ItemInspectionInfoPage() {
</DialogContent>
</Dialog>
<TableSettingsModal
open={ts.open}
onOpenChange={ts.setOpen}
tableName={ts.tableName}
settingsId={ts.settingsId}
defaultVisibleKeys={ts.defaultVisibleKeys}
onSave={ts.applySettings}
/>
<TableSettingsModal open={ts.open} onOpenChange={ts.setOpen} tableName={ts.tableName} settingsId={ts.settingsId} defaultVisibleKeys={ts.defaultVisibleKeys} onSave={ts.applySettings} />
</div>
);
}
@@ -195,6 +195,12 @@ export default function CustomerManagementPage() {
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
} catch { /* skip */ }
}
for (const col of ["division", "unit", "material"]) {
try {
const res = await apiClient.get(`/table-categories/item_info/${col}/values`);
if (res.data?.success) optMap[`item_${col}`] = flatten(res.data.data || []);
} catch { /* skip */ }
}
setCategoryOptions(optMap);
const priceOpts: Record<string, { code: string; label: string }[]> = {};
@@ -709,7 +715,7 @@ export default function CustomerManagementPage() {
const handleCustomerSave = async () => {
if (!customerForm.customer_name) { toast.error("거래처명은 필수입니다."); return; }
if (!customerForm.status) { toast.error("상태는 필수입니다."); return; }
const errors = validateForm(customerForm, ["contact_phone", "email", "business_number"]);
const errors = validateForm(customerForm, ["business_number"]);
setFormErrors(errors);
if (Object.keys(errors).length > 0) {
toast.error("입력 형식을 확인해주세요.");
@@ -808,36 +814,56 @@ export default function CustomerManagementPage() {
};
// 품목 검색
const searchItems = async () => {
const searchItems = useCallback(async () => {
setItemSearchLoading(true);
try {
const filters: any[] = [
{ columnName: "division", operator: "contains", value: "CAT_ML8ZFVEL_1TOR" },
];
if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
const salesCode = categoryOptions["item_division"]?.find((o) => o.label === "영업관리")?.code;
const filters: any[] = salesCode
? [{ columnName: "division", operator: "contains", value: salesCode }]
: [];
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 500,
page: 1, size: 5000,
dataFilter: { enabled: true, filters },
autoFilter: true,
});
const allItems = res.data?.data?.data || res.data?.data?.rows || [];
setItemTotalCount(allItems.length);
const existingItemIds = new Set(priceItems.map((p: any) => p.item_id || p.item_number));
const kw = itemSearchKeyword.toLowerCase();
const seenNumbers = new Set<string>();
const deduped = allItems.filter((item: any) => {
if (existingItemIds.has(item.item_number) || existingItemIds.has(item.id)) return false;
if (item.item_number && seenNumbers.has(item.item_number)) return false;
if (item.item_number) seenNumbers.add(item.item_number);
if (kw) {
const name = (item.item_name || "").toLowerCase();
const code = (item.item_number || "").toLowerCase();
if (!name.includes(kw) && !code.includes(kw)) return false;
}
return true;
});
setItemSearchResults(deduped);
} catch { /* skip */ } finally { setItemSearchLoading(false); }
};
}, [itemSearchKeyword, priceItems]);
// 실시간 검색 (2글자 이상)
useEffect(() => {
if (!itemSelectOpen) return;
if (itemSearchKeyword.length > 0 && itemSearchKeyword.length < 2) return;
searchItems();
}, [itemSearchKeyword, itemSelectOpen]);
// 품목 선택 완료 → 상세 입력 모달로 전환
const goToItemDetail = () => {
const selected = itemSearchResults.filter((i) => itemCheckedIds.has(i.id));
if (selected.length === 0) { toast.error("품목을 선택해주세요."); return; }
const raw = itemSearchResults.filter((i) => itemCheckedIds.has(i.id));
if (raw.length === 0) { toast.error("품목을 선택해주세요."); return; }
const seenKeys = new Set<string>();
const selected = raw.filter((i) => {
const k = i.item_number || i.id;
if (seenKeys.has(k)) return false;
seenKeys.add(k);
return true;
});
setSelectedItemsForDetail(selected);
const mappings: typeof itemMappings = {};
const prices: typeof itemPrices = {};
@@ -969,6 +995,7 @@ export default function CustomerManagementPage() {
{ columnName: "customer_id", operator: "equals", value: selectedCustomer!.customer_code },
{ columnName: "item_id", operator: "equals", value: itemKey },
]}, autoFilter: true,
sort: { columnName: "created_date", order: "asc" },
});
const allMappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
mappingRows = allMappings
@@ -989,7 +1016,8 @@ export default function CustomerManagementPage() {
{ columnName: "item_id", operator: "equals", value: itemKey },
]}, autoFilter: true,
});
const allPriceData = priceRes.data?.data?.data || priceRes.data?.data?.rows || [];
const allPriceData = (priceRes.data?.data?.data || priceRes.data?.data?.rows || [])
.sort((a: any, b: any) => (a.start_date || "").localeCompare(b.start_date || ""));
priceRows = allPriceData.map((p: any) => ({
_id: `p_existing_${p.id}`,
start_date: p.start_date ? String(p.start_date).split("T")[0] : "",
@@ -1027,8 +1055,11 @@ export default function CustomerManagementPage() {
const isEditingExisting = !!editItemData;
setSaving(true);
try {
const processedKeys = new Set<string>();
for (const item of selectedItemsForDetail) {
const itemKey = item.item_number || item.id;
if (processedKeys.has(itemKey)) continue;
processedKeys.add(itemKey);
const mappingRows = itemMappings[itemKey] || [];
if (isEditingExisting && editItemData?.id) {
@@ -1041,6 +1072,7 @@ export default function CustomerManagementPage() {
{ columnName: "customer_id", operator: "equals", value: selectedCustomer.customer_code },
{ columnName: "item_id", operator: "equals", value: itemKey },
]}, autoFilter: true,
sort: { columnName: "created_date", order: "asc" },
});
existingMaps = existingMappings.data?.data?.data || existingMappings.data?.data?.rows || [];
} catch { /* skip */ }
@@ -1090,12 +1122,13 @@ export default function CustomerManagementPage() {
{ columnName: "item_id", operator: "equals", value: itemKey },
]}, autoFilter: true,
});
existingPriceRows = existingPrices.data?.data?.data || existingPrices.data?.data?.rows || [];
existingPriceRows = (existingPrices.data?.data?.data || existingPrices.data?.data?.rows || [])
.sort((a: any, b: any) => (a.start_date || "").localeCompare(b.start_date || ""));
} catch { /* skip */ }
// 단가 upsert
const priceRows = (itemPrices[itemKey] || []).filter((p) =>
(p.base_price && Number(p.base_price) > 0) || p.start_date
p.base_price || p.start_date || p.currency_code || p.base_price_type
);
const usedPriceIds = new Set<string>();
for (let pi = 0; pi < priceRows.length; pi++) {
@@ -1165,7 +1198,7 @@ export default function CustomerManagementPage() {
}
const priceRows = (itemPrices[itemKey] || []).filter((p) =>
(p.base_price && Number(p.base_price) > 0) || p.start_date
p.base_price || p.start_date || p.currency_code || p.base_price_type
);
for (const price of priceRows) {
await apiClient.post(`/table-management/tables/${PRICE_TABLE}/add`, {
@@ -1197,40 +1230,63 @@ export default function CustomerManagementPage() {
}
};
// 품목 매핑 삭제
// 품목 매핑 해제 — 선택한 품목의 모든 매핑 + 단가에서 customer_id를 null 처리
const handlePriceItemDelete = async () => {
if (priceCheckedIds.length === 0) return;
const ok = await confirm(`선택한 ${priceCheckedIds.length}개 품목 매핑제하시겠습니까?`, {
description: "관련된 단가 정보도 함께 삭제됩니다.",
variant: "destructive", confirmText: "제",
const ok = await confirm(`선택한 ${priceCheckedIds.length}개 품목의 연결제하시겠습니까?`, {
description: "해당 품목의 거래처 연결이 해제됩니다. (데이터는 유지)",
variant: "destructive", confirmText: "제",
});
if (!ok) return;
try {
for (const mappingId of priceCheckedIds) {
const itemIds = priceCheckedIds.map((mid) => {
const group = Object.values(priceGroups).find((g) => g.master.id === mid);
return group?.master.item_id || group?.master.item_number || "";
}).filter(Boolean);
for (const itemId of itemIds) {
// 해당 품목의 모든 매핑 조회 → customer_id null 처리
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [
{ columnName: "customer_id", operator: "equals", value: selectedCustomer!.customer_code },
{ columnName: "item_id", operator: "equals", value: itemId },
]}, autoFilter: true,
});
const allMappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
for (const m of allMappings) {
await apiClient.put(`/table-management/tables/${MAPPING_TABLE}/edit`, {
originalData: { id: m.id },
updatedData: { customer_id: null },
});
}
// 해당 품목의 모든 단가 조회 → customer_id null 처리
try {
const priceRes = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "mapping_id", operator: "equals", value: mappingId }] },
autoFilter: true,
dataFilter: { enabled: true, filters: [
{ columnName: "customer_id", operator: "equals", value: selectedCustomer!.customer_code },
{ columnName: "item_id", operator: "equals", value: itemId },
]}, autoFilter: true,
});
const prices = priceRes.data?.data?.data || priceRes.data?.data?.rows || [];
if (prices.length > 0) {
await apiClient.delete(`/table-management/tables/${PRICE_TABLE}/delete`, {
data: prices.map((p: any) => ({ id: p.id })),
for (const p of prices) {
await apiClient.put(`/table-management/tables/${PRICE_TABLE}/edit`, {
originalData: { id: p.id },
updatedData: { customer_id: null },
});
}
} catch { /* skip */ }
}
await apiClient.delete(`/table-management/tables/${MAPPING_TABLE}/delete`, {
data: priceCheckedIds.map((id) => ({ id })),
});
toast.success(`${priceCheckedIds.length}개 품목 매핑이 삭제되었습니다.`);
toast.success(`${priceCheckedIds.length}개 품목의 연결이 해제되었습니다.`);
setPriceCheckedIds([]);
const cid = selectedCustomerId;
setSelectedCustomerId(null);
setTimeout(() => setSelectedCustomerId(cid), 50);
} catch {
toast.error("제에 실패했습니다.");
toast.error("연결 해제에 실패했습니다.");
}
};
@@ -1821,35 +1877,6 @@ export default function CustomerManagementPage() {
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input
value={customerForm.contact_person || ""}
onChange={(e) => setCustomerForm((p) => ({ ...p, contact_person: e.target.value }))}
placeholder="거래처담당자"
className="h-9"
/>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input
value={customerForm.contact_phone || ""}
onChange={(e) => handleFormChange("contact_phone", e.target.value)}
placeholder="010-0000-0000"
className={cn("h-9", formErrors.contact_phone && "border-destructive")}
/>
{formErrors.contact_phone && <p className="text-xs text-destructive">{formErrors.contact_phone}</p>}
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input
value={customerForm.email || ""}
onChange={(e) => handleFormChange("email", e.target.value)}
placeholder="example@email.com"
className={cn("h-9", formErrors.email && "border-destructive")}
/>
{formErrors.email && <p className="text-xs text-destructive">{formErrors.email}</p>}
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input
@@ -2386,7 +2413,7 @@ export default function CustomerManagementPage() {
{itemSearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" /> </>}
</Button>
</div>
<div className="overflow-auto max-h-[350px] border rounded-lg">
<div className="overflow-auto h-[350px] border rounded-lg">
<Table noWrapper>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted h-10">
@@ -2433,8 +2460,8 @@ export default function CustomerManagementPage() {
</TableCell>
<TableCell className="text-sm">{item.item_name}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{item.size}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{item.material}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{item.unit}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{categoryOptions["item_material"]?.find((o) => o.code === item.material)?.label || item.material}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{categoryOptions["item_unit"]?.find((o) => o.code === item.unit)?.label || item.unit}</TableCell>
</TableRow>
))}
</TableBody>
@@ -2476,7 +2503,7 @@ export default function CustomerManagementPage() {
{/* 품목 헤더 */}
<div className="px-5 py-3 bg-muted/30 border-b">
<div className="font-bold">{idx + 1}. {item.item_name || itemKey}</div>
<div className="text-xs text-muted-foreground">{itemKey} | {item.size || ""} | {item.unit || ""}</div>
<div className="text-xs text-muted-foreground">{itemKey} | {item.size || ""} | {categoryOptions["item_unit"]?.find((o) => o.code === item.unit)?.label || item.unit || ""}</div>
</div>
<div className="flex gap-4 p-5 items-stretch">
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -38,6 +38,30 @@ const fmtDate = (v: any) => {
return m ? `${m[1]}-${m[2]}-${m[3]}` : s.substring(0, 10);
};
/** remark가 JSON이면 사람이 읽을 수 있는 텍스트로 변환 */
const parseRemark = (remark: string | null | undefined): string => {
if (!remark) return "";
const trimmed = remark.trim();
if (!trimmed.startsWith("{")) return trimmed;
try {
const d = JSON.parse(trimmed);
switch (d.type) {
case "move":
return `창고이동 (${d.from_warehouse}${d.to_warehouse})`;
case "adjust":
return `재고조정 (${d.reason || "사유 없음"}, ${d.system_qty}${d.actual_qty}, 차이:${d.diff >= 0 ? "+" : ""}${d.diff})`;
case "confirm":
return `재고확인 (${d.reason || "이상없음"})`;
case "process_inbound":
return "공정입고";
default:
return d.reason || d.memo || trimmed;
}
} catch {
return trimmed;
}
};
export default function InboundOutboundPage() {
const { user } = useAuth();
@@ -62,7 +86,6 @@ export default function InboundOutboundPage() {
try {
const filters: any[] = [];
if (typeFilter !== "all") filters.push({ columnName: "transaction_type", operator: "equals", value: typeFilter });
if (categoryFilter !== "all") filters.push({ columnName: "remark", operator: "equals", value: categoryFilter });
for (const f of searchFilters) {
if (f.value) filters.push({ columnName: f.columnName, operator: f.operator, value: f.value });
@@ -81,6 +104,21 @@ export default function InboundOutboundPage() {
const itemCodes = [...new Set(rows.map((r: any) => r.item_code).filter(Boolean))];
if (itemCodes.length > 0) {
try {
// 단위 카테고리 코드→라벨 매핑 로드
let unitLabelMap: Record<string, string> = {};
try {
const catRes = await apiClient.get("/table-categories/item_info/unit/values");
if (catRes.data?.success && catRes.data.data?.length > 0) {
const flatten = (vals: any[]) => {
for (const v of vals) {
unitLabelMap[v.valueCode] = v.valueLabel;
if (v.children?.length) flatten(v.children);
}
};
flatten(catRes.data.data);
}
} catch { /* skip */ }
const itemRes = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: itemCodes.length + 10,
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: itemCodes }] },
@@ -89,7 +127,8 @@ export default function InboundOutboundPage() {
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
const map: Record<string, { item_name: string; unit: string }> = {};
for (const i of items) {
if (!map[i.item_number]) map[i.item_number] = { item_name: i.item_name || "", unit: i.unit || "" };
const rawUnit = i.unit || "";
if (!map[i.item_number]) map[i.item_number] = { item_name: i.item_name || "", unit: unitLabelMap[rawUnit] || rawUnit };
}
setItemMap(map);
} catch { /* skip */ }
@@ -124,7 +163,7 @@ export default function InboundOutboundPage() {
} finally {
setLoading(false);
}
}, [searchFilters, typeFilter, categoryFilter]);
}, [searchFilters, typeFilter]);
useEffect(() => { fetchData(); }, [fetchData]);
@@ -132,20 +171,26 @@ export default function InboundOutboundPage() {
const categoryOptions = useMemo(() => {
const set = new Set<string>();
data.forEach((r) => { if (r.remark) set.add(r.remark); });
data.forEach((r) => { if (r.remark) set.add(parseRemark(r.remark)); });
return Array.from(set).sort();
}, [data]);
// ════════ 그룹핑 ════════
// 카테고리 필터 (클라이언트)
const filteredData = useMemo(() => {
if (categoryFilter === "all") return data;
return data.filter((r) => parseRemark(r.remark) === categoryFilter);
}, [data, categoryFilter]);
const groupedData = useMemo(() => {
if (groupBy === "none") return data;
if (groupBy === "none") return filteredData;
const groups = new Map<string, any[]>();
for (const row of data) {
for (const row of filteredData) {
let key: string;
switch (groupBy) {
case "transaction_type": key = row.transaction_type || "미지정"; break;
case "remark": key = row.remark || "미지정"; break;
case "remark": key = parseRemark(row.remark) || "미지정"; break;
case "warehouse_code": key = warehouseMap[row.warehouse_code] || row.warehouse_code || "미지정"; break;
case "item_code": key = `${row.item_code} ${itemMap[row.item_code]?.item_name || ""}`.trim() || "미지정"; break;
default: key = row[groupBy] || "미지정";
@@ -191,7 +236,7 @@ export default function InboundOutboundPage() {
const rows = data.map((r, i) => ({
No: i + 1,
입출고구분: r.transaction_type || "",
카테고리: r.remark || "",
카테고리: parseRemark(r.remark),
처리일자: fmtDate(r.transaction_date),
창고: warehouseMap[r.warehouse_code] || r.warehouse_code || "",
위치: r.location_code || "",
@@ -252,7 +297,7 @@ export default function InboundOutboundPage() {
<div className="flex items-center gap-2">
<Package className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-semibold"> </span>
<Badge variant="secondary" className="font-mono text-xs">{data.length}</Badge>
<Badge variant="secondary" className="font-mono text-xs">{filteredData.length}</Badge>
</div>
<div className="flex items-center gap-2">
<Select value={groupBy} onValueChange={(v) => { setGroupBy(v); setExpandedGroups(new Set()); }}>
@@ -353,7 +398,7 @@ export default function InboundOutboundPage() {
{row.transaction_type}
</span>
</TableCell>
<TableCell className="text-center text-[12px]">{row.remark || "-"}</TableCell>
<TableCell className="text-center text-[12px]">{parseRemark(row.remark) || "-"}</TableCell>
<TableCell className="text-center text-[12px]">{fmtDate(row.transaction_date)}</TableCell>
<TableCell className="text-[12px]">{warehouseMap[row.warehouse_code] || row.warehouse_code || "-"}</TableCell>
<TableCell className="text-center text-[12px]">{row.location_code || "-"}</TableCell>
@@ -230,11 +230,10 @@ function flattenCategories(items: any[]): { value: string; label: string }[] {
const result: { value: string; label: string }[] = [];
function walk(arr: any[]) {
for (const item of arr) {
if (item.value || item.name) {
result.push({
value: item.value || item.name,
label: item.label || item.name || item.value,
});
const val = item.valueCode || item.value || item.name;
const lbl = item.valueLabel || item.label || item.name || val;
if (val) {
result.push({ value: val, label: lbl });
}
if (item.children?.length) walk(item.children);
}
@@ -140,31 +140,47 @@ export default function InventoryStatusPage() {
Record<string, { code: string; label: string }[]>
>({});
// 카테고리 로드
// 사용자 맵 (writer → 이름)
const [userMap, setUserMap] = useState<Record<string, string>>({});
// 카테고리 + 사용자 로드
useEffect(() => {
const load = async () => {
const optMap: Record<string, { code: string; label: string }[]> = {};
const flatten = (vals: any[]): { code: string; label: string }[] => {
const result: { code: string; label: string }[] = [];
for (const v of vals) {
result.push({ code: v.valueCode, label: v.valueLabel });
result.push({ code: v.valueCode || v.value_code || v.code, label: v.valueLabel || v.value_label || v.label });
if (v.children?.length) result.push(...flatten(v.children));
}
return result;
};
for (const col of ["status", "unit"]) {
// inventory_stock 카테고리
for (const col of ["status"]) {
try {
const res = await apiClient.get(
`/table-categories/${STOCK_TABLE}/${col}/values`
);
const res = await apiClient.get(`/table-categories/${STOCK_TABLE}/${col}/values`);
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
} catch {
/* skip */
}
} catch { /* skip */ }
}
// item_info 단위 카테고리
try {
const res = await apiClient.get("/table-categories/item_info/unit/values?filterCompanyCode=COMPANY_7");
if (res.data?.success) optMap["item_unit"] = flatten(res.data.data || []);
} catch { /* skip */ }
setCategoryOptions(optMap);
};
load();
// 사용자 목록 로드
apiClient.get("/admin/users", { params: { size: 9999 } }).then((res) => {
const users = res.data?.data || res.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;
if (id) map[id] = name;
}
setUserMap(map);
}).catch(() => {});
}, []);
// 재고 목록 조회
@@ -193,10 +209,11 @@ export default function InventoryStatusPage() {
};
const data = raw.map((r: any) => {
const itemInfo = itemMap.get(r.item_code) as any;
const rawUnit = itemInfo?.unit || r.unit || "";
return {
...r,
item_name: itemInfo?.name || "",
unit: itemInfo?.unit || resolve("unit", r.unit),
unit: resolve("item_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),
@@ -332,6 +349,12 @@ export default function InventoryStatusPage() {
),
};
}
if (col.key === "warehouse_code") {
return {
...base,
render: (_val: any, row: any) => row.warehouse_name || row.warehouse_code || "",
};
}
if (col.key === "safety_qty") {
return {
...base,
@@ -613,7 +636,7 @@ export default function InventoryStatusPage() {
<TableCell className="truncate max-w-[150px]">
{h.remark || h.reason || ""}
</TableCell>
<TableCell>{h.writer || h.created_by || ""}</TableCell>
<TableCell>{userMap[h.writer] || userMap[h.created_by] || h.writer || h.created_by || ""}</TableCell>
</TableRow>
))}
</TableBody>
@@ -43,6 +43,7 @@ import {
} from "@/lib/api/materialStatus";
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { apiClient } from "@/lib/api/client";
const GRID_COLUMNS = [
{ key: "plan_no", label: "계획번호" },
@@ -60,26 +61,31 @@ const formatDate = (date: Date) => {
return `${y}-${m}-${d}`;
};
const getStatusLabel = (status: string) => {
const map: Record<string, string> = {
planned: "계획",
in_progress: "진행중",
completed: "완료",
pending: "대기",
cancelled: "취소",
};
return map[status] || status;
const FALLBACK_STATUS_MAP: Record<string, string> = {
planned: "계획",
in_progress: "진행중",
completed: "완료",
pending: "대기",
cancelled: "취소",
};
const getStatusStyle = (status: string) => {
const map: Record<string, string> = {
planned: "bg-secondary text-secondary-foreground border-border",
pending: "bg-secondary text-secondary-foreground border-border",
in_progress: "bg-primary/10 text-primary border-primary/20",
completed: "bg-accent text-accent-foreground border-accent/50",
cancelled: "bg-muted text-muted-foreground border-border",
};
return map[status] || "bg-muted text-muted-foreground border-border";
const STATUS_STYLE_MAP: Record<string, string> = {
planned: "bg-secondary text-secondary-foreground border-border",
pending: "bg-secondary text-secondary-foreground border-border",
in_progress: "bg-primary/10 text-primary border-primary/20",
completed: "bg-accent text-accent-foreground border-accent/50",
cancelled: "bg-muted text-muted-foreground border-border",
};
// 카테고리 라벨 기반으로 스타일 매칭
const LABEL_STYLE_MAP: Record<string, string> = {
"일반": "bg-secondary text-secondary-foreground border-border",
"긴급": "bg-destructive/10 text-destructive border-destructive/20",
"계획": "bg-secondary text-secondary-foreground border-border",
"대기": "bg-secondary text-secondary-foreground border-border",
"진행중": "bg-primary/10 text-primary border-primary/20",
"완료": "bg-accent text-accent-foreground border-accent/50",
"취소": "bg-muted text-muted-foreground border-border",
};
export default function MaterialStatusPage() {
@@ -93,6 +99,32 @@ export default function MaterialStatusPage() {
const [searchItemCode, setSearchItemCode] = useState("");
const [searchItemName, setSearchItemName] = useState("");
// 카테고리 코드→라벨 매핑
const [statusMap, setStatusMap] = useState<Record<string, string>>({});
useEffect(() => {
(async () => {
try {
const res = await apiClient.get("/table-categories/work_instruction/status/values");
if (res.data?.success && res.data.data?.length > 0) {
const map: Record<string, string> = {};
const flatten = (vals: any[]) => {
for (const v of vals) {
map[v.valueCode] = v.valueLabel;
if (v.children?.length) flatten(v.children);
}
};
flatten(res.data.data);
setStatusMap(map);
}
} catch { /* ignore */ }
})();
}, []);
const getStatusLabel = useCallback((status: string) => {
return statusMap[status] || FALLBACK_STATUS_MAP[status] || status;
}, [statusMap]);
const [workOrders, setWorkOrders] = useState<WorkOrder[]>([]);
const [workOrdersLoading, setWorkOrdersLoading] = useState(false);
const [checkedWoIds, setCheckedWoIds] = useState<string[]>([]);
@@ -369,7 +401,7 @@ export default function MaterialStatusPage() {
<span
className={cn(
"ml-auto rounded-md border px-2 py-0.5 text-[11px] font-semibold",
getStatusStyle(wo.status)
STATUS_STYLE_MAP[wo.status] || LABEL_STYLE_MAP[getStatusLabel(wo.status)] || "bg-muted text-muted-foreground border-border"
)}
>
{getStatusLabel(wo.status)}
@@ -1,6 +1,7 @@
"use client";
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { apiClient } from "@/lib/api/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
@@ -311,6 +312,42 @@ export default function OutboundPage() {
const [editMode, setEditMode] = useState(false);
const [editItemIds, setEditItemIds] = useState<string[]>([]);
// 카테고리 코드→라벨 매핑 (재질, 단위)
const [catMap, setCatMap] = useState<Record<string, Record<string, 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 || v.value_code || v.code, label: v.valueLabel || v.value_label || v.label });
if (v.children?.length) result.push(...flatten(v.children));
}
return result;
};
const map: Record<string, Record<string, string>> = {};
Promise.all([
...["material", "unit"].map(async (col) => {
try {
const res = await apiClient.get(`/table-categories/item_info/${col}/values?filterCompanyCode=COMPANY_7`);
const items = flatten(res.data?.data || []);
map[col] = {};
for (const item of items) map[col][item.code] = item.label;
} catch { /* skip */ }
}),
(async () => {
try {
const res = await apiClient.get(`/table-categories/outbound_mng/outbound_type/values`);
const items = flatten(res.data?.data || []);
map["outbound_type"] = {};
for (const item of items) map["outbound_type"][item.code] = item.label;
} catch { /* skip */ }
})(),
]).then(() => setCatMap(map));
}, []);
const resolveCat = useCallback((col: string, code: string) => {
if (!code) return "";
return catMap[col]?.[code] || code;
}, [catMap]);
// 소스 데이터
const [sourceKeyword, setSourceKeyword] = useState("");
const [sourceLoading, setSourceLoading] = useState(false);
@@ -871,8 +908,9 @@ export default function OutboundPage() {
fetchList();
}
}
} catch {
toast.error(editMode ? "수정에 실패했어요" : "출고 등록 중 오류가 발생했습니다.");
} catch (err: any) {
const msg = err?.response?.data?.message || (editMode ? "수정에 실패했어요" : "출고 등록 중 오류가 발생했습니다.");
toast.error(msg);
} finally {
setSaving(false);
}
@@ -1085,7 +1123,7 @@ export default function OutboundPage() {
case "outbound_type": return (
<TableCell key={col.key}>
<Badge variant="outline" className={cn("text-[11px]", getTypeColor(master.outbound_type))}>
{master.outbound_type || "-"}
{resolveCat("outbound_type", master.outbound_type) || "-"}
</Badge>
</TableCell>
);
@@ -1337,6 +1375,7 @@ export default function OutboundPage() {
data={pagedItems}
onAdd={addItem}
selectedKeys={selectedItems.map((s) => s.key)}
resolveCat={resolveCat}
/>
)}
</div>
@@ -1502,7 +1541,7 @@ export default function OutboundPage() {
<TableCell className="p-2 text-center">{idx + 1}</TableCell>
<TableCell className="p-2">
<Badge variant="outline" className={cn("text-[10px]", getTypeColor(item.outbound_type))}>
{item.outbound_type || "-"}
{resolveCat("outbound_type", item.outbound_type) || "-"}
</Badge>
</TableCell>
<TableCell className="max-w-[180px] p-2">
@@ -1772,10 +1811,12 @@ function SourceItemTable({
data,
onAdd,
selectedKeys,
resolveCat,
}: {
data: ItemSource[];
onAdd: (item: ItemSource) => void;
selectedKeys: string[];
resolveCat: (col: string, code: string) => string;
}) {
if (data.length === 0) {
return (
@@ -1826,8 +1867,8 @@ function SourceItemTable({
</div>
</TableCell>
<TableCell className="max-w-[100px] truncate p-2" title={item.spec || "-"}>{item.spec || "-"}</TableCell>
<TableCell className="max-w-[100px] truncate p-2" title={item.material || "-"}>{item.material || "-"}</TableCell>
<TableCell className="p-2">{item.unit || "-"}</TableCell>
<TableCell className="max-w-[100px] truncate p-2" title={resolveCat("material", item.material) || "-"}>{resolveCat("material", item.material) || "-"}</TableCell>
<TableCell className="p-2">{resolveCat("unit", item.unit) || "-"}</TableCell>
<TableCell className="p-2 text-right">
{Number(item.standard_price).toLocaleString()}
</TableCell>
@@ -353,17 +353,45 @@ export default function ReceivingPage() {
// 구매관리 division 코드 (라벨 기준 조회)
const [purchaseDivisionCode, setPurchaseDivisionCode] = useState<string>("");
// 카테고리 코드→라벨 매핑 (재질, 단위)
const [catMap, setCatMap] = useState<Record<string, Record<string, string>>>({});
// 구매관리 division 코드 로드
// 구매관리 division 코드 + 재질/단위 카테고리 로드
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 || v.value_code || v.code, label: v.valueLabel || v.value_label || v.label });
if (v.children?.length) result.push(...flatten(v.children));
}
return result;
};
// division 카테고리에서 "구매관리" 라벨의 코드 조회
apiClient.get("/table-categories/item_info/division/values").then((res) => {
const vals = res.data?.data || [];
const found = vals.find((v: any) => (v.value_label || v.label) === "구매관리");
const found = vals.find((v: any) => (v.valueLabel || v.value_label || v.label) === "구매관리");
if (found) setPurchaseDivisionCode(found.value_code || found.code);
}).catch(() => {});
// 재질, 단위 카테고리
const map: Record<string, Record<string, string>> = {};
Promise.all(
["material", "unit"].map(async (col) => {
try {
const res = await apiClient.get(`/table-categories/item_info/${col}/values?filterCompanyCode=COMPANY_7`);
const items = flatten(res.data?.data || []);
map[col] = {};
for (const item of items) map[col][item.code] = item.label;
} catch { /* skip */ }
})
).then(() => setCatMap(map));
}, []);
// 카테고리 코드→라벨 변환
const resolveCat = useCallback((col: string, code: string) => {
if (!code) return "";
return catMap[col]?.[code] || code;
}, [catMap]);
// 목록 조회
const fetchList = useCallback(async () => {
setLoading(true);
@@ -777,12 +805,16 @@ export default function ReceivingPage() {
return;
}
if (!modalWarehouse) {
toast.error("창고를 선택해주세요.");
return;
}
setSaving(true);
try {
const res = await createReceiving({
inbound_number: modalInboundNo,
inbound_date: modalInboundDate,
warehouse_code: modalWarehouse || undefined,
warehouse_code: modalWarehouse,
location_code: modalLocation || undefined,
inspector: modalInspector || undefined,
manager: modalManager || undefined,
@@ -1283,6 +1315,7 @@ export default function ReceivingPage() {
data={items}
onAdd={addItem}
selectedKeys={selectedItems.map((s) => s.key)}
resolveCat={resolveCat}
/>
)}
</div>
@@ -1738,10 +1771,12 @@ function SourceItemTable({
data,
onAdd,
selectedKeys,
resolveCat,
}: {
data: ItemSource[];
onAdd: (item: ItemSource) => void;
selectedKeys: string[];
resolveCat: (col: string, code: string) => string;
}) {
if (data.length === 0) {
return (
@@ -1794,8 +1829,8 @@ function SourceItemTable({
</div>
</TableCell>
<TableCell className="max-w-[100px] truncate p-2" title={item.spec || "-"}>{item.spec || "-"}</TableCell>
<TableCell className="max-w-[100px] truncate p-2" title={item.material || "-"}>{item.material || "-"}</TableCell>
<TableCell className="p-2">{item.unit || "-"}</TableCell>
<TableCell className="max-w-[100px] truncate p-2" title={resolveCat("material", item.material) || "-"}>{resolveCat("material", item.material) || "-"}</TableCell>
<TableCell className="p-2">{resolveCat("unit", item.unit) || "-"}</TableCell>
<TableCell className="p-2 text-right">
{Number(item.standard_price).toLocaleString()}
</TableCell>
@@ -26,6 +26,7 @@ import {
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
import { ImageUpload } from "@/components/common/ImageUpload";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
@@ -102,8 +103,8 @@ export default function MoldInfoPage() {
const [numberingRuleId, setNumberingRuleId] = useState<string | null>(null);
const [previewCode, setPreviewCode] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
// moldImagePreview는 상세 표시용으로 유지 (ImageUpload는 모달에서만 사용)
const [moldImagePreview, setMoldImagePreview] = useState<string | null>(null);
const moldImageRef = React.useRef<HTMLInputElement>(null);
const [serialModalOpen, setSerialModalOpen] = useState(false);
const [serialForm, setSerialForm] = useState<Record<string, any>>({});
@@ -240,17 +241,7 @@ export default function MoldInfoPage() {
setMoldModalOpen(true);
};
const handleMoldImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onloadend = () => {
const result = reader.result as string;
setMoldImagePreview(result);
setMoldForm((prev) => ({ ...prev, image_path: result }));
};
reader.readAsDataURL(file);
};
// 이미지 업로드는 ImageUpload 컴포넌트가 처리 → objid를 image_path에 저장
const handleSaveMold = async () => {
// 등록 모드에서 채번이 없으면 수동 입력 필수
@@ -460,9 +451,10 @@ export default function MoldInfoPage() {
)}>
{mold.image_path ? (
<img
src={mold.image_path}
src={String(mold.image_path).startsWith("http") || String(mold.image_path).startsWith("/") ? mold.image_path : `/api/files/preview/${mold.image_path}`}
alt={mold.mold_name}
className="w-full h-full object-cover"
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
) : (
<Box className="w-8 h-8 text-muted-foreground/50" />
@@ -662,9 +654,10 @@ export default function MoldInfoPage() {
<div className="w-[200px] min-w-[200px] h-[200px] bg-muted rounded-lg flex items-center justify-center border">
{selectedMold.image_path ? (
<img
src={selectedMold.image_path}
src={String(selectedMold.image_path).startsWith("http") || String(selectedMold.image_path).startsWith("/") ? selectedMold.image_path : `/api/files/preview/${selectedMold.image_path}`}
alt={selectedMold.mold_name}
className="w-full h-full object-cover rounded-lg"
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
) : (
<ImageIcon className="w-16 h-16 text-muted-foreground/40" />
@@ -1143,39 +1136,13 @@ export default function MoldInfoPage() {
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs"> </Label>
<div className="relative w-full h-32 rounded-lg border-2 border-dashed border-border bg-muted/50 flex items-center justify-center overflow-hidden group">
{moldImagePreview ? (
<>
<img src={moldImagePreview} alt="금형 이미지" className="w-full h-full object-contain" />
<div className="absolute inset-0 bg-foreground/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
<Button size="sm" variant="secondary" className="h-7 text-xs" type="button" onClick={() => moldImageRef.current?.click()}>
<Upload className="w-3 h-3 mr-1" />
</Button>
<Button size="sm" variant="destructive" className="h-7 text-xs" type="button" onClick={() => {
setMoldImagePreview(null);
setMoldForm((prev) => ({ ...prev, image_path: null }));
}}>
<XIcon className="w-3 h-3" />
</Button>
</div>
</>
) : (
<button
type="button"
className="flex flex-col items-center gap-1.5 text-muted-foreground"
onClick={() => moldImageRef.current?.click()}
>
<ImageIcon className="w-8 h-8" />
<span className="text-xs"> </span>
</button>
)}
</div>
<input
ref={moldImageRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleMoldImageUpload}
<ImageUpload
value={moldForm.image_path || ""}
onChange={(v) => setMoldForm((prev) => ({ ...prev, image_path: v }))}
tableName="mold_mng"
recordId={moldForm.id || ""}
columnName="image_path"
height="h-32"
/>
</div>
</div>
@@ -142,7 +142,7 @@ export default function SubcontractorItemPage() {
try {
const filters: any[] = [];
if (outsourcingDivisionCode) {
filters.push({ columnName: "division", operator: "equals", value: outsourcingDivisionCode });
filters.push({ columnName: "division", operator: "contains", value: outsourcingDivisionCode });
}
if (searchKeyword) {
filters.push({ columnName: "item_name", operator: "contains", value: searchKeyword });

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