생산관리>M-BOM 관리 — PR-A1 그리드/검색 (mBomMgmtGridList 1:1 이식)

운영 wace productionplanning/mBomMgmtList.jsp + productionplanning.xml:2874-3119
mBomMgmtGridList 매퍼 1:1 이식. PROJECT_MGMT × CONTRACT_ITEM 펼침 그리드
+ M-BOM 헤더/히스토리/구매리스트 상태 표시 + 9 검색 필터.

백엔드 (3 파일 + app.ts 마운트):
- services/mbomService.ts — list() : 9 검색 필터 + 30+ 컬럼 SELECT
  · 주문유형/제품구분/국내해외(CODE_NAME 비교)/고객사(C_ 3-way)/유무상/SN(EXISTS)
  · 품번/품명(PM·CI 양쪽 LIKE)/접수일·요청납기 범위
  · WRITER_NAME/MBOM_EDITOR : user_name() PL/pgSQL (PR-A0 신설)
  · MBOM_STATUS/MBOM_PART_NO/MBOM_REGDATE/MBOM_VERSION : mbom_header+history 서브쿼리
  · PURCHASE_LIST_OBJID/_DATE : sales_request_master.mbom_header_objid 매칭
  · CUSTOMER_NAME : CASE C_% → client_mng / ELSE → supply_mng
- controllers/mbomController.ts — getList
- routes/productionMbomRoutes.ts — GET /list
- app.ts — /api/production/mbom 마운트 (productionRoutes 다음)

프론트 (3 파일):
- lib/api/mbom.ts — MbomListFilter / MbomRow / mbomApi.list
- app/(main)/COMPANY_16/production/mbom/page.tsx — 검색 폼 2행(12 필드) + 16 컬럼 DataGrid
  · comm_code 옵션 로드: /api/sales/codes/0000167 (주문유형) /0000001 (제품구분) /0001782 (유무상)
  · 고객사: /api/sales/customers 재사용 (customer_mng)
  · 국내/해외 + 유상/무상 raw 옵션
- app/(main)/COMPANY_16/purchase/mbom/page.tsx — production/mbom 페이지 re-export
  (사용자 요청: 구매관리 메뉴 트리에도 동일 화면 노출)

메뉴 (data-sync):
- 03_mbom_menu_dedup.sql — menu_info 100016(purchase/mbom) + 100032(production/mbom)
  양쪽 active 보장 (이미 DB에 등록되어 있던 entry)

PR-A2 이후 분리:
- 단건 상세 다이얼로그, read-only mbom_detail 트리 표시
- BOM 복사 (E-BOM→M-BOM 트리 복사)
- 구매리스트 생성 액션 (M-BOM→PURCHASE)
- M-BOM 본 편집 (4프레임 팝업)

검증:
- backend nodemon hot-load OK (401 TOKEN_MISSING 응답으로 라우터 등록 확인)
- 매퍼 SQL 직접 실행: PROJECT_MGMT × CONTRACT_ITEM 5건 + CUSTOMER/M-BOM 매칭 정상
- typecheck: 신규 코드 0 에러 (pre-existing 에러만 잔존)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hjjeong
2026-05-13 15:57:23 +09:00
parent 7af366c595
commit 66cee22be3
8 changed files with 721 additions and 0 deletions
@@ -0,0 +1,293 @@
"use client";
// 생산관리 > M-BOM 관리 (PR-A1) — wace productionplanning/mBomMgmtList.jsp 1:1
// 그리드: PROJECT_MGMT × CONTRACT_ITEM 펼침 (1 프로젝트 = 1+ 행) + M-BOM 상태/저장일
// 검색: 주문유형 / 제품구분 / 국내해외 / 고객사 / 유무상 / S/N / 품번 / 품명 / 접수일 / 요청납기
// 액션 (PR-A1): 조회 / 초기화 / 페이지
// ※ BOM 복사 / 구매리스트 생성 / M-BOM 편집 트리 다이얼로그는 PR-A2 이후 분리.
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Search, Loader2, RotateCcw } from "lucide-react";
import { toast } from "sonner";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { apiClient } from "@/lib/api/client";
import { mbomApi, MbomListFilter, MbomRow } from "@/lib/api/mbom";
const PARENT_CATEGORY = "0000167"; // 주문유형 comm_code parent_code_id
const PARENT_PRODUCT = "0000001"; // 제품구분 comm_code parent_code_id
const PARENT_PAID = "0001782"; // 유/무상 (참고: 빈 결과여도 raw paid/free 매칭으로 fallback)
interface CodeOpt { code: string; label: string; sort?: number | null }
interface CustomerOpt { id: number | string; customer_name: string | null; customer_code: string | null }
const EMPTY_FILTER: MbomListFilter = {
search_category_cd: "",
search_product_cd: "",
search_area_cd: "",
search_customer_objid: "",
search_paid_type: "",
search_serial_no: "",
search_part_no: "",
search_part_name: "",
search_receipt_date_from: "",
search_receipt_date_to: "",
search_req_del_date_from: "",
search_req_del_date_to: "",
page: 1,
page_size: 50,
};
const GRID_COLUMNS: DataGridColumn[] = [
{ key: "project_no", label: "프로젝트번호", width: "w-[140px]" },
{ key: "category_name", label: "주문유형", width: "w-[100px]", align: "center" },
{ key: "product_name", label: "제품구분", width: "w-[90px]", align: "center" },
{ key: "area_name", label: "국내/해외", width: "w-[90px]", align: "center" },
{ key: "receipt_date", label: "접수일", width: "w-[100px]", align: "center" },
{ key: "writer_name", label: "작성자", width: "w-[90px]", align: "center" },
{ key: "customer_name", label: "고객사", minWidth: "min-w-[160px]" },
{ key: "paid_type_name", label: "유/무상", width: "w-[80px]", align: "center" },
{ key: "part_no", label: "품번", width: "w-[150px]" },
{ key: "part_name", label: "품명", minWidth: "min-w-[180px]" },
{ key: "serial_no", label: "S/N", width: "w-[110px]", align: "center" },
{ key: "quantity", label: "수주수량", width: "w-[90px]", align: "right", formatNumber: true },
{ key: "req_del_date", label: "요청납기", width: "w-[100px]", align: "center" },
{ key: "customer_request", label: "고객사요청사항", minWidth: "min-w-[200px]" },
{ key: "mbom_status", label: "M-BOM", width: "w-[80px]", align: "center" },
{ key: "mbom_regdate", label: "최종저장일", width: "w-[100px]", align: "center" },
];
export default function MbomMgmtPage() {
const [rows, setRows] = useState<MbomRow[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [filter, setFilter] = useState<MbomListFilter>(EMPTY_FILTER);
const [categoryOpts, setCategoryOpts] = useState<CodeOpt[]>([]);
const [productOpts, setProductOpts] = useState<CodeOpt[]>([]);
const [paidOpts, setPaidOpts] = useState<CodeOpt[]>([]);
const [customerOpts, setCustomerOpts] = useState<CustomerOpt[]>([]);
const fetchList = useCallback(async (override?: Partial<MbomListFilter>) => {
setLoading(true);
try {
const f = { ...filter, ...override };
const res = await mbomApi.list(f);
setRows(res.rows ?? []);
setTotal(res.totalCount ?? 0);
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패");
} finally {
setLoading(false);
}
}, [filter]);
// 초기 옵션 + 첫 조회
useEffect(() => {
let dead = false;
(async () => {
try {
const [c1, c2, c3, cust] = await Promise.all([
apiClient.get(`/sales/codes/${PARENT_CATEGORY}`),
apiClient.get(`/sales/codes/${PARENT_PRODUCT}`),
apiClient.get(`/sales/codes/${PARENT_PAID}`),
apiClient.get(`/sales/customers`),
]);
if (dead) return;
setCategoryOpts(c1.data?.data ?? []);
setProductOpts(c2.data?.data ?? []);
setPaidOpts(c3.data?.data ?? []);
setCustomerOpts(cust.data?.data ?? []);
} catch {
/* 옵션 로드 실패는 무시 — 그리드는 조회 가능 */
}
})();
fetchList(EMPTY_FILTER);
return () => { dead = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// DataGrid 키 부여 (objid + part_no 조합 — 같은 프로젝트 다중 행 unique)
const gridRows = useMemo(
() => rows.map((r, i) => ({ ...r, id: `${r.objid}__${r.part_no ?? ""}__${i}` })),
[rows]
);
const handleSearch = () => {
setFilter((f) => ({ ...f, page: 1 }));
fetchList({ page: 1 });
};
const handleReset = () => {
setFilter(EMPTY_FILTER);
fetchList(EMPTY_FILTER);
};
return (
<div className="flex h-full flex-col">
<div className="border-b bg-card px-4 py-3">
{/* 운영판 wace mBomMgmtList.jsp 1:1 — 2행 검색 폼 */}
<div className="space-y-2">
<div className="grid grid-cols-2 gap-x-3 gap-y-2 md:grid-cols-4 xl:grid-cols-6">
<Field label="주문유형">
<SelectBox
value={filter.search_category_cd ?? ""}
options={categoryOpts}
onChange={(v) => setFilter({ ...filter, search_category_cd: v })}
/>
</Field>
<Field label="제품구분">
<SelectBox
value={filter.search_product_cd ?? ""}
options={productOpts}
onChange={(v) => setFilter({ ...filter, search_product_cd: v })}
/>
</Field>
<Field label="국내/해외">
<select
className="h-9 w-full rounded-md border bg-background px-2 text-sm"
value={filter.search_area_cd ?? ""}
onChange={(e) => setFilter({ ...filter, search_area_cd: e.target.value })}
>
<option value=""></option>
<option value="국내"></option>
<option value="해외"></option>
</select>
</Field>
<Field label="고객사">
<select
className="h-9 w-full rounded-md border bg-background px-2 text-sm"
value={filter.search_customer_objid ?? ""}
onChange={(e) => setFilter({ ...filter, search_customer_objid: e.target.value })}
>
<option value=""></option>
{customerOpts.map((c) => (
<option key={`${c.id}`} value={c.customer_code ?? `${c.id}`}>
{c.customer_name}
</option>
))}
</select>
</Field>
<Field label="유/무상">
<select
className="h-9 w-full rounded-md border bg-background px-2 text-sm"
value={filter.search_paid_type ?? ""}
onChange={(e) => setFilter({ ...filter, search_paid_type: e.target.value })}
>
<option value=""></option>
{/* 운영판 1:1 — paid/free raw + comm_code 라벨 fallback */}
<option value="paid"></option>
<option value="free"></option>
{paidOpts.map((p) => (
<option key={p.code} value={p.code}>{p.label}</option>
))}
</select>
</Field>
<Field label="S/N">
<Input
value={filter.search_serial_no ?? ""}
onChange={(e) => setFilter({ ...filter, search_serial_no: e.target.value })}
/>
</Field>
</div>
<div className="grid grid-cols-2 gap-x-3 gap-y-2 md:grid-cols-4 xl:grid-cols-6">
<Field label="품번">
<Input
value={filter.search_part_no ?? ""}
onChange={(e) => setFilter({ ...filter, search_part_no: e.target.value })}
/>
</Field>
<Field label="품명">
<Input
value={filter.search_part_name ?? ""}
onChange={(e) => setFilter({ ...filter, search_part_name: e.target.value })}
/>
</Field>
<Field label="접수일 (시작)">
<Input
type="date"
value={filter.search_receipt_date_from ?? ""}
onChange={(e) => setFilter({ ...filter, search_receipt_date_from: e.target.value })}
/>
</Field>
<Field label="접수일 (종료)">
<Input
type="date"
value={filter.search_receipt_date_to ?? ""}
onChange={(e) => setFilter({ ...filter, search_receipt_date_to: e.target.value })}
/>
</Field>
<Field label="요청납기 (시작)">
<Input
type="date"
value={filter.search_req_del_date_from ?? ""}
onChange={(e) => setFilter({ ...filter, search_req_del_date_from: e.target.value })}
/>
</Field>
<Field label="요청납기 (종료)">
<Input
type="date"
value={filter.search_req_del_date_to ?? ""}
onChange={(e) => setFilter({ ...filter, search_req_del_date_to: e.target.value })}
/>
</Field>
</div>
</div>
<div className="mt-3 flex items-center justify-between">
<div className="text-xs text-muted-foreground">
{total.toLocaleString()} · PROJECT_MGMT × CONTRACT_ITEM
</div>
<div className="flex items-end gap-2">
<Button variant="outline" size="sm" onClick={handleReset}>
<RotateCcw className="h-4 w-4" /><span className="ml-1"></span>
</Button>
<Button size="sm" onClick={handleSearch} disabled={loading}>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
<span className="ml-1"></span>
</Button>
</div>
</div>
</div>
<div className="min-h-0 flex-1 p-2">
<DataGrid
columns={GRID_COLUMNS}
data={gridRows}
loading={loading}
showRowNumber
emptyMessage="조건에 맞는 프로젝트가 없습니다."
gridId="production-mbom-mgmt"
/>
</div>
</div>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<Label className="mb-1 block text-xs text-muted-foreground">{label}</Label>
{children}
</div>
);
}
function SelectBox({
value, options, onChange,
}: { value: string; options: CodeOpt[]; onChange: (v: string) => void }) {
return (
<select
className="h-9 w-full rounded-md border bg-background px-2 text-sm"
value={value}
onChange={(e) => onChange(e.target.value)}
>
<option value=""></option>
{options.map((o) => (
<option key={o.code} value={o.code}>{o.label}</option>
))}
</select>
);
}
@@ -0,0 +1,7 @@
// 구매관리 > M-BOM 관리 — production/mbom 페이지 re-export.
// 사용자 요청 (2026-05-13): wace 운영판은 "생산관리_M-BOM관리" 1:1 이지만,
// 구매관리 메뉴 트리에서도 동일 화면 진입을 허용한다.
//
// menu_info: 100016 (purchase/mbom) + 100032 (production/mbom) — 둘 다 active.
export { default } from "../../production/mbom/page";
+74
View File
@@ -0,0 +1,74 @@
import { apiClient } from "./client";
// ============================================================
// 생산관리 > M-BOM 관리 (PR-A1) — wace productionplanning.xml 1:1
// 라우트: /api/production/mbom/*
// ============================================================
export interface MbomListFilter {
search_category_cd?: string;
search_product_cd?: string;
search_area_cd?: string;
search_customer_objid?: string;
search_paid_type?: string;
search_serial_no?: string;
search_part_no?: string;
search_part_name?: string;
search_receipt_date_from?: string;
search_receipt_date_to?: string;
search_req_del_date_from?: string;
search_req_del_date_to?: string;
page?: number;
page_size?: number;
}
export interface MbomRow {
objid: string;
contract_objid: string | null;
project_no: string | null;
category_cd: string | null;
category_name: string | null;
product: string | null;
product_name: string | null;
area_cd: string | null;
area_name: string | null;
receipt_date: string | null;
writer_name: string | null;
customer_objid: string | null;
customer_name: string | null;
paid_type: string | null;
paid_type_name: string | null;
part_no: string | null;
part_name: string | null;
part_objid: string | null;
serial_no: string | null;
serial_no_list: string | null;
quantity: string | number | null;
req_del_date: string | null;
customer_request: string | null;
bom_report_objid: string | null;
ebom_status: string | null;
ebom_regdate: string | null;
mbom_header_objid: string | null;
purchase_list_objid: string | null;
purchase_list_date: string | null;
mbom_status: string | null;
mbom_part_no: string | null;
mbom_regdate: string | null;
mbom_editor: string | null;
mbom_version: number | null;
}
export interface MbomListResponse {
rows: MbomRow[];
totalCount: number;
page: number;
pageSize: number;
}
export const mbomApi = {
async list(filter: MbomListFilter = {}): Promise<MbomListResponse> {
const res = await apiClient.get("/production/mbom/list", { params: filter });
return res.data?.data as MbomListResponse;
},
};