생산관리>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:
@@ -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";
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user