Merge pull request 'jskim-node' (#31) from jskim-node into main
Reviewed-on: https://g.wace.me/jskim/vexplor_dev/pulls/31
This commit is contained in:
@@ -157,6 +157,7 @@ import shippingOrderRoutes from "./routes/shippingOrderRoutes"; // 출하지시
|
||||
import workInstructionRoutes from "./routes/workInstructionRoutes"; // 작업지시 관리
|
||||
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)
|
||||
@@ -381,6 +382,7 @@ app.use("/api/shipping-order", shippingOrderRoutes); // 출하지시 관리
|
||||
app.use("/api/work-instruction", workInstructionRoutes); // 작업지시 관리
|
||||
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); // 설계 모듈
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -384,26 +384,33 @@ export async function getRoutingDetails(req: AuthenticatedRequest, res: Response
|
||||
|
||||
const rows = result.rows;
|
||||
const detailIds = rows.map((r: any) => r.id).filter(Boolean);
|
||||
let mappingByDetail: Record<string, string[]> = {};
|
||||
let idsByDetail: Record<string, string[]> = {};
|
||||
let codesByDetail: Record<string, string[]> = {};
|
||||
if (detailIds.length > 0) {
|
||||
const mapRes = await pool.query(
|
||||
`SELECT routing_detail_id, subcontractor_code
|
||||
FROM item_routing_subcontractor
|
||||
WHERE routing_detail_id = ANY($1::uuid[])
|
||||
ORDER BY seq_order`,
|
||||
`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);
|
||||
if (!mappingByDetail[key]) mappingByDetail[key] = [];
|
||||
mappingByDetail[key].push(m.subcontractor_code);
|
||||
(idsByDetail[key] ||= []).push(m.subcontractor_id);
|
||||
if (m.subcontractor_code) (codesByDetail[key] ||= []).push(m.subcontractor_code);
|
||||
}
|
||||
}
|
||||
const enriched = rows.map((r: any) => {
|
||||
const list = mappingByDetail[String(r.id)] || [];
|
||||
// 레거시 폴백: 매핑이 비어있고 legacy 단일 컬럼에 값이 있으면 배열로 포장
|
||||
if (list.length === 0 && r.outsource_supplier) list.push(r.outsource_supplier);
|
||||
return { ...r, outsource_supplier_list: list };
|
||||
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 });
|
||||
@@ -440,24 +447,36 @@ export async function saveRoutingDetails(req: AuthenticatedRequest, res: Respons
|
||||
);
|
||||
|
||||
for (const d of details) {
|
||||
const suppliers: string[] = Array.isArray(d.outsource_supplier_list)
|
||||
? d.outsource_supplier_list.filter((s: any) => typeof s === "string" && s.trim() !== "")
|
||||
: (d.outsource_supplier ? [d.outsource_supplier] : []);
|
||||
const primaryLegacy = suppliers[0] || d.outsource_supplier || "";
|
||||
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)
|
||||
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", primaryLegacy, writer]
|
||||
[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 < suppliers.length; i++) {
|
||||
for (let i = 0; i < supplierIds.length; i++) {
|
||||
await client.query(
|
||||
`INSERT INTO item_routing_subcontractor (id, company_code, routing_detail_id, subcontractor_code, seq_order)
|
||||
VALUES (gen_random_uuid(), $1, $2, $3, $4)`,
|
||||
[companyCode, newDetailId, suppliers[i], i]
|
||||
`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]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,6 +473,45 @@ export async function update(req: AuthenticatedRequest, res: Response) {
|
||||
|
||||
await client.query("BEGIN");
|
||||
|
||||
// 변경 전 값 조회 (헤더)
|
||||
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
|
||||
@@ -506,13 +546,6 @@ export async function update(req: AuthenticatedRequest, res: Response) {
|
||||
],
|
||||
);
|
||||
|
||||
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) {
|
||||
@@ -563,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 });
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,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 +59,110 @@ 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,
|
||||
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,
|
||||
@@ -97,17 +198,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) {
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
@@ -200,6 +202,7 @@ export default function EquipmentInfoPage() {
|
||||
|
||||
// 우측: 점검항목 조회
|
||||
useEffect(() => {
|
||||
setCheckedInspectionIds(new Set());
|
||||
if (!selectedEquip?.equipment_code) { setInspections([]); return; }
|
||||
const fetchData = async () => {
|
||||
setInspectionLoading(true);
|
||||
@@ -217,6 +220,7 @@ export default function EquipmentInfoPage() {
|
||||
|
||||
// 우측: 소모품 조회
|
||||
useEffect(() => {
|
||||
setCheckedConsumableIds(new Set());
|
||||
if (!selectedEquip?.equipment_code) { setConsumables([]); return; }
|
||||
const fetchData = async () => {
|
||||
setConsumableLoading(true);
|
||||
@@ -292,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; }
|
||||
@@ -546,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>
|
||||
@@ -633,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>
|
||||
@@ -660,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>
|
||||
@@ -688,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>
|
||||
@@ -703,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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
// 카테고리 옵션
|
||||
@@ -201,8 +216,9 @@ export default function InventoryStatusPage() {
|
||||
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();
|
||||
@@ -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">
|
||||
|
||||
@@ -528,9 +528,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 +570,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>
|
||||
@@ -635,14 +635,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 +716,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 +781,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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1772,7 +1772,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>
|
||||
{/* 규격 */}
|
||||
|
||||
+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>
|
||||
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
@@ -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 즉시 생성 → 자동 선택 ──
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
// 카테고리 옵션
|
||||
@@ -201,8 +216,9 @@ export default function InventoryStatusPage() {
|
||||
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();
|
||||
@@ -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">
|
||||
|
||||
@@ -528,9 +528,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 +570,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>
|
||||
@@ -635,14 +635,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 +716,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 +781,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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1772,7 +1772,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>
|
||||
{/* 규격 */}
|
||||
|
||||
+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>
|
||||
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
@@ -200,6 +202,7 @@ export default function EquipmentInfoPage() {
|
||||
|
||||
// 우측: 점검항목 조회
|
||||
useEffect(() => {
|
||||
setCheckedInspectionIds(new Set());
|
||||
if (!selectedEquip?.equipment_code) { setInspections([]); return; }
|
||||
const fetchData = async () => {
|
||||
setInspectionLoading(true);
|
||||
@@ -217,6 +220,7 @@ export default function EquipmentInfoPage() {
|
||||
|
||||
// 우측: 소모품 조회
|
||||
useEffect(() => {
|
||||
setCheckedConsumableIds(new Set());
|
||||
if (!selectedEquip?.equipment_code) { setConsumables([]); return; }
|
||||
const fetchData = async () => {
|
||||
setConsumableLoading(true);
|
||||
@@ -292,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; }
|
||||
@@ -546,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>
|
||||
@@ -633,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>
|
||||
@@ -660,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>
|
||||
@@ -688,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>
|
||||
@@ -703,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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
// 카테고리 옵션
|
||||
@@ -201,8 +216,9 @@ export default function InventoryStatusPage() {
|
||||
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();
|
||||
@@ -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">
|
||||
|
||||
@@ -528,9 +528,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 +570,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>
|
||||
@@ -635,14 +635,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 +716,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 +781,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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1772,7 +1772,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>
|
||||
{/* 규격 */}
|
||||
|
||||
+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>
|
||||
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
@@ -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 즉시 생성 → 자동 선택 ──
|
||||
|
||||
|
||||
@@ -84,6 +84,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);
|
||||
@@ -91,6 +92,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);
|
||||
@@ -198,6 +200,7 @@ export default function EquipmentInfoPage() {
|
||||
|
||||
// 우측: 점검항목 조회
|
||||
useEffect(() => {
|
||||
setCheckedInspectionIds(new Set());
|
||||
if (!selectedEquip?.equipment_code) { setInspections([]); return; }
|
||||
const fetchData = async () => {
|
||||
setInspectionLoading(true);
|
||||
@@ -215,6 +218,7 @@ export default function EquipmentInfoPage() {
|
||||
|
||||
// 우측: 소모품 조회
|
||||
useEffect(() => {
|
||||
setCheckedConsumableIds(new Set());
|
||||
if (!selectedEquip?.equipment_code) { setConsumables([]); return; }
|
||||
const fetchData = async () => {
|
||||
setConsumableLoading(true);
|
||||
@@ -270,6 +274,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; }
|
||||
@@ -524,15 +556,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>
|
||||
@@ -611,6 +651,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>
|
||||
@@ -638,6 +688,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>
|
||||
@@ -666,6 +730,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>
|
||||
@@ -681,6 +755,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>
|
||||
|
||||
@@ -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 },
|
||||
@@ -90,6 +92,8 @@ const getStatusVariant = (
|
||||
return "destructive";
|
||||
case "과잉":
|
||||
return "secondary";
|
||||
case "미등록":
|
||||
return "outline";
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
@@ -122,6 +126,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[]>([]);
|
||||
|
||||
@@ -135,7 +148,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);
|
||||
|
||||
// 카테고리 옵션
|
||||
@@ -204,8 +219,9 @@ export default function InventoryStatusPage() {
|
||||
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 || "", width: i.width || "", height: i.height || "", thickness: i.thickness || "" }]));
|
||||
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 || "", width: i.width || "", height: i.height || "", thickness: i.thickness || "" }]));
|
||||
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;
|
||||
@@ -216,6 +232,7 @@ export default function InventoryStatusPage() {
|
||||
return {
|
||||
...r,
|
||||
item_name: itemInfo?.name || "",
|
||||
spec: itemInfo?.spec || "",
|
||||
width: itemInfo?.width || "",
|
||||
height: itemInfo?.height || "",
|
||||
thickness: itemInfo?.thickness || "",
|
||||
@@ -225,13 +242,46 @@ export default function InventoryStatusPage() {
|
||||
_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 || "",
|
||||
width: i.width || "",
|
||||
height: i.height || "",
|
||||
thickness: i.thickness || "",
|
||||
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();
|
||||
@@ -285,6 +335,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;
|
||||
@@ -297,6 +376,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;
|
||||
@@ -307,8 +400,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),
|
||||
@@ -317,17 +410,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("재고 조정에 실패했어요");
|
||||
@@ -391,6 +500,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,
|
||||
@@ -444,6 +554,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
|
||||
@@ -519,6 +636,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);
|
||||
}}
|
||||
@@ -678,6 +797,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">
|
||||
조정 유형
|
||||
@@ -687,6 +868,7 @@ export default function InventoryStatusPage() {
|
||||
onValueChange={(v) =>
|
||||
setAdjustForm((prev) => ({ ...prev, adjust_type: v }))
|
||||
}
|
||||
disabled={!!selectedStock?._isMissing}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="조정 유형 선택" />
|
||||
@@ -696,6 +878,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">
|
||||
|
||||
@@ -528,9 +528,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 +570,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>
|
||||
@@ -635,14 +635,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 +716,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 +781,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>
|
||||
)}
|
||||
|
||||
@@ -280,6 +280,8 @@ interface SelectedSourceItem {
|
||||
total_amount: number;
|
||||
source_table: string;
|
||||
source_id: string;
|
||||
detail_id?: string;
|
||||
header_id?: string;
|
||||
}
|
||||
|
||||
export default function ReceivingPage() {
|
||||
@@ -616,7 +618,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) : "");
|
||||
@@ -626,8 +628,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 || "",
|
||||
@@ -814,7 +818,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,
|
||||
@@ -822,6 +826,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
|
||||
|
||||
@@ -297,7 +297,11 @@ export default function BomManagementPage() {
|
||||
const [itemSearchKeyword, setItemSearchKeyword] = useState("");
|
||||
const [itemSearchResults, setItemSearchResults] = useState<any[]>([]);
|
||||
const [itemSearchLoading, setItemSearchLoading] = useState(false);
|
||||
const [itemSearchTarget, setItemSearchTarget] = useState<"master" | "detail">("master");
|
||||
const [itemSearchTarget, setItemSearchTarget] = useState<"master" | "detail" | "tree">("master");
|
||||
// 서버사이드 페이지네이션
|
||||
const [itemSearchPage, setItemSearchPage] = useState(1);
|
||||
const [itemSearchPageSize] = useState(20);
|
||||
const [itemSearchTotal, setItemSearchTotal] = useState(0);
|
||||
|
||||
// 엑셀 업로드
|
||||
const [showExcelUpload, setShowExcelUpload] = useState(false);
|
||||
@@ -780,11 +784,14 @@ export default function BomManagementPage() {
|
||||
setTreeHasChanges(true);
|
||||
};
|
||||
|
||||
// 하위 품목 추가 시작
|
||||
// 하위 품목 추가 시작 (BOM 등록 품목검색 모달 재사용)
|
||||
const handleTreeAddChild = (parentId: string | null) => {
|
||||
setAddTargetParentId(parentId);
|
||||
setTreeItemSearchOpen(true);
|
||||
searchItems("");
|
||||
setItemSearchTarget("tree");
|
||||
setItemSearchKeyword("");
|
||||
setItemSearchPage(1);
|
||||
setShowItemSearchModal(true);
|
||||
searchItems(1);
|
||||
};
|
||||
|
||||
// 트리 품목 선택 완료 (트리에 추가)
|
||||
@@ -1191,33 +1198,37 @@ export default function BomManagementPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// ─── 품목 검색 ───────────────────────────────
|
||||
const searchItems = async (keyword?: string) => {
|
||||
// ─── 품목 검색 (서버 페이지네이션) ───────────────────────────────
|
||||
const searchItems = async (pageOverride?: number, keyword?: string) => {
|
||||
const kw = (keyword ?? itemSearchKeyword).trim();
|
||||
const pageCandidate = typeof pageOverride === "number" && Number.isFinite(pageOverride) && pageOverride > 0
|
||||
? pageOverride
|
||||
: itemSearchPage;
|
||||
const page = Number.isFinite(pageCandidate) && pageCandidate > 0 ? pageCandidate : 1;
|
||||
setItemSearchLoading(true);
|
||||
try {
|
||||
// 키워드를 품명 또는 품목코드 어느 쪽에든 매칭 (OR 조건 불가 시 품명 우선)
|
||||
const filters: any[] = [];
|
||||
if (kw) {
|
||||
filters.push({ columnName: "item_name", operator: "contains", value: kw });
|
||||
}
|
||||
if (kw) filters.push({ columnName: "item_name", operator: "contains", value: kw });
|
||||
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||||
page: 1,
|
||||
size: 50,
|
||||
page, size: itemSearchPageSize,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
let rows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
let total = res.data?.data?.total || 0;
|
||||
|
||||
// 키워드가 있고 품명으로 못 찾으면 품목코드로 재시도
|
||||
// 키워드가 있고 품명 매칭이 없으면 품목코드로 재시도
|
||||
if (kw && rows.length === 0) {
|
||||
const res2 = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||||
page: 1, size: 50,
|
||||
page, size: itemSearchPageSize,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "contains", value: kw }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
rows = res2.data?.data?.data || res2.data?.data?.rows || [];
|
||||
total = res2.data?.data?.total || 0;
|
||||
}
|
||||
// 카테고리 코드 → 라벨 변환 (division + inventory_unit)
|
||||
// 렌더 전 코드 → 라벨 변환 (division + inventory_unit)
|
||||
const resolved = rows.map((r: any) => {
|
||||
const out = { ...r };
|
||||
if (out.division) {
|
||||
@@ -1229,9 +1240,17 @@ export default function BomManagementPage() {
|
||||
if (out.inventory_unit) {
|
||||
out.inventory_unit = categoryOptions["inventory_unit"]?.find((o) => o.code === out.inventory_unit)?.label || out.inventory_unit;
|
||||
}
|
||||
// 규격: width x height x thickness 우선, 없으면 size 필드
|
||||
const w = out.width, h = out.height, t = out.thickness;
|
||||
if (w || h || t) {
|
||||
out._spec = [w || "-", h || "-", t || "-"].join(" x ");
|
||||
} else {
|
||||
out._spec = out.size || "";
|
||||
}
|
||||
return out;
|
||||
});
|
||||
setItemSearchResults(resolved);
|
||||
setItemSearchTotal(total);
|
||||
} catch {
|
||||
toast.error("품목 검색에 실패했어요");
|
||||
} finally {
|
||||
@@ -1239,6 +1258,12 @@ export default function BomManagementPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// 페이지 변경 시 재조회
|
||||
useEffect(() => {
|
||||
if (showItemSearchModal) searchItems();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [itemSearchPage]);
|
||||
|
||||
const resolveUnit = (code: string) => {
|
||||
if (!code) return "";
|
||||
return categoryOptions["inventory_unit"]?.find((o) => o.code === code)?.label || code;
|
||||
@@ -1246,6 +1271,13 @@ export default function BomManagementPage() {
|
||||
|
||||
const selectItem = (item: any) => {
|
||||
const unitLabel = resolveUnit(item.inventory_unit);
|
||||
if (itemSearchTarget === "tree") {
|
||||
handleTreeItemSelect(item);
|
||||
setShowItemSearchModal(false);
|
||||
setItemSearchKeyword("");
|
||||
setItemSearchResults([]);
|
||||
return;
|
||||
}
|
||||
if (itemSearchTarget === "master") {
|
||||
setMasterForm((prev) => ({
|
||||
...prev,
|
||||
@@ -1772,7 +1804,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>
|
||||
{/* 규격 */}
|
||||
@@ -1972,7 +2004,7 @@ export default function BomManagementPage() {
|
||||
setItemSearchTarget("master");
|
||||
setItemSearchKeyword("");
|
||||
setShowItemSearchModal(true);
|
||||
searchItems("");
|
||||
searchItems();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
@@ -1983,7 +2015,7 @@ export default function BomManagementPage() {
|
||||
setItemSearchTarget("master");
|
||||
setItemSearchKeyword("");
|
||||
setShowItemSearchModal(true);
|
||||
searchItems("");
|
||||
searchItems();
|
||||
}}
|
||||
>
|
||||
<Search className="w-3.5 h-3.5" />
|
||||
@@ -2084,7 +2116,7 @@ export default function BomManagementPage() {
|
||||
setItemSearchTarget("detail");
|
||||
setItemSearchKeyword("");
|
||||
setShowItemSearchModal(true);
|
||||
searchItems("");
|
||||
searchItems();
|
||||
}}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5 mr-1" />
|
||||
@@ -2186,8 +2218,8 @@ export default function BomManagementPage() {
|
||||
</Dialog>
|
||||
|
||||
{/* ─── 품목 검색 모달 ──────────────────────── */}
|
||||
<Dialog open={showItemSearchModal} onOpenChange={setShowItemSearchModal}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<Dialog open={showItemSearchModal} onOpenChange={(o) => { if (!o) { setItemSearchKeyword(""); setItemSearchResults([]); setItemSearchPage(1); setItemSearchTotal(0); } setShowItemSearchModal(o); }}>
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>품목 검색</DialogTitle>
|
||||
<DialogDescription>품목코드 또는 품명으로 검색해주세요</DialogDescription>
|
||||
@@ -2198,13 +2230,13 @@ export default function BomManagementPage() {
|
||||
placeholder="품목코드 또는 품명 입력"
|
||||
value={itemSearchKeyword}
|
||||
onChange={(e) => setItemSearchKeyword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && searchItems()}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") { setItemSearchPage(1); searchItems(1); } }}
|
||||
/>
|
||||
<Button size="sm" className="h-9" onClick={() => searchItems()} disabled={itemSearchLoading}>
|
||||
<Button size="sm" className="h-9" onClick={() => { setItemSearchPage(1); searchItems(1); }} disabled={itemSearchLoading}>
|
||||
{itemSearchLoading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Search className="w-3.5 h-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-[300px] overflow-auto border rounded-lg">
|
||||
<div className="max-h-[320px] overflow-auto border rounded-lg">
|
||||
{itemSearchResults.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground gap-1">
|
||||
<Search className="w-6 h-6 text-muted-foreground/40" />
|
||||
@@ -2216,20 +2248,35 @@ export default function BomManagementPage() {
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center gap-3 px-3 py-2 border-b last:border-b-0 hover:bg-accent cursor-pointer transition-colors"
|
||||
className="flex items-center gap-2 px-3 py-2 border-b last:border-b-0 hover:bg-accent cursor-pointer transition-colors"
|
||||
onClick={() => selectItem(item)}
|
||||
>
|
||||
<span className={cn("text-[10px] font-semibold px-1.5 py-0.5 rounded shrink-0", badge.className)}>
|
||||
{badge.label}
|
||||
</span>
|
||||
<span className="font-mono text-[11px] text-muted-foreground">{item.item_number}</span>
|
||||
<span className="text-xs">{item.item_name}</span>
|
||||
<span className="ml-auto text-[11px] text-muted-foreground">{item.inventory_unit || ""}</span>
|
||||
<span className="font-mono text-[11px] text-muted-foreground shrink-0">{item.item_number}</span>
|
||||
<span className="text-xs truncate flex-1">{item.item_name}</span>
|
||||
{item._spec && (
|
||||
<span className="text-[11px] text-muted-foreground font-mono shrink-0" title={item._spec}>[{item._spec}]</span>
|
||||
)}
|
||||
<span className="text-[11px] text-muted-foreground shrink-0">{item.inventory_unit || ""}</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
{/* 페이지네이션 */}
|
||||
{itemSearchTotal > 0 && (
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground px-1 pt-2">
|
||||
<span>전체 <b className="text-foreground">{itemSearchTotal.toLocaleString()}</b>건 · {itemSearchPage}/{Math.max(1, Math.ceil(itemSearchTotal / itemSearchPageSize))} 페이지</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button size="sm" variant="outline" className="h-7 px-2" disabled={itemSearchPage === 1 || itemSearchLoading} onClick={() => setItemSearchPage(1)}>«</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 px-2" disabled={itemSearchPage === 1 || itemSearchLoading} onClick={() => setItemSearchPage((p) => Math.max(1, p - 1))}>‹</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 px-2" disabled={itemSearchPage >= Math.ceil(itemSearchTotal / itemSearchPageSize) || itemSearchLoading} onClick={() => setItemSearchPage((p) => p + 1)}>›</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 px-2" disabled={itemSearchPage >= Math.ceil(itemSearchTotal / itemSearchPageSize) || itemSearchLoading} onClick={() => setItemSearchPage(Math.max(1, Math.ceil(itemSearchTotal / itemSearchPageSize)))}>»</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
|
||||
@@ -89,6 +89,7 @@ export default function ProductionResultPage() {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [pageSizeInput, setPageSizeInput] = useState("20");
|
||||
const [wiTotalCount, setWiTotalCount] = useState(0);
|
||||
|
||||
// ── 우측: 실적 ──
|
||||
const [rightTab, setRightTab] = useState<"result" | "defect">("result");
|
||||
@@ -135,7 +136,7 @@ export default function ProductionResultPage() {
|
||||
const fetchWiList = useCallback(async () => {
|
||||
setWiLoading(true);
|
||||
try {
|
||||
const params: Record<string, string> = {};
|
||||
const params: Record<string, string> = { page: String(currentPage), pageSize: String(pageSize) };
|
||||
for (const f of searchFilters) {
|
||||
if (f.value) {
|
||||
if (f.columnName === "progress_status") params.progressStatus = f.value;
|
||||
@@ -145,6 +146,7 @@ export default function ProductionResultPage() {
|
||||
}
|
||||
const res = await apiClient.get("/work-instruction/list", { params });
|
||||
const raw: any[] = res.data?.data || [];
|
||||
const total: number = res.data?.totalCount ?? raw.length;
|
||||
|
||||
// work_instruction_no 기준 중복 제거 (detail JOIN으로 여러 행 반환)
|
||||
const seen = new Set<string>();
|
||||
@@ -167,15 +169,19 @@ export default function ProductionResultPage() {
|
||||
};
|
||||
});
|
||||
setWiList(enriched);
|
||||
setWiTotalCount(total);
|
||||
} catch {
|
||||
toast.error("작업지시 목록 조회 실패");
|
||||
} finally {
|
||||
setWiLoading(false);
|
||||
}
|
||||
}, [searchFilters]);
|
||||
}, [searchFilters, currentPage, pageSize]);
|
||||
|
||||
useEffect(() => { fetchWiList(); }, [fetchWiList]);
|
||||
|
||||
// 검색 조건 변경 시 1페이지로 리셋
|
||||
useEffect(() => { setCurrentPage(1); }, [searchFilters]);
|
||||
|
||||
// 실적 로드
|
||||
useEffect(() => {
|
||||
if (!selectedWiId) { setProcessData([]); return; }
|
||||
@@ -237,13 +243,11 @@ export default function ProductionResultPage() {
|
||||
return result;
|
||||
}, [wiList, groupBy]);
|
||||
|
||||
// 페이지네이션 계산
|
||||
const totalPages = Math.max(1, Math.ceil(wiList.length / pageSize));
|
||||
// 페이지네이션 계산 (서버사이드)
|
||||
const totalPages = Math.max(1, Math.ceil(wiTotalCount / pageSize));
|
||||
const safePage = Math.min(Math.max(1, currentPage), totalPages);
|
||||
const paginatedRows = useMemo(() => {
|
||||
const start = (safePage - 1) * pageSize;
|
||||
return wiList.slice(start, start + pageSize);
|
||||
}, [wiList, safePage, pageSize]);
|
||||
// 서버가 이미 페이지 분량만 반환하므로 slice 불필요
|
||||
const paginatedRows = wiList;
|
||||
|
||||
const paginatedGroupedData = useMemo(() => {
|
||||
if (groupBy === "none") return paginatedRows;
|
||||
@@ -283,8 +287,7 @@ export default function ProductionResultPage() {
|
||||
return pages;
|
||||
};
|
||||
|
||||
// 필터 변경 시 첫 페이지로 이동
|
||||
useEffect(() => { setCurrentPage(1); }, [wiList.length]);
|
||||
// (검색 조건 변경 시 1페이지 리셋은 위 useEffect에서 처리)
|
||||
|
||||
const toggleGroup = (key: string) => {
|
||||
setExpandedGroups((prev) => {
|
||||
@@ -337,7 +340,7 @@ export default function ProductionResultPage() {
|
||||
tableName={WI_TABLE}
|
||||
filterId="c16-production-result"
|
||||
onFilterChange={setSearchFilters}
|
||||
dataCount={wiList.length}
|
||||
dataCount={wiTotalCount}
|
||||
/>
|
||||
|
||||
{/* 메인 */}
|
||||
@@ -351,7 +354,7 @@ export default function ProductionResultPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardList className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-semibold">작업지시 목록</span>
|
||||
<Badge variant="secondary" className="font-mono text-xs">{wiList.length}건</Badge>
|
||||
<Badge variant="secondary" className="font-mono text-xs">{wiTotalCount}건</Badge>
|
||||
</div>
|
||||
<Select value={groupBy} onValueChange={(v) => { setGroupBy(v); setExpandedGroups(new Set()); }}>
|
||||
<SelectTrigger className="h-8 w-[120px] text-xs">
|
||||
@@ -459,7 +462,7 @@ export default function ProductionResultPage() {
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<span>전체</span>
|
||||
<span className="font-medium text-foreground">{wiList.length.toLocaleString()}</span>
|
||||
<span className="font-medium text-foreground">{wiTotalCount.toLocaleString()}</span>
|
||||
<span>건</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
|
||||
+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>
|
||||
|
||||
@@ -65,6 +65,10 @@ export default function WorkInstructionPage() {
|
||||
const ts = useTableSettings("c16-work-instruction", "work_instruction", GRID_COLUMNS);
|
||||
const [orders, setOrders] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
// 서버사이드 페이지네이션 (WI 단위)
|
||||
const [wiPage, setWiPage] = useState(1);
|
||||
const [wiPageSize, setWiPageSize] = useState(20);
|
||||
const [wiTotalCount, setWiTotalCount] = useState(0);
|
||||
const [equipmentOptions, setEquipmentOptions] = useState<EquipmentOption[]>([]);
|
||||
const [employeeOptions, setEmployeeOptions] = useState<EmployeeOption[]>([]);
|
||||
|
||||
@@ -143,7 +147,7 @@ export default function WorkInstructionPage() {
|
||||
const fetchOrders = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = {};
|
||||
const params: any = { page: wiPage, pageSize: wiPageSize };
|
||||
for (const f of searchFilters) {
|
||||
if (f.columnName === "start_date" && f.operator === "between" && f.value) {
|
||||
const [from, to] = f.value.split("|");
|
||||
@@ -160,12 +164,18 @@ export default function WorkInstructionPage() {
|
||||
}
|
||||
}
|
||||
const r = await getWorkInstructionList(params);
|
||||
if (r.success) setOrders(r.data || []);
|
||||
if (r.success) {
|
||||
setOrders(r.data || []);
|
||||
setWiTotalCount(r.totalCount ?? (r.data?.length || 0));
|
||||
}
|
||||
} catch {} finally { setLoading(false); }
|
||||
}, [searchFilters]);
|
||||
}, [searchFilters, wiPage, wiPageSize]);
|
||||
|
||||
useEffect(() => { fetchOrders(); }, [fetchOrders]);
|
||||
|
||||
// 검색 조건 변경 시 1페이지로 리셋
|
||||
useEffect(() => { setWiPage(1); }, [searchFilters]);
|
||||
|
||||
// ─── 1단계 등록 ───
|
||||
const openRegModal = () => {
|
||||
setRegSourceType("production"); setRegSourceData([]); setRegKeyword(""); setRegCheckedIds(new Set());
|
||||
@@ -520,6 +530,12 @@ export default function WorkInstructionPage() {
|
||||
showPagination
|
||||
draggableColumns
|
||||
columnOrderKey="c16-work-instruction"
|
||||
serverPagination
|
||||
serverCurrentPage={wiPage}
|
||||
serverPageSize={wiPageSize}
|
||||
serverTotalCount={wiTotalCount}
|
||||
onServerPageChange={setWiPage}
|
||||
onServerPageSizeChange={(n) => { setWiPageSize(n); setWiPage(1); }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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";
|
||||
import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
|
||||
|
||||
@@ -596,6 +597,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,
|
||||
@@ -1028,7 +1071,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);
|
||||
@@ -1036,15 +1080,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>
|
||||
|
||||
@@ -34,6 +34,7 @@ import { apiClient } from "@/lib/api/client";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { toast } from "sonner";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { SmartSelect } from "@/components/common/SmartSelect";
|
||||
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
|
||||
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
|
||||
@@ -215,9 +216,16 @@ export default function ChunganSalesOrderPage() {
|
||||
// 품목 선택 모달
|
||||
const [itemSelectOpen, setItemSelectOpen] = useState(false);
|
||||
const [itemSearchKeyword, setItemSearchKeyword] = useState("");
|
||||
const [itemSearchWidth, setItemSearchWidth] = useState("");
|
||||
const [itemSearchHeight, setItemSearchHeight] = useState("");
|
||||
const [itemSearchThickness, setItemSearchThickness] = useState("");
|
||||
const [itemSearchResults, setItemSearchResults] = useState<any[]>([]);
|
||||
const [itemSearchLoading, setItemSearchLoading] = useState(false);
|
||||
const [itemCheckedIds, setItemCheckedIds] = useState<Set<string>>(new Set());
|
||||
// 서버사이드 페이지네이션
|
||||
const [itemSearchPage, setItemSearchPage] = useState(1);
|
||||
const [itemSearchPageSize] = useState(20);
|
||||
const [itemSearchTotal, setItemSearchTotal] = useState(0);
|
||||
|
||||
// 기타
|
||||
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
|
||||
@@ -706,24 +714,54 @@ export default function ChunganSalesOrderPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// 품목 검색
|
||||
const searchItems = async () => {
|
||||
// 품목 검색 (서버 페이지네이션) — 관리품목=영업관리, 품목구분=제품 고정
|
||||
// COMPANY_30: type 컬럼에 라벨 "제품"이 직접 저장돼 있어 label로 equals 필터
|
||||
const searchItems = async (pageOverride?: number) => {
|
||||
setItemSearchLoading(true);
|
||||
const page = pageOverride ?? itemSearchPage;
|
||||
try {
|
||||
const salesCode = categoryOptions["item_division"]?.find((o) => o.label === "영업관리")?.code;
|
||||
const filters: any[] = [];
|
||||
if (itemSearchKeyword) {
|
||||
filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
|
||||
}
|
||||
if (salesCode) filters.push({ columnName: "division", operator: "contains", value: salesCode });
|
||||
filters.push({ columnName: "type", operator: "equals", value: "제품" });
|
||||
if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
|
||||
if (itemSearchWidth) filters.push({ columnName: "width", operator: "equals", value: itemSearchWidth });
|
||||
if (itemSearchHeight) filters.push({ columnName: "height", operator: "equals", value: itemSearchHeight });
|
||||
if (itemSearchThickness) filters.push({ columnName: "thickness", operator: "equals", value: itemSearchThickness });
|
||||
const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
|
||||
page: 1, size: 50,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
page, size: itemSearchPageSize,
|
||||
dataFilter: { enabled: true, filters },
|
||||
autoFilter: true,
|
||||
});
|
||||
setItemSearchResults(res.data?.data?.data || res.data?.data?.rows || []);
|
||||
} catch { setItemSearchResults([]); }
|
||||
const raw: any[] = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
// 렌더 전 코드→라벨 변환 (단위)
|
||||
const resolved = raw.map((r) => ({
|
||||
...r,
|
||||
unit: categoryOptions["item_unit"]?.find((o) => o.code === r.unit)?.label || r.unit || "",
|
||||
}));
|
||||
setItemSearchResults(resolved);
|
||||
setItemSearchTotal(res.data?.data?.total || 0);
|
||||
} catch { setItemSearchResults([]); setItemSearchTotal(0); }
|
||||
finally { setItemSearchLoading(false); }
|
||||
};
|
||||
|
||||
// 페이지 변경 시 재조회
|
||||
useEffect(() => {
|
||||
if (itemSelectOpen) searchItems();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [itemSearchPage]);
|
||||
|
||||
// 필터 입력 시 자동 조회 (debounce 350ms, 1페이지로 리셋)
|
||||
useEffect(() => {
|
||||
if (!itemSelectOpen) return;
|
||||
const t = setTimeout(() => {
|
||||
setItemSearchPage(1);
|
||||
searchItems(1);
|
||||
}, 350);
|
||||
return () => clearTimeout(t);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [itemSearchKeyword, itemSearchWidth, itemSearchHeight, itemSearchThickness]);
|
||||
|
||||
// 품목 선택 → 리피터에 추가
|
||||
const addSelectedItemsToDetail = () => {
|
||||
const selected = itemSearchResults.filter((i) => itemCheckedIds.has(i.id));
|
||||
@@ -761,14 +799,18 @@ export default function ChunganSalesOrderPage() {
|
||||
setItemCheckedIds(new Set());
|
||||
};
|
||||
|
||||
// 빈 행 추가 (품명 직접 입력용)
|
||||
// 빈 행 추가 (품명 직접 입력용) — 관리품목=영업관리, 품목구분=제품 고정
|
||||
const addEmptyRow = () => {
|
||||
const divisionCode = "CAT_ML8ZFVEL_1TOR";
|
||||
const typeCode = "CAT_MLYPJFO9_36XG";
|
||||
const divisionLabel = categoryOptions["item_division"]?.find((o) => o.code === divisionCode)?.label || "영업관리";
|
||||
const typeLabel = categoryOptions["item_type"]?.find((o) => o.code === typeCode)?.label || "제품";
|
||||
setModalDetailRows((prev) => [...prev, {
|
||||
_id: `new_${Date.now()}_${Math.random()}`,
|
||||
_fromItemInfo: false,
|
||||
part_code: "", part_name: "", spec: "",
|
||||
division: "", _divisionLabel: "",
|
||||
type: "", _typeLabel: "",
|
||||
division: divisionCode, _divisionLabel: divisionLabel,
|
||||
type: typeCode, _typeLabel: typeLabel,
|
||||
unit: "㎡",
|
||||
width: "", height: "", thickness: "", area: "",
|
||||
qty: "", unit_price: "", amount: "",
|
||||
@@ -819,27 +861,11 @@ export default function ChunganSalesOrderPage() {
|
||||
const renderModalCell = (colKey: string, row: any, idx: number) => {
|
||||
switch (colKey) {
|
||||
case "division":
|
||||
return row._fromItemInfo ? (
|
||||
<span className="text-sm px-2">{row._divisionLabel || "-"}</span>
|
||||
) : (
|
||||
<Select value={row.division || ""} onValueChange={(v) => updateDetailRow(idx, "division", v)}>
|
||||
<SelectTrigger className="h-8 text-sm"><SelectValue placeholder="관리품목" /></SelectTrigger>
|
||||
<SelectContent position="popper" sideOffset={4}>
|
||||
{(categoryOptions["item_division"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
// 관리품목=영업관리 고정 (행추가/검색추가 모두 동일)
|
||||
return <span className="text-sm px-2">{row._divisionLabel || "영업관리"}</span>;
|
||||
case "type":
|
||||
return row._fromItemInfo ? (
|
||||
<span className="text-sm px-2">{row._typeLabel || "-"}</span>
|
||||
) : (
|
||||
<Select value={row.type || ""} onValueChange={(v) => updateDetailRow(idx, "type", v)}>
|
||||
<SelectTrigger className="h-8 text-sm"><SelectValue placeholder="품목구분" /></SelectTrigger>
|
||||
<SelectContent position="popper" sideOffset={4}>
|
||||
{(categoryOptions["item_type"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
// 품목구분=제품 고정 (행추가/검색추가 모두 동일)
|
||||
return <span className="text-sm px-2">{row._typeLabel || "제품"}</span>;
|
||||
case "part_name":
|
||||
return row._fromItemInfo ? (
|
||||
<span className="text-sm px-2">{row.part_name || "-"}</span>
|
||||
@@ -1151,12 +1177,12 @@ export default function ChunganSalesOrderPage() {
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">거래처</Label>
|
||||
<Select value={masterForm.partner_id || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, partner_id: 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>
|
||||
<SmartSelect
|
||||
options={categoryOptions["partner_id"] || []}
|
||||
value={masterForm.partner_id || ""}
|
||||
onValueChange={(v) => setMasterForm((p) => ({ ...p, partner_id: v }))}
|
||||
placeholder="거래처 선택"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">상태</Label>
|
||||
@@ -1204,7 +1230,7 @@ export default function ChunganSalesOrderPage() {
|
||||
<Button size="sm" variant="outline" onClick={addEmptyRow}>
|
||||
<Plus className="w-4 h-4 mr-1" /> 행 추가
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => { setItemCheckedIds(new Set()); setItemSelectOpen(true); searchItems(); }}>
|
||||
<Button size="sm" variant="outline" onClick={() => { setItemCheckedIds(new Set()); setItemSearchKeyword(""); setItemSearchWidth(""); setItemSearchHeight(""); setItemSearchThickness(""); setItemSearchPage(1); setItemSelectOpen(true); searchItems(1); }}>
|
||||
<Search className="w-4 h-4 mr-1" /> 품목 검색
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1287,14 +1313,30 @@ export default function ChunganSalesOrderPage() {
|
||||
<DialogTitle>품목 선택</DialogTitle>
|
||||
<DialogDescription>수주에 추가할 품목을 선택하세요. 품목이 없으면 "행 추가"로 직접 입력도 가능합니다.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex gap-2 mb-3">
|
||||
<Input placeholder="품명 검색" value={itemSearchKeyword}
|
||||
<div className="flex gap-2 mb-3 flex-wrap">
|
||||
<Input placeholder="품명" value={itemSearchKeyword}
|
||||
onChange={(e) => setItemSearchKeyword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && searchItems()}
|
||||
className="h-9 flex-1" />
|
||||
<Button size="sm" onClick={searchItems} disabled={itemSearchLoading} className="h-9">
|
||||
onKeyDown={(e) => e.key === "Enter" && (setItemSearchPage(1), searchItems(1))}
|
||||
className="h-9 flex-1 min-w-[140px]" />
|
||||
<Input type="number" placeholder="가로" value={itemSearchWidth}
|
||||
onChange={(e) => setItemSearchWidth(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && (setItemSearchPage(1), searchItems(1))}
|
||||
className="h-9 w-[90px]" />
|
||||
<Input type="number" placeholder="세로" value={itemSearchHeight}
|
||||
onChange={(e) => setItemSearchHeight(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && (setItemSearchPage(1), searchItems(1))}
|
||||
className="h-9 w-[90px]" />
|
||||
<Input type="number" placeholder="두께" value={itemSearchThickness}
|
||||
onChange={(e) => setItemSearchThickness(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && (setItemSearchPage(1), searchItems(1))}
|
||||
className="h-9 w-[80px]" />
|
||||
<Button size="sm" onClick={() => { setItemSearchPage(1); searchItems(1); }} disabled={itemSearchLoading} className="h-9">
|
||||
{itemSearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" /> 조회</>}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="h-9"
|
||||
onClick={() => { setItemSearchKeyword(""); setItemSearchWidth(""); setItemSearchHeight(""); setItemSearchThickness(""); setItemSearchPage(1); searchItems(1); }}>
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
<div className="overflow-auto max-h-[350px] border rounded-lg">
|
||||
<Table>
|
||||
@@ -1340,6 +1382,16 @@ export default function ChunganSalesOrderPage() {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{/* 페이지네이션 */}
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground px-1 pt-2">
|
||||
<span>전체 <b className="text-foreground">{itemSearchTotal.toLocaleString()}</b>건 · 페이지 {itemSearchPage} / {Math.max(1, Math.ceil(itemSearchTotal / itemSearchPageSize))}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button size="sm" variant="outline" className="h-7 px-2" disabled={itemSearchPage === 1 || itemSearchLoading} onClick={() => setItemSearchPage(1)}>«</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 px-2" disabled={itemSearchPage === 1 || itemSearchLoading} onClick={() => setItemSearchPage((p) => Math.max(1, p - 1))}>‹</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 px-2" disabled={itemSearchPage >= Math.ceil(itemSearchTotal / itemSearchPageSize) || itemSearchLoading} onClick={() => setItemSearchPage((p) => p + 1)}>›</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 px-2" disabled={itemSearchPage >= Math.ceil(itemSearchTotal / itemSearchPageSize) || itemSearchLoading} onClick={() => setItemSearchPage(Math.max(1, Math.ceil(itemSearchTotal / itemSearchPageSize)))}>»</Button>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<div className="flex items-center gap-2 w-full justify-between">
|
||||
<span className="text-sm text-muted-foreground">{itemCheckedIds.size}개 선택됨</span>
|
||||
|
||||
@@ -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 즉시 생성 → 자동 선택 ──
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -200,6 +202,7 @@ export default function EquipmentInfoPage() {
|
||||
|
||||
// 우측: 점검항목 조회
|
||||
useEffect(() => {
|
||||
setCheckedInspectionIds(new Set());
|
||||
if (!selectedEquip?.equipment_code) { setInspections([]); return; }
|
||||
const fetchData = async () => {
|
||||
setInspectionLoading(true);
|
||||
@@ -217,6 +220,7 @@ export default function EquipmentInfoPage() {
|
||||
|
||||
// 우측: 소모품 조회
|
||||
useEffect(() => {
|
||||
setCheckedConsumableIds(new Set());
|
||||
if (!selectedEquip?.equipment_code) { setConsumables([]); return; }
|
||||
const fetchData = async () => {
|
||||
setConsumableLoading(true);
|
||||
@@ -292,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; }
|
||||
@@ -546,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>
|
||||
@@ -633,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>
|
||||
@@ -660,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>
|
||||
@@ -688,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>
|
||||
@@ -703,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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
// 카테고리 옵션
|
||||
@@ -201,8 +216,9 @@ export default function InventoryStatusPage() {
|
||||
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();
|
||||
@@ -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">
|
||||
|
||||
@@ -528,9 +528,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 +570,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>
|
||||
@@ -635,14 +635,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 +716,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 +781,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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1772,7 +1772,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>
|
||||
{/* 규격 */}
|
||||
|
||||
@@ -93,7 +93,7 @@ export function ItemRoutingTab() {
|
||||
const [formWorkType, setFormWorkType] = useState("내부");
|
||||
const [formStandardTime, setFormStandardTime] = useState("");
|
||||
const [formOutsources, setFormOutsources] = useState<string[]>([]);
|
||||
const [subcontractorOptions, setSubcontractorOptions] = useState<{ code: string; name: string }[]>([]);
|
||||
const [subcontractorOptions, setSubcontractorOptions] = useState<{ id: string; code: string; name: string }[]>([]);
|
||||
const [detailSubmitting, setDetailSubmitting] = useState(false);
|
||||
|
||||
const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
|
||||
@@ -117,7 +117,7 @@ export function ItemRoutingTab() {
|
||||
page: 1, size: 500, autoFilter: true,
|
||||
});
|
||||
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
setSubcontractorOptions(rows.map((r: any) => ({ code: r.subcontractor_code || r.id, name: r.subcontractor_name || "" })));
|
||||
setSubcontractorOptions(rows.map((r: any) => ({ id: r.id, code: r.subcontractor_code || "", name: r.subcontractor_name || "" })));
|
||||
} catch { /* skip */ }
|
||||
})();
|
||||
}, []);
|
||||
@@ -309,10 +309,19 @@ export function ItemRoutingTab() {
|
||||
setFormFixedOrder(row.is_fixed_order === "N" ? "N" : "Y");
|
||||
setFormWorkType(row.work_type || "내부");
|
||||
setFormStandardTime(row.standard_time || "");
|
||||
const loaded = Array.isArray(row.outsource_supplier_list) && row.outsource_supplier_list.length > 0
|
||||
? row.outsource_supplier_list
|
||||
: (row.outsource_supplier ? [row.outsource_supplier] : []);
|
||||
setFormOutsources(loaded);
|
||||
// 우선순위: 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);
|
||||
};
|
||||
|
||||
@@ -333,8 +342,10 @@ export function ItemRoutingTab() {
|
||||
return;
|
||||
}
|
||||
const proc = processes.find((p) => p.process_code === formProcessCode);
|
||||
const outsourceList = showOutsourceField ? formOutsources.filter((s) => s && s.trim() !== "") : [];
|
||||
const outsourcePrimary = outsourceList[0] || "";
|
||||
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 {
|
||||
@@ -349,8 +360,8 @@ export function ItemRoutingTab() {
|
||||
is_fixed_order: formFixedOrder,
|
||||
work_type: formWorkType,
|
||||
standard_time: st || "0",
|
||||
outsource_supplier: outsourcePrimary,
|
||||
outsource_supplier_list: outsourceList,
|
||||
outsource_supplier: outsourcePrimaryCode,
|
||||
outsource_supplier_ids: outsourceIds,
|
||||
};
|
||||
setDetails((prev) => sortDetailsBySeq([...prev, newRow]));
|
||||
toast.success("공정이 추가되었어요. 저장을 눌러 반영해주세요");
|
||||
@@ -368,8 +379,8 @@ export function ItemRoutingTab() {
|
||||
is_fixed_order: formFixedOrder,
|
||||
work_type: formWorkType,
|
||||
standard_time: st || "0",
|
||||
outsource_supplier: outsourcePrimary,
|
||||
outsource_supplier_list: outsourceList,
|
||||
outsource_supplier: outsourcePrimaryCode,
|
||||
outsource_supplier_ids: outsourceIds,
|
||||
}
|
||||
: d,
|
||||
),
|
||||
@@ -406,7 +417,7 @@ export function ItemRoutingTab() {
|
||||
work_type: d.work_type || "내부",
|
||||
standard_time: String(d.standard_time ?? "0"),
|
||||
outsource_supplier: d.outsource_supplier || "",
|
||||
outsource_supplier_list: d.outsource_supplier_list || (d.outsource_supplier ? [d.outsource_supplier] : []),
|
||||
outsource_supplier_ids: d.outsource_supplier_ids || [],
|
||||
}));
|
||||
|
||||
setSaving(true);
|
||||
@@ -489,12 +500,16 @@ export function ItemRoutingTab() {
|
||||
const detailsGridData = useMemo(
|
||||
() =>
|
||||
details.map((d) => {
|
||||
const codes = Array.isArray(d.outsource_supplier_list) && d.outsource_supplier_list.length > 0
|
||||
? d.outsource_supplier_list
|
||||
: (d.outsource_supplier ? [d.outsource_supplier] : []);
|
||||
const names = codes
|
||||
.map((c) => subcontractorOptions.find((s) => s.code === c)?.name || c)
|
||||
.filter(Boolean);
|
||||
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,
|
||||
@@ -933,7 +948,7 @@ export function ItemRoutingTab() {
|
||||
{formOutsources.length === 0
|
||||
? "외주업체 선택"
|
||||
: formOutsources
|
||||
.map((c) => subcontractorOptions.find((s) => s.code === c)?.name || c)
|
||||
.map((i) => subcontractorOptions.find((s) => s.id === i)?.name || i)
|
||||
.join(", ")}
|
||||
</span>
|
||||
<Badge variant="secondary" className="ml-2 shrink-0">{formOutsources.length}</Badge>
|
||||
@@ -944,17 +959,17 @@ export function ItemRoutingTab() {
|
||||
{subcontractorOptions.length === 0 ? (
|
||||
<div className="text-xs text-muted-foreground px-2 py-3">등록된 외주업체가 없어요</div>
|
||||
) : subcontractorOptions.map((s) => {
|
||||
const checked = formOutsources.includes(s.code);
|
||||
const checked = formOutsources.includes(s.id);
|
||||
return (
|
||||
<label
|
||||
key={s.code}
|
||||
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.code] : prev.filter((c) => c !== s.code),
|
||||
v ? [...prev, s.id] : prev.filter((i) => i !== s.id),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
@@ -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 즉시 생성 → 자동 선택 ──
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -200,6 +202,7 @@ export default function EquipmentInfoPage() {
|
||||
|
||||
// 우측: 점검항목 조회
|
||||
useEffect(() => {
|
||||
setCheckedInspectionIds(new Set());
|
||||
if (!selectedEquip?.equipment_code) { setInspections([]); return; }
|
||||
const fetchData = async () => {
|
||||
setInspectionLoading(true);
|
||||
@@ -217,6 +220,7 @@ export default function EquipmentInfoPage() {
|
||||
|
||||
// 우측: 소모품 조회
|
||||
useEffect(() => {
|
||||
setCheckedConsumableIds(new Set());
|
||||
if (!selectedEquip?.equipment_code) { setConsumables([]); return; }
|
||||
const fetchData = async () => {
|
||||
setConsumableLoading(true);
|
||||
@@ -292,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; }
|
||||
@@ -546,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>
|
||||
@@ -633,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>
|
||||
@@ -660,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>
|
||||
@@ -688,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>
|
||||
@@ -703,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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
// 카테고리 옵션
|
||||
@@ -201,8 +216,9 @@ export default function InventoryStatusPage() {
|
||||
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();
|
||||
@@ -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,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 +493,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 +547,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 +629,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 +790,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 +861,7 @@ export default function InventoryStatusPage() {
|
||||
onValueChange={(v) =>
|
||||
setAdjustForm((prev) => ({ ...prev, adjust_type: v }))
|
||||
}
|
||||
disabled={!!selectedStock?._isMissing}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="조정 유형 선택" />
|
||||
@@ -690,6 +871,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">
|
||||
|
||||
@@ -528,9 +528,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 +570,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>
|
||||
@@ -635,14 +635,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 +716,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 +781,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>
|
||||
)}
|
||||
|
||||
@@ -249,6 +249,8 @@ interface SelectedSourceItem {
|
||||
total_amount: number;
|
||||
source_table: string;
|
||||
source_id: string;
|
||||
detail_id?: string;
|
||||
header_id?: string;
|
||||
}
|
||||
|
||||
export default function ReceivingPage() {
|
||||
@@ -583,7 +585,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) : "");
|
||||
@@ -593,8 +595,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.inbound_type || "",
|
||||
reference_number: g.reference_number || "",
|
||||
supplier_code: (g as any).supplier_code || "",
|
||||
@@ -781,7 +785,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,
|
||||
@@ -789,6 +793,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
|
||||
|
||||
@@ -1772,7 +1772,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>
|
||||
{/* 규격 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
@@ -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 즉시 생성 → 자동 선택 ──
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -200,6 +202,7 @@ export default function EquipmentInfoPage() {
|
||||
|
||||
// 우측: 점검항목 조회
|
||||
useEffect(() => {
|
||||
setCheckedInspectionIds(new Set());
|
||||
if (!selectedEquip?.equipment_code) { setInspections([]); return; }
|
||||
const fetchData = async () => {
|
||||
setInspectionLoading(true);
|
||||
@@ -217,6 +220,7 @@ export default function EquipmentInfoPage() {
|
||||
|
||||
// 우측: 소모품 조회
|
||||
useEffect(() => {
|
||||
setCheckedConsumableIds(new Set());
|
||||
if (!selectedEquip?.equipment_code) { setConsumables([]); return; }
|
||||
const fetchData = async () => {
|
||||
setConsumableLoading(true);
|
||||
@@ -292,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; }
|
||||
@@ -546,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>
|
||||
@@ -633,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>
|
||||
@@ -660,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>
|
||||
@@ -688,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>
|
||||
@@ -703,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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
// 카테고리 옵션
|
||||
@@ -201,8 +216,9 @@ export default function InventoryStatusPage() {
|
||||
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();
|
||||
@@ -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">
|
||||
|
||||
@@ -528,9 +528,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 +570,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>
|
||||
@@ -635,14 +635,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 +716,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 +781,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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -297,7 +297,11 @@ export default function BomManagementPage() {
|
||||
const [itemSearchKeyword, setItemSearchKeyword] = useState("");
|
||||
const [itemSearchResults, setItemSearchResults] = useState<any[]>([]);
|
||||
const [itemSearchLoading, setItemSearchLoading] = useState(false);
|
||||
const [itemSearchTarget, setItemSearchTarget] = useState<"master" | "detail">("master");
|
||||
const [itemSearchTarget, setItemSearchTarget] = useState<"master" | "detail" | "tree">("master");
|
||||
// 서버사이드 페이지네이션
|
||||
const [itemSearchPage, setItemSearchPage] = useState(1);
|
||||
const [itemSearchPageSize] = useState(20);
|
||||
const [itemSearchTotal, setItemSearchTotal] = useState(0);
|
||||
|
||||
// 엑셀 업로드
|
||||
const [showExcelUpload, setShowExcelUpload] = useState(false);
|
||||
@@ -780,11 +784,14 @@ export default function BomManagementPage() {
|
||||
setTreeHasChanges(true);
|
||||
};
|
||||
|
||||
// 하위 품목 추가 시작
|
||||
// 하위 품목 추가 시작 (BOM 등록 품목검색 모달 재사용)
|
||||
const handleTreeAddChild = (parentId: string | null) => {
|
||||
setAddTargetParentId(parentId);
|
||||
setTreeItemSearchOpen(true);
|
||||
searchItems("");
|
||||
setItemSearchTarget("tree");
|
||||
setItemSearchKeyword("");
|
||||
setItemSearchPage(1);
|
||||
setShowItemSearchModal(true);
|
||||
searchItems(1);
|
||||
};
|
||||
|
||||
// 트리 품목 선택 완료 (트리에 추가)
|
||||
@@ -1191,33 +1198,34 @@ export default function BomManagementPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// ─── 품목 검색 ───────────────────────────────
|
||||
const searchItems = async (keyword?: string) => {
|
||||
// ─── 품목 검색 (서버 페이지네이션) ───────────────────────────────
|
||||
const searchItems = async (pageOverride?: number, keyword?: string) => {
|
||||
const kw = (keyword ?? itemSearchKeyword).trim();
|
||||
const pageCandidate = typeof pageOverride === "number" && Number.isFinite(pageOverride) && pageOverride > 0
|
||||
? pageOverride
|
||||
: itemSearchPage;
|
||||
const page = Number.isFinite(pageCandidate) && pageCandidate > 0 ? pageCandidate : 1;
|
||||
setItemSearchLoading(true);
|
||||
try {
|
||||
const filters: any[] = [];
|
||||
if (kw) {
|
||||
filters.push({ columnName: "item_name", operator: "contains", value: kw });
|
||||
}
|
||||
if (kw) filters.push({ columnName: "item_name", operator: "contains", value: kw });
|
||||
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||||
page: 1,
|
||||
size: 50,
|
||||
page, size: itemSearchPageSize,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
let rows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
let total = res.data?.data?.total || 0;
|
||||
|
||||
// 키워드가 있고 품명으로 못 찾으면 품목코드로 재시도
|
||||
if (kw && rows.length === 0) {
|
||||
const res2 = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||||
page: 1, size: 50,
|
||||
page, size: itemSearchPageSize,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "contains", value: kw }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
rows = res2.data?.data?.data || res2.data?.data?.rows || [];
|
||||
total = res2.data?.data?.total || 0;
|
||||
}
|
||||
// 카테고리 코드 → 라벨 변환 (division + inventory_unit)
|
||||
const resolved = rows.map((r: any) => {
|
||||
const out = { ...r };
|
||||
if (out.division) {
|
||||
@@ -1229,9 +1237,16 @@ export default function BomManagementPage() {
|
||||
if (out.inventory_unit) {
|
||||
out.inventory_unit = categoryOptions["inventory_unit"]?.find((o) => o.code === out.inventory_unit)?.label || out.inventory_unit;
|
||||
}
|
||||
const w = out.width, h = out.height, t = out.thickness;
|
||||
if (w || h || t) {
|
||||
out._spec = [w || "-", h || "-", t || "-"].join(" x ");
|
||||
} else {
|
||||
out._spec = out.size || "";
|
||||
}
|
||||
return out;
|
||||
});
|
||||
setItemSearchResults(resolved);
|
||||
setItemSearchTotal(total);
|
||||
} catch {
|
||||
toast.error("품목 검색에 실패했어요");
|
||||
} finally {
|
||||
@@ -1239,6 +1254,12 @@ export default function BomManagementPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// 페이지 변경 시 재조회
|
||||
useEffect(() => {
|
||||
if (showItemSearchModal) searchItems();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [itemSearchPage]);
|
||||
|
||||
const resolveUnit = (code: string) => {
|
||||
if (!code) return "";
|
||||
return categoryOptions["inventory_unit"]?.find((o) => o.code === code)?.label || code;
|
||||
@@ -1246,6 +1267,13 @@ export default function BomManagementPage() {
|
||||
|
||||
const selectItem = (item: any) => {
|
||||
const unitLabel = resolveUnit(item.inventory_unit);
|
||||
if (itemSearchTarget === "tree") {
|
||||
handleTreeItemSelect(item);
|
||||
setShowItemSearchModal(false);
|
||||
setItemSearchKeyword("");
|
||||
setItemSearchResults([]);
|
||||
return;
|
||||
}
|
||||
if (itemSearchTarget === "master") {
|
||||
setMasterForm((prev) => ({
|
||||
...prev,
|
||||
@@ -1772,7 +1800,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>
|
||||
{/* 규격 */}
|
||||
@@ -1972,7 +2000,7 @@ export default function BomManagementPage() {
|
||||
setItemSearchTarget("master");
|
||||
setItemSearchKeyword("");
|
||||
setShowItemSearchModal(true);
|
||||
searchItems("");
|
||||
searchItems();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
@@ -1983,7 +2011,7 @@ export default function BomManagementPage() {
|
||||
setItemSearchTarget("master");
|
||||
setItemSearchKeyword("");
|
||||
setShowItemSearchModal(true);
|
||||
searchItems("");
|
||||
searchItems();
|
||||
}}
|
||||
>
|
||||
<Search className="w-3.5 h-3.5" />
|
||||
@@ -2084,7 +2112,7 @@ export default function BomManagementPage() {
|
||||
setItemSearchTarget("detail");
|
||||
setItemSearchKeyword("");
|
||||
setShowItemSearchModal(true);
|
||||
searchItems("");
|
||||
searchItems();
|
||||
}}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5 mr-1" />
|
||||
@@ -2186,8 +2214,8 @@ export default function BomManagementPage() {
|
||||
</Dialog>
|
||||
|
||||
{/* ─── 품목 검색 모달 ──────────────────────── */}
|
||||
<Dialog open={showItemSearchModal} onOpenChange={setShowItemSearchModal}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<Dialog open={showItemSearchModal} onOpenChange={(o) => { if (!o) { setItemSearchKeyword(""); setItemSearchResults([]); setItemSearchPage(1); setItemSearchTotal(0); } setShowItemSearchModal(o); }}>
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>품목 검색</DialogTitle>
|
||||
<DialogDescription>품목코드 또는 품명으로 검색해주세요</DialogDescription>
|
||||
@@ -2198,13 +2226,13 @@ export default function BomManagementPage() {
|
||||
placeholder="품목코드 또는 품명 입력"
|
||||
value={itemSearchKeyword}
|
||||
onChange={(e) => setItemSearchKeyword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && searchItems()}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") { setItemSearchPage(1); searchItems(1); } }}
|
||||
/>
|
||||
<Button size="sm" className="h-9" onClick={() => searchItems()} disabled={itemSearchLoading}>
|
||||
<Button size="sm" className="h-9" onClick={() => { setItemSearchPage(1); searchItems(1); }} disabled={itemSearchLoading}>
|
||||
{itemSearchLoading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Search className="w-3.5 h-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-[300px] overflow-auto border rounded-lg">
|
||||
<div className="max-h-[320px] overflow-auto border rounded-lg">
|
||||
{itemSearchResults.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground gap-1">
|
||||
<Search className="w-6 h-6 text-muted-foreground/40" />
|
||||
@@ -2216,20 +2244,35 @@ export default function BomManagementPage() {
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center gap-3 px-3 py-2 border-b last:border-b-0 hover:bg-accent cursor-pointer transition-colors"
|
||||
className="flex items-center gap-2 px-3 py-2 border-b last:border-b-0 hover:bg-accent cursor-pointer transition-colors"
|
||||
onClick={() => selectItem(item)}
|
||||
>
|
||||
<span className={cn("text-[10px] font-semibold px-1.5 py-0.5 rounded shrink-0", badge.className)}>
|
||||
{badge.label}
|
||||
</span>
|
||||
<span className="font-mono text-[11px] text-muted-foreground">{item.item_number}</span>
|
||||
<span className="text-xs">{item.item_name}</span>
|
||||
<span className="ml-auto text-[11px] text-muted-foreground">{item.inventory_unit || ""}</span>
|
||||
<span className="font-mono text-[11px] text-muted-foreground shrink-0">{item.item_number}</span>
|
||||
<span className="text-xs truncate flex-1">{item.item_name}</span>
|
||||
{item._spec && (
|
||||
<span className="text-[11px] text-muted-foreground font-mono shrink-0" title={item._spec}>[{item._spec}]</span>
|
||||
)}
|
||||
<span className="text-[11px] text-muted-foreground shrink-0">{item.inventory_unit || ""}</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
{/* 페이지네이션 */}
|
||||
{itemSearchTotal > 0 && (
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground px-1 pt-2">
|
||||
<span>전체 <b className="text-foreground">{itemSearchTotal.toLocaleString()}</b>건 · {itemSearchPage}/{Math.max(1, Math.ceil(itemSearchTotal / itemSearchPageSize))} 페이지</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button size="sm" variant="outline" className="h-7 px-2" disabled={itemSearchPage === 1 || itemSearchLoading} onClick={() => setItemSearchPage(1)}>«</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 px-2" disabled={itemSearchPage === 1 || itemSearchLoading} onClick={() => setItemSearchPage((p) => Math.max(1, p - 1))}>‹</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 px-2" disabled={itemSearchPage >= Math.ceil(itemSearchTotal / itemSearchPageSize) || itemSearchLoading} onClick={() => setItemSearchPage((p) => p + 1)}>›</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 px-2" disabled={itemSearchPage >= Math.ceil(itemSearchTotal / itemSearchPageSize) || itemSearchLoading} onClick={() => setItemSearchPage(Math.max(1, Math.ceil(itemSearchTotal / itemSearchPageSize)))}>»</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
@@ -564,6 +565,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,
|
||||
@@ -996,7 +1039,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);
|
||||
@@ -1004,15 +1048,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>
|
||||
|
||||
@@ -31,6 +31,7 @@ import { apiClient } from "@/lib/api/client";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { toast } from "sonner";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { SmartSelect } from "@/components/common/SmartSelect";
|
||||
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
|
||||
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
|
||||
@@ -118,9 +119,16 @@ export default function JeilGlassOrderPage() {
|
||||
// 품목 선택 모달
|
||||
const [itemSelectOpen, setItemSelectOpen] = useState(false);
|
||||
const [itemSearchKeyword, setItemSearchKeyword] = useState("");
|
||||
const [itemSearchWidth, setItemSearchWidth] = useState("");
|
||||
const [itemSearchHeight, setItemSearchHeight] = useState("");
|
||||
const [itemSearchThickness, setItemSearchThickness] = useState("");
|
||||
const [itemSearchResults, setItemSearchResults] = useState<any[]>([]);
|
||||
const [itemSearchLoading, setItemSearchLoading] = useState(false);
|
||||
const [itemCheckedIds, setItemCheckedIds] = useState<Set<string>>(new Set());
|
||||
// 서버사이드 페이지네이션
|
||||
const [itemSearchPage, setItemSearchPage] = useState(1);
|
||||
const [itemSearchPageSize] = useState(20);
|
||||
const [itemSearchTotal, setItemSearchTotal] = useState(0);
|
||||
|
||||
// 기타
|
||||
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
|
||||
@@ -558,23 +566,55 @@ export default function JeilGlassOrderPage() {
|
||||
};
|
||||
|
||||
// 품목 검색
|
||||
const searchItems = async () => {
|
||||
// 품목 검색 (서버 페이지네이션) — 관리품목=영업관리, 품목구분=제품 고정
|
||||
// COMPANY_9: type 컬럼에 코드가 저장돼 있어 코드값으로 equals 필터
|
||||
const searchItems = async (pageOverride?: number) => {
|
||||
setItemSearchLoading(true);
|
||||
const page = pageOverride ?? itemSearchPage;
|
||||
try {
|
||||
const salesCode = categoryOptions["item_division"]?.find((o) => o.label === "영업관리")?.code;
|
||||
const productCode = categoryOptions["item_type"]?.find((o) => o.label === "제품")?.code;
|
||||
const filters: any[] = [];
|
||||
if (itemSearchKeyword) {
|
||||
filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
|
||||
}
|
||||
if (salesCode) filters.push({ columnName: "division", operator: "contains", value: salesCode });
|
||||
if (productCode) filters.push({ columnName: "type", operator: "equals", value: productCode });
|
||||
if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
|
||||
if (itemSearchWidth) filters.push({ columnName: "width", operator: "equals", value: itemSearchWidth });
|
||||
if (itemSearchHeight) filters.push({ columnName: "height", operator: "equals", value: itemSearchHeight });
|
||||
if (itemSearchThickness) filters.push({ columnName: "thickness", operator: "equals", value: itemSearchThickness });
|
||||
const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
|
||||
page: 1, size: 50,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
page, size: itemSearchPageSize,
|
||||
dataFilter: { enabled: true, filters },
|
||||
autoFilter: true,
|
||||
});
|
||||
setItemSearchResults(res.data?.data?.data || res.data?.data?.rows || []);
|
||||
} catch { setItemSearchResults([]); }
|
||||
const raw: any[] = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
// 렌더 전 코드→라벨 변환 (단위)
|
||||
const resolved = raw.map((r) => ({
|
||||
...r,
|
||||
unit: categoryOptions["item_unit"]?.find((o) => o.code === r.unit)?.label || r.unit || "",
|
||||
}));
|
||||
setItemSearchResults(resolved);
|
||||
setItemSearchTotal(res.data?.data?.total || 0);
|
||||
} catch { setItemSearchResults([]); setItemSearchTotal(0); }
|
||||
finally { setItemSearchLoading(false); }
|
||||
};
|
||||
|
||||
// 페이지 변경 시 재조회
|
||||
useEffect(() => {
|
||||
if (itemSelectOpen) searchItems();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [itemSearchPage]);
|
||||
|
||||
// 필터 입력 시 자동 조회 (debounce 350ms, 1페이지로 리셋)
|
||||
useEffect(() => {
|
||||
if (!itemSelectOpen) return;
|
||||
const t = setTimeout(() => {
|
||||
setItemSearchPage(1);
|
||||
searchItems(1);
|
||||
}, 350);
|
||||
return () => clearTimeout(t);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [itemSearchKeyword, itemSearchWidth, itemSearchHeight, itemSearchThickness]);
|
||||
|
||||
// 품목 선택 → 리피터에 추가
|
||||
const addSelectedItemsToDetail = () => {
|
||||
const selected = itemSearchResults.filter((i) => itemCheckedIds.has(i.id));
|
||||
@@ -613,11 +653,14 @@ export default function JeilGlassOrderPage() {
|
||||
};
|
||||
|
||||
// 빈 행 추가 (품명 직접 입력용)
|
||||
// 빈 행 추가 — 관리품목=영업관리 고정
|
||||
const addEmptyRow = () => {
|
||||
const divisionCode = "CAT_ML8ZFVEL_1TOR";
|
||||
const divisionLabel = categoryOptions["item_division"]?.find((o) => o.code === divisionCode)?.label || "영업관리";
|
||||
setModalDetailRows((prev) => [...prev, {
|
||||
_id: `new_${Date.now()}_${Math.random()}`,
|
||||
_fromItemInfo: false,
|
||||
part_code: "", part_name: "", spec: "", division: "", _divisionLabel: "", unit: "㎡",
|
||||
part_code: "", part_name: "", spec: "", division: divisionCode, _divisionLabel: divisionLabel, unit: "㎡",
|
||||
width: "", height: "", thickness: "", area: "",
|
||||
qty: "", unit_price: "", amount: "",
|
||||
due_date: "", memo: "",
|
||||
@@ -936,12 +979,12 @@ export default function JeilGlassOrderPage() {
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">거래처</Label>
|
||||
<Select value={masterForm.partner_id || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, partner_id: 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>
|
||||
<SmartSelect
|
||||
options={categoryOptions["partner_id"] || []}
|
||||
value={masterForm.partner_id || ""}
|
||||
onValueChange={(v) => setMasterForm((p) => ({ ...p, partner_id: v }))}
|
||||
placeholder="거래처 선택"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">상태</Label>
|
||||
@@ -989,7 +1032,7 @@ export default function JeilGlassOrderPage() {
|
||||
<Button size="sm" variant="outline" onClick={addEmptyRow}>
|
||||
<Plus className="w-4 h-4 mr-1" /> 행 추가
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => { setItemCheckedIds(new Set()); setItemSelectOpen(true); searchItems(); }}>
|
||||
<Button size="sm" variant="outline" onClick={() => { setItemCheckedIds(new Set()); setItemSearchKeyword(""); setItemSearchWidth(""); setItemSearchHeight(""); setItemSearchThickness(""); setItemSearchPage(1); setItemSelectOpen(true); searchItems(1); }}>
|
||||
<Search className="w-4 h-4 mr-1" /> 품목 검색
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1049,18 +1092,9 @@ export default function JeilGlassOrderPage() {
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-xs text-muted-foreground">{idx + 1}</TableCell>
|
||||
{/* 구분: 품목검색 → 읽기전용, 행추가 → Select */}
|
||||
{/* 구분: 영업관리 고정 */}
|
||||
<TableCell>
|
||||
{row._fromItemInfo ? (
|
||||
<span className="text-sm px-2">{row._divisionLabel || "-"}</span>
|
||||
) : (
|
||||
<Select value={row.division || ""} onValueChange={(v) => updateDetailRow(idx, "division", v)}>
|
||||
<SelectTrigger className="h-8 text-sm"><SelectValue placeholder="구분" /></SelectTrigger>
|
||||
<SelectContent position="popper" sideOffset={4} className="z-[9999]">
|
||||
{(categoryOptions["item_division"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<span className="text-sm px-2">{row._divisionLabel || "영업관리"}</span>
|
||||
</TableCell>
|
||||
{/* 품명: 품목검색 → 읽기전용, 행추가 → 입력 */}
|
||||
<TableCell>
|
||||
@@ -1147,14 +1181,30 @@ export default function JeilGlassOrderPage() {
|
||||
<DialogTitle>품목 선택</DialogTitle>
|
||||
<DialogDescription>수주에 추가할 품목을 선택하세요. 품목이 없으면 "행 추가"로 직접 입력도 가능합니다.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex gap-2 mb-3">
|
||||
<Input placeholder="품명 검색" value={itemSearchKeyword}
|
||||
<div className="flex gap-2 mb-3 flex-wrap">
|
||||
<Input placeholder="품명" value={itemSearchKeyword}
|
||||
onChange={(e) => setItemSearchKeyword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && searchItems()}
|
||||
className="h-9 flex-1" />
|
||||
<Button size="sm" onClick={searchItems} disabled={itemSearchLoading} className="h-9">
|
||||
onKeyDown={(e) => e.key === "Enter" && (setItemSearchPage(1), searchItems(1))}
|
||||
className="h-9 flex-1 min-w-[140px]" />
|
||||
<Input type="number" placeholder="가로" value={itemSearchWidth}
|
||||
onChange={(e) => setItemSearchWidth(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && (setItemSearchPage(1), searchItems(1))}
|
||||
className="h-9 w-[90px]" />
|
||||
<Input type="number" placeholder="세로" value={itemSearchHeight}
|
||||
onChange={(e) => setItemSearchHeight(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && (setItemSearchPage(1), searchItems(1))}
|
||||
className="h-9 w-[90px]" />
|
||||
<Input type="number" placeholder="두께" value={itemSearchThickness}
|
||||
onChange={(e) => setItemSearchThickness(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && (setItemSearchPage(1), searchItems(1))}
|
||||
className="h-9 w-[80px]" />
|
||||
<Button size="sm" onClick={() => { setItemSearchPage(1); searchItems(1); }} disabled={itemSearchLoading} className="h-9">
|
||||
{itemSearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" /> 조회</>}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="h-9"
|
||||
onClick={() => { setItemSearchKeyword(""); setItemSearchWidth(""); setItemSearchHeight(""); setItemSearchThickness(""); setItemSearchPage(1); searchItems(1); }}>
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
<div className="overflow-auto max-h-[350px] border rounded-lg">
|
||||
<Table>
|
||||
@@ -1200,6 +1250,16 @@ export default function JeilGlassOrderPage() {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{/* 페이지네이션 */}
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground px-1 pt-2">
|
||||
<span>전체 <b className="text-foreground">{itemSearchTotal.toLocaleString()}</b>건 · 페이지 {itemSearchPage} / {Math.max(1, Math.ceil(itemSearchTotal / itemSearchPageSize))}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button size="sm" variant="outline" className="h-7 px-2" disabled={itemSearchPage === 1 || itemSearchLoading} onClick={() => setItemSearchPage(1)}>«</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 px-2" disabled={itemSearchPage === 1 || itemSearchLoading} onClick={() => setItemSearchPage((p) => Math.max(1, p - 1))}>‹</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 px-2" disabled={itemSearchPage >= Math.ceil(itemSearchTotal / itemSearchPageSize) || itemSearchLoading} onClick={() => setItemSearchPage((p) => p + 1)}>›</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 px-2" disabled={itemSearchPage >= Math.ceil(itemSearchTotal / itemSearchPageSize) || itemSearchLoading} onClick={() => setItemSearchPage(Math.max(1, Math.ceil(itemSearchTotal / itemSearchPageSize)))}>»</Button>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<div className="flex items-center gap-2 w-full justify-between">
|
||||
<span className="text-sm text-muted-foreground">{itemCheckedIds.size}개 선택됨</span>
|
||||
|
||||
@@ -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 즉시 생성 → 자동 선택 ──
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
|
||||
/* ===== CSS Variables (Vivid Blue Theme) ===== */
|
||||
:root {
|
||||
color-scheme: light;
|
||||
/* Light Theme Colors - HSL Format */
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 224 71% 4%;
|
||||
@@ -123,6 +124,7 @@
|
||||
|
||||
/* ===== Dark Theme (Palantir-Inspired) ===== */
|
||||
.dark {
|
||||
color-scheme: dark;
|
||||
/* 배경: 팔란티어 스타일 깊은 네이비 */
|
||||
--background: 222 47% 6%;
|
||||
--foreground: 210 20% 95%;
|
||||
|
||||
@@ -83,6 +83,15 @@ export interface EDataTableProps<T extends Record<string, any> = any> {
|
||||
showPagination?: boolean;
|
||||
defaultPageSize?: number;
|
||||
|
||||
// ─── 서버사이드 페이지네이션 모드 ───
|
||||
// serverPagination=true 일 때: 내부 slice/filter/sort 미사용, data는 이미 해당 페이지 분량
|
||||
serverPagination?: boolean;
|
||||
serverCurrentPage?: number;
|
||||
serverPageSize?: number;
|
||||
serverTotalCount?: number;
|
||||
onServerPageChange?: (page: number) => void;
|
||||
onServerPageSizeChange?: (size: number) => void;
|
||||
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -275,6 +284,12 @@ export function EDataTable<T extends Record<string, any> = any>({
|
||||
showRowNumber = false,
|
||||
showPagination = true,
|
||||
defaultPageSize = 50,
|
||||
serverPagination = false,
|
||||
serverCurrentPage,
|
||||
serverPageSize,
|
||||
serverTotalCount,
|
||||
onServerPageChange,
|
||||
onServerPageSizeChange,
|
||||
className,
|
||||
}: EDataTableProps<T>) {
|
||||
const [columns, setColumns] = useState(initialColumns);
|
||||
@@ -287,10 +302,21 @@ export function EDataTable<T extends Record<string, any> = any>({
|
||||
// 헤더 필터
|
||||
const [headerFilters, setHeaderFilters] = useState<Record<string, Set<string>>>({});
|
||||
|
||||
// 페이지네이션
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(defaultPageSize);
|
||||
const [pageSizeInput, setPageSizeInput] = useState(String(defaultPageSize));
|
||||
// 페이지네이션 — 서버사이드 모드면 외부 state 사용
|
||||
const [internalCurrentPage, setInternalCurrentPage] = useState(1);
|
||||
const [internalPageSize, setInternalPageSize] = useState(defaultPageSize);
|
||||
const currentPage = serverPagination ? (serverCurrentPage ?? 1) : internalCurrentPage;
|
||||
const pageSize = serverPagination ? (serverPageSize ?? defaultPageSize) : internalPageSize;
|
||||
const setCurrentPage = (next: number | ((prev: number) => number)) => {
|
||||
const resolved = typeof next === "function" ? (next as (p: number) => number)(currentPage) : next;
|
||||
if (serverPagination) onServerPageChange?.(resolved);
|
||||
else setInternalCurrentPage(resolved);
|
||||
};
|
||||
const setPageSize = (n: number) => {
|
||||
if (serverPagination) onServerPageSizeChange?.(n);
|
||||
else setInternalPageSize(n);
|
||||
};
|
||||
const [pageSizeInput, setPageSizeInput] = useState(String(serverPagination ? (serverPageSize ?? defaultPageSize) : defaultPageSize));
|
||||
|
||||
// 그룹 접기/펼치기
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
|
||||
@@ -394,8 +420,9 @@ export function EDataTable<T extends Record<string, any> = any>({
|
||||
});
|
||||
};
|
||||
|
||||
// 필터 + 정렬
|
||||
// 필터 + 정렬 (서버사이드 모드면 원본 data 그대로 사용)
|
||||
const processedData = useMemo(() => {
|
||||
if (serverPagination) return data;
|
||||
let result = [...data];
|
||||
|
||||
// 헤더 필터
|
||||
@@ -425,24 +452,28 @@ export function EDataTable<T extends Record<string, any> = any>({
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [data, headerFilters, sortState, onSortChange]);
|
||||
}, [data, headerFilters, sortState, onSortChange, serverPagination]);
|
||||
|
||||
// 필터/데이터 건수 변경 시 1페이지 리셋 (참조만 바뀐 경우는 리셋 안 함)
|
||||
useEffect(() => { setCurrentPage(1); }, [data.length, headerFilters]);
|
||||
// 필터/데이터 건수 변경 시 1페이지 리셋 (서버사이드에선 외부가 제어)
|
||||
useEffect(() => {
|
||||
if (!serverPagination) setCurrentPage(1);
|
||||
}, [data.length, headerFilters, serverPagination]);
|
||||
|
||||
// 페이지네이션
|
||||
const totalItems = processedData.length;
|
||||
const totalItems = serverPagination ? (serverTotalCount ?? data.length) : processedData.length;
|
||||
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
|
||||
const safePage = Math.min(currentPage, totalPages);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentPage > totalPages) setCurrentPage(totalPages);
|
||||
}, [currentPage, totalPages]);
|
||||
if (!serverPagination && currentPage > totalPages) setCurrentPage(totalPages);
|
||||
}, [currentPage, totalPages, serverPagination]);
|
||||
|
||||
const pageOffset = (safePage - 1) * pageSize;
|
||||
const paginatedDataRaw = showPagination
|
||||
? processedData.slice(pageOffset, pageOffset + pageSize)
|
||||
: processedData;
|
||||
const paginatedDataRaw = serverPagination
|
||||
? processedData
|
||||
: showPagination
|
||||
? processedData.slice(pageOffset, pageOffset + pageSize)
|
||||
: processedData;
|
||||
|
||||
// 접힌 그룹의 데이터 행 숨김
|
||||
const paginatedData = useMemo(() => {
|
||||
|
||||
@@ -4,19 +4,22 @@
|
||||
* SmartSelect
|
||||
*
|
||||
* 옵션 개수에 따라 자동으로 검색 기능을 제공하는 셀렉트 컴포넌트.
|
||||
* - 옵션 5개 미만: 기본 Select (드롭다운)
|
||||
* - 옵션 5개 이상: Combobox (검색 + 드롭다운)
|
||||
* - 옵션 5개 미만: 기본 Select
|
||||
* - 옵션 5개 이상: 검색 + 가상 스크롤 Combobox (대용량 옵션도 빠르게 처리)
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from "react";
|
||||
import React, { useState, useMemo, useEffect, useRef } from "react";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Check, ChevronsUpDown } from "lucide-react";
|
||||
import { Check, ChevronsUpDown, Search as SearchIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
|
||||
const SEARCH_THRESHOLD = 5;
|
||||
const ITEM_HEIGHT = 36;
|
||||
const LIST_HEIGHT = 280;
|
||||
|
||||
export interface SmartSelectOption {
|
||||
code: string;
|
||||
@@ -41,12 +44,40 @@ export function SmartSelect({
|
||||
className,
|
||||
}: SmartSelectProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const selectedLabel = useMemo(
|
||||
() => options.find((o) => o.code === value)?.label,
|
||||
[options, value],
|
||||
);
|
||||
|
||||
// 팝오버 닫힐 때 검색어 리셋
|
||||
useEffect(() => {
|
||||
if (!open) setSearch("");
|
||||
}, [open]);
|
||||
|
||||
// 검색어로 옵션 필터 (대소문자 무시)
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return options;
|
||||
return options.filter((o) => o.label.toLowerCase().includes(q));
|
||||
}, [options, search]);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: filtered.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => ITEM_HEIGHT,
|
||||
overscan: 10,
|
||||
});
|
||||
|
||||
// 팝오버 열릴 때 측정 강제 (Portal 렌더 타이밍 대응)
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const id = requestAnimationFrame(() => virtualizer.measure());
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [open, virtualizer, filtered.length]);
|
||||
|
||||
if (options.length < SEARCH_THRESHOLD) {
|
||||
return (
|
||||
<Select value={value} onValueChange={onValueChange} disabled={disabled}>
|
||||
@@ -85,37 +116,59 @@ export function SmartSelect({
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
align="start"
|
||||
>
|
||||
<Command
|
||||
filter={(val, search) => {
|
||||
if (!search) return 1;
|
||||
return val.toLowerCase().includes(search.toLowerCase()) ? 1 : 0;
|
||||
}}
|
||||
>
|
||||
<CommandInput placeholder="검색..." className="h-9" />
|
||||
<CommandList>
|
||||
<CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options.map((o) => (
|
||||
<CommandItem
|
||||
key={o.code}
|
||||
value={o.label}
|
||||
onSelect={() => {
|
||||
onValueChange(o.code);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
<div className="flex items-center border-b px-2">
|
||||
<SearchIcon className="h-4 w-4 text-muted-foreground mr-1 shrink-0" />
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="검색..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="h-9 border-0 px-1 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
/>
|
||||
</div>
|
||||
{filtered.length === 0 ? (
|
||||
<div className="py-6 text-center text-sm text-muted-foreground">검색 결과가 없습니다.</div>
|
||||
) : (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="overflow-auto py-1"
|
||||
style={{ height: LIST_HEIGHT }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
width: "100%",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((vItem) => {
|
||||
const o = filtered[vItem.index];
|
||||
const isSelected = value === o.code;
|
||||
return (
|
||||
<button
|
||||
key={o.code}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onValueChange(o.code);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
value === o.code ? "opacity-100" : "opacity-0",
|
||||
"absolute left-0 top-0 w-full flex items-center px-2 text-sm text-left hover:bg-accent",
|
||||
isSelected && "bg-accent/60",
|
||||
)}
|
||||
/>
|
||||
{o.label}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
style={{
|
||||
height: `${vItem.size}px`,
|
||||
transform: `translateY(${vItem.start}px)`,
|
||||
}}
|
||||
>
|
||||
<Check className={cn("mr-2 h-4 w-4 shrink-0", isSelected ? "opacity-100" : "opacity-0")} />
|
||||
<span className="truncate">{o.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
@@ -218,6 +218,7 @@ export default function TimelineScheduler({
|
||||
origStartDate: string;
|
||||
origEndDate: string;
|
||||
startX: number;
|
||||
startScrollLeft: number;
|
||||
currentOffsetDays: number;
|
||||
} | null>(null);
|
||||
|
||||
@@ -378,6 +379,7 @@ export default function TimelineScheduler({
|
||||
origStartDate: startDate,
|
||||
origEndDate: endDate,
|
||||
startX: e.clientX,
|
||||
startScrollLeft: scrollRef.current?.scrollLeft ?? 0,
|
||||
currentOffsetDays: 0,
|
||||
});
|
||||
},
|
||||
@@ -388,16 +390,78 @@ export default function TimelineScheduler({
|
||||
useEffect(() => {
|
||||
if (!dragState) return;
|
||||
|
||||
// 드래그 결과가 차트 가시 범위를 벗어나지 않도록 오프셋 제한
|
||||
const clampOffset = (rawOffset: number): number => {
|
||||
const origStart = parseDate(dragState.origStartDate);
|
||||
const origEnd = parseDate(dragState.origEndDate);
|
||||
const lastDate = addDays(baseDate, config.spanDays - 1);
|
||||
const msPerDay = 86400000;
|
||||
if (dragState.mode === "move") {
|
||||
const minOffset = Math.ceil((baseDate.getTime() - origStart.getTime()) / msPerDay);
|
||||
const maxOffset = Math.floor((lastDate.getTime() - origEnd.getTime()) / msPerDay);
|
||||
return Math.max(minOffset, Math.min(maxOffset, rawOffset));
|
||||
} else if (dragState.mode === "resize-left") {
|
||||
const minOffset = Math.ceil((baseDate.getTime() - origStart.getTime()) / msPerDay);
|
||||
const maxOffset = Math.floor((origEnd.getTime() - origStart.getTime()) / msPerDay);
|
||||
return Math.max(minOffset, Math.min(maxOffset, rawOffset));
|
||||
} else if (dragState.mode === "resize-right") {
|
||||
const minOffset = Math.ceil((origStart.getTime() - origEnd.getTime()) / msPerDay);
|
||||
const maxOffset = Math.floor((lastDate.getTime() - origEnd.getTime()) / msPerDay);
|
||||
return Math.max(minOffset, Math.min(maxOffset, rawOffset));
|
||||
}
|
||||
return rawOffset;
|
||||
};
|
||||
|
||||
// 스크롤 변화 보정: 드래그 시작 이후 스크롤된 만큼 dx에 더해줌
|
||||
const getEffectiveDx = (clientX: number): number => {
|
||||
const currentScrollLeft = scrollRef.current?.scrollLeft ?? 0;
|
||||
const scrollDelta = currentScrollLeft - dragState.startScrollLeft;
|
||||
return (clientX - dragState.startX) + scrollDelta;
|
||||
};
|
||||
|
||||
// 자동 스크롤: 뷰포트 가장자리 근처에서 RAF 루프로 스크롤
|
||||
const EDGE = 50; // 가장자리 감지 영역 (px)
|
||||
const MAX_SPEED = 18; // 최대 스크롤 속도 (px per frame)
|
||||
let rafId: number | null = null;
|
||||
let lastClientX = 0;
|
||||
|
||||
const autoScrollTick = () => {
|
||||
const sc = scrollRef.current;
|
||||
if (!sc) { rafId = null; return; }
|
||||
const rect = sc.getBoundingClientRect();
|
||||
const leftDist = lastClientX - rect.left;
|
||||
const rightDist = rect.right - lastClientX;
|
||||
let delta = 0;
|
||||
if (leftDist < EDGE) {
|
||||
delta = -Math.round(((EDGE - Math.max(0, leftDist)) / EDGE) * MAX_SPEED);
|
||||
} else if (rightDist < EDGE) {
|
||||
delta = Math.round(((EDGE - Math.max(0, rightDist)) / EDGE) * MAX_SPEED);
|
||||
}
|
||||
if (delta !== 0) {
|
||||
const before = sc.scrollLeft;
|
||||
sc.scrollLeft = before + delta;
|
||||
if (sc.scrollLeft !== before) {
|
||||
// 스크롤이 실제로 변했으면 dragState.currentOffsetDays 재계산
|
||||
const dx = getEffectiveDx(lastClientX);
|
||||
const dayOffset = clampOffset(Math.round(dx / config.cellWidth));
|
||||
setDragState((prev) => (prev ? { ...prev, currentOffsetDays: dayOffset } : null));
|
||||
}
|
||||
}
|
||||
rafId = requestAnimationFrame(autoScrollTick);
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const dx = e.clientX - dragState.startX;
|
||||
const dayOffset = Math.round(dx / config.cellWidth);
|
||||
lastClientX = e.clientX;
|
||||
const dx = getEffectiveDx(e.clientX);
|
||||
const dayOffset = clampOffset(Math.round(dx / config.cellWidth));
|
||||
setDragState((prev) => (prev ? { ...prev, currentOffsetDays: dayOffset } : null));
|
||||
if (rafId === null) rafId = requestAnimationFrame(autoScrollTick);
|
||||
};
|
||||
|
||||
const handleMouseUp = (e: MouseEvent) => {
|
||||
if (!dragState) return;
|
||||
const dx = e.clientX - dragState.startX;
|
||||
const dayOffset = Math.round(dx / config.cellWidth);
|
||||
const dx = getEffectiveDx(e.clientX);
|
||||
const dayOffset = clampOffset(Math.round(dx / config.cellWidth));
|
||||
|
||||
if (dayOffset !== 0) {
|
||||
const origStart = parseDate(dragState.origStartDate);
|
||||
@@ -445,8 +509,9 @@ export default function TimelineScheduler({
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
if (rafId !== null) cancelAnimationFrame(rafId);
|
||||
};
|
||||
}, [dragState, config.cellWidth, onEventMove, onEventResize]);
|
||||
}, [dragState, config.cellWidth, config.spanDays, baseDate, onEventMove, onEventResize]);
|
||||
|
||||
// 드래그 중인 이벤트의 현재 표시 위치 계산
|
||||
const getDraggedBarStyle = useCallback(
|
||||
@@ -618,7 +683,7 @@ export default function TimelineScheduler({
|
||||
<div className="flex" style={{ minWidth: resourceWidth + totalWidth }}>
|
||||
{/* 좌측: 리소스 라벨 */}
|
||||
<div
|
||||
className="shrink-0 border-r bg-muted/30 z-20 sticky left-0"
|
||||
className="shrink-0 border-r bg-background z-20 sticky left-0"
|
||||
style={{ width: resourceWidth }}
|
||||
>
|
||||
{/* 헤더 공간 */}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { FileDown, FileText, Loader2, Printer } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ComponentConfig, ReportPage, WatermarkConfig } from "@/types/report";
|
||||
import { ComponentConfig, GridCell, ReportPage, WatermarkConfig } from "@/types/report";
|
||||
import { useReportRenderer, QueryResult } from "@/hooks/useReportRenderer";
|
||||
import { getFullImageUrl } from "@/lib/api/client";
|
||||
import {
|
||||
@@ -28,6 +28,10 @@ interface ReportInlineViewerProps {
|
||||
showToolbar?: boolean;
|
||||
/** 컴포넌트 클릭 콜백 — 편집 모드에서 사용 */
|
||||
onComponentClick?: (component: ComponentConfig) => void;
|
||||
/** cellType="input" 셀의 커스텀 값 오버라이드: { [componentId]: { [cellId]: value } } */
|
||||
cellOverrides?: Record<string, Record<string, string>>;
|
||||
/** input 셀 클릭 콜백 */
|
||||
onInputCellClick?: (component: ComponentConfig, cell: GridCell) => void;
|
||||
}
|
||||
|
||||
export function ReportInlineViewer({
|
||||
@@ -36,6 +40,8 @@ export function ReportInlineViewer({
|
||||
className = "",
|
||||
showToolbar = true,
|
||||
onComponentClick,
|
||||
cellOverrides,
|
||||
onInputCellClick,
|
||||
}: ReportInlineViewerProps) {
|
||||
const { detail, pages, watermark, getQueryResult, isLoading } = useReportRenderer(reportId, contextParams);
|
||||
|
||||
@@ -190,6 +196,7 @@ export function ReportInlineViewer({
|
||||
page={page} pageIndex={pageIndex} totalPages={pages.length}
|
||||
pages={pages} watermark={watermark} getQueryResult={getQueryResult}
|
||||
editable={editable} onComponentClick={onComponentClick}
|
||||
cellOverrides={cellOverrides} onInputCellClick={onInputCellClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -226,10 +233,12 @@ function WatermarkLayer({ watermark, pageWidth, pageHeight }: { watermark: Water
|
||||
return null;
|
||||
}
|
||||
|
||||
function PagePreview({ page, pageIndex, totalPages, pages, watermark, getQueryResult, editable, onComponentClick }: {
|
||||
function PagePreview({ page, pageIndex, totalPages, pages, watermark, getQueryResult, editable, onComponentClick, cellOverrides, onInputCellClick }: {
|
||||
page: ReportPage; pageIndex: number; totalPages: number; pages: ReportPage[];
|
||||
watermark?: WatermarkConfig; getQueryResult: (queryId: string) => QueryResult | null;
|
||||
editable?: boolean; onComponentClick?: (comp: ComponentConfig) => void;
|
||||
cellOverrides?: Record<string, Record<string, string>>;
|
||||
onInputCellClick?: (component: ComponentConfig, cell: GridCell) => void;
|
||||
}) {
|
||||
const comps = page.components ?? [];
|
||||
const sortedByY = [...comps].sort((a, b) => a.y - b.y);
|
||||
@@ -286,6 +295,7 @@ function PagePreview({ page, pageIndex, totalPages, pages, watermark, getQueryRe
|
||||
{sortedByY.sort((a, b) => (a.zIndex ?? 0) - (b.zIndex ?? 0)).map((comp) => (
|
||||
<ComponentRenderer key={comp.id} comp={comp} pageIndex={pageIndex} totalPages={totalPages}
|
||||
pages={pages} getQueryResult={getQueryResult} editable={editable} onComponentClick={onComponentClick}
|
||||
cellOverrides={cellOverrides} onInputCellClick={onInputCellClick}
|
||||
yOffset={offsets[comp.id] || 0}
|
||||
measureRef={growableTypes.has(comp.type) ? setRef(comp.id) : undefined} />
|
||||
))}
|
||||
@@ -293,10 +303,12 @@ function PagePreview({ page, pageIndex, totalPages, pages, watermark, getQueryRe
|
||||
);
|
||||
}
|
||||
|
||||
function ComponentRenderer({ comp, pageIndex, totalPages, pages, getQueryResult, editable, onComponentClick, yOffset = 0, measureRef }: {
|
||||
function ComponentRenderer({ comp, pageIndex, totalPages, pages, getQueryResult, editable, onComponentClick, cellOverrides, onInputCellClick, yOffset = 0, measureRef }: {
|
||||
comp: ComponentConfig; pageIndex: number; totalPages: number; pages: ReportPage[];
|
||||
getQueryResult: (queryId: string) => QueryResult | null;
|
||||
editable?: boolean; onComponentClick?: (comp: ComponentConfig) => void;
|
||||
cellOverrides?: Record<string, Record<string, string>>;
|
||||
onInputCellClick?: (component: ComponentConfig, cell: GridCell) => void;
|
||||
yOffset?: number; measureRef?: (el: HTMLDivElement | null) => void;
|
||||
}) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
@@ -360,7 +372,7 @@ function ComponentRenderer({ comp, pageIndex, totalPages, pages, getQueryResult,
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
{(comp.type === "text" || comp.type === "label") && <TextRenderer component={comp} displayValue={displayValue} getQueryResult={getQueryResult} />}
|
||||
{comp.type === "table" && <TableRenderer component={comp} getQueryResult={getQueryResult} />}
|
||||
{comp.type === "table" && <TableRenderer component={comp} getQueryResult={getQueryResult} cellOverrides={cellOverrides} onInputCellClick={onInputCellClick} />}
|
||||
{comp.type === "image" && <ImageRenderer component={comp} />}
|
||||
{comp.type === "divider" && <DividerRenderer component={comp} />}
|
||||
{comp.type === "signature" && <SignatureRenderer component={comp} />}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { useUnsavedChangesGuard, UnsavedChangesDialog } from "@/components/commo
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Loader2, Search, ChevronRight, ChevronDown, FolderOpen, FileText } from "lucide-react";
|
||||
import { Loader2, Search, FileText } from "lucide-react";
|
||||
import { menuApi } from "@/lib/api/menu";
|
||||
import { MenuItem } from "@/types/menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -26,13 +26,10 @@ interface MenuSelectModalProps {
|
||||
selectedMenuObjids?: number[];
|
||||
}
|
||||
|
||||
interface MenuTreeNode {
|
||||
interface FlatMenuEntry {
|
||||
objid: string;
|
||||
menuNameKor: string;
|
||||
menuUrl: string;
|
||||
level: number;
|
||||
children: MenuTreeNode[];
|
||||
parentObjId: string;
|
||||
parentNameKor: string;
|
||||
}
|
||||
|
||||
export function MenuSelectModal({ isOpen, onClose, onConfirm, selectedMenuObjids = [] }: MenuSelectModalProps) {
|
||||
@@ -40,7 +37,6 @@ export function MenuSelectModal({ isOpen, onClose, onConfirm, selectedMenuObjids
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set(selectedMenuObjids));
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
||||
const initialSelectionRef = useRef<string>("");
|
||||
|
||||
const hasChanges = useCallback(() => {
|
||||
@@ -73,14 +69,6 @@ export function MenuSelectModal({ isOpen, onClose, onConfirm, selectedMenuObjids
|
||||
const response = await menuApi.getUserMenus();
|
||||
if (response.success && response.data) {
|
||||
setMenus(response.data as MenuItem[]);
|
||||
const initialExpanded = new Set<string>();
|
||||
(response.data as MenuItem[]).forEach((menu: any) => {
|
||||
const level = menu.lev || menu.LEV || 1;
|
||||
if (level <= 2) {
|
||||
initialExpanded.add(menu.objid || menu.OBJID || "");
|
||||
}
|
||||
});
|
||||
setExpandedIds(initialExpanded);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("메뉴 로드 오류:", error);
|
||||
@@ -89,63 +77,36 @@ export function MenuSelectModal({ isOpen, onClose, onConfirm, selectedMenuObjids
|
||||
}
|
||||
};
|
||||
|
||||
const menuTree = useMemo(() => {
|
||||
const menuMap = new Map<string, MenuTreeNode>();
|
||||
const rootMenus: MenuTreeNode[] = [];
|
||||
|
||||
menus.forEach((menu) => {
|
||||
const objid = menu.objid || menu.OBJID || "";
|
||||
const parentObjId = menu.parentObjId || menu.PARENT_OBJ_ID || "";
|
||||
const menuNameKor = menu.menuNameKor || menu.MENU_NAME_KOR || menu.translated_name || menu.TRANSLATED_NAME || "";
|
||||
const menuUrl = menu.menuUrl || menu.MENU_URL || "";
|
||||
const level = menu.lev || menu.LEV || 1;
|
||||
|
||||
menuMap.set(objid, { objid, menuNameKor, menuUrl, level, children: [], parentObjId });
|
||||
const level2List = useMemo<FlatMenuEntry[]>(() => {
|
||||
const byObjid = new Map<string, any>();
|
||||
menus.forEach((menu: any) => {
|
||||
const objid = String(menu.objid || menu.OBJID || "");
|
||||
byObjid.set(objid, menu);
|
||||
});
|
||||
|
||||
menus.forEach((menu) => {
|
||||
const objid = menu.objid || menu.OBJID || "";
|
||||
const parentObjId = menu.parentObjId || menu.PARENT_OBJ_ID || "";
|
||||
const node = menuMap.get(objid);
|
||||
if (!node) return;
|
||||
|
||||
const parent = menuMap.get(parentObjId);
|
||||
if (parent) {
|
||||
parent.children.push(node);
|
||||
} else {
|
||||
rootMenus.push(node);
|
||||
}
|
||||
});
|
||||
|
||||
const sortChildren = (nodes: MenuTreeNode[]) => {
|
||||
nodes.sort((a, b) => a.menuNameKor.localeCompare(b.menuNameKor, "ko"));
|
||||
nodes.forEach((node) => sortChildren(node.children));
|
||||
};
|
||||
sortChildren(rootMenus);
|
||||
|
||||
return rootMenus;
|
||||
return menus
|
||||
.filter((menu: any) => Number(menu.lev ?? menu.LEV ?? 0) === 2)
|
||||
.map((menu: any) => {
|
||||
const objid = String(menu.objid || menu.OBJID || "");
|
||||
const parentObjId = String(menu.parent_obj_id || menu.PARENT_OBJ_ID || "");
|
||||
const parent = byObjid.get(parentObjId);
|
||||
const parentNameKor = parent
|
||||
? parent.menu_name_kor || parent.MENU_NAME_KOR || parent.translated_name || parent.TRANSLATED_NAME || ""
|
||||
: "";
|
||||
const menuNameKor = menu.menu_name_kor || menu.MENU_NAME_KOR || menu.translated_name || menu.TRANSLATED_NAME || "";
|
||||
return { objid, menuNameKor, parentNameKor };
|
||||
})
|
||||
.sort((a, b) => a.menuNameKor.localeCompare(b.menuNameKor, "ko"));
|
||||
}, [menus]);
|
||||
|
||||
const filteredTree = useMemo(() => {
|
||||
if (!searchText.trim()) return menuTree;
|
||||
|
||||
const searchLower = searchText.toLowerCase();
|
||||
|
||||
const filterNodes = (nodes: MenuTreeNode[]): MenuTreeNode[] => {
|
||||
return nodes
|
||||
.map((node) => {
|
||||
const filteredChildren = filterNodes(node.children);
|
||||
const matches = node.menuNameKor.toLowerCase().includes(searchLower);
|
||||
if (matches || filteredChildren.length > 0) {
|
||||
return { ...node, children: filteredChildren };
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((node): node is MenuTreeNode => node !== null);
|
||||
};
|
||||
|
||||
return filterNodes(menuTree);
|
||||
}, [menuTree, searchText]);
|
||||
const filteredList = useMemo(() => {
|
||||
if (!searchText.trim()) return level2List;
|
||||
const q = searchText.toLowerCase();
|
||||
return level2List.filter(
|
||||
(m) =>
|
||||
m.menuNameKor.toLowerCase().includes(q) || m.parentNameKor.toLowerCase().includes(q),
|
||||
);
|
||||
}, [level2List, searchText]);
|
||||
|
||||
const toggleSelect = useCallback((objid: string) => {
|
||||
const numericId = Number(objid);
|
||||
@@ -160,87 +121,20 @@ export function MenuSelectModal({ isOpen, onClose, onConfirm, selectedMenuObjids
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleExpand = useCallback((objid: string) => {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(objid)) {
|
||||
next.delete(objid);
|
||||
} else {
|
||||
next.add(objid);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm(Array.from(selectedIds));
|
||||
onClose();
|
||||
};
|
||||
|
||||
const renderMenuNode = (node: MenuTreeNode, depth: number = 0) => {
|
||||
const hasChildren = node.children.length > 0;
|
||||
const isExpanded = expandedIds.has(node.objid);
|
||||
const isSelected = selectedIds.has(Number(node.objid));
|
||||
|
||||
return (
|
||||
<div key={node.objid}>
|
||||
<div
|
||||
className={cn(
|
||||
"hover:bg-muted/50 flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5",
|
||||
isSelected && "bg-primary/10",
|
||||
)}
|
||||
style={{ paddingLeft: `${depth * 20 + 8}px` }}
|
||||
onClick={() => toggleSelect(node.objid)}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleExpand(node.objid);
|
||||
}}
|
||||
className="hover:bg-muted rounded p-0.5"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="text-muted-foreground h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="text-muted-foreground h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-5" />
|
||||
)}
|
||||
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => toggleSelect(node.objid)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
|
||||
{hasChildren ? (
|
||||
<FolderOpen className="h-4 w-4 text-amber-500" />
|
||||
) : (
|
||||
<FileText className="text-muted-foreground h-4 w-4" />
|
||||
)}
|
||||
|
||||
<span className={cn("flex-1 truncate text-sm", isSelected && "text-primary font-medium")}>
|
||||
{node.menuNameKor}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{hasChildren && isExpanded && <div>{node.children.map((child) => renderMenuNode(child, depth + 1))}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={guard.handleOpenChange}>
|
||||
<DialogContent className="flex max-h-[80vh] max-w-[600px] flex-col">
|
||||
<DialogContent className="flex h-[80vh] max-w-[600px] flex-col overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>사용 메뉴 선택</DialogTitle>
|
||||
<DialogTitle>사용 메뉴 선택 (대분류)</DialogTitle>
|
||||
<DialogDescription>
|
||||
이 리포트를 사용할 메뉴를 선택하세요. 선택한 메뉴에서 이 리포트를 사용할 수 있습니다.
|
||||
이 리포트가 속할 2레벨(대분류) 메뉴를 선택하세요. 선택한 대분류의 하위 메뉴에서 이 리포트를 사용할 수
|
||||
있습니다. 아무 것도 선택하지 않으면 모든 메뉴에서 보입니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -256,18 +150,45 @@ export function MenuSelectModal({ isOpen, onClose, onConfirm, selectedMenuObjids
|
||||
|
||||
<div className="text-muted-foreground text-sm">{selectedIds.size}개 메뉴 선택됨</div>
|
||||
|
||||
<ScrollArea className="flex-1 rounded-md border">
|
||||
<ScrollArea className="min-h-0 flex-1 rounded-md border">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||
<span className="text-muted-foreground ml-2 text-sm">메뉴 로드 중...</span>
|
||||
</div>
|
||||
) : filteredTree.length === 0 ? (
|
||||
) : filteredList.length === 0 ? (
|
||||
<div className="text-muted-foreground flex items-center justify-center py-8 text-sm">
|
||||
{searchText ? "검색 결과가 없습니다." : "표시할 메뉴가 없습니다."}
|
||||
{searchText ? "검색 결과가 없습니다." : "표시할 2레벨 메뉴가 없습니다."}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2">{filteredTree.map((node) => renderMenuNode(node))}</div>
|
||||
<div className="p-2">
|
||||
{filteredList.map((node) => {
|
||||
const isSelected = selectedIds.has(Number(node.objid));
|
||||
return (
|
||||
<div
|
||||
key={node.objid}
|
||||
className={cn(
|
||||
"hover:bg-muted/50 flex cursor-pointer items-center gap-2 rounded-md px-2 py-2",
|
||||
isSelected && "bg-primary/10",
|
||||
)}
|
||||
onClick={() => toggleSelect(node.objid)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => toggleSelect(node.objid)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<FileText className="text-muted-foreground h-4 w-4" />
|
||||
<span className={cn("flex-1 truncate text-sm", isSelected && "text-primary font-medium")}>
|
||||
{node.menuNameKor}
|
||||
</span>
|
||||
{node.parentNameKor && (
|
||||
<span className="text-muted-foreground text-xs">{node.parentNameKor}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
Maximize,
|
||||
FolderTree,
|
||||
} from "lucide-react";
|
||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
import { useState, useCallback } from "react";
|
||||
@@ -465,6 +466,16 @@ export function ReportDesignerToolbar() {
|
||||
<BookTemplate className="h-4 w-4" />
|
||||
템플릿 저장
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowMenuSelect(true)}
|
||||
className="h-9 gap-1 px-2 lg:gap-2 lg:px-3"
|
||||
title="이 리포트를 사용할 대분류 메뉴 선택"
|
||||
>
|
||||
<FolderTree className="h-4 w-4" />
|
||||
<span className="hidden lg:inline">사용 메뉴</span>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleSave} disabled={isSaving} className="h-9 gap-1 px-2 lg:gap-2 lg:px-3">
|
||||
{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||
<span className="hidden lg:inline">저장</span>
|
||||
|
||||
@@ -782,6 +782,9 @@ export function GridEditor({ component, onUpdate, schemaColumns = [] }: GridEdit
|
||||
<SelectItem value="field">
|
||||
<span className="flex items-center gap-1"><Database className="h-3 w-3" /> 데이터 연결</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="input">
|
||||
<span className="flex items-center gap-1"><Type className="h-3 w-3" /> 사용자 입력</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -812,6 +815,19 @@ export function GridEditor({ component, onUpdate, schemaColumns = [] }: GridEdit
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedCell.cellType === "input" && (
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-medium text-gray-500">입력 힌트(placeholder)</label>
|
||||
<Input
|
||||
className="h-8 w-full text-xs"
|
||||
value={selectedCell.inputPlaceholder ?? ""}
|
||||
onChange={(e) => updateCellProps(selectedCell.row, selectedCell.col, { inputPlaceholder: e.target.value })}
|
||||
placeholder="예: 결제 조건"
|
||||
/>
|
||||
<p className="text-[10px] text-gray-400">뷰어에서 빈 셀 클릭 시 입력 모달이 열립니다</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 구분선 */}
|
||||
<div className="h-px bg-gray-200" />
|
||||
|
||||
|
||||
@@ -51,7 +51,12 @@ function calcSummary(col: TableColumn, rows: Record<string, unknown>[]): string
|
||||
function getGridCellValue(
|
||||
cell: GridCell,
|
||||
row?: Record<string, unknown>,
|
||||
override?: string,
|
||||
): string {
|
||||
if (cell.cellType === "input") {
|
||||
return override ?? "";
|
||||
}
|
||||
|
||||
if (cell.cellType === "static") return cell.value ?? "";
|
||||
|
||||
if (cell.cellType === "field") {
|
||||
@@ -67,7 +72,7 @@ function getGridCellValue(
|
||||
|
||||
// ─── 그리드 테이블 렌더러 ────────────────────────────────────────────────────
|
||||
|
||||
function GridTableRenderer({ component, getQueryResult }: TableRendererProps) {
|
||||
function GridTableRenderer({ component, getQueryResult, cellOverrides, onInputCellClick }: TableRendererProps) {
|
||||
const cells = component.gridCells ?? [];
|
||||
const rowCount = component.gridRowCount ?? 0;
|
||||
const colCount = component.gridColCount ?? 0;
|
||||
@@ -118,28 +123,38 @@ function GridTableRenderer({ component, getQueryResult }: TableRendererProps) {
|
||||
const cellBg = cell.backgroundColor || (isHeader ? hdrBg : "white");
|
||||
const cellColor = cell.textColor || (isHeader ? hdrColor : "#111827");
|
||||
|
||||
const displayValue = getGridCellValue(cell, dataRow);
|
||||
const overrideValue = cellOverrides?.[component.id]?.[cell.id];
|
||||
const displayValue = getGridCellValue(cell, dataRow, overrideValue);
|
||||
const isInputCell = cell.cellType === "input";
|
||||
const showPlaceholder = isInputCell && !overrideValue;
|
||||
|
||||
tds.push(
|
||||
<td
|
||||
key={cell.id}
|
||||
rowSpan={rSpan > 1 ? rSpan : undefined}
|
||||
colSpan={cSpan > 1 ? cSpan : undefined}
|
||||
onClick={
|
||||
isInputCell && onInputCellClick
|
||||
? (e) => { e.stopPropagation(); onInputCellClick(component, cell); }
|
||||
: undefined
|
||||
}
|
||||
style={{
|
||||
backgroundColor: cellBg,
|
||||
backgroundColor: isInputCell && onInputCellClick && !overrideValue ? "#fffbe6" : cellBg,
|
||||
border: `${borderW}px solid #d1d5db`,
|
||||
padding: "2px 4px",
|
||||
fontSize: cell.fontSize ?? 12,
|
||||
fontWeight: cell.fontWeight === "bold" ? 700 : (isHeader ? 600 : 400),
|
||||
color: cellColor,
|
||||
color: showPlaceholder ? "#9ca3af" : cellColor,
|
||||
textAlign: cell.align || "center",
|
||||
verticalAlign: cell.verticalAlign || "middle",
|
||||
overflow: "hidden",
|
||||
whiteSpace: "pre-line",
|
||||
wordBreak: "break-word",
|
||||
fontStyle: showPlaceholder ? "italic" : undefined,
|
||||
cursor: isInputCell && onInputCellClick ? "pointer" : undefined,
|
||||
}}
|
||||
>
|
||||
{displayValue}
|
||||
{showPlaceholder ? (cell.inputPlaceholder || "입력") : displayValue}
|
||||
</td>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ComponentConfig } from "@/types/report";
|
||||
import { ComponentConfig, GridCell } from "@/types/report";
|
||||
|
||||
export interface QueryResult {
|
||||
fields: string[];
|
||||
@@ -14,7 +14,12 @@ export interface TextRendererProps extends BaseRendererProps {
|
||||
displayValue: string;
|
||||
}
|
||||
|
||||
export interface TableRendererProps extends BaseRendererProps {}
|
||||
export interface TableRendererProps extends BaseRendererProps {
|
||||
/** cellType="input" 셀의 커스텀 값 맵: { [componentId]: { [cellId]: value } } */
|
||||
cellOverrides?: Record<string, Record<string, string>>;
|
||||
/** input 셀 클릭 시 호출 */
|
||||
onInputCellClick?: (component: ComponentConfig, cell: GridCell) => void;
|
||||
}
|
||||
|
||||
export interface ImageRendererProps {
|
||||
component: ComponentConfig;
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useMemo } from "react";
|
||||
import { useMenu } from "@/contexts/MenuContext";
|
||||
import { useTabStore, selectTabs, selectActiveTabId } from "@/stores/tabStore";
|
||||
|
||||
function stripCompanyPrefix(pathname: string): string {
|
||||
return pathname.replace(/^\/COMPANY_\d+/, "") || "/";
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 경로가 속한 "대분류" 메뉴의 objid 반환.
|
||||
* - 메뉴 트리에서 parent_obj_id를 따라 올라가며, 트리에 부모가 더 이상 존재하지 않는 루트 바로 직전의 노드를 반환
|
||||
* - lev 필드에 의존하지 않음 (백엔드 재귀 쿼리 결과와 무관)
|
||||
* - 탭 시스템(/main에서 탭으로 페이지를 띄우는 경우) 대응: usePathname이 '/main'을 반환하므로
|
||||
* 활성 탭의 adminUrl을 fallback으로 사용
|
||||
*/
|
||||
export function useCurrent2ndLevelMenuObjid(): number | null {
|
||||
const pathname = usePathname();
|
||||
const { userMenus, adminMenus } = useMenu();
|
||||
const tabs = useTabStore(selectTabs);
|
||||
const activeTabId = useTabStore(selectActiveTabId);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!pathname) return null;
|
||||
|
||||
const all: any[] = [...(userMenus as any[]), ...(adminMenus as any[])];
|
||||
if (all.length === 0) return null;
|
||||
|
||||
// 1차: 실제 pathname 기준. 2차(탭 컨테이너 경로 등): 활성 탭 URL 기준
|
||||
let targetUrl = stripCompanyPrefix(pathname);
|
||||
const isRootLikePath = pathname === "/main" || pathname === "/" || pathname === "";
|
||||
if (isRootLikePath) {
|
||||
const activeTab = tabs.find((t) => t.id === activeTabId);
|
||||
if (activeTab?.adminUrl) {
|
||||
targetUrl = stripCompanyPrefix(activeTab.adminUrl);
|
||||
}
|
||||
}
|
||||
|
||||
const byObjid = new Map<string, any>();
|
||||
for (const m of all) {
|
||||
byObjid.set(String(m.objid), m);
|
||||
}
|
||||
|
||||
const current = all.find((m) => m.menu_url === targetUrl);
|
||||
if (!current) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("[useCurrent2ndLevelMenuObjid] 메뉴 매칭 실패", { targetUrl, sample: all.slice(0, 3) });
|
||||
return null;
|
||||
}
|
||||
|
||||
let node: any = current;
|
||||
let prev: any = null;
|
||||
let safety = 20;
|
||||
while (node && safety-- > 0) {
|
||||
const parentId = node.parent_obj_id;
|
||||
if (parentId === null || parentId === undefined || parentId === 0 || parentId === "0") {
|
||||
break;
|
||||
}
|
||||
const parent = byObjid.get(String(parentId));
|
||||
if (!parent) break;
|
||||
prev = node;
|
||||
node = parent;
|
||||
}
|
||||
|
||||
const resultObjid = prev ? Number(prev.objid) : Number(node.objid);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("[useCurrent2ndLevelMenuObjid]", {
|
||||
targetUrl,
|
||||
currentObjid: current.objid,
|
||||
currentName: current.menu_name_kor,
|
||||
resultObjid,
|
||||
resultName: prev ? prev.menu_name_kor : node.menu_name_kor,
|
||||
});
|
||||
return resultObjid;
|
||||
}, [pathname, userMenus, adminMenus, tabs, activeTabId]);
|
||||
}
|
||||
@@ -58,7 +58,8 @@ export interface RoutingDetail {
|
||||
work_type: string;
|
||||
standard_time: string;
|
||||
outsource_supplier: string;
|
||||
outsource_supplier_list?: string[];
|
||||
outsource_supplier_ids?: string[];
|
||||
outsource_supplier_list?: string[]; // legacy code 배열 (호환용)
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
|
||||
@@ -4,7 +4,7 @@ export interface PaginatedResponse { success: boolean; data: any[]; totalCount:
|
||||
|
||||
export async function getWorkInstructionList(params?: Record<string, any>) {
|
||||
const res = await apiClient.get("/work-instruction/list", { params });
|
||||
return res.data as { success: boolean; data: any[] };
|
||||
return res.data as { success: boolean; data: any[]; totalCount?: number; page?: number; pageSize?: number };
|
||||
}
|
||||
|
||||
export async function previewWorkInstructionNo() {
|
||||
|
||||
@@ -388,10 +388,12 @@ export interface GridCell {
|
||||
col: number;
|
||||
rowSpan?: number;
|
||||
colSpan?: number;
|
||||
cellType: "static" | "field" | "formula";
|
||||
cellType: "static" | "field" | "formula" | "input";
|
||||
value?: string;
|
||||
field?: string;
|
||||
formula?: string;
|
||||
/** input 셀의 placeholder 힌트 */
|
||||
inputPlaceholder?: string;
|
||||
align?: "left" | "center" | "right";
|
||||
verticalAlign?: "top" | "middle" | "bottom";
|
||||
fontWeight?: "normal" | "bold";
|
||||
|
||||
Reference in New Issue
Block a user