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:
kjs
2026-04-20 14:14:24 +09:00
parent 48b9ba3d2a
commit 9737805bf9
48 changed files with 1256 additions and 357 deletions
@@ -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,
+8 -3
View File
@@ -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
);
+130
View File
@@ -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 즉시 생성 → 자동 선택 ──
+2
View File
@@ -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]);
}
+2 -1
View File
@@ -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> {