Merge remote-tracking branch 'origin/jskim-node' into gbpark-node
This commit is contained in:
@@ -158,12 +158,14 @@ import workInstructionRoutes from "./routes/workInstructionRoutes"; // 작업지
|
||||
import cuttingPlanRoutes from "./routes/cuttingPlanRoutes"; // 절단계획 관리
|
||||
import salesReportRoutes from "./routes/salesReportRoutes"; // 영업 리포트
|
||||
import reportPresetRoutes from "./routes/reportPresetRoutes"; // 리포트 프리셋 저장 (회사별/리포트별)
|
||||
import reportCellValueRoutes from "./routes/reportCellValueRoutes"; // 리포트 셀 커스텀 입력값 (input 셀)
|
||||
import analyticsReportRoutes from "./routes/analyticsReportRoutes"; // 분석 리포트 (생산/재고/구매/품질/설비/금형)
|
||||
import systemNoticeRoutes from "./routes/systemNoticeRoutes"; // 시스템 공지
|
||||
import designRoutes from "./routes/designRoutes"; // 설계 모듈 (DR/ECR/프로젝트/ECN)
|
||||
import materialStatusRoutes from "./routes/materialStatusRoutes"; // 자재현황
|
||||
import receivingRoutes from "./routes/receivingRoutes"; // 입고관리
|
||||
import outboundRoutes from "./routes/outboundRoutes"; // 출고관리
|
||||
import outsourcingOutboundRoutes from "./routes/outsourcingOutboundRoutes"; // 외주출고
|
||||
import processInfoRoutes from "./routes/processInfoRoutes"; // 공정정보관리
|
||||
import quoteRoutes from "./routes/quoteRoutes"; // 견적관리
|
||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||
@@ -383,11 +385,13 @@ app.use("/api/work-instruction", workInstructionRoutes); // 작업지시 관리
|
||||
app.use("/api/cutting-plan", cuttingPlanRoutes); // 절단계획 관리
|
||||
app.use("/api/sales-report", salesReportRoutes); // 영업 리포트
|
||||
app.use("/api/report-presets", reportPresetRoutes); // 리포트 프리셋 (회사별/리포트별 저장)
|
||||
app.use("/api/report-cell-values", reportCellValueRoutes); // 리포트 셀 커스텀 입력값
|
||||
app.use("/api/system-notice", systemNoticeRoutes); // 시스템 공지
|
||||
app.use("/api/report", analyticsReportRoutes); // 분석 리포트 (생산/재고/구매/품질/설비/금형)
|
||||
app.use("/api/design", designRoutes); // 설계 모듈
|
||||
app.use("/api/receiving", receivingRoutes); // 입고관리
|
||||
app.use("/api/outbound", outboundRoutes); // 출고관리
|
||||
app.use("/api/outsourcing-outbound", outsourcingOutboundRoutes); // 외주출고
|
||||
app.use("/api/quotes", quoteRoutes); // 견적관리
|
||||
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
|
||||
app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트)
|
||||
|
||||
@@ -2142,6 +2142,35 @@ export const getDepartmentList = async (
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/admin/users/name-map
|
||||
* 사용자 ID → 이름 매핑만 반환하는 경량 엔드포인트
|
||||
* 목적: 이력(writer/created_by 등)에 찍힌 user_id를 이름으로 표시하기 위함
|
||||
* 보안: 민감 정보(전화번호/이메일 등) 미포함, 인증된 사용자면 누구나 조회
|
||||
* 회사 필터 없음 — 최고 관리자 계정(company_code='*')도 포함
|
||||
*/
|
||||
export const getUserNameMap = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const rows = await query(
|
||||
`SELECT user_id, user_name FROM user_info WHERE user_id IS NOT NULL`,
|
||||
[]
|
||||
);
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: rows.map((r: any) => ({
|
||||
user_id: r.user_id,
|
||||
user_name: r.user_name,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("사용자 이름 맵 조회 실패", { error });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "사용자 이름 맵 조회 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/admin/users/:userId
|
||||
* 사용자 상세 조회 API
|
||||
|
||||
@@ -56,45 +56,75 @@ export async function getProductionReportData(req: any, res: Response): Promise<
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
const cf = buildCompanyFilter(companyCode, "wi", idx);
|
||||
const cf = buildCompanyFilter(companyCode, "wop", idx);
|
||||
if (cf.condition) { conditions.push(cf.condition); params.push(...cf.params); idx = cf.nextIdx; }
|
||||
|
||||
const df = buildDateFilter(startDate, endDate, "COALESCE(wi.start_date, wi.created_date::date::text)", idx);
|
||||
const dateExpr = "COALESCE(NULLIF(wop.started_at, ''), wop.created_date::date::text)";
|
||||
const df = buildDateFilter(startDate, endDate, dateExpr, idx);
|
||||
conditions.push(...df.conditions); params.push(...df.params); idx = df.nextIdx;
|
||||
|
||||
const whereClause = buildWhereClause(conditions);
|
||||
|
||||
// 실제 공정별 생산 데이터는 work_order_process에 있음
|
||||
// (work_instruction.routing은 routing_version_id UUID일 뿐이라 공정명이 아님)
|
||||
const dataQuery = `
|
||||
SELECT
|
||||
COALESCE(wi.start_date, wi.created_date::date::text) as date,
|
||||
COALESCE(wi.routing, '미지정') as process,
|
||||
COALESCE(ei.equipment_name, wi.equipment_id, '미지정') as equipment,
|
||||
COALESCE(ii.item_name, wi.item_id, '미지정') as item,
|
||||
COALESCE(wi.worker, '미지정') as worker,
|
||||
CAST(COALESCE(NULLIF(wi.qty, ''), '0') AS numeric) as "planQty",
|
||||
COALESCE(pr.production_qty, 0) as "prodQty",
|
||||
COALESCE(pr.defect_qty, 0) as "defectQty",
|
||||
0 as "runTime",
|
||||
0 as "downTime",
|
||||
wi.status,
|
||||
wi.company_code
|
||||
FROM work_instruction wi
|
||||
LEFT JOIN (
|
||||
SELECT wo_id, company_code,
|
||||
SUM(CAST(COALESCE(NULLIF(production_qty, ''), '0') AS numeric)) as production_qty,
|
||||
SUM(CAST(COALESCE(NULLIF(defect_qty, ''), '0') AS numeric)) as defect_qty
|
||||
FROM production_record GROUP BY wo_id, company_code
|
||||
) pr ON wi.id = pr.wo_id AND wi.company_code = pr.company_code
|
||||
LEFT JOIN (
|
||||
SELECT DISTINCT ON (equipment_code, company_code)
|
||||
equipment_code, equipment_name, equipment_type, company_code
|
||||
FROM equipment_info ORDER BY equipment_code, company_code, created_date DESC
|
||||
) ei ON wi.equipment_id = ei.equipment_code AND wi.company_code = ei.company_code
|
||||
LEFT JOIN (
|
||||
SELECT DISTINCT ON (item_number, company_code)
|
||||
item_number, item_name, company_code
|
||||
FROM item_info ORDER BY item_number, company_code, created_date DESC
|
||||
) ii ON wi.item_id = ii.item_number AND wi.company_code = ii.company_code
|
||||
COALESCE(NULLIF(wop.started_at, ''), wop.created_date::date::text) as date,
|
||||
COALESCE(NULLIF(wop.process_name, ''), NULLIF(wop.process_code, ''), '미지정') as process,
|
||||
COALESCE(NULLIF(em.equipment_name, ''), NULLIF(em.equipment_code, ''), '미지정') as equipment,
|
||||
COALESCE(NULLIF(ii.item_name, ''), NULLIF(ii.item_number, ''), '미지정') as item,
|
||||
COALESCE(NULLIF(wi.worker, ''), '미지정') as worker,
|
||||
CAST(COALESCE(NULLIF(wop.plan_qty, ''), '0') AS numeric) as "planQty",
|
||||
CAST(COALESCE(NULLIF(wop.good_qty, ''), '0') AS numeric) as "prodQty",
|
||||
CAST(COALESCE(NULLIF(wop.defect_qty, ''), '0') AS numeric) as "defectQty",
|
||||
CASE
|
||||
WHEN NULLIF(wop.started_at, '') IS NOT NULL
|
||||
AND NULLIF(wop.completed_at, '') IS NOT NULL
|
||||
THEN GREATEST(
|
||||
EXTRACT(EPOCH FROM (wop.completed_at::timestamp - wop.started_at::timestamp)) / 3600.0,
|
||||
0
|
||||
)
|
||||
ELSE 0
|
||||
END as "runTime",
|
||||
CAST(COALESCE(NULLIF(wop.total_paused_time, ''), '0') AS numeric) / 3600.0 as "downTime",
|
||||
wop.status,
|
||||
wop.company_code
|
||||
FROM work_order_process wop
|
||||
LEFT JOIN work_instruction wi
|
||||
ON wop.wo_id = wi.id AND wop.company_code = wi.company_code
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT equipment_code, equipment_name
|
||||
FROM equipment_mng
|
||||
WHERE company_code = wi.company_code
|
||||
AND (id = wi.equipment_id OR equipment_code = wi.equipment_id
|
||||
OR id = wop.equipment_code OR equipment_code = wop.equipment_code)
|
||||
ORDER BY (id = wi.equipment_id OR id = wop.equipment_code) DESC, created_date DESC
|
||||
LIMIT 1
|
||||
) em ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT ii_inner.item_number, ii_inner.item_name
|
||||
FROM item_info ii_inner
|
||||
WHERE ii_inner.company_code = wi.company_code
|
||||
AND (
|
||||
(NULLIF(wi.item_id, '') IS NOT NULL
|
||||
AND (ii_inner.id = wi.item_id OR ii_inner.item_number = wi.item_id))
|
||||
OR ii_inner.item_number = (
|
||||
SELECT wid.item_number
|
||||
FROM work_instruction_detail wid
|
||||
WHERE wid.work_instruction_id = wi.id
|
||||
AND wid.company_code = wi.company_code
|
||||
AND NULLIF(wid.item_number, '') IS NOT NULL
|
||||
ORDER BY wid.created_date ASC
|
||||
LIMIT 1
|
||||
)
|
||||
)
|
||||
ORDER BY
|
||||
CASE WHEN ii_inner.id = wi.item_id THEN 1
|
||||
WHEN ii_inner.item_number = wi.item_id THEN 2
|
||||
ELSE 3 END,
|
||||
ii_inner.created_date DESC
|
||||
LIMIT 1
|
||||
) ii ON true
|
||||
${whereClause}
|
||||
ORDER BY date DESC NULLS LAST
|
||||
`;
|
||||
|
||||
@@ -174,6 +174,7 @@ export async function getMaterialStatus(
|
||||
ii.item_name AS material_name,
|
||||
ii.item_number AS material_code,
|
||||
ii.unit AS material_unit,
|
||||
ii.inventory_unit AS material_inventory_unit,
|
||||
COALESCE(ii.width::text, '') AS material_width,
|
||||
COALESCE(ii.height::text, '') AS material_height,
|
||||
COALESCE(ii.thickness::text, '') AS material_thickness
|
||||
@@ -220,7 +221,11 @@ export async function getMaterialStatus(
|
||||
materialCode:
|
||||
bomRow.material_code || bomRow.child_item_id,
|
||||
materialName: bomRow.material_name || "알 수 없음",
|
||||
unit: bomRow.bom_unit || bomRow.material_unit || "EA",
|
||||
unit:
|
||||
bomRow.material_inventory_unit ||
|
||||
bomRow.bom_unit ||
|
||||
bomRow.material_unit ||
|
||||
"EA",
|
||||
requiredQty,
|
||||
width: bomRow.material_width || "",
|
||||
height: bomRow.material_height || "",
|
||||
@@ -260,12 +265,16 @@ export async function getMaterialStatus(
|
||||
}
|
||||
|
||||
const stockQuery = `
|
||||
SELECT
|
||||
SELECT
|
||||
s.item_code,
|
||||
s.warehouse_code,
|
||||
w.warehouse_name,
|
||||
s.location_code,
|
||||
COALESCE(CAST(s.current_qty AS NUMERIC), 0) AS current_qty
|
||||
FROM inventory_stock s
|
||||
LEFT JOIN warehouse_info w
|
||||
ON w.warehouse_code = s.warehouse_code
|
||||
AND w.company_code = s.company_code
|
||||
WHERE ${stockConditions.join(" AND ")}
|
||||
AND COALESCE(CAST(s.current_qty AS NUMERIC), 0) > 0
|
||||
ORDER BY s.item_code, s.warehouse_code, s.location_code
|
||||
@@ -277,7 +286,7 @@ export async function getMaterialStatus(
|
||||
// item_code 기준 재고 맵핑 (inventory_stock.item_code는 item_info.item_number 또는 item_info.id일 수 있음)
|
||||
const stockByItem: Record<
|
||||
string,
|
||||
{ location: string; warehouse: string; qty: number }[]
|
||||
{ location: string; warehouse: string; warehouse_name: string; qty: number }[]
|
||||
> = {};
|
||||
|
||||
for (const stockRow of stockResult.rows) {
|
||||
@@ -288,6 +297,7 @@ export async function getMaterialStatus(
|
||||
stockByItem[code].push({
|
||||
location: stockRow.location_code || "",
|
||||
warehouse: stockRow.warehouse_code || "",
|
||||
warehouse_name: stockRow.warehouse_name || "",
|
||||
qty: Number(stockRow.current_qty),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -512,13 +512,36 @@ export async function getMoldSerialSummary(req: AuthenticatedRequest, res: Respo
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { moldCode } = req.params;
|
||||
|
||||
// 카테고리 코드/영문코드/한글라벨 모두 대응
|
||||
// 먼저 카테고리 값 조회하여 매핑
|
||||
// mold_serial.status + mold_mng.operation_status 양쪽 카테고리 모두 조회
|
||||
const catSql = `SELECT value_code, value_label FROM category_values
|
||||
WHERE ((table_name='mold_serial' AND column_name='status') OR (table_name='mold_mng' AND column_name='operation_status'))
|
||||
AND company_code=$1`;
|
||||
const catRows = await query(catSql, [companyCode]);
|
||||
|
||||
// 카테고리 라벨 기준으로 그룹핑할 코드 목록 생성
|
||||
const codesByLabel: Record<string, string[]> = { "사용중": ["IN_USE"], "수리중": ["REPAIR"], "보관중": ["STORED"], "폐기": ["DISPOSED"] };
|
||||
for (const cat of catRows) {
|
||||
const label = cat.value_label || "";
|
||||
if (label.includes("사용")) (codesByLabel["사용중"] = codesByLabel["사용중"] || []).push(cat.value_code);
|
||||
else if (label.includes("수리")) (codesByLabel["수리중"] = codesByLabel["수리중"] || []).push(cat.value_code);
|
||||
else if (label.includes("보관") || label.includes("미사용")) (codesByLabel["보관중"] = codesByLabel["보관중"] || []).push(cat.value_code);
|
||||
else if (label.includes("폐기")) (codesByLabel["폐기"] = codesByLabel["폐기"] || []).push(cat.value_code);
|
||||
}
|
||||
|
||||
const inUseCodes = codesByLabel["사용중"].map(c => `'${c}'`).join(",");
|
||||
const repairCodes = codesByLabel["수리중"].map(c => `'${c}'`).join(",");
|
||||
const storedCodes = codesByLabel["보관중"].map(c => `'${c}'`).join(",");
|
||||
const disposedCodes = codesByLabel["폐기"].map(c => `'${c}'`).join(",");
|
||||
|
||||
const sql = `
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
COUNT(*) FILTER (WHERE status = 'IN_USE') as in_use,
|
||||
COUNT(*) FILTER (WHERE status = 'REPAIR') as repair,
|
||||
COUNT(*) FILTER (WHERE status = 'STORED') as stored,
|
||||
COUNT(*) FILTER (WHERE status = 'DISPOSED') as disposed
|
||||
COUNT(*) FILTER (WHERE status IN (${inUseCodes})) as in_use,
|
||||
COUNT(*) FILTER (WHERE status IN (${repairCodes})) as repair,
|
||||
COUNT(*) FILTER (WHERE status IN (${storedCodes})) as stored,
|
||||
COUNT(*) FILTER (WHERE status IN (${disposedCodes})) as disposed
|
||||
FROM mold_serial
|
||||
WHERE mold_code = $1 AND company_code = $2
|
||||
`;
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import type { Response } from "express";
|
||||
import { getPool } from "../database/db";
|
||||
import type { AuthenticatedRequest } from "../types/auth";
|
||||
import { adjustInventory } from "../utils/inventoryUtils";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
// 출고 목록 조회
|
||||
@@ -324,6 +325,9 @@ export async function create(req: AuthenticatedRequest, res: Response) {
|
||||
|
||||
// 출고 수정
|
||||
export async function update(req: AuthenticatedRequest, res: Response) {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
@@ -341,8 +345,90 @@ export async function update(req: AuthenticatedRequest, res: Response) {
|
||||
memo,
|
||||
} = req.body;
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(
|
||||
await client.query("BEGIN");
|
||||
|
||||
// 변경 전 값 조회
|
||||
const oldRes = await client.query(
|
||||
`SELECT * FROM outbound_mng WHERE id = $1 AND company_code = $2`,
|
||||
[id, companyCode],
|
||||
);
|
||||
if (oldRes.rowCount === 0) {
|
||||
await client.query("ROLLBACK");
|
||||
return res
|
||||
.status(404)
|
||||
.json({ success: false, message: "출고 데이터를 찾을 수 없습니다." });
|
||||
}
|
||||
const old = oldRes.rows[0];
|
||||
const oldQty = Number(old.outbound_qty) || 0;
|
||||
const oldWhCode = old.warehouse_code || null;
|
||||
const oldLocCode = old.location_code || null;
|
||||
const itemCode = old.item_code || old.item_number || null;
|
||||
const outboundNumber = old.outbound_number;
|
||||
|
||||
const newQty =
|
||||
outbound_qty !== undefined && outbound_qty !== null
|
||||
? Number(outbound_qty)
|
||||
: oldQty;
|
||||
const newWhCode =
|
||||
warehouse_code !== undefined ? warehouse_code : oldWhCode;
|
||||
const newLocCode =
|
||||
location_code !== undefined ? location_code : oldLocCode;
|
||||
|
||||
// 재고/이력 반영 (append-only): 수량 또는 창고/위치 변경 시
|
||||
const qtyChanged = newQty !== oldQty;
|
||||
const whChanged =
|
||||
(newWhCode || "") !== (oldWhCode || "") ||
|
||||
(newLocCode || "") !== (oldLocCode || "");
|
||||
|
||||
if (itemCode && (qtyChanged || whChanged)) {
|
||||
if (whChanged) {
|
||||
// 기존 창고 복구
|
||||
if (oldQty > 0) {
|
||||
await adjustInventory(client, {
|
||||
companyCode,
|
||||
userId,
|
||||
itemCode,
|
||||
whCode: oldWhCode,
|
||||
locCode: oldLocCode,
|
||||
delta: +oldQty,
|
||||
transactionType: "출고취소",
|
||||
remark: `출고수정-창고변경 (${outboundNumber}) ${oldWhCode || ""}→${newWhCode || ""}`,
|
||||
});
|
||||
}
|
||||
// 신규 창고 차감 (재고부족 검증)
|
||||
if (newQty > 0) {
|
||||
await adjustInventory(client, {
|
||||
companyCode,
|
||||
userId,
|
||||
itemCode,
|
||||
whCode: newWhCode,
|
||||
locCode: newLocCode,
|
||||
delta: -newQty,
|
||||
transactionType: "출고수정",
|
||||
remark: `출고수정-창고변경 (${outboundNumber}) ${oldWhCode || ""}→${newWhCode || ""}, 수량 ${oldQty}→${newQty}`,
|
||||
validateStockEnough: true,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// 창고 동일, 수량만 변경: 기존 복구(+oldQty) + 신규 차감(-newQty) = delta(+복구/-추가차감)
|
||||
const delta = oldQty - newQty;
|
||||
if (delta !== 0) {
|
||||
await adjustInventory(client, {
|
||||
companyCode,
|
||||
userId,
|
||||
itemCode,
|
||||
whCode: newWhCode,
|
||||
locCode: newLocCode,
|
||||
delta,
|
||||
transactionType: "출고수정",
|
||||
remark: `출고수정 (${outboundNumber}) 수량 ${oldQty}→${newQty}`,
|
||||
validateStockEnough: delta < 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = await client.query(
|
||||
`UPDATE outbound_mng SET
|
||||
outbound_date = COALESCE($1, outbound_date),
|
||||
outbound_qty = COALESCE($2, outbound_qty),
|
||||
@@ -375,45 +461,95 @@ export async function update(req: AuthenticatedRequest, res: Response) {
|
||||
],
|
||||
);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ success: false, message: "출고 데이터를 찾을 수 없습니다." });
|
||||
}
|
||||
await client.query("COMMIT");
|
||||
|
||||
logger.info("출고 수정", { companyCode, userId, id });
|
||||
logger.info("출고 수정", {
|
||||
companyCode,
|
||||
userId,
|
||||
id,
|
||||
oldQty,
|
||||
newQty,
|
||||
oldWhCode,
|
||||
newWhCode,
|
||||
});
|
||||
|
||||
return res.json({ success: true, data: result.rows[0] });
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
logger.error("출고 수정 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// 출고 삭제
|
||||
// 출고 삭제 (재고 복구 + '출고취소' 이력 기록 포함)
|
||||
export async function deleteOutbound(req: AuthenticatedRequest, res: Response) {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const { id } = req.params;
|
||||
const pool = getPool();
|
||||
|
||||
const result = await pool.query(
|
||||
`DELETE FROM outbound_mng WHERE id = $1 AND company_code = $2 RETURNING id`,
|
||||
await client.query("BEGIN");
|
||||
|
||||
// 대상 출고 조회
|
||||
const oldRes = await client.query(
|
||||
`SELECT * FROM outbound_mng WHERE id = $1 AND company_code = $2`,
|
||||
[id, companyCode],
|
||||
);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
if (oldRes.rowCount === 0) {
|
||||
await client.query("ROLLBACK");
|
||||
return res
|
||||
.status(404)
|
||||
.json({ success: false, message: "데이터를 찾을 수 없습니다." });
|
||||
}
|
||||
const old = oldRes.rows[0];
|
||||
const itemCode = old.item_code || old.item_number || null;
|
||||
const whCode = old.warehouse_code || null;
|
||||
const locCode = old.location_code || null;
|
||||
const qty = Number(old.outbound_qty) || 0;
|
||||
const outboundNumber = old.outbound_number;
|
||||
|
||||
logger.info("출고 삭제", { companyCode, id });
|
||||
// 재고 복구 + 이력
|
||||
if (itemCode && qty > 0) {
|
||||
await adjustInventory(client, {
|
||||
companyCode,
|
||||
userId,
|
||||
itemCode,
|
||||
whCode,
|
||||
locCode,
|
||||
delta: +qty,
|
||||
transactionType: "출고취소",
|
||||
remark: `출고 삭제 (${outboundNumber})`,
|
||||
});
|
||||
} else {
|
||||
logger.warn("출고 삭제 - 재고 복구 스킵", {
|
||||
companyCode,
|
||||
id,
|
||||
itemCode,
|
||||
qty,
|
||||
});
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`DELETE FROM outbound_mng WHERE id = $1 AND company_code = $2`,
|
||||
[id, companyCode],
|
||||
);
|
||||
|
||||
await client.query("COMMIT");
|
||||
|
||||
logger.info("출고 삭제", { companyCode, userId, id, itemCode, qty });
|
||||
|
||||
return res.json({ success: true, message: "삭제 완료" });
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
logger.error("출고 삭제 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,473 @@
|
||||
/**
|
||||
* 외주출고 컨트롤러
|
||||
*
|
||||
* 이전 공정이 완료되고 다음 공정이 외주 공정이면
|
||||
* 자동으로 외주출고 대상 목록에 표시 → 출고 처리
|
||||
*
|
||||
* 출고 데이터는 기존 outbound_mng 테이블 재사용
|
||||
* (outbound_type='외주출고', source_type='work_order_process')
|
||||
*/
|
||||
|
||||
import type { Response } from "express";
|
||||
import { getPool } from "../database/db";
|
||||
import type { AuthenticatedRequest } from "../types/auth";
|
||||
import { adjustInventory } from "../utils/inventoryUtils";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
/**
|
||||
* 외주출고 대상 자동 조회
|
||||
* GET /api/outsourcing-outbound/candidates
|
||||
*
|
||||
* 이전 공정 완료 + 다음 공정이 외주 공정인 건 자동 표시
|
||||
*/
|
||||
export async function getCandidates(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { keyword } = req.query;
|
||||
|
||||
const pool = getPool();
|
||||
|
||||
let keywordCondition = "";
|
||||
const params: any[] = [];
|
||||
let paramIdx = 1;
|
||||
|
||||
if (companyCode !== "*") {
|
||||
params.push(companyCode);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
if (keyword) {
|
||||
keywordCondition = `AND (
|
||||
wi.instruction_no ILIKE $${paramIdx}
|
||||
OR wi.item_name ILIKE $${paramIdx}
|
||||
OR wi.item_code ILIKE $${paramIdx}
|
||||
OR sm.subcontractor_name ILIKE $${paramIdx}
|
||||
)`;
|
||||
params.push(`%${keyword}%`);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
const companyFilter = companyCode !== "*"
|
||||
? `wop_done.company_code = $1`
|
||||
: `1=1`;
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
wop_done.id AS completed_process_id,
|
||||
wop_done.wo_id,
|
||||
wop_done.seq_no AS completed_seq_no,
|
||||
wop_done.process_code AS completed_process_code,
|
||||
COALESCE(pm_done.process_name, wop_done.process_name, wop_done.process_code) AS completed_process_name,
|
||||
COALESCE(CAST(NULLIF(wop_done.good_qty, '') AS numeric), 0) AS good_qty,
|
||||
wop_next.id AS next_process_id,
|
||||
wop_next.seq_no AS next_seq_no,
|
||||
wop_next.process_code AS next_process_code,
|
||||
COALESCE(pm_next.process_name, wop_next.process_name, wop_next.process_code) AS next_process_name,
|
||||
wop_next.status AS next_status,
|
||||
wi.instruction_no,
|
||||
wi.item_code,
|
||||
wi.item_name,
|
||||
ii.size AS spec,
|
||||
ii.material,
|
||||
ii.inventory_unit AS unit,
|
||||
sm.id AS subcontractor_id,
|
||||
sm.subcontractor_code,
|
||||
sm.subcontractor_name
|
||||
FROM work_order_process wop_done
|
||||
INNER JOIN work_instruction wi
|
||||
ON wop_done.wo_id = wi.id
|
||||
AND wop_done.company_code = wi.company_code
|
||||
-- 다음 공정 (바로 다음 seq_no)
|
||||
INNER JOIN LATERAL (
|
||||
SELECT wop2.*
|
||||
FROM work_order_process wop2
|
||||
WHERE wop2.wo_id = wop_done.wo_id
|
||||
AND wop2.company_code = wop_done.company_code
|
||||
AND wop2.parent_process_id IS NULL
|
||||
AND CAST(wop2.seq_no AS int) > CAST(wop_done.seq_no AS int)
|
||||
ORDER BY CAST(wop2.seq_no AS int)
|
||||
LIMIT 1
|
||||
) wop_next ON TRUE
|
||||
-- 다음 공정이 외주인지 확인
|
||||
INNER JOIN item_routing_subcontractor irs
|
||||
ON irs.routing_detail_id = wop_next.routing_detail_id
|
||||
INNER JOIN subcontractor_mng sm
|
||||
ON irs.subcontractor_id = sm.id
|
||||
LEFT JOIN item_info ii
|
||||
ON wi.item_code = ii.item_number AND wi.company_code = ii.company_code
|
||||
LEFT JOIN process_mng pm_done
|
||||
ON wop_done.process_code = pm_done.process_code AND wop_done.company_code = pm_done.company_code
|
||||
LEFT JOIN process_mng pm_next
|
||||
ON wop_next.process_code = pm_next.process_code AND wop_next.company_code = pm_next.company_code
|
||||
WHERE ${companyFilter}
|
||||
AND wop_done.parent_process_id IS NULL
|
||||
AND wop_done.status IN ('completed', 'acceptable')
|
||||
AND COALESCE(CAST(NULLIF(wop_done.good_qty, '') AS numeric), 0) > 0
|
||||
-- 아직 외주출고 등록 안 된 건만
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM outbound_mng om
|
||||
WHERE om.outbound_type = '외주출고'
|
||||
AND om.source_type = 'work_order_process'
|
||||
AND om.source_id = wop_done.id
|
||||
${companyCode !== "*" ? "AND om.company_code = $1" : ""}
|
||||
)
|
||||
${keywordCondition}
|
||||
ORDER BY wi.instruction_no, CAST(wop_done.seq_no AS int)
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
logger.info("외주출고 대상 조회", { companyCode, count: result.rowCount });
|
||||
return res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("외주출고 대상 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 외주출고 목록 조회
|
||||
* GET /api/outsourcing-outbound/list
|
||||
*/
|
||||
export async function getList(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { outbound_status, search_keyword, date_from, date_to } = req.query;
|
||||
|
||||
const conditions: string[] = ["om.outbound_type = '외주출고'"];
|
||||
const params: any[] = [];
|
||||
let paramIdx = 1;
|
||||
|
||||
if (companyCode !== "*") {
|
||||
conditions.push(`om.company_code = $${paramIdx}`);
|
||||
params.push(companyCode);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
if (outbound_status && outbound_status !== "all") {
|
||||
conditions.push(`om.outbound_status = $${paramIdx}`);
|
||||
params.push(outbound_status);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
if (search_keyword) {
|
||||
conditions.push(`(
|
||||
om.outbound_number ILIKE $${paramIdx}
|
||||
OR om.item_name ILIKE $${paramIdx}
|
||||
OR om.item_code ILIKE $${paramIdx}
|
||||
OR om.customer_name ILIKE $${paramIdx}
|
||||
OR om.reference_number ILIKE $${paramIdx}
|
||||
)`);
|
||||
params.push(`%${search_keyword}%`);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
if (date_from) {
|
||||
conditions.push(`om.outbound_date >= $${paramIdx}`);
|
||||
params.push(date_from);
|
||||
paramIdx++;
|
||||
}
|
||||
if (date_to) {
|
||||
conditions.push(`om.outbound_date <= $${paramIdx}`);
|
||||
params.push(date_to);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
const whereClause = `WHERE ${conditions.join(" AND ")}`;
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(
|
||||
`SELECT om.*, wh.warehouse_name
|
||||
FROM outbound_mng om
|
||||
LEFT JOIN warehouse_info wh ON om.warehouse_code = wh.warehouse_code AND om.company_code = wh.company_code
|
||||
${whereClause}
|
||||
ORDER BY om.created_date DESC`,
|
||||
params,
|
||||
);
|
||||
|
||||
logger.info("외주출고 목록 조회", { companyCode, count: result.rowCount });
|
||||
return res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("외주출고 목록 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 외주출고 등록
|
||||
* POST /api/outsourcing-outbound
|
||||
*/
|
||||
export async function create(req: AuthenticatedRequest, res: Response) {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const {
|
||||
items,
|
||||
outbound_number,
|
||||
outbound_date,
|
||||
warehouse_code,
|
||||
location_code,
|
||||
manager_id,
|
||||
memo,
|
||||
} = req.body;
|
||||
|
||||
if (!items || !Array.isArray(items) || items.length === 0) {
|
||||
return res.status(400).json({ success: false, message: "출고 품목이 없습니다." });
|
||||
}
|
||||
|
||||
await client.query("BEGIN");
|
||||
|
||||
const insertedRows: any[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
const result = await client.query(
|
||||
`INSERT INTO outbound_mng (
|
||||
id, company_code, outbound_number, outbound_type, outbound_date,
|
||||
reference_number, customer_code, customer_name,
|
||||
item_code, item_name, specification, material, unit,
|
||||
outbound_qty, unit_price, total_amount,
|
||||
warehouse_code, location_code,
|
||||
outbound_status, manager_id, memo,
|
||||
source_type, source_id,
|
||||
created_date, created_by, writer, status
|
||||
) VALUES (
|
||||
gen_random_uuid()::text, $1, $2, '외주출고', $3,
|
||||
$4, $5, $6,
|
||||
$7, $8, $9, $10, $11,
|
||||
$12, 0, 0,
|
||||
$13, $14,
|
||||
'출고완료', $15, $16,
|
||||
'work_order_process', $17,
|
||||
NOW(), $18, $18, '출고'
|
||||
) RETURNING *`,
|
||||
[
|
||||
companyCode,
|
||||
outbound_number || item.outbound_number,
|
||||
outbound_date || item.outbound_date,
|
||||
item.reference_number || null, // 작업지시번호
|
||||
item.subcontractor_code || null, // 외주사코드 → customer_code
|
||||
item.subcontractor_name || null, // 외주사명 → customer_name
|
||||
item.item_code || null,
|
||||
item.item_name || null,
|
||||
item.spec || null,
|
||||
item.material || null,
|
||||
item.unit || null,
|
||||
item.outbound_qty || 0,
|
||||
warehouse_code || item.warehouse_code || null,
|
||||
location_code || item.location_code || null,
|
||||
manager_id || item.manager_id || null,
|
||||
memo || item.memo || null,
|
||||
item.completed_process_id || null, // source_id = 완료된 공정 ID
|
||||
userId,
|
||||
],
|
||||
);
|
||||
|
||||
insertedRows.push(result.rows[0]);
|
||||
|
||||
// 재고 차감
|
||||
const itemCode = item.item_code || null;
|
||||
const whCode = warehouse_code || item.warehouse_code || null;
|
||||
const locCode = location_code || item.location_code || null;
|
||||
const outQty = Number(item.outbound_qty) || 0;
|
||||
|
||||
if (itemCode && outQty > 0 && whCode) {
|
||||
await adjustInventory(client, {
|
||||
companyCode,
|
||||
userId,
|
||||
itemCode,
|
||||
whCode,
|
||||
locCode,
|
||||
delta: -outQty,
|
||||
transactionType: "외주출고",
|
||||
remark: `외주출고 (${outbound_number || ""}) → ${item.subcontractor_name || ""}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
|
||||
logger.info("외주출고 등록 완료", {
|
||||
companyCode,
|
||||
userId,
|
||||
count: insertedRows.length,
|
||||
outbound_number,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: insertedRows,
|
||||
message: `${insertedRows.length}건 외주출고 등록 완료`,
|
||||
});
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
logger.error("외주출고 등록 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 외주출고 수정
|
||||
* PUT /api/outsourcing-outbound/:id
|
||||
*/
|
||||
export async function update(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const { id } = req.params;
|
||||
const { outbound_date, outbound_qty, warehouse_code, location_code, memo } = req.body;
|
||||
|
||||
const pool = getPool();
|
||||
const companyCondition = companyCode === "*" ? "" : `AND company_code = '${companyCode}'`;
|
||||
|
||||
const result = await pool.query(
|
||||
`UPDATE outbound_mng SET
|
||||
outbound_date = COALESCE($1::date, outbound_date),
|
||||
outbound_qty = COALESCE($2::numeric, outbound_qty),
|
||||
warehouse_code = COALESCE($3, warehouse_code),
|
||||
location_code = COALESCE($4, location_code),
|
||||
memo = COALESCE($5, memo),
|
||||
updated_date = NOW(),
|
||||
updated_by = $6
|
||||
WHERE id = $7 ${companyCondition}
|
||||
RETURNING *`,
|
||||
[outbound_date, outbound_qty, warehouse_code, location_code, memo, userId, id],
|
||||
);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: result.rows[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("외주출고 수정 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 외주출고 삭제
|
||||
* DELETE /api/outsourcing-outbound/:id
|
||||
*/
|
||||
export async function deleteOutbound(req: AuthenticatedRequest, res: Response) {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const { id } = req.params;
|
||||
|
||||
await client.query("BEGIN");
|
||||
|
||||
// 삭제 전 데이터 조회 (재고 복구용)
|
||||
const companyCondition = companyCode === "*" ? "" : `AND company_code = $2`;
|
||||
const queryParams = companyCode === "*" ? [id] : [id, companyCode];
|
||||
|
||||
const oldRes = await client.query(
|
||||
`SELECT * FROM outbound_mng WHERE id = $1 ${companyCondition}`,
|
||||
queryParams,
|
||||
);
|
||||
|
||||
if (oldRes.rowCount === 0) {
|
||||
await client.query("ROLLBACK");
|
||||
return res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
const old = oldRes.rows[0];
|
||||
const itemCode = old.item_code || null;
|
||||
const whCode = old.warehouse_code || null;
|
||||
const locCode = old.location_code || null;
|
||||
const oldQty = Number(old.outbound_qty) || 0;
|
||||
|
||||
// 재고 복구
|
||||
if (itemCode && oldQty > 0 && whCode) {
|
||||
await adjustInventory(client, {
|
||||
companyCode: old.company_code,
|
||||
userId,
|
||||
itemCode,
|
||||
whCode,
|
||||
locCode,
|
||||
delta: +oldQty,
|
||||
transactionType: "외주출고취소",
|
||||
remark: `외주출고 삭제 (${old.outbound_number || ""})`,
|
||||
});
|
||||
}
|
||||
|
||||
// 삭제
|
||||
await client.query(
|
||||
`DELETE FROM outbound_mng WHERE id = $1 ${companyCondition}`,
|
||||
queryParams,
|
||||
);
|
||||
|
||||
await client.query("COMMIT");
|
||||
|
||||
logger.info("외주출고 삭제", { companyCode, id });
|
||||
return res.json({ success: true, message: "삭제되었습니다." });
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
logger.error("외주출고 삭제 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 외주출고번호 자동생성
|
||||
* GET /api/outsourcing-outbound/generate-number
|
||||
*/
|
||||
export async function generateNumber(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const pool = getPool();
|
||||
const yyyy = new Date().getFullYear();
|
||||
const prefix = `OSOUT-${yyyy}-`;
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT outbound_number FROM outbound_mng
|
||||
WHERE company_code = $1 AND outbound_number LIKE $2
|
||||
ORDER BY outbound_number DESC LIMIT 1`,
|
||||
[companyCode, `${prefix}%`],
|
||||
);
|
||||
|
||||
let seq = 1;
|
||||
if (result.rows.length > 0) {
|
||||
const lastNo = result.rows[0].outbound_number;
|
||||
const lastSeq = parseInt(lastNo.replace(prefix, ""), 10);
|
||||
if (!isNaN(lastSeq)) seq = lastSeq + 1;
|
||||
}
|
||||
|
||||
const newNumber = `${prefix}${String(seq).padStart(4, "0")}`;
|
||||
return res.json({ success: true, data: newNumber });
|
||||
} catch (error: any) {
|
||||
logger.error("외주출고번호 생성 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 창고 목록 (outbound 컨트롤러와 공유)
|
||||
*/
|
||||
export async function getWarehouses(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const pool = getPool();
|
||||
|
||||
const condition = companyCode === "*" ? "" : `WHERE company_code = $1`;
|
||||
const params = companyCode === "*" ? [] : [companyCode];
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT warehouse_code, warehouse_name, warehouse_type FROM warehouse_info ${condition} ORDER BY warehouse_name`,
|
||||
params,
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
@@ -175,7 +175,7 @@ export async function getPkgUnitItems(
|
||||
const pool = getPool();
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT pui.*, ii.item_name, ii.size AS spec, ii.unit
|
||||
`SELECT pui.*, ii.item_name, ii.size AS spec, ii.unit, ii.inventory_unit, ii.material
|
||||
FROM pkg_unit_item pui
|
||||
LEFT JOIN item_info ii ON pui.item_number = ii.item_number AND pui.company_code = ii.company_code
|
||||
WHERE pui.pkg_code=$1 AND pui.company_code=$2
|
||||
@@ -228,10 +228,11 @@ export async function deletePkgUnitItem(
|
||||
const { id } = req.params;
|
||||
const pool = getPool();
|
||||
|
||||
const result = await pool.query(
|
||||
`DELETE FROM pkg_unit_item WHERE id=$1 AND company_code=$2 RETURNING id`,
|
||||
[id, companyCode]
|
||||
);
|
||||
const query = companyCode === "*"
|
||||
? `DELETE FROM pkg_unit_item WHERE id=$1 RETURNING id`
|
||||
: `DELETE FROM pkg_unit_item WHERE id=$1 AND company_code=$2 RETURNING id`;
|
||||
const params = companyCode === "*" ? [id] : [id, companyCode];
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." });
|
||||
@@ -471,10 +472,11 @@ export async function deleteLoadingUnitPkg(
|
||||
const { id } = req.params;
|
||||
const pool = getPool();
|
||||
|
||||
const result = await pool.query(
|
||||
`DELETE FROM loading_unit_pkg WHERE id=$1 AND company_code=$2 RETURNING id`,
|
||||
[id, companyCode]
|
||||
);
|
||||
const query = companyCode === "*"
|
||||
? `DELETE FROM loading_unit_pkg WHERE id=$1 RETURNING id`
|
||||
: `DELETE FROM loading_unit_pkg WHERE id=$1 AND company_code=$2 RETURNING id`;
|
||||
const params = companyCode === "*" ? [id] : [id, companyCode];
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." });
|
||||
@@ -530,7 +532,7 @@ export async function getItemsByDivision(
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT id, item_number, item_name, size, material, unit, division
|
||||
`SELECT id, item_number, item_name, size, material, unit, inventory_unit, division
|
||||
FROM item_info
|
||||
WHERE ${conditions.join(" AND ")}
|
||||
ORDER BY item_name`,
|
||||
@@ -583,7 +585,7 @@ export async function getGeneralItems(
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT id, item_number, item_name, size AS spec, material, unit, division
|
||||
`SELECT id, item_number, item_name, size AS spec, material, unit, inventory_unit, division
|
||||
FROM item_info
|
||||
WHERE ${conditions.join(" AND ")}
|
||||
ORDER BY item_name
|
||||
|
||||
@@ -154,10 +154,13 @@ export async function getProcessEquipments(req: AuthenticatedRequest, res: Respo
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { processCode } = req.params;
|
||||
|
||||
// equipment_code 컬럼에 코드(legacy) 또는 id(신규)가 들어올 수 있어 두 경우 모두 매칭
|
||||
const result = await pool.query(
|
||||
`SELECT pe.*, em.equipment_name
|
||||
FROM process_equipment pe
|
||||
LEFT JOIN equipment_mng em ON pe.equipment_code = em.equipment_code AND pe.company_code = em.company_code
|
||||
LEFT JOIN equipment_mng em
|
||||
ON pe.company_code = em.company_code
|
||||
AND (pe.equipment_code = em.equipment_code OR pe.equipment_code = em.id)
|
||||
WHERE pe.process_code = $1 AND pe.company_code = $2
|
||||
ORDER BY pe.equipment_code`,
|
||||
[processCode, companyCode]
|
||||
@@ -382,7 +385,38 @@ export async function getRoutingDetails(req: AuthenticatedRequest, res: Response
|
||||
[versionId, companyCode]
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: result.rows });
|
||||
const rows = result.rows;
|
||||
const detailIds = rows.map((r: any) => r.id).filter(Boolean);
|
||||
let idsByDetail: Record<string, string[]> = {};
|
||||
let codesByDetail: Record<string, string[]> = {};
|
||||
if (detailIds.length > 0) {
|
||||
const mapRes = await pool.query(
|
||||
`SELECT irs.routing_detail_id, irs.subcontractor_id, sm.subcontractor_code
|
||||
FROM item_routing_subcontractor irs
|
||||
LEFT JOIN subcontractor_mng sm ON irs.subcontractor_id = sm.id
|
||||
WHERE irs.routing_detail_id = ANY($1::varchar[])
|
||||
ORDER BY irs.seq_order`,
|
||||
[detailIds]
|
||||
);
|
||||
for (const m of mapRes.rows) {
|
||||
const key = String(m.routing_detail_id);
|
||||
(idsByDetail[key] ||= []).push(m.subcontractor_id);
|
||||
if (m.subcontractor_code) (codesByDetail[key] ||= []).push(m.subcontractor_code);
|
||||
}
|
||||
}
|
||||
const enriched = rows.map((r: any) => {
|
||||
const ids = idsByDetail[String(r.id)] || [];
|
||||
const codes = codesByDetail[String(r.id)] || [];
|
||||
// 레거시 폴백: 매핑이 비어있고 legacy 단일 컬럼(code)에 값이 있으면 code 배열로 반환
|
||||
const legacyCodes = ids.length === 0 && r.outsource_supplier ? [r.outsource_supplier] : codes;
|
||||
return {
|
||||
...r,
|
||||
outsource_supplier_ids: ids,
|
||||
outsource_supplier_list: legacyCodes, // 하위호환 별칭 (code 배열)
|
||||
};
|
||||
});
|
||||
|
||||
return res.json({ success: true, data: enriched });
|
||||
} catch (error: any) {
|
||||
logger.error("라우팅 상세 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
@@ -400,6 +434,15 @@ export async function saveRoutingDetails(req: AuthenticatedRequest, res: Respons
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
// 기존 상세의 외주업체 매핑을 먼저 제거
|
||||
await client.query(
|
||||
`DELETE FROM item_routing_subcontractor
|
||||
WHERE routing_detail_id IN (
|
||||
SELECT id FROM item_routing_detail WHERE routing_version_id=$1 AND company_code=$2
|
||||
)`,
|
||||
[versionId, companyCode]
|
||||
);
|
||||
|
||||
// 기존 상세 삭제 후 재입력
|
||||
await client.query(
|
||||
`DELETE FROM item_routing_detail WHERE routing_version_id=$1 AND company_code=$2`,
|
||||
@@ -407,11 +450,38 @@ export async function saveRoutingDetails(req: AuthenticatedRequest, res: Respons
|
||||
);
|
||||
|
||||
for (const d of details) {
|
||||
await client.query(
|
||||
const supplierIds: string[] = Array.isArray(d.outsource_supplier_ids)
|
||||
? d.outsource_supplier_ids.filter((s: any) => typeof s === "string" && s.trim() !== "")
|
||||
: [];
|
||||
|
||||
// legacy code 해석: 첫 번째 subcontractor_id → subcontractor_code 조회
|
||||
let legacyCode = "";
|
||||
if (supplierIds.length > 0) {
|
||||
const codeRes = await client.query(
|
||||
`SELECT subcontractor_code FROM subcontractor_mng WHERE id=$1 LIMIT 1`,
|
||||
[supplierIds[0]]
|
||||
);
|
||||
legacyCode = codeRes.rows[0]?.subcontractor_code || "";
|
||||
} else if (d.outsource_supplier) {
|
||||
// 프론트가 아직 id 없이 code만 보낸 경우(레거시 호환)
|
||||
legacyCode = d.outsource_supplier;
|
||||
}
|
||||
|
||||
const insertRes = await client.query(
|
||||
`INSERT INTO item_routing_detail (id, company_code, routing_version_id, seq_no, process_code, is_required, is_fixed_order, work_type, standard_time, outsource_supplier, writer)
|
||||
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
||||
[companyCode, versionId, d.seq_no, d.process_code, d.is_required || "Y", d.is_fixed_order || "Y", d.work_type || "내부", d.standard_time || "0", d.outsource_supplier || "", writer]
|
||||
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING id`,
|
||||
[companyCode, versionId, d.seq_no, d.process_code, d.is_required || "Y", d.is_fixed_order || "Y", d.work_type || "내부", d.standard_time || "0", legacyCode, writer]
|
||||
);
|
||||
const newDetailId = insertRes.rows[0].id;
|
||||
|
||||
for (let i = 0; i < supplierIds.length; i++) {
|
||||
await client.query(
|
||||
`INSERT INTO item_routing_subcontractor (id, company_code, routing_detail_id, subcontractor_id, seq_order)
|
||||
VALUES (gen_random_uuid()::text, $1, $2, $3, $4)`,
|
||||
[companyCode, newDetailId, supplierIds[i], i]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import type { Response } from "express";
|
||||
import { getPool } from "../database/db";
|
||||
import type { AuthenticatedRequest } from "../types/auth";
|
||||
import { adjustInventory } from "../utils/inventoryUtils";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
// 입고 목록 조회 (헤더-디테일 JOIN, 레거시 호환)
|
||||
@@ -472,7 +473,46 @@ export async function update(req: AuthenticatedRequest, res: Response) {
|
||||
|
||||
await client.query("BEGIN");
|
||||
|
||||
// 헤더 업데이트 (inbound_mng) — 헤더 레벨 필드만
|
||||
// 변경 전 값 조회 (헤더)
|
||||
const oldHeaderRes = await client.query(
|
||||
`SELECT * FROM inbound_mng WHERE id = $1 AND company_code = $2`,
|
||||
[id, companyCode],
|
||||
);
|
||||
if (oldHeaderRes.rowCount === 0) {
|
||||
await client.query("ROLLBACK");
|
||||
return res
|
||||
.status(404)
|
||||
.json({ success: false, message: "입고 데이터를 찾을 수 없습니다." });
|
||||
}
|
||||
const oldHeader = oldHeaderRes.rows[0];
|
||||
|
||||
// 변경 전 값 조회 (디테일, 있을 경우)
|
||||
let oldDetail: any = null;
|
||||
if (detail_id) {
|
||||
const oldDetailRes = await client.query(
|
||||
`SELECT * FROM inbound_detail WHERE id = $1 AND company_code = $2`,
|
||||
[detail_id, companyCode],
|
||||
);
|
||||
oldDetail = oldDetailRes.rows[0] || null;
|
||||
}
|
||||
|
||||
const oldQty =
|
||||
Number(oldDetail?.inbound_qty ?? oldHeader.inbound_qty) || 0;
|
||||
const oldWhCode = oldHeader.warehouse_code || null;
|
||||
const oldLocCode = oldHeader.location_code || null;
|
||||
const itemCode = oldDetail?.item_number || oldHeader.item_number || null;
|
||||
const inboundNumber = oldHeader.inbound_number;
|
||||
|
||||
const newQty =
|
||||
inbound_qty !== undefined && inbound_qty !== null
|
||||
? Number(inbound_qty)
|
||||
: oldQty;
|
||||
const newWhCode =
|
||||
warehouse_code !== undefined ? warehouse_code : oldWhCode;
|
||||
const newLocCode =
|
||||
location_code !== undefined ? location_code : oldLocCode;
|
||||
|
||||
// 입고 레코드 업데이트 (헤더 + 품목 필드 모두)
|
||||
const headerResult = await client.query(
|
||||
`UPDATE inbound_mng SET
|
||||
inbound_date = COALESCE($1::date, inbound_date),
|
||||
@@ -482,6 +522,9 @@ export async function update(req: AuthenticatedRequest, res: Response) {
|
||||
inspector = COALESCE($5, inspector),
|
||||
manager = COALESCE($6, manager),
|
||||
memo = COALESCE($7, memo),
|
||||
inbound_qty = COALESCE($11::numeric, inbound_qty),
|
||||
unit_price = COALESCE($12::numeric, unit_price),
|
||||
total_amount = COALESCE($13::numeric, total_amount),
|
||||
updated_date = NOW(),
|
||||
updated_by = $8
|
||||
WHERE id = $9 AND company_code = $10
|
||||
@@ -497,16 +540,12 @@ export async function update(req: AuthenticatedRequest, res: Response) {
|
||||
userId,
|
||||
id,
|
||||
companyCode,
|
||||
inbound_qty || null,
|
||||
unit_price || null,
|
||||
total_amount || null,
|
||||
],
|
||||
);
|
||||
|
||||
if (headerResult.rowCount === 0) {
|
||||
await client.query("ROLLBACK");
|
||||
return res
|
||||
.status(404)
|
||||
.json({ success: false, message: "입고 데이터를 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
// 디테일 업데이트 (inbound_detail) — detail_id가 있으면 디테일 레벨 필드 업데이트
|
||||
let detailRow = null;
|
||||
if (detail_id) {
|
||||
@@ -557,9 +596,67 @@ export async function update(req: AuthenticatedRequest, res: Response) {
|
||||
);
|
||||
}
|
||||
|
||||
// 재고/이력 반영 (append-only): 수량 또는 창고/위치 변경 시
|
||||
const qtyChanged = newQty !== oldQty;
|
||||
const whChanged =
|
||||
(newWhCode || "") !== (oldWhCode || "") ||
|
||||
(newLocCode || "") !== (oldLocCode || "");
|
||||
|
||||
if (itemCode && (qtyChanged || whChanged)) {
|
||||
if (whChanged) {
|
||||
if (oldQty > 0) {
|
||||
await adjustInventory(client, {
|
||||
companyCode,
|
||||
userId,
|
||||
itemCode,
|
||||
whCode: oldWhCode,
|
||||
locCode: oldLocCode,
|
||||
delta: -oldQty,
|
||||
transactionType: "입고취소",
|
||||
remark: `입고수정-창고변경 (${inboundNumber}) ${oldWhCode || ""}→${newWhCode || ""}`,
|
||||
});
|
||||
}
|
||||
if (newQty > 0) {
|
||||
await adjustInventory(client, {
|
||||
companyCode,
|
||||
userId,
|
||||
itemCode,
|
||||
whCode: newWhCode,
|
||||
locCode: newLocCode,
|
||||
delta: newQty,
|
||||
transactionType: "입고수정",
|
||||
remark: `입고수정-창고변경 (${inboundNumber}) ${oldWhCode || ""}→${newWhCode || ""}, 수량 ${oldQty}→${newQty}`,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const delta = newQty - oldQty;
|
||||
if (delta !== 0) {
|
||||
await adjustInventory(client, {
|
||||
companyCode,
|
||||
userId,
|
||||
itemCode,
|
||||
whCode: newWhCode,
|
||||
locCode: newLocCode,
|
||||
delta,
|
||||
transactionType: "입고수정",
|
||||
remark: `입고수정 (${inboundNumber}) 수량 ${oldQty}→${newQty}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
|
||||
logger.info("입고 수정", { companyCode, userId, id, detail_id });
|
||||
logger.info("입고 수정", {
|
||||
companyCode,
|
||||
userId,
|
||||
id,
|
||||
detail_id,
|
||||
oldQty,
|
||||
newQty,
|
||||
oldWhCode,
|
||||
newWhCode,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* 리포트 셀 커스텀 입력값 컨트롤러
|
||||
*
|
||||
* 리포트 디자이너에서 cellType="input"으로 지정한 셀에 대해
|
||||
* 각 대상 레코드(quote 등)별로 사용자가 입력한 값을 관리
|
||||
*/
|
||||
|
||||
import type { Response } from "express";
|
||||
import { getPool } from "../database/db";
|
||||
import type { AuthenticatedRequest } from "../types/auth";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
// 목록 조회: 특정 리포트 + 타겟에 대한 모든 셀 값
|
||||
export async function getList(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { report_id, target_type, target_id } = req.query;
|
||||
|
||||
if (!report_id || !target_type || !target_id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "report_id, target_type, target_id는 필수입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(
|
||||
`SELECT id, report_id, target_type, target_id, component_id, cell_id, value
|
||||
FROM report_cell_values
|
||||
WHERE company_code = $1 AND report_id = $2 AND target_type = $3 AND target_id = $4`,
|
||||
[companyCode, report_id, target_type, target_id],
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("리포트 셀 값 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// UPSERT 단건: 같은 (report_id, target_type, target_id, component_id, cell_id)면 UPDATE, 아니면 INSERT
|
||||
export async function upsert(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const { report_id, target_type, target_id, component_id, cell_id, value } =
|
||||
req.body;
|
||||
|
||||
if (!report_id || !target_type || !target_id || !component_id || !cell_id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 필드 누락",
|
||||
});
|
||||
}
|
||||
|
||||
const pool = getPool();
|
||||
|
||||
// value가 빈 문자열이면 DELETE (오버라이드 해제)
|
||||
if (value === "" || value === null || value === undefined) {
|
||||
await pool.query(
|
||||
`DELETE FROM report_cell_values
|
||||
WHERE company_code = $1 AND report_id = $2 AND target_type = $3
|
||||
AND target_id = $4 AND component_id = $5 AND cell_id = $6`,
|
||||
[companyCode, report_id, target_type, target_id, component_id, cell_id],
|
||||
);
|
||||
return res.json({ success: true, data: null });
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO report_cell_values
|
||||
(id, company_code, report_id, target_type, target_id, component_id, cell_id, value, created_by, updated_by)
|
||||
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $8)
|
||||
ON CONFLICT (company_code, report_id, target_type, target_id, component_id, cell_id)
|
||||
DO UPDATE SET value = EXCLUDED.value, updated_at = CURRENT_TIMESTAMP, updated_by = EXCLUDED.updated_by
|
||||
RETURNING *`,
|
||||
[
|
||||
companyCode,
|
||||
report_id,
|
||||
target_type,
|
||||
target_id,
|
||||
component_id,
|
||||
cell_id,
|
||||
value,
|
||||
userId,
|
||||
],
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: result.rows[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("리포트 셀 값 저장 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,7 @@ export const getCategoryValues = async (req: AuthenticatedRequest, res: Response
|
||||
const includeInactive = req.query.includeInactive === "true";
|
||||
const menuObjid = req.query.menuObjid ? Number(req.query.menuObjid) : undefined;
|
||||
const filterCompanyCode = req.query.filterCompanyCode as string | undefined;
|
||||
const topLevelOnly = req.query.topLevelOnly === "true";
|
||||
|
||||
// 최고관리자가 특정 회사 기준 필터링을 요청한 경우 해당 회사 코드 사용
|
||||
const effectiveCompanyCode = (userCompanyCode === "*" && filterCompanyCode)
|
||||
@@ -86,7 +87,8 @@ export const getCategoryValues = async (req: AuthenticatedRequest, res: Response
|
||||
columnName,
|
||||
effectiveCompanyCode,
|
||||
includeInactive,
|
||||
menuObjid
|
||||
menuObjid,
|
||||
topLevelOnly
|
||||
);
|
||||
|
||||
return res.json({
|
||||
|
||||
@@ -7,13 +7,19 @@ import { getPool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
import { numberingRuleService } from "../services/numberingRuleService";
|
||||
|
||||
// 자동 마이그레이션: work_instruction_detail에 routing_version_id 컬럼 추가
|
||||
// 자동 마이그레이션: work_instruction_detail에 routing_version_id + 품목별 일정/설비/작업조/작업자 컬럼 추가
|
||||
let _migrationDone = false;
|
||||
async function ensureDetailRoutingColumn() {
|
||||
if (_migrationDone) return;
|
||||
try {
|
||||
const pool = getPool();
|
||||
await pool.query("ALTER TABLE work_instruction_detail ADD COLUMN IF NOT EXISTS routing_version_id VARCHAR(500)");
|
||||
// 품목별 일정/설비/작업조/작업자 컬럼 (옵션 A — 다중선택 지원)
|
||||
await pool.query("ALTER TABLE work_instruction_detail ADD COLUMN IF NOT EXISTS start_date VARCHAR(500)");
|
||||
await pool.query("ALTER TABLE work_instruction_detail ADD COLUMN IF NOT EXISTS end_date VARCHAR(500)");
|
||||
await pool.query("ALTER TABLE work_instruction_detail ADD COLUMN IF NOT EXISTS equipment_ids VARCHAR(1000)");
|
||||
await pool.query("ALTER TABLE work_instruction_detail ADD COLUMN IF NOT EXISTS work_teams VARCHAR(200)");
|
||||
await pool.query("ALTER TABLE work_instruction_detail ADD COLUMN IF NOT EXISTS workers VARCHAR(1000)");
|
||||
_migrationDone = true;
|
||||
} catch { /* 이미 존재하거나 권한 문제 시 무시 */ }
|
||||
}
|
||||
@@ -23,7 +29,12 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
await ensureDetailRoutingColumn();
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { dateFrom, dateTo, status, progressStatus, keyword } = req.query;
|
||||
const { dateFrom, dateTo, status, progressStatus, keyword, page, pageSize } = req.query;
|
||||
|
||||
// 페이지네이션 파라미터 파싱 (page 없으면 전체 반환 — 하위호환)
|
||||
const pageNum = page ? Math.max(1, parseInt(page as string, 10) || 1) : null;
|
||||
const sizeNum = pageSize ? Math.max(1, Math.min(1000, parseInt(pageSize as string, 10) || 20)) : null;
|
||||
const paginated = pageNum !== null && sizeNum !== null;
|
||||
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
@@ -54,14 +65,115 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
|
||||
params.push(progressStatus);
|
||||
idx++;
|
||||
}
|
||||
// keyword 검색: wi 자체 필드 + detail.item_number 존재 여부로 EXISTS
|
||||
if (keyword) {
|
||||
conditions.push(`(wi.work_instruction_no ILIKE $${idx} OR wi.worker ILIKE $${idx} OR COALESCE(itm.item_name,'') ILIKE $${idx} OR COALESCE(d.item_number,'') ILIKE $${idx})`);
|
||||
conditions.push(`(
|
||||
wi.work_instruction_no ILIKE $${idx}
|
||||
OR wi.worker ILIKE $${idx}
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM work_instruction_detail dd
|
||||
LEFT JOIN item_info ii ON ii.item_number = dd.item_number AND ii.company_code = wi.company_code
|
||||
WHERE dd.work_instruction_id = wi.id
|
||||
AND (dd.item_number ILIKE $${idx} OR COALESCE(ii.item_name,'') ILIKE $${idx})
|
||||
)
|
||||
)`);
|
||||
params.push(`%${keyword}%`);
|
||||
idx++;
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
|
||||
const pool = getPool();
|
||||
|
||||
// 페이지네이션 모드: WI 단위로 페이지 잘라낸 뒤 detail과 JOIN
|
||||
if (paginated) {
|
||||
// 1) 총 WI 개수 카운트
|
||||
const countSql = `
|
||||
SELECT COUNT(*)::int AS cnt
|
||||
FROM work_instruction wi
|
||||
${whereClause}
|
||||
`;
|
||||
const countRes = await pool.query(countSql, params);
|
||||
const totalCount = countRes.rows[0]?.cnt ?? 0;
|
||||
|
||||
// 2) 현재 페이지 WI id 목록
|
||||
const offset = (pageNum! - 1) * sizeNum!;
|
||||
const pageSql = `
|
||||
SELECT wi.id
|
||||
FROM work_instruction wi
|
||||
${whereClause}
|
||||
ORDER BY wi.created_date DESC, wi.id DESC
|
||||
LIMIT ${sizeNum} OFFSET ${offset}
|
||||
`;
|
||||
const pageRes = await pool.query(pageSql, params);
|
||||
const wiIds = pageRes.rows.map((r) => r.id);
|
||||
|
||||
if (wiIds.length === 0) {
|
||||
return res.json({ success: true, data: [], totalCount, page: pageNum, pageSize: sizeNum });
|
||||
}
|
||||
|
||||
// 3) 해당 WI들의 detail + 품목/설비/라우팅 JOIN
|
||||
const dataSql = `
|
||||
SELECT
|
||||
wi.id AS wi_id,
|
||||
wi.work_instruction_no,
|
||||
wi.status,
|
||||
wi.progress_status,
|
||||
wi.qty AS total_qty,
|
||||
wi.completed_qty,
|
||||
wi.start_date,
|
||||
wi.end_date,
|
||||
wi.equipment_id,
|
||||
wi.work_team,
|
||||
wi.worker,
|
||||
wi.remark AS wi_remark,
|
||||
wi.created_date,
|
||||
d.id AS detail_id,
|
||||
d.item_number,
|
||||
d.qty AS detail_qty,
|
||||
d.remark AS detail_remark,
|
||||
d.part_code,
|
||||
d.source_table,
|
||||
d.source_id,
|
||||
d.routing_version_id AS detail_routing_version_id,
|
||||
d.start_date AS detail_start_date,
|
||||
d.end_date AS detail_end_date,
|
||||
d.equipment_ids AS detail_equipment_ids,
|
||||
d.work_teams AS detail_work_teams,
|
||||
d.workers AS detail_workers,
|
||||
COALESCE(itm.item_name, '') AS item_name,
|
||||
COALESCE(itm.type, '') AS item_type,
|
||||
COALESCE(itm.size, '') AS item_spec,
|
||||
COALESCE(e.equipment_name, '') AS equipment_name,
|
||||
COALESCE(e.equipment_code, '') AS equipment_code,
|
||||
wi.routing AS routing_version_id,
|
||||
COALESCE(rv.version_name, '') AS routing_name,
|
||||
ROW_NUMBER() OVER (PARTITION BY wi.work_instruction_no ORDER BY d.created_date) AS detail_seq,
|
||||
COUNT(*) OVER (PARTITION BY wi.work_instruction_no) AS detail_count
|
||||
FROM work_instruction wi
|
||||
INNER JOIN work_instruction_detail d
|
||||
ON d.work_instruction_id = wi.id
|
||||
LEFT JOIN item_info itm
|
||||
ON itm.item_number = d.item_number AND itm.company_code = wi.company_code
|
||||
LEFT JOIN equipment_mng e
|
||||
ON wi.equipment_id = e.id AND wi.company_code = e.company_code
|
||||
LEFT JOIN item_routing_version rv
|
||||
ON wi.routing = rv.id AND rv.company_code = wi.company_code
|
||||
WHERE wi.id = ANY($1::varchar[])
|
||||
ORDER BY wi.created_date DESC, wi.id DESC, d.created_date ASC
|
||||
`;
|
||||
const dataRes = await pool.query(dataSql, [wiIds]);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: dataRes.rows,
|
||||
totalCount,
|
||||
page: pageNum,
|
||||
pageSize: sizeNum,
|
||||
});
|
||||
}
|
||||
|
||||
// 비페이지 모드 (하위호환): 기존 방식 유지, LATERAL만 LEFT JOIN으로 교체
|
||||
const query = `
|
||||
SELECT
|
||||
wi.id AS wi_id,
|
||||
@@ -85,6 +197,11 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
|
||||
d.source_table,
|
||||
d.source_id,
|
||||
d.routing_version_id AS detail_routing_version_id,
|
||||
d.start_date AS detail_start_date,
|
||||
d.end_date AS detail_end_date,
|
||||
d.equipment_ids AS detail_equipment_ids,
|
||||
d.work_teams AS detail_work_teams,
|
||||
d.workers AS detail_workers,
|
||||
COALESCE(itm.item_name, '') AS item_name,
|
||||
COALESCE(itm.type, '') AS item_type,
|
||||
COALESCE(itm.size, '') AS item_spec,
|
||||
@@ -97,17 +214,14 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
|
||||
FROM work_instruction wi
|
||||
INNER JOIN work_instruction_detail d
|
||||
ON d.work_instruction_id = wi.id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT item_name, size, type FROM item_info
|
||||
WHERE item_number = d.item_number AND company_code = wi.company_code LIMIT 1
|
||||
) itm ON true
|
||||
LEFT JOIN item_info itm
|
||||
ON itm.item_number = d.item_number AND itm.company_code = wi.company_code
|
||||
LEFT JOIN equipment_mng e ON wi.equipment_id = e.id AND wi.company_code = e.company_code
|
||||
LEFT JOIN item_routing_version rv ON wi.routing = rv.id AND rv.company_code = wi.company_code
|
||||
${whereClause}
|
||||
ORDER BY wi.created_date DESC, d.created_date ASC
|
||||
`;
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(query, params);
|
||||
return res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
@@ -195,8 +309,25 @@ export async function save(req: AuthenticatedRequest, res: Response) {
|
||||
if (!firstRouting && itemRouting) firstRouting = itemRouting;
|
||||
totalQty += Number(item.qty || 0);
|
||||
await client.query(
|
||||
`INSERT INTO work_instruction_detail (id,company_code,work_instruction_no,work_instruction_id,item_number,qty,remark,source_table,source_id,part_code,routing_version_id,created_date,writer) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,NOW(),$11)`,
|
||||
[companyCode, wiNo, wiId, item.itemNumber||item.itemCode||"", item.qty||"0", item.remark||"", item.sourceTable||"", item.sourceId||"", item.partCode||item.itemNumber||item.itemCode||"", itemRouting, userId]
|
||||
`INSERT INTO work_instruction_detail (id,company_code,work_instruction_no,work_instruction_id,item_number,qty,remark,source_table,source_id,part_code,routing_version_id,start_date,end_date,equipment_ids,work_teams,workers,created_date,writer) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,NOW(),$16)`,
|
||||
[
|
||||
companyCode,
|
||||
wiNo,
|
||||
wiId,
|
||||
item.itemNumber||item.itemCode||"",
|
||||
item.qty||"0",
|
||||
item.remark||"",
|
||||
item.sourceTable||"",
|
||||
item.sourceId||"",
|
||||
item.partCode||item.itemNumber||item.itemCode||"",
|
||||
itemRouting,
|
||||
item.startDate||"",
|
||||
item.endDate||"",
|
||||
item.equipmentIds||"",
|
||||
item.workTeams||"",
|
||||
item.workers||"",
|
||||
userId,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -296,7 +427,30 @@ export async function getProductionPlanSource(req: AuthenticatedRequest, res: Re
|
||||
const pool = getPool();
|
||||
const cnt = await pool.query(`SELECT COUNT(*) AS total FROM production_plan_mng p WHERE ${w}`, params);
|
||||
params.push(pageSize, offset);
|
||||
const rows = await pool.query(`SELECT p.id, p.plan_no, p.item_code, COALESCE(p.item_name,'') AS item_name, COALESCE(p.plan_qty,0) AS plan_qty, p.start_date, p.end_date, p.status, COALESCE(p.equipment_name,'') AS equipment_name FROM production_plan_mng p WHERE ${w} ORDER BY p.created_date DESC LIMIT $${idx} OFFSET $${idx+1}`, params);
|
||||
// work_instruction_detail에서 해당 계획에 이미 내린 작업지시 수량 합계 → applied_qty, remain_qty
|
||||
const rows = await pool.query(
|
||||
`SELECT p.id, p.plan_no, p.item_code,
|
||||
COALESCE(p.item_name,'') AS item_name,
|
||||
COALESCE(p.plan_qty,0) AS plan_qty,
|
||||
p.start_date, p.end_date, p.status,
|
||||
COALESCE(p.equipment_name,'') AS equipment_name,
|
||||
COALESCE(wi.applied_qty, 0) AS applied_qty,
|
||||
(COALESCE(CAST(NULLIF(p.plan_qty::text, '') AS numeric), 0)
|
||||
- COALESCE(wi.applied_qty, 0)) AS remain_qty
|
||||
FROM production_plan_mng p
|
||||
LEFT JOIN (
|
||||
SELECT source_id,
|
||||
SUM(COALESCE(CAST(NULLIF(qty, '') AS numeric), 0)) AS applied_qty
|
||||
FROM work_instruction_detail
|
||||
WHERE source_table = 'production_plan_mng'
|
||||
AND company_code = $1
|
||||
GROUP BY source_id
|
||||
) wi ON wi.source_id = p.id::text
|
||||
WHERE ${w}
|
||||
ORDER BY p.created_date DESC
|
||||
LIMIT $${idx} OFFSET $${idx+1}`,
|
||||
params,
|
||||
);
|
||||
return res.json({ success: true, data: rows.rows, totalCount: parseInt(cnt.rows[0].total), page, pageSize });
|
||||
} catch (error: any) { return res.status(500).json({ success: false, message: error.message }); }
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
toggleMenuStatus, // 메뉴 상태 토글
|
||||
copyMenu, // 메뉴 복사
|
||||
getUserList,
|
||||
getUserNameMap, // 사용자 ID→이름 맵 (경량)
|
||||
getUserInfo, // 사용자 상세 조회
|
||||
getUserHistory, // 사용자 변경이력 조회
|
||||
changeUserStatus, // 사용자 상태 변경
|
||||
@@ -70,6 +71,7 @@ router.delete("/menus/:menuId", deleteMenu); // 메뉴 삭제
|
||||
|
||||
// 사용자 관리 API
|
||||
router.get("/users", getUserList);
|
||||
router.get("/users/name-map", getUserNameMap); // 사용자 ID→이름 매핑 (경량)
|
||||
router.get("/users/:userId", getUserInfo); // 사용자 상세 조회
|
||||
router.get("/users/:userId/history", getUserHistory); // 사용자 변경이력 조회
|
||||
router.get("/users/:userId/with-dept", getUserWithDept); // 사원 + 부서 조회 (NEW!)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Router } from "express";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import * as ctrl from "../controllers/outsourcingOutboundController";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(authenticateToken);
|
||||
|
||||
// 외주출고 대상 자동 조회
|
||||
router.get("/candidates", ctrl.getCandidates);
|
||||
|
||||
// 외주출고 목록 조회
|
||||
router.get("/list", ctrl.getList);
|
||||
|
||||
// 외주출고번호 자동생성
|
||||
router.get("/generate-number", ctrl.generateNumber);
|
||||
|
||||
// 창고 목록
|
||||
router.get("/warehouses", ctrl.getWarehouses);
|
||||
|
||||
// 외주출고 등록
|
||||
router.post("/", ctrl.create);
|
||||
|
||||
// 외주출고 수정
|
||||
router.put("/:id", ctrl.update);
|
||||
|
||||
// 외주출고 삭제
|
||||
router.delete("/:id", ctrl.deleteOutbound);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Router } from "express";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import * as controller from "../controllers/reportCellValueController";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(authenticateToken);
|
||||
|
||||
router.get("/", controller.getList);
|
||||
router.post("/", controller.upsert);
|
||||
|
||||
export default router;
|
||||
@@ -60,8 +60,9 @@ export async function getBomHeader(bomId: string, tableName?: string) {
|
||||
const sql = `
|
||||
SELECT b.*,
|
||||
i.item_name, i.item_number, i.division as item_type,
|
||||
COALESCE(b.unit, i.unit) as unit,
|
||||
COALESCE(NULLIF(b.unit, ''), NULLIF(i.unit, ''), NULLIF(i.inventory_unit, '')) as unit,
|
||||
i.unit as item_unit,
|
||||
i.inventory_unit as item_inventory_unit,
|
||||
i.division, i.size, i.material
|
||||
FROM ${table} b
|
||||
LEFT JOIN item_info i ON b.item_id = i.id
|
||||
|
||||
@@ -223,13 +223,14 @@ class CategoryTreeService {
|
||||
|
||||
const query = `
|
||||
INSERT INTO category_values (
|
||||
table_name, column_name, value_code, value_label, value_order,
|
||||
value_id, table_name, column_name, value_code, value_label, value_order,
|
||||
parent_value_id, depth, path, description, color, icon,
|
||||
is_active, is_default, company_code, created_by, updated_by
|
||||
) VALUES (
|
||||
(SELECT COALESCE(MAX(value_id), 0) + 1 FROM category_values),
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $15
|
||||
)
|
||||
RETURNING
|
||||
RETURNING
|
||||
value_id AS "valueId",
|
||||
table_name AS "tableName",
|
||||
column_name AS "columnName",
|
||||
|
||||
@@ -694,13 +694,16 @@ export async function mergeSchedules(
|
||||
[companyCode, ...scheduleIds]
|
||||
);
|
||||
|
||||
// 병합된 스케줄 생성
|
||||
// 병합된 스케줄 생성 (PP-YYYYMMDD-NNNN 형식)
|
||||
const todayStr = new Date().toISOString().split("T")[0].replace(/-/g, "");
|
||||
const planNoResult = await client.query(
|
||||
`SELECT COALESCE(MAX(CAST(REPLACE(plan_no, 'PP-', '') AS INTEGER)), 0) + 1 AS next_no
|
||||
FROM production_plan_mng WHERE company_code = $1`,
|
||||
[companyCode]
|
||||
`SELECT COUNT(*) + 1 AS next_no
|
||||
FROM production_plan_mng
|
||||
WHERE company_code = $1 AND plan_no LIKE $2`,
|
||||
[companyCode, `PP-${todayStr}-%`]
|
||||
);
|
||||
const planNo = `PP-${String(planNoResult.rows[0].next_no || 1).padStart(6, "0")}`;
|
||||
const nextNo = parseInt(planNoResult.rows[0].next_no, 10) || 1;
|
||||
const planNo = `PP-${todayStr}-${String(nextNo).padStart(4, "0")}`;
|
||||
|
||||
const insertResult = await client.query(
|
||||
`INSERT INTO production_plan_mng (
|
||||
@@ -1017,13 +1020,16 @@ export async function splitSchedule(
|
||||
[originalQty - splitQty, splitBy, planId, companyCode]
|
||||
);
|
||||
|
||||
// 분할된 새 계획 생성
|
||||
// 분할된 새 계획 생성 (PP-YYYYMMDD-NNNN 형식)
|
||||
const todayStr = new Date().toISOString().split("T")[0].replace(/-/g, "");
|
||||
const planNoResult = await client.query(
|
||||
`SELECT COALESCE(MAX(CAST(REPLACE(plan_no, 'PP-', '') AS INTEGER)), 0) + 1 AS next_no
|
||||
FROM production_plan_mng WHERE company_code = $1`,
|
||||
[companyCode]
|
||||
`SELECT COUNT(*) + 1 AS next_no
|
||||
FROM production_plan_mng
|
||||
WHERE company_code = $1 AND plan_no LIKE $2`,
|
||||
[companyCode, `PP-${todayStr}-%`]
|
||||
);
|
||||
const planNo = `PP-${String(planNoResult.rows[0].next_no || 1).padStart(6, "0")}`;
|
||||
const nextNo = parseInt(planNoResult.rows[0].next_no, 10) || 1;
|
||||
const planNo = `PP-${todayStr}-${String(nextNo).padStart(4, "0")}`;
|
||||
|
||||
const insertResult = await client.query(
|
||||
`INSERT INTO production_plan_mng (
|
||||
|
||||
@@ -884,18 +884,23 @@ export class ReportService {
|
||||
menuObjid: number,
|
||||
companyCode: string
|
||||
): Promise<{ items: ReportMaster[]; total: number }> {
|
||||
// 매핑 없는 리포트(글로벌)는 어느 메뉴에서나 보이고,
|
||||
// 매핑 있는 리포트는 해당 menu_objid에 매핑된 경우에만 보임.
|
||||
const companyFilter = companyCode !== "*" ? " AND rm.company_code = $2" : "";
|
||||
const params = companyCode !== "*" ? [menuObjid, companyCode] : [menuObjid];
|
||||
|
||||
const items = await query<ReportMaster>(
|
||||
`SELECT rm.report_id, rm.report_name_kor, rm.report_name_eng,
|
||||
`SELECT DISTINCT rm.report_id, rm.report_name_kor, rm.report_name_eng,
|
||||
rm.template_id, rt.template_name_kor AS template_name,
|
||||
rm.report_type, rm.company_code, rm.description, rm.use_yn,
|
||||
rm.created_at, rm.created_by, rm.updated_at, rm.updated_by
|
||||
FROM report_master rm
|
||||
JOIN report_menu_mapping rmm ON rm.report_id = rmm.report_id
|
||||
LEFT JOIN report_template rt ON rm.template_id = rt.template_id
|
||||
WHERE rmm.menu_objid = $1 AND rm.use_yn = 'Y'${companyFilter}
|
||||
WHERE rm.use_yn = 'Y'${companyFilter}
|
||||
AND (
|
||||
NOT EXISTS (SELECT 1 FROM report_menu_mapping WHERE report_id = rm.report_id)
|
||||
OR EXISTS (SELECT 1 FROM report_menu_mapping WHERE report_id = rm.report_id AND menu_objid = $1)
|
||||
)
|
||||
ORDER BY rm.report_name_kor ASC`,
|
||||
params
|
||||
);
|
||||
|
||||
@@ -167,7 +167,8 @@ class TableCategoryValueService {
|
||||
columnName: string,
|
||||
companyCode: string,
|
||||
includeInactive: boolean = false,
|
||||
menuObjid?: number
|
||||
menuObjid?: number,
|
||||
topLevelOnly: boolean = false
|
||||
): Promise<TableCategoryValue[]> {
|
||||
try {
|
||||
logger.info("카테고리 값 목록 조회 (메뉴 스코프)", {
|
||||
@@ -235,6 +236,10 @@ class TableCategoryValueService {
|
||||
query += ` AND is_active = true`;
|
||||
}
|
||||
|
||||
if (topLevelOnly) {
|
||||
query += ` AND (depth = 1 OR depth IS NULL OR parent_value_id IS NULL)`;
|
||||
}
|
||||
|
||||
query += ` ORDER BY value_order, value_label`;
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import type { PoolClient } from "pg";
|
||||
|
||||
export interface AdjustInventoryParams {
|
||||
companyCode: string;
|
||||
userId: string;
|
||||
itemCode: string;
|
||||
whCode: string | null;
|
||||
locCode: string | null;
|
||||
delta: number;
|
||||
transactionType: string;
|
||||
remark: string;
|
||||
validateStockEnough?: boolean;
|
||||
}
|
||||
|
||||
export async function adjustInventory(
|
||||
client: PoolClient,
|
||||
params: AdjustInventoryParams,
|
||||
): Promise<void> {
|
||||
const {
|
||||
companyCode,
|
||||
userId,
|
||||
itemCode,
|
||||
whCode,
|
||||
locCode,
|
||||
delta,
|
||||
transactionType,
|
||||
remark,
|
||||
validateStockEnough,
|
||||
} = params;
|
||||
|
||||
if (!itemCode || delta === 0) return;
|
||||
|
||||
if (validateStockEnough && delta < 0) {
|
||||
const stockRes = await client.query(
|
||||
`SELECT COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) AS cur
|
||||
FROM inventory_stock
|
||||
WHERE company_code = $1 AND item_code = $2
|
||||
AND COALESCE(warehouse_code, '') = COALESCE($3, '')
|
||||
AND COALESCE(location_code, '') = COALESCE($4, '')
|
||||
LIMIT 1`,
|
||||
[companyCode, itemCode, whCode || "", locCode || ""],
|
||||
);
|
||||
const cur = parseFloat(stockRes.rows[0]?.cur || "0");
|
||||
if (cur + delta < 0) {
|
||||
throw new Error(
|
||||
`재고 부족: 품목 ${itemCode} (창고 ${whCode || "미지정"}) — 현재 재고 ${cur}, 차감 요청 ${-delta}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const existing = await client.query(
|
||||
`SELECT id FROM inventory_stock
|
||||
WHERE company_code = $1 AND item_code = $2
|
||||
AND COALESCE(warehouse_code, '') = COALESCE($3, '')
|
||||
AND COALESCE(location_code, '') = COALESCE($4, '')
|
||||
LIMIT 1`,
|
||||
[companyCode, itemCode, whCode || "", locCode || ""],
|
||||
);
|
||||
|
||||
if (existing.rows.length > 0) {
|
||||
if (delta >= 0) {
|
||||
await client.query(
|
||||
`UPDATE inventory_stock
|
||||
SET current_qty = CAST(COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) + $1 AS text),
|
||||
last_in_date = NOW(),
|
||||
updated_date = NOW()
|
||||
WHERE id = $2`,
|
||||
[delta, existing.rows[0].id],
|
||||
);
|
||||
} else {
|
||||
await client.query(
|
||||
`UPDATE inventory_stock
|
||||
SET current_qty = CAST(GREATEST(COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) + $1, 0) AS text),
|
||||
last_out_date = NOW(),
|
||||
updated_date = NOW()
|
||||
WHERE id = $2`,
|
||||
[delta, existing.rows[0].id],
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const initQty = Math.max(delta, 0);
|
||||
await client.query(
|
||||
`INSERT INTO inventory_stock (
|
||||
id, company_code, item_code, warehouse_code, location_code,
|
||||
current_qty, safety_qty, last_in_date, last_out_date,
|
||||
created_date, updated_date, writer
|
||||
) VALUES (
|
||||
gen_random_uuid()::text, $1, $2, $3, $4,
|
||||
$5, '0',
|
||||
${delta > 0 ? "NOW()" : "NULL"},
|
||||
${delta < 0 ? "NOW()" : "NULL"},
|
||||
NOW(), NOW(), $6
|
||||
)`,
|
||||
[companyCode, itemCode, whCode, locCode, String(initQty), userId],
|
||||
);
|
||||
}
|
||||
|
||||
const afterRes = await client.query(
|
||||
`SELECT current_qty FROM inventory_stock
|
||||
WHERE company_code = $1 AND item_code = $2
|
||||
AND COALESCE(warehouse_code, '') = COALESCE($3, '')
|
||||
AND COALESCE(location_code, '') = COALESCE($4, '')
|
||||
LIMIT 1`,
|
||||
[companyCode, itemCode, whCode || "", locCode || ""],
|
||||
);
|
||||
const afterQty = afterRes.rows[0]?.current_qty || "0";
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO inventory_history (
|
||||
id, company_code, item_code, warehouse_code, location_code,
|
||||
transaction_type, transaction_date, quantity, balance_qty, remark,
|
||||
writer, created_date
|
||||
) VALUES (
|
||||
gen_random_uuid()::text, $1, $2, $3, $4,
|
||||
$5, NOW(), $6, $7, $8,
|
||||
$9, NOW()
|
||||
)`,
|
||||
[
|
||||
companyCode,
|
||||
itemCode,
|
||||
whCode,
|
||||
locCode,
|
||||
transactionType,
|
||||
(delta > 0 ? "+" : "") + String(delta),
|
||||
afterQty,
|
||||
remark,
|
||||
userId,
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -86,6 +86,7 @@ export default function EquipmentInfoPage() {
|
||||
const [inspectionForm, setInspectionForm] = useState<Record<string, any>>({});
|
||||
const [inspectionContinuous, setInspectionContinuous] = useState(false);
|
||||
const [inspectionEditMode, setInspectionEditMode] = useState(false);
|
||||
const [checkedInspectionIds, setCheckedInspectionIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// 소모품 추가/수정 모달
|
||||
const [consumableModalOpen, setConsumableModalOpen] = useState(false);
|
||||
@@ -93,6 +94,7 @@ export default function EquipmentInfoPage() {
|
||||
const [consumableContinuous, setConsumableContinuous] = useState(false);
|
||||
const [consumableEditMode, setConsumableEditMode] = useState(false);
|
||||
const [consumableItemOptions, setConsumableItemOptions] = useState<any[]>([]);
|
||||
const [checkedConsumableIds, setCheckedConsumableIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// 점검항목 복사
|
||||
const [copyModalOpen, setCopyModalOpen] = useState(false);
|
||||
@@ -147,17 +149,17 @@ export default function EquipmentInfoPage() {
|
||||
const colProps: Record<string, Partial<EDataTableColumn>> = {
|
||||
equipment_code: { width: "w-[110px]" },
|
||||
equipment_name: { minWidth: "min-w-[130px]", truncate: true, render: (v) => v || "-" },
|
||||
equipment_type: { width: "w-[90px]", render: (v) => v || "-" },
|
||||
equipment_type: { width: "w-[90px]", render: (v) => resolve("equipment_type", v) || v || "-" },
|
||||
manufacturer: { width: "w-[100px]", render: (v) => v || "-" },
|
||||
installation_location: { width: "w-[100px]", render: (v) => v || "-" },
|
||||
operation_status: { width: "w-[80px]", render: (v) => v || "-" },
|
||||
operation_status: { width: "w-[80px]", render: (v) => resolve("operation_status", v) || v || "-" },
|
||||
};
|
||||
return ts.visibleColumns.map((col) => ({
|
||||
key: col.key,
|
||||
label: col.label,
|
||||
...colProps[col.key],
|
||||
}));
|
||||
}, [ts.visibleColumns]);
|
||||
}, [ts.visibleColumns, catOptions]);
|
||||
|
||||
// 설비 조회
|
||||
const fetchEquipments = useCallback(async () => {
|
||||
@@ -165,16 +167,12 @@ export default function EquipmentInfoPage() {
|
||||
try {
|
||||
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
|
||||
const res = await apiClient.post(`/table-management/tables/${EQUIP_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
const raw = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
setEquipments(raw.map((r: any) => ({
|
||||
...r,
|
||||
equipment_type: resolve("equipment_type", r.equipment_type),
|
||||
operation_status: resolve("operation_status", r.operation_status),
|
||||
})));
|
||||
setEquipments(raw);
|
||||
setEquipCount(res.data?.data?.total || raw.length);
|
||||
} catch { toast.error("설비 목록 조회 실패"); } finally { setEquipLoading(false); }
|
||||
}, [searchFilters, catOptions]);
|
||||
@@ -204,12 +202,13 @@ export default function EquipmentInfoPage() {
|
||||
|
||||
// 우측: 점검항목 조회
|
||||
useEffect(() => {
|
||||
setCheckedInspectionIds(new Set());
|
||||
if (!selectedEquip?.equipment_code) { setInspections([]); return; }
|
||||
const fetchData = async () => {
|
||||
setInspectionLoading(true);
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "equals", value: selectedEquip.equipment_code }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
@@ -221,12 +220,13 @@ export default function EquipmentInfoPage() {
|
||||
|
||||
// 우측: 소모품 조회
|
||||
useEffect(() => {
|
||||
setCheckedConsumableIds(new Set());
|
||||
if (!selectedEquip?.equipment_code) { setConsumables([]); return; }
|
||||
const fetchData = async () => {
|
||||
setConsumableLoading(true);
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/${CONSUMABLE_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "equals", value: selectedEquip.equipment_code }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
@@ -296,6 +296,34 @@ export default function EquipmentInfoPage() {
|
||||
} catch { toast.error("삭제 실패"); }
|
||||
};
|
||||
|
||||
// 점검항목 삭제
|
||||
const handleInspectionDelete = async () => {
|
||||
const ids = Array.from(checkedInspectionIds);
|
||||
if (ids.length === 0) { toast.error("삭제할 점검항목을 선택해주세요."); return; }
|
||||
const ok = await confirm(`선택한 ${ids.length}건의 점검항목을 삭제하시겠습니까?`, { variant: "destructive", confirmText: "삭제" });
|
||||
if (!ok) return;
|
||||
try {
|
||||
await apiClient.delete(`/table-management/tables/${INSPECTION_TABLE}/delete`, { data: ids.map((id) => ({ id })) });
|
||||
toast.success("삭제되었습니다.");
|
||||
setCheckedInspectionIds(new Set());
|
||||
refreshRight();
|
||||
} catch { toast.error("삭제 실패"); }
|
||||
};
|
||||
|
||||
// 소모품 삭제
|
||||
const handleConsumableDelete = async () => {
|
||||
const ids = Array.from(checkedConsumableIds);
|
||||
if (ids.length === 0) { toast.error("삭제할 소모품을 선택해주세요."); return; }
|
||||
const ok = await confirm(`선택한 ${ids.length}건의 소모품을 삭제하시겠습니까?`, { variant: "destructive", confirmText: "삭제" });
|
||||
if (!ok) return;
|
||||
try {
|
||||
await apiClient.delete(`/table-management/tables/${CONSUMABLE_TABLE}/delete`, { data: ids.map((id) => ({ id })) });
|
||||
toast.success("삭제되었습니다.");
|
||||
setCheckedConsumableIds(new Set());
|
||||
refreshRight();
|
||||
} catch { toast.error("삭제 실패"); }
|
||||
};
|
||||
|
||||
// 점검항목 추가
|
||||
const handleInspectionSave = async () => {
|
||||
if (!inspectionForm.inspection_item) { toast.error("점검항목명은 필수입니다."); return; }
|
||||
@@ -362,7 +390,7 @@ export default function EquipmentInfoPage() {
|
||||
if (consumableDiv) filters.push({ columnName: "division", operator: "equals", value: consumableDiv.valueCode });
|
||||
const results = await Promise.all(filters.map((f) =>
|
||||
apiClient.post(`/table-management/tables/item_info/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [f] },
|
||||
autoFilter: true,
|
||||
})
|
||||
@@ -409,7 +437,7 @@ export default function EquipmentInfoPage() {
|
||||
setCopyLoading(true);
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "equals", value: equipCode }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
@@ -437,9 +465,9 @@ export default function EquipmentInfoPage() {
|
||||
const handleExcelDownload = async () => {
|
||||
if (equipments.length === 0) return;
|
||||
await exportToExcel(equipments.map((e) => ({
|
||||
설비코드: e.equipment_code, 설비명: e.equipment_name, 설비유형: e.equipment_type,
|
||||
설비코드: e.equipment_code, 설비명: e.equipment_name, 설비유형: resolve("equipment_type", e.equipment_type),
|
||||
제조사: e.manufacturer, 모델명: e.model_name, 설치장소: e.installation_location,
|
||||
도입일자: e.introduction_date, 가동상태: e.operation_status,
|
||||
도입일자: e.introduction_date, 가동상태: resolve("operation_status", e.operation_status),
|
||||
})), "설비정보.xlsx", "설비");
|
||||
toast.success("다운로드 완료");
|
||||
};
|
||||
@@ -550,15 +578,23 @@ export default function EquipmentInfoPage() {
|
||||
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setInspectionForm({}); setInspectionEditMode(false); setInspectionModalOpen(true); }}>
|
||||
<Plus className="w-3.5 h-3.5 mr-1" /> 추가
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={checkedInspectionIds.size === 0} onClick={handleInspectionDelete} className="text-destructive border-destructive/20 bg-destructive/5 hover:bg-destructive/10">
|
||||
<Trash2 className="w-3.5 h-3.5 mr-1" /> 삭제
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setCopySourceEquip(""); setCopyItems([]); setCopyChecked(new Set()); setCopyModalOpen(true); }}>
|
||||
<Copy className="w-3.5 h-3.5 mr-1" /> 복사
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{rightTab === "consumable" && (
|
||||
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setConsumableForm({}); setConsumableEditMode(false); loadConsumableItems(); setConsumableModalOpen(true); }}>
|
||||
<Plus className="w-3.5 h-3.5 mr-1" /> 추가
|
||||
</Button>
|
||||
<>
|
||||
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setConsumableForm({}); setConsumableEditMode(false); loadConsumableItems(); setConsumableModalOpen(true); }}>
|
||||
<Plus className="w-3.5 h-3.5 mr-1" /> 추가
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={checkedConsumableIds.size === 0} onClick={handleConsumableDelete} className="text-destructive border-destructive/20 bg-destructive/5 hover:bg-destructive/10">
|
||||
<Trash2 className="w-3.5 h-3.5 mr-1" /> 삭제
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -637,6 +673,16 @@ export default function EquipmentInfoPage() {
|
||||
<Table noWrapper>
|
||||
<thead className="sticky top-0 z-10 bg-card">
|
||||
<TableRow>
|
||||
<TableHead
|
||||
className="w-[40px] text-center cursor-pointer"
|
||||
onClick={() => {
|
||||
const allChecked = inspections.length > 0 && checkedInspectionIds.size === inspections.length;
|
||||
if (allChecked) setCheckedInspectionIds(new Set());
|
||||
else setCheckedInspectionIds(new Set(inspections.map((i) => i.id)));
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={inspections.length > 0 && checkedInspectionIds.size === inspections.length} />
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">점검항목</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">점검주기</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">점검방법</TableHead>
|
||||
@@ -664,6 +710,20 @@ export default function EquipmentInfoPage() {
|
||||
setInspectionEditMode(true);
|
||||
setInspectionModalOpen(true);
|
||||
}}>
|
||||
<TableCell
|
||||
className="text-center cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setCheckedInspectionIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(item.id)) next.delete(item.id); else next.add(item.id);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Checkbox checked={checkedInspectionIds.has(item.id)} />
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{item.inspection_item || "-"}</TableCell>
|
||||
<TableCell className="text-[13px]">{resolve("inspection_cycle", item.inspection_cycle)}</TableCell>
|
||||
<TableCell className="text-[13px]">{resolve("inspection_method", item.inspection_method)}</TableCell>
|
||||
@@ -692,6 +752,16 @@ export default function EquipmentInfoPage() {
|
||||
<Table noWrapper>
|
||||
<thead className="sticky top-0 z-10 bg-card">
|
||||
<TableRow>
|
||||
<TableHead
|
||||
className="w-[40px] text-center cursor-pointer"
|
||||
onClick={() => {
|
||||
const allChecked = consumables.length > 0 && checkedConsumableIds.size === consumables.length;
|
||||
if (allChecked) setCheckedConsumableIds(new Set());
|
||||
else setCheckedConsumableIds(new Set(consumables.map((i) => i.id)));
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={consumables.length > 0 && checkedConsumableIds.size === consumables.length} />
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">소모품명</TableHead>
|
||||
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">교체주기</TableHead>
|
||||
<TableHead className="w-[60px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">단위</TableHead>
|
||||
@@ -707,6 +777,20 @@ export default function EquipmentInfoPage() {
|
||||
loadConsumableItems();
|
||||
setConsumableModalOpen(true);
|
||||
}}>
|
||||
<TableCell
|
||||
className="text-center cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setCheckedConsumableIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(item.id)) next.delete(item.id); else next.add(item.id);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Checkbox checked={checkedConsumableIds.has(item.id)} />
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{item.consumable_name || "-"}</TableCell>
|
||||
<TableCell className="text-[13px]">{item.replacement_cycle || "-"}</TableCell>
|
||||
<TableCell className="text-[13px]">{item.unit || "-"}</TableCell>
|
||||
|
||||
@@ -83,7 +83,7 @@ export default function EquipmentInspectionRecordPage() {
|
||||
}).catch(() => ({ data: { data: { data: [] } } })),
|
||||
apiClient.post(`/table-management/tables/equipment_mng/data`, {
|
||||
page: 1,
|
||||
size: 500,
|
||||
size: 0,
|
||||
autoFilter: true,
|
||||
}).catch(() => ({ data: { data: { data: [] } } })),
|
||||
]);
|
||||
|
||||
@@ -100,7 +100,7 @@ export default function PlcSettingsPage() {
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const eqRes = await apiClient.post(`/table-management/tables/${EQUIPMENT_TABLE}/data`, { page: 1, size: 500, autoFilter: true });
|
||||
const eqRes = await apiClient.post(`/table-management/tables/${EQUIPMENT_TABLE}/data`, { page: 1, size: 0, autoFilter: true });
|
||||
const eqs = eqRes.data?.data?.data || eqRes.data?.data?.rows || [];
|
||||
setEquipOptions(eqs.map((r: any) => ({ code: r.equipment_code, label: `${r.equipment_code} - ${r.equipment_name || ""}` })));
|
||||
} catch { /* skip */ }
|
||||
@@ -122,7 +122,7 @@ export default function PlcSettingsPage() {
|
||||
const filters: any[] = [];
|
||||
if (kw.trim()) filters.push({ columnName: "equipment_code", operator: "contains", value: kw.trim() });
|
||||
const res = await apiClient.post(`/table-management/tables/${DATATYPE_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
@@ -140,7 +140,7 @@ export default function PlcSettingsPage() {
|
||||
const filters: any[] = [];
|
||||
if (kw.trim()) filters.push({ columnName: "config_name", operator: "contains", value: kw.trim() });
|
||||
const res = await apiClient.post(`/table-management/tables/${COLLECTION_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
|
||||
@@ -150,7 +150,7 @@ export default function InboundOutboundPage() {
|
||||
if (writerIds.length > 0) {
|
||||
try {
|
||||
const userRes = await apiClient.post(`/table-management/tables/user_info/data`, {
|
||||
page: 1, size: 500, autoFilter: true,
|
||||
page: 1, size: 0, autoFilter: true,
|
||||
});
|
||||
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
|
||||
const uMap: Record<string, string> = {};
|
||||
|
||||
@@ -327,11 +327,11 @@ export default function LogisticsInfoPage() {
|
||||
try {
|
||||
const [carrierRes, routeRes] = await Promise.all([
|
||||
apiClient.post("/table-management/tables/carrier_mng/data", {
|
||||
page: 1, size: 500, autoFilter: true,
|
||||
page: 1, size: 0, autoFilter: true,
|
||||
sort: { columnName: "carrier_code", order: "asc" },
|
||||
}),
|
||||
apiClient.post("/table-management/tables/delivery_route_mng/data", {
|
||||
page: 1, size: 500, autoFilter: true,
|
||||
page: 1, size: 0, autoFilter: true,
|
||||
sort: { columnName: "route_code", order: "asc" },
|
||||
}),
|
||||
]);
|
||||
@@ -358,13 +358,15 @@ export default function LogisticsInfoPage() {
|
||||
loadReferences();
|
||||
}, [loadReferences]);
|
||||
|
||||
// 카테고리 옵션 로드
|
||||
// 카테고리 옵션 로드 (관리자 계정일 때 filterCompanyCode 미제공 시 "*" 스코프로 빈 결과 반환됨)
|
||||
const loadCategoryOptions = useCallback(async (tableColumn: string) => {
|
||||
if (loadedCategories.current.has(tableColumn)) return;
|
||||
loadedCategories.current.add(tableColumn);
|
||||
const [tableName, columnName] = tableColumn.split(":");
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/${tableName}/${columnName}/values`);
|
||||
const res = await apiClient.get(
|
||||
`/table-categories/${tableName}/${columnName}/values?filterCompanyCode=COMPANY_10`
|
||||
);
|
||||
const data = res.data?.data || [];
|
||||
setCategoryOptions((prev) => ({
|
||||
...prev,
|
||||
@@ -393,7 +395,7 @@ export default function LogisticsInfoPage() {
|
||||
const res = await apiClient.post(
|
||||
`/table-management/tables/${config.tableName}/data`,
|
||||
{
|
||||
page: 1, size: 500, autoFilter: true,
|
||||
page: 1, size: 0, autoFilter: true,
|
||||
sort: { columnName: config.defaultSortColumn, order: "asc" },
|
||||
}
|
||||
);
|
||||
@@ -823,13 +825,24 @@ export default function LogisticsInfoPage() {
|
||||
{/* 테이블 영역 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<EDataTable
|
||||
columns={getVisibleColumns(tab.key).map((col): EDataTableColumn => ({
|
||||
key: col.key,
|
||||
label: col.label,
|
||||
align: col.align,
|
||||
formatNumber: col.formatNumber,
|
||||
truncate: true,
|
||||
}))}
|
||||
columns={getVisibleColumns(tab.key).map((col): EDataTableColumn => {
|
||||
// 같은 key의 formField에 categoryKey가 있으면 코드→라벨 변환
|
||||
const formField = tab.formFields.find((f) => f.key === col.key && f.categoryKey);
|
||||
return {
|
||||
key: col.key,
|
||||
label: col.label,
|
||||
align: col.align,
|
||||
formatNumber: col.formatNumber,
|
||||
truncate: true,
|
||||
render: formField?.categoryKey
|
||||
? (value: any) => {
|
||||
const opts = categoryOptions[formField.categoryKey!] || [];
|
||||
const matched = opts.find((o: any) => o.value === value);
|
||||
return matched?.label || value || "-";
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
})}
|
||||
data={tsMap[tab.key].groupData(displayData)}
|
||||
rowKey={(row: any) => String(row.id)}
|
||||
loading={tabLoading[tab.key]}
|
||||
|
||||
@@ -13,6 +13,7 @@ import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
@@ -68,6 +69,7 @@ const STOCK_TABLE = "inventory_stock";
|
||||
const STOCK_COLUMNS = [
|
||||
{ key: "item_code", label: "품목코드" },
|
||||
{ key: "item_name", label: "품명" },
|
||||
{ key: "spec", label: "규격" },
|
||||
{ key: "warehouse_code", label: "창고" },
|
||||
{ key: "location_code", label: "위치" },
|
||||
{ key: "current_qty", label: "현재수량", align: "right" as const },
|
||||
@@ -87,6 +89,8 @@ const getStatusVariant = (
|
||||
return "destructive";
|
||||
case "과잉":
|
||||
return "secondary";
|
||||
case "미등록":
|
||||
return "outline";
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
@@ -119,6 +123,15 @@ export default function InventoryStatusPage() {
|
||||
const [stockLoading, setStockLoading] = useState(false);
|
||||
const [selectedStockId, setSelectedStockId] = useState<string | null>(null);
|
||||
|
||||
// 재고 없는 품목 표시 여부
|
||||
const [showMissingItems, setShowMissingItems] = useState(false);
|
||||
|
||||
// 창고 목록 (조정 모달에서 사용)
|
||||
const [warehouseList, setWarehouseList] = useState<{ code: string; name: string }[]>([]);
|
||||
|
||||
// 선택된 창고의 위치 목록 (조정 모달에서 사용)
|
||||
const [locationList, setLocationList] = useState<{ code: string; name: string }[]>([]);
|
||||
|
||||
// 검색 필터
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
|
||||
@@ -132,7 +145,9 @@ export default function InventoryStatusPage() {
|
||||
adjust_type: string;
|
||||
adjust_qty: string;
|
||||
reason: string;
|
||||
}>({ adjust_type: "증가", adjust_qty: "", reason: "" });
|
||||
warehouse_code: string;
|
||||
location_code: string;
|
||||
}>({ adjust_type: "증가", adjust_qty: "", reason: "", warehouse_code: "", location_code: "" });
|
||||
const [adjustSaving, setAdjustSaving] = useState(false);
|
||||
|
||||
// 카테고리 옵션
|
||||
@@ -171,12 +186,12 @@ export default function InventoryStatusPage() {
|
||||
};
|
||||
load();
|
||||
// 사용자 목록 로드
|
||||
apiClient.get("/admin/users", { params: { size: 9999 } }).then((res) => {
|
||||
const users = res.data?.data || res.data || [];
|
||||
apiClient.get("/admin/users/name-map").then((res) => {
|
||||
const users = res.data?.data || [];
|
||||
const map: Record<string, string> = {};
|
||||
for (const u of users) {
|
||||
const id = u.userId || u.user_id || u.id;
|
||||
const name = u.user_name || u.name || id;
|
||||
const id = u.user_id;
|
||||
const name = u.user_name || id;
|
||||
if (id) map[id] = name;
|
||||
}
|
||||
setUserMap(map);
|
||||
@@ -190,19 +205,20 @@ export default function InventoryStatusPage() {
|
||||
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
|
||||
const [stockRes, itemRes, whRes] = await Promise.all([
|
||||
apiClient.post(`/table-management/tables/${STOCK_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
sort: { columnName: "item_code", order: "asc" },
|
||||
}),
|
||||
apiClient.post(`/table-management/tables/item_info/data`, { page: 1, size: 500, autoFilter: true }),
|
||||
apiClient.post(`/table-management/tables/warehouse_info/data`, { page: 1, size: 500, autoFilter: true }),
|
||||
apiClient.post(`/table-management/tables/item_info/data`, { page: 1, size: 0, autoFilter: true }),
|
||||
apiClient.post(`/table-management/tables/warehouse_info/data`, { page: 1, size: 0, autoFilter: true }),
|
||||
]);
|
||||
const raw = stockRes.data?.data?.data || stockRes.data?.data?.rows || [];
|
||||
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
|
||||
const warehouses = whRes.data?.data?.data || whRes.data?.data?.rows || [];
|
||||
const itemMap = new Map(items.map((i: any) => [i.item_number || i.item_code, { name: i.item_name || "", unit: i.inventory_unit || "" }]));
|
||||
const itemMap = new Map(items.map((i: any) => [i.item_number || i.item_code, { name: i.item_name || "", unit: i.inventory_unit || "", spec: i.size || "" }]));
|
||||
const whMap = new Map(warehouses.map((w: any) => [w.warehouse_code, w.warehouse_name || w.warehouse_code]));
|
||||
setWarehouseList(warehouses.map((w: any) => ({ code: w.warehouse_code, name: w.warehouse_name || w.warehouse_code })));
|
||||
const resolve = (col: string, code: string) => {
|
||||
if (!code) return "";
|
||||
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
|
||||
@@ -213,19 +229,50 @@ export default function InventoryStatusPage() {
|
||||
return {
|
||||
...r,
|
||||
item_name: itemInfo?.name || "",
|
||||
spec: itemInfo?.spec || "",
|
||||
unit: resolve("item_inventory_unit", rawUnit) || rawUnit,
|
||||
warehouse_name: whMap.get(r.warehouse_code) || r.warehouse_code || "",
|
||||
status: resolve("status", r.status),
|
||||
_isLow: r.safety_qty && Number(r.current_qty) < Number(r.safety_qty),
|
||||
};
|
||||
});
|
||||
setStockItems(data);
|
||||
|
||||
if (showMissingItems) {
|
||||
const existingCodes = new Set(raw.map((r: any) => r.item_code).filter(Boolean));
|
||||
const missingRows = items
|
||||
.filter((i: any) => {
|
||||
const code = i.item_number || i.item_code;
|
||||
return code && !existingCodes.has(code);
|
||||
})
|
||||
.map((i: any) => {
|
||||
const code = i.item_number || i.item_code;
|
||||
const rawUnit = i.inventory_unit || "";
|
||||
return {
|
||||
id: `missing-${code}`,
|
||||
item_code: code,
|
||||
item_name: i.item_name || "",
|
||||
spec: i.size || "",
|
||||
warehouse_code: "",
|
||||
warehouse_name: "",
|
||||
location_code: "",
|
||||
current_qty: "0",
|
||||
safety_qty: "",
|
||||
unit: resolve("item_inventory_unit", rawUnit) || rawUnit,
|
||||
status: "미등록",
|
||||
_isLow: false,
|
||||
_isMissing: true,
|
||||
};
|
||||
});
|
||||
setStockItems([...data, ...missingRows]);
|
||||
} else {
|
||||
setStockItems(data);
|
||||
}
|
||||
} catch {
|
||||
toast.error("재고 목록을 불러오지 못했어요");
|
||||
} finally {
|
||||
setStockLoading(false);
|
||||
}
|
||||
}, [categoryOptions, searchFilters]);
|
||||
}, [categoryOptions, searchFilters, showMissingItems]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStock();
|
||||
@@ -260,7 +307,7 @@ export default function InventoryStatusPage() {
|
||||
`/table-management/tables/${HISTORY_TABLE}/data`,
|
||||
{
|
||||
page: 1,
|
||||
size: 500,
|
||||
size: 0,
|
||||
dataFilter: { enabled: true, filters: historyFilters },
|
||||
autoFilter: true,
|
||||
sort: { columnName: "transaction_date", order: "desc" },
|
||||
@@ -279,6 +326,35 @@ export default function InventoryStatusPage() {
|
||||
fetchHistory();
|
||||
}, [fetchHistory]);
|
||||
|
||||
useEffect(() => {
|
||||
const whCode = adjustForm.warehouse_code;
|
||||
if (!whCode) {
|
||||
setLocationList([]);
|
||||
return;
|
||||
}
|
||||
(async () => {
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/warehouse_location/data`, {
|
||||
page: 1,
|
||||
size: 0,
|
||||
dataFilter: {
|
||||
enabled: true,
|
||||
filters: [{ columnName: "warehouse_code", operator: "equals", value: whCode }],
|
||||
},
|
||||
autoFilter: true,
|
||||
});
|
||||
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
setLocationList(
|
||||
rows
|
||||
.filter((r: any) => r.location_code)
|
||||
.map((r: any) => ({ code: r.location_code, name: r.location_name || r.location_code }))
|
||||
);
|
||||
} catch {
|
||||
setLocationList([]);
|
||||
}
|
||||
})();
|
||||
}, [adjustForm.warehouse_code]);
|
||||
|
||||
// 재고 조정 저장
|
||||
const handleAdjustSave = async () => {
|
||||
if (!selectedStock) return;
|
||||
@@ -291,6 +367,20 @@ export default function InventoryStatusPage() {
|
||||
toast.error("조정 사유를 입력해주세요");
|
||||
return;
|
||||
}
|
||||
|
||||
const isMissing = !!selectedStock._isMissing;
|
||||
const targetWhCode = isMissing ? adjustForm.warehouse_code : (selectedStock.warehouse_code || "");
|
||||
const targetLocCode = isMissing ? adjustForm.location_code : (selectedStock.location_code || "");
|
||||
|
||||
if (isMissing && !targetWhCode) {
|
||||
toast.error("창고를 선택해주세요");
|
||||
return;
|
||||
}
|
||||
if (isMissing && adjustForm.adjust_type === "감소") {
|
||||
toast.error("미등록 품목은 감소 조정이 불가해요");
|
||||
return;
|
||||
}
|
||||
|
||||
setAdjustSaving(true);
|
||||
try {
|
||||
const changeQty = adjustForm.adjust_type === "증가" ? qty : -qty;
|
||||
@@ -301,8 +391,8 @@ export default function InventoryStatusPage() {
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
item_code: selectedStock.item_code,
|
||||
warehouse_code: selectedStock.warehouse_code || "",
|
||||
location_code: selectedStock.location_code || "",
|
||||
warehouse_code: targetWhCode,
|
||||
location_code: targetLocCode,
|
||||
transaction_type: "조정",
|
||||
transaction_date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().replace("Z", "+09:00"),
|
||||
quantity: String(changeQty),
|
||||
@@ -311,17 +401,33 @@ export default function InventoryStatusPage() {
|
||||
}
|
||||
);
|
||||
|
||||
await apiClient.put(
|
||||
`/table-management/tables/${STOCK_TABLE}/edit`,
|
||||
{
|
||||
originalData: { id: selectedStock.id },
|
||||
updatedData: { current_qty: afterQty },
|
||||
}
|
||||
);
|
||||
if (isMissing) {
|
||||
await apiClient.post(
|
||||
`/table-management/tables/${STOCK_TABLE}/add`,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
item_code: selectedStock.item_code,
|
||||
warehouse_code: targetWhCode,
|
||||
location_code: targetLocCode,
|
||||
current_qty: String(afterQty),
|
||||
safety_qty: "0",
|
||||
last_in_date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().replace("Z", "+09:00"),
|
||||
}
|
||||
);
|
||||
} else {
|
||||
await apiClient.put(
|
||||
`/table-management/tables/${STOCK_TABLE}/edit`,
|
||||
{
|
||||
originalData: { id: selectedStock.id },
|
||||
updatedData: { current_qty: afterQty },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
toast.success("재고가 조정되었어요");
|
||||
setAdjustModalOpen(false);
|
||||
setAdjustForm({ adjust_type: "증가", adjust_qty: "", reason: "" });
|
||||
setAdjustForm({ adjust_type: "증가", adjust_qty: "", reason: "", warehouse_code: "", location_code: "" });
|
||||
setSelectedStockId(null);
|
||||
fetchStock();
|
||||
} catch {
|
||||
toast.error("재고 조정에 실패했어요");
|
||||
@@ -385,6 +491,7 @@ export default function InventoryStatusPage() {
|
||||
stockItems.map((r) => ({
|
||||
품목코드: r.item_code,
|
||||
품명: r.item_name,
|
||||
규격: r.spec || "",
|
||||
창고: r.warehouse_name || r.warehouse_code,
|
||||
위치: r.location_code,
|
||||
현재수량: r.current_qty,
|
||||
@@ -438,6 +545,13 @@ export default function InventoryStatusPage() {
|
||||
{stockItems.length}건
|
||||
</Badge>
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
|
||||
<Checkbox
|
||||
checked={showMissingItems}
|
||||
onCheckedChange={(v) => setShowMissingItems(!!v)}
|
||||
/>
|
||||
<span>재고 없는 품목 표시</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<EDataTable
|
||||
@@ -513,6 +627,8 @@ export default function InventoryStatusPage() {
|
||||
adjust_type: "증가",
|
||||
adjust_qty: "",
|
||||
reason: "",
|
||||
warehouse_code: selectedStock._isMissing ? "" : (selectedStock.warehouse_code || ""),
|
||||
location_code: selectedStock._isMissing ? "" : (selectedStock.location_code || ""),
|
||||
});
|
||||
setAdjustModalOpen(true);
|
||||
}}
|
||||
@@ -672,6 +788,68 @@ export default function InventoryStatusPage() {
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-2">
|
||||
{selectedStock?._isMissing && (
|
||||
<>
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">
|
||||
창고 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={adjustForm.warehouse_code}
|
||||
onValueChange={(v) =>
|
||||
setAdjustForm((prev) => ({
|
||||
...prev,
|
||||
warehouse_code: v,
|
||||
location_code: "",
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="창고를 선택해주세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{warehouseList.map((w) => (
|
||||
<SelectItem key={w.code} value={w.code}>
|
||||
{w.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">
|
||||
위치
|
||||
</Label>
|
||||
<Select
|
||||
value={adjustForm.location_code}
|
||||
onValueChange={(v) =>
|
||||
setAdjustForm((prev) => ({ ...prev, location_code: v }))
|
||||
}
|
||||
disabled={!adjustForm.warehouse_code || locationList.length === 0}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
!adjustForm.warehouse_code
|
||||
? "창고를 먼저 선택하세요"
|
||||
: locationList.length === 0
|
||||
? "등록된 위치가 없어요"
|
||||
: "위치 선택 (선택 사항)"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{locationList.map((l) => (
|
||||
<SelectItem key={l.code} value={l.code}>
|
||||
{l.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">
|
||||
조정 유형
|
||||
@@ -681,6 +859,7 @@ export default function InventoryStatusPage() {
|
||||
onValueChange={(v) =>
|
||||
setAdjustForm((prev) => ({ ...prev, adjust_type: v }))
|
||||
}
|
||||
disabled={!!selectedStock?._isMissing}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="조정 유형 선택" />
|
||||
@@ -690,6 +869,11 @@ export default function InventoryStatusPage() {
|
||||
<SelectItem value="감소">감소 (출고 보정)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedStock?._isMissing && (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
미등록 품목은 증가(신규 등록)만 가능해요
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
|
||||
@@ -628,7 +628,7 @@ export default function MaterialStatusPage() {
|
||||
className="inline-flex items-center gap-1 rounded bg-muted/40 px-2 py-0.5 text-xs transition-colors hover:bg-muted/60"
|
||||
>
|
||||
<span className="font-semibold font-mono text-primary">
|
||||
{loc.location || loc.warehouse}
|
||||
{loc.warehouse_name || loc.location || loc.warehouse}
|
||||
</span>
|
||||
<span className="font-semibold">
|
||||
{loc.qty.toLocaleString()}
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
getItemsByDivision, getGeneralItems,
|
||||
type PkgUnit, type PkgUnitItem, type LoadingUnit, type LoadingUnitPkg, type ItemInfoForPkg,
|
||||
} from "@/lib/api/packaging";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
@@ -118,6 +119,45 @@ export default function PackagingPage() {
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// 카테고리 옵션 (inventory_unit / material) — 코드 → 라벨 변환
|
||||
const [categoryOptions, setCategoryOptions] = useState<
|
||||
Record<string, { code: string; label: string }[]>
|
||||
>({});
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
const flatten = (vals: any[]): { code: string; label: string }[] => {
|
||||
const out: { code: string; label: string }[] = [];
|
||||
for (const v of vals) {
|
||||
out.push({
|
||||
code: v.valueCode || v.value_code || v.code,
|
||||
label: v.valueLabel || v.value_label || v.label,
|
||||
});
|
||||
if (v.children?.length) out.push(...flatten(v.children));
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const optMap: Record<string, { code: string; label: string }[]> = {};
|
||||
for (const col of ["inventory_unit", "material"]) {
|
||||
try {
|
||||
const res = await apiClient.get(
|
||||
`/table-categories/item_info/${col}/values`
|
||||
);
|
||||
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
}
|
||||
setCategoryOptions(optMap);
|
||||
};
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const resolveCat = (col: string, code: string | null | undefined) => {
|
||||
if (!code) return "";
|
||||
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
|
||||
};
|
||||
|
||||
// --- 데이터 로드 (item_info 기반 + pkg_unit/loading_unit LEFT JOIN 방식) ---
|
||||
const fetchPkgUnits = useCallback(async () => {
|
||||
setPkgLoading(true);
|
||||
@@ -528,9 +568,9 @@ export default function PackagingPage() {
|
||||
|
||||
{/* 4. 콘텐츠 영역 */}
|
||||
{activeTab === "packing" ? (
|
||||
<div className="flex flex-1 flex-col overflow-hidden rounded-lg border bg-card">
|
||||
<div className="flex flex-1 flex-row overflow-hidden rounded-lg border bg-card">
|
||||
{/* 포장재 목록 테이블 */}
|
||||
<div className={cn("overflow-auto", selectedPkg ? "flex-[0_0_50%] border-b" : "flex-1")}>
|
||||
<div className="flex-[0_0_50%] overflow-auto border-r">
|
||||
<EDataTable
|
||||
columns={ts.visibleColumns.map((col): EDataTableColumn<PkgUnit> => {
|
||||
const renderMap: Record<string, Partial<EDataTableColumn<PkgUnit>>> = {
|
||||
@@ -570,8 +610,8 @@ export default function PackagingPage() {
|
||||
</div>
|
||||
|
||||
{/* 매칭 품목 서브패널 */}
|
||||
{selectedPkg && (
|
||||
<>
|
||||
{selectedPkg ? (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex shrink-0 items-center justify-between bg-muted/50 px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">매칭 품목</span>
|
||||
@@ -622,7 +662,7 @@ export default function PackagingPage() {
|
||||
<TableCell className="p-2 font-medium">{item.item_number}</TableCell>
|
||||
<TableCell className="p-2">{item.item_name || "-"}</TableCell>
|
||||
<TableCell className="p-2">{item.spec || "-"}</TableCell>
|
||||
<TableCell className="p-2">{item.unit || "EA"}</TableCell>
|
||||
<TableCell className="p-2">{resolveCat("inventory_unit", item.inventory_unit) || "EA"}</TableCell>
|
||||
<TableCell className="p-2 text-right font-semibold">{Number(item.pkg_qty).toLocaleString()}</TableCell>
|
||||
<TableCell className="p-2 text-center">
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => handleDeletePkgItem(item)}>
|
||||
@@ -635,14 +675,21 @@ export default function PackagingPage() {
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center p-8">
|
||||
<div className="text-center">
|
||||
<Package className="mx-auto mb-2 h-10 w-10 text-muted-foreground/30" />
|
||||
<p className="text-sm text-muted-foreground">좌측 목록에서 포장재를 선택하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* 적재함 관리 탭 */
|
||||
<div className="flex flex-1 flex-col overflow-hidden rounded-lg border bg-card">
|
||||
<div className="flex flex-1 flex-row overflow-hidden rounded-lg border bg-card">
|
||||
{/* 적재함 목록 테이블 */}
|
||||
<div className={cn("overflow-auto", selectedLoading ? "flex-[0_0_50%] border-b" : "flex-1")}>
|
||||
<div className="flex-[0_0_50%] overflow-auto border-r">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
@@ -709,8 +756,8 @@ export default function PackagingPage() {
|
||||
</div>
|
||||
|
||||
{/* 포장구성 서브패널 */}
|
||||
{selectedLoading && (
|
||||
<>
|
||||
{selectedLoading ? (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex shrink-0 items-center justify-between bg-muted/50 px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">적재 가능 포장단위</span>
|
||||
@@ -774,7 +821,14 @@ export default function PackagingPage() {
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center p-8">
|
||||
<div className="text-center">
|
||||
<Box className="mx-auto mb-2 h-10 w-10 text-muted-foreground/30" />
|
||||
<p className="text-sm text-muted-foreground">좌측 목록에서 적재함을 선택하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -940,8 +994,8 @@ export default function PackagingPage() {
|
||||
<TableCell className="p-2 font-medium">{item.item_number}</TableCell>
|
||||
<TableCell className="p-2">{item.item_name}</TableCell>
|
||||
<TableCell className="p-2">{item.spec || "-"}</TableCell>
|
||||
<TableCell className="p-2">{item.material || "-"}</TableCell>
|
||||
<TableCell className="p-2">{item.unit || "EA"}</TableCell>
|
||||
<TableCell className="p-2">{resolveCat("material", item.material) || "-"}</TableCell>
|
||||
<TableCell className="p-2">{resolveCat("inventory_unit", item.inventory_unit) || "EA"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
@@ -250,6 +250,8 @@ interface SelectedSourceItem {
|
||||
total_amount: number;
|
||||
source_table: string;
|
||||
source_id: string;
|
||||
detail_id?: string;
|
||||
header_id?: string;
|
||||
}
|
||||
|
||||
export default function ReceivingPage() {
|
||||
@@ -584,7 +586,7 @@ export default function ReceivingPage() {
|
||||
const first = grouped[0] || row;
|
||||
|
||||
setEditMode(true);
|
||||
setEditItemIds(grouped.map((g) => g.id));
|
||||
setEditItemIds(grouped.map((g, idx) => (g as any).detail_id || `${g.id}__${idx}`));
|
||||
setModalInboundNo(inNo);
|
||||
setModalInboundType(first.inbound_type || "구매입고");
|
||||
setModalInboundDate(first.inbound_date ? String(first.inbound_date).slice(0, 10) : "");
|
||||
@@ -594,8 +596,10 @@ export default function ReceivingPage() {
|
||||
setModalManager((first as any).manager || "");
|
||||
setModalMemo(first.memo || "");
|
||||
setSelectedItems(
|
||||
grouped.map((g) => ({
|
||||
key: g.id,
|
||||
grouped.map((g, idx) => ({
|
||||
key: (g as any).detail_id || `${g.id}__${idx}`,
|
||||
detail_id: (g as any).detail_id || undefined,
|
||||
header_id: g.id,
|
||||
inbound_type: (g as any).detail_inbound_type || g.inbound_type || "",
|
||||
reference_number: g.reference_number || "",
|
||||
supplier_code: (g as any).supplier_code || "",
|
||||
@@ -782,7 +786,7 @@ export default function ReceivingPage() {
|
||||
await Promise.all([
|
||||
...toDelete.map((id) => deleteReceiving(id)),
|
||||
...toUpdate.map((item) =>
|
||||
updateReceiving(item.key, {
|
||||
updateReceiving(item.header_id || item.key, {
|
||||
inbound_date: modalInboundDate,
|
||||
inbound_qty: item.inbound_qty,
|
||||
unit_price: item.unit_price,
|
||||
@@ -790,6 +794,7 @@ export default function ReceivingPage() {
|
||||
warehouse_code: modalWarehouse || undefined,
|
||||
location_code: modalLocation || undefined,
|
||||
memo: modalMemo || undefined,
|
||||
detail_id: item.detail_id,
|
||||
} as any)
|
||||
),
|
||||
...(toCreate.length > 0
|
||||
|
||||
@@ -74,7 +74,7 @@ const WAREHOUSE_COLUMNS = [
|
||||
{ key: "warehouse_code", label: "창고코드" },
|
||||
{ key: "warehouse_name", label: "창고명" },
|
||||
{ key: "warehouse_type", label: "유형" },
|
||||
{ key: "manager", label: "관리자" },
|
||||
{ key: "manager_name", label: "관리자" },
|
||||
{ key: "status", label: "상태" },
|
||||
];
|
||||
const LOCATION_TABLE = "warehouse_location";
|
||||
@@ -158,6 +158,10 @@ export default function WarehouseManagementPage() {
|
||||
const [rackStatus, setRackStatus] = useState("");
|
||||
const [rackPreview, setRackPreview] = useState<any[]>([]);
|
||||
const [rackSaving, setRackSaving] = useState(false);
|
||||
// 위치명 접미사 (자동 조립: {zone}{구역접미사}-{row}{열접미사}-{level}{단접미사})
|
||||
const [rackZoneLabel, setRackZoneLabel] = useState("구역");
|
||||
const [rackRowLabel, setRackRowLabel] = useState("열");
|
||||
const [rackLevelLabel, setRackLevelLabel] = useState("단");
|
||||
|
||||
// 카테고리 옵션
|
||||
const [categoryOptions, setCategoryOptions] = useState<
|
||||
@@ -230,7 +234,7 @@ export default function WarehouseManagementPage() {
|
||||
`/table-management/tables/${WAREHOUSE_TABLE}/data`,
|
||||
{
|
||||
page: 1,
|
||||
size: 500,
|
||||
size: 0,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
sort: { columnName: "warehouse_code", order: "asc" },
|
||||
@@ -239,6 +243,8 @@ export default function WarehouseManagementPage() {
|
||||
const raw = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
const data = raw.map((r: any) => ({
|
||||
...r,
|
||||
_warehouse_type_code: r.warehouse_type,
|
||||
_status_code: r.status,
|
||||
warehouse_type: resolveCategory(categoryOptions, "warehouse_type", r.warehouse_type),
|
||||
status: resolveCategory(categoryOptions, "status", r.status),
|
||||
}));
|
||||
@@ -270,7 +276,7 @@ export default function WarehouseManagementPage() {
|
||||
`/table-management/tables/${LOCATION_TABLE}/data`,
|
||||
{
|
||||
page: 1,
|
||||
size: 500,
|
||||
size: 0,
|
||||
dataFilter: {
|
||||
enabled: true,
|
||||
filters: [
|
||||
@@ -344,7 +350,11 @@ export default function WarehouseManagementPage() {
|
||||
|
||||
const openWarehouseEditModal = (row: any) => {
|
||||
setWarehouseEditMode(true);
|
||||
setWarehouseForm({ ...row });
|
||||
setWarehouseForm({
|
||||
...row,
|
||||
warehouse_type: row._warehouse_type_code ?? row.warehouse_type ?? "",
|
||||
status: row._status_code ?? row.status ?? "",
|
||||
});
|
||||
setWarehouseModalOpen(true);
|
||||
};
|
||||
|
||||
@@ -374,10 +384,10 @@ export default function WarehouseManagementPage() {
|
||||
warehouse_code: finalWarehouseCode,
|
||||
warehouse_name: warehouseForm.warehouse_name?.trim(),
|
||||
warehouse_type: warehouseForm.warehouse_type || "",
|
||||
manager: warehouseForm.manager || "",
|
||||
address: warehouseForm.address || "",
|
||||
manager_name: warehouseForm.manager_name || "",
|
||||
contact: warehouseForm.contact || "",
|
||||
status: warehouseForm.status || "",
|
||||
description: warehouseForm.description || "",
|
||||
memo: warehouseForm.memo || "",
|
||||
};
|
||||
|
||||
// 신규 등록 시 창고코드 중복 체크
|
||||
@@ -630,7 +640,7 @@ export default function WarehouseManagementPage() {
|
||||
duplicates.push(locationCode);
|
||||
continue;
|
||||
}
|
||||
const locationName = `${zoneCode}구역-${rowStr}열-${level}단`;
|
||||
const locationName = `${zoneCode}${rackZoneLabel}-${rowStr}${rackRowLabel}-${level}${rackLevelLabel}`;
|
||||
items.push({
|
||||
location_code: locationCode,
|
||||
location_name: locationName,
|
||||
@@ -729,7 +739,7 @@ export default function WarehouseManagementPage() {
|
||||
창고코드: r.warehouse_code,
|
||||
창고명: r.warehouse_name,
|
||||
유형: r.warehouse_type,
|
||||
관리자: r.manager,
|
||||
관리자: r.manager_name,
|
||||
상태: r.status,
|
||||
})),
|
||||
"창고정보"
|
||||
@@ -1041,9 +1051,9 @@ export default function WarehouseManagementPage() {
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">관리자</Label>
|
||||
<Input
|
||||
value={warehouseForm.manager || ""}
|
||||
value={warehouseForm.manager_name || ""}
|
||||
onChange={(e) =>
|
||||
setWarehouseForm((prev) => ({ ...prev, manager: e.target.value }))
|
||||
setWarehouseForm((prev) => ({ ...prev, manager_name: e.target.value }))
|
||||
}
|
||||
placeholder="관리자를 입력해주세요"
|
||||
/>
|
||||
@@ -1069,24 +1079,24 @@ export default function WarehouseManagementPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* 주소 (전체 너비) */}
|
||||
{/* 연락처 (전체 너비) */}
|
||||
<div className="grid gap-1.5 col-span-2">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">주소</Label>
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">연락처</Label>
|
||||
<Input
|
||||
value={warehouseForm.address || ""}
|
||||
value={warehouseForm.contact || ""}
|
||||
onChange={(e) =>
|
||||
setWarehouseForm((prev) => ({ ...prev, address: e.target.value }))
|
||||
setWarehouseForm((prev) => ({ ...prev, contact: e.target.value }))
|
||||
}
|
||||
placeholder="주소를 입력해주세요"
|
||||
placeholder="연락처를 입력해주세요"
|
||||
/>
|
||||
</div>
|
||||
{/* 비고 (전체 너비) */}
|
||||
<div className="grid gap-1.5 col-span-2">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">비고</Label>
|
||||
<Input
|
||||
value={warehouseForm.description || ""}
|
||||
value={warehouseForm.memo || ""}
|
||||
onChange={(e) =>
|
||||
setWarehouseForm((prev) => ({ ...prev, description: e.target.value }))
|
||||
setWarehouseForm((prev) => ({ ...prev, memo: e.target.value }))
|
||||
}
|
||||
placeholder="비고를 입력해주세요"
|
||||
/>
|
||||
@@ -1496,6 +1506,38 @@ export default function WarehouseManagementPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 위치명 형식 — 구역/열/단 뒤에 붙일 표현만 자유 입력 */}
|
||||
<div className="rounded-lg border p-3 bg-muted/30 space-y-2">
|
||||
<Label className="text-xs font-semibold">위치명 형식</Label>
|
||||
<div className="flex items-center gap-1 text-xs flex-wrap">
|
||||
<span className="font-mono text-muted-foreground">A</span>
|
||||
<Input
|
||||
value={rackZoneLabel}
|
||||
onChange={(e) => setRackZoneLabel(e.target.value)}
|
||||
placeholder="구역"
|
||||
className="h-8 w-20 text-xs"
|
||||
/>
|
||||
<span className="font-mono text-muted-foreground">- 01</span>
|
||||
<Input
|
||||
value={rackRowLabel}
|
||||
onChange={(e) => setRackRowLabel(e.target.value)}
|
||||
placeholder="열"
|
||||
className="h-8 w-20 text-xs"
|
||||
/>
|
||||
<span className="font-mono text-muted-foreground">- 1</span>
|
||||
<Input
|
||||
value={rackLevelLabel}
|
||||
onChange={(e) => setRackLevelLabel(e.target.value)}
|
||||
placeholder="단"
|
||||
className="h-8 w-20 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
예시: <span className="font-mono font-semibold">A{rackZoneLabel}-01{rackRowLabel}-1{rackLevelLabel}</span>
|
||||
{" "}— 구역/열/단 번호는 자동 계산되고, 뒤에 붙는 명칭만 수정할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 등록 미리보기 */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
|
||||
@@ -193,7 +193,7 @@ export default function CompanyPage() {
|
||||
setDeptLoading(true);
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/${DEPT_TABLE}/data`, {
|
||||
page: 1, size: 500, autoFilter: true,
|
||||
page: 1, size: 0, autoFilter: true,
|
||||
});
|
||||
const raw = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
setDepts(raw);
|
||||
@@ -217,7 +217,7 @@ export default function CompanyPage() {
|
||||
setMemberLoading(true);
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/${USER_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "dept_code", operator: "equals", value: selectedDeptCode }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
@@ -563,10 +563,6 @@ export default function CompanyPage() {
|
||||
|
||||
{/* 기본 정보 그리드 (2열) */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">회사코드</Label>
|
||||
<Input value={companyForm.company_code || ""} className="h-9 bg-muted/50" disabled readOnly />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
회사명 <span className="text-destructive">*</span>
|
||||
|
||||
@@ -99,7 +99,7 @@ export default function DepartmentPage() {
|
||||
try {
|
||||
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
|
||||
const res = await apiClient.post(`/table-management/tables/${DEPT_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
@@ -129,7 +129,7 @@ export default function DepartmentPage() {
|
||||
? [{ columnName: "dept_code", operator: "equals", value: selectedDeptCode }]
|
||||
: [];
|
||||
const res = await apiClient.post(`/table-management/tables/${USER_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
|
||||
@@ -5,7 +5,12 @@ import { Settings2, Tags, Hash } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CategoryColumnList } from "@/components/table-category/CategoryColumnList";
|
||||
import { CategoryValueManager } from "@/components/table-category/CategoryValueManager";
|
||||
import { CategoryValueManagerTree } from "@/components/table-category/CategoryValueManagerTree";
|
||||
import { NumberingRuleDesigner } from "@/components/numbering-rule/NumberingRuleDesigner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
|
||||
|
||||
const TABS = [
|
||||
{ id: "category", label: "카테고리 설정", icon: Tags },
|
||||
@@ -21,6 +26,12 @@ export default function OptionsSettingPage() {
|
||||
const [selectedColumnLabel, setSelectedColumnLabel] = useState("");
|
||||
const [selectedTableName, setSelectedTableName] = useState("");
|
||||
|
||||
const [useHierarchy, setUseHierarchy] = useState(false);
|
||||
const [hasChildRows, setHasChildRows] = useState(false);
|
||||
const [detectingHierarchy, setDetectingHierarchy] = useState(false);
|
||||
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||
|
||||
const [leftWidth, setLeftWidth] = useState(340);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -51,6 +62,71 @@ export default function OptionsSettingPage() {
|
||||
};
|
||||
}, [isDragging]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedColumn || !selectedTableName) {
|
||||
setUseHierarchy(false);
|
||||
setHasChildRows(false);
|
||||
return;
|
||||
}
|
||||
const columnNameOnly = selectedColumn.includes(".")
|
||||
? selectedColumn.split(".").pop()!
|
||||
: selectedColumn;
|
||||
let cancelled = false;
|
||||
setDetectingHierarchy(true);
|
||||
(async () => {
|
||||
const res = await getCategoryValues(selectedTableName, columnNameOnly, true);
|
||||
if (cancelled) return;
|
||||
const values = (res as any)?.data || [];
|
||||
const hasChild = Array.isArray(values)
|
||||
? values.some(
|
||||
(v: any) =>
|
||||
(typeof v.depth === "number" && v.depth > 1) ||
|
||||
(v.parentValueId !== null && v.parentValueId !== undefined),
|
||||
)
|
||||
: false;
|
||||
setHasChildRows(hasChild);
|
||||
setUseHierarchy(hasChild);
|
||||
setDetectingHierarchy(false);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedColumn, selectedTableName]);
|
||||
|
||||
const handleToggleHierarchy = useCallback(
|
||||
async (checked: boolean) => {
|
||||
if (!checked && hasChildRows) {
|
||||
const ok = await confirm(
|
||||
"이미 등록된 하위분류(중/소분류)가 있습니다.\n하위분류 사용을 해제해도 기존 데이터는 삭제되지 않으며, 다시 사용 설정 시 그대로 복원됩니다.\n계속하시겠습니까?",
|
||||
{ variant: "destructive", confirmText: "해제" },
|
||||
);
|
||||
if (!ok) return;
|
||||
}
|
||||
setUseHierarchy(checked);
|
||||
},
|
||||
[hasChildRows, confirm],
|
||||
);
|
||||
|
||||
const columnNameOnly = selectedColumn
|
||||
? selectedColumn.includes(".")
|
||||
? selectedColumn.split(".").pop()!
|
||||
: selectedColumn
|
||||
: "";
|
||||
|
||||
const headerRight = selectedColumn ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="use-hierarchy-switch" className="cursor-pointer text-xs">
|
||||
하위분류 사용
|
||||
</Label>
|
||||
<Switch
|
||||
id="use-hierarchy-switch"
|
||||
checked={useHierarchy}
|
||||
onCheckedChange={handleToggleHierarchy}
|
||||
disabled={detectingHierarchy}
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col p-3 gap-3">
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -108,11 +184,21 @@ export default function OptionsSettingPage() {
|
||||
|
||||
<div className="flex-1 min-w-0 border rounded-lg bg-card overflow-hidden">
|
||||
{selectedColumn && selectedTableName ? (
|
||||
<CategoryValueManager
|
||||
tableName={selectedTableName}
|
||||
columnName={selectedColumn.includes(".") ? selectedColumn.split(".").pop()! : selectedColumn}
|
||||
columnLabel={selectedColumnLabel}
|
||||
/>
|
||||
useHierarchy ? (
|
||||
<CategoryValueManagerTree
|
||||
tableName={selectedTableName}
|
||||
columnName={columnNameOnly}
|
||||
columnLabel={selectedColumnLabel}
|
||||
headerRight={headerRight}
|
||||
/>
|
||||
) : (
|
||||
<CategoryValueManager
|
||||
tableName={selectedTableName}
|
||||
columnName={columnNameOnly}
|
||||
columnLabel={selectedColumnLabel}
|
||||
headerRight={headerRight}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center space-y-2">
|
||||
@@ -131,6 +217,7 @@ export default function OptionsSettingPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{ConfirmDialogComponent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,6 +72,36 @@ export default function MoldInfoPage() {
|
||||
const [selectedMoldCode, setSelectedMoldCode] = useState<string | null>(null);
|
||||
const [viewMode, setViewMode] = useState<"list" | "grid">("list");
|
||||
|
||||
// ─── 카테고리 옵션 (금형유형, 운영상태) ───
|
||||
const [moldTypeCatOptions, setMoldTypeCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
const [operationStatusCatOptions, setOperationStatusCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const flatten = (arr: any[]): { code: string; label: string }[] => {
|
||||
const result: { code: string; label: string }[] = [];
|
||||
for (const v of arr) { result.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) result.push(...flatten(v.children)); }
|
||||
return result;
|
||||
};
|
||||
(async () => {
|
||||
try {
|
||||
const [typeRes, statusRes] = await Promise.all([
|
||||
apiClient.get("/table-categories/mold_mng/mold_type/values"),
|
||||
apiClient.get("/table-categories/mold_mng/operation_status/values"),
|
||||
]);
|
||||
if (typeRes.data?.success) setMoldTypeCatOptions(flatten(typeRes.data.data || []));
|
||||
if (statusRes.data?.success) setOperationStatusCatOptions(flatten(statusRes.data.data || []));
|
||||
} catch { /* skip */ }
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const resolveMoldType = (code: string) => moldTypeCatOptions.find((o) => o.code === code)?.label || code;
|
||||
const resolveOpStatus = (code: string) => {
|
||||
const catLabel = operationStatusCatOptions.find((o) => o.code === code)?.label;
|
||||
if (catLabel) return catLabel;
|
||||
const legacyMap: Record<string, string> = { ACTIVE: "사용중", INACTIVE: "미사용", REPAIR: "수리중", DISPOSED: "폐기", IN_USE: "사용중" };
|
||||
return legacyMap[code] || code;
|
||||
};
|
||||
|
||||
// ─── 검색 필터 ───
|
||||
const [filterCode, setFilterCode] = useState("");
|
||||
const [filterName, setFilterName] = useState("");
|
||||
@@ -426,7 +456,7 @@ export default function MoldInfoPage() {
|
||||
// ─── 카드 렌더링 ───
|
||||
const renderCard = (mold: any) => {
|
||||
const pct = calcLifePct(mold);
|
||||
const st = STATUS_MAP[mold.operation_status] || { label: mold.operation_status || "-", variant: "secondary" as const };
|
||||
const stLabel = resolveOpStatus(mold.operation_status);
|
||||
const isSelected = selectedMoldCode === mold.mold_code;
|
||||
|
||||
return (
|
||||
@@ -460,7 +490,7 @@ export default function MoldInfoPage() {
|
||||
<Box className="w-8 h-8 text-muted-foreground/50" />
|
||||
)}
|
||||
<div className="absolute top-2 right-2">
|
||||
<Badge variant={st.variant} className="text-[10px]">{st.label}</Badge>
|
||||
<Badge variant="secondary" className="text-[10px]">{stLabel}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -470,7 +500,7 @@ export default function MoldInfoPage() {
|
||||
<p className="text-xs text-muted-foreground font-mono truncate">{mold.mold_code}</p>
|
||||
<p className="text-sm font-semibold truncate">{mold.mold_name}</p>
|
||||
{mold.mold_type && (
|
||||
<Badge variant="outline" className="text-[10px] mt-1">{mold.mold_type}</Badge>
|
||||
<Badge variant="outline" className="text-[10px] mt-1">{resolveMoldType(mold.mold_type)}</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -531,10 +561,7 @@ export default function MoldInfoPage() {
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">전체</SelectItem>
|
||||
<SelectItem value="사출금형">사출금형</SelectItem>
|
||||
<SelectItem value="프레스금형">프레스금형</SelectItem>
|
||||
<SelectItem value="다이캐스팅">다이캐스팅</SelectItem>
|
||||
<SelectItem value="단조금형">단조금형</SelectItem>
|
||||
{moldTypeCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -546,10 +573,7 @@ export default function MoldInfoPage() {
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">전체</SelectItem>
|
||||
<SelectItem value="ACTIVE">사용중</SelectItem>
|
||||
<SelectItem value="INSPECTION">점검중</SelectItem>
|
||||
<SelectItem value="REPAIR">수리중</SelectItem>
|
||||
<SelectItem value="DISPOSED">폐기</SelectItem>
|
||||
{operationStatusCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -670,13 +694,13 @@ export default function MoldInfoPage() {
|
||||
<h2 className="text-xl font-bold mb-2 truncate">{selectedMold.mold_name}</h2>
|
||||
<div className="flex gap-1.5 mb-4 flex-wrap">
|
||||
{selectedMold.mold_type && (
|
||||
<Badge variant="outline">{selectedMold.mold_type}</Badge>
|
||||
<Badge variant="outline">{resolveMoldType(selectedMold.mold_type)}</Badge>
|
||||
)}
|
||||
{selectedMold.category && (
|
||||
<Badge variant="secondary">{selectedMold.category}</Badge>
|
||||
)}
|
||||
<Badge variant={STATUS_MAP[selectedMold.operation_status]?.variant || "secondary"}>
|
||||
{STATUS_MAP[selectedMold.operation_status]?.label || selectedMold.operation_status || "-"}
|
||||
<Badge variant="secondary">
|
||||
{resolveOpStatus(selectedMold.operation_status) || "-"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
@@ -811,15 +835,15 @@ export default function MoldInfoPage() {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{serials.map((s: any) => {
|
||||
const ss = SERIAL_STATUS_MAP[s.status] || { label: s.status || "-", variant: "secondary" as const };
|
||||
const maxShot = detail?.shot_count || 0;
|
||||
const ssLabel = resolveOpStatus(s.status);
|
||||
const maxShot = selectedMold?.shot_count || 0;
|
||||
const curShot = s.current_shot_count || 0;
|
||||
const pct = maxShot > 0 ? Math.min(Math.round((curShot / maxShot) * 100), 100) : 0;
|
||||
return (
|
||||
<TableRow key={s.id}>
|
||||
<TableCell className="text-[13px] font-mono font-semibold">{s.serial_number}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={ss.variant} className="text-[10px]">{ss.label}</Badge>
|
||||
<Badge variant="secondary" className="text-[10px]">{ssLabel}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{maxShot > 0 ? (
|
||||
@@ -1043,10 +1067,7 @@ export default function MoldInfoPage() {
|
||||
<SelectValue placeholder="선택해주세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="사출금형">사출금형</SelectItem>
|
||||
<SelectItem value="프레스금형">프레스금형</SelectItem>
|
||||
<SelectItem value="다이캐스팅">다이캐스팅</SelectItem>
|
||||
<SelectItem value="단조금형">단조금형</SelectItem>
|
||||
{moldTypeCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -1117,10 +1138,7 @@ export default function MoldInfoPage() {
|
||||
<SelectValue placeholder="선택해주세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ACTIVE">사용중</SelectItem>
|
||||
<SelectItem value="INSPECTION">점검중</SelectItem>
|
||||
<SelectItem value="REPAIR">수리중</SelectItem>
|
||||
<SelectItem value="DISPOSED">폐기</SelectItem>
|
||||
{operationStatusCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -1175,10 +1193,7 @@ export default function MoldInfoPage() {
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="IN_USE">사용중</SelectItem>
|
||||
<SelectItem value="STORED">보관중</SelectItem>
|
||||
<SelectItem value="REPAIR">수리중</SelectItem>
|
||||
<SelectItem value="DISPOSED">폐기</SelectItem>
|
||||
{operationStatusCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -170,7 +170,7 @@ export default function EquipmentMonitoringPage() {
|
||||
apiClient.post("/table-management/tables/equipment_mng/data", {
|
||||
autoFilter: true,
|
||||
page: 1,
|
||||
size: 500,
|
||||
size: 0,
|
||||
}),
|
||||
apiClient.get("/work-instruction/list").catch(() => ({ data: { data: [] } })),
|
||||
apiClient.post("/table-management/tables/work_order_process/data", {
|
||||
|
||||
@@ -98,12 +98,26 @@ export default function SubcontractorItemPage() {
|
||||
}
|
||||
return result;
|
||||
};
|
||||
for (const col of ["material", "division", "type", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"]) {
|
||||
for (const col of ["material", "division", "type", "status", "unit", "inventory_unit", "currency_code", "user_type01", "user_type02"]) {
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/${ITEM_TABLE}/${col}/values`);
|
||||
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
// 외주사관리에서 사용하는 subcontractor_item_prices.currency_code도 병합
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/subcontractor_item_prices/currency_code/values`);
|
||||
if (res.data?.success) {
|
||||
const extra = flatten(res.data.data || []);
|
||||
const seen = new Set((optMap["currency_code"] || []).map((o) => o.code));
|
||||
for (const e of extra) {
|
||||
if (!seen.has(e.code)) {
|
||||
(optMap["currency_code"] ||= []).push(e);
|
||||
seen.add(e.code);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
// 외주업체 거래유형 (subcontractor_mng.division)
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/${SUBCONTRACTOR_TABLE}/division/values`);
|
||||
@@ -124,10 +138,10 @@ export default function SubcontractorItemPage() {
|
||||
item_number: { width: "w-[110px]" },
|
||||
item_name: { minWidth: "min-w-[130px]", render: (v) => v || "-" },
|
||||
size: { width: "w-[90px]", render: (v) => v || "-" },
|
||||
unit: { width: "w-[60px]", render: (v) => v || "-" },
|
||||
unit: { width: "w-[60px]", render: (v) => resolve("unit", v) || "-" },
|
||||
standard_price: { width: "w-[90px]", align: "right", formatNumber: true },
|
||||
selling_price: { width: "w-[90px]", align: "right", formatNumber: true },
|
||||
currency_code: { width: "w-[50px]", render: (v) => v || "-" },
|
||||
currency_code: { width: "w-[50px]", render: (v) => resolve("currency_code", v) || "-" },
|
||||
status: { width: "w-[60px]", render: (v) => v || "-" },
|
||||
};
|
||||
return ts.visibleColumns.map((col) => ({
|
||||
@@ -135,7 +149,8 @@ export default function SubcontractorItemPage() {
|
||||
label: col.label,
|
||||
...colProps[col.key],
|
||||
}));
|
||||
}, [ts.visibleColumns]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ts.visibleColumns, categoryOptions]);
|
||||
|
||||
// 좌측: 품목 조회 (division이 "외주관리"인 품목만 필터링)
|
||||
const outsourcingDivisionCode = categoryOptions["division"]?.find(
|
||||
@@ -153,7 +168,7 @@ export default function SubcontractorItemPage() {
|
||||
filters.push({ columnName: "item_name", operator: "contains", value: searchKeyword });
|
||||
}
|
||||
const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
@@ -164,8 +179,8 @@ export default function SubcontractorItemPage() {
|
||||
for (const col of CATS) {
|
||||
if (converted[col]) converted[col] = resolve(col, converted[col]);
|
||||
}
|
||||
// item_info의 inventory_unit을 단위 표시용 unit에 매핑
|
||||
converted.unit = converted.inventory_unit || converted.unit || "";
|
||||
// "단위" 컬럼은 재고단위(inventory_unit)만 사용 — unit 폴백 제거
|
||||
converted.unit = converted.inventory_unit || "";
|
||||
return converted;
|
||||
});
|
||||
setItems(data);
|
||||
@@ -191,7 +206,7 @@ export default function SubcontractorItemPage() {
|
||||
setSubcontractorLoading(true);
|
||||
try {
|
||||
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_id", operator: "equals", value: itemKey }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
@@ -212,11 +227,35 @@ export default function SubcontractorItemPage() {
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
|
||||
setSubcontractorItems(mappings.map((m: any) => ({
|
||||
...m,
|
||||
subcontractor_code: m.subcontractor_id,
|
||||
subcontractor_name: subMap[m.subcontractor_id]?.subcontractor_name || "",
|
||||
})));
|
||||
// 외주사관리에서 입력된 최신 단가(subcontractor_item_prices) 조회 → subcontractor_id 별 최신 1건
|
||||
const priceMap: Record<string, any> = {};
|
||||
try {
|
||||
const priceRes = await apiClient.post(`/table-management/tables/subcontractor_item_prices/data`, {
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_id", operator: "equals", value: itemKey }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const prices = priceRes.data?.data?.data || priceRes.data?.data?.rows || [];
|
||||
for (const p of prices) {
|
||||
const key = p.subcontractor_id;
|
||||
if (!key) continue;
|
||||
if (!priceMap[key] || (p.start_date && (!priceMap[key].start_date || p.start_date > priceMap[key].start_date))) {
|
||||
priceMap[key] = p;
|
||||
}
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
|
||||
setSubcontractorItems(mappings.map((m: any) => {
|
||||
const price = priceMap[m.subcontractor_id] || {};
|
||||
return {
|
||||
...m,
|
||||
subcontractor_code: m.subcontractor_id,
|
||||
subcontractor_name: subMap[m.subcontractor_id]?.subcontractor_name || "",
|
||||
base_price: price.base_price ?? m.base_price,
|
||||
calculated_price: price.calculated_price ?? price.unit_price ?? m.calculated_price,
|
||||
currency_code: resolve("currency_code", price.currency_code ?? m.currency_code),
|
||||
};
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error("외주업체 조회 실패:", err);
|
||||
} finally {
|
||||
@@ -224,7 +263,8 @@ export default function SubcontractorItemPage() {
|
||||
}
|
||||
};
|
||||
fetchSubcontractorItems();
|
||||
}, [selectedItem?.item_number]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedItem?.item_number, categoryOptions]);
|
||||
|
||||
// 외주업체 검색
|
||||
const searchSubcontractors = async () => {
|
||||
|
||||
@@ -194,7 +194,7 @@ export default function SubcontractorManagementPage() {
|
||||
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
|
||||
|
||||
const res = await apiClient.post(`/table-management/tables/${SUBCONTRACTOR_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
@@ -229,7 +229,7 @@ export default function SubcontractorManagementPage() {
|
||||
setPriceLoading(true);
|
||||
try {
|
||||
const mappingRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "subcontractor_id", operator: "equals", value: selectedSubcontractor.subcontractor_code },
|
||||
]},
|
||||
@@ -256,7 +256,7 @@ export default function SubcontractorManagementPage() {
|
||||
if (mappings.length > 0) {
|
||||
try {
|
||||
const priceRes = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "subcontractor_id", operator: "equals", value: selectedSubcontractor.subcontractor_code },
|
||||
]},
|
||||
@@ -413,7 +413,7 @@ export default function SubcontractorManagementPage() {
|
||||
const filters: any[] = [];
|
||||
if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
|
||||
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
@@ -550,7 +550,7 @@ export default function SubcontractorManagementPage() {
|
||||
}> = [];
|
||||
try {
|
||||
const priceRes = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "subcontractor_id", operator: "equals", value: selectedSubcontractor!.subcontractor_code },
|
||||
{ columnName: "item_id", operator: "equals", value: itemKey },
|
||||
@@ -610,7 +610,7 @@ export default function SubcontractorManagementPage() {
|
||||
// 2) 기존 단가 모두 삭제 (subcontractor_id + item_id 기준)
|
||||
try {
|
||||
const existingPrices = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "subcontractor_id", operator: "equals", value: selectedSubcontractor.subcontractor_code },
|
||||
{ columnName: "item_id", operator: "equals", value: itemKey },
|
||||
@@ -727,7 +727,7 @@ export default function SubcontractorManagementPage() {
|
||||
|
||||
if (subCodes.length > 0) {
|
||||
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
|
||||
page: 1, size: 5000, autoFilter: true,
|
||||
page: 1, size: 0, autoFilter: true,
|
||||
});
|
||||
const mappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
Settings2,
|
||||
Save,
|
||||
Package,
|
||||
Pencil,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
@@ -349,13 +350,19 @@ export default function BomManagementPage() {
|
||||
|
||||
const res = await apiClient.post(`/table-management/tables/${BOM_TABLE}/data`, {
|
||||
page: 1,
|
||||
size: 500,
|
||||
size: 0,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
sort: { columnName: "created_at", order: "desc" },
|
||||
});
|
||||
|
||||
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
// DB 컬럼이 item_type/expired_date → 프론트 내부에서는 bom_type/expiry_date로 통일
|
||||
const rawRows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
const rows = rawRows.map((r: any) => ({
|
||||
...r,
|
||||
bom_type: r.bom_type ?? r.item_type,
|
||||
expiry_date: r.expiry_date ?? r.expired_date,
|
||||
}));
|
||||
setBomList(rows);
|
||||
setTotalCount(rows.length);
|
||||
} catch (err: any) {
|
||||
@@ -452,9 +459,16 @@ export default function BomManagementPage() {
|
||||
const fetchBomDetail = useCallback(async (bomId: string) => {
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
// 헤더 조회
|
||||
// 헤더 조회 (DB 컬럼 item_type/expired_date → bom_type/expiry_date로 매핑)
|
||||
const headerRes = await apiClient.get(`/bom/${bomId}/header`);
|
||||
const header = headerRes.data?.data || headerRes.data;
|
||||
const rawHeader = headerRes.data?.data || headerRes.data;
|
||||
const header = rawHeader
|
||||
? {
|
||||
...rawHeader,
|
||||
bom_type: rawHeader.bom_type ?? rawHeader.item_type,
|
||||
expiry_date: rawHeader.expiry_date ?? rawHeader.expired_date,
|
||||
}
|
||||
: null;
|
||||
setBomHeader(header);
|
||||
setCurrentVersionId(header?.current_version_id || null);
|
||||
|
||||
@@ -497,12 +511,14 @@ export default function BomManagementPage() {
|
||||
const c = code.trim();
|
||||
return categoryOptions["division"]?.find((o) => o.code === c)?.label || c;
|
||||
}).filter((v: string) => v && v !== "s").join(", ");
|
||||
const rawUnit = d.unit || item?.inventory_unit || "";
|
||||
const unitLabel = categoryOptions["inventory_unit"]?.find((o) => o.code === rawUnit)?.label || rawUnit;
|
||||
return {
|
||||
...d,
|
||||
item_number: item?.item_number || "",
|
||||
item_name: item?.item_name || "",
|
||||
item_type: divisionLabel,
|
||||
unit: d.unit || item?.inventory_unit || "",
|
||||
unit: unitLabel,
|
||||
spec: item?.size || item?.spec || "",
|
||||
writer: d.writer || "",
|
||||
updated_date: d.updated_at || d.updated_date || "",
|
||||
@@ -631,7 +647,7 @@ export default function BomManagementPage() {
|
||||
// bom_detail에서 child_item_id가 현재 품목인 행 조회
|
||||
const itemId = bomHeader.item_id || bomHeader.id;
|
||||
const res = await apiClient.post(`/table-management/tables/bom_detail/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "child_item_id", operator: "equals", value: itemId }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
@@ -818,6 +834,8 @@ export default function BomManagementPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 같은 레벨 중복 허용 — 소요량/공정 등이 다른 동일 품목을 별도 row로 등록할 수 있음
|
||||
|
||||
const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
||||
const parentNode = addTargetParentId ? findNodeById(editingTree, addTargetParentId) : null;
|
||||
const newLevel = parentNode ? ((parentNode._level ?? parentNode.level ?? 0) as number) + 1 : 0;
|
||||
@@ -1089,17 +1107,18 @@ export default function BomManagementPage() {
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
// DB 실제 컬럼: item_type / expired_date (프론트 내부 bom_type/expiry_date와 다름)
|
||||
const bomFields: Record<string, any> = {
|
||||
item_id: masterForm.item_id,
|
||||
item_code: masterForm.item_code,
|
||||
item_name: masterForm.item_name,
|
||||
bom_type: masterForm.bom_type,
|
||||
item_type: masterForm.bom_type,
|
||||
base_qty: masterForm.base_qty || "1",
|
||||
unit: masterForm.unit || "",
|
||||
version: masterForm.version || "1.0",
|
||||
status: masterForm.status || "draft",
|
||||
effective_date: masterForm.effective_date || null,
|
||||
expiry_date: masterForm.expiry_date || null,
|
||||
expired_date: masterForm.expiry_date || null,
|
||||
remark: masterForm.remark || "",
|
||||
writer: user?.userId || "",
|
||||
company_code: user?.company_code || "",
|
||||
@@ -1471,6 +1490,21 @@ export default function BomManagementPage() {
|
||||
<Plus className="w-3.5 h-3.5 mr-1" />
|
||||
등록
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (!selectedBomId || !bomHeader) {
|
||||
toast.error("수정할 BOM을 선택해주세요");
|
||||
return;
|
||||
}
|
||||
openEditModal();
|
||||
}}
|
||||
disabled={!selectedBomId || !bomHeader}
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5 mr-1" />
|
||||
수정
|
||||
</Button>
|
||||
<div className="w-px h-4 bg-border mx-0.5" />
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -1530,53 +1564,6 @@ export default function BomManagementPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 상세 카드 */}
|
||||
<div className="border-b shrink-0">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/50">
|
||||
<h3 className="text-[13px] font-bold text-foreground">BOM 상세정보</h3>
|
||||
<Button size="sm" variant="ghost" onClick={openEditModal}>
|
||||
<FileText className="w-3.5 h-3.5 mr-1" />
|
||||
편집
|
||||
</Button>
|
||||
</div>
|
||||
{detailLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : bomHeader ? (
|
||||
<div className="grid grid-cols-2 text-sm">
|
||||
<div className="flex flex-col gap-1 p-3 border-b border-r">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">품목코드</span>
|
||||
<span className="font-mono text-xs">{bomHeader.item_code || bomHeader.item_number || "-"}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 border-b">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">품명</span>
|
||||
<span className="text-xs">{bomHeader.item_name || "-"}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 border-b border-r">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">BOM 유형</span>
|
||||
<span className="text-xs">{BOM_TYPE_OPTIONS.find((o) => o.code === bomHeader.bom_type)?.label || bomHeader.bom_type || "-"}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 border-b">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">버전</span>
|
||||
<span className="text-xs">{bomHeader.version || "-"}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 border-b border-r">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">기준수량</span>
|
||||
<span className="text-xs">{bomHeader.base_qty || "1"} {bomHeader.unit || ""}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 border-b">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">상태</span>
|
||||
{renderStatusBadge(bomHeader.status)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 col-span-2">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">메모</span>
|
||||
<span className="text-xs text-muted-foreground">{bomHeader.remark || "-"}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* 하단 탭: 트리뷰 / 버전 / 이력 */}
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<Tabs value={rightTab} onValueChange={(v) => {
|
||||
@@ -1808,7 +1795,7 @@ export default function BomManagementPage() {
|
||||
{/* 소요량 */}
|
||||
<td className="px-3 py-2 text-center">{isVirtualRoot ? (bomHeader?.base_qty || "1") : (node.quantity || "-")}</td>
|
||||
{/* 단위 */}
|
||||
<td className="px-3 py-2 text-center">{isVirtualRoot ? "-" : (node.unit || "-")}</td>
|
||||
<td className="px-3 py-2 text-center">{isVirtualRoot ? (categoryOptions["inventory_unit"]?.find((o) => o.code === bomHeader?.unit)?.label || bomHeader?.unit || "-") : (node.unit || "-")}</td>
|
||||
{/* 공정구분 */}
|
||||
<td className="overflow-hidden text-ellipsis whitespace-nowrap px-3 py-2">{isVirtualRoot ? "-" : (node.process_type || "-")}</td>
|
||||
{/* 규격 */}
|
||||
|
||||
@@ -185,9 +185,6 @@ export default function ProductionPlanManagementPage() {
|
||||
const [modalQuantity, setModalQuantity] = useState(0);
|
||||
const [modalStartDate, setModalStartDate] = useState("");
|
||||
const [modalEndDate, setModalEndDate] = useState("");
|
||||
const [modalManager, setModalManager] = useState("");
|
||||
const [modalWorkOrderNo, setModalWorkOrderNo] = useState("");
|
||||
const [modalRemarks, setModalRemarks] = useState("");
|
||||
const [modalEquipmentId, setModalEquipmentId] = useState("");
|
||||
|
||||
// 미리보기 데이터
|
||||
@@ -200,7 +197,10 @@ export default function ProductionPlanManagementPage() {
|
||||
const [selectedPlanIds, setSelectedPlanIds] = useState<Set<number>>(new Set());
|
||||
|
||||
// useConfirmDialog
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||
const { confirm, ConfirmDialogComponent, isConfirmOpenRef } = useConfirmDialog();
|
||||
|
||||
// 수량 지정 분할 입력값
|
||||
const [customSplitQty, setCustomSplitQty] = useState<number | "">("");
|
||||
|
||||
// ========== 데이터 로드 ==========
|
||||
|
||||
@@ -694,10 +694,8 @@ export default function ProductionPlanManagementPage() {
|
||||
setModalQuantity(Number(plan.plan_qty));
|
||||
setModalStartDate(plan.start_date?.split("T")[0] || "");
|
||||
setModalEndDate(plan.end_date?.split("T")[0] || "");
|
||||
setModalManager((plan as any).manager_name || "");
|
||||
setModalWorkOrderNo((plan as any).work_order_no || "");
|
||||
setModalRemarks(plan.remarks || "");
|
||||
setModalEquipmentId((plan as any).equipment_code || (plan.equipment_id ? String(plan.equipment_id) : ""));
|
||||
setCustomSplitQty("");
|
||||
setScheduleModalOpen(true);
|
||||
}, []);
|
||||
|
||||
@@ -709,9 +707,6 @@ export default function ProductionPlanManagementPage() {
|
||||
plan_qty: modalQuantity,
|
||||
start_date: modalStartDate,
|
||||
end_date: modalEndDate,
|
||||
manager_name: modalManager,
|
||||
work_order_no: modalWorkOrderNo,
|
||||
remarks: modalRemarks,
|
||||
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
|
||||
equipment_name: modalEquipmentId && modalEquipmentId !== "none"
|
||||
? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null
|
||||
@@ -721,13 +716,14 @@ export default function ProductionPlanManagementPage() {
|
||||
toast.success("생산계획이 수정되었습니다");
|
||||
setScheduleModalOpen(false);
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error("수정 실패: " + (err.message || ""));
|
||||
toast.error("수정 실패: " + (err?.response?.data?.message || err.message || ""));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalManager, modalWorkOrderNo, modalRemarks, modalEquipmentId, fetchPlans]);
|
||||
}, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId, equipmentList, fetchPlans, fetchOrderSummary]);
|
||||
|
||||
const handleDeletePlan = useCallback(async () => {
|
||||
if (!selectedPlan) return;
|
||||
@@ -741,24 +737,158 @@ export default function ProductionPlanManagementPage() {
|
||||
toast.success("삭제되었습니다");
|
||||
setScheduleModalOpen(false);
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
} catch (err: any) {
|
||||
toast.error("삭제 실패: " + (err.message || ""));
|
||||
toast.error("삭제 실패: " + (err?.response?.data?.message || err.message || ""));
|
||||
}
|
||||
}, [selectedPlan, fetchPlans, confirm]);
|
||||
}, [selectedPlan, fetchPlans, fetchOrderSummary, confirm]);
|
||||
|
||||
// 에러 메시지 추출 헬퍼
|
||||
const extractErrMsg = (err: any): string => {
|
||||
return err?.response?.data?.message || err?.message || "";
|
||||
};
|
||||
|
||||
// modalQuantity/일정/설비가 DB의 selectedPlan 값과 다른지 확인 (dirty 체크)
|
||||
const isModalDirty = useCallback((): boolean => {
|
||||
if (!selectedPlan) return false;
|
||||
const planQty = Number(selectedPlan.plan_qty) || 0;
|
||||
const planStart = selectedPlan.start_date?.split("T")[0] || "";
|
||||
const planEnd = selectedPlan.end_date?.split("T")[0] || "";
|
||||
const planEq = (selectedPlan as any).equipment_code || (selectedPlan.equipment_id ? String(selectedPlan.equipment_id) : "");
|
||||
return (
|
||||
planQty !== Number(modalQuantity) ||
|
||||
planStart !== modalStartDate ||
|
||||
planEnd !== modalEndDate ||
|
||||
planEq !== modalEquipmentId
|
||||
);
|
||||
}, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId]);
|
||||
|
||||
// dirty 상태면 자동 저장 후 selectedPlan 을 최신 값으로 갱신
|
||||
const ensureSavedBeforeSplit = useCallback(async (): Promise<boolean> => {
|
||||
if (!selectedPlan) return false;
|
||||
if (!isModalDirty()) return true;
|
||||
try {
|
||||
const res = await updatePlan(selectedPlan.id, {
|
||||
plan_qty: modalQuantity,
|
||||
start_date: modalStartDate,
|
||||
end_date: modalEndDate,
|
||||
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
|
||||
equipment_name: modalEquipmentId && modalEquipmentId !== "none"
|
||||
? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null
|
||||
: null,
|
||||
} as any);
|
||||
if (!res.success) {
|
||||
toast.error("저장 실패로 분할이 중단되었습니다");
|
||||
return false;
|
||||
}
|
||||
// selectedPlan 을 최신 값으로 동기화 (이후 로직에서 plan_qty 를 참조)
|
||||
setSelectedPlan((prev) => prev ? ({
|
||||
...prev,
|
||||
plan_qty: modalQuantity,
|
||||
start_date: modalStartDate,
|
||||
end_date: modalEndDate,
|
||||
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
|
||||
} as any) : prev);
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
toast.error("저장 실패로 분할이 중단되었습니다: " + extractErrMsg(err));
|
||||
return false;
|
||||
}
|
||||
}, [selectedPlan, isModalDirty, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId, equipmentList]);
|
||||
|
||||
// 균등 분할 (2/3/4분할 버튼)
|
||||
const handleSplitSchedule = useCallback(async (splitCount: number) => {
|
||||
if (!selectedPlan || splitCount < 2) return;
|
||||
// 모달 입력값 기준 (이후 자동 저장되므로 modalQuantity 가 진실)
|
||||
const originalQty = Number(modalQuantity) || 0;
|
||||
if (originalQty < splitCount) {
|
||||
toast.error(`${splitCount}분할하려면 수량이 ${splitCount} 이상이어야 합니다`);
|
||||
return;
|
||||
}
|
||||
if (selectedPlan.status && selectedPlan.status !== "planned") {
|
||||
toast.error("계획 상태인 건만 분할할 수 있습니다");
|
||||
return;
|
||||
}
|
||||
const ok = await confirm(`이 계획을 ${splitCount}개로 균등 분할하시겠습니까?`, {
|
||||
description: `수량 ${originalQty}이(가) ${splitCount}개로 나뉩니다.`,
|
||||
confirmText: "분할",
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
// dirty 면 자동 저장
|
||||
const saved = await ensureSavedBeforeSplit();
|
||||
if (!saved) return;
|
||||
|
||||
const eachQty = Math.floor(originalQty / splitCount);
|
||||
if (eachQty <= 0) {
|
||||
toast.error("분할 수량이 부족합니다");
|
||||
return;
|
||||
}
|
||||
|
||||
let successCount = 0;
|
||||
try {
|
||||
// N-1회 호출: 매번 eachQty만큼 원본에서 떼어내 새 plan 생성
|
||||
for (let i = 0; i < splitCount - 1; i++) {
|
||||
const res = await splitSchedule(selectedPlan.id, eachQty);
|
||||
if (!res.success) throw new Error("분할 응답 실패");
|
||||
successCount++;
|
||||
}
|
||||
toast.success(`계획이 ${splitCount}개로 분할되었습니다`);
|
||||
setScheduleModalOpen(false);
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
} catch (err: any) {
|
||||
const msg = extractErrMsg(err);
|
||||
if (successCount > 0) {
|
||||
toast.error(`분할 일부 실패 (${successCount + 1}개 생성됨): ${msg}`);
|
||||
} else {
|
||||
toast.error("분할 실패: " + msg);
|
||||
}
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
}
|
||||
}, [selectedPlan, modalQuantity, fetchPlans, fetchOrderSummary, confirm, ensureSavedBeforeSplit]);
|
||||
|
||||
// 수량 지정 분할 (원본에서 입력 수량만큼 떼어내기)
|
||||
const handleCustomSplit = useCallback(async () => {
|
||||
if (!selectedPlan) return;
|
||||
const splitQty = Number(customSplitQty);
|
||||
const originalQty = Number(modalQuantity) || 0;
|
||||
if (!splitQty || splitQty < 1) {
|
||||
toast.error("떼어낼 수량을 1 이상으로 입력하세요");
|
||||
return;
|
||||
}
|
||||
if (splitQty >= originalQty) {
|
||||
toast.error("떼어낼 수량은 원본 수량보다 작아야 합니다");
|
||||
return;
|
||||
}
|
||||
if (selectedPlan.status && selectedPlan.status !== "planned") {
|
||||
toast.error("계획 상태인 건만 분할할 수 있습니다");
|
||||
return;
|
||||
}
|
||||
const ok = await confirm(`이 계획에서 ${splitQty}만큼 떼어내시겠습니까?`, {
|
||||
description: `원본 ${originalQty} → 원본 ${originalQty - splitQty} + 신규 ${splitQty}`,
|
||||
confirmText: "분할",
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
const saved = await ensureSavedBeforeSplit();
|
||||
if (!saved) return;
|
||||
|
||||
const handleSplitSchedule = useCallback(async (splitQty: number) => {
|
||||
if (!selectedPlan || splitQty <= 0) return;
|
||||
try {
|
||||
const res = await splitSchedule(selectedPlan.id, splitQty);
|
||||
if (res.success) {
|
||||
toast.success("계획이 분할되었습니다");
|
||||
setScheduleModalOpen(false);
|
||||
fetchPlans();
|
||||
}
|
||||
if (!res.success) throw new Error("분할 응답 실패");
|
||||
toast.success(`${splitQty} 수량이 분리되었습니다`);
|
||||
setCustomSplitQty("");
|
||||
setScheduleModalOpen(false);
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
} catch (err: any) {
|
||||
toast.error("분할 실패: " + (err.message || ""));
|
||||
toast.error("분할 실패: " + extractErrMsg(err));
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
}
|
||||
}, [selectedPlan, fetchPlans]);
|
||||
}, [selectedPlan, modalQuantity, customSplitQty, fetchPlans, fetchOrderSummary, confirm, ensureSavedBeforeSplit]);
|
||||
|
||||
// 병합 핸들러
|
||||
const handleMergeSchedules = useCallback(async () => {
|
||||
@@ -780,11 +910,12 @@ export default function ProductionPlanManagementPage() {
|
||||
toast.success("계획이 병합되었습니다");
|
||||
setSelectedPlanIds(new Set());
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error("병합 실패: " + (err.message || ""));
|
||||
toast.error("병합 실패: " + (err?.response?.data?.message || err.message || ""));
|
||||
}
|
||||
}, [selectedPlanIds, rightTab, fetchPlans, confirm]);
|
||||
}, [selectedPlanIds, rightTab, fetchPlans, fetchOrderSummary, confirm]);
|
||||
|
||||
// 타임라인 이벤트 드래그 이동
|
||||
const handleEventMove = useCallback(async (eventId: string | number, newStart: string, newEnd: string) => {
|
||||
@@ -796,11 +927,12 @@ export default function ProductionPlanManagementPage() {
|
||||
if (res.success) {
|
||||
toast.success("일정이 변경되었습니다");
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error("일정 변경 실패: " + (err.message || ""));
|
||||
}
|
||||
}, [fetchPlans]);
|
||||
}, [fetchPlans, fetchOrderSummary]);
|
||||
|
||||
// 타임라인 이벤트 리사이즈
|
||||
const handleEventResize = useCallback(async (eventId: string | number, newStart: string, newEnd: string) => {
|
||||
@@ -812,11 +944,12 @@ export default function ProductionPlanManagementPage() {
|
||||
if (res.success) {
|
||||
toast.success("기간이 변경되었습니다");
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error("기간 변경 실패: " + (err.message || ""));
|
||||
}
|
||||
}, [fetchPlans]);
|
||||
}, [fetchPlans, fetchOrderSummary]);
|
||||
|
||||
// 불러오기 처리
|
||||
const handleImportOrderItems = useCallback(async () => {
|
||||
@@ -1463,8 +1596,26 @@ export default function ProductionPlanManagementPage() {
|
||||
{/* ========== 모달들 ========== */}
|
||||
|
||||
{/* 스케줄 상세/편집 모달 */}
|
||||
<Dialog open={scheduleModalOpen} onOpenChange={setScheduleModalOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[650px] max-h-[85vh] overflow-y-auto">
|
||||
<Dialog
|
||||
open={scheduleModalOpen}
|
||||
onOpenChange={(v) => {
|
||||
// confirm 다이얼로그가 열려 있는 동안 발생하는 닫힘 이벤트(포커스 이탈 등)는 무시
|
||||
if (!v && isConfirmOpenRef.current) return;
|
||||
setScheduleModalOpen(v);
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="max-w-[95vw] sm:max-w-[650px] max-h-[85vh] overflow-y-auto"
|
||||
onPointerDownOutside={(e) => {
|
||||
if (isConfirmOpenRef.current) e.preventDefault();
|
||||
}}
|
||||
onInteractOutside={(e) => {
|
||||
if (isConfirmOpenRef.current) e.preventDefault();
|
||||
}}
|
||||
onFocusOutside={(e) => {
|
||||
if (isConfirmOpenRef.current) e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg flex items-center gap-2">
|
||||
<ClipboardList className="h-5 w-5" />
|
||||
@@ -1554,37 +1705,67 @@ export default function ProductionPlanManagementPage() {
|
||||
<Scissors className="h-4 w-4" />
|
||||
계획 분할
|
||||
</p>
|
||||
<div className="flex gap-1.5">
|
||||
{[2, 3, 4].map((n) => {
|
||||
const canSplit =
|
||||
modalQuantity >= n &&
|
||||
(selectedPlan?.status === "planned" || !selectedPlan?.status);
|
||||
return (
|
||||
<Button
|
||||
key={n}
|
||||
size="sm"
|
||||
variant="warning"
|
||||
className="h-7 text-xs"
|
||||
disabled={!canSplit}
|
||||
onClick={() => handleSplitSchedule(n)}
|
||||
>
|
||||
{n}분할
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-foreground mb-2">
|
||||
하나의 생산계획을 선택한 개수만큼 균등 분할합니다. (수량 부족 또는 완료 상태는 불가)
|
||||
</p>
|
||||
{/* 수량 지정 분할 */}
|
||||
<div className="flex items-center gap-1.5 pt-2 border-t border-warning/20">
|
||||
<Label className="text-xs text-muted-foreground shrink-0">수량 지정:</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={customSplitQty}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
if (v === "") setCustomSplitQty("");
|
||||
else setCustomSplitQty(Math.max(0, Math.floor(Number(v) || 0)));
|
||||
}}
|
||||
className="h-7 w-28 text-xs"
|
||||
placeholder="떼어낼 수량"
|
||||
min={1}
|
||||
max={Math.max(0, modalQuantity - 1)}
|
||||
step={1}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
/ {modalQuantity}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="warning"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => {
|
||||
const qty = Math.floor(modalQuantity / 2);
|
||||
if (qty > 0) handleSplitSchedule(qty);
|
||||
}}
|
||||
className="h-7 text-xs ml-auto"
|
||||
disabled={
|
||||
!customSplitQty ||
|
||||
Number(customSplitQty) < 1 ||
|
||||
Number(customSplitQty) >= modalQuantity ||
|
||||
!(selectedPlan?.status === "planned" || !selectedPlan?.status)
|
||||
}
|
||||
onClick={handleCustomSplit}
|
||||
>
|
||||
2분할
|
||||
분할
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-foreground">하나의 생산계획을 여러 개로 분할합니다.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-3 pb-2 border-b">추가 정보</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs">담당자</Label>
|
||||
<Input value={modalManager} onChange={(e) => setModalManager(e.target.value)} className="h-9 text-xs" placeholder="담당자명" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">작업지시번호</Label>
|
||||
<Input value={modalWorkOrderNo} onChange={(e) => setModalWorkOrderNo(e.target.value)} className="h-9 text-xs" placeholder="자동생성" />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<Label className="text-xs">비고</Label>
|
||||
<Input value={modalRemarks} onChange={(e) => setModalRemarks(e.target.value)} className="h-9 text-xs" placeholder="비고사항 입력" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground mt-1.5">
|
||||
입력한 수량만큼 떼어내 새 계획을 생성합니다. (1 이상, 원본 수량 미만)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||
import {
|
||||
@@ -91,7 +92,8 @@ export function ItemRoutingTab() {
|
||||
const [formFixedOrder, setFormFixedOrder] = useState("Y");
|
||||
const [formWorkType, setFormWorkType] = useState("내부");
|
||||
const [formStandardTime, setFormStandardTime] = useState("");
|
||||
const [formOutsource, setFormOutsource] = useState("");
|
||||
const [formOutsources, setFormOutsources] = useState<string[]>([]);
|
||||
const [subcontractorOptions, setSubcontractorOptions] = useState<{ id: string; code: string; name: string }[]>([]);
|
||||
const [detailSubmitting, setDetailSubmitting] = useState(false);
|
||||
|
||||
const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
|
||||
@@ -107,6 +109,19 @@ export function ItemRoutingTab() {
|
||||
return () => window.clearTimeout(t);
|
||||
}, [searchInput]);
|
||||
|
||||
// 외주사 목록 로드
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const res = await apiClient.post("/table-management/tables/subcontractor_mng/data", {
|
||||
page: 1, size: 500, autoFilter: true,
|
||||
});
|
||||
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
setSubcontractorOptions(rows.map((r: any) => ({ id: r.id, code: r.subcontractor_code || "", name: r.subcontractor_name || "" })));
|
||||
} catch { /* skip */ }
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const t = window.setTimeout(() => setRegisterSearchDebounced(registerSearch.trim()), 300);
|
||||
return () => window.clearTimeout(t);
|
||||
@@ -267,7 +282,7 @@ export function ItemRoutingTab() {
|
||||
setFormFixedOrder("Y");
|
||||
setFormWorkType("내부");
|
||||
setFormStandardTime("");
|
||||
setFormOutsource("");
|
||||
setFormOutsources([]);
|
||||
setDetailDialogOpen(true);
|
||||
};
|
||||
|
||||
@@ -294,7 +309,19 @@ export function ItemRoutingTab() {
|
||||
setFormFixedOrder(row.is_fixed_order === "N" ? "N" : "Y");
|
||||
setFormWorkType(row.work_type || "내부");
|
||||
setFormStandardTime(row.standard_time || "");
|
||||
setFormOutsource(row.outsource_supplier || "");
|
||||
// 우선순위: id 배열 → legacy code 배열(id로 역변환) → legacy 단일 code(id로 역변환)
|
||||
let loadedIds: string[] = [];
|
||||
if (Array.isArray(row.outsource_supplier_ids) && row.outsource_supplier_ids.length > 0) {
|
||||
loadedIds = row.outsource_supplier_ids;
|
||||
} else {
|
||||
const legacyCodes = Array.isArray(row.outsource_supplier_list) && row.outsource_supplier_list.length > 0
|
||||
? row.outsource_supplier_list
|
||||
: (row.outsource_supplier ? [row.outsource_supplier] : []);
|
||||
loadedIds = legacyCodes
|
||||
.map((c: string) => subcontractorOptions.find((s) => s.code === c)?.id)
|
||||
.filter((v): v is string => Boolean(v));
|
||||
}
|
||||
setFormOutsources(loadedIds);
|
||||
setDetailDialogOpen(true);
|
||||
};
|
||||
|
||||
@@ -315,7 +342,10 @@ export function ItemRoutingTab() {
|
||||
return;
|
||||
}
|
||||
const proc = processes.find((p) => p.process_code === formProcessCode);
|
||||
const outsource = showOutsourceField ? formOutsource.trim() : "";
|
||||
const outsourceIds = showOutsourceField ? formOutsources.filter((s) => s && s.trim() !== "") : [];
|
||||
const outsourcePrimaryCode = outsourceIds.length > 0
|
||||
? (subcontractorOptions.find((s) => s.id === outsourceIds[0])?.code || "")
|
||||
: "";
|
||||
|
||||
setDetailSubmitting(true);
|
||||
try {
|
||||
@@ -330,7 +360,8 @@ export function ItemRoutingTab() {
|
||||
is_fixed_order: formFixedOrder,
|
||||
work_type: formWorkType,
|
||||
standard_time: st || "0",
|
||||
outsource_supplier: outsource,
|
||||
outsource_supplier: outsourcePrimaryCode,
|
||||
outsource_supplier_ids: outsourceIds,
|
||||
};
|
||||
setDetails((prev) => sortDetailsBySeq([...prev, newRow]));
|
||||
toast.success("공정이 추가되었어요. 저장을 눌러 반영해주세요");
|
||||
@@ -348,7 +379,8 @@ export function ItemRoutingTab() {
|
||||
is_fixed_order: formFixedOrder,
|
||||
work_type: formWorkType,
|
||||
standard_time: st || "0",
|
||||
outsource_supplier: outsource,
|
||||
outsource_supplier: outsourcePrimaryCode,
|
||||
outsource_supplier_ids: outsourceIds,
|
||||
}
|
||||
: d,
|
||||
),
|
||||
@@ -385,6 +417,7 @@ export function ItemRoutingTab() {
|
||||
work_type: d.work_type || "내부",
|
||||
standard_time: String(d.standard_time ?? "0"),
|
||||
outsource_supplier: d.outsource_supplier || "",
|
||||
outsource_supplier_ids: d.outsource_supplier_ids || [],
|
||||
}));
|
||||
|
||||
setSaving(true);
|
||||
@@ -466,12 +499,24 @@ export function ItemRoutingTab() {
|
||||
|
||||
const detailsGridData = useMemo(
|
||||
() =>
|
||||
details.map((d) => ({
|
||||
...d,
|
||||
process_display: d.process_name || d.process_code,
|
||||
outsource_display: d.outsource_supplier || "—",
|
||||
})),
|
||||
[details],
|
||||
details.map((d) => {
|
||||
const ids = Array.isArray(d.outsource_supplier_ids) && d.outsource_supplier_ids.length > 0
|
||||
? d.outsource_supplier_ids
|
||||
: [];
|
||||
let names = ids
|
||||
.map((i) => subcontractorOptions.find((s) => s.id === i)?.name)
|
||||
.filter((v): v is string => Boolean(v));
|
||||
// 레거시 폴백: id 매핑 없을 때 단일 code로 표시
|
||||
if (names.length === 0 && d.outsource_supplier) {
|
||||
names = [subcontractorOptions.find((s) => s.code === d.outsource_supplier)?.name || d.outsource_supplier];
|
||||
}
|
||||
return {
|
||||
...d,
|
||||
process_display: d.process_name || d.process_code,
|
||||
outsource_display: names.length === 0 ? "—" : names.join(", "),
|
||||
};
|
||||
}),
|
||||
[details, subcontractorOptions],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -895,13 +940,46 @@ export function ItemRoutingTab() {
|
||||
</div>
|
||||
{showOutsourceField && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">외주업체</Label>
|
||||
<Input
|
||||
value={formOutsource}
|
||||
onChange={(e) => setFormOutsource(e.target.value)}
|
||||
placeholder="외주 업체명"
|
||||
className="h-9"
|
||||
/>
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">외주업체 (다중 선택)</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="h-9 w-full justify-between font-normal">
|
||||
<span className="truncate text-left text-sm">
|
||||
{formOutsources.length === 0
|
||||
? "외주업체 선택"
|
||||
: formOutsources
|
||||
.map((i) => subcontractorOptions.find((s) => s.id === i)?.name || i)
|
||||
.join(", ")}
|
||||
</span>
|
||||
<Badge variant="secondary" className="ml-2 shrink-0">{formOutsources.length}</Badge>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[320px] p-0" align="start">
|
||||
<div className="max-h-64 overflow-y-auto p-2 space-y-1">
|
||||
{subcontractorOptions.length === 0 ? (
|
||||
<div className="text-xs text-muted-foreground px-2 py-3">등록된 외주업체가 없어요</div>
|
||||
) : subcontractorOptions.map((s) => {
|
||||
const checked = formOutsources.includes(s.id);
|
||||
return (
|
||||
<label
|
||||
key={s.id}
|
||||
className="flex items-center gap-2 rounded px-2 py-1.5 text-sm cursor-pointer hover:bg-muted"
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(v) => {
|
||||
setFormOutsources((prev) =>
|
||||
v ? [...prev, s.id] : prev.filter((i) => i !== s.id),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<span className="truncate">{s.name}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||
import { SmartSelect } from "@/components/common/SmartSelect";
|
||||
import {
|
||||
getProcessList,
|
||||
createProcess,
|
||||
@@ -221,18 +222,28 @@ export function ProcessMasterTab() {
|
||||
};
|
||||
|
||||
const openEdit = () => {
|
||||
if (!selectedProcess) {
|
||||
toast.message("수정할 공정을 좌측 목록에서 선택해주세요");
|
||||
if (selectedIds.size === 0) {
|
||||
toast.message("수정할 공정을 체크박스로 선택해주세요");
|
||||
return;
|
||||
}
|
||||
if (selectedIds.size > 1) {
|
||||
toast.message("수정은 1건만 선택해주세요");
|
||||
return;
|
||||
}
|
||||
const targetId = Array.from(selectedIds)[0];
|
||||
const target = processes.find((p) => p.id === targetId);
|
||||
if (!target) {
|
||||
toast.error("선택한 공정을 찾을 수 없습니다");
|
||||
return;
|
||||
}
|
||||
setFormMode("edit");
|
||||
setEditingId(selectedProcess.id);
|
||||
setFormProcessCode(selectedProcess.process_code);
|
||||
setFormProcessName(selectedProcess.process_name);
|
||||
setFormProcessType(selectedProcess.process_type);
|
||||
setFormStandardTime(selectedProcess.standard_time ?? "");
|
||||
setFormWorkerCount(selectedProcess.worker_count ?? "");
|
||||
setFormUseYn(selectedProcess.use_yn);
|
||||
setEditingId(target.id);
|
||||
setFormProcessCode(target.process_code);
|
||||
setFormProcessName(target.process_name);
|
||||
setFormProcessType(target.process_type);
|
||||
setFormStandardTime(target.standard_time ?? "");
|
||||
setFormWorkerCount(target.worker_count ?? "");
|
||||
setFormUseYn(target.use_yn);
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
@@ -313,8 +324,15 @@ export function ProcessMasterTab() {
|
||||
};
|
||||
|
||||
const availableEquipments = useMemo(() => {
|
||||
const used = new Set(processEquipments.map((e) => e.equipment_code));
|
||||
return equipmentMaster.filter((e) => !used.has(e.equipment_code));
|
||||
const used = new Set<string>();
|
||||
for (const pe of processEquipments) {
|
||||
if (pe.equipment_code) used.add(pe.equipment_code);
|
||||
}
|
||||
return equipmentMaster.filter((e) => {
|
||||
if (e.equipment_code && used.has(e.equipment_code)) return false;
|
||||
if (e.id && used.has(e.id)) return false;
|
||||
return true;
|
||||
});
|
||||
}, [equipmentMaster, processEquipments]);
|
||||
|
||||
const handleAddEquipment = async () => {
|
||||
@@ -323,11 +341,16 @@ export function ProcessMasterTab() {
|
||||
toast.message("추가할 설비를 선택해주세요");
|
||||
return;
|
||||
}
|
||||
const picked = availableEquipments.find((e) => e.id === equipmentPick);
|
||||
if (!picked) {
|
||||
toast.error("선택한 설비를 찾을 수 없어요");
|
||||
return;
|
||||
}
|
||||
setAddingEquipment(true);
|
||||
try {
|
||||
const res = await addProcessEquipment({
|
||||
process_code: selectedProcess.process_code,
|
||||
equipment_code: equipmentPick,
|
||||
equipment_code: picked.equipment_code || picked.id,
|
||||
});
|
||||
if (!res.success) {
|
||||
toast.error(res.message || "설비 추가에 실패했어요");
|
||||
@@ -501,23 +524,17 @@ export function ProcessMasterTab() {
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">설비 선택</Label>
|
||||
<Select
|
||||
<SmartSelect
|
||||
key={selectedProcess.id}
|
||||
value={equipmentPick || undefined}
|
||||
options={availableEquipments.map((eq) => ({
|
||||
code: eq.id,
|
||||
label: eq.equipment_name,
|
||||
}))}
|
||||
value={equipmentPick || ""}
|
||||
onValueChange={setEquipmentPick}
|
||||
placeholder="설비를 선택해주세요"
|
||||
disabled={addingEquipment || availableEquipments.length === 0}
|
||||
>
|
||||
<SelectTrigger className="h-9" size="sm">
|
||||
<SelectValue placeholder="설비를 선택해주세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableEquipments.map((eq) => (
|
||||
<SelectItem key={eq.id} value={eq.equipment_code}>
|
||||
{eq.equipment_code} · {eq.equipment_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -548,8 +565,11 @@ export function ProcessMasterTab() {
|
||||
{processEquipments.map((pe) => (
|
||||
<li key={pe.id} className="flex items-center gap-3 rounded-lg border p-3 transition-colors hover:bg-muted/30">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{pe.equipment_code}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{pe.equipment_name || "설비명 없음"}</p>
|
||||
<p className="truncate text-sm font-medium">
|
||||
{pe.equipment_name
|
||||
|| equipmentMaster.find((e) => e.id === pe.equipment_code || e.equipment_code === pe.equipment_code)?.equipment_name
|
||||
|| "설비명 없음"}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => void handleRemoveEquipment(pe)}>
|
||||
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import React, { useState, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import {
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
ClipboardList,
|
||||
ChevronRight,
|
||||
Factory,
|
||||
Keyboard,
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
@@ -41,7 +40,6 @@ const TAB_META = [
|
||||
icon: Settings,
|
||||
color: "text-blue-500",
|
||||
badgeColor: "bg-blue-50 text-blue-700 ring-blue-600/20",
|
||||
shortcut: "1",
|
||||
actions: ["공정 등록", "공정 수정", "공정 삭제", "설비 연결"],
|
||||
},
|
||||
{
|
||||
@@ -53,7 +51,6 @@ const TAB_META = [
|
||||
icon: GitBranch,
|
||||
color: "text-emerald-500",
|
||||
badgeColor: "bg-emerald-50 text-emerald-700 ring-emerald-600/20",
|
||||
shortcut: "2",
|
||||
actions: ["버전 생성", "공정 순서 설정", "품목 등록", "품목 해제"],
|
||||
},
|
||||
{
|
||||
@@ -65,7 +62,6 @@ const TAB_META = [
|
||||
icon: ClipboardList,
|
||||
color: "text-violet-500",
|
||||
badgeColor: "bg-violet-50 text-violet-700 ring-violet-600/20",
|
||||
shortcut: "3",
|
||||
actions: ["기준서 등록", "기준서 수정", "기준서 삭제", "작업 표준 관리"],
|
||||
},
|
||||
] as const;
|
||||
@@ -77,7 +73,6 @@ const ACTION_ICONS = [Plus, Pencil, Trash2, List] as const;
|
||||
export default function ProcessInfoPage() {
|
||||
const ts = useTableSettings("c16-process-info", "process_mst", GRID_COLUMNS);
|
||||
const [activeTab, setActiveTab] = useState<TabValue>("process");
|
||||
const [showShortcutHint, setShowShortcutHint] = useState(false);
|
||||
|
||||
const activeMeta = TAB_META.find((t) => t.value === activeTab)!;
|
||||
|
||||
@@ -85,19 +80,6 @@ export default function ProcessInfoPage() {
|
||||
setActiveTab(value as TabValue);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!e.altKey) return;
|
||||
const tabByShortcut = TAB_META.find((t) => t.shortcut === e.key);
|
||||
if (tabByShortcut) {
|
||||
e.preventDefault();
|
||||
setActiveTab(tabByShortcut.value);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-4rem)] flex-col bg-muted/30">
|
||||
{/* 페이지 헤더 */}
|
||||
@@ -120,30 +102,8 @@ export default function ProcessInfoPage() {
|
||||
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
|
||||
<Settings2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 rounded px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
onClick={() => setShowShortcutHint((v) => !v)}
|
||||
aria-label="키보드 단축키 보기"
|
||||
>
|
||||
<Keyboard className="h-3 w-3" />
|
||||
<span>단축키</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{showShortcutHint && (
|
||||
<div className="mt-2 flex items-center gap-3 rounded-md bg-muted px-3 py-2 text-xs text-muted-foreground">
|
||||
<span className="font-medium text-foreground">탭 전환:</span>
|
||||
{TAB_META.map((t) => (
|
||||
<span key={t.value} className="flex items-center gap-1">
|
||||
<kbd className="rounded border border-border bg-background px-1.5 py-0.5 font-mono text-[10px]">
|
||||
Alt+{t.shortcut}
|
||||
</kbd>
|
||||
<span>{t.shortLabel}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
@@ -154,12 +114,12 @@ export default function ProcessInfoPage() {
|
||||
{/* 탭 네비게이션 */}
|
||||
<div className="shrink-0 border-b bg-background px-4">
|
||||
<TabsList className="h-12 bg-transparent gap-1">
|
||||
{TAB_META.map(({ value, label, icon: Icon, shortcut }) => (
|
||||
{TAB_META.map(({ value, label, icon: Icon }) => (
|
||||
<TabsTrigger
|
||||
key={value}
|
||||
value={value}
|
||||
className="data-[state=active]:border-b-2 data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none rounded-none px-4 gap-1.5"
|
||||
aria-label={`${label} (Alt+${shortcut})`}
|
||||
aria-label={label}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{label}
|
||||
@@ -168,34 +128,6 @@ export default function ProcessInfoPage() {
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{/* 탭 설명 배너 */}
|
||||
<div className="shrink-0 border-b bg-background/60 px-6 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset ${activeMeta.badgeColor}`}
|
||||
>
|
||||
{activeMeta.shortLabel}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{activeMeta.detailDesc}</span>
|
||||
</div>
|
||||
<div className="hidden items-center gap-2 sm:flex">
|
||||
{activeMeta.actions.map((action, i) => {
|
||||
const ActionIcon = ACTION_ICONS[i % ACTION_ICONS.length];
|
||||
return (
|
||||
<span
|
||||
key={action}
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground/70"
|
||||
>
|
||||
<ActionIcon className="h-3 w-3" />
|
||||
{action}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 탭 컨텐츠 */}
|
||||
<TabsContent value="process" className="min-h-0 flex-1 overflow-hidden mt-0">
|
||||
<ProcessMasterTab />
|
||||
|
||||
@@ -183,7 +183,7 @@ export default function ProductionResultPage() {
|
||||
setProcessLoading(true);
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/${WOP_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "wo_id", operator: "equals", value: selectedWiId }] },
|
||||
autoFilter: true,
|
||||
sort: { columnName: "seq_no", order: "asc" },
|
||||
|
||||
+2
-2
@@ -586,7 +586,7 @@ export function WorkStandardEditModal({
|
||||
<thead className="sticky top-0 bg-muted/50">
|
||||
<tr className="border-b">
|
||||
<th className="w-12 px-2 py-2 text-center font-medium text-muted-foreground">순서</th>
|
||||
<th className="w-20 px-2 py-2 text-left font-medium text-muted-foreground">유형</th>
|
||||
<th className="w-24 px-2 py-2 text-left font-medium text-muted-foreground">유형</th>
|
||||
<th className="px-2 py-2 text-left font-medium text-muted-foreground">내용</th>
|
||||
<th className="w-14 px-2 py-2 text-center font-medium text-muted-foreground">필수</th>
|
||||
<th className="w-16 px-2 py-2 text-center font-medium text-muted-foreground">관리</th>
|
||||
@@ -597,7 +597,7 @@ export function WorkStandardEditModal({
|
||||
<tr key={detail.id || idx} className="border-b transition-colors hover:bg-muted/30">
|
||||
<td className="px-2 py-1.5 text-center text-muted-foreground">{idx + 1}</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<Badge variant="outline" className="text-[10px] font-normal">
|
||||
<Badge variant="outline" className="text-[10px] font-normal whitespace-nowrap">
|
||||
{getDetailTypeLabel(detail.detail_type || "checklist")}
|
||||
</Badge>
|
||||
</td>
|
||||
|
||||
@@ -59,6 +59,90 @@ interface SelectedItem {
|
||||
itemCode: string; itemName: string; spec: string; qty: number; remark: string;
|
||||
sourceType: SourceType; sourceTable: string; sourceId: string | number;
|
||||
routing?: string; routingOptions?: RoutingVersionData[];
|
||||
// 품목별 일정/설비/작업조/작업자 (옵션 A — 다중선택 지원)
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
equipmentIds?: string[];
|
||||
workTeams?: string[];
|
||||
workers?: string[];
|
||||
}
|
||||
|
||||
// 공용 다중선택 Popover 컴포넌트 (설비/작업조/작업자에 재사용)
|
||||
interface MultiSelectOption { value: string; label: string; sub?: string; }
|
||||
interface MultiSelectPopoverProps {
|
||||
options: MultiSelectOption[];
|
||||
value: string[];
|
||||
onChange: (next: string[]) => void;
|
||||
placeholder?: string;
|
||||
searchable?: boolean;
|
||||
triggerClassName?: string;
|
||||
emptyMessage?: string;
|
||||
}
|
||||
|
||||
function MultiSelectPopover({ options, value, onChange, placeholder = "선택", searchable = false, triggerClassName, emptyMessage = "항목이 없어요" }: MultiSelectPopoverProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
|
||||
const selectedSet = useMemo(() => new Set(value), [value]);
|
||||
const toggle = (val: string) => {
|
||||
if (selectedSet.has(val)) onChange(value.filter(v => v !== val));
|
||||
else onChange([...value, val]);
|
||||
};
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!searchable || !keyword.trim()) return options;
|
||||
const k = keyword.trim().toLowerCase();
|
||||
return options.filter(o => o.label.toLowerCase().includes(k) || (o.sub || "").toLowerCase().includes(k));
|
||||
}, [options, keyword, searchable]);
|
||||
|
||||
const display = useMemo(() => {
|
||||
if (value.length === 0) return placeholder;
|
||||
if (value.length === 1) return options.find(o => o.value === value[0])?.label || value[0];
|
||||
if (value.length === 2) {
|
||||
const labels = value.map(v => options.find(o => o.value === v)?.label || v);
|
||||
return labels.join(", ");
|
||||
}
|
||||
return `${value.length}개 선택`;
|
||||
}, [value, options, placeholder]);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" role="combobox" aria-expanded={open}
|
||||
className={cn("w-full justify-between font-normal", triggerClassName || "h-7 text-xs")}>
|
||||
<span className={cn("truncate", value.length === 0 && "text-muted-foreground")}>{display}</span>
|
||||
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)", minWidth: 200 }} align="start">
|
||||
{searchable && (
|
||||
<div className="p-2 border-b">
|
||||
<Input placeholder="검색..." value={keyword} onChange={e => setKeyword(e.target.value)} className="h-7 text-xs" />
|
||||
</div>
|
||||
)}
|
||||
<div className="max-h-56 overflow-y-auto py-1">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="py-4 text-center text-xs text-muted-foreground">{emptyMessage}</div>
|
||||
) : filtered.map(opt => (
|
||||
<label
|
||||
key={opt.value}
|
||||
className="flex items-center gap-2 px-2 py-1.5 cursor-pointer hover:bg-muted/50 text-xs"
|
||||
onClick={e => { e.preventDefault(); toggle(opt.value); }}
|
||||
>
|
||||
<Checkbox checked={selectedSet.has(opt.value)} onCheckedChange={() => toggle(opt.value)} className="h-3.5 w-3.5" />
|
||||
<span className="flex-1 truncate">{opt.label}{opt.sub ? <span className="text-muted-foreground ml-1">({opt.sub})</span> : null}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{value.length > 0 && (
|
||||
<div className="p-1.5 border-t flex items-center justify-between">
|
||||
<span className="text-[10px] text-muted-foreground">{value.length}개 선택됨</span>
|
||||
<Button variant="ghost" size="sm" className="h-6 text-[11px] px-2" onClick={() => onChange([])}>초기화</Button>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WorkInstructionPage() {
|
||||
@@ -197,12 +281,23 @@ export default function WorkInstructionPage() {
|
||||
|
||||
const applyRegistration = () => {
|
||||
if (regCheckedIds.size === 0) { alert("품목을 선택해주세요."); return; }
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
const items: SelectedItem[] = [];
|
||||
for (const item of regSourceData) {
|
||||
if (!regCheckedIds.has(getRegId(item))) continue;
|
||||
if (regSourceType === "item") items.push({ itemCode: item.item_code, itemName: item.item_name || "", spec: item.spec || "", qty: 1, remark: "", sourceType: "item", sourceTable: "item_info", sourceId: item.item_code });
|
||||
else if (regSourceType === "order") items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: item.spec || "", qty: Number(item.qty || 1), remark: "", sourceType: "order", sourceTable: "sales_order_detail", sourceId: item.id });
|
||||
else items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: "", qty: Number(item.plan_qty || 1), remark: "", sourceType: "production", sourceTable: "production_plan_mng", sourceId: item.id });
|
||||
const baseExtra = { startDate: today, endDate: "", equipmentIds: [], workTeams: [], workers: [] } as Pick<SelectedItem, "startDate" | "endDate" | "equipmentIds" | "workTeams" | "workers">;
|
||||
if (regSourceType === "item") items.push({ itemCode: item.item_code, itemName: item.item_name || "", spec: item.spec || "", qty: 1, remark: "", sourceType: "item", sourceTable: "item_info", sourceId: item.item_code, ...baseExtra });
|
||||
else if (regSourceType === "order") items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: item.spec || "", qty: Number(item.qty || 1), remark: "", sourceType: "order", sourceTable: "sales_order_detail", sourceId: item.id, ...baseExtra });
|
||||
else {
|
||||
// 생산계획: 잔량(remain_qty)이 있으면 잔량 기반으로 기본 수량 제안 (0/음수 허용 — 계획 초과 가능)
|
||||
const defaultQty = item.remain_qty !== undefined && item.remain_qty !== null
|
||||
? Number(item.remain_qty)
|
||||
: Number(item.plan_qty || 1);
|
||||
// 생산계획: 일정이 있으면 기본값으로 전달
|
||||
const planStart = item.start_date ? String(item.start_date).split("T")[0] : today;
|
||||
const planEnd = item.end_date ? String(item.end_date).split("T")[0] : "";
|
||||
items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: "", qty: defaultQty, remark: "", sourceType: "production", sourceTable: "production_plan_mng", sourceId: item.id, startDate: planStart, endDate: planEnd, equipmentIds: [], workTeams: [], workers: [] });
|
||||
}
|
||||
}
|
||||
|
||||
// 동일품목 합산
|
||||
@@ -250,6 +345,9 @@ export default function WorkInstructionPage() {
|
||||
itemCode: firstItem?.itemCode || "", itemName: firstItem?.itemName || "", spec: firstItem?.spec || "",
|
||||
qty: Number(confirmAddQty), remark: "",
|
||||
sourceType: "item" as SourceType, sourceTable: "item_info", sourceId: firstItem?.itemCode || "",
|
||||
startDate: firstItem?.startDate || new Date().toISOString().split("T")[0],
|
||||
endDate: firstItem?.endDate || "",
|
||||
equipmentIds: [], workTeams: [], workers: [],
|
||||
}]);
|
||||
setConfirmAddQty("");
|
||||
};
|
||||
@@ -259,11 +357,29 @@ export default function WorkInstructionPage() {
|
||||
if (confirmItems.length === 0) { alert("품목이 없습니다."); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
// 헤더 대표값: 첫 번째 품목의 첫 번째 값으로 (하위 호환 유지 — 조회 화면이 헤더값으로 표시되는 레거시 대비)
|
||||
const first = confirmItems[0];
|
||||
const headerStart = first?.startDate || "";
|
||||
const headerEnd = first?.endDate || "";
|
||||
const headerEquipment = first?.equipmentIds?.[0] || "";
|
||||
const headerWorkTeam = first?.workTeams?.[0] || "";
|
||||
const headerWorker = first?.workers?.[0] || "";
|
||||
const payload = {
|
||||
status: confirmStatus, startDate: confirmStartDate, endDate: confirmEndDate,
|
||||
equipmentId: confirmEquipmentId, workTeam: confirmWorkTeam, worker: confirmWorker,
|
||||
status: confirmStatus,
|
||||
startDate: headerStart, endDate: headerEnd,
|
||||
equipmentId: headerEquipment, workTeam: headerWorkTeam, worker: headerWorker,
|
||||
routing: confirmRouting || null,
|
||||
items: confirmItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode, routing: i.routing || null })),
|
||||
items: confirmItems.map(i => ({
|
||||
itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark,
|
||||
sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode,
|
||||
routing: i.routing || null,
|
||||
// 품목별 일정/설비/작업조/작업자 (옵션 A — 다중값 쉼표 구분)
|
||||
startDate: i.startDate || "",
|
||||
endDate: i.endDate || "",
|
||||
equipmentIds: (i.equipmentIds || []).join(","),
|
||||
workTeams: (i.workTeams || []).join(","),
|
||||
workers: (i.workers || []).join(","),
|
||||
})),
|
||||
};
|
||||
const r = await saveWorkInstruction(payload);
|
||||
if (r.success) { setIsConfirmModalOpen(false); fetchOrders(); alert("작업지시가 등록되었습니다."); }
|
||||
@@ -286,6 +402,12 @@ export default function WorkInstructionPage() {
|
||||
sourceTable: d.source_table || "item_info", sourceId: d.source_id || "",
|
||||
routing: d.detail_routing_version_id || order.routing_version_id || "",
|
||||
routingOptions: [],
|
||||
// 품목별 일정/설비/작업조/작업자 (detail 값 우선, 없으면 헤더값 폴백)
|
||||
startDate: d.detail_start_date || d.start_date || "",
|
||||
endDate: d.detail_end_date || d.end_date || "",
|
||||
equipmentIds: (d.detail_equipment_ids || "").split(",").filter(Boolean),
|
||||
workTeams: (d.detail_work_teams || "").split(",").filter(Boolean),
|
||||
workers: (d.detail_workers || "").split(",").filter(Boolean),
|
||||
}));
|
||||
setEditItems(items);
|
||||
setAddQty(""); setAddEquipment(""); setAddWorkTeam(""); setAddWorker("");
|
||||
@@ -316,9 +438,13 @@ export default function WorkInstructionPage() {
|
||||
|
||||
const addEditItem = () => {
|
||||
if (!addQty || Number(addQty) <= 0) { alert("수량을 입력해주세요."); return; }
|
||||
const firstItem = editItems[0];
|
||||
setEditItems(prev => [...prev, {
|
||||
itemCode: editOrder?.item_number || "", itemName: editOrder?.item_name || "", spec: editOrder?.item_spec || "",
|
||||
qty: Number(addQty), remark: "", sourceType: "item", sourceTable: "item_info", sourceId: editOrder?.item_number || "",
|
||||
startDate: firstItem?.startDate || editStartDate || "",
|
||||
endDate: firstItem?.endDate || editEndDate || "",
|
||||
equipmentIds: [], workTeams: [], workers: [],
|
||||
}]);
|
||||
setAddQty("");
|
||||
};
|
||||
@@ -327,11 +453,30 @@ export default function WorkInstructionPage() {
|
||||
if (!editOrder || editItems.length === 0) { alert("품목이 없습니다."); return; }
|
||||
setEditSaving(true);
|
||||
try {
|
||||
// 헤더 대표값: 첫 번째 품목의 첫 번째 값 사용 (하위 호환 — 등록 모달과 동일 패턴)
|
||||
const first = editItems[0];
|
||||
const headerStart = first?.startDate || editStartDate || "";
|
||||
const headerEnd = first?.endDate || editEndDate || "";
|
||||
const headerEquipment = first?.equipmentIds?.[0] || editEquipmentId || "";
|
||||
const headerWorkTeam = first?.workTeams?.[0] || editWorkTeam || "";
|
||||
const headerWorker = first?.workers?.[0] || editWorker || "";
|
||||
const payload = {
|
||||
id: editOrder.wi_id, status: editStatus, startDate: editStartDate, endDate: editEndDate,
|
||||
equipmentId: editEquipmentId, workTeam: editWorkTeam, worker: editWorker, remark: editRemark,
|
||||
id: editOrder.wi_id, status: editStatus,
|
||||
startDate: headerStart, endDate: headerEnd,
|
||||
equipmentId: headerEquipment, workTeam: headerWorkTeam, worker: headerWorker,
|
||||
remark: editRemark,
|
||||
routing: editRouting || null,
|
||||
items: editItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode, routing: i.routing || null })),
|
||||
items: editItems.map(i => ({
|
||||
itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark,
|
||||
sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode,
|
||||
routing: i.routing || null,
|
||||
// 품목별 일정/설비/작업조/작업자 (다중값 쉼표 구분 — 등록 모달과 동일)
|
||||
startDate: i.startDate || "",
|
||||
endDate: i.endDate || "",
|
||||
equipmentIds: (i.equipmentIds || []).join(","),
|
||||
workTeams: (i.workTeams || []).join(","),
|
||||
workers: (i.workers || []).join(","),
|
||||
})),
|
||||
};
|
||||
const r = await saveWorkInstruction(payload);
|
||||
if (r.success) { setIsEditModalOpen(false); fetchOrders(); alert("수정되었습니다."); }
|
||||
@@ -578,7 +723,7 @@ export default function WorkInstructionPage() {
|
||||
<TableHead className="w-[50px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground"><Checkbox checked={regSourceData.length > 0 && regCheckedIds.size === regSourceData.length} onCheckedChange={toggleRegAll} /></TableHead>
|
||||
{regSourceType === "item" && <><TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목코드</TableHead><TableHead>품목명</TableHead><TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">규격</TableHead></>}
|
||||
{regSourceType === "order" && <><TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">수주번호</TableHead><TableHead className="w-[100px]">품번</TableHead><TableHead>품목명</TableHead><TableHead className="w-[100px]">규격</TableHead><TableHead className="w-[80px] text-right">수량</TableHead><TableHead className="w-[100px]">납기일</TableHead></>}
|
||||
{regSourceType === "production" && <><TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">계획번호</TableHead><TableHead className="w-[100px]">품번</TableHead><TableHead>품목명</TableHead><TableHead className="w-[80px] text-right">계획수량</TableHead><TableHead className="w-[90px]">시작일</TableHead><TableHead className="w-[90px]">완료일</TableHead><TableHead className="w-[100px]">설비</TableHead></>}
|
||||
{regSourceType === "production" && <><TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">계획번호</TableHead><TableHead className="w-[100px]">품번</TableHead><TableHead>품목명</TableHead><TableHead className="w-[80px] text-right">계획수량</TableHead><TableHead className="w-[80px] text-right">적용수량</TableHead><TableHead className="w-[80px] text-right">잔량</TableHead><TableHead className="w-[90px]">시작일</TableHead><TableHead className="w-[90px]">완료일</TableHead><TableHead className="w-[100px]">설비</TableHead></>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -590,7 +735,7 @@ export default function WorkInstructionPage() {
|
||||
<TableCell className="text-center" onClick={e => e.stopPropagation()}><Checkbox checked={checked} onCheckedChange={() => toggleRegItem(id)} /></TableCell>
|
||||
{regSourceType === "item" && <><TableCell className="text-[13px] font-medium">{item.item_code}</TableCell><TableCell className="text-sm">{item.item_name}</TableCell><TableCell className="text-[13px]">{item.spec || "-"}</TableCell></>}
|
||||
{regSourceType === "order" && <><TableCell className="text-[13px]">{item.order_no}</TableCell><TableCell className="text-[13px]">{item.item_code}</TableCell><TableCell className="text-sm">{item.item_name}</TableCell><TableCell className="text-[13px]">{item.spec || "-"}</TableCell><TableCell className="text-right text-[13px]">{Number(item.qty || 0).toLocaleString()}</TableCell><TableCell className="text-[13px]">{item.due_date || "-"}</TableCell></>}
|
||||
{regSourceType === "production" && <><TableCell className="text-[13px]">{item.plan_no}</TableCell><TableCell className="text-[13px]">{item.item_code}</TableCell><TableCell className="text-sm">{item.item_name}</TableCell><TableCell className="text-right text-[13px]">{Number(item.plan_qty || 0).toLocaleString()}</TableCell><TableCell className="text-[13px]">{item.start_date ? String(item.start_date).split("T")[0] : "-"}</TableCell><TableCell className="text-[13px]">{item.end_date ? String(item.end_date).split("T")[0] : "-"}</TableCell><TableCell className="text-[13px]">{item.equipment_name || "-"}</TableCell></>}
|
||||
{regSourceType === "production" && <><TableCell className="text-[13px]">{item.plan_no}</TableCell><TableCell className="text-[13px]">{item.item_code}</TableCell><TableCell className="text-sm">{item.item_name}</TableCell><TableCell className="text-right text-[13px]">{Number(item.plan_qty || 0).toLocaleString()}</TableCell><TableCell className="text-right text-[13px] text-muted-foreground">{Number(item.applied_qty || 0).toLocaleString()}</TableCell><TableCell className={cn("text-right text-[13px] font-semibold", Number(item.remain_qty ?? item.plan_qty ?? 0) < 0 && "text-destructive")}>{Number(item.remain_qty ?? item.plan_qty ?? 0).toLocaleString()}</TableCell><TableCell className="text-[13px]">{item.start_date ? String(item.start_date).split("T")[0] : "-"}</TableCell><TableCell className="text-[13px]">{item.end_date ? String(item.end_date).split("T")[0] : "-"}</TableCell><TableCell className="text-[13px]">{item.equipment_name || "-"}</TableCell></>}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
@@ -619,7 +764,7 @@ export default function WorkInstructionPage() {
|
||||
|
||||
{/* ── 2단계: 확인 모달 ── */}
|
||||
<Dialog open={isConfirmModalOpen} onOpenChange={setIsConfirmModalOpen}>
|
||||
<DialogContent className="max-w-[1100px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden">
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[1500px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>작업지시 적용 확인</DialogTitle>
|
||||
<DialogDescription>기본 정보를 입력하고 '최종 적용' 버튼을 눌러주세요.</DialogDescription>
|
||||
@@ -628,38 +773,33 @@ export default function WorkInstructionPage() {
|
||||
<div className="space-y-5">
|
||||
<div className="bg-muted/30 border rounded-lg p-5">
|
||||
<h4 className="text-[13px] font-bold pb-2 border-b text-foreground">작업지시 기본 정보</h4>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-3">
|
||||
<p className="text-[11px] text-muted-foreground mt-2">시작일·완료예정일·설비·작업조·작업자는 아래 품목별로 지정해주세요.</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-3">
|
||||
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground">작업지시번호</Label><Input value={confirmWiNo} readOnly className="h-9 bg-muted cursor-not-allowed font-mono" /></div>
|
||||
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground">상태</Label>
|
||||
<Select value={confirmStatus} onValueChange={setConfirmStatus}><SelectTrigger className="h-9"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="일반">일반</SelectItem><SelectItem value="긴급">긴급</SelectItem></SelectContent></Select>
|
||||
</div>
|
||||
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground">시작일</Label><Input type="date" value={confirmStartDate} onChange={(e) => setConfirmStartDate(e.target.value)} className="h-9" /></div>
|
||||
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground">완료예정일</Label><Input type="date" value={confirmEndDate} onChange={(e) => setConfirmEndDate(e.target.value)} className="h-9" /></div>
|
||||
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground">설비</Label>
|
||||
<Select value={nv(confirmEquipmentId)} onValueChange={v => setConfirmEquipmentId(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="설비 선택" /></SelectTrigger><SelectContent><SelectItem value="none">선택 안 함</SelectItem>{equipmentOptions.map(eq => <SelectItem key={eq.id} value={eq.id}>{eq.equipment_name}</SelectItem>)}</SelectContent></Select>
|
||||
</div>
|
||||
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground">작업조</Label>
|
||||
<Select value={nv(confirmWorkTeam)} onValueChange={v => setConfirmWorkTeam(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="작업조" /></SelectTrigger><SelectContent><SelectItem value="none">선택 안 함</SelectItem><SelectItem value="주간">주간</SelectItem><SelectItem value="야간">야간</SelectItem></SelectContent></Select>
|
||||
</div>
|
||||
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground">작업자</Label>
|
||||
<WorkerCombobox value={confirmWorker} onChange={setConfirmWorker} open={confirmWorkerOpen} onOpenChange={setConfirmWorkerOpen} />
|
||||
</div>
|
||||
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground">비고</Label><Input className="h-9" placeholder="비고를 입력해주세요" /></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border rounded-lg p-5">
|
||||
<h4 className="text-[13px] font-bold pb-2 border-b text-foreground mb-3">품목 목록</h4>
|
||||
<div className="overflow-auto">
|
||||
<Table>
|
||||
<Table className="min-w-[1500px]">
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[50px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">순번</TableHead>
|
||||
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목코드</TableHead>
|
||||
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목명</TableHead>
|
||||
<TableHead className="w-[140px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목명</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">규격</TableHead>
|
||||
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">수량</TableHead>
|
||||
<TableHead className="w-[180px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">라우팅</TableHead>
|
||||
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">비고</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">수량</TableHead>
|
||||
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">라우팅</TableHead>
|
||||
<TableHead className="w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">시작일</TableHead>
|
||||
<TableHead className="w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">완료예정일</TableHead>
|
||||
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">설비</TableHead>
|
||||
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">작업조</TableHead>
|
||||
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">작업자</TableHead>
|
||||
<TableHead className="w-[140px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">비고</TableHead>
|
||||
<TableHead className="w-[40px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -668,7 +808,7 @@ export default function WorkInstructionPage() {
|
||||
<TableRow key={idx}>
|
||||
<TableCell className="text-[13px] text-center">{idx + 1}</TableCell>
|
||||
<TableCell className="text-[13px] font-medium">{item.itemCode}</TableCell>
|
||||
<TableCell className="text-sm">{item.itemName || item.itemCode}</TableCell>
|
||||
<TableCell className="text-sm truncate max-w-[140px]" title={item.itemName || item.itemCode}>{item.itemName || item.itemCode}</TableCell>
|
||||
<TableCell className="text-[13px]">{item.spec || "-"}</TableCell>
|
||||
<TableCell><Input type="number" className="h-7 text-[13px] w-20" value={item.qty} onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /></TableCell>
|
||||
<TableCell>
|
||||
@@ -690,6 +830,40 @@ export default function WorkInstructionPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input type="date" className="h-7 text-[13px]" value={item.startDate || ""} onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, startDate: e.target.value } : it))} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input type="date" className="h-7 text-[13px]" value={item.endDate || ""} onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, endDate: e.target.value } : it))} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<MultiSelectPopover
|
||||
options={equipmentOptions.map(eq => ({ value: eq.id, label: eq.equipment_name || eq.equipment_code, sub: eq.equipment_code }))}
|
||||
value={item.equipmentIds || []}
|
||||
onChange={next => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, equipmentIds: next } : it))}
|
||||
placeholder="설비 선택"
|
||||
searchable
|
||||
emptyMessage="설비가 없어요"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<MultiSelectPopover
|
||||
options={[{ value: "주간", label: "주간" }, { value: "야간", label: "야간" }]}
|
||||
value={item.workTeams || []}
|
||||
onChange={next => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, workTeams: next } : it))}
|
||||
placeholder="작업조 선택"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<MultiSelectPopover
|
||||
options={employeeOptions.map(emp => ({ value: emp.user_id, label: emp.user_name, sub: emp.dept_name || undefined }))}
|
||||
value={item.workers || []}
|
||||
onChange={next => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, workers: next } : it))}
|
||||
placeholder="작업자 선택"
|
||||
searchable
|
||||
emptyMessage="사원을 찾을 수 없어요"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell><Input className="h-7 text-[13px]" value={item.remark} placeholder="비고" onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /></TableCell>
|
||||
<TableCell><Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setConfirmItems(prev => prev.filter((_, i) => i !== idx))}><X className="w-3 h-3 text-destructive" /></Button></TableCell>
|
||||
</TableRow>
|
||||
@@ -710,7 +884,7 @@ export default function WorkInstructionPage() {
|
||||
|
||||
{/* ── 수정 모달 ── */}
|
||||
<Dialog open={isEditModalOpen} onOpenChange={(v) => { if (!v && wsModalOpen) return; setIsEditModalOpen(v); }}>
|
||||
<DialogContent className="max-w-[1100px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden">
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[1500px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{`작업지시 관리 - ${editOrder?.work_instruction_no || ""}`}</DialogTitle>
|
||||
<DialogDescription>품목을 추가/삭제하고 정보를 수정해주세요.</DialogDescription>
|
||||
@@ -719,48 +893,47 @@ export default function WorkInstructionPage() {
|
||||
<div className="space-y-5">
|
||||
<div className="bg-muted/30 border rounded-lg p-5">
|
||||
<h4 className="text-[13px] font-bold pb-2 border-b text-foreground">기본 정보</h4>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-3">
|
||||
<p className="text-[11px] text-muted-foreground mt-2">시작일·완료예정일·설비·작업조·작업자는 아래 품목별로 지정해주세요.</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-3">
|
||||
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground">상태</Label><Select value={editStatus} onValueChange={setEditStatus}><SelectTrigger className="h-9"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="일반">일반</SelectItem><SelectItem value="긴급">긴급</SelectItem></SelectContent></Select></div>
|
||||
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground">시작일</Label><Input type="date" value={editStartDate} onChange={(e) => setEditStartDate(e.target.value)} className="h-9" /></div>
|
||||
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground">완료예정일</Label><Input type="date" value={editEndDate} onChange={(e) => setEditEndDate(e.target.value)} className="h-9" /></div>
|
||||
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground">설비</Label><Select value={nv(editEquipmentId)} onValueChange={v => setEditEquipmentId(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="설비 선택" /></SelectTrigger><SelectContent><SelectItem value="none">선택 안 함</SelectItem>{equipmentOptions.map(eq => <SelectItem key={eq.id} value={eq.id}>{eq.equipment_name}</SelectItem>)}</SelectContent></Select></div>
|
||||
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground">작업조</Label><Select value={nv(editWorkTeam)} onValueChange={v => setEditWorkTeam(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="작업조" /></SelectTrigger><SelectContent><SelectItem value="none">선택 안 함</SelectItem><SelectItem value="주간">주간</SelectItem><SelectItem value="야간">야간</SelectItem></SelectContent></Select></div>
|
||||
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground">작업자</Label>
|
||||
<WorkerCombobox value={editWorker} onChange={setEditWorker} open={editWorkerOpen} onOpenChange={setEditWorkerOpen} />
|
||||
</div>
|
||||
<div className="space-y-1 col-span-2"><Label className="text-[11px] font-semibold text-muted-foreground">비고</Label><Input value={editRemark} onChange={e => setEditRemark(e.target.value)} className="h-9" placeholder="비고를 입력해주세요" /></div>
|
||||
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground">비고</Label><Input value={editRemark} onChange={e => setEditRemark(e.target.value)} className="h-9" placeholder="비고를 입력해주세요" /></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 품목 테이블 — 라우팅/공정작업기준을 품목별로 표시 */}
|
||||
{/* 품목 테이블 — 품목별 일정/설비/작업조/작업자 + 라우팅/공정작업기준 */}
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 bg-muted/30 border-b">
|
||||
<span className="text-[13px] font-bold text-foreground">작업지시 항목</span>
|
||||
<span className="text-[11px] font-semibold text-primary bg-primary/8 border border-primary/15 px-2 py-0.5 rounded-full font-mono">{editItems.length}건</span>
|
||||
</div>
|
||||
<div className="overflow-auto">
|
||||
<Table>
|
||||
<Table className="min-w-[1500px]">
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[50px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">순번</TableHead>
|
||||
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목코드</TableHead>
|
||||
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목명</TableHead>
|
||||
<TableHead className="w-[140px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목명</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">규격</TableHead>
|
||||
<TableHead className="w-[90px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">수량</TableHead>
|
||||
<TableHead className="w-[180px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">라우팅</TableHead>
|
||||
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">공정작업기준</TableHead>
|
||||
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">비고</TableHead>
|
||||
<TableHead className="w-[80px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">수량</TableHead>
|
||||
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">라우팅</TableHead>
|
||||
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">공정작업기준</TableHead>
|
||||
<TableHead className="w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">시작일</TableHead>
|
||||
<TableHead className="w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">완료예정일</TableHead>
|
||||
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">설비</TableHead>
|
||||
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">작업조</TableHead>
|
||||
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">작업자</TableHead>
|
||||
<TableHead className="w-[140px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">비고</TableHead>
|
||||
<TableHead className="w-[40px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{editItems.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={9} className="text-center py-8 text-sm text-muted-foreground">등록된 품목이 없어요</TableCell></TableRow>
|
||||
<TableRow><TableCell colSpan={14} className="text-center py-8 text-sm text-muted-foreground">등록된 품목이 없어요</TableCell></TableRow>
|
||||
) : editItems.map((item, idx) => (
|
||||
<TableRow key={idx}>
|
||||
<TableCell className="text-[13px] text-center">{idx + 1}</TableCell>
|
||||
<TableCell className="text-[13px] font-medium">{item.itemCode}</TableCell>
|
||||
<TableCell className="text-[13px] max-w-[150px] truncate" title={item.itemName}>{item.itemName || "-"}</TableCell>
|
||||
<TableCell className="text-sm truncate max-w-[140px]" title={item.itemName}>{item.itemName || "-"}</TableCell>
|
||||
<TableCell className="text-[13px] truncate" title={item.spec}>{item.spec || "-"}</TableCell>
|
||||
<TableCell className="text-right"><Input type="number" className="h-7 text-[13px] w-20 ml-auto" value={item.qty} onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /></TableCell>
|
||||
<TableCell>
|
||||
@@ -803,6 +976,40 @@ export default function WorkInstructionPage() {
|
||||
<ClipboardCheck className="w-3 h-3 mr-1" /> 수정
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input type="date" className="h-7 text-[13px]" value={item.startDate || ""} onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, startDate: e.target.value } : it))} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input type="date" className="h-7 text-[13px]" value={item.endDate || ""} onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, endDate: e.target.value } : it))} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<MultiSelectPopover
|
||||
options={equipmentOptions.map(eq => ({ value: eq.id, label: eq.equipment_name || eq.equipment_code, sub: eq.equipment_code }))}
|
||||
value={item.equipmentIds || []}
|
||||
onChange={next => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, equipmentIds: next } : it))}
|
||||
placeholder="설비 선택"
|
||||
searchable
|
||||
emptyMessage="설비가 없어요"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<MultiSelectPopover
|
||||
options={[{ value: "주간", label: "주간" }, { value: "야간", label: "야간" }]}
|
||||
value={item.workTeams || []}
|
||||
onChange={next => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, workTeams: next } : it))}
|
||||
placeholder="작업조 선택"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<MultiSelectPopover
|
||||
options={employeeOptions.map(emp => ({ value: emp.user_id, label: emp.user_name, sub: emp.dept_name || undefined }))}
|
||||
value={item.workers || []}
|
||||
onChange={next => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, workers: next } : it))}
|
||||
placeholder="작업자 선택"
|
||||
searchable
|
||||
emptyMessage="사원을 찾을 수 없어요"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell><Input className="h-7 text-[13px]" value={item.remark} placeholder="비고" onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /></TableCell>
|
||||
<TableCell><Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setEditItems(prev => prev.filter((_, i) => i !== idx))}><X className="w-3 h-3 text-destructive" /></Button></TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -30,6 +30,7 @@ import { toast } from "sonner";
|
||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { SmartSelect } from "@/components/common/SmartSelect";
|
||||
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||
|
||||
const MASTER_TABLE = "purchase_order_mng";
|
||||
@@ -237,7 +238,7 @@ export default function PurchaseOrderPage() {
|
||||
);
|
||||
try {
|
||||
const suppRes = await apiClient.post(`/table-management/tables/supplier_mng/data`, {
|
||||
page: 1, size: 5000, autoFilter: true,
|
||||
page: 1, size: 0, autoFilter: true,
|
||||
});
|
||||
const supps = suppRes.data?.data?.data || suppRes.data?.data?.rows || [];
|
||||
optMap["supplier_code"] = supps.map((s: any) => ({
|
||||
@@ -247,7 +248,7 @@ export default function PurchaseOrderPage() {
|
||||
} catch { /* skip */ }
|
||||
try {
|
||||
const userRes = await apiClient.post(`/table-management/tables/user_info/data`, {
|
||||
page: 1, size: 5000, autoFilter: true,
|
||||
page: 1, size: 0, autoFilter: true,
|
||||
});
|
||||
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
|
||||
optMap["manager"] = users.map((u: any) => ({
|
||||
@@ -293,7 +294,7 @@ export default function PurchaseOrderPage() {
|
||||
}
|
||||
|
||||
const res = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
|
||||
page: 1, size: 5000,
|
||||
page: 1, size: 0,
|
||||
dataFilter: detailFilters.length > 0 ? { enabled: true, filters: detailFilters } : undefined,
|
||||
autoFilter: true,
|
||||
sort: { columnName: "purchase_no", order: "desc" },
|
||||
@@ -555,6 +556,48 @@ export default function PurchaseOrderPage() {
|
||||
if (divLabel) divValues.push(divLabel);
|
||||
filters.push({ columnName: "division", operator: "in", value: divValues });
|
||||
}
|
||||
|
||||
// 공급업체 선택 시 supplier_item_mapping으로 매핑 id 정규화 → 서버 필터 적용
|
||||
const supplierCode = masterForm.supplier_code;
|
||||
if (supplierCode) {
|
||||
try {
|
||||
const mappingRes = await apiClient.post(`/table-management/tables/supplier_item_mapping/data`, {
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "supplier_id", operator: "equals", value: supplierCode }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const mappings = mappingRes.data?.data?.data || mappingRes.data?.data?.rows || [];
|
||||
const rawIds = [...new Set(mappings.map((m: any) => m.item_id).filter(Boolean))] as string[];
|
||||
if (rawIds.length === 0) {
|
||||
setItemSearchResults([]); setItemTotal(0); setItemTotalPages(1);
|
||||
setItemSearchLoading(false);
|
||||
return;
|
||||
}
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const uuidIds = rawIds.filter((v) => uuidRegex.test(v));
|
||||
const codeIds = rawIds.filter((v) => !uuidRegex.test(v));
|
||||
|
||||
let convertedIds: string[] = [];
|
||||
if (codeIds.length > 0) {
|
||||
const convRes = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||||
page: 1, size: codeIds.length + 10,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: codeIds }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const convRows = convRes.data?.data?.data || convRes.data?.data?.rows || [];
|
||||
convertedIds = convRows.map((r: any) => r.id).filter(Boolean);
|
||||
}
|
||||
|
||||
const finalIds = [...new Set([...uuidIds, ...convertedIds])];
|
||||
if (finalIds.length === 0) {
|
||||
setItemSearchResults([]); setItemTotal(0); setItemTotalPages(1);
|
||||
setItemSearchLoading(false);
|
||||
return;
|
||||
}
|
||||
filters.push({ columnName: "id", operator: "in", value: finalIds });
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
|
||||
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||||
page: p, size: s,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
@@ -607,7 +650,7 @@ export default function PurchaseOrderPage() {
|
||||
try {
|
||||
const itemIds = selected.map((item) => item.item_number || item.id);
|
||||
const res = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, {
|
||||
page: 1, size: 5000,
|
||||
page: 1, size: 0,
|
||||
dataFilter: {
|
||||
enabled: true,
|
||||
filters: [
|
||||
@@ -670,7 +713,7 @@ export default function PurchaseOrderPage() {
|
||||
if (itemCodes.length === 0) return;
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||||
page: 1, size: 5000,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: itemCodes }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
@@ -692,7 +735,7 @@ export default function PurchaseOrderPage() {
|
||||
if (itemCodes.length === 0) return;
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, {
|
||||
page: 1, size: 5000,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "supplier_id", operator: "equals", value: supplierCode },
|
||||
{ columnName: "item_id", operator: "in", value: itemCodes },
|
||||
@@ -984,7 +1027,8 @@ export default function PurchaseOrderPage() {
|
||||
<div className="grid grid-cols-2 gap-3.5">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground tracking-wide">공급업체</Label>
|
||||
<Select
|
||||
<SmartSelect
|
||||
options={categoryOptions["supplier_code"] || []}
|
||||
value={masterForm.supplier_code || ""}
|
||||
onValueChange={(v) => {
|
||||
const supp = categoryOptions["supplier_code"]?.find((o) => o.code === v);
|
||||
@@ -992,15 +1036,9 @@ export default function PurchaseOrderPage() {
|
||||
setMasterForm((p) => ({ ...p, supplier_code: v, supplier_name: name }));
|
||||
recalcPrices(masterForm.price_mode || "", v);
|
||||
}}
|
||||
placeholder="공급업체 선택"
|
||||
disabled={isReadOnly}
|
||||
>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="공급업체 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(categoryOptions["supplier_code"] || []).map((o) => (
|
||||
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -312,6 +312,11 @@ export default function PurchaseItemPage() {
|
||||
|
||||
// 좌측: 품목 조회
|
||||
const fetchItems = useCallback(async () => {
|
||||
// 카테고리 로드 완료 전엔 대기 — 먼저 나간 unfiltered 요청이 나중에 도착해
|
||||
// filtered 결과를 덮어쓰는 race condition 방지
|
||||
if (!categoryOptions["division"]?.length) {
|
||||
return;
|
||||
}
|
||||
setItemLoading(true);
|
||||
try {
|
||||
const filters: { columnName: string; operator: string; value: any }[] = [];
|
||||
@@ -328,7 +333,7 @@ export default function PurchaseItemPage() {
|
||||
}
|
||||
|
||||
const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
|
||||
page: 1, size: 5000,
|
||||
page: 1, size: 0,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
@@ -619,7 +624,7 @@ export default function PurchaseItemPage() {
|
||||
try {
|
||||
// 1. supplier_item_mapping에서 해당 품목의 매핑 조회
|
||||
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_id", operator: "equals", value: itemKey }] },
|
||||
autoFilter: true,
|
||||
sort: { columnName: "created_date", order: "asc" },
|
||||
@@ -647,7 +652,7 @@ export default function PurchaseItemPage() {
|
||||
if (mappings.length > 0) {
|
||||
try {
|
||||
const priceRes = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "item_id", operator: "equals", value: itemKey },
|
||||
]},
|
||||
@@ -1104,7 +1109,7 @@ export default function PurchaseItemPage() {
|
||||
for (const suppCode of supplierCodes) {
|
||||
// 해당 공급업체의 모든 매핑 조회 → item_id null 처리
|
||||
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "item_id", operator: "equals", value: selectedItem!.item_number },
|
||||
{ columnName: "supplier_id", operator: "equals", value: suppCode },
|
||||
@@ -1121,7 +1126,7 @@ export default function PurchaseItemPage() {
|
||||
// 해당 공급업체의 모든 단가 조회 → item_id null 처리
|
||||
try {
|
||||
const priceRes = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "item_id", operator: "equals", value: selectedItem!.item_number },
|
||||
{ columnName: "supplier_id", operator: "equals", value: suppCode },
|
||||
|
||||
@@ -219,7 +219,7 @@ export default function SupplierManagementPage() {
|
||||
} catch { /* skip */ }
|
||||
};
|
||||
load();
|
||||
apiClient.post(`/table-management/tables/user_info/data`, { page: 1, size: 500, autoFilter: true })
|
||||
apiClient.post(`/table-management/tables/user_info/data`, { page: 1, size: 0, autoFilter: true })
|
||||
.then((res) => {
|
||||
const users = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
setEmployeeOptions(users.map((u: any) => ({
|
||||
@@ -239,7 +239,7 @@ export default function SupplierManagementPage() {
|
||||
}));
|
||||
|
||||
const res = await apiClient.post(`/table-management/tables/${SUPPLIER_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
sort: { columnName: "supplier_code", order: "desc" },
|
||||
@@ -284,7 +284,7 @@ export default function SupplierManagementPage() {
|
||||
const fetchMainContacts = useCallback(async () => {
|
||||
try {
|
||||
const contactRes = await apiClient.post(`/table-management/tables/${CONTACT_TABLE}/data`, {
|
||||
page: 1, size: 500, autoFilter: true,
|
||||
page: 1, size: 0, autoFilter: true,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "is_main", operator: "equals", value: "Y" }] },
|
||||
});
|
||||
const allContacts = contactRes.data?.data?.data || contactRes.data?.data?.rows || [];
|
||||
@@ -310,7 +310,7 @@ export default function SupplierManagementPage() {
|
||||
setPriceLoading(true);
|
||||
try {
|
||||
const mappingRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "supplier_id", operator: "equals", value: selectedSupplier.supplier_code },
|
||||
]},
|
||||
@@ -337,7 +337,7 @@ export default function SupplierManagementPage() {
|
||||
if (mappings.length > 0) {
|
||||
try {
|
||||
const priceRes = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "supplier_id", operator: "equals", value: selectedSupplier.supplier_code },
|
||||
]},
|
||||
@@ -412,7 +412,7 @@ export default function SupplierManagementPage() {
|
||||
setDeliveryLoading(true);
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/${DELIVERY_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "supplier_code", operator: "equals", value: selectedSupplier.supplier_code },
|
||||
]},
|
||||
@@ -492,7 +492,7 @@ export default function SupplierManagementPage() {
|
||||
if (ruleData?.success && ruleData?.data?.ruleId) {
|
||||
const ruleId = ruleData.data.ruleId;
|
||||
const allRes = await apiClient.post(`/table-management/tables/${DELIVERY_TABLE}/data`, {
|
||||
page: 1, size: 500, autoFilter: true,
|
||||
page: 1, size: 0, autoFilter: true,
|
||||
sort: { columnName: "destination_code", order: "desc" },
|
||||
});
|
||||
const allRows = allRes.data?.data?.data || allRes.data?.data?.rows || [];
|
||||
@@ -565,7 +565,7 @@ export default function SupplierManagementPage() {
|
||||
const ruleId = ruleData.data.ruleId;
|
||||
// 기존 데이터에서 CUST-XXX 패턴의 최대 순번 조회
|
||||
const allRes = await apiClient.post(`/table-management/tables/${SUPPLIER_TABLE}/data`, {
|
||||
page: 1, size: 500, autoFilter: true,
|
||||
page: 1, size: 0, autoFilter: true,
|
||||
sort: { columnName: "supplier_code", order: "desc" },
|
||||
});
|
||||
const allRows = allRes.data?.data?.data || allRes.data?.data?.rows || [];
|
||||
@@ -766,7 +766,7 @@ export default function SupplierManagementPage() {
|
||||
const ruleData = ruleRes.data;
|
||||
if (ruleData?.success && ruleData?.data?.ruleId) {
|
||||
const ruleId = ruleData.data.ruleId;
|
||||
const allRes = await apiClient.post(`/table-management/tables/${SUPPLIER_TABLE}/data`, { page: 1, size: 500, autoFilter: true, sort: { columnName: "supplier_code", order: "desc" } });
|
||||
const allRes = await apiClient.post(`/table-management/tables/${SUPPLIER_TABLE}/data`, { page: 1, size: 0, autoFilter: true, sort: { columnName: "supplier_code", order: "desc" } });
|
||||
const allRows = allRes.data?.data?.data || allRes.data?.data?.rows || [];
|
||||
let maxSeq = 0;
|
||||
for (const row of allRows) { const match = (row.supplier_code || "").match(/(\d+)$/); if (match) { const seq = parseInt(match[1], 10); if (seq > maxSeq) maxSeq = seq; } }
|
||||
@@ -819,7 +819,7 @@ export default function SupplierManagementPage() {
|
||||
const filters: any[] = [];
|
||||
if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
|
||||
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||||
page: 1, size: 5000,
|
||||
page: 1, size: 0,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
@@ -1227,7 +1227,7 @@ export default function SupplierManagementPage() {
|
||||
for (const itemId of itemIds) {
|
||||
// 해당 품목의 모든 매핑 조회 → supplier_id null 처리
|
||||
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "supplier_id", operator: "equals", value: selectedSupplier!.supplier_code },
|
||||
{ columnName: "item_id", operator: "equals", value: itemId },
|
||||
@@ -1244,7 +1244,7 @@ export default function SupplierManagementPage() {
|
||||
// 해당 품목의 모든 단가 조회 → supplier_id null 처리
|
||||
try {
|
||||
const priceRes = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "supplier_id", operator: "equals", value: selectedSupplier!.supplier_code },
|
||||
{ columnName: "item_id", operator: "equals", value: itemId },
|
||||
@@ -1316,7 +1316,7 @@ export default function SupplierManagementPage() {
|
||||
try {
|
||||
const allMappings: any[] = [];
|
||||
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
|
||||
page: 1, size: 5000, autoFilter: true,
|
||||
page: 1, size: 0, autoFilter: true,
|
||||
});
|
||||
const mappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
|
||||
const itemIds = [...new Set(mappings.map((m: any) => m.item_id).filter(Boolean))];
|
||||
|
||||
@@ -101,7 +101,7 @@ export default function InspectionResultPage() {
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
|
||||
page: 1,
|
||||
size: 500,
|
||||
size: 0,
|
||||
autoFilter: true,
|
||||
search: { master_id: masterId },
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Di
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||
import {
|
||||
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ClipboardList,
|
||||
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, FileSpreadsheet,
|
||||
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, FileSpreadsheet, Copy,
|
||||
} from "lucide-react";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -43,6 +43,7 @@ type InspectionRow = {
|
||||
inspection_detail: string;
|
||||
inspection_method: string;
|
||||
apply_process: string;
|
||||
classification: string;
|
||||
acceptance_criteria: string;
|
||||
is_required: boolean;
|
||||
judgment_criteria?: string; // 판단기준 라벨 (수치(범위)/텍스트입력/O·X/선택형)
|
||||
@@ -119,9 +120,9 @@ export default function ItemInspectionInfoPage() {
|
||||
const loadOptions = async () => {
|
||||
try {
|
||||
const [itemRes, inspRes, userRes] = await Promise.all([
|
||||
apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, { page: 1, size: 500, autoFilter: true }),
|
||||
apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, { page: 1, size: 500, autoFilter: true }),
|
||||
apiClient.post(`/table-management/tables/user_info/data`, { page: 1, size: 500, autoFilter: true }),
|
||||
apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, { page: 1, size: 0, autoFilter: true }),
|
||||
apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, { page: 1, size: 0, autoFilter: true }),
|
||||
apiClient.post(`/table-management/tables/user_info/data`, { page: 1, size: 0, autoFilter: true }),
|
||||
]);
|
||||
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
|
||||
setItemOptions(items.map((r: any) => ({
|
||||
@@ -253,13 +254,193 @@ export default function ItemInspectionInfoPage() {
|
||||
loadProcessOptions(item.code);
|
||||
};
|
||||
|
||||
// 복사 모달: 편집 가능한 기준 데이터 상태 (등록/수정 폼과 평행 구조)
|
||||
const [copyForm, setCopyForm] = useState<Record<string, any>>({});
|
||||
const [copyInspectionRows, setCopyInspectionRows] = useState<Record<string, InspectionRow[]>>({});
|
||||
const [copyCollapsedTypes, setCopyCollapsedTypes] = useState<Record<string, boolean>>({});
|
||||
|
||||
/* ═══════════════════ 복사 모달 (기준 품목 검사정보 → 다른 품목들) ═══════════════════ */
|
||||
const [copyModalOpen, setCopyModalOpen] = useState(false);
|
||||
const [copySearchKeyword, setCopySearchKeyword] = useState("");
|
||||
const [copyFilteredItems, setCopyFilteredItems] = useState<typeof itemOptions>([]);
|
||||
const [copySearchLoading, setCopySearchLoading] = useState(false);
|
||||
const [copyPage, setCopyPage] = useState(1);
|
||||
const [copyTotal, setCopyTotal] = useState(0);
|
||||
const [copyCheckedIds, setCopyCheckedIds] = useState<string[]>([]);
|
||||
const [copying, setCopying] = useState(false);
|
||||
const [copyProgress, setCopyProgress] = useState({ current: 0, total: 0 });
|
||||
const copyPageSize = 20;
|
||||
const copyTotalPages = Math.max(1, Math.ceil(copyTotal / copyPageSize));
|
||||
|
||||
const searchCopyTargets = async (page?: number) => {
|
||||
const p = page ?? copyPage;
|
||||
setCopySearchLoading(true);
|
||||
try {
|
||||
const filters: any[] = [];
|
||||
if (copySearchKeyword.trim()) {
|
||||
filters.push({ columnName: "item_name", operator: "contains", value: copySearchKeyword.trim() });
|
||||
}
|
||||
const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
|
||||
page: p, size: copyPageSize,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
const resData = res.data?.data;
|
||||
const rows = resData?.data || resData?.rows || [];
|
||||
const cm = itemCatMapRef.current;
|
||||
const list = rows
|
||||
.filter((r: any) => r.item_number !== selectedItemCode)
|
||||
.map((r: any) => ({
|
||||
code: r.item_number,
|
||||
name: r.item_name,
|
||||
item_type: cm["type"]?.[r.type] || r.type || "",
|
||||
unit: cm["inventory_unit"]?.[r.inventory_unit] || r.inventory_unit || "",
|
||||
}));
|
||||
setCopyFilteredItems(list);
|
||||
setCopyTotal(resData?.total || resData?.totalCount || rows.length);
|
||||
} catch { /* skip */ } finally { setCopySearchLoading(false); }
|
||||
};
|
||||
const openCopyModal = async () => {
|
||||
if (!selectedItemCode) { toast.error("복사 기준 품목을 먼저 선택해주세요"); return; }
|
||||
const srcGroup = groupedData.find(g => g.item_code === selectedItemCode);
|
||||
if (!srcGroup || srcGroup.rows.length === 0) { toast.error("선택한 품목에 복사할 검사정보가 없어요"); return; }
|
||||
setCopySearchKeyword(""); setCopyPage(1); setCopyCheckedIds([]);
|
||||
|
||||
// 기준 품목 데이터를 편집용 상태로 복제 (openEdit과 동일한 변환 로직)
|
||||
const baseRow = srcGroup.rows[0];
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: selectedItemCode }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const allRows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
const rowMap: Record<string, InspectionRow[]> = {};
|
||||
const typeFlags: Record<string, boolean> = {};
|
||||
for (const r of allRows) {
|
||||
const inspType = r.inspection_type || "";
|
||||
const matched = INSPECTION_TYPES.find(t =>
|
||||
t.matchLabels.some(ml => inspType.includes(ml)) ||
|
||||
inspTypeCatOptions.some(cat => inspType.includes(cat.code) && t.matchLabels.some(ml => cat.label.includes(ml)))
|
||||
);
|
||||
const typeKey = matched?.key || "";
|
||||
if (!typeKey) continue;
|
||||
typeFlags[typeKey] = true;
|
||||
if (!rowMap[typeKey]) rowMap[typeKey] = [];
|
||||
const mCode = r.inspection_method || "";
|
||||
const mLabel = inspMethodCatOptions.find(o => o.code === mCode)?.label || mCode;
|
||||
const inspOpt = inspOptions.find(o => o.code === r.inspection_standard_id);
|
||||
const jcCode = inspOpt?.judgment_criteria || "";
|
||||
const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode;
|
||||
const unitCode = inspOpt?.unit || "";
|
||||
const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode;
|
||||
rowMap[typeKey].push({
|
||||
id: crypto.randomUUID(), // 복사본은 새 id 부여 (원본과 분리)
|
||||
inspection_standard_id: r.inspection_standard_id || "",
|
||||
inspection_detail: r.inspection_item_name || r.inspection_standard || "",
|
||||
inspection_method: mLabel,
|
||||
apply_process: r.apply_process || "",
|
||||
classification: r.classification || "",
|
||||
acceptance_criteria: r.pass_criteria || "",
|
||||
is_required: r.is_required === "true" || r.is_required === true,
|
||||
judgment_criteria: jcLabel,
|
||||
selection_options: inspOpt?.selection_options || "",
|
||||
unit: unitLabel,
|
||||
});
|
||||
}
|
||||
setCopyInspectionRows(rowMap);
|
||||
setCopyForm({ ...baseRow, ...typeFlags });
|
||||
setCopyCollapsedTypes({});
|
||||
} catch {
|
||||
setCopyInspectionRows({});
|
||||
setCopyForm({ ...baseRow });
|
||||
setCopyCollapsedTypes({});
|
||||
}
|
||||
|
||||
setCopyModalOpen(true);
|
||||
searchCopyTargets(1);
|
||||
};
|
||||
const handleCopySearch = () => { setCopyPage(1); searchCopyTargets(1); };
|
||||
const toggleCopyChecked = (code: string) => {
|
||||
setCopyCheckedIds(prev => prev.includes(code) ? prev.filter(c => c !== code) : [...prev, code]);
|
||||
};
|
||||
const handleCopy = async () => {
|
||||
if (!selectedItemCode) { toast.error("복사 기준 품목이 없어요"); return; }
|
||||
if (copyCheckedIds.length === 0) { toast.error("붙여넣을 품목을 선택해주세요"); return; }
|
||||
|
||||
// 편집된 rows를 평탄화 (선택된 검사유형의 rows만)
|
||||
const enabledTypes = INSPECTION_TYPES.filter(t => !!copyForm[t.key]);
|
||||
const flatRows: Array<{ row: InspectionRow; typeLabel: string }> = [];
|
||||
for (const t of enabledTypes) {
|
||||
const rows = copyInspectionRows[t.key] || [];
|
||||
for (const r of rows) flatRows.push({ row: r, typeLabel: t.label });
|
||||
}
|
||||
if (flatRows.length === 0) { toast.error("복사할 검사항목이 없어요"); return; }
|
||||
|
||||
const ok = await confirm(
|
||||
`선택한 ${copyCheckedIds.length}개 품목에 편집된 검사정보(${flatRows.length}개 행)를 복사할까요?`,
|
||||
{ description: "대상 품목의 기존 검사정보는 삭제 후 교체됩니다.", variant: "info", confirmText: "복사" }
|
||||
);
|
||||
if (!ok) return;
|
||||
setCopying(true);
|
||||
setCopyProgress({ current: 0, total: copyCheckedIds.length });
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
try {
|
||||
for (let i = 0; i < copyCheckedIds.length; i++) {
|
||||
const targetCode = copyCheckedIds[i];
|
||||
const target = copyFilteredItems.find(o => o.code === targetCode) || itemOptions.find(o => o.code === targetCode);
|
||||
const targetName = target?.name || "";
|
||||
const existRes = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: targetCode }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const existing = existRes.data?.data?.data || existRes.data?.data?.rows || [];
|
||||
if (existing.length > 0) {
|
||||
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: existing.map((r: any) => ({ id: r.id })) });
|
||||
}
|
||||
let orderSeq = 0;
|
||||
for (const { row: r, typeLabel } of flatRows) {
|
||||
orderSeq += 1;
|
||||
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
item_code: targetCode,
|
||||
item_name: targetName,
|
||||
inspection_type: typeLabel,
|
||||
inspection_standard_id: r.inspection_standard_id || "",
|
||||
inspection_item_name: r.inspection_detail || "",
|
||||
inspection_method: r.inspection_method || "",
|
||||
apply_process: r.apply_process || "",
|
||||
classification: r.classification || "",
|
||||
pass_criteria: r.acceptance_criteria || "",
|
||||
is_required: r.is_required ? "true" : "false",
|
||||
is_active: copyForm.is_active || "사용",
|
||||
manager: copyForm.manager || "",
|
||||
manager_id: copyForm.manager_id || "",
|
||||
memo: copyForm.remarks || "",
|
||||
sort_order: String(orderSeq).padStart(4, "0"),
|
||||
});
|
||||
}
|
||||
setCopyProgress({ current: i + 1, total: copyCheckedIds.length });
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
toast.success(`${copyCheckedIds.length}개 품목에 복사했어요`);
|
||||
setCopyModalOpen(false);
|
||||
fetchData();
|
||||
} catch { toast.error("복사에 실패했어요"); }
|
||||
finally {
|
||||
setCopying(false);
|
||||
setCopyProgress({ current: 0, total: 0 });
|
||||
}
|
||||
};
|
||||
|
||||
/* ═══════════════════ 데이터 조회 ═══════════════════ */
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
|
||||
const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
@@ -300,7 +481,13 @@ export default function ItemInspectionInfoPage() {
|
||||
// 선택된 탭의 검사항목 행
|
||||
const selectedTabRows = useMemo(() => {
|
||||
if (!selectedGroup || !selectedTypeTab) return [];
|
||||
return selectedGroup.rows.filter((r: any) => r.inspection_type === selectedTypeTab && r.inspection_standard_id);
|
||||
const filtered = selectedGroup.rows.filter((r: any) => r.inspection_type === selectedTypeTab && r.inspection_standard_id);
|
||||
return [...filtered].sort((a: any, b: any) => {
|
||||
const av = parseInt(String(a.sort_order || "9999"), 10);
|
||||
const bv = parseInt(String(b.sort_order || "9999"), 10);
|
||||
if (av === bv) return String(a.id).localeCompare(String(b.id));
|
||||
return av - bv;
|
||||
});
|
||||
}, [selectedGroup, selectedTypeTab]);
|
||||
|
||||
// 검사기준 ID → 라벨
|
||||
@@ -329,11 +516,18 @@ export default function ItemInspectionInfoPage() {
|
||||
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: code }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const allRows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
// sort_order 기준 오름차순 정렬 (varchar이므로 숫자 파싱 후 비교)
|
||||
allRows.sort((a: any, b: any) => {
|
||||
const av = parseInt(String(a.sort_order || "9999"), 10);
|
||||
const bv = parseInt(String(b.sort_order || "9999"), 10);
|
||||
if (av === bv) return String(a.id).localeCompare(String(b.id));
|
||||
return av - bv;
|
||||
});
|
||||
const rowMap: Record<string, InspectionRow[]> = {};
|
||||
const typeFlags: Record<string, boolean> = {};
|
||||
|
||||
@@ -360,7 +554,8 @@ export default function ItemInspectionInfoPage() {
|
||||
inspection_standard_id: r.inspection_standard_id || "",
|
||||
inspection_detail: r.inspection_item_name || r.inspection_standard || "",
|
||||
inspection_method: mLabel,
|
||||
apply_process: "",
|
||||
apply_process: r.apply_process || "",
|
||||
classification: r.classification || "",
|
||||
acceptance_criteria: r.pass_criteria || "",
|
||||
is_required: r.is_required === "true" || r.is_required === true,
|
||||
judgment_criteria: jcLabel,
|
||||
@@ -378,7 +573,7 @@ export default function ItemInspectionInfoPage() {
|
||||
const addInspRow = (typeKey: string) => {
|
||||
setInspectionRows(prev => ({
|
||||
...prev,
|
||||
[typeKey]: [...(prev[typeKey] || []), { id: crypto.randomUUID(), inspection_standard_id: "", inspection_detail: "", inspection_method: "", apply_process: "", acceptance_criteria: "", is_required: false }],
|
||||
[typeKey]: [...(prev[typeKey] || []), { id: crypto.randomUUID(), inspection_standard_id: "", inspection_detail: "", inspection_method: "", apply_process: "", classification: "", acceptance_criteria: "", is_required: false }],
|
||||
}));
|
||||
};
|
||||
const removeInspRow = (typeKey: string, rowId: string) => {
|
||||
@@ -423,13 +618,53 @@ export default function ItemInspectionInfoPage() {
|
||||
};
|
||||
const toggleCollapse = (typeKey: string) => { setCollapsedTypes(prev => ({ ...prev, [typeKey]: !prev[typeKey] })); };
|
||||
|
||||
/* ═══════════════════ 복사 모달용 검사항목 행 관리 (등록 폼과 평행) ═══════════════════ */
|
||||
const addCopyInspRow = (typeKey: string) => {
|
||||
setCopyInspectionRows(prev => ({
|
||||
...prev,
|
||||
[typeKey]: [...(prev[typeKey] || []), { id: crypto.randomUUID(), inspection_standard_id: "", inspection_detail: "", inspection_method: "", apply_process: "", classification: "", acceptance_criteria: "", is_required: false }],
|
||||
}));
|
||||
};
|
||||
const removeCopyInspRow = (typeKey: string, rowId: string) => {
|
||||
setCopyInspectionRows(prev => ({ ...prev, [typeKey]: (prev[typeKey] || []).filter(r => r.id !== rowId) }));
|
||||
};
|
||||
const updateCopyInspRow = (typeKey: string, rowId: string, field: string, value: any) => {
|
||||
setCopyInspectionRows(prev => ({
|
||||
...prev,
|
||||
[typeKey]: (prev[typeKey] || []).map(r => {
|
||||
if (r.id !== rowId) return r;
|
||||
if (field === "inspection_standard_id") {
|
||||
const opt = inspOptions.find(o => o.code === value);
|
||||
const methodCode = opt?.method || "";
|
||||
const methodLabel = inspMethodCatOptions.find(o => o.code === methodCode)?.label || methodCode;
|
||||
const jcCode = opt?.judgment_criteria || "";
|
||||
const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode;
|
||||
const unitCode = opt?.unit || "";
|
||||
const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode;
|
||||
return {
|
||||
...r,
|
||||
inspection_standard_id: value,
|
||||
inspection_detail: opt?.detail || "",
|
||||
inspection_method: methodLabel,
|
||||
judgment_criteria: jcLabel,
|
||||
selection_options: opt?.selection_options || "",
|
||||
unit: unitLabel,
|
||||
acceptance_criteria: "",
|
||||
};
|
||||
}
|
||||
return { ...r, [field]: value };
|
||||
}),
|
||||
}));
|
||||
};
|
||||
const toggleCopyCollapse = (typeKey: string) => { setCopyCollapsedTypes(prev => ({ ...prev, [typeKey]: !prev[typeKey] })); };
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.item_code) { toast.error("품목코드는 필수예요"); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editMode) {
|
||||
const existRes = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: form.item_code }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
@@ -440,18 +675,23 @@ export default function ItemInspectionInfoPage() {
|
||||
}
|
||||
const enabledTypes = INSPECTION_TYPES.filter(t => !!form[t.key]);
|
||||
const rows: any[] = [];
|
||||
let globalOrder = 0;
|
||||
for (const t of enabledTypes) {
|
||||
const typeRows = inspectionRows[t.key] || [];
|
||||
if (typeRows.length === 0) {
|
||||
rows.push({ id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label, is_active: form.is_active || "사용", manager_id: form.manager_id || "", memo: form.remarks || "" });
|
||||
globalOrder += 1;
|
||||
rows.push({ id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label, is_active: form.is_active || "사용", manager_id: form.manager_id || "", memo: form.remarks || "", sort_order: String(globalOrder).padStart(4, "0") });
|
||||
} else {
|
||||
for (const r of typeRows) {
|
||||
globalOrder += 1;
|
||||
rows.push({
|
||||
id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label,
|
||||
inspection_standard_id: r.inspection_standard_id || "", inspection_item_name: r.inspection_detail || "",
|
||||
inspection_method: r.inspection_method || "", pass_criteria: r.acceptance_criteria || "",
|
||||
apply_process: r.apply_process || "", classification: r.classification || "",
|
||||
is_required: r.is_required ? "true" : "false", is_active: form.is_active || "사용",
|
||||
manager_id: form.manager_id || "", memo: form.remarks || "",
|
||||
sort_order: String(globalOrder).padStart(4, "0"),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -732,7 +972,6 @@ export default function ItemInspectionInfoPage() {
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={openExcelUpload}>
|
||||
<FileSpreadsheet className="w-3.5 h-3.5 mr-1" />엑셀 업로드
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" />수정</Button>
|
||||
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" />삭제</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => ts.setOpen(true)}><Settings2 className="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
@@ -814,6 +1053,7 @@ export default function ItemInspectionInfoPage() {
|
||||
{selectedGroup && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" />수정</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={openCopyModal}><Copy className="w-3.5 h-3.5 mr-1" />복사</Button>
|
||||
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" />삭제</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -872,15 +1112,17 @@ export default function ItemInspectionInfoPage() {
|
||||
<TableHead className="text-[10px] font-bold h-8">검사기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8">검사방법</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8">적용공정</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8">구분</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8">판단기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8">합격기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8 w-[50px]">필수</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8 w-[70px]">단위</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{selectedTabRows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-8 text-xs text-muted-foreground">등록된 검사항목이 없어요</TableCell>
|
||||
<TableCell colSpan={9} className="text-center py-8 text-xs text-muted-foreground">등록된 검사항목이 없어요</TableCell>
|
||||
</TableRow>
|
||||
) : selectedTabRows.map((row: any) => (
|
||||
<TableRow key={row.id}>
|
||||
@@ -899,6 +1141,7 @@ export default function ItemInspectionInfoPage() {
|
||||
const proc = processOptions.find(p => p.code === code);
|
||||
return proc?.name || code;
|
||||
})()}</TableCell>
|
||||
<TableCell className="text-xs py-2">{row.classification || "-"}</TableCell>
|
||||
<TableCell className="text-xs py-2">
|
||||
{(() => {
|
||||
const insp = inspOptions.find(o => o.code === row.inspection_standard_id);
|
||||
@@ -907,12 +1150,29 @@ export default function ItemInspectionInfoPage() {
|
||||
return jcLabel ? <Badge variant="outline" className="text-[10px]">{jcLabel}</Badge> : "-";
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs py-2 font-mono">{row.pass_criteria || "-"}</TableCell>
|
||||
<TableCell className="text-xs py-2 font-mono">{(() => {
|
||||
const pc = row.pass_criteria;
|
||||
if (!pc) return "-";
|
||||
if (pc.includes("|")) {
|
||||
const [s, t] = pc.split("|");
|
||||
if (!t || !t.trim()) return s || "-";
|
||||
return `${s} ± ${t}`;
|
||||
}
|
||||
return pc;
|
||||
})()}</TableCell>
|
||||
<TableCell className="text-xs py-2 text-center">
|
||||
{row.is_required === "true" || row.is_required === true ? (
|
||||
<Badge variant="destructive" className="text-[9px]">필수</Badge>
|
||||
) : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs py-2">
|
||||
{(() => {
|
||||
const insp = inspOptions.find(o => o.code === row.inspection_standard_id);
|
||||
const unitCode = insp?.unit || "";
|
||||
const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode;
|
||||
return unitLabel || "-";
|
||||
})()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -1074,15 +1334,17 @@ export default function ItemInspectionInfoPage() {
|
||||
<TableHead className="text-[10px] font-bold w-[130px]">검사기준 상세</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[90px]">검사방법</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[90px]">적용공정</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[100px]">구분</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[80px]">판단기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[200px]">합격기준 (판단기준별)</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[40px]">필수</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[70px]">단위</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[36px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(!inspectionRows[key] || inspectionRows[key].length === 0) ? (
|
||||
<TableRow><TableCell colSpan={8} className="text-center py-4 text-xs text-muted-foreground">항목추가 버튼으로 검사항목을 추가하세요</TableCell></TableRow>
|
||||
<TableRow><TableCell colSpan={10} className="text-center py-4 text-xs text-muted-foreground">항목추가 버튼으로 검사항목을 추가하세요</TableCell></TableRow>
|
||||
) : inspectionRows[key].map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="p-1">
|
||||
@@ -1107,6 +1369,9 @@ export default function ItemInspectionInfoPage() {
|
||||
<Input className="h-8 text-xs" value={row.apply_process} onChange={(e) => updateInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Input className="h-8 text-xs" value={row.classification || ""} onChange={(e) => updateInspRow(key, row.id, "classification", e.target.value)} placeholder="구분 입력" />
|
||||
</TableCell>
|
||||
<TableCell className="p-1 text-center">
|
||||
{row.judgment_criteria ? <Badge variant="outline" className="text-[10px]">{row.judgment_criteria}</Badge> : <span className="text-[10px] text-muted-foreground">-</span>}
|
||||
</TableCell>
|
||||
@@ -1148,6 +1413,7 @@ export default function ItemInspectionInfoPage() {
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="p-1 text-center"><Checkbox checked={row.is_required} onCheckedChange={(v) => updateInspRow(key, row.id, "is_required", !!v)} /></TableCell>
|
||||
<TableCell className="p-1 text-xs text-muted-foreground">{row.unit || "-"}</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Button type="button" variant="destructive" size="sm" className="h-7 w-7 p-0" onClick={() => removeInspRow(key, row.id)}><Trash2 className="w-3.5 h-3.5" /></Button>
|
||||
</TableCell>
|
||||
@@ -1172,6 +1438,278 @@ export default function ItemInspectionInfoPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ═══════════════════ 복사 모달 (2단 분할: 좌 대상 / 우 편집) ═══════════════════ */}
|
||||
<Dialog open={copyModalOpen} onOpenChange={(v) => { if (!copying) setCopyModalOpen(v); }}>
|
||||
<DialogContent
|
||||
className="max-w-[95vw] sm:max-w-[1400px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden"
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => { if (copying) e.preventDefault(); }}
|
||||
>
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle>{copying ? "검사정보 복사 중..." : "검사정보 복사"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
<span className="font-medium text-foreground">{selectedGroup?.item_name || "-"}</span>
|
||||
<span className="text-muted-foreground"> ({selectedItemCode})</span>
|
||||
<span>{copying ? " 의 검사정보를 복사하고 있습니다" : " 의 검사정보를 편집해서 선택한 품목들에 복사합니다. 기준 품목은 변경되지 않아요"}</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{copying ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-4 py-8 px-4">
|
||||
<div className="w-full max-w-sm space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-blue-600" />
|
||||
<span className="text-sm font-medium text-blue-700">복사 진행 중...</span>
|
||||
<span className="text-xs text-blue-600 ml-auto">
|
||||
{copyProgress.current.toLocaleString()} / {copyProgress.total.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-blue-100 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${copyProgress.total > 0 ? Math.round((copyProgress.current / copyProgress.total) * 100) : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground text-center pt-2">
|
||||
모달을 닫지 마세요. 완료 후 자동으로 닫혀요.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 grid grid-cols-[420px_1fr] gap-4 overflow-hidden">
|
||||
{/* 좌측: 복사 대상 품목 선택 */}
|
||||
<div className="flex flex-col overflow-hidden border rounded-lg">
|
||||
<div className="flex items-center justify-between border-b bg-muted/50 px-3 py-2">
|
||||
<span className="text-xs font-semibold">복사 대상 품목 선택</span>
|
||||
{copyCheckedIds.length > 0 && <span className="text-[10px] text-primary">선택 {copyCheckedIds.length}건</span>}
|
||||
</div>
|
||||
<div className="flex gap-2 px-2 pt-2">
|
||||
<Input className="h-8 flex-1 text-xs" placeholder="품목코드 또는 품목명" value={copySearchKeyword}
|
||||
onChange={(e) => setCopySearchKeyword(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") handleCopySearch(); }} />
|
||||
<Button size="sm" className="h-8 text-xs" onClick={handleCopySearch} disabled={copySearchLoading}>
|
||||
{copySearchLoading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Search className="w-3.5 h-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto mt-2">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-background">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[36px] text-center text-[10px]">
|
||||
<Checkbox
|
||||
checked={copyFilteredItems.length > 0 && copyFilteredItems.every(i => copyCheckedIds.includes(i.code))}
|
||||
onCheckedChange={(v) => {
|
||||
if (v) setCopyCheckedIds(prev => Array.from(new Set([...prev, ...copyFilteredItems.map(i => i.code)])));
|
||||
else setCopyCheckedIds(prev => prev.filter(c => !copyFilteredItems.some(i => i.code === c)));
|
||||
}}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[120px]">품목코드</TableHead>
|
||||
<TableHead className="text-[10px] font-bold">품목명</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{copyFilteredItems.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={3} className="text-center py-6 text-muted-foreground text-xs">
|
||||
{copySearchLoading ? "검색 중..." : "검색 결과가 없어요"}
|
||||
</TableCell></TableRow>
|
||||
) : copyFilteredItems.map((item) => (
|
||||
<TableRow key={item.code} className="cursor-pointer hover:bg-primary/5"
|
||||
onClick={() => toggleCopyChecked(item.code)}>
|
||||
<TableCell className="text-center p-1" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={copyCheckedIds.includes(item.code)} onCheckedChange={() => toggleCopyChecked(item.code)} />
|
||||
</TableCell>
|
||||
<TableCell className="text-xs font-mono p-1">{item.code}</TableCell>
|
||||
<TableCell className="text-xs p-1 truncate">{item.name}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="border-t flex items-center justify-between px-2 py-1 text-[10px] text-muted-foreground">
|
||||
<span>전체 <span className="font-medium text-foreground">{copyTotal.toLocaleString()}</span>건</span>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button onClick={() => { setCopyPage(1); searchCopyTargets(1); }} disabled={copyPage <= 1}
|
||||
className="h-6 w-6 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsLeft className="h-3 w-3" /></button>
|
||||
<button onClick={() => { const p = copyPage - 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage <= 1}
|
||||
className="h-6 w-6 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronLeft className="h-3 w-3" /></button>
|
||||
<span className="text-[10px] mx-1">{copyPage}/{copyTotalPages}</span>
|
||||
<button onClick={() => { const p = copyPage + 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage >= copyTotalPages}
|
||||
className="h-6 w-6 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronRight className="h-3 w-3" /></button>
|
||||
<button onClick={() => { setCopyPage(copyTotalPages); searchCopyTargets(copyTotalPages); }} disabled={copyPage >= copyTotalPages}
|
||||
className="h-6 w-6 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsRight className="h-3 w-3" /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우측: 편집 폼 (등록/수정 폼과 동일 구조) */}
|
||||
<div className="flex flex-col overflow-hidden border rounded-lg">
|
||||
<div className="border-b bg-muted/50 px-3 py-2">
|
||||
<span className="text-xs font-semibold">복사할 검사정보 편집 (기준: {selectedItemCode})</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">사용여부</Label>
|
||||
<Select value={copyForm.is_active === false || copyForm.is_active === "N" ? "N" : "Y"} onValueChange={(v) => setCopyForm(p => ({ ...p, is_active: v === "Y" ? "사용" : "미사용" }))}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Y">사용</SelectItem>
|
||||
<SelectItem value="N">미사용</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">관리자</Label>
|
||||
<Select value={copyForm.manager || ""} onValueChange={(v) => setCopyForm(p => ({ ...p, manager: v }))}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="관리자 선택" /></SelectTrigger>
|
||||
<SelectContent>{userOptions.map(o => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-semibold">검사유형 선택</h4>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{INSPECTION_TYPES.map(({ key, label }) => (
|
||||
<div key={key} className="flex items-center gap-1.5">
|
||||
<Checkbox checked={!!copyForm[key]} onCheckedChange={(v) => setCopyForm(p => ({ ...p, [key]: !!v }))} />
|
||||
<Label className="text-xs cursor-pointer">{label}</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{INSPECTION_TYPES.filter(t => !!copyForm[t.key]).map(({ key, label }) => (
|
||||
<div key={key} className="space-y-1.5">
|
||||
<button type="button" className="w-full flex items-center gap-2 py-1.5 px-2 rounded-md border bg-muted/50 hover:bg-muted text-left" onClick={() => toggleCopyCollapse(key)}>
|
||||
<Badge variant="default" className="text-[10px]">{label}</Badge>
|
||||
<span className="text-xs font-medium">검사항목 설정</span>
|
||||
<span className="text-[10px] text-muted-foreground ml-auto">{(copyInspectionRows[key] || []).length}개</span>
|
||||
</button>
|
||||
{!copyCollapsedTypes[key] && (
|
||||
<div className="space-y-1.5 pl-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground">검사항목 목록</span>
|
||||
<Button type="button" size="sm" variant="outline" className="h-6 text-[10px]" onClick={() => addCopyInspRow(key)}>
|
||||
<Plus className="w-3 h-3 mr-1" />항목추가
|
||||
</Button>
|
||||
</div>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50 hover:bg-muted/50">
|
||||
<TableHead className="text-[10px] font-bold w-[150px]">검사기준 선택</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[110px]">검사기준 상세</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[80px]">검사방법</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[80px]">적용공정</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[90px]">구분</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[70px]">판단기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[180px]">합격기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[36px]">필수</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[60px]">단위</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[32px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(!copyInspectionRows[key] || copyInspectionRows[key].length === 0) ? (
|
||||
<TableRow><TableCell colSpan={10} className="text-center py-3 text-[10px] text-muted-foreground">항목추가 버튼으로 검사항목을 추가하세요</TableCell></TableRow>
|
||||
) : copyInspectionRows[key].map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="p-1">
|
||||
<Select value={row.inspection_standard_id || ""} onValueChange={(v) => updateCopyInspRow(key, row.id, "inspection_standard_id", v)}>
|
||||
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="검사기준" /></SelectTrigger>
|
||||
<SelectContent>{getFilteredInspOptions(key).map(o => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="p-1"><Input className="h-7 text-[10px] bg-muted" value={row.inspection_detail} readOnly placeholder="자동" /></TableCell>
|
||||
<TableCell className="p-1"><Input className="h-7 text-[10px] bg-muted" value={row.inspection_method} readOnly placeholder="자동" /></TableCell>
|
||||
<TableCell className="p-1">
|
||||
{processOptions.length > 0 ? (
|
||||
<Select value={row.apply_process || ""} onValueChange={(v) => updateCopyInspRow(key, row.id, "apply_process", v)}>
|
||||
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="공정" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{processOptions.map((p) => (
|
||||
<SelectItem key={p.code} value={p.code} className="text-xs">{p.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input className="h-7 text-[10px]" value={row.apply_process} onChange={(e) => updateCopyInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Input className="h-7 text-[10px]" value={row.classification || ""} onChange={(e) => updateCopyInspRow(key, row.id, "classification", e.target.value)} placeholder="구분" />
|
||||
</TableCell>
|
||||
<TableCell className="p-1 text-center">
|
||||
{row.judgment_criteria ? <Badge variant="outline" className="text-[9px]">{row.judgment_criteria}</Badge> : <span className="text-[9px] text-muted-foreground">-</span>}
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
{row.judgment_criteria === "선택형" && row.selection_options ? (
|
||||
<Select value={row.acceptance_criteria || ""} onValueChange={(v) => updateCopyInspRow(key, row.id, "acceptance_criteria", v)} disabled={!row.inspection_standard_id}>
|
||||
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{row.selection_options.split(",").filter(Boolean).map((opt) => (
|
||||
<SelectItem key={opt} value={opt} className="text-xs">{opt}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : row.judgment_criteria === "O/X" ? (
|
||||
<Select value={row.acceptance_criteria || ""} onValueChange={(v) => updateCopyInspRow(key, row.id, "acceptance_criteria", v)} disabled={!row.inspection_standard_id}>
|
||||
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="O" className="text-xs">O (합격)</SelectItem>
|
||||
<SelectItem value="X" className="text-xs">X (불합격)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : row.judgment_criteria === "수치(범위)" ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Input className="h-7 text-[10px] w-14" value={row.acceptance_criteria?.split("|")[0] || ""} onChange={(e) => {
|
||||
const parts = (row.acceptance_criteria || "||").split("|");
|
||||
parts[0] = e.target.value;
|
||||
updateCopyInspRow(key, row.id, "acceptance_criteria", parts.join("|"));
|
||||
}} placeholder="기준" disabled={!row.inspection_standard_id} />
|
||||
<span className="text-[9px] text-muted-foreground">±</span>
|
||||
<Input className="h-7 text-[10px] w-10" value={row.acceptance_criteria?.split("|")[1] || ""} onChange={(e) => {
|
||||
const parts = (row.acceptance_criteria || "||").split("|");
|
||||
parts[1] = e.target.value;
|
||||
updateCopyInspRow(key, row.id, "acceptance_criteria", parts.join("|"));
|
||||
}} placeholder="±" disabled={!row.inspection_standard_id} />
|
||||
</div>
|
||||
) : (
|
||||
<Input className="h-7 text-[10px]" value={row.acceptance_criteria} onChange={(e) => updateCopyInspRow(key, row.id, "acceptance_criteria", e.target.value)} placeholder="합격기준" disabled={!row.inspection_standard_id} />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="p-1 text-center"><Checkbox checked={row.is_required} onCheckedChange={(v) => updateCopyInspRow(key, row.id, "is_required", !!v)} /></TableCell>
|
||||
<TableCell className="p-1 text-[10px] text-muted-foreground">{row.unit || "-"}</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Button type="button" variant="destructive" size="sm" className="h-6 w-6 p-0" onClick={() => removeCopyInspRow(key, row.id)}><Trash2 className="w-3 h-3" /></Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter className="shrink-0">
|
||||
<Button variant="outline" onClick={() => setCopyModalOpen(false)} disabled={copying}>취소</Button>
|
||||
<Button onClick={handleCopy} disabled={copying || copyCheckedIds.length === 0}>
|
||||
{copying ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Copy className="w-4 h-4 mr-1" />}
|
||||
{copying
|
||||
? `복사 중 (${copyProgress.current}/${copyProgress.total})`
|
||||
: copyCheckedIds.length > 0 ? `${copyCheckedIds.length}개 품목에 복사` : "복사"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<TableSettingsModal open={ts.open} onOpenChange={ts.setOpen} tableName={ts.tableName} settingsId={ts.settingsId} defaultVisibleKeys={ts.defaultVisibleKeys} onSave={ts.applySettings} />
|
||||
|
||||
{/* ═══════ 엑셀 업로드 모달 ═══════ */}
|
||||
|
||||
@@ -190,7 +190,7 @@ export default function ClaimManagementPage() {
|
||||
|
||||
const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
|
||||
page: 1,
|
||||
size: 500,
|
||||
size: 0,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
sort: { columnName: "claim_date", order: "desc" },
|
||||
|
||||
@@ -219,7 +219,7 @@ export default function CustomerManagementPage() {
|
||||
} catch { /* skip */ }
|
||||
};
|
||||
load();
|
||||
apiClient.post(`/table-management/tables/user_info/data`, { page: 1, size: 500, autoFilter: true })
|
||||
apiClient.post(`/table-management/tables/user_info/data`, { page: 1, size: 0, autoFilter: true })
|
||||
.then((res) => {
|
||||
const users = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
setEmployeeOptions(users.map((u: any) => ({
|
||||
@@ -284,7 +284,7 @@ export default function CustomerManagementPage() {
|
||||
const fetchMainContacts = useCallback(async () => {
|
||||
try {
|
||||
const contactRes = await apiClient.post(`/table-management/tables/${CONTACT_TABLE}/data`, {
|
||||
page: 1, size: 500, autoFilter: true,
|
||||
page: 1, size: 0, autoFilter: true,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "is_main", operator: "equals", value: "Y" }] },
|
||||
});
|
||||
const allContacts = contactRes.data?.data?.data || contactRes.data?.data?.rows || [];
|
||||
@@ -310,7 +310,7 @@ export default function CustomerManagementPage() {
|
||||
setPriceLoading(true);
|
||||
try {
|
||||
const mappingRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "customer_id", operator: "equals", value: selectedCustomer.customer_code },
|
||||
]},
|
||||
@@ -337,7 +337,7 @@ export default function CustomerManagementPage() {
|
||||
if (mappings.length > 0) {
|
||||
try {
|
||||
const priceRes = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "customer_id", operator: "equals", value: selectedCustomer.customer_code },
|
||||
]},
|
||||
@@ -413,7 +413,7 @@ export default function CustomerManagementPage() {
|
||||
setDeliveryLoading(true);
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/${DELIVERY_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "customer_code", operator: "equals", value: selectedCustomer.customer_code },
|
||||
]},
|
||||
@@ -493,7 +493,7 @@ export default function CustomerManagementPage() {
|
||||
if (ruleData?.success && ruleData?.data?.ruleId) {
|
||||
const ruleId = ruleData.data.ruleId;
|
||||
const allRes = await apiClient.post(`/table-management/tables/${DELIVERY_TABLE}/data`, {
|
||||
page: 1, size: 500, autoFilter: true,
|
||||
page: 1, size: 0, autoFilter: true,
|
||||
sort: { columnName: "destination_code", order: "desc" },
|
||||
});
|
||||
const allRows = allRes.data?.data?.data || allRes.data?.data?.rows || [];
|
||||
@@ -566,7 +566,7 @@ export default function CustomerManagementPage() {
|
||||
const ruleId = ruleData.data.ruleId;
|
||||
// 기존 데이터에서 CUST-XXX 패턴의 최대 순번 조회
|
||||
const allRes = await apiClient.post(`/table-management/tables/${CUSTOMER_TABLE}/data`, {
|
||||
page: 1, size: 500, autoFilter: true,
|
||||
page: 1, size: 0, autoFilter: true,
|
||||
sort: { columnName: "customer_code", order: "desc" },
|
||||
});
|
||||
const allRows = allRes.data?.data?.data || allRes.data?.data?.rows || [];
|
||||
@@ -767,7 +767,7 @@ export default function CustomerManagementPage() {
|
||||
const ruleData = ruleRes.data;
|
||||
if (ruleData?.success && ruleData?.data?.ruleId) {
|
||||
const ruleId = ruleData.data.ruleId;
|
||||
const allRes = await apiClient.post(`/table-management/tables/${CUSTOMER_TABLE}/data`, { page: 1, size: 500, autoFilter: true, sort: { columnName: "customer_code", order: "desc" } });
|
||||
const allRes = await apiClient.post(`/table-management/tables/${CUSTOMER_TABLE}/data`, { page: 1, size: 0, autoFilter: true, sort: { columnName: "customer_code", order: "desc" } });
|
||||
const allRows = allRes.data?.data?.data || allRes.data?.data?.rows || [];
|
||||
let maxSeq = 0;
|
||||
for (const row of allRows) { const match = (row.customer_code || "").match(/(\d+)$/); if (match) { const seq = parseInt(match[1], 10); if (seq > maxSeq) maxSeq = seq; } }
|
||||
@@ -822,7 +822,7 @@ export default function CustomerManagementPage() {
|
||||
? [{ columnName: "division", operator: "contains", value: salesCode }]
|
||||
: [];
|
||||
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||||
page: 1, size: 5000,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters },
|
||||
autoFilter: true,
|
||||
});
|
||||
@@ -1247,7 +1247,7 @@ export default function CustomerManagementPage() {
|
||||
for (const itemId of itemIds) {
|
||||
// 해당 품목의 모든 매핑 조회 → customer_id null 처리
|
||||
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "customer_id", operator: "equals", value: selectedCustomer!.customer_code },
|
||||
{ columnName: "item_id", operator: "equals", value: itemId },
|
||||
@@ -1264,7 +1264,7 @@ export default function CustomerManagementPage() {
|
||||
// 해당 품목의 모든 단가 조회 → customer_id null 처리
|
||||
try {
|
||||
const priceRes = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "customer_id", operator: "equals", value: selectedCustomer!.customer_code },
|
||||
{ columnName: "item_id", operator: "equals", value: itemId },
|
||||
@@ -1336,7 +1336,7 @@ export default function CustomerManagementPage() {
|
||||
try {
|
||||
const allMappings: any[] = [];
|
||||
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
|
||||
page: 1, size: 5000, autoFilter: true,
|
||||
page: 1, size: 0, autoFilter: true,
|
||||
});
|
||||
const mappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
|
||||
const itemIds = [...new Set(mappings.map((m: any) => m.item_id).filter(Boolean))];
|
||||
|
||||
@@ -28,6 +28,7 @@ import { ShippingPlanBatchModal } from "@/components/common/ShippingPlanBatchMod
|
||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { SmartSelect } from "@/components/common/SmartSelect";
|
||||
import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
|
||||
|
||||
const DETAIL_TABLE = "sales_order_detail";
|
||||
@@ -277,7 +278,7 @@ export default function SalesOrderPage() {
|
||||
// 거래처 목록
|
||||
try {
|
||||
const custRes = await apiClient.post(`/table-management/tables/customer_mng/data`, {
|
||||
page: 1, size: 500, autoFilter: true,
|
||||
page: 1, size: 0, autoFilter: true,
|
||||
});
|
||||
const custs = custRes.data?.data?.data || custRes.data?.data?.rows || [];
|
||||
optMap["partner_id"] = custs.map((c: any) => ({ code: c.customer_code, label: c.customer_name }));
|
||||
@@ -285,7 +286,7 @@ export default function SalesOrderPage() {
|
||||
// 사용자 목록
|
||||
try {
|
||||
const userRes = await apiClient.post(`/table-management/tables/user_info/data`, {
|
||||
page: 1, size: 500, autoFilter: true,
|
||||
page: 1, size: 0, autoFilter: true,
|
||||
});
|
||||
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
|
||||
optMap["manager_id"] = users.map((u: any) => ({
|
||||
@@ -330,7 +331,7 @@ export default function SalesOrderPage() {
|
||||
}));
|
||||
|
||||
const res = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
sort: { columnName: "order_no", order: "desc" },
|
||||
@@ -770,7 +771,7 @@ export default function SalesOrderPage() {
|
||||
if (isCustomerPrice && partnerId) {
|
||||
try {
|
||||
const mappingRes = await apiClient.post(`/table-management/tables/customer_item_mapping/data`, {
|
||||
page: 1, size: 5000,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "customer_id", operator: "equals", value: partnerId }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
@@ -860,7 +861,7 @@ export default function SalesOrderPage() {
|
||||
try {
|
||||
const itemIds = selected.map((item) => item.item_number || item.id);
|
||||
const res = await apiClient.post(`/table-management/tables/customer_item_prices/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: {
|
||||
enabled: true,
|
||||
filters: [
|
||||
@@ -931,7 +932,7 @@ export default function SalesOrderPage() {
|
||||
if (itemCodes.length === 0) return;
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: itemCodes }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
@@ -954,7 +955,7 @@ export default function SalesOrderPage() {
|
||||
if (itemCodes.length === 0) return;
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/customer_item_prices/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "customer_id", operator: "equals", value: partnerId },
|
||||
{ columnName: "item_id", operator: "in", value: itemCodes },
|
||||
@@ -1481,17 +1482,12 @@ export default function SalesOrderPage() {
|
||||
<div className="grid grid-cols-1 gap-3.5 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">거래처</Label>
|
||||
<Select
|
||||
<SmartSelect
|
||||
options={categoryOptions["partner_id"] || []}
|
||||
value={masterForm.partner_id || ""}
|
||||
onValueChange={(v) => { setMasterForm((p) => ({ ...p, partner_id: v, delivery_partner_id: "" })); loadDeliveryOptions(v); recalcPrices(masterForm.price_mode || "", v); }}
|
||||
>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="거래처 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(categoryOptions["partner_id"] || []).map((o) => (
|
||||
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
placeholder="거래처 선택"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">담당자</Label>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { reportApi } from "@/lib/api/reportApi";
|
||||
import { useCurrent2ndLevelMenuObjid } from "@/hooks/useCurrent2ndLevelMenuObjid";
|
||||
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
@@ -153,10 +154,13 @@ export default function QuoteManagementPage() {
|
||||
|
||||
useEffect(() => { fetchQuotes(); }, [fetchQuotes]);
|
||||
|
||||
const current2ndLevelMenuObjid = useCurrent2ndLevelMenuObjid();
|
||||
|
||||
useEffect(() => {
|
||||
if (current2ndLevelMenuObjid === null) return;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await reportApi.getReports({ page: 1, limit: 100 });
|
||||
const res = await reportApi.getReportsByMenuObjid(current2ndLevelMenuObjid);
|
||||
if (res.success) {
|
||||
const items = res.data.items ?? [];
|
||||
setReportList(items);
|
||||
@@ -164,7 +168,7 @@ export default function QuoteManagementPage() {
|
||||
}
|
||||
} catch { /* 무시 */ }
|
||||
})();
|
||||
}, []);
|
||||
}, [current2ndLevelMenuObjid]);
|
||||
|
||||
// ── "신규" → DB에 draft 즉시 생성 → 자동 선택 ──
|
||||
|
||||
|
||||
@@ -311,6 +311,11 @@ export default function SalesItemPage() {
|
||||
|
||||
// 좌측: 품목 조회
|
||||
const fetchItems = useCallback(async () => {
|
||||
// 카테고리 로드 완료 전엔 대기 — 먼저 나간 unfiltered 요청이 나중에 도착해
|
||||
// filtered 결과를 덮어쓰는 race condition 방지
|
||||
if (!categoryOptions["division"]?.length) {
|
||||
return;
|
||||
}
|
||||
setItemLoading(true);
|
||||
try {
|
||||
const filters: { columnName: string; operator: string; value: any }[] = [];
|
||||
@@ -327,7 +332,7 @@ export default function SalesItemPage() {
|
||||
}
|
||||
|
||||
const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
|
||||
page: 1, size: 5000,
|
||||
page: 1, size: 0,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
@@ -373,7 +378,7 @@ export default function SalesItemPage() {
|
||||
try {
|
||||
// 1. customer_item_mapping에서 해당 품목의 매핑 조회
|
||||
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_id", operator: "equals", value: itemKey }] },
|
||||
autoFilter: true,
|
||||
sort: { columnName: "created_date", order: "asc" },
|
||||
@@ -401,7 +406,7 @@ export default function SalesItemPage() {
|
||||
if (mappings.length > 0) {
|
||||
try {
|
||||
const priceRes = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "item_id", operator: "equals", value: itemKey },
|
||||
]},
|
||||
@@ -1111,7 +1116,7 @@ export default function SalesItemPage() {
|
||||
for (const custCode of customerCodes) {
|
||||
// 해당 거래처의 모든 매핑 조회 → item_id null 처리
|
||||
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "item_id", operator: "equals", value: selectedItem!.item_number },
|
||||
{ columnName: "customer_id", operator: "equals", value: custCode },
|
||||
@@ -1128,7 +1133,7 @@ export default function SalesItemPage() {
|
||||
// 해당 거래처의 모든 단가 조회 → item_id null 처리
|
||||
try {
|
||||
const priceRes = await apiClient.post(`/table-management/tables/customer_item_prices/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "item_id", operator: "equals", value: selectedItem!.item_number },
|
||||
{ columnName: "customer_id", operator: "equals", value: custCode },
|
||||
|
||||
@@ -150,17 +150,17 @@ export default function EquipmentInfoPage() {
|
||||
const colProps: Record<string, Partial<EDataTableColumn>> = {
|
||||
equipment_code: { width: "w-[110px]" },
|
||||
equipment_name: { minWidth: "min-w-[130px]", truncate: true, render: (v) => v || "-" },
|
||||
equipment_type: { width: "w-[90px]", render: (v) => v || "-" },
|
||||
equipment_type: { width: "w-[90px]", render: (v) => resolve("equipment_type", v) || v || "-" },
|
||||
manufacturer: { width: "w-[100px]", render: (v) => v || "-" },
|
||||
installation_location: { width: "w-[100px]", render: (v) => v || "-" },
|
||||
operation_status: { width: "w-[80px]", render: (v) => v || "-" },
|
||||
operation_status: { width: "w-[80px]", render: (v) => resolve("operation_status", v) || v || "-" },
|
||||
};
|
||||
return ts.visibleColumns.map((col) => ({
|
||||
key: col.key,
|
||||
label: col.label,
|
||||
...colProps[col.key],
|
||||
}));
|
||||
}, [ts.visibleColumns]);
|
||||
}, [ts.visibleColumns, catOptions]);
|
||||
|
||||
// 설비 조회
|
||||
const fetchEquipments = useCallback(async () => {
|
||||
@@ -168,16 +168,12 @@ export default function EquipmentInfoPage() {
|
||||
try {
|
||||
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
|
||||
const res = await apiClient.post(`/table-management/tables/${EQUIP_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
const raw = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
setEquipments(raw.map((r: any) => ({
|
||||
...r,
|
||||
equipment_type: resolve("equipment_type", r.equipment_type),
|
||||
operation_status: resolve("operation_status", r.operation_status),
|
||||
})));
|
||||
setEquipments(raw);
|
||||
setEquipCount(res.data?.data?.total || raw.length);
|
||||
} catch { toast.error("설비 목록 조회 실패"); } finally { setEquipLoading(false); }
|
||||
}, [searchFilters, catOptions]);
|
||||
@@ -213,7 +209,7 @@ export default function EquipmentInfoPage() {
|
||||
setInspectionLoading(true);
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "equals", value: selectedEquip.equipment_code }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
@@ -231,7 +227,7 @@ export default function EquipmentInfoPage() {
|
||||
setConsumableLoading(true);
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/${CONSUMABLE_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "equals", value: selectedEquip.equipment_code }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
@@ -407,7 +403,7 @@ export default function EquipmentInfoPage() {
|
||||
if (consumableDiv) filters.push({ columnName: "division", operator: "equals", value: consumableDiv.valueCode });
|
||||
const results = await Promise.all(filters.map((f) =>
|
||||
apiClient.post(`/table-management/tables/item_info/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [f] },
|
||||
autoFilter: true,
|
||||
})
|
||||
@@ -454,7 +450,7 @@ export default function EquipmentInfoPage() {
|
||||
setCopyLoading(true);
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "equals", value: equipCode }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
@@ -482,9 +478,9 @@ export default function EquipmentInfoPage() {
|
||||
const handleExcelDownload = async () => {
|
||||
if (equipments.length === 0) return;
|
||||
await exportToExcel(equipments.map((e) => ({
|
||||
설비코드: e.equipment_code, 설비명: e.equipment_name, 설비유형: e.equipment_type,
|
||||
설비코드: e.equipment_code, 설비명: e.equipment_name, 설비유형: resolve("equipment_type", e.equipment_type),
|
||||
제조사: e.manufacturer, 모델명: e.model_name, 설치장소: e.installation_location,
|
||||
도입일자: e.introduction_date, 가동상태: e.operation_status,
|
||||
도입일자: e.introduction_date, 가동상태: resolve("operation_status", e.operation_status),
|
||||
})), "설비정보.xlsx", "설비");
|
||||
toast.success("다운로드 완료");
|
||||
};
|
||||
|
||||
@@ -83,7 +83,7 @@ export default function EquipmentInspectionRecordPage() {
|
||||
}).catch(() => ({ data: { data: { data: [] } } })),
|
||||
apiClient.post(`/table-management/tables/equipment_mng/data`, {
|
||||
page: 1,
|
||||
size: 500,
|
||||
size: 0,
|
||||
autoFilter: true,
|
||||
}).catch(() => ({ data: { data: { data: [] } } })),
|
||||
]);
|
||||
|
||||
@@ -100,7 +100,7 @@ export default function PlcSettingsPage() {
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const eqRes = await apiClient.post(`/table-management/tables/${EQUIPMENT_TABLE}/data`, { page: 1, size: 500, autoFilter: true });
|
||||
const eqRes = await apiClient.post(`/table-management/tables/${EQUIPMENT_TABLE}/data`, { page: 1, size: 0, autoFilter: true });
|
||||
const eqs = eqRes.data?.data?.data || eqRes.data?.data?.rows || [];
|
||||
setEquipOptions(eqs.map((r: any) => ({ code: r.equipment_code, label: `${r.equipment_code} - ${r.equipment_name || ""}` })));
|
||||
} catch { /* skip */ }
|
||||
@@ -122,7 +122,7 @@ export default function PlcSettingsPage() {
|
||||
const filters: any[] = [];
|
||||
if (kw.trim()) filters.push({ columnName: "equipment_code", operator: "contains", value: kw.trim() });
|
||||
const res = await apiClient.post(`/table-management/tables/${DATATYPE_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
@@ -140,7 +140,7 @@ export default function PlcSettingsPage() {
|
||||
const filters: any[] = [];
|
||||
if (kw.trim()) filters.push({ columnName: "config_name", operator: "contains", value: kw.trim() });
|
||||
const res = await apiClient.post(`/table-management/tables/${COLLECTION_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
|
||||
@@ -150,7 +150,7 @@ export default function InboundOutboundPage() {
|
||||
if (writerIds.length > 0) {
|
||||
try {
|
||||
const userRes = await apiClient.post(`/table-management/tables/user_info/data`, {
|
||||
page: 1, size: 500, autoFilter: true,
|
||||
page: 1, size: 0, autoFilter: true,
|
||||
});
|
||||
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
|
||||
const uMap: Record<string, string> = {};
|
||||
|
||||
@@ -327,11 +327,11 @@ export default function LogisticsInfoPage() {
|
||||
try {
|
||||
const [carrierRes, routeRes] = await Promise.all([
|
||||
apiClient.post("/table-management/tables/carrier_mng/data", {
|
||||
page: 1, size: 500, autoFilter: true,
|
||||
page: 1, size: 0, autoFilter: true,
|
||||
sort: { columnName: "carrier_code", order: "asc" },
|
||||
}),
|
||||
apiClient.post("/table-management/tables/delivery_route_mng/data", {
|
||||
page: 1, size: 500, autoFilter: true,
|
||||
page: 1, size: 0, autoFilter: true,
|
||||
sort: { columnName: "route_code", order: "asc" },
|
||||
}),
|
||||
]);
|
||||
@@ -358,13 +358,15 @@ export default function LogisticsInfoPage() {
|
||||
loadReferences();
|
||||
}, [loadReferences]);
|
||||
|
||||
// 카테고리 옵션 로드
|
||||
// 카테고리 옵션 로드 (관리자 계정일 때 filterCompanyCode 미제공 시 "*" 스코프로 빈 결과 반환됨)
|
||||
const loadCategoryOptions = useCallback(async (tableColumn: string) => {
|
||||
if (loadedCategories.current.has(tableColumn)) return;
|
||||
loadedCategories.current.add(tableColumn);
|
||||
const [tableName, columnName] = tableColumn.split(":");
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/${tableName}/${columnName}/values`);
|
||||
const res = await apiClient.get(
|
||||
`/table-categories/${tableName}/${columnName}/values?filterCompanyCode=COMPANY_16`
|
||||
);
|
||||
const data = res.data?.data || [];
|
||||
setCategoryOptions((prev) => ({
|
||||
...prev,
|
||||
@@ -393,7 +395,7 @@ export default function LogisticsInfoPage() {
|
||||
const res = await apiClient.post(
|
||||
`/table-management/tables/${config.tableName}/data`,
|
||||
{
|
||||
page: 1, size: 500, autoFilter: true,
|
||||
page: 1, size: 0, autoFilter: true,
|
||||
sort: { columnName: config.defaultSortColumn, order: "asc" },
|
||||
}
|
||||
);
|
||||
@@ -823,13 +825,24 @@ export default function LogisticsInfoPage() {
|
||||
{/* 테이블 영역 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<EDataTable
|
||||
columns={getVisibleColumns(tab.key).map((col): EDataTableColumn => ({
|
||||
key: col.key,
|
||||
label: col.label,
|
||||
align: col.align,
|
||||
formatNumber: col.formatNumber,
|
||||
truncate: true,
|
||||
}))}
|
||||
columns={getVisibleColumns(tab.key).map((col): EDataTableColumn => {
|
||||
// 같은 key의 formField에 categoryKey가 있으면 코드→라벨 변환
|
||||
const formField = tab.formFields.find((f) => f.key === col.key && f.categoryKey);
|
||||
return {
|
||||
key: col.key,
|
||||
label: col.label,
|
||||
align: col.align,
|
||||
formatNumber: col.formatNumber,
|
||||
truncate: true,
|
||||
render: formField?.categoryKey
|
||||
? (value: any) => {
|
||||
const opts = categoryOptions[formField.categoryKey!] || [];
|
||||
const matched = opts.find((o: any) => o.value === value);
|
||||
return matched?.label || value || "-";
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
})}
|
||||
data={tsMap[tab.key].groupData(displayData)}
|
||||
rowKey={(row: any) => String(row.id)}
|
||||
loading={tabLoading[tab.key]}
|
||||
|
||||
@@ -13,6 +13,7 @@ import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
@@ -68,6 +69,7 @@ const STOCK_TABLE = "inventory_stock";
|
||||
const STOCK_COLUMNS = [
|
||||
{ key: "item_code", label: "품목코드" },
|
||||
{ key: "item_name", label: "품명" },
|
||||
{ key: "spec", label: "규격" },
|
||||
{ key: "warehouse_code", label: "창고" },
|
||||
{ key: "location_code", label: "위치" },
|
||||
{ key: "current_qty", label: "현재수량", align: "right" as const },
|
||||
@@ -87,6 +89,8 @@ const getStatusVariant = (
|
||||
return "destructive";
|
||||
case "과잉":
|
||||
return "secondary";
|
||||
case "미등록":
|
||||
return "outline";
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
@@ -119,6 +123,15 @@ export default function InventoryStatusPage() {
|
||||
const [stockLoading, setStockLoading] = useState(false);
|
||||
const [selectedStockId, setSelectedStockId] = useState<string | null>(null);
|
||||
|
||||
// 재고 없는 품목 표시 여부
|
||||
const [showMissingItems, setShowMissingItems] = useState(false);
|
||||
|
||||
// 창고 목록 (조정 모달에서 사용)
|
||||
const [warehouseList, setWarehouseList] = useState<{ code: string; name: string }[]>([]);
|
||||
|
||||
// 선택된 창고의 위치 목록 (조정 모달에서 사용)
|
||||
const [locationList, setLocationList] = useState<{ code: string; name: string }[]>([]);
|
||||
|
||||
// 검색 필터
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
|
||||
@@ -132,7 +145,9 @@ export default function InventoryStatusPage() {
|
||||
adjust_type: string;
|
||||
adjust_qty: string;
|
||||
reason: string;
|
||||
}>({ adjust_type: "증가", adjust_qty: "", reason: "" });
|
||||
warehouse_code: string;
|
||||
location_code: string;
|
||||
}>({ adjust_type: "증가", adjust_qty: "", reason: "", warehouse_code: "", location_code: "" });
|
||||
const [adjustSaving, setAdjustSaving] = useState(false);
|
||||
|
||||
// 카테고리 옵션
|
||||
@@ -171,12 +186,12 @@ export default function InventoryStatusPage() {
|
||||
};
|
||||
load();
|
||||
// 사용자 목록 로드
|
||||
apiClient.get("/admin/users", { params: { size: 9999 } }).then((res) => {
|
||||
const users = res.data?.data || res.data || [];
|
||||
apiClient.get("/admin/users/name-map").then((res) => {
|
||||
const users = res.data?.data || [];
|
||||
const map: Record<string, string> = {};
|
||||
for (const u of users) {
|
||||
const id = u.userId || u.user_id || u.id;
|
||||
const name = u.user_name || u.name || id;
|
||||
const id = u.user_id;
|
||||
const name = u.user_name || id;
|
||||
if (id) map[id] = name;
|
||||
}
|
||||
setUserMap(map);
|
||||
@@ -190,19 +205,20 @@ export default function InventoryStatusPage() {
|
||||
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
|
||||
const [stockRes, itemRes, whRes] = await Promise.all([
|
||||
apiClient.post(`/table-management/tables/${STOCK_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
sort: { columnName: "item_code", order: "asc" },
|
||||
}),
|
||||
apiClient.post(`/table-management/tables/item_info/data`, { page: 1, size: 500, autoFilter: true }),
|
||||
apiClient.post(`/table-management/tables/warehouse_info/data`, { page: 1, size: 500, autoFilter: true }),
|
||||
apiClient.post(`/table-management/tables/item_info/data`, { page: 1, size: 0, autoFilter: true }),
|
||||
apiClient.post(`/table-management/tables/warehouse_info/data`, { page: 1, size: 0, autoFilter: true }),
|
||||
]);
|
||||
const raw = stockRes.data?.data?.data || stockRes.data?.data?.rows || [];
|
||||
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
|
||||
const warehouses = whRes.data?.data?.data || whRes.data?.data?.rows || [];
|
||||
const itemMap = new Map(items.map((i: any) => [i.item_number || i.item_code, { name: i.item_name || "", unit: i.inventory_unit || "" }]));
|
||||
const itemMap = new Map(items.map((i: any) => [i.item_number || i.item_code, { name: i.item_name || "", unit: i.inventory_unit || "", spec: i.size || "" }]));
|
||||
const whMap = new Map(warehouses.map((w: any) => [w.warehouse_code, w.warehouse_name || w.warehouse_code]));
|
||||
setWarehouseList(warehouses.map((w: any) => ({ code: w.warehouse_code, name: w.warehouse_name || w.warehouse_code })));
|
||||
const resolve = (col: string, code: string) => {
|
||||
if (!code) return "";
|
||||
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
|
||||
@@ -213,19 +229,51 @@ export default function InventoryStatusPage() {
|
||||
return {
|
||||
...r,
|
||||
item_name: itemInfo?.name || "",
|
||||
spec: itemInfo?.spec || "",
|
||||
unit: resolve("item_inventory_unit", rawUnit) || rawUnit,
|
||||
warehouse_name: whMap.get(r.warehouse_code) || r.warehouse_code || "",
|
||||
status: resolve("status", r.status),
|
||||
_isLow: r.safety_qty && Number(r.current_qty) < Number(r.safety_qty),
|
||||
};
|
||||
});
|
||||
setStockItems(data);
|
||||
|
||||
// 재고 없는 품목 표시: inventory_stock에 없는 item_info 품목을 미등록 가상 행으로 추가
|
||||
if (showMissingItems) {
|
||||
const existingCodes = new Set(raw.map((r: any) => r.item_code).filter(Boolean));
|
||||
const missingRows = items
|
||||
.filter((i: any) => {
|
||||
const code = i.item_number || i.item_code;
|
||||
return code && !existingCodes.has(code);
|
||||
})
|
||||
.map((i: any) => {
|
||||
const code = i.item_number || i.item_code;
|
||||
const rawUnit = i.inventory_unit || "";
|
||||
return {
|
||||
id: `missing-${code}`,
|
||||
item_code: code,
|
||||
item_name: i.item_name || "",
|
||||
spec: i.size || "",
|
||||
warehouse_code: "",
|
||||
warehouse_name: "",
|
||||
location_code: "",
|
||||
current_qty: "0",
|
||||
safety_qty: "",
|
||||
unit: resolve("item_inventory_unit", rawUnit) || rawUnit,
|
||||
status: "미등록",
|
||||
_isLow: false,
|
||||
_isMissing: true,
|
||||
};
|
||||
});
|
||||
setStockItems([...data, ...missingRows]);
|
||||
} else {
|
||||
setStockItems(data);
|
||||
}
|
||||
} catch {
|
||||
toast.error("재고 목록을 불러오지 못했어요");
|
||||
} finally {
|
||||
setStockLoading(false);
|
||||
}
|
||||
}, [categoryOptions, searchFilters]);
|
||||
}, [categoryOptions, searchFilters, showMissingItems]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStock();
|
||||
@@ -260,7 +308,7 @@ export default function InventoryStatusPage() {
|
||||
`/table-management/tables/${HISTORY_TABLE}/data`,
|
||||
{
|
||||
page: 1,
|
||||
size: 500,
|
||||
size: 0,
|
||||
dataFilter: { enabled: true, filters: historyFilters },
|
||||
autoFilter: true,
|
||||
sort: { columnName: "transaction_date", order: "desc" },
|
||||
@@ -279,6 +327,36 @@ export default function InventoryStatusPage() {
|
||||
fetchHistory();
|
||||
}, [fetchHistory]);
|
||||
|
||||
// 창고 선택 시 해당 창고의 위치 목록 조회 (조정 모달용)
|
||||
useEffect(() => {
|
||||
const whCode = adjustForm.warehouse_code;
|
||||
if (!whCode) {
|
||||
setLocationList([]);
|
||||
return;
|
||||
}
|
||||
(async () => {
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/warehouse_location/data`, {
|
||||
page: 1,
|
||||
size: 0,
|
||||
dataFilter: {
|
||||
enabled: true,
|
||||
filters: [{ columnName: "warehouse_code", operator: "equals", value: whCode }],
|
||||
},
|
||||
autoFilter: true,
|
||||
});
|
||||
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
setLocationList(
|
||||
rows
|
||||
.filter((r: any) => r.location_code)
|
||||
.map((r: any) => ({ code: r.location_code, name: r.location_name || r.location_code }))
|
||||
);
|
||||
} catch {
|
||||
setLocationList([]);
|
||||
}
|
||||
})();
|
||||
}, [adjustForm.warehouse_code]);
|
||||
|
||||
// 재고 조정 저장
|
||||
const handleAdjustSave = async () => {
|
||||
if (!selectedStock) return;
|
||||
@@ -291,6 +369,20 @@ export default function InventoryStatusPage() {
|
||||
toast.error("조정 사유를 입력해주세요");
|
||||
return;
|
||||
}
|
||||
|
||||
const isMissing = !!selectedStock._isMissing;
|
||||
const targetWhCode = isMissing ? adjustForm.warehouse_code : (selectedStock.warehouse_code || "");
|
||||
const targetLocCode = isMissing ? adjustForm.location_code : (selectedStock.location_code || "");
|
||||
|
||||
if (isMissing && !targetWhCode) {
|
||||
toast.error("창고를 선택해주세요");
|
||||
return;
|
||||
}
|
||||
if (isMissing && adjustForm.adjust_type === "감소") {
|
||||
toast.error("미등록 품목은 감소 조정이 불가해요");
|
||||
return;
|
||||
}
|
||||
|
||||
setAdjustSaving(true);
|
||||
try {
|
||||
const changeQty = adjustForm.adjust_type === "증가" ? qty : -qty;
|
||||
@@ -301,8 +393,8 @@ export default function InventoryStatusPage() {
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
item_code: selectedStock.item_code,
|
||||
warehouse_code: selectedStock.warehouse_code || "",
|
||||
location_code: selectedStock.location_code || "",
|
||||
warehouse_code: targetWhCode,
|
||||
location_code: targetLocCode,
|
||||
transaction_type: "조정",
|
||||
transaction_date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().replace("Z", "+09:00"),
|
||||
quantity: String(changeQty),
|
||||
@@ -311,17 +403,34 @@ export default function InventoryStatusPage() {
|
||||
}
|
||||
);
|
||||
|
||||
await apiClient.put(
|
||||
`/table-management/tables/${STOCK_TABLE}/edit`,
|
||||
{
|
||||
originalData: { id: selectedStock.id },
|
||||
updatedData: { current_qty: afterQty },
|
||||
}
|
||||
);
|
||||
if (isMissing) {
|
||||
// 새 재고 레코드 생성
|
||||
await apiClient.post(
|
||||
`/table-management/tables/${STOCK_TABLE}/add`,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
item_code: selectedStock.item_code,
|
||||
warehouse_code: targetWhCode,
|
||||
location_code: targetLocCode,
|
||||
current_qty: String(afterQty),
|
||||
safety_qty: "0",
|
||||
last_in_date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().replace("Z", "+09:00"),
|
||||
}
|
||||
);
|
||||
} else {
|
||||
await apiClient.put(
|
||||
`/table-management/tables/${STOCK_TABLE}/edit`,
|
||||
{
|
||||
originalData: { id: selectedStock.id },
|
||||
updatedData: { current_qty: afterQty },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
toast.success("재고가 조정되었어요");
|
||||
setAdjustModalOpen(false);
|
||||
setAdjustForm({ adjust_type: "증가", adjust_qty: "", reason: "" });
|
||||
setAdjustForm({ adjust_type: "증가", adjust_qty: "", reason: "", warehouse_code: "", location_code: "" });
|
||||
setSelectedStockId(null);
|
||||
fetchStock();
|
||||
} catch {
|
||||
toast.error("재고 조정에 실패했어요");
|
||||
@@ -385,6 +494,7 @@ export default function InventoryStatusPage() {
|
||||
stockItems.map((r) => ({
|
||||
품목코드: r.item_code,
|
||||
품명: r.item_name,
|
||||
규격: r.spec || "",
|
||||
창고: r.warehouse_name || r.warehouse_code,
|
||||
위치: r.location_code,
|
||||
현재수량: r.current_qty,
|
||||
@@ -438,6 +548,13 @@ export default function InventoryStatusPage() {
|
||||
{stockItems.length}건
|
||||
</Badge>
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
|
||||
<Checkbox
|
||||
checked={showMissingItems}
|
||||
onCheckedChange={(v) => setShowMissingItems(!!v)}
|
||||
/>
|
||||
<span>재고 없는 품목 표시</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<EDataTable
|
||||
@@ -513,6 +630,8 @@ export default function InventoryStatusPage() {
|
||||
adjust_type: "증가",
|
||||
adjust_qty: "",
|
||||
reason: "",
|
||||
warehouse_code: selectedStock._isMissing ? "" : (selectedStock.warehouse_code || ""),
|
||||
location_code: selectedStock._isMissing ? "" : (selectedStock.location_code || ""),
|
||||
});
|
||||
setAdjustModalOpen(true);
|
||||
}}
|
||||
@@ -672,6 +791,68 @@ export default function InventoryStatusPage() {
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-2">
|
||||
{selectedStock?._isMissing && (
|
||||
<>
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">
|
||||
창고 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={adjustForm.warehouse_code}
|
||||
onValueChange={(v) =>
|
||||
setAdjustForm((prev) => ({
|
||||
...prev,
|
||||
warehouse_code: v,
|
||||
location_code: "",
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="창고를 선택해주세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{warehouseList.map((w) => (
|
||||
<SelectItem key={w.code} value={w.code}>
|
||||
{w.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">
|
||||
위치
|
||||
</Label>
|
||||
<Select
|
||||
value={adjustForm.location_code}
|
||||
onValueChange={(v) =>
|
||||
setAdjustForm((prev) => ({ ...prev, location_code: v }))
|
||||
}
|
||||
disabled={!adjustForm.warehouse_code || locationList.length === 0}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
!adjustForm.warehouse_code
|
||||
? "창고를 먼저 선택하세요"
|
||||
: locationList.length === 0
|
||||
? "등록된 위치가 없어요"
|
||||
: "위치 선택 (선택 사항)"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{locationList.map((l) => (
|
||||
<SelectItem key={l.code} value={l.code}>
|
||||
{l.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">
|
||||
조정 유형
|
||||
@@ -681,6 +862,7 @@ export default function InventoryStatusPage() {
|
||||
onValueChange={(v) =>
|
||||
setAdjustForm((prev) => ({ ...prev, adjust_type: v }))
|
||||
}
|
||||
disabled={!!selectedStock?._isMissing}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="조정 유형 선택" />
|
||||
@@ -690,6 +872,11 @@ export default function InventoryStatusPage() {
|
||||
<SelectItem value="감소">감소 (출고 보정)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedStock?._isMissing && (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
미등록 품목은 증가(신규 등록)만 가능해요
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
|
||||
@@ -628,7 +628,7 @@ export default function MaterialStatusPage() {
|
||||
className="inline-flex items-center gap-1 rounded bg-muted/40 px-2 py-0.5 text-xs transition-colors hover:bg-muted/60"
|
||||
>
|
||||
<span className="font-semibold font-mono text-primary">
|
||||
{loc.location || loc.warehouse}
|
||||
{loc.warehouse_name || loc.location || loc.warehouse}
|
||||
</span>
|
||||
<span className="font-semibold">
|
||||
{loc.qty.toLocaleString()}
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
getItemsByDivision, getGeneralItems,
|
||||
type PkgUnit, type PkgUnitItem, type LoadingUnit, type LoadingUnitPkg, type ItemInfoForPkg,
|
||||
} from "@/lib/api/packaging";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
@@ -118,6 +119,45 @@ export default function PackagingPage() {
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// 카테고리 옵션 (inventory_unit / material) — 코드 → 라벨 변환
|
||||
const [categoryOptions, setCategoryOptions] = useState<
|
||||
Record<string, { code: string; label: string }[]>
|
||||
>({});
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
const flatten = (vals: any[]): { code: string; label: string }[] => {
|
||||
const out: { code: string; label: string }[] = [];
|
||||
for (const v of vals) {
|
||||
out.push({
|
||||
code: v.valueCode || v.value_code || v.code,
|
||||
label: v.valueLabel || v.value_label || v.label,
|
||||
});
|
||||
if (v.children?.length) out.push(...flatten(v.children));
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const optMap: Record<string, { code: string; label: string }[]> = {};
|
||||
for (const col of ["inventory_unit", "material"]) {
|
||||
try {
|
||||
const res = await apiClient.get(
|
||||
`/table-categories/item_info/${col}/values`
|
||||
);
|
||||
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
}
|
||||
setCategoryOptions(optMap);
|
||||
};
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const resolveCat = (col: string, code: string | null | undefined) => {
|
||||
if (!code) return "";
|
||||
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
|
||||
};
|
||||
|
||||
// --- 데이터 로드 (item_info 기반 + pkg_unit/loading_unit LEFT JOIN 방식) ---
|
||||
const fetchPkgUnits = useCallback(async () => {
|
||||
setPkgLoading(true);
|
||||
@@ -528,9 +568,9 @@ export default function PackagingPage() {
|
||||
|
||||
{/* 4. 콘텐츠 영역 */}
|
||||
{activeTab === "packing" ? (
|
||||
<div className="flex flex-1 flex-col overflow-hidden rounded-lg border bg-card">
|
||||
<div className="flex flex-1 flex-row overflow-hidden rounded-lg border bg-card">
|
||||
{/* 포장재 목록 테이블 */}
|
||||
<div className={cn("overflow-auto", selectedPkg ? "flex-[0_0_50%] border-b" : "flex-1")}>
|
||||
<div className="flex-[0_0_50%] overflow-auto border-r">
|
||||
<EDataTable
|
||||
columns={ts.visibleColumns.map((col): EDataTableColumn<PkgUnit> => {
|
||||
const renderMap: Record<string, Partial<EDataTableColumn<PkgUnit>>> = {
|
||||
@@ -570,8 +610,8 @@ export default function PackagingPage() {
|
||||
</div>
|
||||
|
||||
{/* 매칭 품목 서브패널 */}
|
||||
{selectedPkg && (
|
||||
<>
|
||||
{selectedPkg ? (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex shrink-0 items-center justify-between bg-muted/50 px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">매칭 품목</span>
|
||||
@@ -622,7 +662,7 @@ export default function PackagingPage() {
|
||||
<TableCell className="p-2 font-medium">{item.item_number}</TableCell>
|
||||
<TableCell className="p-2">{item.item_name || "-"}</TableCell>
|
||||
<TableCell className="p-2">{item.spec || "-"}</TableCell>
|
||||
<TableCell className="p-2">{item.unit || "EA"}</TableCell>
|
||||
<TableCell className="p-2">{resolveCat("inventory_unit", item.inventory_unit) || "EA"}</TableCell>
|
||||
<TableCell className="p-2 text-right font-semibold">{Number(item.pkg_qty).toLocaleString()}</TableCell>
|
||||
<TableCell className="p-2 text-center">
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => handleDeletePkgItem(item)}>
|
||||
@@ -635,14 +675,21 @@ export default function PackagingPage() {
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center p-8">
|
||||
<div className="text-center">
|
||||
<Package className="mx-auto mb-2 h-10 w-10 text-muted-foreground/30" />
|
||||
<p className="text-sm text-muted-foreground">좌측 목록에서 포장재를 선택하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* 적재함 관리 탭 */
|
||||
<div className="flex flex-1 flex-col overflow-hidden rounded-lg border bg-card">
|
||||
<div className="flex flex-1 flex-row overflow-hidden rounded-lg border bg-card">
|
||||
{/* 적재함 목록 테이블 */}
|
||||
<div className={cn("overflow-auto", selectedLoading ? "flex-[0_0_50%] border-b" : "flex-1")}>
|
||||
<div className="flex-[0_0_50%] overflow-auto border-r">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
@@ -709,8 +756,8 @@ export default function PackagingPage() {
|
||||
</div>
|
||||
|
||||
{/* 포장구성 서브패널 */}
|
||||
{selectedLoading && (
|
||||
<>
|
||||
{selectedLoading ? (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex shrink-0 items-center justify-between bg-muted/50 px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">적재 가능 포장단위</span>
|
||||
@@ -774,7 +821,14 @@ export default function PackagingPage() {
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center p-8">
|
||||
<div className="text-center">
|
||||
<Box className="mx-auto mb-2 h-10 w-10 text-muted-foreground/30" />
|
||||
<p className="text-sm text-muted-foreground">좌측 목록에서 적재함을 선택하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -940,8 +994,8 @@ export default function PackagingPage() {
|
||||
<TableCell className="p-2 font-medium">{item.item_number}</TableCell>
|
||||
<TableCell className="p-2">{item.item_name}</TableCell>
|
||||
<TableCell className="p-2">{item.spec || "-"}</TableCell>
|
||||
<TableCell className="p-2">{item.material || "-"}</TableCell>
|
||||
<TableCell className="p-2">{item.unit || "EA"}</TableCell>
|
||||
<TableCell className="p-2">{resolveCat("material", item.material) || "-"}</TableCell>
|
||||
<TableCell className="p-2">{resolveCat("inventory_unit", item.inventory_unit) || "EA"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
@@ -250,6 +250,8 @@ interface SelectedSourceItem {
|
||||
total_amount: number;
|
||||
source_table: string;
|
||||
source_id: string;
|
||||
detail_id?: string;
|
||||
header_id?: string;
|
||||
}
|
||||
|
||||
export default function ReceivingPage() {
|
||||
@@ -584,7 +586,7 @@ export default function ReceivingPage() {
|
||||
const first = grouped[0] || row;
|
||||
|
||||
setEditMode(true);
|
||||
setEditItemIds(grouped.map((g) => g.id));
|
||||
setEditItemIds(grouped.map((g, idx) => (g as any).detail_id || `${g.id}__${idx}`));
|
||||
setModalInboundNo(inNo);
|
||||
setModalInboundType(first.inbound_type || "구매입고");
|
||||
setModalInboundDate(first.inbound_date ? String(first.inbound_date).slice(0, 10) : "");
|
||||
@@ -594,8 +596,10 @@ export default function ReceivingPage() {
|
||||
setModalManager((first as any).manager || "");
|
||||
setModalMemo(first.memo || "");
|
||||
setSelectedItems(
|
||||
grouped.map((g) => ({
|
||||
key: g.id,
|
||||
grouped.map((g, idx) => ({
|
||||
key: (g as any).detail_id || `${g.id}__${idx}`,
|
||||
detail_id: (g as any).detail_id || undefined,
|
||||
header_id: g.id,
|
||||
inbound_type: (g as any).detail_inbound_type || g.inbound_type || "",
|
||||
reference_number: g.reference_number || "",
|
||||
supplier_code: (g as any).supplier_code || "",
|
||||
@@ -782,7 +786,7 @@ export default function ReceivingPage() {
|
||||
await Promise.all([
|
||||
...toDelete.map((id) => deleteReceiving(id)),
|
||||
...toUpdate.map((item) =>
|
||||
updateReceiving(item.key, {
|
||||
updateReceiving(item.header_id || item.key, {
|
||||
inbound_date: modalInboundDate,
|
||||
inbound_qty: item.inbound_qty,
|
||||
unit_price: item.unit_price,
|
||||
@@ -790,6 +794,7 @@ export default function ReceivingPage() {
|
||||
warehouse_code: modalWarehouse || undefined,
|
||||
location_code: modalLocation || undefined,
|
||||
memo: modalMemo || undefined,
|
||||
detail_id: item.detail_id,
|
||||
} as any)
|
||||
),
|
||||
...(toCreate.length > 0
|
||||
|
||||
@@ -74,7 +74,7 @@ const WAREHOUSE_COLUMNS = [
|
||||
{ key: "warehouse_code", label: "창고코드" },
|
||||
{ key: "warehouse_name", label: "창고명" },
|
||||
{ key: "warehouse_type", label: "유형" },
|
||||
{ key: "manager", label: "관리자" },
|
||||
{ key: "manager_name", label: "관리자" },
|
||||
{ key: "status", label: "상태" },
|
||||
];
|
||||
const LOCATION_TABLE = "warehouse_location";
|
||||
@@ -158,6 +158,10 @@ export default function WarehouseManagementPage() {
|
||||
const [rackStatus, setRackStatus] = useState("");
|
||||
const [rackPreview, setRackPreview] = useState<any[]>([]);
|
||||
const [rackSaving, setRackSaving] = useState(false);
|
||||
// 위치명 접미사 (자동 조립: {zone}{구역접미사}-{row}{열접미사}-{level}{단접미사})
|
||||
const [rackZoneLabel, setRackZoneLabel] = useState("구역");
|
||||
const [rackRowLabel, setRackRowLabel] = useState("열");
|
||||
const [rackLevelLabel, setRackLevelLabel] = useState("단");
|
||||
|
||||
// 카테고리 옵션
|
||||
const [categoryOptions, setCategoryOptions] = useState<
|
||||
@@ -230,7 +234,7 @@ export default function WarehouseManagementPage() {
|
||||
`/table-management/tables/${WAREHOUSE_TABLE}/data`,
|
||||
{
|
||||
page: 1,
|
||||
size: 500,
|
||||
size: 0,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
sort: { columnName: "warehouse_code", order: "asc" },
|
||||
@@ -239,6 +243,8 @@ export default function WarehouseManagementPage() {
|
||||
const raw = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
const data = raw.map((r: any) => ({
|
||||
...r,
|
||||
_warehouse_type_code: r.warehouse_type,
|
||||
_status_code: r.status,
|
||||
warehouse_type: resolveCategory(categoryOptions, "warehouse_type", r.warehouse_type),
|
||||
status: resolveCategory(categoryOptions, "status", r.status),
|
||||
}));
|
||||
@@ -270,7 +276,7 @@ export default function WarehouseManagementPage() {
|
||||
`/table-management/tables/${LOCATION_TABLE}/data`,
|
||||
{
|
||||
page: 1,
|
||||
size: 500,
|
||||
size: 0,
|
||||
dataFilter: {
|
||||
enabled: true,
|
||||
filters: [
|
||||
@@ -344,7 +350,11 @@ export default function WarehouseManagementPage() {
|
||||
|
||||
const openWarehouseEditModal = (row: any) => {
|
||||
setWarehouseEditMode(true);
|
||||
setWarehouseForm({ ...row });
|
||||
setWarehouseForm({
|
||||
...row,
|
||||
warehouse_type: row._warehouse_type_code ?? row.warehouse_type ?? "",
|
||||
status: row._status_code ?? row.status ?? "",
|
||||
});
|
||||
setWarehouseModalOpen(true);
|
||||
};
|
||||
|
||||
@@ -374,10 +384,10 @@ export default function WarehouseManagementPage() {
|
||||
warehouse_code: finalWarehouseCode,
|
||||
warehouse_name: warehouseForm.warehouse_name?.trim(),
|
||||
warehouse_type: warehouseForm.warehouse_type || "",
|
||||
manager: warehouseForm.manager || "",
|
||||
address: warehouseForm.address || "",
|
||||
manager_name: warehouseForm.manager_name || "",
|
||||
contact: warehouseForm.contact || "",
|
||||
status: warehouseForm.status || "",
|
||||
description: warehouseForm.description || "",
|
||||
memo: warehouseForm.memo || "",
|
||||
};
|
||||
|
||||
// 신규 등록 시 창고코드 중복 체크
|
||||
@@ -630,7 +640,7 @@ export default function WarehouseManagementPage() {
|
||||
duplicates.push(locationCode);
|
||||
continue;
|
||||
}
|
||||
const locationName = `${zoneCode}구역-${rowStr}열-${level}단`;
|
||||
const locationName = `${zoneCode}${rackZoneLabel}-${rowStr}${rackRowLabel}-${level}${rackLevelLabel}`;
|
||||
items.push({
|
||||
location_code: locationCode,
|
||||
location_name: locationName,
|
||||
@@ -729,7 +739,7 @@ export default function WarehouseManagementPage() {
|
||||
창고코드: r.warehouse_code,
|
||||
창고명: r.warehouse_name,
|
||||
유형: r.warehouse_type,
|
||||
관리자: r.manager,
|
||||
관리자: r.manager_name,
|
||||
상태: r.status,
|
||||
})),
|
||||
"창고정보"
|
||||
@@ -1041,9 +1051,9 @@ export default function WarehouseManagementPage() {
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">관리자</Label>
|
||||
<Input
|
||||
value={warehouseForm.manager || ""}
|
||||
value={warehouseForm.manager_name || ""}
|
||||
onChange={(e) =>
|
||||
setWarehouseForm((prev) => ({ ...prev, manager: e.target.value }))
|
||||
setWarehouseForm((prev) => ({ ...prev, manager_name: e.target.value }))
|
||||
}
|
||||
placeholder="관리자를 입력해주세요"
|
||||
/>
|
||||
@@ -1069,24 +1079,24 @@ export default function WarehouseManagementPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* 주소 (전체 너비) */}
|
||||
{/* 연락처 (전체 너비) */}
|
||||
<div className="grid gap-1.5 col-span-2">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">주소</Label>
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">연락처</Label>
|
||||
<Input
|
||||
value={warehouseForm.address || ""}
|
||||
value={warehouseForm.contact || ""}
|
||||
onChange={(e) =>
|
||||
setWarehouseForm((prev) => ({ ...prev, address: e.target.value }))
|
||||
setWarehouseForm((prev) => ({ ...prev, contact: e.target.value }))
|
||||
}
|
||||
placeholder="주소를 입력해주세요"
|
||||
placeholder="연락처를 입력해주세요"
|
||||
/>
|
||||
</div>
|
||||
{/* 비고 (전체 너비) */}
|
||||
<div className="grid gap-1.5 col-span-2">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">비고</Label>
|
||||
<Input
|
||||
value={warehouseForm.description || ""}
|
||||
value={warehouseForm.memo || ""}
|
||||
onChange={(e) =>
|
||||
setWarehouseForm((prev) => ({ ...prev, description: e.target.value }))
|
||||
setWarehouseForm((prev) => ({ ...prev, memo: e.target.value }))
|
||||
}
|
||||
placeholder="비고를 입력해주세요"
|
||||
/>
|
||||
@@ -1496,6 +1506,38 @@ export default function WarehouseManagementPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 위치명 형식 — 구역/열/단 뒤에 붙일 표현만 자유 입력 */}
|
||||
<div className="rounded-lg border p-3 bg-muted/30 space-y-2">
|
||||
<Label className="text-xs font-semibold">위치명 형식</Label>
|
||||
<div className="flex items-center gap-1 text-xs flex-wrap">
|
||||
<span className="font-mono text-muted-foreground">A</span>
|
||||
<Input
|
||||
value={rackZoneLabel}
|
||||
onChange={(e) => setRackZoneLabel(e.target.value)}
|
||||
placeholder="구역"
|
||||
className="h-8 w-20 text-xs"
|
||||
/>
|
||||
<span className="font-mono text-muted-foreground">- 01</span>
|
||||
<Input
|
||||
value={rackRowLabel}
|
||||
onChange={(e) => setRackRowLabel(e.target.value)}
|
||||
placeholder="열"
|
||||
className="h-8 w-20 text-xs"
|
||||
/>
|
||||
<span className="font-mono text-muted-foreground">- 1</span>
|
||||
<Input
|
||||
value={rackLevelLabel}
|
||||
onChange={(e) => setRackLevelLabel(e.target.value)}
|
||||
placeholder="단"
|
||||
className="h-8 w-20 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
예시: <span className="font-mono font-semibold">A{rackZoneLabel}-01{rackRowLabel}-1{rackLevelLabel}</span>
|
||||
{" "}— 구역/열/단 번호는 자동 계산되고, 뒤에 붙는 명칭만 수정할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 등록 미리보기 */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
|
||||
@@ -193,7 +193,7 @@ export default function CompanyPage() {
|
||||
setDeptLoading(true);
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/${DEPT_TABLE}/data`, {
|
||||
page: 1, size: 500, autoFilter: true,
|
||||
page: 1, size: 0, autoFilter: true,
|
||||
});
|
||||
const raw = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
setDepts(raw);
|
||||
@@ -217,7 +217,7 @@ export default function CompanyPage() {
|
||||
setMemberLoading(true);
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/${USER_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "dept_code", operator: "equals", value: selectedDeptCode }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
@@ -563,10 +563,6 @@ export default function CompanyPage() {
|
||||
|
||||
{/* 기본 정보 그리드 (2열) */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">회사코드</Label>
|
||||
<Input value={companyForm.company_code || ""} className="h-9 bg-muted/50" disabled readOnly />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
회사명 <span className="text-destructive">*</span>
|
||||
|
||||
@@ -99,7 +99,7 @@ export default function DepartmentPage() {
|
||||
try {
|
||||
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
|
||||
const res = await apiClient.post(`/table-management/tables/${DEPT_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
@@ -129,7 +129,7 @@ export default function DepartmentPage() {
|
||||
? [{ columnName: "dept_code", operator: "equals", value: selectedDeptCode }]
|
||||
: [];
|
||||
const res = await apiClient.post(`/table-management/tables/${USER_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
|
||||
@@ -5,7 +5,12 @@ import { Settings2, Tags, Hash } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CategoryColumnList } from "@/components/table-category/CategoryColumnList";
|
||||
import { CategoryValueManager } from "@/components/table-category/CategoryValueManager";
|
||||
import { CategoryValueManagerTree } from "@/components/table-category/CategoryValueManagerTree";
|
||||
import { NumberingRuleDesigner } from "@/components/numbering-rule/NumberingRuleDesigner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
|
||||
|
||||
const TABS = [
|
||||
{ id: "category", label: "카테고리 설정", icon: Tags },
|
||||
@@ -21,6 +26,13 @@ export default function OptionsSettingPage() {
|
||||
const [selectedColumnLabel, setSelectedColumnLabel] = useState("");
|
||||
const [selectedTableName, setSelectedTableName] = useState("");
|
||||
|
||||
// 하위분류 사용 여부 (카테고리별 자동감지 + 수동 토글)
|
||||
const [useHierarchy, setUseHierarchy] = useState(false);
|
||||
const [hasChildRows, setHasChildRows] = useState(false);
|
||||
const [detectingHierarchy, setDetectingHierarchy] = useState(false);
|
||||
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||
|
||||
const [leftWidth, setLeftWidth] = useState(340);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -51,6 +63,72 @@ export default function OptionsSettingPage() {
|
||||
};
|
||||
}, [isDragging]);
|
||||
|
||||
// 카테고리 선택 변경 시 하위분류 존재 여부 자동감지
|
||||
useEffect(() => {
|
||||
if (!selectedColumn || !selectedTableName) {
|
||||
setUseHierarchy(false);
|
||||
setHasChildRows(false);
|
||||
return;
|
||||
}
|
||||
const columnNameOnly = selectedColumn.includes(".")
|
||||
? selectedColumn.split(".").pop()!
|
||||
: selectedColumn;
|
||||
let cancelled = false;
|
||||
setDetectingHierarchy(true);
|
||||
(async () => {
|
||||
const res = await getCategoryValues(selectedTableName, columnNameOnly, true);
|
||||
if (cancelled) return;
|
||||
const values = (res as any)?.data || [];
|
||||
const hasChild = Array.isArray(values)
|
||||
? values.some(
|
||||
(v: any) =>
|
||||
(typeof v.depth === "number" && v.depth > 1) ||
|
||||
(v.parentValueId !== null && v.parentValueId !== undefined),
|
||||
)
|
||||
: false;
|
||||
setHasChildRows(hasChild);
|
||||
setUseHierarchy(hasChild);
|
||||
setDetectingHierarchy(false);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedColumn, selectedTableName]);
|
||||
|
||||
const handleToggleHierarchy = useCallback(
|
||||
async (checked: boolean) => {
|
||||
if (!checked && hasChildRows) {
|
||||
const ok = await confirm(
|
||||
"이미 등록된 하위분류(중/소분류)가 있습니다.\n하위분류 사용을 해제해도 기존 데이터는 삭제되지 않으며, 다시 사용 설정 시 그대로 복원됩니다.\n계속하시겠습니까?",
|
||||
{ variant: "destructive", confirmText: "해제" },
|
||||
);
|
||||
if (!ok) return;
|
||||
}
|
||||
setUseHierarchy(checked);
|
||||
},
|
||||
[hasChildRows, confirm],
|
||||
);
|
||||
|
||||
const columnNameOnly = selectedColumn
|
||||
? selectedColumn.includes(".")
|
||||
? selectedColumn.split(".").pop()!
|
||||
: selectedColumn
|
||||
: "";
|
||||
|
||||
const headerRight = selectedColumn ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="use-hierarchy-switch" className="cursor-pointer text-xs">
|
||||
하위분류 사용
|
||||
</Label>
|
||||
<Switch
|
||||
id="use-hierarchy-switch"
|
||||
checked={useHierarchy}
|
||||
onCheckedChange={handleToggleHierarchy}
|
||||
disabled={detectingHierarchy}
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col p-3 gap-3">
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -108,11 +186,21 @@ export default function OptionsSettingPage() {
|
||||
|
||||
<div className="flex-1 min-w-0 border rounded-lg bg-card overflow-hidden">
|
||||
{selectedColumn && selectedTableName ? (
|
||||
<CategoryValueManager
|
||||
tableName={selectedTableName}
|
||||
columnName={selectedColumn.includes(".") ? selectedColumn.split(".").pop()! : selectedColumn}
|
||||
columnLabel={selectedColumnLabel}
|
||||
/>
|
||||
useHierarchy ? (
|
||||
<CategoryValueManagerTree
|
||||
tableName={selectedTableName}
|
||||
columnName={columnNameOnly}
|
||||
columnLabel={selectedColumnLabel}
|
||||
headerRight={headerRight}
|
||||
/>
|
||||
) : (
|
||||
<CategoryValueManager
|
||||
tableName={selectedTableName}
|
||||
columnName={columnNameOnly}
|
||||
columnLabel={selectedColumnLabel}
|
||||
headerRight={headerRight}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center space-y-2">
|
||||
@@ -131,6 +219,7 @@ export default function OptionsSettingPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{ConfirmDialogComponent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,6 +72,36 @@ export default function MoldInfoPage() {
|
||||
const [selectedMoldCode, setSelectedMoldCode] = useState<string | null>(null);
|
||||
const [viewMode, setViewMode] = useState<"list" | "grid">("list");
|
||||
|
||||
// ─── 카테고리 옵션 (금형유형, 운영상태) ───
|
||||
const [moldTypeCatOptions, setMoldTypeCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
const [operationStatusCatOptions, setOperationStatusCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const flatten = (arr: any[]): { code: string; label: string }[] => {
|
||||
const result: { code: string; label: string }[] = [];
|
||||
for (const v of arr) { result.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) result.push(...flatten(v.children)); }
|
||||
return result;
|
||||
};
|
||||
(async () => {
|
||||
try {
|
||||
const [typeRes, statusRes] = await Promise.all([
|
||||
apiClient.get("/table-categories/mold_mng/mold_type/values"),
|
||||
apiClient.get("/table-categories/mold_mng/operation_status/values"),
|
||||
]);
|
||||
if (typeRes.data?.success) setMoldTypeCatOptions(flatten(typeRes.data.data || []));
|
||||
if (statusRes.data?.success) setOperationStatusCatOptions(flatten(statusRes.data.data || []));
|
||||
} catch { /* skip */ }
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const resolveMoldType = (code: string) => moldTypeCatOptions.find((o) => o.code === code)?.label || code;
|
||||
const resolveOpStatus = (code: string) => {
|
||||
const catLabel = operationStatusCatOptions.find((o) => o.code === code)?.label;
|
||||
if (catLabel) return catLabel;
|
||||
const legacyMap: Record<string, string> = { ACTIVE: "사용중", INACTIVE: "미사용", REPAIR: "수리중", DISPOSED: "폐기", IN_USE: "사용중" };
|
||||
return legacyMap[code] || code;
|
||||
};
|
||||
|
||||
// ─── 검색 필터 ───
|
||||
const [filterCode, setFilterCode] = useState("");
|
||||
const [filterName, setFilterName] = useState("");
|
||||
@@ -426,7 +456,7 @@ export default function MoldInfoPage() {
|
||||
// ─── 카드 렌더링 ───
|
||||
const renderCard = (mold: any) => {
|
||||
const pct = calcLifePct(mold);
|
||||
const st = STATUS_MAP[mold.operation_status] || { label: mold.operation_status || "-", variant: "secondary" as const };
|
||||
const stLabel = resolveOpStatus(mold.operation_status);
|
||||
const isSelected = selectedMoldCode === mold.mold_code;
|
||||
|
||||
return (
|
||||
@@ -460,7 +490,7 @@ export default function MoldInfoPage() {
|
||||
<Box className="w-8 h-8 text-muted-foreground/50" />
|
||||
)}
|
||||
<div className="absolute top-2 right-2">
|
||||
<Badge variant={st.variant} className="text-[10px]">{st.label}</Badge>
|
||||
<Badge variant="secondary" className="text-[10px]">{stLabel}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -470,7 +500,7 @@ export default function MoldInfoPage() {
|
||||
<p className="text-xs text-muted-foreground font-mono truncate">{mold.mold_code}</p>
|
||||
<p className="text-sm font-semibold truncate">{mold.mold_name}</p>
|
||||
{mold.mold_type && (
|
||||
<Badge variant="outline" className="text-[10px] mt-1">{mold.mold_type}</Badge>
|
||||
<Badge variant="outline" className="text-[10px] mt-1">{resolveMoldType(mold.mold_type)}</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -531,10 +561,7 @@ export default function MoldInfoPage() {
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">전체</SelectItem>
|
||||
<SelectItem value="사출금형">사출금형</SelectItem>
|
||||
<SelectItem value="프레스금형">프레스금형</SelectItem>
|
||||
<SelectItem value="다이캐스팅">다이캐스팅</SelectItem>
|
||||
<SelectItem value="단조금형">단조금형</SelectItem>
|
||||
{moldTypeCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -546,10 +573,7 @@ export default function MoldInfoPage() {
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">전체</SelectItem>
|
||||
<SelectItem value="ACTIVE">사용중</SelectItem>
|
||||
<SelectItem value="INSPECTION">점검중</SelectItem>
|
||||
<SelectItem value="REPAIR">수리중</SelectItem>
|
||||
<SelectItem value="DISPOSED">폐기</SelectItem>
|
||||
{operationStatusCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -670,13 +694,13 @@ export default function MoldInfoPage() {
|
||||
<h2 className="text-xl font-bold mb-2 truncate">{selectedMold.mold_name}</h2>
|
||||
<div className="flex gap-1.5 mb-4 flex-wrap">
|
||||
{selectedMold.mold_type && (
|
||||
<Badge variant="outline">{selectedMold.mold_type}</Badge>
|
||||
<Badge variant="outline">{resolveMoldType(selectedMold.mold_type)}</Badge>
|
||||
)}
|
||||
{selectedMold.category && (
|
||||
<Badge variant="secondary">{selectedMold.category}</Badge>
|
||||
)}
|
||||
<Badge variant={STATUS_MAP[selectedMold.operation_status]?.variant || "secondary"}>
|
||||
{STATUS_MAP[selectedMold.operation_status]?.label || selectedMold.operation_status || "-"}
|
||||
<Badge variant="secondary">
|
||||
{resolveOpStatus(selectedMold.operation_status) || "-"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
@@ -811,15 +835,15 @@ export default function MoldInfoPage() {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{serials.map((s: any) => {
|
||||
const ss = SERIAL_STATUS_MAP[s.status] || { label: s.status || "-", variant: "secondary" as const };
|
||||
const maxShot = detail?.shot_count || 0;
|
||||
const ssLabel = resolveOpStatus(s.status);
|
||||
const maxShot = selectedMold?.shot_count || 0;
|
||||
const curShot = s.current_shot_count || 0;
|
||||
const pct = maxShot > 0 ? Math.min(Math.round((curShot / maxShot) * 100), 100) : 0;
|
||||
return (
|
||||
<TableRow key={s.id}>
|
||||
<TableCell className="text-[13px] font-mono font-semibold">{s.serial_number}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={ss.variant} className="text-[10px]">{ss.label}</Badge>
|
||||
<Badge variant="secondary" className="text-[10px]">{ssLabel}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{maxShot > 0 ? (
|
||||
@@ -1043,10 +1067,7 @@ export default function MoldInfoPage() {
|
||||
<SelectValue placeholder="선택해주세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="사출금형">사출금형</SelectItem>
|
||||
<SelectItem value="프레스금형">프레스금형</SelectItem>
|
||||
<SelectItem value="다이캐스팅">다이캐스팅</SelectItem>
|
||||
<SelectItem value="단조금형">단조금형</SelectItem>
|
||||
{moldTypeCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -1117,10 +1138,7 @@ export default function MoldInfoPage() {
|
||||
<SelectValue placeholder="선택해주세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ACTIVE">사용중</SelectItem>
|
||||
<SelectItem value="INSPECTION">점검중</SelectItem>
|
||||
<SelectItem value="REPAIR">수리중</SelectItem>
|
||||
<SelectItem value="DISPOSED">폐기</SelectItem>
|
||||
{operationStatusCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -1175,10 +1193,7 @@ export default function MoldInfoPage() {
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="IN_USE">사용중</SelectItem>
|
||||
<SelectItem value="STORED">보관중</SelectItem>
|
||||
<SelectItem value="REPAIR">수리중</SelectItem>
|
||||
<SelectItem value="DISPOSED">폐기</SelectItem>
|
||||
{operationStatusCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -170,7 +170,7 @@ export default function EquipmentMonitoringPage() {
|
||||
apiClient.post("/table-management/tables/equipment_mng/data", {
|
||||
autoFilter: true,
|
||||
page: 1,
|
||||
size: 500,
|
||||
size: 0,
|
||||
}),
|
||||
apiClient.get("/work-instruction/list").catch(() => ({ data: { data: [] } })),
|
||||
apiClient.post("/table-management/tables/work_order_process/data", {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -98,12 +98,26 @@ export default function SubcontractorItemPage() {
|
||||
}
|
||||
return result;
|
||||
};
|
||||
for (const col of ["material", "division", "type", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"]) {
|
||||
for (const col of ["material", "division", "type", "status", "unit", "inventory_unit", "currency_code", "user_type01", "user_type02"]) {
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/${ITEM_TABLE}/${col}/values`);
|
||||
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
// 외주사관리에서 사용하는 subcontractor_item_prices.currency_code도 병합
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/subcontractor_item_prices/currency_code/values`);
|
||||
if (res.data?.success) {
|
||||
const extra = flatten(res.data.data || []);
|
||||
const seen = new Set((optMap["currency_code"] || []).map((o) => o.code));
|
||||
for (const e of extra) {
|
||||
if (!seen.has(e.code)) {
|
||||
(optMap["currency_code"] ||= []).push(e);
|
||||
seen.add(e.code);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
// 외주업체 거래유형 (subcontractor_mng.division)
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/${SUBCONTRACTOR_TABLE}/division/values`);
|
||||
@@ -124,10 +138,10 @@ export default function SubcontractorItemPage() {
|
||||
item_number: { width: "w-[110px]" },
|
||||
item_name: { minWidth: "min-w-[130px]", render: (v) => v || "-" },
|
||||
size: { width: "w-[90px]", render: (v) => v || "-" },
|
||||
unit: { width: "w-[60px]", render: (v) => v || "-" },
|
||||
unit: { width: "w-[60px]", render: (v) => resolve("unit", v) || "-" },
|
||||
standard_price: { width: "w-[90px]", align: "right", formatNumber: true },
|
||||
selling_price: { width: "w-[90px]", align: "right", formatNumber: true },
|
||||
currency_code: { width: "w-[50px]", render: (v) => v || "-" },
|
||||
currency_code: { width: "w-[50px]", render: (v) => resolve("currency_code", v) || "-" },
|
||||
status: { width: "w-[60px]", render: (v) => v || "-" },
|
||||
};
|
||||
return ts.visibleColumns.map((col) => ({
|
||||
@@ -135,7 +149,8 @@ export default function SubcontractorItemPage() {
|
||||
label: col.label,
|
||||
...colProps[col.key],
|
||||
}));
|
||||
}, [ts.visibleColumns]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ts.visibleColumns, categoryOptions]);
|
||||
|
||||
// 좌측: 품목 조회 (division이 "외주관리"인 품목만 필터링)
|
||||
const outsourcingDivisionCode = categoryOptions["division"]?.find(
|
||||
@@ -153,7 +168,7 @@ export default function SubcontractorItemPage() {
|
||||
filters.push({ columnName: "item_name", operator: "contains", value: searchKeyword });
|
||||
}
|
||||
const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
@@ -164,8 +179,8 @@ export default function SubcontractorItemPage() {
|
||||
for (const col of CATS) {
|
||||
if (converted[col]) converted[col] = resolve(col, converted[col]);
|
||||
}
|
||||
// item_info의 inventory_unit을 단위 표시용 unit에 매핑
|
||||
converted.unit = converted.inventory_unit || converted.unit || "";
|
||||
// "단위" 컬럼은 재고단위(inventory_unit)만 사용 — unit 폴백 제거
|
||||
converted.unit = converted.inventory_unit || "";
|
||||
return converted;
|
||||
});
|
||||
setItems(data);
|
||||
@@ -191,7 +206,7 @@ export default function SubcontractorItemPage() {
|
||||
setSubcontractorLoading(true);
|
||||
try {
|
||||
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_id", operator: "equals", value: itemKey }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
@@ -212,11 +227,35 @@ export default function SubcontractorItemPage() {
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
|
||||
setSubcontractorItems(mappings.map((m: any) => ({
|
||||
...m,
|
||||
subcontractor_code: m.subcontractor_id,
|
||||
subcontractor_name: subMap[m.subcontractor_id]?.subcontractor_name || "",
|
||||
})));
|
||||
// 외주사관리에서 입력된 최신 단가(subcontractor_item_prices) 조회 → subcontractor_id 별 최신 1건
|
||||
const priceMap: Record<string, any> = {};
|
||||
try {
|
||||
const priceRes = await apiClient.post(`/table-management/tables/subcontractor_item_prices/data`, {
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_id", operator: "equals", value: itemKey }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const prices = priceRes.data?.data?.data || priceRes.data?.data?.rows || [];
|
||||
for (const p of prices) {
|
||||
const key = p.subcontractor_id;
|
||||
if (!key) continue;
|
||||
if (!priceMap[key] || (p.start_date && (!priceMap[key].start_date || p.start_date > priceMap[key].start_date))) {
|
||||
priceMap[key] = p;
|
||||
}
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
|
||||
setSubcontractorItems(mappings.map((m: any) => {
|
||||
const price = priceMap[m.subcontractor_id] || {};
|
||||
return {
|
||||
...m,
|
||||
subcontractor_code: m.subcontractor_id,
|
||||
subcontractor_name: subMap[m.subcontractor_id]?.subcontractor_name || "",
|
||||
base_price: price.base_price ?? m.base_price,
|
||||
calculated_price: price.calculated_price ?? price.unit_price ?? m.calculated_price,
|
||||
currency_code: resolve("currency_code", price.currency_code ?? m.currency_code),
|
||||
};
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error("외주업체 조회 실패:", err);
|
||||
} finally {
|
||||
@@ -224,7 +263,8 @@ export default function SubcontractorItemPage() {
|
||||
}
|
||||
};
|
||||
fetchSubcontractorItems();
|
||||
}, [selectedItem?.item_number]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedItem?.item_number, categoryOptions]);
|
||||
|
||||
// 외주업체 검색
|
||||
const searchSubcontractors = async () => {
|
||||
|
||||
@@ -194,7 +194,7 @@ export default function SubcontractorManagementPage() {
|
||||
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
|
||||
|
||||
const res = await apiClient.post(`/table-management/tables/${SUBCONTRACTOR_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
@@ -229,7 +229,7 @@ export default function SubcontractorManagementPage() {
|
||||
setPriceLoading(true);
|
||||
try {
|
||||
const mappingRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "subcontractor_id", operator: "equals", value: selectedSubcontractor.subcontractor_code },
|
||||
]},
|
||||
@@ -256,7 +256,7 @@ export default function SubcontractorManagementPage() {
|
||||
if (mappings.length > 0) {
|
||||
try {
|
||||
const priceRes = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "subcontractor_id", operator: "equals", value: selectedSubcontractor.subcontractor_code },
|
||||
]},
|
||||
@@ -413,7 +413,7 @@ export default function SubcontractorManagementPage() {
|
||||
const filters: any[] = [];
|
||||
if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
|
||||
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
@@ -550,7 +550,7 @@ export default function SubcontractorManagementPage() {
|
||||
}> = [];
|
||||
try {
|
||||
const priceRes = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "subcontractor_id", operator: "equals", value: selectedSubcontractor!.subcontractor_code },
|
||||
{ columnName: "item_id", operator: "equals", value: itemKey },
|
||||
@@ -610,7 +610,7 @@ export default function SubcontractorManagementPage() {
|
||||
// 2) 기존 단가 모두 삭제 (subcontractor_id + item_id 기준)
|
||||
try {
|
||||
const existingPrices = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "subcontractor_id", operator: "equals", value: selectedSubcontractor.subcontractor_code },
|
||||
{ columnName: "item_id", operator: "equals", value: itemKey },
|
||||
@@ -727,7 +727,7 @@ export default function SubcontractorManagementPage() {
|
||||
|
||||
if (subCodes.length > 0) {
|
||||
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
|
||||
page: 1, size: 5000, autoFilter: true,
|
||||
page: 1, size: 0, autoFilter: true,
|
||||
});
|
||||
const mappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
Settings2,
|
||||
Save,
|
||||
Package,
|
||||
Pencil,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
@@ -349,13 +350,19 @@ export default function BomManagementPage() {
|
||||
|
||||
const res = await apiClient.post(`/table-management/tables/${BOM_TABLE}/data`, {
|
||||
page: 1,
|
||||
size: 500,
|
||||
size: 0,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
sort: { columnName: "created_at", order: "desc" },
|
||||
});
|
||||
|
||||
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
// DB 컬럼이 item_type/expired_date → 프론트 내부에서는 bom_type/expiry_date로 통일
|
||||
const rawRows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
const rows = rawRows.map((r: any) => ({
|
||||
...r,
|
||||
bom_type: r.bom_type ?? r.item_type,
|
||||
expiry_date: r.expiry_date ?? r.expired_date,
|
||||
}));
|
||||
setBomList(rows);
|
||||
setTotalCount(rows.length);
|
||||
} catch (err: any) {
|
||||
@@ -452,9 +459,16 @@ export default function BomManagementPage() {
|
||||
const fetchBomDetail = useCallback(async (bomId: string) => {
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
// 헤더 조회
|
||||
// 헤더 조회 (DB 컬럼 item_type/expired_date → bom_type/expiry_date로 매핑)
|
||||
const headerRes = await apiClient.get(`/bom/${bomId}/header`);
|
||||
const header = headerRes.data?.data || headerRes.data;
|
||||
const rawHeader = headerRes.data?.data || headerRes.data;
|
||||
const header = rawHeader
|
||||
? {
|
||||
...rawHeader,
|
||||
bom_type: rawHeader.bom_type ?? rawHeader.item_type,
|
||||
expiry_date: rawHeader.expiry_date ?? rawHeader.expired_date,
|
||||
}
|
||||
: null;
|
||||
setBomHeader(header);
|
||||
setCurrentVersionId(header?.current_version_id || null);
|
||||
|
||||
@@ -497,12 +511,14 @@ export default function BomManagementPage() {
|
||||
const c = code.trim();
|
||||
return categoryOptions["division"]?.find((o) => o.code === c)?.label || c;
|
||||
}).filter((v: string) => v && v !== "s").join(", ");
|
||||
const rawUnit = d.unit || item?.inventory_unit || "";
|
||||
const unitLabel = categoryOptions["inventory_unit"]?.find((o) => o.code === rawUnit)?.label || rawUnit;
|
||||
return {
|
||||
...d,
|
||||
item_number: item?.item_number || "",
|
||||
item_name: item?.item_name || "",
|
||||
item_type: divisionLabel,
|
||||
unit: d.unit || item?.inventory_unit || "",
|
||||
unit: unitLabel,
|
||||
spec: item?.size || item?.spec || "",
|
||||
writer: d.writer || "",
|
||||
updated_date: d.updated_at || d.updated_date || "",
|
||||
@@ -631,7 +647,7 @@ export default function BomManagementPage() {
|
||||
// bom_detail에서 child_item_id가 현재 품목인 행 조회
|
||||
const itemId = bomHeader.item_id || bomHeader.id;
|
||||
const res = await apiClient.post(`/table-management/tables/bom_detail/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "child_item_id", operator: "equals", value: itemId }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
@@ -818,6 +834,8 @@ export default function BomManagementPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 같은 레벨 중복 허용 — 소요량/공정 등이 다른 동일 품목을 별도 row로 등록할 수 있음
|
||||
|
||||
const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
||||
const parentNode = addTargetParentId ? findNodeById(editingTree, addTargetParentId) : null;
|
||||
const newLevel = parentNode ? ((parentNode._level ?? parentNode.level ?? 0) as number) + 1 : 0;
|
||||
@@ -1089,17 +1107,18 @@ export default function BomManagementPage() {
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
// DB 실제 컬럼: item_type / expired_date (프론트 내부 bom_type/expiry_date와 다름)
|
||||
const bomFields: Record<string, any> = {
|
||||
item_id: masterForm.item_id,
|
||||
item_code: masterForm.item_code,
|
||||
item_name: masterForm.item_name,
|
||||
bom_type: masterForm.bom_type,
|
||||
item_type: masterForm.bom_type,
|
||||
base_qty: masterForm.base_qty || "1",
|
||||
unit: masterForm.unit || "",
|
||||
version: masterForm.version || "1.0",
|
||||
status: masterForm.status || "draft",
|
||||
effective_date: masterForm.effective_date || null,
|
||||
expiry_date: masterForm.expiry_date || null,
|
||||
expired_date: masterForm.expiry_date || null,
|
||||
remark: masterForm.remark || "",
|
||||
writer: user?.userId || "",
|
||||
company_code: user?.company_code || "",
|
||||
@@ -1471,6 +1490,21 @@ export default function BomManagementPage() {
|
||||
<Plus className="w-3.5 h-3.5 mr-1" />
|
||||
등록
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (!selectedBomId || !bomHeader) {
|
||||
toast.error("수정할 BOM을 선택해주세요");
|
||||
return;
|
||||
}
|
||||
openEditModal();
|
||||
}}
|
||||
disabled={!selectedBomId || !bomHeader}
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5 mr-1" />
|
||||
수정
|
||||
</Button>
|
||||
<div className="w-px h-4 bg-border mx-0.5" />
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -1530,53 +1564,6 @@ export default function BomManagementPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 상세 카드 */}
|
||||
<div className="border-b shrink-0">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/50">
|
||||
<h3 className="text-[13px] font-bold text-foreground">BOM 상세정보</h3>
|
||||
<Button size="sm" variant="ghost" onClick={openEditModal}>
|
||||
<FileText className="w-3.5 h-3.5 mr-1" />
|
||||
편집
|
||||
</Button>
|
||||
</div>
|
||||
{detailLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : bomHeader ? (
|
||||
<div className="grid grid-cols-2 text-sm">
|
||||
<div className="flex flex-col gap-1 p-3 border-b border-r">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">품목코드</span>
|
||||
<span className="font-mono text-xs">{bomHeader.item_code || bomHeader.item_number || "-"}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 border-b">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">품명</span>
|
||||
<span className="text-xs">{bomHeader.item_name || "-"}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 border-b border-r">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">BOM 유형</span>
|
||||
<span className="text-xs">{BOM_TYPE_OPTIONS.find((o) => o.code === bomHeader.bom_type)?.label || bomHeader.bom_type || "-"}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 border-b">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">버전</span>
|
||||
<span className="text-xs">{bomHeader.version || "-"}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 border-b border-r">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">기준수량</span>
|
||||
<span className="text-xs">{bomHeader.base_qty || "1"} {bomHeader.unit || ""}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 border-b">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">상태</span>
|
||||
{renderStatusBadge(bomHeader.status)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 col-span-2">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">메모</span>
|
||||
<span className="text-xs text-muted-foreground">{bomHeader.remark || "-"}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* 하단 탭: 트리뷰 / 버전 / 이력 */}
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<Tabs value={rightTab} onValueChange={(v) => {
|
||||
@@ -1808,7 +1795,7 @@ export default function BomManagementPage() {
|
||||
{/* 소요량 */}
|
||||
<td className="px-3 py-2 text-center">{isVirtualRoot ? (bomHeader?.base_qty || "1") : (node.quantity || "-")}</td>
|
||||
{/* 단위 */}
|
||||
<td className="px-3 py-2 text-center">{isVirtualRoot ? "-" : (node.unit || "-")}</td>
|
||||
<td className="px-3 py-2 text-center">{isVirtualRoot ? (categoryOptions["inventory_unit"]?.find((o) => o.code === bomHeader?.unit)?.label || bomHeader?.unit || "-") : (node.unit || "-")}</td>
|
||||
{/* 공정구분 */}
|
||||
<td className="overflow-hidden text-ellipsis whitespace-nowrap px-3 py-2">{isVirtualRoot ? "-" : (node.process_type || "-")}</td>
|
||||
{/* 규격 */}
|
||||
|
||||
@@ -185,9 +185,6 @@ export default function ProductionPlanManagementPage() {
|
||||
const [modalQuantity, setModalQuantity] = useState(0);
|
||||
const [modalStartDate, setModalStartDate] = useState("");
|
||||
const [modalEndDate, setModalEndDate] = useState("");
|
||||
const [modalManager, setModalManager] = useState("");
|
||||
const [modalWorkOrderNo, setModalWorkOrderNo] = useState("");
|
||||
const [modalRemarks, setModalRemarks] = useState("");
|
||||
const [modalEquipmentId, setModalEquipmentId] = useState("");
|
||||
|
||||
// 미리보기 데이터
|
||||
@@ -200,7 +197,10 @@ export default function ProductionPlanManagementPage() {
|
||||
const [selectedPlanIds, setSelectedPlanIds] = useState<Set<number>>(new Set());
|
||||
|
||||
// useConfirmDialog
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||
const { confirm, ConfirmDialogComponent, isConfirmOpenRef } = useConfirmDialog();
|
||||
|
||||
// 수량 지정 분할 입력값
|
||||
const [customSplitQty, setCustomSplitQty] = useState<number | "">("");
|
||||
|
||||
// ========== 데이터 로드 ==========
|
||||
|
||||
@@ -694,10 +694,8 @@ export default function ProductionPlanManagementPage() {
|
||||
setModalQuantity(Number(plan.plan_qty));
|
||||
setModalStartDate(plan.start_date?.split("T")[0] || "");
|
||||
setModalEndDate(plan.end_date?.split("T")[0] || "");
|
||||
setModalManager((plan as any).manager_name || "");
|
||||
setModalWorkOrderNo((plan as any).work_order_no || "");
|
||||
setModalRemarks(plan.remarks || "");
|
||||
setModalEquipmentId((plan as any).equipment_code || (plan.equipment_id ? String(plan.equipment_id) : ""));
|
||||
setCustomSplitQty("");
|
||||
setScheduleModalOpen(true);
|
||||
}, []);
|
||||
|
||||
@@ -709,9 +707,6 @@ export default function ProductionPlanManagementPage() {
|
||||
plan_qty: modalQuantity,
|
||||
start_date: modalStartDate,
|
||||
end_date: modalEndDate,
|
||||
manager_name: modalManager,
|
||||
work_order_no: modalWorkOrderNo,
|
||||
remarks: modalRemarks,
|
||||
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
|
||||
equipment_name: modalEquipmentId && modalEquipmentId !== "none"
|
||||
? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null
|
||||
@@ -721,13 +716,14 @@ export default function ProductionPlanManagementPage() {
|
||||
toast.success("생산계획이 수정되었습니다");
|
||||
setScheduleModalOpen(false);
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error("수정 실패: " + (err.message || ""));
|
||||
toast.error("수정 실패: " + (err?.response?.data?.message || err.message || ""));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalManager, modalWorkOrderNo, modalRemarks, modalEquipmentId, fetchPlans]);
|
||||
}, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId, equipmentList, fetchPlans, fetchOrderSummary]);
|
||||
|
||||
const handleDeletePlan = useCallback(async () => {
|
||||
if (!selectedPlan) return;
|
||||
@@ -741,24 +737,158 @@ export default function ProductionPlanManagementPage() {
|
||||
toast.success("삭제되었습니다");
|
||||
setScheduleModalOpen(false);
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
} catch (err: any) {
|
||||
toast.error("삭제 실패: " + (err.message || ""));
|
||||
toast.error("삭제 실패: " + (err?.response?.data?.message || err.message || ""));
|
||||
}
|
||||
}, [selectedPlan, fetchPlans, confirm]);
|
||||
}, [selectedPlan, fetchPlans, fetchOrderSummary, confirm]);
|
||||
|
||||
// 에러 메시지 추출 헬퍼
|
||||
const extractErrMsg = (err: any): string => {
|
||||
return err?.response?.data?.message || err?.message || "";
|
||||
};
|
||||
|
||||
// modalQuantity/일정/설비가 DB의 selectedPlan 값과 다른지 확인 (dirty 체크)
|
||||
const isModalDirty = useCallback((): boolean => {
|
||||
if (!selectedPlan) return false;
|
||||
const planQty = Number(selectedPlan.plan_qty) || 0;
|
||||
const planStart = selectedPlan.start_date?.split("T")[0] || "";
|
||||
const planEnd = selectedPlan.end_date?.split("T")[0] || "";
|
||||
const planEq = (selectedPlan as any).equipment_code || (selectedPlan.equipment_id ? String(selectedPlan.equipment_id) : "");
|
||||
return (
|
||||
planQty !== Number(modalQuantity) ||
|
||||
planStart !== modalStartDate ||
|
||||
planEnd !== modalEndDate ||
|
||||
planEq !== modalEquipmentId
|
||||
);
|
||||
}, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId]);
|
||||
|
||||
// dirty 상태면 자동 저장 후 selectedPlan 을 최신 값으로 갱신
|
||||
const ensureSavedBeforeSplit = useCallback(async (): Promise<boolean> => {
|
||||
if (!selectedPlan) return false;
|
||||
if (!isModalDirty()) return true;
|
||||
try {
|
||||
const res = await updatePlan(selectedPlan.id, {
|
||||
plan_qty: modalQuantity,
|
||||
start_date: modalStartDate,
|
||||
end_date: modalEndDate,
|
||||
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
|
||||
equipment_name: modalEquipmentId && modalEquipmentId !== "none"
|
||||
? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null
|
||||
: null,
|
||||
} as any);
|
||||
if (!res.success) {
|
||||
toast.error("저장 실패로 분할이 중단되었습니다");
|
||||
return false;
|
||||
}
|
||||
// selectedPlan 을 최신 값으로 동기화 (이후 로직에서 plan_qty 를 참조)
|
||||
setSelectedPlan((prev) => prev ? ({
|
||||
...prev,
|
||||
plan_qty: modalQuantity,
|
||||
start_date: modalStartDate,
|
||||
end_date: modalEndDate,
|
||||
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
|
||||
} as any) : prev);
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
toast.error("저장 실패로 분할이 중단되었습니다: " + extractErrMsg(err));
|
||||
return false;
|
||||
}
|
||||
}, [selectedPlan, isModalDirty, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId, equipmentList]);
|
||||
|
||||
// 균등 분할 (2/3/4분할 버튼)
|
||||
const handleSplitSchedule = useCallback(async (splitCount: number) => {
|
||||
if (!selectedPlan || splitCount < 2) return;
|
||||
// 모달 입력값 기준 (이후 자동 저장되므로 modalQuantity 가 진실)
|
||||
const originalQty = Number(modalQuantity) || 0;
|
||||
if (originalQty < splitCount) {
|
||||
toast.error(`${splitCount}분할하려면 수량이 ${splitCount} 이상이어야 합니다`);
|
||||
return;
|
||||
}
|
||||
if (selectedPlan.status && selectedPlan.status !== "planned") {
|
||||
toast.error("계획 상태인 건만 분할할 수 있습니다");
|
||||
return;
|
||||
}
|
||||
const ok = await confirm(`이 계획을 ${splitCount}개로 균등 분할하시겠습니까?`, {
|
||||
description: `수량 ${originalQty}이(가) ${splitCount}개로 나뉩니다.`,
|
||||
confirmText: "분할",
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
// dirty 면 자동 저장
|
||||
const saved = await ensureSavedBeforeSplit();
|
||||
if (!saved) return;
|
||||
|
||||
const eachQty = Math.floor(originalQty / splitCount);
|
||||
if (eachQty <= 0) {
|
||||
toast.error("분할 수량이 부족합니다");
|
||||
return;
|
||||
}
|
||||
|
||||
let successCount = 0;
|
||||
try {
|
||||
// N-1회 호출: 매번 eachQty만큼 원본에서 떼어내 새 plan 생성
|
||||
for (let i = 0; i < splitCount - 1; i++) {
|
||||
const res = await splitSchedule(selectedPlan.id, eachQty);
|
||||
if (!res.success) throw new Error("분할 응답 실패");
|
||||
successCount++;
|
||||
}
|
||||
toast.success(`계획이 ${splitCount}개로 분할되었습니다`);
|
||||
setScheduleModalOpen(false);
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
} catch (err: any) {
|
||||
const msg = extractErrMsg(err);
|
||||
if (successCount > 0) {
|
||||
toast.error(`분할 일부 실패 (${successCount + 1}개 생성됨): ${msg}`);
|
||||
} else {
|
||||
toast.error("분할 실패: " + msg);
|
||||
}
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
}
|
||||
}, [selectedPlan, modalQuantity, fetchPlans, fetchOrderSummary, confirm, ensureSavedBeforeSplit]);
|
||||
|
||||
// 수량 지정 분할 (원본에서 입력 수량만큼 떼어내기)
|
||||
const handleCustomSplit = useCallback(async () => {
|
||||
if (!selectedPlan) return;
|
||||
const splitQty = Number(customSplitQty);
|
||||
const originalQty = Number(modalQuantity) || 0;
|
||||
if (!splitQty || splitQty < 1) {
|
||||
toast.error("떼어낼 수량을 1 이상으로 입력하세요");
|
||||
return;
|
||||
}
|
||||
if (splitQty >= originalQty) {
|
||||
toast.error("떼어낼 수량은 원본 수량보다 작아야 합니다");
|
||||
return;
|
||||
}
|
||||
if (selectedPlan.status && selectedPlan.status !== "planned") {
|
||||
toast.error("계획 상태인 건만 분할할 수 있습니다");
|
||||
return;
|
||||
}
|
||||
const ok = await confirm(`이 계획에서 ${splitQty}만큼 떼어내시겠습니까?`, {
|
||||
description: `원본 ${originalQty} → 원본 ${originalQty - splitQty} + 신규 ${splitQty}`,
|
||||
confirmText: "분할",
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
const saved = await ensureSavedBeforeSplit();
|
||||
if (!saved) return;
|
||||
|
||||
const handleSplitSchedule = useCallback(async (splitQty: number) => {
|
||||
if (!selectedPlan || splitQty <= 0) return;
|
||||
try {
|
||||
const res = await splitSchedule(selectedPlan.id, splitQty);
|
||||
if (res.success) {
|
||||
toast.success("계획이 분할되었습니다");
|
||||
setScheduleModalOpen(false);
|
||||
fetchPlans();
|
||||
}
|
||||
if (!res.success) throw new Error("분할 응답 실패");
|
||||
toast.success(`${splitQty} 수량이 분리되었습니다`);
|
||||
setCustomSplitQty("");
|
||||
setScheduleModalOpen(false);
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
} catch (err: any) {
|
||||
toast.error("분할 실패: " + (err.message || ""));
|
||||
toast.error("분할 실패: " + extractErrMsg(err));
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
}
|
||||
}, [selectedPlan, fetchPlans]);
|
||||
}, [selectedPlan, modalQuantity, customSplitQty, fetchPlans, fetchOrderSummary, confirm, ensureSavedBeforeSplit]);
|
||||
|
||||
// 병합 핸들러
|
||||
const handleMergeSchedules = useCallback(async () => {
|
||||
@@ -780,11 +910,12 @@ export default function ProductionPlanManagementPage() {
|
||||
toast.success("계획이 병합되었습니다");
|
||||
setSelectedPlanIds(new Set());
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error("병합 실패: " + (err.message || ""));
|
||||
toast.error("병합 실패: " + (err?.response?.data?.message || err.message || ""));
|
||||
}
|
||||
}, [selectedPlanIds, rightTab, fetchPlans, confirm]);
|
||||
}, [selectedPlanIds, rightTab, fetchPlans, fetchOrderSummary, confirm]);
|
||||
|
||||
// 타임라인 이벤트 드래그 이동
|
||||
const handleEventMove = useCallback(async (eventId: string | number, newStart: string, newEnd: string) => {
|
||||
@@ -796,11 +927,12 @@ export default function ProductionPlanManagementPage() {
|
||||
if (res.success) {
|
||||
toast.success("일정이 변경되었습니다");
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error("일정 변경 실패: " + (err.message || ""));
|
||||
}
|
||||
}, [fetchPlans]);
|
||||
}, [fetchPlans, fetchOrderSummary]);
|
||||
|
||||
// 타임라인 이벤트 리사이즈
|
||||
const handleEventResize = useCallback(async (eventId: string | number, newStart: string, newEnd: string) => {
|
||||
@@ -812,11 +944,12 @@ export default function ProductionPlanManagementPage() {
|
||||
if (res.success) {
|
||||
toast.success("기간이 변경되었습니다");
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error("기간 변경 실패: " + (err.message || ""));
|
||||
}
|
||||
}, [fetchPlans]);
|
||||
}, [fetchPlans, fetchOrderSummary]);
|
||||
|
||||
// 불러오기 처리
|
||||
const handleImportOrderItems = useCallback(async () => {
|
||||
@@ -1463,8 +1596,26 @@ export default function ProductionPlanManagementPage() {
|
||||
{/* ========== 모달들 ========== */}
|
||||
|
||||
{/* 스케줄 상세/편집 모달 */}
|
||||
<Dialog open={scheduleModalOpen} onOpenChange={setScheduleModalOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[650px] max-h-[85vh] overflow-y-auto">
|
||||
<Dialog
|
||||
open={scheduleModalOpen}
|
||||
onOpenChange={(v) => {
|
||||
// confirm 다이얼로그가 열려 있는 동안 발생하는 닫힘 이벤트(포커스 이탈 등)는 무시
|
||||
if (!v && isConfirmOpenRef.current) return;
|
||||
setScheduleModalOpen(v);
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="max-w-[95vw] sm:max-w-[650px] max-h-[85vh] overflow-y-auto"
|
||||
onPointerDownOutside={(e) => {
|
||||
if (isConfirmOpenRef.current) e.preventDefault();
|
||||
}}
|
||||
onInteractOutside={(e) => {
|
||||
if (isConfirmOpenRef.current) e.preventDefault();
|
||||
}}
|
||||
onFocusOutside={(e) => {
|
||||
if (isConfirmOpenRef.current) e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg flex items-center gap-2">
|
||||
<ClipboardList className="h-5 w-5" />
|
||||
@@ -1554,37 +1705,67 @@ export default function ProductionPlanManagementPage() {
|
||||
<Scissors className="h-4 w-4" />
|
||||
계획 분할
|
||||
</p>
|
||||
<div className="flex gap-1.5">
|
||||
{[2, 3, 4].map((n) => {
|
||||
const canSplit =
|
||||
modalQuantity >= n &&
|
||||
(selectedPlan?.status === "planned" || !selectedPlan?.status);
|
||||
return (
|
||||
<Button
|
||||
key={n}
|
||||
size="sm"
|
||||
variant="warning"
|
||||
className="h-7 text-xs"
|
||||
disabled={!canSplit}
|
||||
onClick={() => handleSplitSchedule(n)}
|
||||
>
|
||||
{n}분할
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-foreground mb-2">
|
||||
하나의 생산계획을 선택한 개수만큼 균등 분할합니다. (수량 부족 또는 완료 상태는 불가)
|
||||
</p>
|
||||
{/* 수량 지정 분할 */}
|
||||
<div className="flex items-center gap-1.5 pt-2 border-t border-warning/20">
|
||||
<Label className="text-xs text-muted-foreground shrink-0">수량 지정:</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={customSplitQty}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
if (v === "") setCustomSplitQty("");
|
||||
else setCustomSplitQty(Math.max(0, Math.floor(Number(v) || 0)));
|
||||
}}
|
||||
className="h-7 w-28 text-xs"
|
||||
placeholder="떼어낼 수량"
|
||||
min={1}
|
||||
max={Math.max(0, modalQuantity - 1)}
|
||||
step={1}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
/ {modalQuantity}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="warning"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => {
|
||||
const qty = Math.floor(modalQuantity / 2);
|
||||
if (qty > 0) handleSplitSchedule(qty);
|
||||
}}
|
||||
className="h-7 text-xs ml-auto"
|
||||
disabled={
|
||||
!customSplitQty ||
|
||||
Number(customSplitQty) < 1 ||
|
||||
Number(customSplitQty) >= modalQuantity ||
|
||||
!(selectedPlan?.status === "planned" || !selectedPlan?.status)
|
||||
}
|
||||
onClick={handleCustomSplit}
|
||||
>
|
||||
2분할
|
||||
분할
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-foreground">하나의 생산계획을 여러 개로 분할합니다.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-3 pb-2 border-b">추가 정보</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs">담당자</Label>
|
||||
<Input value={modalManager} onChange={(e) => setModalManager(e.target.value)} className="h-9 text-xs" placeholder="담당자명" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">작업지시번호</Label>
|
||||
<Input value={modalWorkOrderNo} onChange={(e) => setModalWorkOrderNo(e.target.value)} className="h-9 text-xs" placeholder="자동생성" />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<Label className="text-xs">비고</Label>
|
||||
<Input value={modalRemarks} onChange={(e) => setModalRemarks(e.target.value)} className="h-9 text-xs" placeholder="비고사항 입력" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground mt-1.5">
|
||||
입력한 수량만큼 떼어내 새 계획을 생성합니다. (1 이상, 원본 수량 미만)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||
import {
|
||||
@@ -91,7 +92,8 @@ export function ItemRoutingTab() {
|
||||
const [formFixedOrder, setFormFixedOrder] = useState("Y");
|
||||
const [formWorkType, setFormWorkType] = useState("내부");
|
||||
const [formStandardTime, setFormStandardTime] = useState("");
|
||||
const [formOutsource, setFormOutsource] = useState("");
|
||||
const [formOutsources, setFormOutsources] = useState<string[]>([]);
|
||||
const [subcontractorOptions, setSubcontractorOptions] = useState<{ id: string; code: string; name: string }[]>([]);
|
||||
const [detailSubmitting, setDetailSubmitting] = useState(false);
|
||||
|
||||
const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
|
||||
@@ -107,6 +109,19 @@ export function ItemRoutingTab() {
|
||||
return () => window.clearTimeout(t);
|
||||
}, [searchInput]);
|
||||
|
||||
// 외주사 목록 로드
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const res = await apiClient.post("/table-management/tables/subcontractor_mng/data", {
|
||||
page: 1, size: 500, autoFilter: true,
|
||||
});
|
||||
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
setSubcontractorOptions(rows.map((r: any) => ({ id: r.id, code: r.subcontractor_code || "", name: r.subcontractor_name || "" })));
|
||||
} catch { /* skip */ }
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const t = window.setTimeout(() => setRegisterSearchDebounced(registerSearch.trim()), 300);
|
||||
return () => window.clearTimeout(t);
|
||||
@@ -267,7 +282,7 @@ export function ItemRoutingTab() {
|
||||
setFormFixedOrder("Y");
|
||||
setFormWorkType("내부");
|
||||
setFormStandardTime("");
|
||||
setFormOutsource("");
|
||||
setFormOutsources([]);
|
||||
setDetailDialogOpen(true);
|
||||
};
|
||||
|
||||
@@ -294,7 +309,19 @@ export function ItemRoutingTab() {
|
||||
setFormFixedOrder(row.is_fixed_order === "N" ? "N" : "Y");
|
||||
setFormWorkType(row.work_type || "내부");
|
||||
setFormStandardTime(row.standard_time || "");
|
||||
setFormOutsource(row.outsource_supplier || "");
|
||||
// 우선순위: id 배열 → legacy code 배열(id로 역변환) → legacy 단일 code(id로 역변환)
|
||||
let loadedIds: string[] = [];
|
||||
if (Array.isArray(row.outsource_supplier_ids) && row.outsource_supplier_ids.length > 0) {
|
||||
loadedIds = row.outsource_supplier_ids;
|
||||
} else {
|
||||
const legacyCodes = Array.isArray(row.outsource_supplier_list) && row.outsource_supplier_list.length > 0
|
||||
? row.outsource_supplier_list
|
||||
: (row.outsource_supplier ? [row.outsource_supplier] : []);
|
||||
loadedIds = legacyCodes
|
||||
.map((c: string) => subcontractorOptions.find((s) => s.code === c)?.id)
|
||||
.filter((v): v is string => Boolean(v));
|
||||
}
|
||||
setFormOutsources(loadedIds);
|
||||
setDetailDialogOpen(true);
|
||||
};
|
||||
|
||||
@@ -315,7 +342,10 @@ export function ItemRoutingTab() {
|
||||
return;
|
||||
}
|
||||
const proc = processes.find((p) => p.process_code === formProcessCode);
|
||||
const outsource = showOutsourceField ? formOutsource.trim() : "";
|
||||
const outsourceIds = showOutsourceField ? formOutsources.filter((s) => s && s.trim() !== "") : [];
|
||||
const outsourcePrimaryCode = outsourceIds.length > 0
|
||||
? (subcontractorOptions.find((s) => s.id === outsourceIds[0])?.code || "")
|
||||
: "";
|
||||
|
||||
setDetailSubmitting(true);
|
||||
try {
|
||||
@@ -330,7 +360,8 @@ export function ItemRoutingTab() {
|
||||
is_fixed_order: formFixedOrder,
|
||||
work_type: formWorkType,
|
||||
standard_time: st || "0",
|
||||
outsource_supplier: outsource,
|
||||
outsource_supplier: outsourcePrimaryCode,
|
||||
outsource_supplier_ids: outsourceIds,
|
||||
};
|
||||
setDetails((prev) => sortDetailsBySeq([...prev, newRow]));
|
||||
toast.success("공정이 추가되었어요. 저장을 눌러 반영해주세요");
|
||||
@@ -348,7 +379,8 @@ export function ItemRoutingTab() {
|
||||
is_fixed_order: formFixedOrder,
|
||||
work_type: formWorkType,
|
||||
standard_time: st || "0",
|
||||
outsource_supplier: outsource,
|
||||
outsource_supplier: outsourcePrimaryCode,
|
||||
outsource_supplier_ids: outsourceIds,
|
||||
}
|
||||
: d,
|
||||
),
|
||||
@@ -385,6 +417,7 @@ export function ItemRoutingTab() {
|
||||
work_type: d.work_type || "내부",
|
||||
standard_time: String(d.standard_time ?? "0"),
|
||||
outsource_supplier: d.outsource_supplier || "",
|
||||
outsource_supplier_ids: d.outsource_supplier_ids || [],
|
||||
}));
|
||||
|
||||
setSaving(true);
|
||||
@@ -466,12 +499,24 @@ export function ItemRoutingTab() {
|
||||
|
||||
const detailsGridData = useMemo(
|
||||
() =>
|
||||
details.map((d) => ({
|
||||
...d,
|
||||
process_display: d.process_name || d.process_code,
|
||||
outsource_display: d.outsource_supplier || "—",
|
||||
})),
|
||||
[details],
|
||||
details.map((d) => {
|
||||
const ids = Array.isArray(d.outsource_supplier_ids) && d.outsource_supplier_ids.length > 0
|
||||
? d.outsource_supplier_ids
|
||||
: [];
|
||||
let names = ids
|
||||
.map((i) => subcontractorOptions.find((s) => s.id === i)?.name)
|
||||
.filter((v): v is string => Boolean(v));
|
||||
// 레거시 폴백: id 매핑 없을 때 단일 code로 표시
|
||||
if (names.length === 0 && d.outsource_supplier) {
|
||||
names = [subcontractorOptions.find((s) => s.code === d.outsource_supplier)?.name || d.outsource_supplier];
|
||||
}
|
||||
return {
|
||||
...d,
|
||||
process_display: d.process_name || d.process_code,
|
||||
outsource_display: names.length === 0 ? "—" : names.join(", "),
|
||||
};
|
||||
}),
|
||||
[details, subcontractorOptions],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -895,13 +940,46 @@ export function ItemRoutingTab() {
|
||||
</div>
|
||||
{showOutsourceField && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">외주업체</Label>
|
||||
<Input
|
||||
value={formOutsource}
|
||||
onChange={(e) => setFormOutsource(e.target.value)}
|
||||
placeholder="외주 업체명"
|
||||
className="h-9"
|
||||
/>
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">외주업체 (다중 선택)</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="h-9 w-full justify-between font-normal">
|
||||
<span className="truncate text-left text-sm">
|
||||
{formOutsources.length === 0
|
||||
? "외주업체 선택"
|
||||
: formOutsources
|
||||
.map((i) => subcontractorOptions.find((s) => s.id === i)?.name || i)
|
||||
.join(", ")}
|
||||
</span>
|
||||
<Badge variant="secondary" className="ml-2 shrink-0">{formOutsources.length}</Badge>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[320px] p-0" align="start">
|
||||
<div className="max-h-64 overflow-y-auto p-2 space-y-1">
|
||||
{subcontractorOptions.length === 0 ? (
|
||||
<div className="text-xs text-muted-foreground px-2 py-3">등록된 외주업체가 없어요</div>
|
||||
) : subcontractorOptions.map((s) => {
|
||||
const checked = formOutsources.includes(s.id);
|
||||
return (
|
||||
<label
|
||||
key={s.id}
|
||||
className="flex items-center gap-2 rounded px-2 py-1.5 text-sm cursor-pointer hover:bg-muted"
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(v) => {
|
||||
setFormOutsources((prev) =>
|
||||
v ? [...prev, s.id] : prev.filter((i) => i !== s.id),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<span className="truncate">{s.name}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Search,
|
||||
RotateCcw,
|
||||
Wrench,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -47,6 +48,7 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||
import { SmartSelect } from "@/components/common/SmartSelect";
|
||||
import {
|
||||
getProcessList,
|
||||
createProcess,
|
||||
@@ -61,6 +63,10 @@ import {
|
||||
type Equipment,
|
||||
} from "@/lib/api/processInfo"; // API: /process-info/*
|
||||
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
|
||||
import { SmartExcelUploadModal } from "@/components/common/SmartExcelUpload";
|
||||
import type { SmartExcelUploadConfig, ParsedSheetData } from "@/components/common/SmartExcelUpload";
|
||||
import { allocateNumberingCode } from "@/lib/api/numberingRule";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
const ALL_VALUE = "__all__";
|
||||
|
||||
@@ -68,6 +74,8 @@ export function ProcessMasterTab() {
|
||||
const [processes, setProcesses] = useState<ProcessMaster[]>([]);
|
||||
const [equipmentMaster, setEquipmentMaster] = useState<Equipment[]>([]);
|
||||
const [processTypeOptions, setProcessTypeOptions] = useState<{ valueCode: string; valueLabel: string }[]>([]);
|
||||
const [useYnOptions, setUseYnOptions] = useState<{ valueCode: string; valueLabel: string }[]>([]);
|
||||
const [excelOpen, setExcelOpen] = useState(false);
|
||||
const [loadingInitial, setLoadingInitial] = useState(true);
|
||||
const [loadingList, setLoadingList] = useState(false);
|
||||
const [loadingEquipments, setLoadingEquipments] = useState(false);
|
||||
@@ -153,6 +161,18 @@ export function ProcessMasterTab() {
|
||||
});
|
||||
setProcessTypeOptions(unique.map((v: any) => ({ valueCode: v.valueCode, valueLabel: v.valueLabel })));
|
||||
}
|
||||
// 사용여부 카테고리 (엑셀업로드 드롭다운용)
|
||||
const uyRes = await getCategoryValues("process_mng", "use_yn");
|
||||
if (uyRes.success && "data" in uyRes && Array.isArray(uyRes.data)) {
|
||||
const activeValues = uyRes.data.filter((v: any) => v.isActive !== false);
|
||||
const seen = new Set<string>();
|
||||
const unique = activeValues.filter((v: any) => {
|
||||
if (seen.has(v.valueCode)) return false;
|
||||
seen.add(v.valueCode);
|
||||
return true;
|
||||
});
|
||||
setUseYnOptions(unique.map((v: any) => ({ valueCode: v.valueCode, valueLabel: v.valueLabel })));
|
||||
}
|
||||
} finally {
|
||||
setLoadingInitial(false);
|
||||
}
|
||||
@@ -221,18 +241,28 @@ export function ProcessMasterTab() {
|
||||
};
|
||||
|
||||
const openEdit = () => {
|
||||
if (!selectedProcess) {
|
||||
toast.message("수정할 공정을 좌측 목록에서 선택해주세요");
|
||||
if (selectedIds.size === 0) {
|
||||
toast.message("수정할 공정을 체크박스로 선택해주세요");
|
||||
return;
|
||||
}
|
||||
if (selectedIds.size > 1) {
|
||||
toast.message("수정은 1건만 선택해주세요");
|
||||
return;
|
||||
}
|
||||
const targetId = Array.from(selectedIds)[0];
|
||||
const target = processes.find((p) => p.id === targetId);
|
||||
if (!target) {
|
||||
toast.error("선택한 공정을 찾을 수 없습니다");
|
||||
return;
|
||||
}
|
||||
setFormMode("edit");
|
||||
setEditingId(selectedProcess.id);
|
||||
setFormProcessCode(selectedProcess.process_code);
|
||||
setFormProcessName(selectedProcess.process_name);
|
||||
setFormProcessType(selectedProcess.process_type);
|
||||
setFormStandardTime(selectedProcess.standard_time ?? "");
|
||||
setFormWorkerCount(selectedProcess.worker_count ?? "");
|
||||
setFormUseYn(selectedProcess.use_yn);
|
||||
setEditingId(target.id);
|
||||
setFormProcessCode(target.process_code);
|
||||
setFormProcessName(target.process_name);
|
||||
setFormProcessType(target.process_type);
|
||||
setFormStandardTime(target.standard_time ?? "");
|
||||
setFormWorkerCount(target.worker_count ?? "");
|
||||
setFormUseYn(target.use_yn);
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
@@ -313,8 +343,17 @@ export function ProcessMasterTab() {
|
||||
};
|
||||
|
||||
const availableEquipments = useMemo(() => {
|
||||
const used = new Set(processEquipments.map((e) => e.equipment_code));
|
||||
return equipmentMaster.filter((e) => !used.has(e.equipment_code));
|
||||
// equipment_code 컬럼에 code(legacy) 또는 id(신규)가 들어있을 수 있음
|
||||
// 빈 값은 used set에서 제외 (코드 없는 설비를 모두 가리는 것 방지)
|
||||
const used = new Set<string>();
|
||||
for (const pe of processEquipments) {
|
||||
if (pe.equipment_code) used.add(pe.equipment_code);
|
||||
}
|
||||
return equipmentMaster.filter((e) => {
|
||||
if (e.equipment_code && used.has(e.equipment_code)) return false;
|
||||
if (e.id && used.has(e.id)) return false;
|
||||
return true;
|
||||
});
|
||||
}, [equipmentMaster, processEquipments]);
|
||||
|
||||
const handleAddEquipment = async () => {
|
||||
@@ -323,11 +362,17 @@ export function ProcessMasterTab() {
|
||||
toast.message("추가할 설비를 선택해주세요");
|
||||
return;
|
||||
}
|
||||
const picked = availableEquipments.find((e) => e.id === equipmentPick);
|
||||
if (!picked) {
|
||||
toast.error("선택한 설비를 찾을 수 없어요");
|
||||
return;
|
||||
}
|
||||
setAddingEquipment(true);
|
||||
try {
|
||||
// equipment_code가 비어있으면 id를 저장 (백엔드 JOIN이 양쪽 다 매칭)
|
||||
const res = await addProcessEquipment({
|
||||
process_code: selectedProcess.process_code,
|
||||
equipment_code: equipmentPick,
|
||||
equipment_code: picked.equipment_code || picked.id,
|
||||
});
|
||||
if (!res.success) {
|
||||
toast.error(res.message || "설비 추가에 실패했어요");
|
||||
@@ -357,6 +402,175 @@ export function ProcessMasterTab() {
|
||||
|
||||
const listBusy = loadingInitial || loadingList;
|
||||
|
||||
/* ═══════════════════ 엑셀 업로드 설정 ═══════════════════ */
|
||||
const processTypeLabelToCode = useMemo(() => {
|
||||
const m: Record<string, string> = {};
|
||||
processTypeOptions.forEach((o) => {
|
||||
m[o.valueLabel] = o.valueCode;
|
||||
});
|
||||
return m;
|
||||
}, [processTypeOptions]);
|
||||
|
||||
const useYnLabelToCode = useMemo(() => {
|
||||
const m: Record<string, string> = {};
|
||||
useYnOptions.forEach((o) => {
|
||||
m[o.valueLabel] = o.valueCode;
|
||||
});
|
||||
return m;
|
||||
}, [useYnOptions]);
|
||||
|
||||
const excelConfig = useMemo<SmartExcelUploadConfig>(
|
||||
() => ({
|
||||
templateName: "공정 마스터",
|
||||
sheets: [
|
||||
{
|
||||
name: "공정 마스터",
|
||||
typeKey: "process_mng",
|
||||
columns: [
|
||||
{ key: "process_code", label: "공정코드", type: "text", width: 14 },
|
||||
{ key: "process_name", label: "공정명", required: true, type: "text", width: 20 },
|
||||
{
|
||||
key: "process_type",
|
||||
label: "공정유형",
|
||||
required: true,
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: processTypeOptions.map((o) => o.valueLabel) },
|
||||
width: 18,
|
||||
},
|
||||
{ key: "standard_time", label: "표준시간(분)", type: "number", width: 12 },
|
||||
{ key: "worker_count", label: "작업인원", type: "number", width: 10 },
|
||||
{
|
||||
key: "use_yn",
|
||||
label: "사용여부",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: useYnOptions.map((o) => o.valueLabel) },
|
||||
width: 10,
|
||||
},
|
||||
// 설비명을 쉼표로 구분하여 입력 (예: "절단기1,포장기"). 매칭 실패한 이름은 스킵.
|
||||
{ key: "equipment_list", label: "사용설비(쉼표구분)", type: "text", width: 28 },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
[processTypeOptions, useYnOptions],
|
||||
);
|
||||
|
||||
const excelDropdownOptions = useMemo<Record<string, string[]>>(
|
||||
() => ({
|
||||
process_type: processTypeOptions.map((o) => o.valueLabel),
|
||||
use_yn: useYnOptions.map((o) => o.valueLabel),
|
||||
}),
|
||||
[processTypeOptions, useYnOptions],
|
||||
);
|
||||
|
||||
const excelLabelToCodeMap = useMemo<Record<string, Record<string, string>>>(
|
||||
() => ({
|
||||
process_type: processTypeLabelToCode,
|
||||
use_yn: useYnLabelToCode,
|
||||
}),
|
||||
[processTypeLabelToCode, useYnLabelToCode],
|
||||
);
|
||||
|
||||
const handleExcelUpload = async (data: ParsedSheetData[]) => {
|
||||
const rows = data[0]?.rows ?? [];
|
||||
if (rows.length === 0) {
|
||||
toast.error("업로드할 데이터가 없어요");
|
||||
return;
|
||||
}
|
||||
|
||||
// 채번 규칙 사전 조회 (공정코드 미입력 행에만 사용)
|
||||
let ruleId: string | null = null;
|
||||
try {
|
||||
const res = await apiClient.get(`/numbering-rules/by-column/process_mng/process_code`);
|
||||
if (res.data?.success && res.data?.data?.ruleId) {
|
||||
ruleId = res.data.data.ruleId;
|
||||
}
|
||||
} catch {
|
||||
/* 채번 규칙 없으면 스킵 */
|
||||
}
|
||||
|
||||
let okCount = 0;
|
||||
const failList: string[] = [];
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
try {
|
||||
// row.{key}는 templateParser가 이미 label→code로 자동변환한 값 (dropdown 컬럼)
|
||||
let processCode = String(row.process_code || "").trim();
|
||||
if (!processCode && ruleId) {
|
||||
const alloc = await allocateNumberingCode(ruleId);
|
||||
if (alloc.success && alloc.data?.generatedCode) {
|
||||
processCode = alloc.data.generatedCode;
|
||||
} else {
|
||||
failList.push(`${i + 2}행: 채번 실패`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (!processCode) {
|
||||
failList.push(`${i + 2}행: 공정코드 없음 (채번 규칙 필요)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
process_code: processCode,
|
||||
process_name: row.process_name || "",
|
||||
process_type: row.process_type || "",
|
||||
standard_time: String(row.standard_time ?? ""),
|
||||
worker_count: String(row.worker_count ?? ""),
|
||||
use_yn: row.use_yn || "USE_Y",
|
||||
};
|
||||
|
||||
// 기존 공정 조회 후 upsert
|
||||
const existRes = await getProcessList({ processCode });
|
||||
const existing = existRes.success
|
||||
? (existRes.data ?? []).find((p) => p.process_code === processCode)
|
||||
: null;
|
||||
if (existing) {
|
||||
const up = await updateProcess(existing.id, payload);
|
||||
if (!up.success) {
|
||||
failList.push(`${i + 2}행: 수정 실패`);
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
const cr = await createProcess(payload);
|
||||
if (!cr.success) {
|
||||
failList.push(`${i + 2}행: 등록 실패`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 사용설비 매핑 (쉼표 구분 설비명). 매칭 안 되는 이름은 무시 (사용자 책임).
|
||||
const rawEqs = String(row.equipment_list || "").trim();
|
||||
if (rawEqs) {
|
||||
const eqLabels = rawEqs.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
// 기존 매핑 조회 후 중복 방지
|
||||
const curRes = await getProcessEquipments(processCode);
|
||||
const currentSet = new Set((curRes.success ? curRes.data ?? [] : []).map((e) => e.equipment_code));
|
||||
const eqByName = new Map(equipmentMaster.map((e) => [e.equipment_name, e.equipment_code]));
|
||||
for (const label of eqLabels) {
|
||||
const eqCode = eqByName.get(label);
|
||||
if (!eqCode) continue; // 오타/미등록 설비 → 스킵
|
||||
if (currentSet.has(eqCode)) continue; // 이미 매핑됨
|
||||
await addProcessEquipment({ process_code: processCode, equipment_code: eqCode });
|
||||
currentSet.add(eqCode);
|
||||
}
|
||||
}
|
||||
|
||||
okCount++;
|
||||
} catch {
|
||||
failList.push(`${i + 2}행: 저장 실패`);
|
||||
}
|
||||
}
|
||||
|
||||
if (okCount > 0) toast.success(`${okCount}건을 업로드했어요`);
|
||||
if (failList.length > 0) {
|
||||
toast.error(
|
||||
`실패 ${failList.length}건: ${failList.slice(0, 3).join(" / ")}${failList.length > 3 ? " …" : ""}`,
|
||||
);
|
||||
}
|
||||
void loadProcesses();
|
||||
};
|
||||
|
||||
// 표시용 데이터
|
||||
const processGridData = useMemo(
|
||||
() =>
|
||||
@@ -420,6 +634,10 @@ export function ProcessMasterTab() {
|
||||
|
||||
{/* 액션 바 */}
|
||||
<div className="flex items-center justify-end gap-2 border-b bg-muted/30 px-4 py-2">
|
||||
<Button size="sm" variant="outline" onClick={() => setExcelOpen(true)}>
|
||||
<Upload className="mr-1 h-3.5 w-3.5" />
|
||||
엑셀업로드
|
||||
</Button>
|
||||
<Button size="sm" onClick={openAdd}>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
공정 추가
|
||||
@@ -501,23 +719,17 @@ export function ProcessMasterTab() {
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">설비 선택</Label>
|
||||
<Select
|
||||
<SmartSelect
|
||||
key={selectedProcess.id}
|
||||
value={equipmentPick || undefined}
|
||||
options={availableEquipments.map((eq) => ({
|
||||
code: eq.id,
|
||||
label: eq.equipment_name,
|
||||
}))}
|
||||
value={equipmentPick || ""}
|
||||
onValueChange={setEquipmentPick}
|
||||
placeholder="설비를 선택해주세요"
|
||||
disabled={addingEquipment || availableEquipments.length === 0}
|
||||
>
|
||||
<SelectTrigger className="h-9" size="sm">
|
||||
<SelectValue placeholder="설비를 선택해주세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableEquipments.map((eq) => (
|
||||
<SelectItem key={eq.id} value={eq.equipment_code}>
|
||||
{eq.equipment_code} · {eq.equipment_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -548,8 +760,11 @@ export function ProcessMasterTab() {
|
||||
{processEquipments.map((pe) => (
|
||||
<li key={pe.id} className="flex items-center gap-3 rounded-lg border p-3 transition-colors hover:bg-muted/30">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{pe.equipment_code}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{pe.equipment_name || "설비명 없음"}</p>
|
||||
<p className="truncate text-sm font-medium">
|
||||
{pe.equipment_name
|
||||
|| equipmentMaster.find((e) => e.id === pe.equipment_code || e.equipment_code === pe.equipment_code)?.equipment_name
|
||||
|| "설비명 없음"}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => void handleRemoveEquipment(pe)}>
|
||||
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
||||
@@ -659,6 +874,38 @@ export function ProcessMasterTab() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ═══════════════════ 엑셀업로드 모달 ═══════════════════ */}
|
||||
<SmartExcelUploadModal
|
||||
open={excelOpen}
|
||||
onOpenChange={setExcelOpen}
|
||||
config={excelConfig}
|
||||
dropdownOptions={excelDropdownOptions}
|
||||
labelToCodeMap={excelLabelToCodeMap}
|
||||
onUpload={handleExcelUpload}
|
||||
customValidator={(data) => {
|
||||
// 사용설비 컬럼 검증: 쉼표 구분 각 이름이 equipmentMaster에 등록된 설비명인지
|
||||
const errors: any[] = [];
|
||||
const validNames = new Set(equipmentMaster.map((e) => e.equipment_name));
|
||||
for (const sheet of data) {
|
||||
sheet.rows.forEach((row, idx) => {
|
||||
const raw = String(row.equipment_list || "").trim();
|
||||
if (!raw) return;
|
||||
const names = raw.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
const invalid = names.filter((n) => !validNames.has(n));
|
||||
if (invalid.length > 0) {
|
||||
errors.push({
|
||||
sheet: sheet.sheetName,
|
||||
row: idx + 2,
|
||||
column: "사용설비(쉼표구분)",
|
||||
message: `등록되지 않은 설비: ${invalid.join(", ")}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return errors;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import React, { useState, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import {
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
ClipboardList,
|
||||
ChevronRight,
|
||||
Factory,
|
||||
Keyboard,
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
@@ -41,7 +40,6 @@ const TAB_META = [
|
||||
icon: Settings,
|
||||
color: "text-blue-500",
|
||||
badgeColor: "bg-blue-50 text-blue-700 ring-blue-600/20",
|
||||
shortcut: "1",
|
||||
actions: ["공정 등록", "공정 수정", "공정 삭제", "설비 연결"],
|
||||
},
|
||||
{
|
||||
@@ -53,7 +51,6 @@ const TAB_META = [
|
||||
icon: GitBranch,
|
||||
color: "text-emerald-500",
|
||||
badgeColor: "bg-emerald-50 text-emerald-700 ring-emerald-600/20",
|
||||
shortcut: "2",
|
||||
actions: ["버전 생성", "공정 순서 설정", "품목 등록", "품목 해제"],
|
||||
},
|
||||
{
|
||||
@@ -65,7 +62,6 @@ const TAB_META = [
|
||||
icon: ClipboardList,
|
||||
color: "text-violet-500",
|
||||
badgeColor: "bg-violet-50 text-violet-700 ring-violet-600/20",
|
||||
shortcut: "3",
|
||||
actions: ["기준서 등록", "기준서 수정", "기준서 삭제", "작업 표준 관리"],
|
||||
},
|
||||
] as const;
|
||||
@@ -77,7 +73,6 @@ const ACTION_ICONS = [Plus, Pencil, Trash2, List] as const;
|
||||
export default function ProcessInfoPage() {
|
||||
const ts = useTableSettings("c16-process-info", "process_mst", GRID_COLUMNS);
|
||||
const [activeTab, setActiveTab] = useState<TabValue>("process");
|
||||
const [showShortcutHint, setShowShortcutHint] = useState(false);
|
||||
|
||||
const activeMeta = TAB_META.find((t) => t.value === activeTab)!;
|
||||
|
||||
@@ -85,19 +80,6 @@ export default function ProcessInfoPage() {
|
||||
setActiveTab(value as TabValue);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!e.altKey) return;
|
||||
const tabByShortcut = TAB_META.find((t) => t.shortcut === e.key);
|
||||
if (tabByShortcut) {
|
||||
e.preventDefault();
|
||||
setActiveTab(tabByShortcut.value);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-4rem)] flex-col bg-muted/30">
|
||||
{/* 페이지 헤더 */}
|
||||
@@ -120,30 +102,8 @@ export default function ProcessInfoPage() {
|
||||
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
|
||||
<Settings2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 rounded px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
onClick={() => setShowShortcutHint((v) => !v)}
|
||||
aria-label="키보드 단축키 보기"
|
||||
>
|
||||
<Keyboard className="h-3 w-3" />
|
||||
<span>단축키</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{showShortcutHint && (
|
||||
<div className="mt-2 flex items-center gap-3 rounded-md bg-muted px-3 py-2 text-xs text-muted-foreground">
|
||||
<span className="font-medium text-foreground">탭 전환:</span>
|
||||
{TAB_META.map((t) => (
|
||||
<span key={t.value} className="flex items-center gap-1">
|
||||
<kbd className="rounded border border-border bg-background px-1.5 py-0.5 font-mono text-[10px]">
|
||||
Alt+{t.shortcut}
|
||||
</kbd>
|
||||
<span>{t.shortLabel}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
@@ -154,12 +114,12 @@ export default function ProcessInfoPage() {
|
||||
{/* 탭 네비게이션 */}
|
||||
<div className="shrink-0 border-b bg-background px-4">
|
||||
<TabsList className="h-12 bg-transparent gap-1">
|
||||
{TAB_META.map(({ value, label, icon: Icon, shortcut }) => (
|
||||
{TAB_META.map(({ value, label, icon: Icon }) => (
|
||||
<TabsTrigger
|
||||
key={value}
|
||||
value={value}
|
||||
className="data-[state=active]:border-b-2 data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none rounded-none px-4 gap-1.5"
|
||||
aria-label={`${label} (Alt+${shortcut})`}
|
||||
aria-label={label}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{label}
|
||||
@@ -168,34 +128,6 @@ export default function ProcessInfoPage() {
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{/* 탭 설명 배너 */}
|
||||
<div className="shrink-0 border-b bg-background/60 px-6 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset ${activeMeta.badgeColor}`}
|
||||
>
|
||||
{activeMeta.shortLabel}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{activeMeta.detailDesc}</span>
|
||||
</div>
|
||||
<div className="hidden items-center gap-2 sm:flex">
|
||||
{activeMeta.actions.map((action, i) => {
|
||||
const ActionIcon = ACTION_ICONS[i % ACTION_ICONS.length];
|
||||
return (
|
||||
<span
|
||||
key={action}
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground/70"
|
||||
>
|
||||
<ActionIcon className="h-3 w-3" />
|
||||
{action}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 탭 컨텐츠 */}
|
||||
<TabsContent value="process" className="min-h-0 flex-1 overflow-hidden mt-0">
|
||||
<ProcessMasterTab />
|
||||
|
||||
@@ -183,7 +183,7 @@ export default function ProductionResultPage() {
|
||||
setProcessLoading(true);
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/${WOP_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "wo_id", operator: "equals", value: selectedWiId }] },
|
||||
autoFilter: true,
|
||||
sort: { columnName: "seq_no", order: "asc" },
|
||||
|
||||
+2
-2
@@ -586,7 +586,7 @@ export function WorkStandardEditModal({
|
||||
<thead className="sticky top-0 bg-muted/50">
|
||||
<tr className="border-b">
|
||||
<th className="w-12 px-2 py-2 text-center font-medium text-muted-foreground">순서</th>
|
||||
<th className="w-20 px-2 py-2 text-left font-medium text-muted-foreground">유형</th>
|
||||
<th className="w-24 px-2 py-2 text-left font-medium text-muted-foreground">유형</th>
|
||||
<th className="px-2 py-2 text-left font-medium text-muted-foreground">내용</th>
|
||||
<th className="w-14 px-2 py-2 text-center font-medium text-muted-foreground">필수</th>
|
||||
<th className="w-16 px-2 py-2 text-center font-medium text-muted-foreground">관리</th>
|
||||
@@ -597,7 +597,7 @@ export function WorkStandardEditModal({
|
||||
<tr key={detail.id || idx} className="border-b transition-colors hover:bg-muted/30">
|
||||
<td className="px-2 py-1.5 text-center text-muted-foreground">{idx + 1}</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<Badge variant="outline" className="text-[10px] font-normal">
|
||||
<Badge variant="outline" className="text-[10px] font-normal whitespace-nowrap">
|
||||
{getDetailTypeLabel(detail.detail_type || "checklist")}
|
||||
</Badge>
|
||||
</td>
|
||||
|
||||
@@ -59,6 +59,90 @@ interface SelectedItem {
|
||||
itemCode: string; itemName: string; spec: string; qty: number; remark: string;
|
||||
sourceType: SourceType; sourceTable: string; sourceId: string | number;
|
||||
routing?: string; routingOptions?: RoutingVersionData[];
|
||||
// 품목별 일정/설비/작업조/작업자 (옵션 A — 다중선택 지원)
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
equipmentIds?: string[];
|
||||
workTeams?: string[];
|
||||
workers?: string[];
|
||||
}
|
||||
|
||||
// 공용 다중선택 Popover 컴포넌트 (설비/작업조/작업자에 재사용)
|
||||
interface MultiSelectOption { value: string; label: string; sub?: string; }
|
||||
interface MultiSelectPopoverProps {
|
||||
options: MultiSelectOption[];
|
||||
value: string[];
|
||||
onChange: (next: string[]) => void;
|
||||
placeholder?: string;
|
||||
searchable?: boolean;
|
||||
triggerClassName?: string;
|
||||
emptyMessage?: string;
|
||||
}
|
||||
|
||||
function MultiSelectPopover({ options, value, onChange, placeholder = "선택", searchable = false, triggerClassName, emptyMessage = "항목이 없어요" }: MultiSelectPopoverProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
|
||||
const selectedSet = useMemo(() => new Set(value), [value]);
|
||||
const toggle = (val: string) => {
|
||||
if (selectedSet.has(val)) onChange(value.filter(v => v !== val));
|
||||
else onChange([...value, val]);
|
||||
};
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!searchable || !keyword.trim()) return options;
|
||||
const k = keyword.trim().toLowerCase();
|
||||
return options.filter(o => o.label.toLowerCase().includes(k) || (o.sub || "").toLowerCase().includes(k));
|
||||
}, [options, keyword, searchable]);
|
||||
|
||||
const display = useMemo(() => {
|
||||
if (value.length === 0) return placeholder;
|
||||
if (value.length === 1) return options.find(o => o.value === value[0])?.label || value[0];
|
||||
if (value.length === 2) {
|
||||
const labels = value.map(v => options.find(o => o.value === v)?.label || v);
|
||||
return labels.join(", ");
|
||||
}
|
||||
return `${value.length}개 선택`;
|
||||
}, [value, options, placeholder]);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" role="combobox" aria-expanded={open}
|
||||
className={cn("w-full justify-between font-normal", triggerClassName || "h-7 text-xs")}>
|
||||
<span className={cn("truncate", value.length === 0 && "text-muted-foreground")}>{display}</span>
|
||||
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)", minWidth: 200 }} align="start">
|
||||
{searchable && (
|
||||
<div className="p-2 border-b">
|
||||
<Input placeholder="검색..." value={keyword} onChange={e => setKeyword(e.target.value)} className="h-7 text-xs" />
|
||||
</div>
|
||||
)}
|
||||
<div className="max-h-56 overflow-y-auto py-1">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="py-4 text-center text-xs text-muted-foreground">{emptyMessage}</div>
|
||||
) : filtered.map(opt => (
|
||||
<label
|
||||
key={opt.value}
|
||||
className="flex items-center gap-2 px-2 py-1.5 cursor-pointer hover:bg-muted/50 text-xs"
|
||||
onClick={e => { e.preventDefault(); toggle(opt.value); }}
|
||||
>
|
||||
<Checkbox checked={selectedSet.has(opt.value)} onCheckedChange={() => toggle(opt.value)} className="h-3.5 w-3.5" />
|
||||
<span className="flex-1 truncate">{opt.label}{opt.sub ? <span className="text-muted-foreground ml-1">({opt.sub})</span> : null}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{value.length > 0 && (
|
||||
<div className="p-1.5 border-t flex items-center justify-between">
|
||||
<span className="text-[10px] text-muted-foreground">{value.length}개 선택됨</span>
|
||||
<Button variant="ghost" size="sm" className="h-6 text-[11px] px-2" onClick={() => onChange([])}>초기화</Button>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WorkInstructionPage() {
|
||||
@@ -185,11 +269,15 @@ export default function WorkInstructionPage() {
|
||||
case "order": r = await getWISalesOrderSource(params); break;
|
||||
case "item": r = await getWIItemSource(params); break;
|
||||
}
|
||||
if (r?.success) { setRegSourceData(r.data || []); setRegTotalCount(r.totalCount || 0); }
|
||||
if (r?.success) {
|
||||
// 생산계획 근거는 백엔드에서 applied_qty / remain_qty 포함해 내려옴
|
||||
setRegSourceData(r.data || []);
|
||||
setRegTotalCount(r.totalCount || 0);
|
||||
}
|
||||
} catch {} finally { setRegSourceLoading(false); }
|
||||
}, [regSourceType, regKeyword, regPage, regPageSize]);
|
||||
|
||||
useEffect(() => { if (isRegModalOpen && regSourceType) { setRegPage(1); setRegCheckedIds(new Set()); fetchRegSource(1); } }, [regSourceType]);
|
||||
useEffect(() => { if (isRegModalOpen && regSourceType) { setRegPage(1); setRegCheckedIds(new Set()); fetchRegSource(1); } }, [isRegModalOpen, regSourceType]);
|
||||
|
||||
const getRegId = (item: any) => regSourceType === "item" ? (item.item_code || item.id) : String(item.id);
|
||||
const toggleRegItem = (id: string) => { setRegCheckedIds(prev => { const n = new Set(prev); if (n.has(id)) n.delete(id); else n.add(id); return n; }); };
|
||||
@@ -197,12 +285,23 @@ export default function WorkInstructionPage() {
|
||||
|
||||
const applyRegistration = () => {
|
||||
if (regCheckedIds.size === 0) { alert("품목을 선택해주세요."); return; }
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
const items: SelectedItem[] = [];
|
||||
for (const item of regSourceData) {
|
||||
if (!regCheckedIds.has(getRegId(item))) continue;
|
||||
if (regSourceType === "item") items.push({ itemCode: item.item_code, itemName: item.item_name || "", spec: item.spec || "", qty: 1, remark: "", sourceType: "item", sourceTable: "item_info", sourceId: item.item_code });
|
||||
else if (regSourceType === "order") items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: item.spec || "", qty: Number(item.qty || 1), remark: "", sourceType: "order", sourceTable: "sales_order_detail", sourceId: item.id });
|
||||
else items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: "", qty: Number(item.plan_qty || 1), remark: "", sourceType: "production", sourceTable: "production_plan_mng", sourceId: item.id });
|
||||
const baseExtra = { startDate: today, endDate: "", equipmentIds: [], workTeams: [], workers: [] } as Pick<SelectedItem, "startDate" | "endDate" | "equipmentIds" | "workTeams" | "workers">;
|
||||
if (regSourceType === "item") items.push({ itemCode: item.item_code, itemName: item.item_name || "", spec: item.spec || "", qty: 1, remark: "", sourceType: "item", sourceTable: "item_info", sourceId: item.item_code, ...baseExtra });
|
||||
else if (regSourceType === "order") items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: item.spec || "", qty: Number(item.qty || 1), remark: "", sourceType: "order", sourceTable: "sales_order_detail", sourceId: item.id, ...baseExtra });
|
||||
else {
|
||||
// 생산계획: 잔량(remain_qty)이 있으면 잔량 기반으로 기본 수량 제안 (0/음수 허용 — 계획 초과 가능)
|
||||
const defaultQty = item.remain_qty !== undefined && item.remain_qty !== null
|
||||
? Number(item.remain_qty)
|
||||
: Number(item.plan_qty || 1);
|
||||
// 생산계획: 일정이 있으면 기본값으로 전달
|
||||
const planStart = item.start_date ? String(item.start_date).split("T")[0] : today;
|
||||
const planEnd = item.end_date ? String(item.end_date).split("T")[0] : "";
|
||||
items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: "", qty: defaultQty, remark: "", sourceType: "production", sourceTable: "production_plan_mng", sourceId: item.id, startDate: planStart, endDate: planEnd, equipmentIds: [], workTeams: [], workers: [] });
|
||||
}
|
||||
}
|
||||
|
||||
// 동일품목 합산
|
||||
@@ -250,6 +349,9 @@ export default function WorkInstructionPage() {
|
||||
itemCode: firstItem?.itemCode || "", itemName: firstItem?.itemName || "", spec: firstItem?.spec || "",
|
||||
qty: Number(confirmAddQty), remark: "",
|
||||
sourceType: "item" as SourceType, sourceTable: "item_info", sourceId: firstItem?.itemCode || "",
|
||||
startDate: firstItem?.startDate || new Date().toISOString().split("T")[0],
|
||||
endDate: firstItem?.endDate || "",
|
||||
equipmentIds: [], workTeams: [], workers: [],
|
||||
}]);
|
||||
setConfirmAddQty("");
|
||||
};
|
||||
@@ -259,11 +361,29 @@ export default function WorkInstructionPage() {
|
||||
if (confirmItems.length === 0) { alert("품목이 없습니다."); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
// 헤더 대표값: 첫 번째 품목의 첫 번째 값으로 (하위 호환 유지 — 조회 화면이 헤더값으로 표시되는 레거시 대비)
|
||||
const first = confirmItems[0];
|
||||
const headerStart = first?.startDate || "";
|
||||
const headerEnd = first?.endDate || "";
|
||||
const headerEquipment = first?.equipmentIds?.[0] || "";
|
||||
const headerWorkTeam = first?.workTeams?.[0] || "";
|
||||
const headerWorker = first?.workers?.[0] || "";
|
||||
const payload = {
|
||||
status: confirmStatus, startDate: confirmStartDate, endDate: confirmEndDate,
|
||||
equipmentId: confirmEquipmentId, workTeam: confirmWorkTeam, worker: confirmWorker,
|
||||
status: confirmStatus,
|
||||
startDate: headerStart, endDate: headerEnd,
|
||||
equipmentId: headerEquipment, workTeam: headerWorkTeam, worker: headerWorker,
|
||||
routing: confirmRouting || null,
|
||||
items: confirmItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode, routing: i.routing || null })),
|
||||
items: confirmItems.map(i => ({
|
||||
itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark,
|
||||
sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode,
|
||||
routing: i.routing || null,
|
||||
// 품목별 일정/설비/작업조/작업자 (옵션 A — 다중값 쉼표 구분)
|
||||
startDate: i.startDate || "",
|
||||
endDate: i.endDate || "",
|
||||
equipmentIds: (i.equipmentIds || []).join(","),
|
||||
workTeams: (i.workTeams || []).join(","),
|
||||
workers: (i.workers || []).join(","),
|
||||
})),
|
||||
};
|
||||
const r = await saveWorkInstruction(payload);
|
||||
if (r.success) { setIsConfirmModalOpen(false); fetchOrders(); alert("작업지시가 등록되었습니다."); }
|
||||
@@ -286,6 +406,12 @@ export default function WorkInstructionPage() {
|
||||
sourceTable: d.source_table || "item_info", sourceId: d.source_id || "",
|
||||
routing: d.detail_routing_version_id || order.routing_version_id || "",
|
||||
routingOptions: [],
|
||||
// 품목별 일정/설비/작업조/작업자 (detail 값 우선, 없으면 헤더값 폴백)
|
||||
startDate: d.detail_start_date || d.start_date || "",
|
||||
endDate: d.detail_end_date || d.end_date || "",
|
||||
equipmentIds: (d.detail_equipment_ids || "").split(",").filter(Boolean),
|
||||
workTeams: (d.detail_work_teams || "").split(",").filter(Boolean),
|
||||
workers: (d.detail_workers || "").split(",").filter(Boolean),
|
||||
}));
|
||||
setEditItems(items);
|
||||
setAddQty(""); setAddEquipment(""); setAddWorkTeam(""); setAddWorker("");
|
||||
@@ -316,9 +442,13 @@ export default function WorkInstructionPage() {
|
||||
|
||||
const addEditItem = () => {
|
||||
if (!addQty || Number(addQty) <= 0) { alert("수량을 입력해주세요."); return; }
|
||||
const firstItem = editItems[0];
|
||||
setEditItems(prev => [...prev, {
|
||||
itemCode: editOrder?.item_number || "", itemName: editOrder?.item_name || "", spec: editOrder?.item_spec || "",
|
||||
qty: Number(addQty), remark: "", sourceType: "item", sourceTable: "item_info", sourceId: editOrder?.item_number || "",
|
||||
startDate: firstItem?.startDate || editStartDate || "",
|
||||
endDate: firstItem?.endDate || editEndDate || "",
|
||||
equipmentIds: [], workTeams: [], workers: [],
|
||||
}]);
|
||||
setAddQty("");
|
||||
};
|
||||
@@ -327,11 +457,30 @@ export default function WorkInstructionPage() {
|
||||
if (!editOrder || editItems.length === 0) { alert("품목이 없습니다."); return; }
|
||||
setEditSaving(true);
|
||||
try {
|
||||
// 헤더 대표값: 첫 번째 품목의 첫 번째 값 사용 (하위 호환 — 등록 모달과 동일 패턴)
|
||||
const first = editItems[0];
|
||||
const headerStart = first?.startDate || editStartDate || "";
|
||||
const headerEnd = first?.endDate || editEndDate || "";
|
||||
const headerEquipment = first?.equipmentIds?.[0] || editEquipmentId || "";
|
||||
const headerWorkTeam = first?.workTeams?.[0] || editWorkTeam || "";
|
||||
const headerWorker = first?.workers?.[0] || editWorker || "";
|
||||
const payload = {
|
||||
id: editOrder.wi_id, status: editStatus, startDate: editStartDate, endDate: editEndDate,
|
||||
equipmentId: editEquipmentId, workTeam: editWorkTeam, worker: editWorker, remark: editRemark,
|
||||
id: editOrder.wi_id, status: editStatus,
|
||||
startDate: headerStart, endDate: headerEnd,
|
||||
equipmentId: headerEquipment, workTeam: headerWorkTeam, worker: headerWorker,
|
||||
remark: editRemark,
|
||||
routing: editRouting || null,
|
||||
items: editItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode, routing: i.routing || null })),
|
||||
items: editItems.map(i => ({
|
||||
itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark,
|
||||
sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode,
|
||||
routing: i.routing || null,
|
||||
// 품목별 일정/설비/작업조/작업자 (다중값 쉼표 구분 — 등록 모달과 동일)
|
||||
startDate: i.startDate || "",
|
||||
endDate: i.endDate || "",
|
||||
equipmentIds: (i.equipmentIds || []).join(","),
|
||||
workTeams: (i.workTeams || []).join(","),
|
||||
workers: (i.workers || []).join(","),
|
||||
})),
|
||||
};
|
||||
const r = await saveWorkInstruction(payload);
|
||||
if (r.success) { setIsEditModalOpen(false); fetchOrders(); alert("수정되었습니다."); }
|
||||
@@ -578,7 +727,7 @@ export default function WorkInstructionPage() {
|
||||
<TableHead className="w-[50px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground"><Checkbox checked={regSourceData.length > 0 && regCheckedIds.size === regSourceData.length} onCheckedChange={toggleRegAll} /></TableHead>
|
||||
{regSourceType === "item" && <><TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목코드</TableHead><TableHead>품목명</TableHead><TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">규격</TableHead></>}
|
||||
{regSourceType === "order" && <><TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">수주번호</TableHead><TableHead className="w-[100px]">품번</TableHead><TableHead>품목명</TableHead><TableHead className="w-[100px]">규격</TableHead><TableHead className="w-[80px] text-right">수량</TableHead><TableHead className="w-[100px]">납기일</TableHead></>}
|
||||
{regSourceType === "production" && <><TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">계획번호</TableHead><TableHead className="w-[100px]">품번</TableHead><TableHead>품목명</TableHead><TableHead className="w-[80px] text-right">계획수량</TableHead><TableHead className="w-[90px]">시작일</TableHead><TableHead className="w-[90px]">완료일</TableHead><TableHead className="w-[100px]">설비</TableHead></>}
|
||||
{regSourceType === "production" && <><TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">계획번호</TableHead><TableHead className="w-[100px]">품번</TableHead><TableHead>품목명</TableHead><TableHead className="w-[80px] text-right">계획수량</TableHead><TableHead className="w-[80px] text-right">적용수량</TableHead><TableHead className="w-[80px] text-right">잔량</TableHead><TableHead className="w-[90px]">시작일</TableHead><TableHead className="w-[90px]">완료일</TableHead><TableHead className="w-[100px]">설비</TableHead></>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -590,7 +739,7 @@ export default function WorkInstructionPage() {
|
||||
<TableCell className="text-center" onClick={e => e.stopPropagation()}><Checkbox checked={checked} onCheckedChange={() => toggleRegItem(id)} /></TableCell>
|
||||
{regSourceType === "item" && <><TableCell className="text-[13px] font-medium">{item.item_code}</TableCell><TableCell className="text-sm">{item.item_name}</TableCell><TableCell className="text-[13px]">{item.spec || "-"}</TableCell></>}
|
||||
{regSourceType === "order" && <><TableCell className="text-[13px]">{item.order_no}</TableCell><TableCell className="text-[13px]">{item.item_code}</TableCell><TableCell className="text-sm">{item.item_name}</TableCell><TableCell className="text-[13px]">{item.spec || "-"}</TableCell><TableCell className="text-right text-[13px]">{Number(item.qty || 0).toLocaleString()}</TableCell><TableCell className="text-[13px]">{item.due_date || "-"}</TableCell></>}
|
||||
{regSourceType === "production" && <><TableCell className="text-[13px]">{item.plan_no}</TableCell><TableCell className="text-[13px]">{item.item_code}</TableCell><TableCell className="text-sm">{item.item_name}</TableCell><TableCell className="text-right text-[13px]">{Number(item.plan_qty || 0).toLocaleString()}</TableCell><TableCell className="text-[13px]">{item.start_date ? String(item.start_date).split("T")[0] : "-"}</TableCell><TableCell className="text-[13px]">{item.end_date ? String(item.end_date).split("T")[0] : "-"}</TableCell><TableCell className="text-[13px]">{item.equipment_name || "-"}</TableCell></>}
|
||||
{regSourceType === "production" && <><TableCell className="text-[13px]">{item.plan_no}</TableCell><TableCell className="text-[13px]">{item.item_code}</TableCell><TableCell className="text-sm">{item.item_name}</TableCell><TableCell className="text-right text-[13px]">{Number(item.plan_qty || 0).toLocaleString()}</TableCell><TableCell className="text-right text-[13px] text-muted-foreground">{Number(item.applied_qty || 0).toLocaleString()}</TableCell><TableCell className={cn("text-right text-[13px] font-semibold", Number(item.remain_qty ?? item.plan_qty ?? 0) < 0 && "text-destructive")}>{Number(item.remain_qty ?? item.plan_qty ?? 0).toLocaleString()}</TableCell><TableCell className="text-[13px]">{item.start_date ? String(item.start_date).split("T")[0] : "-"}</TableCell><TableCell className="text-[13px]">{item.end_date ? String(item.end_date).split("T")[0] : "-"}</TableCell><TableCell className="text-[13px]">{item.equipment_name || "-"}</TableCell></>}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
@@ -619,7 +768,7 @@ export default function WorkInstructionPage() {
|
||||
|
||||
{/* ── 2단계: 확인 모달 ── */}
|
||||
<Dialog open={isConfirmModalOpen} onOpenChange={setIsConfirmModalOpen}>
|
||||
<DialogContent className="max-w-[1100px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden">
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[1500px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>작업지시 적용 확인</DialogTitle>
|
||||
<DialogDescription>기본 정보를 입력하고 '최종 적용' 버튼을 눌러주세요.</DialogDescription>
|
||||
@@ -628,38 +777,33 @@ export default function WorkInstructionPage() {
|
||||
<div className="space-y-5">
|
||||
<div className="bg-muted/30 border rounded-lg p-5">
|
||||
<h4 className="text-[13px] font-bold pb-2 border-b text-foreground">작업지시 기본 정보</h4>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-3">
|
||||
<p className="text-[11px] text-muted-foreground mt-2">시작일·완료예정일·설비·작업조·작업자는 아래 품목별로 지정해주세요.</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-3">
|
||||
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground">작업지시번호</Label><Input value={confirmWiNo} readOnly className="h-9 bg-muted cursor-not-allowed font-mono" /></div>
|
||||
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground">상태</Label>
|
||||
<Select value={confirmStatus} onValueChange={setConfirmStatus}><SelectTrigger className="h-9"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="일반">일반</SelectItem><SelectItem value="긴급">긴급</SelectItem></SelectContent></Select>
|
||||
</div>
|
||||
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground">시작일</Label><Input type="date" value={confirmStartDate} onChange={(e) => setConfirmStartDate(e.target.value)} className="h-9" /></div>
|
||||
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground">완료예정일</Label><Input type="date" value={confirmEndDate} onChange={(e) => setConfirmEndDate(e.target.value)} className="h-9" /></div>
|
||||
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground">설비</Label>
|
||||
<Select value={nv(confirmEquipmentId)} onValueChange={v => setConfirmEquipmentId(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="설비 선택" /></SelectTrigger><SelectContent><SelectItem value="none">선택 안 함</SelectItem>{equipmentOptions.map(eq => <SelectItem key={eq.id} value={eq.id}>{eq.equipment_name}</SelectItem>)}</SelectContent></Select>
|
||||
</div>
|
||||
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground">작업조</Label>
|
||||
<Select value={nv(confirmWorkTeam)} onValueChange={v => setConfirmWorkTeam(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="작업조" /></SelectTrigger><SelectContent><SelectItem value="none">선택 안 함</SelectItem><SelectItem value="주간">주간</SelectItem><SelectItem value="야간">야간</SelectItem></SelectContent></Select>
|
||||
</div>
|
||||
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground">작업자</Label>
|
||||
<WorkerCombobox value={confirmWorker} onChange={setConfirmWorker} open={confirmWorkerOpen} onOpenChange={setConfirmWorkerOpen} />
|
||||
</div>
|
||||
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground">비고</Label><Input className="h-9" placeholder="비고를 입력해주세요" /></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border rounded-lg p-5">
|
||||
<h4 className="text-[13px] font-bold pb-2 border-b text-foreground mb-3">품목 목록</h4>
|
||||
<div className="overflow-auto">
|
||||
<Table>
|
||||
<Table className="min-w-[1500px]">
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[50px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">순번</TableHead>
|
||||
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목코드</TableHead>
|
||||
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목명</TableHead>
|
||||
<TableHead className="w-[140px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목명</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">규격</TableHead>
|
||||
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">수량</TableHead>
|
||||
<TableHead className="w-[180px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">라우팅</TableHead>
|
||||
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">비고</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">수량</TableHead>
|
||||
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">라우팅</TableHead>
|
||||
<TableHead className="w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">시작일</TableHead>
|
||||
<TableHead className="w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">완료예정일</TableHead>
|
||||
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">설비</TableHead>
|
||||
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">작업조</TableHead>
|
||||
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">작업자</TableHead>
|
||||
<TableHead className="w-[140px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">비고</TableHead>
|
||||
<TableHead className="w-[40px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -668,7 +812,7 @@ export default function WorkInstructionPage() {
|
||||
<TableRow key={idx}>
|
||||
<TableCell className="text-[13px] text-center">{idx + 1}</TableCell>
|
||||
<TableCell className="text-[13px] font-medium">{item.itemCode}</TableCell>
|
||||
<TableCell className="text-sm">{item.itemName || item.itemCode}</TableCell>
|
||||
<TableCell className="text-sm truncate max-w-[140px]" title={item.itemName || item.itemCode}>{item.itemName || item.itemCode}</TableCell>
|
||||
<TableCell className="text-[13px]">{item.spec || "-"}</TableCell>
|
||||
<TableCell><Input type="number" className="h-7 text-[13px] w-20" value={item.qty} onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /></TableCell>
|
||||
<TableCell>
|
||||
@@ -690,6 +834,40 @@ export default function WorkInstructionPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input type="date" className="h-7 text-[13px]" value={item.startDate || ""} onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, startDate: e.target.value } : it))} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input type="date" className="h-7 text-[13px]" value={item.endDate || ""} onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, endDate: e.target.value } : it))} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<MultiSelectPopover
|
||||
options={equipmentOptions.map(eq => ({ value: eq.id, label: eq.equipment_name || eq.equipment_code, sub: eq.equipment_code }))}
|
||||
value={item.equipmentIds || []}
|
||||
onChange={next => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, equipmentIds: next } : it))}
|
||||
placeholder="설비 선택"
|
||||
searchable
|
||||
emptyMessage="설비가 없어요"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<MultiSelectPopover
|
||||
options={[{ value: "주간", label: "주간" }, { value: "야간", label: "야간" }]}
|
||||
value={item.workTeams || []}
|
||||
onChange={next => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, workTeams: next } : it))}
|
||||
placeholder="작업조 선택"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<MultiSelectPopover
|
||||
options={employeeOptions.map(emp => ({ value: emp.user_id, label: emp.user_name, sub: emp.dept_name || undefined }))}
|
||||
value={item.workers || []}
|
||||
onChange={next => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, workers: next } : it))}
|
||||
placeholder="작업자 선택"
|
||||
searchable
|
||||
emptyMessage="사원을 찾을 수 없어요"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell><Input className="h-7 text-[13px]" value={item.remark} placeholder="비고" onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /></TableCell>
|
||||
<TableCell><Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setConfirmItems(prev => prev.filter((_, i) => i !== idx))}><X className="w-3 h-3 text-destructive" /></Button></TableCell>
|
||||
</TableRow>
|
||||
@@ -710,7 +888,7 @@ export default function WorkInstructionPage() {
|
||||
|
||||
{/* ── 수정 모달 ── */}
|
||||
<Dialog open={isEditModalOpen} onOpenChange={(v) => { if (!v && wsModalOpen) return; setIsEditModalOpen(v); }}>
|
||||
<DialogContent className="max-w-[1100px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden">
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[1500px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{`작업지시 관리 - ${editOrder?.work_instruction_no || ""}`}</DialogTitle>
|
||||
<DialogDescription>품목을 추가/삭제하고 정보를 수정해주세요.</DialogDescription>
|
||||
@@ -719,48 +897,47 @@ export default function WorkInstructionPage() {
|
||||
<div className="space-y-5">
|
||||
<div className="bg-muted/30 border rounded-lg p-5">
|
||||
<h4 className="text-[13px] font-bold pb-2 border-b text-foreground">기본 정보</h4>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-3">
|
||||
<p className="text-[11px] text-muted-foreground mt-2">시작일·완료예정일·설비·작업조·작업자는 아래 품목별로 지정해주세요.</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-3">
|
||||
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground">상태</Label><Select value={editStatus} onValueChange={setEditStatus}><SelectTrigger className="h-9"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="일반">일반</SelectItem><SelectItem value="긴급">긴급</SelectItem></SelectContent></Select></div>
|
||||
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground">시작일</Label><Input type="date" value={editStartDate} onChange={(e) => setEditStartDate(e.target.value)} className="h-9" /></div>
|
||||
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground">완료예정일</Label><Input type="date" value={editEndDate} onChange={(e) => setEditEndDate(e.target.value)} className="h-9" /></div>
|
||||
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground">설비</Label><Select value={nv(editEquipmentId)} onValueChange={v => setEditEquipmentId(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="설비 선택" /></SelectTrigger><SelectContent><SelectItem value="none">선택 안 함</SelectItem>{equipmentOptions.map(eq => <SelectItem key={eq.id} value={eq.id}>{eq.equipment_name}</SelectItem>)}</SelectContent></Select></div>
|
||||
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground">작업조</Label><Select value={nv(editWorkTeam)} onValueChange={v => setEditWorkTeam(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="작업조" /></SelectTrigger><SelectContent><SelectItem value="none">선택 안 함</SelectItem><SelectItem value="주간">주간</SelectItem><SelectItem value="야간">야간</SelectItem></SelectContent></Select></div>
|
||||
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground">작업자</Label>
|
||||
<WorkerCombobox value={editWorker} onChange={setEditWorker} open={editWorkerOpen} onOpenChange={setEditWorkerOpen} />
|
||||
</div>
|
||||
<div className="space-y-1 col-span-2"><Label className="text-[11px] font-semibold text-muted-foreground">비고</Label><Input value={editRemark} onChange={e => setEditRemark(e.target.value)} className="h-9" placeholder="비고를 입력해주세요" /></div>
|
||||
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground">비고</Label><Input value={editRemark} onChange={e => setEditRemark(e.target.value)} className="h-9" placeholder="비고를 입력해주세요" /></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 품목 테이블 — 라우팅/공정작업기준을 품목별로 표시 */}
|
||||
{/* 품목 테이블 — 품목별 일정/설비/작업조/작업자 + 라우팅/공정작업기준 */}
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 bg-muted/30 border-b">
|
||||
<span className="text-[13px] font-bold text-foreground">작업지시 항목</span>
|
||||
<span className="text-[11px] font-semibold text-primary bg-primary/8 border border-primary/15 px-2 py-0.5 rounded-full font-mono">{editItems.length}건</span>
|
||||
</div>
|
||||
<div className="overflow-auto">
|
||||
<Table>
|
||||
<Table className="min-w-[1500px]">
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[50px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">순번</TableHead>
|
||||
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목코드</TableHead>
|
||||
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목명</TableHead>
|
||||
<TableHead className="w-[140px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목명</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">규격</TableHead>
|
||||
<TableHead className="w-[90px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">수량</TableHead>
|
||||
<TableHead className="w-[180px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">라우팅</TableHead>
|
||||
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">공정작업기준</TableHead>
|
||||
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">비고</TableHead>
|
||||
<TableHead className="w-[80px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">수량</TableHead>
|
||||
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">라우팅</TableHead>
|
||||
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">공정작업기준</TableHead>
|
||||
<TableHead className="w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">시작일</TableHead>
|
||||
<TableHead className="w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">완료예정일</TableHead>
|
||||
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">설비</TableHead>
|
||||
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">작업조</TableHead>
|
||||
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">작업자</TableHead>
|
||||
<TableHead className="w-[140px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">비고</TableHead>
|
||||
<TableHead className="w-[40px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{editItems.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={9} className="text-center py-8 text-sm text-muted-foreground">등록된 품목이 없어요</TableCell></TableRow>
|
||||
<TableRow><TableCell colSpan={14} className="text-center py-8 text-sm text-muted-foreground">등록된 품목이 없어요</TableCell></TableRow>
|
||||
) : editItems.map((item, idx) => (
|
||||
<TableRow key={idx}>
|
||||
<TableCell className="text-[13px] text-center">{idx + 1}</TableCell>
|
||||
<TableCell className="text-[13px] font-medium">{item.itemCode}</TableCell>
|
||||
<TableCell className="text-[13px] max-w-[150px] truncate" title={item.itemName}>{item.itemName || "-"}</TableCell>
|
||||
<TableCell className="text-sm truncate max-w-[140px]" title={item.itemName}>{item.itemName || "-"}</TableCell>
|
||||
<TableCell className="text-[13px] truncate" title={item.spec}>{item.spec || "-"}</TableCell>
|
||||
<TableCell className="text-right"><Input type="number" className="h-7 text-[13px] w-20 ml-auto" value={item.qty} onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /></TableCell>
|
||||
<TableCell>
|
||||
@@ -803,6 +980,40 @@ export default function WorkInstructionPage() {
|
||||
<ClipboardCheck className="w-3 h-3 mr-1" /> 수정
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input type="date" className="h-7 text-[13px]" value={item.startDate || ""} onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, startDate: e.target.value } : it))} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input type="date" className="h-7 text-[13px]" value={item.endDate || ""} onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, endDate: e.target.value } : it))} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<MultiSelectPopover
|
||||
options={equipmentOptions.map(eq => ({ value: eq.id, label: eq.equipment_name || eq.equipment_code, sub: eq.equipment_code }))}
|
||||
value={item.equipmentIds || []}
|
||||
onChange={next => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, equipmentIds: next } : it))}
|
||||
placeholder="설비 선택"
|
||||
searchable
|
||||
emptyMessage="설비가 없어요"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<MultiSelectPopover
|
||||
options={[{ value: "주간", label: "주간" }, { value: "야간", label: "야간" }]}
|
||||
value={item.workTeams || []}
|
||||
onChange={next => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, workTeams: next } : it))}
|
||||
placeholder="작업조 선택"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<MultiSelectPopover
|
||||
options={employeeOptions.map(emp => ({ value: emp.user_id, label: emp.user_name, sub: emp.dept_name || undefined }))}
|
||||
value={item.workers || []}
|
||||
onChange={next => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, workers: next } : it))}
|
||||
placeholder="작업자 선택"
|
||||
searchable
|
||||
emptyMessage="사원을 찾을 수 없어요"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell><Input className="h-7 text-[13px]" value={item.remark} placeholder="비고" onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /></TableCell>
|
||||
<TableCell><Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setEditItems(prev => prev.filter((_, i) => i !== idx))}><X className="w-3 h-3 text-destructive" /></Button></TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -30,6 +30,7 @@ import { toast } from "sonner";
|
||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { SmartSelect } from "@/components/common/SmartSelect";
|
||||
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||
|
||||
const MASTER_TABLE = "purchase_order_mng";
|
||||
@@ -237,7 +238,7 @@ export default function PurchaseOrderPage() {
|
||||
);
|
||||
try {
|
||||
const suppRes = await apiClient.post(`/table-management/tables/supplier_mng/data`, {
|
||||
page: 1, size: 5000, autoFilter: true,
|
||||
page: 1, size: 0, autoFilter: true,
|
||||
});
|
||||
const supps = suppRes.data?.data?.data || suppRes.data?.data?.rows || [];
|
||||
optMap["supplier_code"] = supps.map((s: any) => ({
|
||||
@@ -247,7 +248,7 @@ export default function PurchaseOrderPage() {
|
||||
} catch { /* skip */ }
|
||||
try {
|
||||
const userRes = await apiClient.post(`/table-management/tables/user_info/data`, {
|
||||
page: 1, size: 5000, autoFilter: true,
|
||||
page: 1, size: 0, autoFilter: true,
|
||||
});
|
||||
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
|
||||
optMap["manager"] = users.map((u: any) => ({
|
||||
@@ -293,7 +294,7 @@ export default function PurchaseOrderPage() {
|
||||
}
|
||||
|
||||
const res = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
|
||||
page: 1, size: 5000,
|
||||
page: 1, size: 0,
|
||||
dataFilter: detailFilters.length > 0 ? { enabled: true, filters: detailFilters } : undefined,
|
||||
autoFilter: true,
|
||||
sort: { columnName: "purchase_no", order: "desc" },
|
||||
@@ -555,6 +556,50 @@ export default function PurchaseOrderPage() {
|
||||
if (divLabel) divValues.push(divLabel);
|
||||
filters.push({ columnName: "division", operator: "in", value: divValues });
|
||||
}
|
||||
|
||||
// 공급업체 선택 시 supplier_item_mapping으로 매핑 id 정규화 → 서버 필터 적용
|
||||
const supplierCode = masterForm.supplier_code;
|
||||
if (supplierCode) {
|
||||
try {
|
||||
const mappingRes = await apiClient.post(`/table-management/tables/supplier_item_mapping/data`, {
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "supplier_id", operator: "equals", value: supplierCode }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const mappings = mappingRes.data?.data?.data || mappingRes.data?.data?.rows || [];
|
||||
const rawIds = [...new Set(mappings.map((m: any) => m.item_id).filter(Boolean))] as string[];
|
||||
if (rawIds.length === 0) {
|
||||
setItemSearchResults([]); setItemTotal(0); setItemTotalPages(1);
|
||||
setItemSearchLoading(false);
|
||||
return;
|
||||
}
|
||||
// UUID와 문자열(item_number) 분리
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const uuidIds = rawIds.filter((v) => uuidRegex.test(v));
|
||||
const codeIds = rawIds.filter((v) => !uuidRegex.test(v));
|
||||
|
||||
// 문자열(item_number)을 item_info에서 id로 변환
|
||||
let convertedIds: string[] = [];
|
||||
if (codeIds.length > 0) {
|
||||
const convRes = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||||
page: 1, size: codeIds.length + 10,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: codeIds }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const convRows = convRes.data?.data?.data || convRes.data?.data?.rows || [];
|
||||
convertedIds = convRows.map((r: any) => r.id).filter(Boolean);
|
||||
}
|
||||
|
||||
const finalIds = [...new Set([...uuidIds, ...convertedIds])];
|
||||
if (finalIds.length === 0) {
|
||||
setItemSearchResults([]); setItemTotal(0); setItemTotalPages(1);
|
||||
setItemSearchLoading(false);
|
||||
return;
|
||||
}
|
||||
filters.push({ columnName: "id", operator: "in", value: finalIds });
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
|
||||
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||||
page: p, size: s,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
@@ -607,7 +652,7 @@ export default function PurchaseOrderPage() {
|
||||
try {
|
||||
const itemIds = selected.map((item) => item.item_number || item.id);
|
||||
const res = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, {
|
||||
page: 1, size: 5000,
|
||||
page: 1, size: 0,
|
||||
dataFilter: {
|
||||
enabled: true,
|
||||
filters: [
|
||||
@@ -670,7 +715,7 @@ export default function PurchaseOrderPage() {
|
||||
if (itemCodes.length === 0) return;
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||||
page: 1, size: 5000,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: itemCodes }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
@@ -692,7 +737,7 @@ export default function PurchaseOrderPage() {
|
||||
if (itemCodes.length === 0) return;
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, {
|
||||
page: 1, size: 5000,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "supplier_id", operator: "equals", value: supplierCode },
|
||||
{ columnName: "item_id", operator: "in", value: itemCodes },
|
||||
@@ -984,7 +1029,8 @@ export default function PurchaseOrderPage() {
|
||||
<div className="grid grid-cols-2 gap-3.5">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground tracking-wide">공급업체</Label>
|
||||
<Select
|
||||
<SmartSelect
|
||||
options={categoryOptions["supplier_code"] || []}
|
||||
value={masterForm.supplier_code || ""}
|
||||
onValueChange={(v) => {
|
||||
const supp = categoryOptions["supplier_code"]?.find((o) => o.code === v);
|
||||
@@ -992,15 +1038,9 @@ export default function PurchaseOrderPage() {
|
||||
setMasterForm((p) => ({ ...p, supplier_code: v, supplier_name: name }));
|
||||
recalcPrices(masterForm.price_mode || "", v);
|
||||
}}
|
||||
placeholder="공급업체 선택"
|
||||
disabled={isReadOnly}
|
||||
>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="공급업체 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(categoryOptions["supplier_code"] || []).map((o) => (
|
||||
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -312,6 +312,11 @@ export default function PurchaseItemPage() {
|
||||
|
||||
// 좌측: 품목 조회
|
||||
const fetchItems = useCallback(async () => {
|
||||
// 카테고리 로드 완료 전엔 대기 — 먼저 나간 unfiltered 요청이 나중에 도착해
|
||||
// filtered 결과를 덮어쓰는 race condition 방지
|
||||
if (!categoryOptions["division"]?.length) {
|
||||
return;
|
||||
}
|
||||
setItemLoading(true);
|
||||
try {
|
||||
const filters: { columnName: string; operator: string; value: any }[] = [];
|
||||
@@ -328,7 +333,7 @@ export default function PurchaseItemPage() {
|
||||
}
|
||||
|
||||
const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
|
||||
page: 1, size: 5000,
|
||||
page: 1, size: 0,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
@@ -619,7 +624,7 @@ export default function PurchaseItemPage() {
|
||||
try {
|
||||
// 1. supplier_item_mapping에서 해당 품목의 매핑 조회
|
||||
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_id", operator: "equals", value: itemKey }] },
|
||||
autoFilter: true,
|
||||
sort: { columnName: "created_date", order: "asc" },
|
||||
@@ -647,7 +652,7 @@ export default function PurchaseItemPage() {
|
||||
if (mappings.length > 0) {
|
||||
try {
|
||||
const priceRes = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "item_id", operator: "equals", value: itemKey },
|
||||
]},
|
||||
@@ -1104,7 +1109,7 @@ export default function PurchaseItemPage() {
|
||||
for (const suppCode of supplierCodes) {
|
||||
// 해당 공급업체의 모든 매핑 조회 → item_id null 처리
|
||||
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "item_id", operator: "equals", value: selectedItem!.item_number },
|
||||
{ columnName: "supplier_id", operator: "equals", value: suppCode },
|
||||
@@ -1121,7 +1126,7 @@ export default function PurchaseItemPage() {
|
||||
// 해당 공급업체의 모든 단가 조회 → item_id null 처리
|
||||
try {
|
||||
const priceRes = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "item_id", operator: "equals", value: selectedItem!.item_number },
|
||||
{ columnName: "supplier_id", operator: "equals", value: suppCode },
|
||||
|
||||
@@ -224,7 +224,7 @@ export default function SupplierManagementPage() {
|
||||
} catch { /* skip */ }
|
||||
};
|
||||
load();
|
||||
apiClient.post(`/table-management/tables/user_info/data`, { page: 1, size: 500, autoFilter: true })
|
||||
apiClient.post(`/table-management/tables/user_info/data`, { page: 1, size: 0, autoFilter: true })
|
||||
.then((res) => {
|
||||
const users = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
setEmployeeOptions(users.map((u: any) => ({
|
||||
@@ -244,7 +244,7 @@ export default function SupplierManagementPage() {
|
||||
}));
|
||||
|
||||
const res = await apiClient.post(`/table-management/tables/${SUPPLIER_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
sort: { columnName: "supplier_code", order: "desc" },
|
||||
@@ -289,7 +289,7 @@ export default function SupplierManagementPage() {
|
||||
const fetchMainContacts = useCallback(async () => {
|
||||
try {
|
||||
const contactRes = await apiClient.post(`/table-management/tables/${CONTACT_TABLE}/data`, {
|
||||
page: 1, size: 500, autoFilter: true,
|
||||
page: 1, size: 0, autoFilter: true,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "is_main", operator: "equals", value: "Y" }] },
|
||||
});
|
||||
const allContacts = contactRes.data?.data?.data || contactRes.data?.data?.rows || [];
|
||||
@@ -315,7 +315,7 @@ export default function SupplierManagementPage() {
|
||||
setPriceLoading(true);
|
||||
try {
|
||||
const mappingRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "supplier_id", operator: "equals", value: selectedSupplier.supplier_code },
|
||||
]},
|
||||
@@ -342,7 +342,7 @@ export default function SupplierManagementPage() {
|
||||
if (mappings.length > 0) {
|
||||
try {
|
||||
const priceRes = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "supplier_id", operator: "equals", value: selectedSupplier.supplier_code },
|
||||
]},
|
||||
@@ -417,7 +417,7 @@ export default function SupplierManagementPage() {
|
||||
setDeliveryLoading(true);
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/${DELIVERY_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "supplier_code", operator: "equals", value: selectedSupplier.supplier_code },
|
||||
]},
|
||||
@@ -497,7 +497,7 @@ export default function SupplierManagementPage() {
|
||||
if (ruleData?.success && ruleData?.data?.ruleId) {
|
||||
const ruleId = ruleData.data.ruleId;
|
||||
const allRes = await apiClient.post(`/table-management/tables/${DELIVERY_TABLE}/data`, {
|
||||
page: 1, size: 500, autoFilter: true,
|
||||
page: 1, size: 0, autoFilter: true,
|
||||
sort: { columnName: "destination_code", order: "desc" },
|
||||
});
|
||||
const allRows = allRes.data?.data?.data || allRes.data?.data?.rows || [];
|
||||
@@ -570,7 +570,7 @@ export default function SupplierManagementPage() {
|
||||
const ruleId = ruleData.data.ruleId;
|
||||
// 기존 데이터에서 CUST-XXX 패턴의 최대 순번 조회
|
||||
const allRes = await apiClient.post(`/table-management/tables/${SUPPLIER_TABLE}/data`, {
|
||||
page: 1, size: 500, autoFilter: true,
|
||||
page: 1, size: 0, autoFilter: true,
|
||||
sort: { columnName: "supplier_code", order: "desc" },
|
||||
});
|
||||
const allRows = allRes.data?.data?.data || allRes.data?.data?.rows || [];
|
||||
@@ -771,7 +771,7 @@ export default function SupplierManagementPage() {
|
||||
const ruleData = ruleRes.data;
|
||||
if (ruleData?.success && ruleData?.data?.ruleId) {
|
||||
const ruleId = ruleData.data.ruleId;
|
||||
const allRes = await apiClient.post(`/table-management/tables/${SUPPLIER_TABLE}/data`, { page: 1, size: 500, autoFilter: true, sort: { columnName: "supplier_code", order: "desc" } });
|
||||
const allRes = await apiClient.post(`/table-management/tables/${SUPPLIER_TABLE}/data`, { page: 1, size: 0, autoFilter: true, sort: { columnName: "supplier_code", order: "desc" } });
|
||||
const allRows = allRes.data?.data?.data || allRes.data?.data?.rows || [];
|
||||
let maxSeq = 0;
|
||||
for (const row of allRows) { const match = (row.supplier_code || "").match(/(\d+)$/); if (match) { const seq = parseInt(match[1], 10); if (seq > maxSeq) maxSeq = seq; } }
|
||||
@@ -824,7 +824,7 @@ export default function SupplierManagementPage() {
|
||||
const filters: any[] = [];
|
||||
if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
|
||||
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||||
page: 1, size: 5000,
|
||||
page: 1, size: 0,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
@@ -1232,7 +1232,7 @@ export default function SupplierManagementPage() {
|
||||
for (const itemId of itemIds) {
|
||||
// 해당 품목의 모든 매핑 조회 → supplier_id null 처리
|
||||
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "supplier_id", operator: "equals", value: selectedSupplier!.supplier_code },
|
||||
{ columnName: "item_id", operator: "equals", value: itemId },
|
||||
@@ -1249,7 +1249,7 @@ export default function SupplierManagementPage() {
|
||||
// 해당 품목의 모든 단가 조회 → supplier_id null 처리
|
||||
try {
|
||||
const priceRes = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "supplier_id", operator: "equals", value: selectedSupplier!.supplier_code },
|
||||
{ columnName: "item_id", operator: "equals", value: itemId },
|
||||
@@ -1324,7 +1324,7 @@ export default function SupplierManagementPage() {
|
||||
try {
|
||||
const allMappings: any[] = [];
|
||||
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
|
||||
page: 1, size: 5000, autoFilter: true,
|
||||
page: 1, size: 0, autoFilter: true,
|
||||
});
|
||||
const mappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
|
||||
const itemIds = [...new Set(mappings.map((m: any) => m.item_id).filter(Boolean))];
|
||||
|
||||
@@ -101,7 +101,7 @@ export default function InspectionResultPage() {
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
|
||||
page: 1,
|
||||
size: 500,
|
||||
size: 0,
|
||||
autoFilter: true,
|
||||
search: { master_id: masterId },
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Di
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||
import {
|
||||
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ClipboardList,
|
||||
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, FileSpreadsheet,
|
||||
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, FileSpreadsheet, Copy,
|
||||
} from "lucide-react";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -43,6 +43,7 @@ type InspectionRow = {
|
||||
inspection_detail: string;
|
||||
inspection_method: string;
|
||||
apply_process: string;
|
||||
classification: string;
|
||||
acceptance_criteria: string;
|
||||
is_required: boolean;
|
||||
judgment_criteria?: string; // 판단기준 라벨 (수치(범위)/텍스트입력/O·X/선택형)
|
||||
@@ -98,6 +99,11 @@ export default function ItemInspectionInfoPage() {
|
||||
const [inspectionRows, setInspectionRows] = useState<Record<string, InspectionRow[]>>({});
|
||||
const [collapsedTypes, setCollapsedTypes] = useState<Record<string, boolean>>({});
|
||||
|
||||
// 복사 모달: 편집 가능한 기준 데이터 상태 (등록/수정 폼과 평행 구조)
|
||||
const [copyForm, setCopyForm] = useState<Record<string, any>>({});
|
||||
const [copyInspectionRows, setCopyInspectionRows] = useState<Record<string, InspectionRow[]>>({});
|
||||
const [copyCollapsedTypes, setCopyCollapsedTypes] = useState<Record<string, boolean>>({});
|
||||
|
||||
// 기본 라우팅 공정 목록 (적용공정 Select용)
|
||||
const [processOptions, setProcessOptions] = useState<{ code: string; name: string }[]>([]);
|
||||
|
||||
@@ -119,9 +125,9 @@ export default function ItemInspectionInfoPage() {
|
||||
const loadOptions = async () => {
|
||||
try {
|
||||
const [itemRes, inspRes, userRes] = await Promise.all([
|
||||
apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, { page: 1, size: 500, autoFilter: true }),
|
||||
apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, { page: 1, size: 500, autoFilter: true }),
|
||||
apiClient.post(`/table-management/tables/user_info/data`, { page: 1, size: 500, autoFilter: true }),
|
||||
apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, { page: 1, size: 0, autoFilter: true }),
|
||||
apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, { page: 1, size: 0, autoFilter: true }),
|
||||
apiClient.post(`/table-management/tables/user_info/data`, { page: 1, size: 0, autoFilter: true }),
|
||||
]);
|
||||
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
|
||||
setItemOptions(items.map((r: any) => ({
|
||||
@@ -253,13 +259,180 @@ export default function ItemInspectionInfoPage() {
|
||||
loadProcessOptions(item.code);
|
||||
};
|
||||
|
||||
/* ═══════════════════ 복사 모달 (기준 품목 검사정보 → 다른 품목들) ═══════════════════ */
|
||||
const [copyModalOpen, setCopyModalOpen] = useState(false);
|
||||
const [copySearchKeyword, setCopySearchKeyword] = useState("");
|
||||
const [copyFilteredItems, setCopyFilteredItems] = useState<typeof itemOptions>([]);
|
||||
const [copySearchLoading, setCopySearchLoading] = useState(false);
|
||||
const [copyPage, setCopyPage] = useState(1);
|
||||
const [copyTotal, setCopyTotal] = useState(0);
|
||||
const [copyCheckedIds, setCopyCheckedIds] = useState<string[]>([]);
|
||||
const [copying, setCopying] = useState(false);
|
||||
const [copyProgress, setCopyProgress] = useState({ current: 0, total: 0 });
|
||||
const copyPageSize = 20;
|
||||
const copyTotalPages = Math.max(1, Math.ceil(copyTotal / copyPageSize));
|
||||
|
||||
const searchCopyTargets = async (page?: number) => {
|
||||
const p = page ?? copyPage;
|
||||
setCopySearchLoading(true);
|
||||
try {
|
||||
const filters: any[] = [];
|
||||
if (copySearchKeyword.trim()) {
|
||||
filters.push({ columnName: "item_name", operator: "contains", value: copySearchKeyword.trim() });
|
||||
}
|
||||
const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
|
||||
page: p, size: copyPageSize,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
const resData = res.data?.data;
|
||||
const rows = resData?.data || resData?.rows || [];
|
||||
const cm = itemCatMapRef.current;
|
||||
const list = rows
|
||||
.filter((r: any) => r.item_number !== selectedItemCode)
|
||||
.map((r: any) => ({
|
||||
code: r.item_number,
|
||||
name: r.item_name,
|
||||
item_type: cm["type"]?.[r.type] || r.type || "",
|
||||
unit: cm["inventory_unit"]?.[r.inventory_unit] || r.inventory_unit || "",
|
||||
}));
|
||||
setCopyFilteredItems(list);
|
||||
setCopyTotal(resData?.total || resData?.totalCount || rows.length);
|
||||
} catch { /* skip */ } finally { setCopySearchLoading(false); }
|
||||
};
|
||||
const openCopyModal = async () => {
|
||||
if (!selectedItemCode) { toast.error("복사 기준 품목을 먼저 선택해주세요"); return; }
|
||||
const srcGroup = groupedData.find(g => g.item_code === selectedItemCode);
|
||||
if (!srcGroup || srcGroup.rows.length === 0) { toast.error("선택한 품목에 복사할 검사정보가 없어요"); return; }
|
||||
setCopySearchKeyword(""); setCopyPage(1); setCopyCheckedIds([]);
|
||||
|
||||
// 기준 품목 데이터를 편집용 상태로 복제 (openEdit과 동일한 변환 로직)
|
||||
const baseRow = srcGroup.rows[0];
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: selectedItemCode }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const allRows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
const rowMap: Record<string, InspectionRow[]> = {};
|
||||
const typeFlags: Record<string, boolean> = {};
|
||||
for (const r of allRows) {
|
||||
const inspType = r.inspection_type || "";
|
||||
const matched = INSPECTION_TYPES.find(t =>
|
||||
t.matchLabels.some(ml => inspType.includes(ml)) ||
|
||||
inspTypeCatOptions.some(cat => inspType.includes(cat.code) && t.matchLabels.some(ml => cat.label.includes(ml)))
|
||||
);
|
||||
const typeKey = matched?.key || "";
|
||||
if (!typeKey) continue;
|
||||
typeFlags[typeKey] = true;
|
||||
if (!rowMap[typeKey]) rowMap[typeKey] = [];
|
||||
const mCode = r.inspection_method || "";
|
||||
const mLabel = inspMethodCatOptions.find(o => o.code === mCode)?.label || mCode;
|
||||
const inspOpt = inspOptions.find(o => o.code === r.inspection_standard_id);
|
||||
const jcCode = inspOpt?.judgment_criteria || "";
|
||||
const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode;
|
||||
const unitCode = inspOpt?.unit || "";
|
||||
const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode;
|
||||
rowMap[typeKey].push({
|
||||
id: crypto.randomUUID(), // 복사본은 새 id 부여 (원본과 분리)
|
||||
inspection_standard_id: r.inspection_standard_id || "",
|
||||
inspection_detail: r.inspection_item_name || r.inspection_standard || "",
|
||||
inspection_method: mLabel,
|
||||
apply_process: "",
|
||||
acceptance_criteria: r.pass_criteria || "",
|
||||
is_required: r.is_required === "true" || r.is_required === true,
|
||||
judgment_criteria: jcLabel,
|
||||
selection_options: inspOpt?.selection_options || "",
|
||||
unit: unitLabel,
|
||||
});
|
||||
}
|
||||
setCopyInspectionRows(rowMap);
|
||||
setCopyForm({ ...baseRow, ...typeFlags });
|
||||
setCopyCollapsedTypes({});
|
||||
} catch {
|
||||
setCopyInspectionRows({});
|
||||
setCopyForm({ ...baseRow });
|
||||
setCopyCollapsedTypes({});
|
||||
}
|
||||
|
||||
setCopyModalOpen(true);
|
||||
searchCopyTargets(1);
|
||||
};
|
||||
const handleCopySearch = () => { setCopyPage(1); searchCopyTargets(1); };
|
||||
const toggleCopyChecked = (code: string) => {
|
||||
setCopyCheckedIds(prev => prev.includes(code) ? prev.filter(c => c !== code) : [...prev, code]);
|
||||
};
|
||||
const handleCopy = async () => {
|
||||
if (!selectedItemCode) { toast.error("복사 기준 품목이 없어요"); return; }
|
||||
if (copyCheckedIds.length === 0) { toast.error("붙여넣을 품목을 선택해주세요"); return; }
|
||||
|
||||
// 편집된 rows를 평탄화 (선택된 검사유형의 rows만)
|
||||
const enabledTypes = INSPECTION_TYPES.filter(t => !!copyForm[t.key]);
|
||||
const flatRows: Array<{ row: InspectionRow; typeLabel: string }> = [];
|
||||
for (const t of enabledTypes) {
|
||||
const rows = copyInspectionRows[t.key] || [];
|
||||
for (const r of rows) flatRows.push({ row: r, typeLabel: t.label });
|
||||
}
|
||||
if (flatRows.length === 0) { toast.error("복사할 검사항목이 없어요"); return; }
|
||||
|
||||
const ok = await confirm(
|
||||
`선택한 ${copyCheckedIds.length}개 품목에 편집된 검사정보(${flatRows.length}개 행)를 복사할까요?`,
|
||||
{ description: "대상 품목의 기존 검사정보는 삭제 후 교체됩니다.", variant: "info", confirmText: "복사" }
|
||||
);
|
||||
if (!ok) return;
|
||||
setCopying(true);
|
||||
setCopyProgress({ current: 0, total: copyCheckedIds.length });
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
try {
|
||||
for (let i = 0; i < copyCheckedIds.length; i++) {
|
||||
const targetCode = copyCheckedIds[i];
|
||||
const target = copyFilteredItems.find(o => o.code === targetCode) || itemOptions.find(o => o.code === targetCode);
|
||||
const targetName = target?.name || "";
|
||||
const existRes = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: targetCode }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const existing = existRes.data?.data?.data || existRes.data?.data?.rows || [];
|
||||
if (existing.length > 0) {
|
||||
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: existing.map((r: any) => ({ id: r.id })) });
|
||||
}
|
||||
for (const { row: r, typeLabel } of flatRows) {
|
||||
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
item_code: targetCode,
|
||||
item_name: targetName,
|
||||
inspection_type: typeLabel,
|
||||
inspection_standard_id: r.inspection_standard_id || "",
|
||||
inspection_item_name: r.inspection_detail || "",
|
||||
inspection_method: r.inspection_method || "",
|
||||
pass_criteria: r.acceptance_criteria || "",
|
||||
is_required: r.is_required ? "true" : "false",
|
||||
is_active: copyForm.is_active || "사용",
|
||||
manager: copyForm.manager || "",
|
||||
});
|
||||
}
|
||||
setCopyProgress({ current: i + 1, total: copyCheckedIds.length });
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
toast.success(`${copyCheckedIds.length}개 품목에 복사했어요`);
|
||||
setCopyModalOpen(false);
|
||||
fetchData();
|
||||
} catch { toast.error("복사에 실패했어요"); }
|
||||
finally {
|
||||
setCopying(false);
|
||||
setCopyProgress({ current: 0, total: 0 });
|
||||
}
|
||||
};
|
||||
|
||||
/* ═══════════════════ 데이터 조회 ═══════════════════ */
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
|
||||
const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
@@ -300,7 +473,13 @@ export default function ItemInspectionInfoPage() {
|
||||
// 선택된 탭의 검사항목 행
|
||||
const selectedTabRows = useMemo(() => {
|
||||
if (!selectedGroup || !selectedTypeTab) return [];
|
||||
return selectedGroup.rows.filter((r: any) => r.inspection_type === selectedTypeTab && r.inspection_standard_id);
|
||||
const filtered = selectedGroup.rows.filter((r: any) => r.inspection_type === selectedTypeTab && r.inspection_standard_id);
|
||||
return [...filtered].sort((a: any, b: any) => {
|
||||
const av = parseInt(String(a.sort_order || "9999"), 10);
|
||||
const bv = parseInt(String(b.sort_order || "9999"), 10);
|
||||
if (av === bv) return String(a.id).localeCompare(String(b.id));
|
||||
return av - bv;
|
||||
});
|
||||
}, [selectedGroup, selectedTypeTab]);
|
||||
|
||||
// 검사기준 ID → 라벨
|
||||
@@ -329,11 +508,18 @@ export default function ItemInspectionInfoPage() {
|
||||
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: code }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const allRows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
// sort_order 기준 오름차순 정렬 (varchar이므로 숫자 파싱 후 비교)
|
||||
allRows.sort((a: any, b: any) => {
|
||||
const av = parseInt(String(a.sort_order || "9999"), 10);
|
||||
const bv = parseInt(String(b.sort_order || "9999"), 10);
|
||||
if (av === bv) return String(a.id).localeCompare(String(b.id));
|
||||
return av - bv;
|
||||
});
|
||||
const rowMap: Record<string, InspectionRow[]> = {};
|
||||
const typeFlags: Record<string, boolean> = {};
|
||||
|
||||
@@ -360,7 +546,8 @@ export default function ItemInspectionInfoPage() {
|
||||
inspection_standard_id: r.inspection_standard_id || "",
|
||||
inspection_detail: r.inspection_item_name || r.inspection_standard || "",
|
||||
inspection_method: mLabel,
|
||||
apply_process: "",
|
||||
apply_process: r.apply_process || "",
|
||||
classification: r.classification || "",
|
||||
acceptance_criteria: r.pass_criteria || "",
|
||||
is_required: r.is_required === "true" || r.is_required === true,
|
||||
judgment_criteria: jcLabel,
|
||||
@@ -378,7 +565,7 @@ export default function ItemInspectionInfoPage() {
|
||||
const addInspRow = (typeKey: string) => {
|
||||
setInspectionRows(prev => ({
|
||||
...prev,
|
||||
[typeKey]: [...(prev[typeKey] || []), { id: crypto.randomUUID(), inspection_standard_id: "", inspection_detail: "", inspection_method: "", apply_process: "", acceptance_criteria: "", is_required: false }],
|
||||
[typeKey]: [...(prev[typeKey] || []), { id: crypto.randomUUID(), inspection_standard_id: "", inspection_detail: "", inspection_method: "", apply_process: "", classification: "", acceptance_criteria: "", is_required: false }],
|
||||
}));
|
||||
};
|
||||
const removeInspRow = (typeKey: string, rowId: string) => {
|
||||
@@ -423,13 +610,53 @@ export default function ItemInspectionInfoPage() {
|
||||
};
|
||||
const toggleCollapse = (typeKey: string) => { setCollapsedTypes(prev => ({ ...prev, [typeKey]: !prev[typeKey] })); };
|
||||
|
||||
/* ═══════════════════ 복사 모달용 검사항목 행 관리 (등록 폼과 평행) ═══════════════════ */
|
||||
const addCopyInspRow = (typeKey: string) => {
|
||||
setCopyInspectionRows(prev => ({
|
||||
...prev,
|
||||
[typeKey]: [...(prev[typeKey] || []), { id: crypto.randomUUID(), inspection_standard_id: "", inspection_detail: "", inspection_method: "", apply_process: "", acceptance_criteria: "", is_required: false }],
|
||||
}));
|
||||
};
|
||||
const removeCopyInspRow = (typeKey: string, rowId: string) => {
|
||||
setCopyInspectionRows(prev => ({ ...prev, [typeKey]: (prev[typeKey] || []).filter(r => r.id !== rowId) }));
|
||||
};
|
||||
const updateCopyInspRow = (typeKey: string, rowId: string, field: string, value: any) => {
|
||||
setCopyInspectionRows(prev => ({
|
||||
...prev,
|
||||
[typeKey]: (prev[typeKey] || []).map(r => {
|
||||
if (r.id !== rowId) return r;
|
||||
if (field === "inspection_standard_id") {
|
||||
const opt = inspOptions.find(o => o.code === value);
|
||||
const methodCode = opt?.method || "";
|
||||
const methodLabel = inspMethodCatOptions.find(o => o.code === methodCode)?.label || methodCode;
|
||||
const jcCode = opt?.judgment_criteria || "";
|
||||
const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode;
|
||||
const unitCode = opt?.unit || "";
|
||||
const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode;
|
||||
return {
|
||||
...r,
|
||||
inspection_standard_id: value,
|
||||
inspection_detail: opt?.detail || "",
|
||||
inspection_method: methodLabel,
|
||||
judgment_criteria: jcLabel,
|
||||
selection_options: opt?.selection_options || "",
|
||||
unit: unitLabel,
|
||||
acceptance_criteria: "",
|
||||
};
|
||||
}
|
||||
return { ...r, [field]: value };
|
||||
}),
|
||||
}));
|
||||
};
|
||||
const toggleCopyCollapse = (typeKey: string) => { setCopyCollapsedTypes(prev => ({ ...prev, [typeKey]: !prev[typeKey] })); };
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.item_code) { toast.error("품목코드는 필수예요"); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editMode) {
|
||||
const existRes = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: form.item_code }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
@@ -440,18 +667,23 @@ export default function ItemInspectionInfoPage() {
|
||||
}
|
||||
const enabledTypes = INSPECTION_TYPES.filter(t => !!form[t.key]);
|
||||
const rows: any[] = [];
|
||||
let globalOrder = 0;
|
||||
for (const t of enabledTypes) {
|
||||
const typeRows = inspectionRows[t.key] || [];
|
||||
if (typeRows.length === 0) {
|
||||
rows.push({ id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label, is_active: form.is_active || "사용", manager_id: form.manager_id || "", memo: form.remarks || "" });
|
||||
globalOrder += 1;
|
||||
rows.push({ id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label, is_active: form.is_active || "사용", manager_id: form.manager_id || "", memo: form.remarks || "", sort_order: String(globalOrder).padStart(4, "0") });
|
||||
} else {
|
||||
for (const r of typeRows) {
|
||||
globalOrder += 1;
|
||||
rows.push({
|
||||
id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label,
|
||||
inspection_standard_id: r.inspection_standard_id || "", inspection_item_name: r.inspection_detail || "",
|
||||
inspection_method: r.inspection_method || "", pass_criteria: r.acceptance_criteria || "",
|
||||
apply_process: r.apply_process || "", classification: r.classification || "",
|
||||
is_required: r.is_required ? "true" : "false", is_active: form.is_active || "사용",
|
||||
manager_id: form.manager_id || "", memo: form.remarks || "",
|
||||
sort_order: String(globalOrder).padStart(4, "0"),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -732,7 +964,6 @@ export default function ItemInspectionInfoPage() {
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={openExcelUpload}>
|
||||
<FileSpreadsheet className="w-3.5 h-3.5 mr-1" />엑셀 업로드
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" />수정</Button>
|
||||
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" />삭제</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => ts.setOpen(true)}><Settings2 className="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
@@ -814,6 +1045,7 @@ export default function ItemInspectionInfoPage() {
|
||||
{selectedGroup && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" />수정</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={openCopyModal}><Copy className="w-3.5 h-3.5 mr-1" />복사</Button>
|
||||
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" />삭제</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -872,15 +1104,17 @@ export default function ItemInspectionInfoPage() {
|
||||
<TableHead className="text-[10px] font-bold h-8">검사기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8">검사방법</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8">적용공정</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8">구분</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8">판단기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8">합격기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8 w-[50px]">필수</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8 w-[70px]">단위</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{selectedTabRows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-8 text-xs text-muted-foreground">등록된 검사항목이 없어요</TableCell>
|
||||
<TableCell colSpan={9} className="text-center py-8 text-xs text-muted-foreground">등록된 검사항목이 없어요</TableCell>
|
||||
</TableRow>
|
||||
) : selectedTabRows.map((row: any) => (
|
||||
<TableRow key={row.id}>
|
||||
@@ -899,6 +1133,7 @@ export default function ItemInspectionInfoPage() {
|
||||
const proc = processOptions.find(p => p.code === code);
|
||||
return proc?.name || code;
|
||||
})()}</TableCell>
|
||||
<TableCell className="text-xs py-2">{row.classification || "-"}</TableCell>
|
||||
<TableCell className="text-xs py-2">
|
||||
{(() => {
|
||||
const insp = inspOptions.find(o => o.code === row.inspection_standard_id);
|
||||
@@ -907,12 +1142,29 @@ export default function ItemInspectionInfoPage() {
|
||||
return jcLabel ? <Badge variant="outline" className="text-[10px]">{jcLabel}</Badge> : "-";
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs py-2 font-mono">{row.pass_criteria || "-"}</TableCell>
|
||||
<TableCell className="text-xs py-2 font-mono">{(() => {
|
||||
const pc = row.pass_criteria;
|
||||
if (!pc) return "-";
|
||||
if (pc.includes("|")) {
|
||||
const [s, t] = pc.split("|");
|
||||
if (!t || !t.trim()) return s || "-";
|
||||
return `${s} ± ${t}`;
|
||||
}
|
||||
return pc;
|
||||
})()}</TableCell>
|
||||
<TableCell className="text-xs py-2 text-center">
|
||||
{row.is_required === "true" || row.is_required === true ? (
|
||||
<Badge variant="destructive" className="text-[9px]">필수</Badge>
|
||||
) : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs py-2">
|
||||
{(() => {
|
||||
const insp = inspOptions.find(o => o.code === row.inspection_standard_id);
|
||||
const unitCode = insp?.unit || "";
|
||||
const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode;
|
||||
return unitLabel || "-";
|
||||
})()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -1074,15 +1326,17 @@ export default function ItemInspectionInfoPage() {
|
||||
<TableHead className="text-[10px] font-bold w-[130px]">검사기준 상세</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[90px]">검사방법</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[90px]">적용공정</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[100px]">구분</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[80px]">판단기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[200px]">합격기준 (판단기준별)</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[40px]">필수</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[70px]">단위</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[36px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(!inspectionRows[key] || inspectionRows[key].length === 0) ? (
|
||||
<TableRow><TableCell colSpan={8} className="text-center py-4 text-xs text-muted-foreground">항목추가 버튼으로 검사항목을 추가하세요</TableCell></TableRow>
|
||||
<TableRow><TableCell colSpan={10} className="text-center py-4 text-xs text-muted-foreground">항목추가 버튼으로 검사항목을 추가하세요</TableCell></TableRow>
|
||||
) : inspectionRows[key].map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="p-1">
|
||||
@@ -1107,6 +1361,9 @@ export default function ItemInspectionInfoPage() {
|
||||
<Input className="h-8 text-xs" value={row.apply_process} onChange={(e) => updateInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Input className="h-8 text-xs" value={row.classification || ""} onChange={(e) => updateInspRow(key, row.id, "classification", e.target.value)} placeholder="구분 입력" />
|
||||
</TableCell>
|
||||
<TableCell className="p-1 text-center">
|
||||
{row.judgment_criteria ? <Badge variant="outline" className="text-[10px]">{row.judgment_criteria}</Badge> : <span className="text-[10px] text-muted-foreground">-</span>}
|
||||
</TableCell>
|
||||
@@ -1148,6 +1405,7 @@ export default function ItemInspectionInfoPage() {
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="p-1 text-center"><Checkbox checked={row.is_required} onCheckedChange={(v) => updateInspRow(key, row.id, "is_required", !!v)} /></TableCell>
|
||||
<TableCell className="p-1 text-xs text-muted-foreground">{row.unit || "-"}</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Button type="button" variant="destructive" size="sm" className="h-7 w-7 p-0" onClick={() => removeInspRow(key, row.id)}><Trash2 className="w-3.5 h-3.5" /></Button>
|
||||
</TableCell>
|
||||
@@ -1172,6 +1430,278 @@ export default function ItemInspectionInfoPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ═══════════════════ 복사 모달 (2단 분할: 좌 대상 / 우 편집) ═══════════════════ */}
|
||||
<Dialog open={copyModalOpen} onOpenChange={(v) => { if (!copying) setCopyModalOpen(v); }}>
|
||||
<DialogContent
|
||||
className="max-w-[95vw] sm:max-w-[1400px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden"
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => { if (copying) e.preventDefault(); }}
|
||||
>
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle>{copying ? "검사정보 복사 중..." : "검사정보 복사"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
<span className="font-medium text-foreground">{selectedGroup?.item_name || "-"}</span>
|
||||
<span className="text-muted-foreground"> ({selectedItemCode})</span>
|
||||
<span>{copying ? " 의 검사정보를 복사하고 있습니다" : " 의 검사정보를 편집해서 선택한 품목들에 복사합니다. 기준 품목은 변경되지 않아요"}</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{copying ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-4 py-8 px-4">
|
||||
<div className="w-full max-w-sm space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-blue-600" />
|
||||
<span className="text-sm font-medium text-blue-700">복사 진행 중...</span>
|
||||
<span className="text-xs text-blue-600 ml-auto">
|
||||
{copyProgress.current.toLocaleString()} / {copyProgress.total.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-blue-100 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${copyProgress.total > 0 ? Math.round((copyProgress.current / copyProgress.total) * 100) : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground text-center pt-2">
|
||||
모달을 닫지 마세요. 완료 후 자동으로 닫혀요.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 grid grid-cols-[420px_1fr] gap-4 overflow-hidden">
|
||||
{/* 좌측: 복사 대상 품목 선택 */}
|
||||
<div className="flex flex-col overflow-hidden border rounded-lg">
|
||||
<div className="flex items-center justify-between border-b bg-muted/50 px-3 py-2">
|
||||
<span className="text-xs font-semibold">복사 대상 품목 선택</span>
|
||||
{copyCheckedIds.length > 0 && <span className="text-[10px] text-primary">선택 {copyCheckedIds.length}건</span>}
|
||||
</div>
|
||||
<div className="flex gap-2 px-2 pt-2">
|
||||
<Input className="h-8 flex-1 text-xs" placeholder="품목코드 또는 품목명" value={copySearchKeyword}
|
||||
onChange={(e) => setCopySearchKeyword(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") handleCopySearch(); }} />
|
||||
<Button size="sm" className="h-8 text-xs" onClick={handleCopySearch} disabled={copySearchLoading}>
|
||||
{copySearchLoading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Search className="w-3.5 h-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto mt-2">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-background">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[36px] text-center text-[10px]">
|
||||
<Checkbox
|
||||
checked={copyFilteredItems.length > 0 && copyFilteredItems.every(i => copyCheckedIds.includes(i.code))}
|
||||
onCheckedChange={(v) => {
|
||||
if (v) setCopyCheckedIds(prev => Array.from(new Set([...prev, ...copyFilteredItems.map(i => i.code)])));
|
||||
else setCopyCheckedIds(prev => prev.filter(c => !copyFilteredItems.some(i => i.code === c)));
|
||||
}}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[120px]">품목코드</TableHead>
|
||||
<TableHead className="text-[10px] font-bold">품목명</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{copyFilteredItems.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={3} className="text-center py-6 text-muted-foreground text-xs">
|
||||
{copySearchLoading ? "검색 중..." : "검색 결과가 없어요"}
|
||||
</TableCell></TableRow>
|
||||
) : copyFilteredItems.map((item) => (
|
||||
<TableRow key={item.code} className="cursor-pointer hover:bg-primary/5"
|
||||
onClick={() => toggleCopyChecked(item.code)}>
|
||||
<TableCell className="text-center p-1" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={copyCheckedIds.includes(item.code)} onCheckedChange={() => toggleCopyChecked(item.code)} />
|
||||
</TableCell>
|
||||
<TableCell className="text-xs font-mono p-1">{item.code}</TableCell>
|
||||
<TableCell className="text-xs p-1 truncate">{item.name}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="border-t flex items-center justify-between px-2 py-1 text-[10px] text-muted-foreground">
|
||||
<span>전체 <span className="font-medium text-foreground">{copyTotal.toLocaleString()}</span>건</span>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button onClick={() => { setCopyPage(1); searchCopyTargets(1); }} disabled={copyPage <= 1}
|
||||
className="h-6 w-6 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsLeft className="h-3 w-3" /></button>
|
||||
<button onClick={() => { const p = copyPage - 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage <= 1}
|
||||
className="h-6 w-6 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronLeft className="h-3 w-3" /></button>
|
||||
<span className="text-[10px] mx-1">{copyPage}/{copyTotalPages}</span>
|
||||
<button onClick={() => { const p = copyPage + 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage >= copyTotalPages}
|
||||
className="h-6 w-6 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronRight className="h-3 w-3" /></button>
|
||||
<button onClick={() => { setCopyPage(copyTotalPages); searchCopyTargets(copyTotalPages); }} disabled={copyPage >= copyTotalPages}
|
||||
className="h-6 w-6 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsRight className="h-3 w-3" /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우측: 편집 폼 (등록/수정 폼과 동일 구조) */}
|
||||
<div className="flex flex-col overflow-hidden border rounded-lg">
|
||||
<div className="border-b bg-muted/50 px-3 py-2">
|
||||
<span className="text-xs font-semibold">복사할 검사정보 편집 (기준: {selectedItemCode})</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">사용여부</Label>
|
||||
<Select value={copyForm.is_active === false || copyForm.is_active === "N" ? "N" : "Y"} onValueChange={(v) => setCopyForm(p => ({ ...p, is_active: v === "Y" ? "사용" : "미사용" }))}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Y">사용</SelectItem>
|
||||
<SelectItem value="N">미사용</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">관리자</Label>
|
||||
<Select value={copyForm.manager || ""} onValueChange={(v) => setCopyForm(p => ({ ...p, manager: v }))}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="관리자 선택" /></SelectTrigger>
|
||||
<SelectContent>{userOptions.map(o => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-semibold">검사유형 선택</h4>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{INSPECTION_TYPES.map(({ key, label }) => (
|
||||
<div key={key} className="flex items-center gap-1.5">
|
||||
<Checkbox checked={!!copyForm[key]} onCheckedChange={(v) => setCopyForm(p => ({ ...p, [key]: !!v }))} />
|
||||
<Label className="text-xs cursor-pointer">{label}</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{INSPECTION_TYPES.filter(t => !!copyForm[t.key]).map(({ key, label }) => (
|
||||
<div key={key} className="space-y-1.5">
|
||||
<button type="button" className="w-full flex items-center gap-2 py-1.5 px-2 rounded-md border bg-muted/50 hover:bg-muted text-left" onClick={() => toggleCopyCollapse(key)}>
|
||||
<Badge variant="default" className="text-[10px]">{label}</Badge>
|
||||
<span className="text-xs font-medium">검사항목 설정</span>
|
||||
<span className="text-[10px] text-muted-foreground ml-auto">{(copyInspectionRows[key] || []).length}개</span>
|
||||
</button>
|
||||
{!copyCollapsedTypes[key] && (
|
||||
<div className="space-y-1.5 pl-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground">검사항목 목록</span>
|
||||
<Button type="button" size="sm" variant="outline" className="h-6 text-[10px]" onClick={() => addCopyInspRow(key)}>
|
||||
<Plus className="w-3 h-3 mr-1" />항목추가
|
||||
</Button>
|
||||
</div>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50 hover:bg-muted/50">
|
||||
<TableHead className="text-[10px] font-bold w-[150px]">검사기준 선택</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[110px]">검사기준 상세</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[80px]">검사방법</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[80px]">적용공정</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[80px]">구분</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[70px]">판단기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[180px]">합격기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[36px]">필수</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[60px]">단위</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[32px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(!copyInspectionRows[key] || copyInspectionRows[key].length === 0) ? (
|
||||
<TableRow><TableCell colSpan={10} className="text-center py-3 text-[10px] text-muted-foreground">항목추가 버튼으로 검사항목을 추가하세요</TableCell></TableRow>
|
||||
) : copyInspectionRows[key].map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="p-1">
|
||||
<Select value={row.inspection_standard_id || ""} onValueChange={(v) => updateCopyInspRow(key, row.id, "inspection_standard_id", v)}>
|
||||
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="검사기준" /></SelectTrigger>
|
||||
<SelectContent>{getFilteredInspOptions(key).map(o => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="p-1"><Input className="h-7 text-[10px] bg-muted" value={row.inspection_detail} readOnly placeholder="자동" /></TableCell>
|
||||
<TableCell className="p-1"><Input className="h-7 text-[10px] bg-muted" value={row.inspection_method} readOnly placeholder="자동" /></TableCell>
|
||||
<TableCell className="p-1">
|
||||
{processOptions.length > 0 ? (
|
||||
<Select value={row.apply_process || ""} onValueChange={(v) => updateCopyInspRow(key, row.id, "apply_process", v)}>
|
||||
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="공정" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{processOptions.map((p) => (
|
||||
<SelectItem key={p.code} value={p.code} className="text-xs">{p.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input className="h-7 text-[10px]" value={row.apply_process} onChange={(e) => updateCopyInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Input className="h-7 text-[10px]" value={row.classification || ""} onChange={(e) => updateCopyInspRow(key, row.id, "classification", e.target.value)} placeholder="구분" />
|
||||
</TableCell>
|
||||
<TableCell className="p-1 text-center">
|
||||
{row.judgment_criteria ? <Badge variant="outline" className="text-[9px]">{row.judgment_criteria}</Badge> : <span className="text-[9px] text-muted-foreground">-</span>}
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
{row.judgment_criteria === "선택형" && row.selection_options ? (
|
||||
<Select value={row.acceptance_criteria || ""} onValueChange={(v) => updateCopyInspRow(key, row.id, "acceptance_criteria", v)} disabled={!row.inspection_standard_id}>
|
||||
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{row.selection_options.split(",").filter(Boolean).map((opt) => (
|
||||
<SelectItem key={opt} value={opt} className="text-xs">{opt}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : row.judgment_criteria === "O/X" ? (
|
||||
<Select value={row.acceptance_criteria || ""} onValueChange={(v) => updateCopyInspRow(key, row.id, "acceptance_criteria", v)} disabled={!row.inspection_standard_id}>
|
||||
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="O" className="text-xs">O (합격)</SelectItem>
|
||||
<SelectItem value="X" className="text-xs">X (불합격)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : row.judgment_criteria === "수치(범위)" ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Input className="h-7 text-[10px] w-14" value={row.acceptance_criteria?.split("|")[0] || ""} onChange={(e) => {
|
||||
const parts = (row.acceptance_criteria || "||").split("|");
|
||||
parts[0] = e.target.value;
|
||||
updateCopyInspRow(key, row.id, "acceptance_criteria", parts.join("|"));
|
||||
}} placeholder="기준" disabled={!row.inspection_standard_id} />
|
||||
<span className="text-[9px] text-muted-foreground">±</span>
|
||||
<Input className="h-7 text-[10px] w-10" value={row.acceptance_criteria?.split("|")[1] || ""} onChange={(e) => {
|
||||
const parts = (row.acceptance_criteria || "||").split("|");
|
||||
parts[1] = e.target.value;
|
||||
updateCopyInspRow(key, row.id, "acceptance_criteria", parts.join("|"));
|
||||
}} placeholder="±" disabled={!row.inspection_standard_id} />
|
||||
</div>
|
||||
) : (
|
||||
<Input className="h-7 text-[10px]" value={row.acceptance_criteria} onChange={(e) => updateCopyInspRow(key, row.id, "acceptance_criteria", e.target.value)} placeholder="합격기준" disabled={!row.inspection_standard_id} />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="p-1 text-center"><Checkbox checked={row.is_required} onCheckedChange={(v) => updateCopyInspRow(key, row.id, "is_required", !!v)} /></TableCell>
|
||||
<TableCell className="p-1 text-[10px] text-muted-foreground">{row.unit || "-"}</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Button type="button" variant="destructive" size="sm" className="h-6 w-6 p-0" onClick={() => removeCopyInspRow(key, row.id)}><Trash2 className="w-3 h-3" /></Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter className="shrink-0">
|
||||
<Button variant="outline" onClick={() => setCopyModalOpen(false)} disabled={copying}>취소</Button>
|
||||
<Button onClick={handleCopy} disabled={copying || copyCheckedIds.length === 0}>
|
||||
{copying ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Copy className="w-4 h-4 mr-1" />}
|
||||
{copying
|
||||
? `복사 중 (${copyProgress.current}/${copyProgress.total})`
|
||||
: copyCheckedIds.length > 0 ? `${copyCheckedIds.length}개 품목에 복사` : "복사"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<TableSettingsModal open={ts.open} onOpenChange={ts.setOpen} tableName={ts.tableName} settingsId={ts.settingsId} defaultVisibleKeys={ts.defaultVisibleKeys} onSave={ts.applySettings} />
|
||||
|
||||
{/* ═══════ 엑셀 업로드 모달 ═══════ */}
|
||||
|
||||
@@ -190,7 +190,7 @@ export default function ClaimManagementPage() {
|
||||
|
||||
const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
|
||||
page: 1,
|
||||
size: 500,
|
||||
size: 0,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
sort: { columnName: "claim_date", order: "desc" },
|
||||
|
||||
@@ -224,7 +224,7 @@ export default function CustomerManagementPage() {
|
||||
} catch { /* skip */ }
|
||||
};
|
||||
load();
|
||||
apiClient.post(`/table-management/tables/user_info/data`, { page: 1, size: 500, autoFilter: true })
|
||||
apiClient.post(`/table-management/tables/user_info/data`, { page: 1, size: 0, autoFilter: true })
|
||||
.then((res) => {
|
||||
const users = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
setEmployeeOptions(users.map((u: any) => ({
|
||||
@@ -289,7 +289,7 @@ export default function CustomerManagementPage() {
|
||||
const fetchMainContacts = useCallback(async () => {
|
||||
try {
|
||||
const contactRes = await apiClient.post(`/table-management/tables/${CONTACT_TABLE}/data`, {
|
||||
page: 1, size: 500, autoFilter: true,
|
||||
page: 1, size: 0, autoFilter: true,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "is_main", operator: "equals", value: "Y" }] },
|
||||
});
|
||||
const allContacts = contactRes.data?.data?.data || contactRes.data?.data?.rows || [];
|
||||
@@ -315,7 +315,7 @@ export default function CustomerManagementPage() {
|
||||
setPriceLoading(true);
|
||||
try {
|
||||
const mappingRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "customer_id", operator: "equals", value: selectedCustomer.customer_code },
|
||||
]},
|
||||
@@ -342,7 +342,7 @@ export default function CustomerManagementPage() {
|
||||
if (mappings.length > 0) {
|
||||
try {
|
||||
const priceRes = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "customer_id", operator: "equals", value: selectedCustomer.customer_code },
|
||||
]},
|
||||
@@ -418,7 +418,7 @@ export default function CustomerManagementPage() {
|
||||
setDeliveryLoading(true);
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/${DELIVERY_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "customer_code", operator: "equals", value: selectedCustomer.customer_code },
|
||||
]},
|
||||
@@ -498,7 +498,7 @@ export default function CustomerManagementPage() {
|
||||
if (ruleData?.success && ruleData?.data?.ruleId) {
|
||||
const ruleId = ruleData.data.ruleId;
|
||||
const allRes = await apiClient.post(`/table-management/tables/${DELIVERY_TABLE}/data`, {
|
||||
page: 1, size: 500, autoFilter: true,
|
||||
page: 1, size: 0, autoFilter: true,
|
||||
sort: { columnName: "destination_code", order: "desc" },
|
||||
});
|
||||
const allRows = allRes.data?.data?.data || allRes.data?.data?.rows || [];
|
||||
@@ -571,7 +571,7 @@ export default function CustomerManagementPage() {
|
||||
const ruleId = ruleData.data.ruleId;
|
||||
// 기존 데이터에서 CUST-XXX 패턴의 최대 순번 조회
|
||||
const allRes = await apiClient.post(`/table-management/tables/${CUSTOMER_TABLE}/data`, {
|
||||
page: 1, size: 500, autoFilter: true,
|
||||
page: 1, size: 0, autoFilter: true,
|
||||
sort: { columnName: "customer_code", order: "desc" },
|
||||
});
|
||||
const allRows = allRes.data?.data?.data || allRes.data?.data?.rows || [];
|
||||
@@ -772,7 +772,7 @@ export default function CustomerManagementPage() {
|
||||
const ruleData = ruleRes.data;
|
||||
if (ruleData?.success && ruleData?.data?.ruleId) {
|
||||
const ruleId = ruleData.data.ruleId;
|
||||
const allRes = await apiClient.post(`/table-management/tables/${CUSTOMER_TABLE}/data`, { page: 1, size: 500, autoFilter: true, sort: { columnName: "customer_code", order: "desc" } });
|
||||
const allRes = await apiClient.post(`/table-management/tables/${CUSTOMER_TABLE}/data`, { page: 1, size: 0, autoFilter: true, sort: { columnName: "customer_code", order: "desc" } });
|
||||
const allRows = allRes.data?.data?.data || allRes.data?.data?.rows || [];
|
||||
let maxSeq = 0;
|
||||
for (const row of allRows) { const match = (row.customer_code || "").match(/(\d+)$/); if (match) { const seq = parseInt(match[1], 10); if (seq > maxSeq) maxSeq = seq; } }
|
||||
@@ -827,7 +827,7 @@ export default function CustomerManagementPage() {
|
||||
? [{ columnName: "division", operator: "contains", value: salesCode }]
|
||||
: [];
|
||||
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||||
page: 1, size: 5000,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters },
|
||||
autoFilter: true,
|
||||
});
|
||||
@@ -1252,7 +1252,7 @@ export default function CustomerManagementPage() {
|
||||
for (const itemId of itemIds) {
|
||||
// 해당 품목의 모든 매핑 조회 → customer_id null 처리
|
||||
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "customer_id", operator: "equals", value: selectedCustomer!.customer_code },
|
||||
{ columnName: "item_id", operator: "equals", value: itemId },
|
||||
@@ -1269,7 +1269,7 @@ export default function CustomerManagementPage() {
|
||||
// 해당 품목의 모든 단가 조회 → customer_id null 처리
|
||||
try {
|
||||
const priceRes = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "customer_id", operator: "equals", value: selectedCustomer!.customer_code },
|
||||
{ columnName: "item_id", operator: "equals", value: itemId },
|
||||
@@ -1344,7 +1344,7 @@ export default function CustomerManagementPage() {
|
||||
try {
|
||||
const allMappings: any[] = [];
|
||||
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
|
||||
page: 1, size: 5000, autoFilter: true,
|
||||
page: 1, size: 0, autoFilter: true,
|
||||
});
|
||||
const mappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
|
||||
const itemIds = [...new Set(mappings.map((m: any) => m.item_id).filter(Boolean))];
|
||||
|
||||
@@ -28,6 +28,7 @@ import { ShippingPlanBatchModal } from "@/components/common/ShippingPlanBatchMod
|
||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { SmartSelect } from "@/components/common/SmartSelect";
|
||||
import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
|
||||
|
||||
const DETAIL_TABLE = "sales_order_detail";
|
||||
@@ -277,7 +278,7 @@ export default function SalesOrderPage() {
|
||||
// 거래처 목록
|
||||
try {
|
||||
const custRes = await apiClient.post(`/table-management/tables/customer_mng/data`, {
|
||||
page: 1, size: 500, autoFilter: true,
|
||||
page: 1, size: 0, autoFilter: true,
|
||||
});
|
||||
const custs = custRes.data?.data?.data || custRes.data?.data?.rows || [];
|
||||
optMap["partner_id"] = custs.map((c: any) => ({ code: c.customer_code, label: c.customer_name }));
|
||||
@@ -285,7 +286,7 @@ export default function SalesOrderPage() {
|
||||
// 사용자 목록
|
||||
try {
|
||||
const userRes = await apiClient.post(`/table-management/tables/user_info/data`, {
|
||||
page: 1, size: 500, autoFilter: true,
|
||||
page: 1, size: 0, autoFilter: true,
|
||||
});
|
||||
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
|
||||
optMap["manager_id"] = users.map((u: any) => ({
|
||||
@@ -330,7 +331,7 @@ export default function SalesOrderPage() {
|
||||
}));
|
||||
|
||||
const res = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
sort: { columnName: "order_no", order: "desc" },
|
||||
@@ -770,7 +771,7 @@ export default function SalesOrderPage() {
|
||||
if (isCustomerPrice && partnerId) {
|
||||
try {
|
||||
const mappingRes = await apiClient.post(`/table-management/tables/customer_item_mapping/data`, {
|
||||
page: 1, size: 5000,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "customer_id", operator: "equals", value: partnerId }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
@@ -860,7 +861,7 @@ export default function SalesOrderPage() {
|
||||
try {
|
||||
const itemIds = selected.map((item) => item.item_number || item.id);
|
||||
const res = await apiClient.post(`/table-management/tables/customer_item_prices/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: {
|
||||
enabled: true,
|
||||
filters: [
|
||||
@@ -931,7 +932,7 @@ export default function SalesOrderPage() {
|
||||
if (itemCodes.length === 0) return;
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: itemCodes }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
@@ -954,7 +955,7 @@ export default function SalesOrderPage() {
|
||||
if (itemCodes.length === 0) return;
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/customer_item_prices/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "customer_id", operator: "equals", value: partnerId },
|
||||
{ columnName: "item_id", operator: "in", value: itemCodes },
|
||||
@@ -1481,17 +1482,12 @@ export default function SalesOrderPage() {
|
||||
<div className="grid grid-cols-1 gap-3.5 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">거래처</Label>
|
||||
<Select
|
||||
<SmartSelect
|
||||
options={categoryOptions["partner_id"] || []}
|
||||
value={masterForm.partner_id || ""}
|
||||
onValueChange={(v) => { setMasterForm((p) => ({ ...p, partner_id: v, delivery_partner_id: "" })); loadDeliveryOptions(v); recalcPrices(masterForm.price_mode || "", v); }}
|
||||
>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="거래처 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(categoryOptions["partner_id"] || []).map((o) => (
|
||||
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
placeholder="거래처 선택"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">담당자</Label>
|
||||
@@ -1642,10 +1638,8 @@ export default function SalesOrderPage() {
|
||||
<TableHead className="min-w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품명</TableHead>
|
||||
<TableHead className="min-w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">규격</TableHead>
|
||||
<TableHead className="min-w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">재질</TableHead>
|
||||
<TableHead className="min-w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">포장재</TableHead>
|
||||
<TableHead className="min-w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">단위</TableHead>
|
||||
<TableHead className="min-w-[90px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">수량</TableHead>
|
||||
<TableHead className="min-w-[100px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">포장수량</TableHead>
|
||||
<TableHead className="min-w-[120px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">단가</TableHead>
|
||||
<TableHead className="min-w-[100px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">금액</TableHead>
|
||||
<TableHead className="min-w-[160px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">납기일</TableHead>
|
||||
@@ -1664,14 +1658,6 @@ export default function SalesOrderPage() {
|
||||
</TableCell>
|
||||
<TableCell className="text-[13px] text-muted-foreground whitespace-nowrap">{row.spec}</TableCell>
|
||||
<TableCell className="text-[13px] text-muted-foreground whitespace-nowrap">{row.material}</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
value={row.packing_material || ""}
|
||||
onChange={(e) => updateDetailRow(idx, "packing_material", e.target.value)}
|
||||
placeholder="포장재"
|
||||
className="h-8 text-xs w-full"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Select value={row.unit || ""} onValueChange={(v) => updateDetailRow(idx, "unit", v)}>
|
||||
<SelectTrigger className="h-8 text-xs w-full"><SelectValue placeholder="단위" /></SelectTrigger>
|
||||
@@ -1692,15 +1678,6 @@ export default function SalesOrderPage() {
|
||||
className="h-8 text-xs text-right font-mono w-full"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
value={row.pack_qty || "0"}
|
||||
onChange={(e) => updateDetailRow(idx, "pack_qty", e.target.value)}
|
||||
className="h-8 text-xs text-right font-mono w-full"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
value={formatNumber(row.unit_price || "")}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { reportApi } from "@/lib/api/reportApi";
|
||||
import { useCurrent2ndLevelMenuObjid } from "@/hooks/useCurrent2ndLevelMenuObjid";
|
||||
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
@@ -28,7 +29,7 @@ import {
|
||||
ResizablePanelGroup, ResizablePanel, ResizableHandle,
|
||||
} from "@/components/ui/resizable";
|
||||
import { ReportInlineViewer } from "@/components/report/ReportInlineViewer";
|
||||
import { ReportMaster, ComponentConfig } from "@/types/report";
|
||||
import { ReportMaster, ComponentConfig, GridCell } from "@/types/report";
|
||||
|
||||
const MASTER_TABLE = "quote_mng";
|
||||
|
||||
@@ -82,6 +83,12 @@ export default function QuoteManagementPage() {
|
||||
const [basicInfoOpen, setBasicInfoOpen] = useState(false);
|
||||
const [basicForm, setBasicForm] = useState({ quote_date: "", valid_until: "", status: "draft" });
|
||||
|
||||
// 리포트 셀 input 오버라이드
|
||||
const [cellOverrides, setCellOverrides] = useState<Record<string, Record<string, string>>>({});
|
||||
const [inputCellOpen, setInputCellOpen] = useState(false);
|
||||
const [inputCellCtx, setInputCellCtx] = useState<{ comp: ComponentConfig; cells: GridCell[] } | null>(null);
|
||||
const [inputCellValues, setInputCellValues] = useState<Record<string, string>>({});
|
||||
|
||||
// 엑셀 / 리포트
|
||||
const [excelOpen, setExcelOpen] = useState(false);
|
||||
const [reportList, setReportList] = useState<ReportMaster[]>([]);
|
||||
@@ -153,10 +160,13 @@ export default function QuoteManagementPage() {
|
||||
|
||||
useEffect(() => { fetchQuotes(); }, [fetchQuotes]);
|
||||
|
||||
const current2ndLevelMenuObjid = useCurrent2ndLevelMenuObjid();
|
||||
|
||||
useEffect(() => {
|
||||
if (current2ndLevelMenuObjid === null) return;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await reportApi.getReports({ page: 1, limit: 100 });
|
||||
const res = await reportApi.getReportsByMenuObjid(current2ndLevelMenuObjid);
|
||||
if (res.success) {
|
||||
const items = res.data.items ?? [];
|
||||
setReportList(items);
|
||||
@@ -164,7 +174,105 @@ export default function QuoteManagementPage() {
|
||||
}
|
||||
} catch { /* 무시 */ }
|
||||
})();
|
||||
}, []);
|
||||
}, [current2ndLevelMenuObjid]);
|
||||
|
||||
// ── 리포트 셀 오버라이드: 견적/리포트 변경 시 로드 ──
|
||||
useEffect(() => {
|
||||
if (!selectedRow?.objid || !selectedReportId) {
|
||||
setCellOverrides({});
|
||||
return;
|
||||
}
|
||||
(async () => {
|
||||
try {
|
||||
const res = await apiClient.get("/report-cell-values", {
|
||||
params: { report_id: selectedReportId, target_type: "quote", target_id: String(selectedRow.objid) },
|
||||
});
|
||||
const rows = res.data?.data || [];
|
||||
const map: Record<string, Record<string, string>> = {};
|
||||
for (const r of rows) {
|
||||
if (!map[r.component_id]) map[r.component_id] = {};
|
||||
map[r.component_id][r.cell_id] = r.value ?? "";
|
||||
}
|
||||
setCellOverrides(map);
|
||||
} catch {
|
||||
setCellOverrides({});
|
||||
}
|
||||
})();
|
||||
}, [selectedRow?.objid, selectedReportId]);
|
||||
|
||||
// ── input 셀 클릭 → 해당 테이블의 모든 input 셀을 모아 한 모달에 표시 ──
|
||||
const handleInputCellClick = (comp: ComponentConfig, _cell: GridCell) => {
|
||||
const allCells = ((comp as any).gridCells || []) as GridCell[];
|
||||
const inputCells = allCells
|
||||
.filter((c) => c.cellType === "input" && !c.merged)
|
||||
.sort((a, b) => (a.row - b.row) || (a.col - b.col));
|
||||
const vals: Record<string, string> = {};
|
||||
for (const c of inputCells) {
|
||||
vals[c.id] = cellOverrides[comp.id]?.[c.id] ?? "";
|
||||
}
|
||||
setInputCellCtx({ comp, cells: inputCells });
|
||||
setInputCellValues(vals);
|
||||
setInputCellOpen(true);
|
||||
};
|
||||
|
||||
// ── input 셀 라벨 찾기: 같은 행의 static 라벨 셀 값 → 없으면 placeholder ──
|
||||
const getInputCellLabel = (comp: ComponentConfig, cell: GridCell): string => {
|
||||
const allCells = ((comp as any).gridCells || []) as GridCell[];
|
||||
const labelCell = allCells
|
||||
.filter((c) => c.row === cell.row && c.col < cell.col && c.cellType === "static" && c.value && !c.merged)
|
||||
.sort((a, b) => b.col - a.col)[0];
|
||||
if (labelCell?.value) return String(labelCell.value).trim();
|
||||
return cell.inputPlaceholder || "값";
|
||||
};
|
||||
|
||||
// ── input 셀 저장: 변경된 셀들만 일괄 저장 ──
|
||||
const handleInputCellSave = async () => {
|
||||
if (!inputCellCtx || !selectedRow?.objid || !selectedReportId) return;
|
||||
const { comp, cells } = inputCellCtx;
|
||||
const existing = cellOverrides[comp.id] || {};
|
||||
const toSave: { cellId: string; value: string }[] = [];
|
||||
for (const c of cells) {
|
||||
const newVal = inputCellValues[c.id] ?? "";
|
||||
const oldVal = existing[c.id] ?? "";
|
||||
if (newVal !== oldVal) toSave.push({ cellId: c.id, value: newVal });
|
||||
}
|
||||
if (toSave.length === 0) {
|
||||
setInputCellOpen(false);
|
||||
setInputCellCtx(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await Promise.all(
|
||||
toSave.map((t) =>
|
||||
apiClient.post("/report-cell-values", {
|
||||
report_id: selectedReportId,
|
||||
target_type: "quote",
|
||||
target_id: String(selectedRow.objid),
|
||||
component_id: comp.id,
|
||||
cell_id: t.cellId,
|
||||
value: t.value,
|
||||
})
|
||||
)
|
||||
);
|
||||
setCellOverrides((prev) => {
|
||||
const next = { ...prev };
|
||||
const curr = { ...(next[comp.id] || {}) };
|
||||
for (const c of cells) {
|
||||
const v = inputCellValues[c.id] ?? "";
|
||||
if (v === "") delete curr[c.id];
|
||||
else curr[c.id] = v;
|
||||
}
|
||||
if (Object.keys(curr).length === 0) delete next[comp.id];
|
||||
else next[comp.id] = curr;
|
||||
return next;
|
||||
});
|
||||
toast.success(`${toSave.length}개 항목이 저장됐어요`);
|
||||
setInputCellOpen(false);
|
||||
setInputCellCtx(null);
|
||||
} catch {
|
||||
toast.error("저장 실패");
|
||||
}
|
||||
};
|
||||
|
||||
// ── "신규" → DB에 draft 즉시 생성 → 자동 선택 ──
|
||||
|
||||
@@ -214,6 +322,15 @@ export default function QuoteManagementPage() {
|
||||
setEditComp(comp);
|
||||
|
||||
if (comp.type === "table") {
|
||||
// 품목 테이블 판별: tableColumns 중 품목 관련 필드가 포함되어야 편집 대상
|
||||
const cols = (comp as any).tableColumns || [];
|
||||
const ITEM_FIELDS = new Set(["item_code", "item_name", "qty", "unit_price", "spec", "total_amount", "supply_amount", "vat_amount"]);
|
||||
const isItemTable = cols.some((c: any) => ITEM_FIELDS.has((c.field || "").toLowerCase()));
|
||||
if (!isItemTable) {
|
||||
toast.info("이 테이블의 각 셀에서 직접 입력하세요 (input 셀로 지정된 곳만 편집 가능)");
|
||||
setEditComp(null);
|
||||
return;
|
||||
}
|
||||
// 테이블 → 품목 편집
|
||||
try {
|
||||
const res = await apiClient.get(`/quotes/${selectedRow.objid}`);
|
||||
@@ -692,6 +809,8 @@ export default function QuoteManagementPage() {
|
||||
reportId={selectedReportId}
|
||||
contextParams={contextParams}
|
||||
onComponentClick={handleComponentClick}
|
||||
cellOverrides={cellOverrides}
|
||||
onInputCellClick={handleInputCellClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -848,6 +967,42 @@ export default function QuoteManagementPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ═══ 리포트 input 셀 입력 모달 (테이블 내 모든 input 셀을 한 번에 편집) ═══ */}
|
||||
<Dialog open={inputCellOpen} onOpenChange={(o) => { if (!o) { setInputCellOpen(false); setInputCellCtx(null); } }}>
|
||||
<DialogContent className="max-h-[85vh] max-w-lg overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>값 입력</DialogTitle>
|
||||
<DialogDescription>
|
||||
이 테이블의 입력 항목을 한 번에 편집할 수 있어요. 빈 값으로 저장하면 해당 항목은 리포트에서 숨겨져요.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 space-y-3 overflow-auto py-2">
|
||||
{inputCellCtx?.cells.map((c) => (
|
||||
<div key={c.id} className="space-y-1">
|
||||
<Label className="text-xs font-semibold">
|
||||
{inputCellCtx ? getInputCellLabel(inputCellCtx.comp, c) : ""}
|
||||
</Label>
|
||||
<Textarea
|
||||
value={inputCellValues[c.id] ?? ""}
|
||||
onChange={(e) => setInputCellValues((prev) => ({ ...prev, [c.id]: e.target.value }))}
|
||||
placeholder={c.inputPlaceholder || "값"}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{inputCellCtx?.cells.length === 0 && (
|
||||
<p className="text-center text-xs text-muted-foreground">입력 가능한 셀이 없어요</p>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => { setInputCellOpen(false); setInputCellCtx(null); }}>취소</Button>
|
||||
<Button onClick={handleInputCellSave} className="gap-1.5">
|
||||
<Save className="h-4 w-4" /> 저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ═══ 담당자(사원) 검색 모달 ═══ */}
|
||||
<Dialog open={userSearchOpen} onOpenChange={setUserSearchOpen}>
|
||||
<DialogContent className="flex max-h-[70vh] max-w-lg flex-col overflow-hidden">
|
||||
|
||||
@@ -311,6 +311,11 @@ export default function SalesItemPage() {
|
||||
|
||||
// 좌측: 품목 조회
|
||||
const fetchItems = useCallback(async () => {
|
||||
// 카테고리 로드 완료 전엔 대기 — 먼저 나간 unfiltered 요청이 나중에 도착해
|
||||
// filtered 결과를 덮어쓰는 race condition 방지
|
||||
if (!categoryOptions["division"]?.length) {
|
||||
return;
|
||||
}
|
||||
setItemLoading(true);
|
||||
try {
|
||||
const filters: { columnName: string; operator: string; value: any }[] = [];
|
||||
@@ -327,7 +332,7 @@ export default function SalesItemPage() {
|
||||
}
|
||||
|
||||
const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
|
||||
page: 1, size: 5000,
|
||||
page: 1, size: 0,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
@@ -373,7 +378,7 @@ export default function SalesItemPage() {
|
||||
try {
|
||||
// 1. customer_item_mapping에서 해당 품목의 매핑 조회
|
||||
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_id", operator: "equals", value: itemKey }] },
|
||||
autoFilter: true,
|
||||
sort: { columnName: "created_date", order: "asc" },
|
||||
@@ -401,7 +406,7 @@ export default function SalesItemPage() {
|
||||
if (mappings.length > 0) {
|
||||
try {
|
||||
const priceRes = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "item_id", operator: "equals", value: itemKey },
|
||||
]},
|
||||
@@ -1111,7 +1116,7 @@ export default function SalesItemPage() {
|
||||
for (const custCode of customerCodes) {
|
||||
// 해당 거래처의 모든 매핑 조회 → item_id null 처리
|
||||
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "item_id", operator: "equals", value: selectedItem!.item_number },
|
||||
{ columnName: "customer_id", operator: "equals", value: custCode },
|
||||
@@ -1128,7 +1133,7 @@ export default function SalesItemPage() {
|
||||
// 해당 거래처의 모든 단가 조회 → item_id null 처리
|
||||
try {
|
||||
const priceRes = await apiClient.post(`/table-management/tables/customer_item_prices/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "item_id", operator: "equals", value: selectedItem!.item_number },
|
||||
{ columnName: "customer_id", operator: "equals", value: custCode },
|
||||
|
||||
@@ -86,6 +86,7 @@ export default function EquipmentInfoPage() {
|
||||
const [inspectionForm, setInspectionForm] = useState<Record<string, any>>({});
|
||||
const [inspectionContinuous, setInspectionContinuous] = useState(false);
|
||||
const [inspectionEditMode, setInspectionEditMode] = useState(false);
|
||||
const [checkedInspectionIds, setCheckedInspectionIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// 소모품 추가/수정 모달
|
||||
const [consumableModalOpen, setConsumableModalOpen] = useState(false);
|
||||
@@ -93,6 +94,7 @@ export default function EquipmentInfoPage() {
|
||||
const [consumableContinuous, setConsumableContinuous] = useState(false);
|
||||
const [consumableEditMode, setConsumableEditMode] = useState(false);
|
||||
const [consumableItemOptions, setConsumableItemOptions] = useState<any[]>([]);
|
||||
const [checkedConsumableIds, setCheckedConsumableIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// 점검항목 복사
|
||||
const [copyModalOpen, setCopyModalOpen] = useState(false);
|
||||
@@ -147,17 +149,17 @@ export default function EquipmentInfoPage() {
|
||||
const colProps: Record<string, Partial<EDataTableColumn>> = {
|
||||
equipment_code: { width: "w-[110px]" },
|
||||
equipment_name: { minWidth: "min-w-[130px]", truncate: true, render: (v) => v || "-" },
|
||||
equipment_type: { width: "w-[90px]", render: (v) => v || "-" },
|
||||
equipment_type: { width: "w-[90px]", render: (v) => resolve("equipment_type", v) || v || "-" },
|
||||
manufacturer: { width: "w-[100px]", render: (v) => v || "-" },
|
||||
installation_location: { width: "w-[100px]", render: (v) => v || "-" },
|
||||
operation_status: { width: "w-[80px]", render: (v) => v || "-" },
|
||||
operation_status: { width: "w-[80px]", render: (v) => resolve("operation_status", v) || v || "-" },
|
||||
};
|
||||
return ts.visibleColumns.map((col) => ({
|
||||
key: col.key,
|
||||
label: col.label,
|
||||
...colProps[col.key],
|
||||
}));
|
||||
}, [ts.visibleColumns]);
|
||||
}, [ts.visibleColumns, catOptions]);
|
||||
|
||||
// 설비 조회
|
||||
const fetchEquipments = useCallback(async () => {
|
||||
@@ -165,16 +167,12 @@ export default function EquipmentInfoPage() {
|
||||
try {
|
||||
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
|
||||
const res = await apiClient.post(`/table-management/tables/${EQUIP_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
const raw = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
setEquipments(raw.map((r: any) => ({
|
||||
...r,
|
||||
equipment_type: resolve("equipment_type", r.equipment_type),
|
||||
operation_status: resolve("operation_status", r.operation_status),
|
||||
})));
|
||||
setEquipments(raw);
|
||||
setEquipCount(res.data?.data?.total || raw.length);
|
||||
} catch { toast.error("설비 목록 조회 실패"); } finally { setEquipLoading(false); }
|
||||
}, [searchFilters, catOptions]);
|
||||
@@ -204,12 +202,13 @@ export default function EquipmentInfoPage() {
|
||||
|
||||
// 우측: 점검항목 조회
|
||||
useEffect(() => {
|
||||
setCheckedInspectionIds(new Set());
|
||||
if (!selectedEquip?.equipment_code) { setInspections([]); return; }
|
||||
const fetchData = async () => {
|
||||
setInspectionLoading(true);
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "equals", value: selectedEquip.equipment_code }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
@@ -221,12 +220,13 @@ export default function EquipmentInfoPage() {
|
||||
|
||||
// 우측: 소모품 조회
|
||||
useEffect(() => {
|
||||
setCheckedConsumableIds(new Set());
|
||||
if (!selectedEquip?.equipment_code) { setConsumables([]); return; }
|
||||
const fetchData = async () => {
|
||||
setConsumableLoading(true);
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/${CONSUMABLE_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "equals", value: selectedEquip.equipment_code }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
@@ -296,6 +296,34 @@ export default function EquipmentInfoPage() {
|
||||
} catch { toast.error("삭제 실패"); }
|
||||
};
|
||||
|
||||
// 점검항목 삭제
|
||||
const handleInspectionDelete = async () => {
|
||||
const ids = Array.from(checkedInspectionIds);
|
||||
if (ids.length === 0) { toast.error("삭제할 점검항목을 선택해주세요."); return; }
|
||||
const ok = await confirm(`선택한 ${ids.length}건의 점검항목을 삭제하시겠습니까?`, { variant: "destructive", confirmText: "삭제" });
|
||||
if (!ok) return;
|
||||
try {
|
||||
await apiClient.delete(`/table-management/tables/${INSPECTION_TABLE}/delete`, { data: ids.map((id) => ({ id })) });
|
||||
toast.success("삭제되었습니다.");
|
||||
setCheckedInspectionIds(new Set());
|
||||
refreshRight();
|
||||
} catch { toast.error("삭제 실패"); }
|
||||
};
|
||||
|
||||
// 소모품 삭제
|
||||
const handleConsumableDelete = async () => {
|
||||
const ids = Array.from(checkedConsumableIds);
|
||||
if (ids.length === 0) { toast.error("삭제할 소모품을 선택해주세요."); return; }
|
||||
const ok = await confirm(`선택한 ${ids.length}건의 소모품을 삭제하시겠습니까?`, { variant: "destructive", confirmText: "삭제" });
|
||||
if (!ok) return;
|
||||
try {
|
||||
await apiClient.delete(`/table-management/tables/${CONSUMABLE_TABLE}/delete`, { data: ids.map((id) => ({ id })) });
|
||||
toast.success("삭제되었습니다.");
|
||||
setCheckedConsumableIds(new Set());
|
||||
refreshRight();
|
||||
} catch { toast.error("삭제 실패"); }
|
||||
};
|
||||
|
||||
// 점검항목 추가
|
||||
const handleInspectionSave = async () => {
|
||||
if (!inspectionForm.inspection_item) { toast.error("점검항목명은 필수입니다."); return; }
|
||||
@@ -362,7 +390,7 @@ export default function EquipmentInfoPage() {
|
||||
if (consumableDiv) filters.push({ columnName: "division", operator: "equals", value: consumableDiv.valueCode });
|
||||
const results = await Promise.all(filters.map((f) =>
|
||||
apiClient.post(`/table-management/tables/item_info/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [f] },
|
||||
autoFilter: true,
|
||||
})
|
||||
@@ -409,7 +437,7 @@ export default function EquipmentInfoPage() {
|
||||
setCopyLoading(true);
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "equals", value: equipCode }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
@@ -437,9 +465,9 @@ export default function EquipmentInfoPage() {
|
||||
const handleExcelDownload = async () => {
|
||||
if (equipments.length === 0) return;
|
||||
await exportToExcel(equipments.map((e) => ({
|
||||
설비코드: e.equipment_code, 설비명: e.equipment_name, 설비유형: e.equipment_type,
|
||||
설비코드: e.equipment_code, 설비명: e.equipment_name, 설비유형: resolve("equipment_type", e.equipment_type),
|
||||
제조사: e.manufacturer, 모델명: e.model_name, 설치장소: e.installation_location,
|
||||
도입일자: e.introduction_date, 가동상태: e.operation_status,
|
||||
도입일자: e.introduction_date, 가동상태: resolve("operation_status", e.operation_status),
|
||||
})), "설비정보.xlsx", "설비");
|
||||
toast.success("다운로드 완료");
|
||||
};
|
||||
@@ -550,15 +578,23 @@ export default function EquipmentInfoPage() {
|
||||
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setInspectionForm({}); setInspectionEditMode(false); setInspectionModalOpen(true); }}>
|
||||
<Plus className="w-3.5 h-3.5 mr-1" /> 추가
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={checkedInspectionIds.size === 0} onClick={handleInspectionDelete} className="text-destructive border-destructive/20 bg-destructive/5 hover:bg-destructive/10">
|
||||
<Trash2 className="w-3.5 h-3.5 mr-1" /> 삭제
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setCopySourceEquip(""); setCopyItems([]); setCopyChecked(new Set()); setCopyModalOpen(true); }}>
|
||||
<Copy className="w-3.5 h-3.5 mr-1" /> 복사
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{rightTab === "consumable" && (
|
||||
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setConsumableForm({}); setConsumableEditMode(false); loadConsumableItems(); setConsumableModalOpen(true); }}>
|
||||
<Plus className="w-3.5 h-3.5 mr-1" /> 추가
|
||||
</Button>
|
||||
<>
|
||||
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setConsumableForm({}); setConsumableEditMode(false); loadConsumableItems(); setConsumableModalOpen(true); }}>
|
||||
<Plus className="w-3.5 h-3.5 mr-1" /> 추가
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={checkedConsumableIds.size === 0} onClick={handleConsumableDelete} className="text-destructive border-destructive/20 bg-destructive/5 hover:bg-destructive/10">
|
||||
<Trash2 className="w-3.5 h-3.5 mr-1" /> 삭제
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -637,6 +673,16 @@ export default function EquipmentInfoPage() {
|
||||
<Table noWrapper>
|
||||
<thead className="sticky top-0 z-10 bg-card">
|
||||
<TableRow>
|
||||
<TableHead
|
||||
className="w-[40px] text-center cursor-pointer"
|
||||
onClick={() => {
|
||||
const allChecked = inspections.length > 0 && checkedInspectionIds.size === inspections.length;
|
||||
if (allChecked) setCheckedInspectionIds(new Set());
|
||||
else setCheckedInspectionIds(new Set(inspections.map((i) => i.id)));
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={inspections.length > 0 && checkedInspectionIds.size === inspections.length} />
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">점검항목</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">점검주기</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">점검방법</TableHead>
|
||||
@@ -664,6 +710,20 @@ export default function EquipmentInfoPage() {
|
||||
setInspectionEditMode(true);
|
||||
setInspectionModalOpen(true);
|
||||
}}>
|
||||
<TableCell
|
||||
className="text-center cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setCheckedInspectionIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(item.id)) next.delete(item.id); else next.add(item.id);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Checkbox checked={checkedInspectionIds.has(item.id)} />
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{item.inspection_item || "-"}</TableCell>
|
||||
<TableCell className="text-[13px]">{resolve("inspection_cycle", item.inspection_cycle)}</TableCell>
|
||||
<TableCell className="text-[13px]">{resolve("inspection_method", item.inspection_method)}</TableCell>
|
||||
@@ -692,6 +752,16 @@ export default function EquipmentInfoPage() {
|
||||
<Table noWrapper>
|
||||
<thead className="sticky top-0 z-10 bg-card">
|
||||
<TableRow>
|
||||
<TableHead
|
||||
className="w-[40px] text-center cursor-pointer"
|
||||
onClick={() => {
|
||||
const allChecked = consumables.length > 0 && checkedConsumableIds.size === consumables.length;
|
||||
if (allChecked) setCheckedConsumableIds(new Set());
|
||||
else setCheckedConsumableIds(new Set(consumables.map((i) => i.id)));
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={consumables.length > 0 && checkedConsumableIds.size === consumables.length} />
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">소모품명</TableHead>
|
||||
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">교체주기</TableHead>
|
||||
<TableHead className="w-[60px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">단위</TableHead>
|
||||
@@ -707,6 +777,20 @@ export default function EquipmentInfoPage() {
|
||||
loadConsumableItems();
|
||||
setConsumableModalOpen(true);
|
||||
}}>
|
||||
<TableCell
|
||||
className="text-center cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setCheckedConsumableIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(item.id)) next.delete(item.id); else next.add(item.id);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Checkbox checked={checkedConsumableIds.has(item.id)} />
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{item.consumable_name || "-"}</TableCell>
|
||||
<TableCell className="text-[13px]">{item.replacement_cycle || "-"}</TableCell>
|
||||
<TableCell className="text-[13px]">{item.unit || "-"}</TableCell>
|
||||
|
||||
@@ -83,7 +83,7 @@ export default function EquipmentInspectionRecordPage() {
|
||||
}).catch(() => ({ data: { data: { data: [] } } })),
|
||||
apiClient.post(`/table-management/tables/equipment_mng/data`, {
|
||||
page: 1,
|
||||
size: 500,
|
||||
size: 0,
|
||||
autoFilter: true,
|
||||
}).catch(() => ({ data: { data: { data: [] } } })),
|
||||
]);
|
||||
|
||||
@@ -100,7 +100,7 @@ export default function PlcSettingsPage() {
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const eqRes = await apiClient.post(`/table-management/tables/${EQUIPMENT_TABLE}/data`, { page: 1, size: 500, autoFilter: true });
|
||||
const eqRes = await apiClient.post(`/table-management/tables/${EQUIPMENT_TABLE}/data`, { page: 1, size: 0, autoFilter: true });
|
||||
const eqs = eqRes.data?.data?.data || eqRes.data?.data?.rows || [];
|
||||
setEquipOptions(eqs.map((r: any) => ({ code: r.equipment_code, label: `${r.equipment_code} - ${r.equipment_name || ""}` })));
|
||||
} catch { /* skip */ }
|
||||
@@ -122,7 +122,7 @@ export default function PlcSettingsPage() {
|
||||
const filters: any[] = [];
|
||||
if (kw.trim()) filters.push({ columnName: "equipment_code", operator: "contains", value: kw.trim() });
|
||||
const res = await apiClient.post(`/table-management/tables/${DATATYPE_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
@@ -140,7 +140,7 @@ export default function PlcSettingsPage() {
|
||||
const filters: any[] = [];
|
||||
if (kw.trim()) filters.push({ columnName: "config_name", operator: "contains", value: kw.trim() });
|
||||
const res = await apiClient.post(`/table-management/tables/${COLLECTION_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
|
||||
@@ -150,7 +150,7 @@ export default function InboundOutboundPage() {
|
||||
if (writerIds.length > 0) {
|
||||
try {
|
||||
const userRes = await apiClient.post(`/table-management/tables/user_info/data`, {
|
||||
page: 1, size: 500, autoFilter: true,
|
||||
page: 1, size: 0, autoFilter: true,
|
||||
});
|
||||
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
|
||||
const uMap: Record<string, string> = {};
|
||||
|
||||
@@ -327,11 +327,11 @@ export default function LogisticsInfoPage() {
|
||||
try {
|
||||
const [carrierRes, routeRes] = await Promise.all([
|
||||
apiClient.post("/table-management/tables/carrier_mng/data", {
|
||||
page: 1, size: 500, autoFilter: true,
|
||||
page: 1, size: 0, autoFilter: true,
|
||||
sort: { columnName: "carrier_code", order: "asc" },
|
||||
}),
|
||||
apiClient.post("/table-management/tables/delivery_route_mng/data", {
|
||||
page: 1, size: 500, autoFilter: true,
|
||||
page: 1, size: 0, autoFilter: true,
|
||||
sort: { columnName: "route_code", order: "asc" },
|
||||
}),
|
||||
]);
|
||||
@@ -358,13 +358,15 @@ export default function LogisticsInfoPage() {
|
||||
loadReferences();
|
||||
}, [loadReferences]);
|
||||
|
||||
// 카테고리 옵션 로드
|
||||
// 카테고리 옵션 로드 (관리자 계정일 때 filterCompanyCode 미제공 시 "*" 스코프로 빈 결과 반환됨)
|
||||
const loadCategoryOptions = useCallback(async (tableColumn: string) => {
|
||||
if (loadedCategories.current.has(tableColumn)) return;
|
||||
loadedCategories.current.add(tableColumn);
|
||||
const [tableName, columnName] = tableColumn.split(":");
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/${tableName}/${columnName}/values`);
|
||||
const res = await apiClient.get(
|
||||
`/table-categories/${tableName}/${columnName}/values?filterCompanyCode=COMPANY_29`
|
||||
);
|
||||
const data = res.data?.data || [];
|
||||
setCategoryOptions((prev) => ({
|
||||
...prev,
|
||||
@@ -393,7 +395,7 @@ export default function LogisticsInfoPage() {
|
||||
const res = await apiClient.post(
|
||||
`/table-management/tables/${config.tableName}/data`,
|
||||
{
|
||||
page: 1, size: 500, autoFilter: true,
|
||||
page: 1, size: 0, autoFilter: true,
|
||||
sort: { columnName: config.defaultSortColumn, order: "asc" },
|
||||
}
|
||||
);
|
||||
@@ -823,13 +825,24 @@ export default function LogisticsInfoPage() {
|
||||
{/* 테이블 영역 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<EDataTable
|
||||
columns={getVisibleColumns(tab.key).map((col): EDataTableColumn => ({
|
||||
key: col.key,
|
||||
label: col.label,
|
||||
align: col.align,
|
||||
formatNumber: col.formatNumber,
|
||||
truncate: true,
|
||||
}))}
|
||||
columns={getVisibleColumns(tab.key).map((col): EDataTableColumn => {
|
||||
// 같은 key의 formField에 categoryKey가 있으면 코드→라벨 변환
|
||||
const formField = tab.formFields.find((f) => f.key === col.key && f.categoryKey);
|
||||
return {
|
||||
key: col.key,
|
||||
label: col.label,
|
||||
align: col.align,
|
||||
formatNumber: col.formatNumber,
|
||||
truncate: true,
|
||||
render: formField?.categoryKey
|
||||
? (value: any) => {
|
||||
const opts = categoryOptions[formField.categoryKey!] || [];
|
||||
const matched = opts.find((o: any) => o.value === value);
|
||||
return matched?.label || value || "-";
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
})}
|
||||
data={tsMap[tab.key].groupData(displayData)}
|
||||
rowKey={(row: any) => String(row.id)}
|
||||
loading={tabLoading[tab.key]}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user