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