feat: Enhance outbound and receiving update functionalities with inventory adjustments
- Updated the `update` function in the outbound controller to include detailed inventory adjustments when modifying outbound records, ensuring accurate stock management. - Implemented rollback mechanisms for both outbound and receiving updates to maintain data integrity in case of errors. - Enhanced the `deleteOutbound` function to include inventory recovery and historical logging for deleted outbound records. - Introduced a new utility function `adjustInventory` to handle inventory changes consistently across different controllers. - Improved error handling and logging for better traceability during outbound and receiving operations.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
{/* 규격 */}
|
||||
|
||||
@@ -555,6 +555,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,
|
||||
|
||||
@@ -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 즉시 생성 → 자동 선택 ──
|
||||
|
||||
|
||||
@@ -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>
|
||||
{/* 규격 */}
|
||||
|
||||
@@ -555,6 +555,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,
|
||||
|
||||
@@ -1642,10 +1642,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 +1662,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 +1682,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";
|
||||
@@ -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 즉시 생성 → 자동 선택 ──
|
||||
|
||||
|
||||
@@ -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>
|
||||
{/* 규격 */}
|
||||
|
||||
@@ -555,6 +555,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,
|
||||
|
||||
@@ -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 즉시 생성 → 자동 선택 ──
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
{/* 규격 */}
|
||||
|
||||
@@ -596,6 +596,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,
|
||||
|
||||
@@ -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 즉시 생성 → 자동 선택 ──
|
||||
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -555,6 +555,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,
|
||||
|
||||
@@ -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 즉시 생성 → 자동 선택 ──
|
||||
|
||||
|
||||
@@ -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>
|
||||
{/* 규격 */}
|
||||
|
||||
@@ -555,6 +555,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,
|
||||
|
||||
@@ -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 즉시 생성 → 자동 선택 ──
|
||||
|
||||
|
||||
@@ -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>
|
||||
{/* 규격 */}
|
||||
|
||||
@@ -564,6 +564,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,
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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 }}
|
||||
>
|
||||
{/* 헤더 공간 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useMemo } from "react";
|
||||
import { useMenu } from "@/contexts/MenuContext";
|
||||
|
||||
function stripCompanyPrefix(pathname: string): string {
|
||||
return pathname.replace(/^\/COMPANY_\d+/, "") || "/";
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 경로가 속한 "대분류" 메뉴의 objid 반환.
|
||||
* - 메뉴 트리에서 parent_obj_id를 따라 올라가며, 트리에 부모가 더 이상 존재하지 않는 루트 바로 직전의 노드를 반환
|
||||
* - lev 필드에 의존하지 않음 (백엔드 재귀 쿼리 결과와 무관)
|
||||
*/
|
||||
export function useCurrent2ndLevelMenuObjid(): number | null {
|
||||
const pathname = usePathname();
|
||||
const { userMenus, adminMenus } = useMenu();
|
||||
|
||||
return useMemo(() => {
|
||||
if (!pathname) return null;
|
||||
|
||||
const all: any[] = [...(userMenus as any[]), ...(adminMenus as any[])];
|
||||
if (all.length === 0) return null;
|
||||
|
||||
const targetUrl = stripCompanyPrefix(pathname);
|
||||
|
||||
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]);
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user