From 36c1f3579e5284077dc452dedfcfb19512c2eddc Mon Sep 17 00:00:00 2001 From: hjjeong Date: Thu, 14 May 2026 14:42:52 +0900 Subject: [PATCH 1/7] =?UTF-8?q?=EA=B3=B5=EC=9A=A9=20DataGrid=20=E2=80=94?= =?UTF-8?q?=20logicstudio=20=EC=8A=A4=ED=83=80=EC=9D=BC=20toolbar=20+=20fo?= =?UTF-8?q?oter=20=ED=86=B5=EA=B3=84=20+=20=EC=B0=A8=ED=8A=B8=20=EB=B6=84?= =?UTF-8?q?=EC=84=9D=20=ED=8C=A8=EB=84=90=20=ED=9D=A1=EC=88=98,=20?= =?UTF-8?q?=EA=B2=AC=EC=A0=81=EA=B4=80=EB=A6=AC=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DataGrid: - ⟳ Refresh · ⬇ Download · ⚙️ 컬럼 표시 설정 · 📊 차트 분석 toolbar - 컬럼 visibility 토글 (데이터/시스템 그룹 분리 + 표시·순서·너비 reset) - summaryStats 하단 통계 행 (라벨/값/접미사) - paginationStyle 'range' — "1-N / 총 X건" + 페이지 크기 Select - 행 높이 컴팩트화 (h-7 + py-0 + leading-none, 아이콘 h-3.5) - sticky 헤더 불투명 배경(bg-muted)으로 스크롤 시 본문 비침 차단 - ⋮⋮ 드래그 핸들 항상 표시 DataGridChartPanel (신규): - 여러 차트 추가/삭제, 제목 인라인 편집, 드래그 순서 변경 - Bar/Line/Pie + X/Y축 선택 + count/sum/avg/min/max 집계 - localStorage 영속, 360px 고정 높이 + 내부 스크롤 견적관리: - 컬럼 폭 조정 (⋮⋮ 추가로 좁아진 한국어 4글자 라벨 보장) - summaryStats, onRefresh, onDownload(exportToExcel) 연결 - gridId="sales-estimate"로 영속화 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../(main)/COMPANY_16/sales/estimate/page.tsx | 59 ++- frontend/components/common/DataGrid.tsx | 323 ++++++++++++++-- .../components/common/DataGridChartPanel.tsx | 366 ++++++++++++++++++ 3 files changed, 703 insertions(+), 45 deletions(-) create mode 100644 frontend/components/common/DataGridChartPanel.tsx diff --git a/frontend/app/(main)/COMPANY_16/sales/estimate/page.tsx b/frontend/app/(main)/COMPANY_16/sales/estimate/page.tsx index 25480e51..94b4e385 100644 --- a/frontend/app/(main)/COMPANY_16/sales/estimate/page.tsx +++ b/frontend/app/(main)/COMPANY_16/sales/estimate/page.tsx @@ -26,30 +26,31 @@ import { ItemSearchDialog, ItemRow } from "@/components/common/ItemSearchDialog" import { AttachmentDialog } from "@/components/common/AttachmentDialog"; import { EstimateMailDialog } from "@/components/sales/EstimateMailDialog"; import { salesEstimateApi, EstimateRow, EstimateBody, EstimateItem } from "@/lib/api/salesEstimate"; +import { exportToExcel } from "@/lib/utils/excelExport"; // ─── 컬럼 ───────────────────────────────────────────────────── // wace_plm 원본 견적관리 그리드 컬럼 순서를 그대로 따름 const GRID_COLUMNS: DataGridColumn[] = [ { key: "contract_no", label: "영업번호", width: "w-[125px]", frozen: true }, - { key: "category_name", label: "주문유형", width: "w-[90px]", align: "center" }, + { key: "category_name", label: "주문유형", width: "w-[115px]", align: "center" }, { key: "receipt_date", label: "접수일", width: "w-[115px]", align: "center" }, { key: "earliest_due_date_label", label: "요청납기", width: "w-[160px]", align: "center" }, { key: "customer_name", label: "고객사", width: "w-[150px]" }, { key: "item_summary", label: "품명", width: "w-[200px]" }, - { key: "estimate_quantity", label: "견적수량", width: "w-[80px]", align: "right", formatNumber: true }, - { key: "paid_type_name", label: "유/무상", width: "w-[70px]", align: "center" }, - { key: "est_total_amount", label: "공급가액", width: "w-[110px]", formatMoney: true }, - { key: "est_total_amount_krw", label: "원화환산공급가액", width: "w-[140px]", formatMoney: true }, - { key: "est_status", label: "견적현황", width: "w-[80px]", align: "center", renderType: "folder" }, - { key: "add_est_cnt", label: "추가견적", width: "w-[80px]", align: "center", renderType: "clip" }, - { key: "appr_status", label: "결재상태", width: "w-[90px]", align: "center" }, - { key: "mail_send_status_label", label: "메일발송", width: "w-[110px]", align: "center" }, - { key: "contract_currency_name", label: "환종", width: "w-[70px]", align: "center" }, - { key: "exchange_rate", label: "환율", width: "w-[80px]", formatMoney: true }, + { key: "estimate_quantity", label: "견적수량", width: "w-[115px]", align: "right", formatNumber: true }, + { key: "paid_type_name", label: "유/무상", width: "w-[100px]", align: "center" }, + { key: "est_total_amount", label: "공급가액", width: "w-[130px]", formatMoney: true }, + { key: "est_total_amount_krw", label: "원화환산공급가액", width: "w-[180px]", formatMoney: true }, + { key: "est_status", label: "견적현황", width: "w-[115px]", align: "center", renderType: "folder" }, + { key: "add_est_cnt", label: "추가견적", width: "w-[115px]", align: "center", renderType: "clip" }, + { key: "appr_status", label: "결재상태", width: "w-[115px]", align: "center" }, + { key: "mail_send_status_label", label: "메일발송", width: "w-[125px]", align: "center" }, + { key: "contract_currency_name", label: "환종", width: "w-[95px]", align: "center" }, + { key: "exchange_rate", label: "환율", width: "w-[95px]", formatMoney: true }, { key: "serial_no", label: "S/N", width: "w-[140px]" }, { key: "part_no", label: "품번", width: "w-[120px]" }, - { key: "writer_name", label: "작성자", width: "w-[100px]" }, + { key: "writer_name", label: "작성자", width: "w-[115px]" }, /* wace estimateList_new.jsp 494~502 — 비활성(주석) 컬럼. 활성화 시 아래 주석 해제. { key: "product_name", label: "제품구분", width: "w-[90px]", align: "center" }, { key: "area_name", label: "국내/해외", width: "w-[90px]", align: "center" }, @@ -251,6 +252,23 @@ export default function SalesEstimatePage() { useEffect(() => { fetchList(); }, [fetchList]); + // ─── 하단 통계 ────────────────────────────────────────────── + // 견적 건수 / 견적수량 합계 / 공급가액 합계 / 원화환산공급가액 합계 + const estimateSummary = useMemo(() => { + const count = rows.length; + const qtySum = rows.reduce((acc, r) => acc + Number(r.estimate_quantity || 0), 0); + const amtSum = rows.reduce((acc, r) => acc + Number(r.est_total_amount || 0), 0); + const krwSum = rows.reduce((acc, r) => acc + Number(r.est_total_amount_krw || 0), 0); + const money = (n: number) => n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + const intFmt = (n: number) => n.toLocaleString(); + return [ + { label: "견적 건수", value: intFmt(count), suffix: "건" }, + { label: "견적수량 합계", value: intFmt(qtySum) }, + { label: "공급가액 합계", value: money(amtSum) }, + { label: "원화환산공급가액 합계", value: money(krwSum) }, + ]; + }, [rows]); + // ─── 다이얼로그 열기 ──────────────────────────────────────── const openCreate = async () => { @@ -567,6 +585,7 @@ export default function SalesEstimatePage() { {/* 그리드 — 첫 컬럼 체크박스 (행 아무 셀 클릭으로 단일 선택, 클립/폴더 등 팝업 컬럼은 stopPropagation으로 제외) */} { setSelected(row); openEdit(); }} emptyMessage={loading ? "조회 중..." : "데이터가 없습니다"} loading={loading} + showColumnSettings + paginationStyle="range" + pageSizeOptions={[10, 15, 20, 50, 100]} + summaryStats={estimateSummary} + systemColumnKeys={["writer_name"]} + onRefresh={fetchList} + onDownload={() => { + if (rows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; } + const exportRows = rows.map((r) => { + const out: Record = {}; + GRID_COLUMNS.forEach((col) => { out[col.label] = r[col.key] ?? ""; }); + return out; + }); + exportToExcel(exportRows, "견적관리.xlsx", "견적관리"); + }} + showChart /> {/* 등록/수정 Dialog */} diff --git a/frontend/components/common/DataGrid.tsx b/frontend/components/common/DataGrid.tsx index 8b911afc..cef90a87 100644 --- a/frontend/components/common/DataGrid.tsx +++ b/frontend/components/common/DataGrid.tsx @@ -22,7 +22,9 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@ import { Checkbox } from "@/components/ui/checkbox"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Input } from "@/components/ui/input"; -import { Filter, Check, Search, ImageIcon, X, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Folder, Paperclip } from "lucide-react"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Filter, Check, Search, ImageIcon, X, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Folder, Paperclip, Settings, GripVertical, RotateCw, Download as DownloadIcon, BarChart3 } from "lucide-react"; +import { DataGridChartPanel } from "./DataGridChartPanel"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; import { apiClient } from "@/lib/api/client"; @@ -76,6 +78,22 @@ export interface DataGridProps { showPagination?: boolean; /** 초기 페이지 크기 (기본: 50) */ defaultPageSize?: number; + /** 페이지네이션 라벨 포맷 — 'classic'(기본): "전체 N건", 'range': "1-N / 총 X건" */ + paginationStyle?: "classic" | "range"; + /** 페이지 크기 드롭다운 옵션. 지정 시 Input 대신 Select 렌더. 예: [10,15,20,50,100] */ + pageSizeOptions?: number[]; + /** 컬럼 표시/숨김 설정 드롭다운 활성화 (gear 아이콘) */ + showColumnSettings?: boolean; + /** 시스템 컬럼으로 분리 표시할 key 배열. 표시 메뉴에서 '시스템 컬럼' 그룹으로 묶임 */ + systemColumnKeys?: string[]; + /** 하단 통계 행 — 평균/합계 등. footer에 좌측 배치 */ + summaryStats?: Array<{ label: string; value: string | number; suffix?: string }>; + /** 재조회 콜백. 지정 시 그리드 상단 toolbar에 ⟳ 아이콘 표시 */ + onRefresh?: () => void; + /** 다운로드 콜백 (Excel/CSV 등). 지정 시 그리드 상단 toolbar에 ⬇ 아이콘 표시 */ + onDownload?: () => void; + /** 차트 분석 패널 활성화. 지정 시 toolbar에 📊 토글 + 그리드 하단에 패널 노출 */ + showChart?: boolean; } const fmtNum = (val: any) => { @@ -140,15 +158,19 @@ function SortableHeaderCell({ style={style} className={cn( widthPx == null && col.width, widthPx == null && col.minWidth, - "select-none relative", + "select-none relative group/th", col.frozen && cn("sticky z-20 bg-background", frozenLeftClass), )} > -
-
+ +
{ e.stopPropagation(); if (col.sortable !== false) onSort(col.key); @@ -273,10 +295,64 @@ export function DataGrid({ gridId, showPagination = true, defaultPageSize = 50, + paginationStyle = "classic", + pageSizeOptions, + showColumnSettings = false, + systemColumnKeys, + summaryStats, + onRefresh, + onDownload, + showChart = false, }: DataGridProps) { const [columns, setColumns] = useState(initialColumns); useEffect(() => { setColumns(initialColumns); }, [initialColumns]); + // 차트 패널 열림 상태 (gridId 있으면 localStorage 영속) + const [chartPanelOpen, setChartPanelOpen] = useState(false); + useEffect(() => { + if (!gridId) return; + const saved = localStorage.getItem(`datagrid_chart_open_${gridId}`); + if (saved === "1") setChartPanelOpen(true); + }, [gridId]); + const toggleChartPanel = useCallback(() => { + setChartPanelOpen((prev) => { + const next = !prev; + if (gridId) { + try { localStorage.setItem(`datagrid_chart_open_${gridId}`, next ? "1" : "0"); } catch { /* skip */ } + } + return next; + }); + }, [gridId]); + + // 컬럼 visibility — 숨겨진 컬럼 key Set. localStorage 영속(gridId 있을 때). + const [hiddenColumns, setHiddenColumns] = useState>(new Set()); + useEffect(() => { + if (!gridId) return; + const saved = localStorage.getItem(`datagrid_col_hidden_${gridId}`); + if (saved) { + try { setHiddenColumns(new Set(JSON.parse(saved) as string[])); } catch { /* skip */ } + } + }, [gridId]); + const toggleColumnVisibility = useCallback((key: string) => { + setHiddenColumns((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); else next.add(key); + if (gridId) { + try { localStorage.setItem(`datagrid_col_hidden_${gridId}`, JSON.stringify(Array.from(next))); } catch { /* skip */ } + } + return next; + }); + }, [gridId]); + const resetVisibility = useCallback(() => { + setHiddenColumns(new Set()); + if (gridId) try { localStorage.removeItem(`datagrid_col_hidden_${gridId}`); } catch { /* skip */ } + }, [gridId]); + const resetOrder = useCallback(() => { + setColumns(initialColumns); + if (gridId) try { localStorage.removeItem(`datagrid_col_order_${gridId}`); } catch { /* skip */ } + }, [gridId, initialColumns]); + // resetWidthsAll은 setColumnWidths 선언 이후에 정의 (아래에서) + // 정렬 const [sortKey, setSortKey] = useState(null); const [sortDir, setSortDir] = useState<"asc" | "desc">("asc"); @@ -331,6 +407,16 @@ export function DataGrid({ try { localStorage.setItem(`datagrid_col_widths_${gridId}`, JSON.stringify(next)); } catch { /* skip */ } } }, [gridId]); + const resetWidthsAll = useCallback(() => { + setColumnWidths({}); + if (gridId) try { localStorage.removeItem(`datagrid_col_widths_${gridId}`); } catch { /* skip */ } + }, [gridId]); + + // 가시 컬럼 — hiddenColumns 적용. 헤더/바디 렌더에 사용. DnD/순서 영속은 전체 columns 기준 유지. + const visibleColumns = useMemo( + () => columns.filter((c) => !hiddenColumns.has(c.key)), + [columns, hiddenColumns], + ); // 리사이즈 드래그 핸들러 — 헤더 우측 핸들에서 mousedown 발생 시 호출. const startResize = useCallback((e: React.MouseEvent, colKey: string, currentWidthPx: number) => { @@ -629,7 +715,7 @@ export function DataGrid({ const isRightAlign = col.align === "right" || isNumericCol; return ( - {display} @@ -637,22 +723,135 @@ export function DataGrid({ }; // 좌측 고정 보조: NO/체크박스 컬럼은 40px(=left-10), 첫 frozen 컬럼은 그 다음 위치 - const hasFrozen = columns.some((c) => c.frozen); + const hasFrozen = visibleColumns.some((c) => c.frozen); const hasFirstCol = showCheckbox || showRowNumber; const stickyFirstColClass = "sticky left-0 z-20 bg-background"; const stickyFirstColBodyClass = "sticky left-0 z-[6]"; const frozenLeftClass = hasFirstCol ? "left-10" : "left-0"; + // 컬럼 settings dropdown — 데이터/시스템 그룹 분리. 시스템 키는 systemColumnKeys로 지정. + const systemKeySet = useMemo(() => new Set(systemColumnKeys ?? []), [systemColumnKeys]); + const dataCols = useMemo(() => columns.filter((c) => !systemKeySet.has(c.key)), [columns, systemKeySet]); + const systemCols = useMemo(() => columns.filter((c) => systemKeySet.has(c.key)), [columns, systemKeySet]); + + const hasToolbar = showColumnSettings || onRefresh || onDownload || showChart; + return (
+ {hasToolbar && ( +
+ {onRefresh && ( + + )} + {showChart && ( + + )} + {onDownload && ( + + )} + {showColumnSettings && ( + + + + + +
+ 표시할 컬럼 선택 +
+
+ {dataCols.length > 0 && ( +
+
데이터 컬럼
+
+ {dataCols.map((col) => { + const checked = !hiddenColumns.has(col.key); + return ( + + ); + })} +
+
+ )} + {systemCols.length > 0 && ( +
+
시스템 컬럼
+
+ {systemCols.map((col) => { + const checked = !hiddenColumns.has(col.key); + return ( + + ); + })} +
+
+ )} +
+
+ + + +
+
+
+ )} +
+ )}
- - c.key)} strategy={horizontalListSortingStrategy}> + + c.key)} strategy={horizontalListSortingStrategy}> {showCheckbox && ( - + 0 && checkedIds.length === processedData.length} onCheckedChange={(checked) => { @@ -662,9 +861,9 @@ export function DataGrid({ )} {showRowNumber && !showCheckbox && ( - No + No )} - {columns.map((col) => ( + {visibleColumns.map((col) => ( - + {loading ? ( - + 로딩 중... ) : paginatedData.length === 0 ? ( - + {emptyMessage} @@ -704,6 +903,7 @@ export function DataGrid({ return ( {pageOffset + rowIdx + 1} )} - {columns.map((col) => { + {visibleColumns.map((col) => { const w = columnWidths[col.key]; const inlineStyle = w != null ? { width: w, minWidth: w, maxWidth: w } : undefined; // 일반 텍스트 셀에 컬럼 단위 onClick 지원 (clip/folder 외) @@ -759,7 +959,7 @@ export function DataGrid({ key={col.key} style={inlineStyle} className={cn( - w == null && col.width, w == null && col.minWidth, "py-2.5", + w == null && col.width, w == null && col.minWidth, "py-1", col.editable && "cursor-text", cellClickable && "cursor-pointer hover:underline text-primary", isSelected && "bg-accent", @@ -784,27 +984,79 @@ export function DataGrid({ + {/* 하단 통계 행 — summaryStats 지정 시. 페이지네이션 footer 위에 별도 띠로 노출. */} + {summaryStats && summaryStats.length > 0 && ( +
+ {summaryStats.map((s, i) => ( +
+ {s.label} + {s.value}{s.suffix ?? ""} +
+ ))} +
+ )} + {/* 페이지네이션 footer */} {showPagination && (
- {/* 좌측: 데이터 수량 + 페이지 크기 입력 */} + {/* 좌측: 데이터 수량 + 페이지 크기 입력/Select */}
- 전체 - {totalItems.toLocaleString()} - + {paginationStyle === "range" ? ( + <> + + {totalItems === 0 ? 0 : (pageOffset + 1).toLocaleString()}-{(pageOffset + paginatedData.length).toLocaleString()} + + / 총 + {totalItems.toLocaleString()} + + + ) : ( + <> + 전체 + {totalItems.toLocaleString()} + + + )}
- setPageSizeInput(e.target.value)} - onBlur={applyPageSize} - onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); applyPageSize(); } }} - className="h-7 w-16 text-center text-xs" - /> - 건씩 보기 + {pageSizeOptions && pageSizeOptions.length > 0 ? ( + + ) : ( + <> + setPageSizeInput(e.target.value)} + onBlur={applyPageSize} + onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); applyPageSize(); } }} + className="h-7 w-16 text-center text-xs" + /> + 건씩 보기 + + )}
@@ -854,6 +1106,11 @@ export function DataGrid({
)} + {/* 차트 분석 패널 — showChart + chartPanelOpen 시 노출 */} + {showChart && chartPanelOpen && ( + + )} + {/* 이미지 확대 모달 */} {previewImage && (
= { + count: "건수", sum: "합계", avg: "평균", min: "최소", max: "최대", +}; +const CHART_TYPE_LABEL: Record = { + bar: "막대", line: "선", pie: "원형", +}; +const PIE_COLORS = ["#3b82f6", "#f59e0b", "#10b981", "#ef4444", "#8b5cf6", "#ec4899", "#06b6d4", "#84cc16"]; + +function newChartConfig(columns: DataGridColumn[]): ChartConfig { + const numericCols = columns.filter((c) => c.formatNumber || c.formatMoney || c.inputType === "number"); + const firstCat = columns.find((c) => !numericCols.includes(c)); + const firstNum = numericCols[0]; + return { + id: `chart-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + title: "제목 없는 차트", + type: "bar", + xKey: firstCat?.key ?? columns[0]?.key ?? "", + yKey: firstNum?.key ?? "", + agg: firstNum ? "sum" : "count", + }; +} + +function aggregate( + rows: Record[], + xKey: string, + yKey: string, + agg: AggType, +): Array<{ name: string; value: number }> { + const groups: Record = {}; + for (const row of rows) { + const xVal = row[xKey] == null || row[xKey] === "" ? "(빈 값)" : String(row[xKey]); + if (!groups[xVal]) groups[xVal] = []; + if (agg === "count") { + groups[xVal].push(1); + } else { + const raw = row[yKey]; + const n = typeof raw === "number" ? raw : Number(String(raw ?? "").replace(/,/g, "")); + if (!isNaN(n)) groups[xVal].push(n); + } + } + return Object.entries(groups).map(([name, vals]) => { + let v: number; + if (agg === "count") v = vals.length; + else if (agg === "sum") v = vals.reduce((a, b) => a + b, 0); + else if (agg === "avg") v = vals.length > 0 ? vals.reduce((a, b) => a + b, 0) / vals.length : 0; + else if (agg === "min") v = vals.length > 0 ? Math.min(...vals) : 0; + else v = vals.length > 0 ? Math.max(...vals) : 0; + return { name, value: Number(v.toFixed(2)) }; + }); +} + +// ─── 단일 차트 카드 ───────────────────────────────────────────── + +interface ChartCardProps { + cfg: ChartConfig; + columns: DataGridColumn[]; + data: Record[]; + onChange: (next: ChartConfig) => void; + onDelete: () => void; +} + +function SortableChartCard({ cfg, columns, data, onChange, onDelete }: ChartCardProps) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: cfg.id }); + const [editingTitle, setEditingTitle] = useState(false); + const [titleDraft, setTitleDraft] = useState(cfg.title); + + const numericCols = useMemo( + () => columns.filter((c) => c.formatNumber || c.formatMoney || c.inputType === "number"), + [columns], + ); + + const chartData = useMemo( + () => aggregate(data, cfg.xKey, cfg.yKey, cfg.agg), + [data, cfg.xKey, cfg.yKey, cfg.agg], + ); + + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + }; + + return ( +
+ {/* 카드 헤더: 드래그 핸들 + 제목 + 삭제 */} +
+ + {editingTitle ? ( + setTitleDraft(e.target.value)} + onBlur={() => { onChange({ ...cfg, title: titleDraft.trim() || "제목 없는 차트" }); setEditingTitle(false); }} + onKeyDown={(e) => { + if (e.key === "Enter") { onChange({ ...cfg, title: titleDraft.trim() || "제목 없는 차트" }); setEditingTitle(false); } + if (e.key === "Escape") { setTitleDraft(cfg.title); setEditingTitle(false); } + }} + className="h-7 text-sm flex-1" + /> + ) : ( + + )} + +
+ + {/* 컨트롤 행 */} +
+ 유형 + + + X축 + + + 집계 + + + {cfg.agg !== "count" && ( + <> + Y축 + + + )} +
+ + {/* 차트 본체 */} +
+ {chartData.length === 0 ? ( +
+ 데이터 없음 +
+ ) : ( + + {cfg.type === "bar" ? ( + + + + + + c.key === cfg.yKey)?.label ?? ""} ${AGG_LABEL[cfg.agg]}`} /> + + ) : cfg.type === "line" ? ( + + + + + + c.key === cfg.yKey)?.label ?? ""} ${AGG_LABEL[cfg.agg]}`} /> + + ) : ( + + + {chartData.map((_, idx) => )} + + + + + )} + + )} +
+
+ ); +} + +// ─── 패널 본체 ────────────────────────────────────────────────── + +interface DataGridChartPanelProps { + /** localStorage 키 prefix용 grid ID. 미지정 시 영속 비활성 */ + gridId?: string; + columns: DataGridColumn[]; + data: Record[]; +} + +export function DataGridChartPanel({ gridId, columns, data }: DataGridChartPanelProps) { + const [charts, setCharts] = useState([]); + const [collapsed, setCollapsed] = useState(false); + + const storageKey = gridId ? `datagrid_charts_${gridId}` : null; + + // 영속 로드 + useEffect(() => { + if (!storageKey) return; + try { + const saved = localStorage.getItem(storageKey); + if (saved) { + const parsed = JSON.parse(saved) as ChartConfig[]; + if (Array.isArray(parsed)) setCharts(parsed); + } + } catch { /* skip */ } + }, [storageKey]); + + const persist = useCallback((next: ChartConfig[]) => { + setCharts(next); + if (storageKey) { + try { localStorage.setItem(storageKey, JSON.stringify(next)); } catch { /* skip */ } + } + }, [storageKey]); + + const addChart = () => persist([...charts, newChartConfig(columns)]); + const updateChart = (id: string, next: ChartConfig) => persist(charts.map((c) => c.id === id ? next : c)); + const deleteChart = (id: string) => persist(charts.filter((c) => c.id !== id)); + + const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } })); + const handleDragEnd = (e: DragEndEvent) => { + const { active, over } = e; + if (!over || active.id === over.id) return; + const oldIdx = charts.findIndex((c) => c.id === active.id); + const newIdx = charts.findIndex((c) => c.id === over.id); + if (oldIdx < 0 || newIdx < 0) return; + persist(arrayMove(charts, oldIdx, newIdx)); + }; + + return ( +
+ {/* 패널 헤더 */} +
+
+ + 차트 분석 ({charts.length}개) +
+ +
+ + {/* 차트 카드들 — 내부 스크롤 */} + {!collapsed && ( +
+ {charts.length === 0 ? ( +
+ + 차트 추가 버튼을 눌러 분석 차트를 만들어보세요. +
+ ) : ( + + c.id)} strategy={verticalListSortingStrategy}> +
= 2 ? "grid-cols-1 lg:grid-cols-2" : "grid-cols-1")}> + {charts.map((cfg) => ( + updateChart(cfg.id, next)} + onDelete={() => deleteChart(cfg.id)} + /> + ))} +
+
+
+ )} +
+ )} +
+ ); +} From fc959d8872b0a2ad897fb0491e0b89edacce1a62 Mon Sep 17 00:00:00 2001 From: hjjeong Date: Thu, 14 May 2026 14:53:07 +0900 Subject: [PATCH 2/7] =?UTF-8?q?=EC=98=81=EC=97=85=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=E2=80=94=20=EC=A3=BC=EB=AC=B8/=ED=8C=90=EB=A7=A4/=EB=A7=A4?= =?UTF-8?q?=EC=B6=9C=EC=97=90=20logicstudio=20=EC=8A=A4=ED=83=80=EC=9D=BC?= =?UTF-8?q?=20DataGrid=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 견적관리(36c1f357)와 동일한 패턴을 나머지 3개 메뉴로 확장: 공통 DataGrid props: - gridId (sales-order / sales-sale / sales-revenue) 로 컬럼 visibility·순서·너비·차트 영속 - showColumnSettings, paginationStyle="range", pageSizeOptions=[10,15,20,50,100] - onRefresh = fetchList, onDownload = exportToExcel(GRID_COLUMNS 라벨 매핑) - showChart 도메인별 summaryStats (하단 통계 행): - 주문: 수주 건수 / 수주수량·수주취소 합계 / 공급가액·부가세·총액·원화총액 합계 - 판매: 판매 라인 / 수주·판매·잔량 수량 합계 / 판매공급가액·판매원화·잔량원화 합계 - 매출: 매출 이력 / 수량 합계 / 공급가액·부가세·총액·원화총액 합계 컬럼 폭 보정: - ⋮⋮ 드래그 핸들 추가로 좁아진 4글자 한국어 라벨이 잘리지 않도록 95~135px로 확대 - 시스템 컬럼: 주문관리 writer_name (systemColumnKeys 분리) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../(main)/COMPANY_16/sales/order/page.tsx | 73 ++++++++++++++---- .../(main)/COMPANY_16/sales/revenue/page.tsx | 70 +++++++++++++---- .../app/(main)/COMPANY_16/sales/sale/page.tsx | 76 ++++++++++++++----- 3 files changed, 169 insertions(+), 50 deletions(-) diff --git a/frontend/app/(main)/COMPANY_16/sales/order/page.tsx b/frontend/app/(main)/COMPANY_16/sales/order/page.tsx index ce218529..804615d0 100644 --- a/frontend/app/(main)/COMPANY_16/sales/order/page.tsx +++ b/frontend/app/(main)/COMPANY_16/sales/order/page.tsx @@ -26,33 +26,34 @@ import { AttachmentDialog } from "@/components/common/AttachmentDialog"; import { OrderFormViewDialog } from "@/components/sales/OrderFormViewDialog"; import { OrderRegistDialog } from "@/components/sales/OrderRegistDialog"; import { salesOrderMgmtApi, OrderRow, OrderBody, OrderItem } from "@/lib/api/salesOrderMgmt"; +import { exportToExcel } from "@/lib/utils/excelExport"; // wace_plm orderMgmtList.jsp 컬럼 순서/라벨에 맞춤 const GRID_COLUMNS: DataGridColumn[] = [ { key: "contract_no", label: "영업번호", width: "w-[125px]", frozen: true }, - { key: "category_name", label: "주문유형", width: "w-[90px]", align: "center" }, - { key: "order_date", label: "발주일", width: "w-[120px]", align: "center" }, - { key: "po_no", label: "발주번호", width: "w-[130px]" }, + { key: "category_name", label: "주문유형", width: "w-[115px]", align: "center" }, + { key: "order_date", label: "발주일", width: "w-[115px]", align: "center" }, + { key: "po_no", label: "발주번호", width: "w-[140px]" }, { key: "earliest_due_date_label", label: "요청납기", width: "w-[160px]", align: "center" }, { key: "customer_name", label: "고객사", width: "w-[160px]" }, { key: "item_summary", label: "품명", width: "w-[200px]" }, - { key: "order_quantity", label: "수주수량", width: "w-[90px]", align: "right", formatNumber: true }, - { key: "cancel_qty_sum", label: "수주취소", width: "w-[90px]", align: "right", formatNumber: true }, - { key: "paid_type_name", label: "유/무상", width: "w-[80px]", align: "center" }, - { key: "contract_result_name", label: "수주상태", width: "w-[100px]", align: "center" }, - { key: "order_supply_price_sum", label: "공급가액", width: "w-[120px]", formatMoney: true }, - { key: "order_vat_sum", label: "부가세", width: "w-[100px]", formatMoney: true }, + { key: "order_quantity", label: "수주수량", width: "w-[115px]", align: "right", formatNumber: true }, + { key: "cancel_qty_sum", label: "수주취소", width: "w-[115px]", align: "right", formatNumber: true }, + { key: "paid_type_name", label: "유/무상", width: "w-[100px]", align: "center" }, + { key: "contract_result_name", label: "수주상태", width: "w-[115px]", align: "center" }, + { key: "order_supply_price_sum", label: "공급가액", width: "w-[130px]", formatMoney: true }, + { key: "order_vat_sum", label: "부가세", width: "w-[115px]", formatMoney: true }, { key: "order_total_amount_sum", label: "총액", width: "w-[120px]", formatMoney: true }, - { key: "order_total_amount_krw", label: "원화총액", width: "w-[120px]", formatMoney: true }, - { key: "cu01_cnt", label: "주문서첨부", width: "w-[90px]", align: "center", renderType: "clip" }, - { key: "has_order_data", label: "주문서", width: "w-[80px]", align: "center", renderType: "folder" }, + { key: "order_total_amount_krw", label: "원화총액", width: "w-[130px]", formatMoney: true }, + { key: "cu01_cnt", label: "주문서첨부", width: "w-[115px]", align: "center", renderType: "clip" }, + { key: "has_order_data", label: "주문서", width: "w-[100px]", align: "center", renderType: "folder" }, { key: "customer_request", label: "고객사요청사항", width: "w-[180px]" }, - { key: "order_appr_status", label: "결재상태", width: "w-[90px]", align: "center" }, - { key: "contract_currency_name", label: "환종", width: "w-[70px]", align: "center" }, - { key: "exchange_rate", label: "환율", width: "w-[80px]", formatMoney: true }, + { key: "order_appr_status", label: "결재상태", width: "w-[115px]", align: "center" }, + { key: "contract_currency_name", label: "환종", width: "w-[95px]", align: "center" }, + { key: "exchange_rate", label: "환율", width: "w-[95px]", formatMoney: true }, { key: "serial_no", label: "S/N", width: "w-[140px]" }, { key: "part_no", label: "품번", width: "w-[120px]" }, - { key: "writer_name", label: "작성자", width: "w-[110px]" }, + { key: "writer_name", label: "작성자", width: "w-[115px]" }, /* wace orderMgmtList.jsp 429~434 — 비활성(주석) 컬럼. 활성화 시 아래 주석 해제. { key: "product_name", label: "제품구분", width: "w-[90px]", align: "center" }, { key: "area_name", label: "국내/해외", width: "w-[90px]", align: "center" }, @@ -209,6 +210,29 @@ export default function SalesOrderPage() { useEffect(() => { fetchList(); }, [fetchList]); + // ─── 하단 통계 ────────────────────────────────────────────── + // 수주 건수 / 수주수량·취소 합계 / 공급가액·부가세·총액·원화총액 합계 + const orderSummary = useMemo(() => { + const count = rows.length; + const qtySum = rows.reduce((acc, r) => acc + Number(r.order_quantity || 0), 0); + const cancelSum = rows.reduce((acc, r) => acc + Number(r.cancel_qty_sum || 0), 0); + const supplySum = rows.reduce((acc, r) => acc + Number(r.order_supply_price_sum || 0), 0); + const vatSum = rows.reduce((acc, r) => acc + Number(r.order_vat_sum || 0), 0); + const totalSum = rows.reduce((acc, r) => acc + Number(r.order_total_amount_sum || 0), 0); + const krwSum = rows.reduce((acc, r) => acc + Number(r.order_total_amount_krw || 0), 0); + const money = (n: number) => n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + const intFmt = (n: number) => n.toLocaleString(); + return [ + { label: "수주 건수", value: intFmt(count), suffix: "건" }, + { label: "수주수량 합계", value: intFmt(qtySum) }, + { label: "수주취소 합계", value: intFmt(cancelSum) }, + { label: "공급가액 합계", value: money(supplySum) }, + { label: "부가세 합계", value: money(vatSum) }, + { label: "총액 합계", value: money(totalSum) }, + { label: "원화총액 합계", value: money(krwSum) }, + ]; + }, [rows]); + const openCreate = async () => { setDialogMode("create"); const contractNo = await salesOrderMgmtApi.generateNumber().catch(() => ""); @@ -619,6 +643,7 @@ export default function SalesOrderPage() { { setSelected(row); openEdit(); }} emptyMessage={loading ? "조회 중..." : "데이터가 없습니다"} loading={loading} + showColumnSettings + paginationStyle="range" + pageSizeOptions={[10, 15, 20, 50, 100]} + summaryStats={orderSummary} + systemColumnKeys={["writer_name"]} + onRefresh={fetchList} + onDownload={() => { + if (rows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; } + const exportRows = rows.map((r) => { + const out: Record = {}; + GRID_COLUMNS.forEach((col) => { out[col.label] = r[col.key] ?? ""; }); + return out; + }); + exportToExcel(exportRows, "주문관리.xlsx", "주문관리"); + }} + showChart /> diff --git a/frontend/app/(main)/COMPANY_16/sales/revenue/page.tsx b/frontend/app/(main)/COMPANY_16/sales/revenue/page.tsx index e93d3dea..99dd5c19 100644 --- a/frontend/app/(main)/COMPANY_16/sales/revenue/page.tsx +++ b/frontend/app/(main)/COMPANY_16/sales/revenue/page.tsx @@ -23,6 +23,7 @@ import { PageHeader } from "@/components/common/PageHeader"; import { CompactFilterBar, CompactFilterField, CompactDateRange } from "@/components/common/CompactFilterBar"; import { ProjectInfoDialog, ProjectInfoData } from "@/components/project/ProjectInfoDialog"; import { salesSaleApi, RevenueListRow, DeadlineInfoBody } from "@/lib/api/salesSale"; +import { exportToExcel } from "@/lib/utils/excelExport"; // RevenueListRow → 정규화된 ProjectInfoData (wace 운영판 다이얼로그 1:1) const toProjectInfo = (r: RevenueListRow): ProjectInfoData => ({ @@ -45,31 +46,31 @@ const toProjectInfo = (r: RevenueListRow): ProjectInfoData => ({ // wace_plm revenueMgmtList.jsp 컬럼 순서/라벨에 맞춤 const GRID_COLUMNS: DataGridColumn[] = [ { key: "project_no", label: "프로젝트번호", width: "w-[170px]", frozen: true }, - { key: "order_type_name", label: "주문유형", width: "w-[90px]", align: "center" }, - { key: "sales_deadline_date", label: "매출마감", width: "w-[110px]", align: "center" }, + { key: "order_type_name", label: "주문유형", width: "w-[115px]", align: "center" }, + { key: "sales_deadline_date", label: "매출마감", width: "w-[115px]", align: "center" }, { key: "order_date", label: "발주일", width: "w-[115px]", align: "center" }, { key: "po_no", label: "발주번호", width: "w-[140px]" }, { key: "customer", label: "고객사", width: "w-[160px]" }, - { key: "product_type_name", label: "제품구분", width: "w-[90px]", align: "center" }, + { key: "product_type_name", label: "제품구분", width: "w-[115px]", align: "center" }, { key: "product_name", label: "품명", width: "w-[180px]" }, - { key: "sales_quantity", label: "수량", width: "w-[80px]", align: "right", formatNumber: true }, - { key: "sales_unit_price", label: "단가", width: "w-[110px]", formatMoney: true }, - { key: "sales_supply_price", label: "공급가액", width: "w-[120px]", formatMoney: true }, - { key: "sales_vat", label: "부가세", width: "w-[100px]", formatMoney: true }, + { key: "sales_quantity", label: "수량", width: "w-[95px]", align: "right", formatNumber: true }, + { key: "sales_unit_price", label: "단가", width: "w-[115px]", formatMoney: true }, + { key: "sales_supply_price", label: "공급가액", width: "w-[130px]", formatMoney: true }, + { key: "sales_vat", label: "부가세", width: "w-[115px]", formatMoney: true }, { key: "sales_total_amount", label: "총액", width: "w-[120px]", formatMoney: true }, - { key: "sales_total_amount_krw", label: "원화총액", width: "w-[120px]", formatMoney: true }, + { key: "sales_total_amount_krw", label: "원화총액", width: "w-[130px]", formatMoney: true }, { key: "shipping_date", label: "출하일", width: "w-[115px]", align: "center" }, - { key: "nation_name", label: "국내/해외", width: "w-[90px]", align: "center" }, - { key: "sales_currency_name", label: "환종", width: "w-[70px]", align: "center" }, - { key: "sales_exchange_rate", label: "환율", width: "w-[80px]", formatMoney: true }, + { key: "nation_name", label: "국내/해외", width: "w-[115px]", align: "center" }, + { key: "sales_currency_name", label: "환종", width: "w-[95px]", align: "center" }, + { key: "sales_exchange_rate", label: "환율", width: "w-[95px]", formatMoney: true }, { key: "serial_no", label: "S/N", width: "w-[140px]" }, { key: "split_serial_no", label: "분할S/N", width: "w-[140px]" }, { key: "product_no", label: "품번", width: "w-[120px]" }, - { key: "tax_type", label: "과세구분", width: "w-[100px]", align: "center" }, - { key: "tax_invoice_date", label: "세금계산서발행일", width: "w-[140px]", align: "center" }, - { key: "export_decl_no", label: "수출신고필증신고번호", width: "w-[160px]" }, - { key: "loading_date", label: "선적일자", width: "w-[100px]", align: "center" }, - { key: "has_transaction_statement", label: "거래명세서", width: "w-[100px]", align: "center" }, + { key: "tax_type", label: "과세구분", width: "w-[115px]", align: "center" }, + { key: "tax_invoice_date", label: "세금계산서발행일", width: "w-[160px]", align: "center" }, + { key: "export_decl_no", label: "수출신고필증신고번호", width: "w-[180px]" }, + { key: "loading_date", label: "선적일자", width: "w-[115px]", align: "center" }, + { key: "has_transaction_statement", label: "거래명세서", width: "w-[115px]", align: "center" }, /* wace revenueMgmtList.jsp 615~632 — 비활성(주석) 컬럼. 활성화 시 아래 주석 해제. { key: "receipt_date", label: "접수일", width: "w-[115px]", align: "center" }, { key: "payment_type_name", label: "유/무상", width: "w-[80px]", align: "center" }, @@ -134,6 +135,27 @@ export default function SalesRevenuePage() { useEffect(() => { fetchList(); }, [fetchList]); + // ─── 하단 통계 ────────────────────────────────────────────── + // 매출 이력 건수 / 수량 합계 / 공급가액·부가세·총액·원화총액 합계 + const revenueSummary = useMemo(() => { + const count = rows.length; + const qtySum = rows.reduce((acc, r) => acc + Number(r.sales_quantity || 0), 0); + const supplySum = rows.reduce((acc, r) => acc + Number(r.sales_supply_price || 0), 0); + const vatSum = rows.reduce((acc, r) => acc + Number(r.sales_vat || 0), 0); + const totalSum = rows.reduce((acc, r) => acc + Number(r.sales_total_amount || 0), 0); + const krwSum = rows.reduce((acc, r) => acc + Number(r.sales_total_amount_krw || 0), 0); + const money = (n: number) => n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + const intFmt = (n: number) => n.toLocaleString(); + return [ + { label: "매출 이력", value: intFmt(count), suffix: "건" }, + { label: "수량 합계", value: intFmt(qtySum) }, + { label: "공급가액 합계", value: money(supplySum) }, + { label: "부가세 합계", value: money(vatSum) }, + { label: "총액 합계", value: money(totalSum) }, + { label: "원화총액 합계", value: money(krwSum) }, + ]; + }, [rows]); + const openDeadline = () => { if (!selected) { toast.warning("마감정보를 입력할 행을 선택하세요."); return; } setForm({ @@ -273,6 +295,7 @@ export default function SalesRevenuePage() { { setSelected(row); openDeadline(); }} emptyMessage={loading ? "조회 중..." : "데이터가 없습니다"} loading={loading} + showColumnSettings + paginationStyle="range" + pageSizeOptions={[10, 15, 20, 50, 100]} + summaryStats={revenueSummary} + onRefresh={fetchList} + onDownload={() => { + if (rows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; } + const exportRows = rows.map((r) => { + const out: Record = {}; + GRID_COLUMNS.forEach((col) => { out[col.label] = r[col.key] ?? ""; }); + return out; + }); + exportToExcel(exportRows, "매출관리.xlsx", "매출관리"); + }} + showChart /> diff --git a/frontend/app/(main)/COMPANY_16/sales/sale/page.tsx b/frontend/app/(main)/COMPANY_16/sales/sale/page.tsx index 8035d5e7..6f76e4ac 100644 --- a/frontend/app/(main)/COMPANY_16/sales/sale/page.tsx +++ b/frontend/app/(main)/COMPANY_16/sales/sale/page.tsx @@ -22,6 +22,7 @@ import { PageHeader } from "@/components/common/PageHeader"; import { CompactFilterBar, CompactFilterField, CompactDateRange } from "@/components/common/CompactFilterBar"; import { ProjectInfoDialog, ProjectInfoData } from "@/components/project/ProjectInfoDialog"; import { salesSaleApi, SaleListRow, SaleRegisterBody } from "@/lib/api/salesSale"; +import { exportToExcel } from "@/lib/utils/excelExport"; // SaleListRow → 정규화된 ProjectInfoData (wace 운영판 다이얼로그 1:1) const toProjectInfo = (r: SaleListRow): ProjectInfoData => ({ @@ -44,29 +45,29 @@ const toProjectInfo = (r: SaleListRow): ProjectInfoData => ({ // wace_plm salesMgmtList.jsp 컬럼 순서/라벨에 맞춤 const GRID_COLUMNS: DataGridColumn[] = [ { key: "project_no", label: "프로젝트번호", width: "w-[170px]", frozen: true }, - { key: "order_type_name", label: "주문유형", width: "w-[90px]", align: "center" }, + { key: "order_type_name", label: "주문유형", width: "w-[115px]", align: "center" }, { key: "order_date", label: "발주일", width: "w-[115px]", align: "center" }, { key: "po_no", label: "발주번호", width: "w-[140px]" }, { key: "request_date", label: "요청납기", width: "w-[115px]", align: "center" }, { key: "shipping_date", label: "출하일", width: "w-[115px]", align: "center" }, { key: "customer", label: "고객사", width: "w-[160px]" }, { key: "product_name", label: "품명", width: "w-[180px]" }, - { key: "order_quantity", label: "수주수량", width: "w-[90px]", align: "right", formatNumber: true }, - { key: "sales_quantity", label: "판매수량", width: "w-[90px]", align: "right", formatNumber: true }, - { key: "remaining_quantity", label: "잔량", width: "w-[80px]", align: "right", formatNumber: true }, - { key: "sales_unit_price", label: "판매단가", width: "w-[110px]", formatMoney: true }, - { key: "sales_supply_price", label: "판매공급가액", width: "w-[130px]", formatMoney: true }, - { key: "sales_vat", label: "부가세", width: "w-[100px]", formatMoney: true }, - { key: "sales_total_amount", label: "판매총액", width: "w-[120px]", formatMoney: true }, - { key: "sales_total_amount_krw", label: "판매원화총액", width: "w-[130px]", formatMoney: true }, - { key: "remaining_amount_krw", label: "잔량원화총액", width: "w-[130px]", formatMoney: true }, - { key: "order_status_name", label: "수주상태", width: "w-[90px]", align: "center" }, - { key: "sales_status", label: "판매상태", width: "w-[100px]", align: "center" }, - { key: "production_status", label: "생산상태", width: "w-[100px]", align: "center" }, - { key: "shipping_order_status", label: "출하지시상태", width: "w-[110px]", align: "center" }, - { key: "payment_type_name", label: "유/무상", width: "w-[80px]", align: "center" }, - { key: "sales_currency_name", label: "환종", width: "w-[70px]", align: "center" }, - { key: "sales_exchange_rate", label: "환율", width: "w-[80px]", formatMoney: true }, + { key: "order_quantity", label: "수주수량", width: "w-[115px]", align: "right", formatNumber: true }, + { key: "sales_quantity", label: "판매수량", width: "w-[115px]", align: "right", formatNumber: true }, + { key: "remaining_quantity", label: "잔량", width: "w-[95px]", align: "right", formatNumber: true }, + { key: "sales_unit_price", label: "판매단가", width: "w-[115px]", formatMoney: true }, + { key: "sales_supply_price", label: "판매공급가액", width: "w-[140px]", formatMoney: true }, + { key: "sales_vat", label: "부가세", width: "w-[115px]", formatMoney: true }, + { key: "sales_total_amount", label: "판매총액", width: "w-[130px]", formatMoney: true }, + { key: "sales_total_amount_krw", label: "판매원화총액", width: "w-[140px]", formatMoney: true }, + { key: "remaining_amount_krw", label: "잔량원화총액", width: "w-[140px]", formatMoney: true }, + { key: "order_status_name", label: "수주상태", width: "w-[115px]", align: "center" }, + { key: "sales_status", label: "판매상태", width: "w-[115px]", align: "center" }, + { key: "production_status", label: "생산상태", width: "w-[115px]", align: "center" }, + { key: "shipping_order_status", label: "출하지시상태", width: "w-[135px]", align: "center" }, + { key: "payment_type_name", label: "유/무상", width: "w-[100px]", align: "center" }, + { key: "sales_currency_name", label: "환종", width: "w-[95px]", align: "center" }, + { key: "sales_exchange_rate", label: "환율", width: "w-[95px]", formatMoney: true }, { key: "serial_no", label: "S/N", width: "w-[140px]" }, { key: "split_serial_no", label: "분할S/N", width: "w-[140px]" }, { key: "product_no", label: "품번", width: "w-[120px]" }, @@ -80,7 +81,7 @@ const GRID_COLUMNS: DataGridColumn[] = [ { key: "manager_name", label: "담당자", width: "w-[100px]", align: "center" }, { key: "incoterms", label: "인도조건", width: "w-[90px]", align: "center" }, */ - { key: "has_transaction_statement", label: "거래명세서", width: "w-[100px]", align: "center" }, + { key: "has_transaction_statement", label: "거래명세서", width: "w-[115px]", align: "center" }, ]; export default function SalesSalePage() { @@ -130,6 +131,29 @@ export default function SalesSalePage() { useEffect(() => { fetchList(); }, [fetchList]); + // ─── 하단 통계 ────────────────────────────────────────────── + // 판매 라인 수 / 수주·판매·잔량 수량 합계 / 판매공급가액·판매원화총액·잔량원화총액 합계 + const saleSummary = useMemo(() => { + const count = rows.length; + const ordQty = rows.reduce((acc, r) => acc + Number(r.order_quantity || 0), 0); + const salQty = rows.reduce((acc, r) => acc + Number(r.sales_quantity || 0), 0); + const remQty = rows.reduce((acc, r) => acc + Number(r.remaining_quantity || 0), 0); + const supplySum = rows.reduce((acc, r) => acc + Number(r.sales_supply_price || 0), 0); + const totalKrw = rows.reduce((acc, r) => acc + Number(r.sales_total_amount_krw || 0), 0); + const remKrw = rows.reduce((acc, r) => acc + Number(r.remaining_amount_krw || 0), 0); + const money = (n: number) => n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + const intFmt = (n: number) => n.toLocaleString(); + return [ + { label: "판매 라인", value: intFmt(count), suffix: "건" }, + { label: "수주수량 합계", value: intFmt(ordQty) }, + { label: "판매수량 합계", value: intFmt(salQty) }, + { label: "잔량 합계", value: intFmt(remQty) }, + { label: "판매공급가액 합계", value: money(supplySum) }, + { label: "판매원화총액 합계", value: money(totalKrw) }, + { label: "잔량원화총액 합계", value: money(remKrw) }, + ]; + }, [rows]); + const openRegister = () => { if (!selected) { toast.warning("판매등록할 행을 선택하세요."); return; } setForm({ @@ -257,6 +281,7 @@ export default function SalesSalePage() { { setSelected(row); openRegister(); }} emptyMessage={loading ? "조회 중..." : "데이터가 없습니다"} loading={loading} + showColumnSettings + paginationStyle="range" + pageSizeOptions={[10, 15, 20, 50, 100]} + summaryStats={saleSummary} + onRefresh={fetchList} + onDownload={() => { + if (rows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; } + const exportRows = rows.map((r) => { + const out: Record = {}; + GRID_COLUMNS.forEach((col) => { out[col.label] = r[col.key] ?? ""; }); + return out; + }); + exportToExcel(exportRows, "판매관리.xlsx", "판매관리"); + }} + showChart /> From 6a1813719a53b2eef22c4a719a261ca6501090e7 Mon Sep 17 00:00:00 2001 From: hjjeong Date: Thu, 14 May 2026 14:56:54 +0900 Subject: [PATCH 3/7] =?UTF-8?q?=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=E2=80=94=20=EC=A7=84=ED=96=89=EA=B4=80?= =?UTF-8?q?=EB=A6=AC/WBS=ED=85=9C=ED=94=8C=EB=A6=BF=EC=97=90=20logicstudio?= =?UTF-8?q?=20=EC=8A=A4=ED=83=80=EC=9D=BC=20DataGrid=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 영업관리(fc959d88)와 동일 패턴을 프로젝트관리 2개 메뉴로 확장: 공통 DataGrid props: - gridId 는 기존(project-progress-wbslist3 / project-wbs-template) 그대로 유지 - showColumnSettings, paginationStyle="range", pageSizeOptions=[10,15,20,50,100] - onRefresh = fetchList(혹은 fetchList(filterProduct)), onDownload = exportToExcel(라벨 매핑) - showChart 도메인별 summaryStats: - 진행관리: 프로젝트 건수 / 수주수량 합계 / 입고율 평균(% 문자열 파싱) - WBS 템플릿: 템플릿 건수 / WBS 작업 합계 / 평균 WBS 작업수 WBS 템플릿 systemColumnKeys: writer_title, reg_date_title (등록자/등록일 시스템 영역) 컬럼 폭 보정: - ⋮⋮ 드래그 핸들 추가로 좁아진 4글자 한국어/영문 라벨 95~120px 로 확대 - 진행관리: 주문유형·제품구분·국내/해외·요청납기·E-BOM·M-BOM·제조1,2팀·제조3팀 등 - WBS: WBS 컬럼 100→115, 등록일 130→140 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../COMPANY_16/project/progress/page.tsx | 66 ++++++++++++++----- .../COMPANY_16/project/wbs-template/page.tsx | 37 ++++++++++- 2 files changed, 84 insertions(+), 19 deletions(-) diff --git a/frontend/app/(main)/COMPANY_16/project/progress/page.tsx b/frontend/app/(main)/COMPANY_16/project/progress/page.tsx index bcc7fa37..207fd6a1 100644 --- a/frontend/app/(main)/COMPANY_16/project/progress/page.tsx +++ b/frontend/app/(main)/COMPANY_16/project/progress/page.tsx @@ -22,6 +22,7 @@ import { PageHeader } from "@/components/common/PageHeader"; import { CompactFilterBar, CompactFilterField, CompactDateRange } from "@/components/common/CompactFilterBar"; import { ProjectInfoDialog, ProjectInfoData } from "@/components/project/ProjectInfoDialog"; import { projectMgmtApi, ProgressListFilter, ProgressRow } from "@/lib/api/projectMgmt"; +import { exportToExcel } from "@/lib/utils/excelExport"; // 진행관리 row → 정규화된 ProjectInfoData 매핑 const toProjectInfo = (r: ProgressRow): ProjectInfoData => ({ @@ -45,32 +46,32 @@ const toProjectInfo = (r: ProgressRow): ProjectInfoData => ({ const GRID_COLUMNS: DataGridColumn[] = [ { key: "project_no", label: "프로젝트번호", width: "w-[160px]", frozen: true }, // 프로젝트정보 그룹 - { key: "category_name", label: "주문유형", width: "w-[90px]", align: "center" }, - { key: "product_name", label: "제품구분", width: "w-[100px]", align: "left" }, - { key: "area_name", label: "국내/해외", width: "w-[90px]", align: "center" }, - { key: "reg_date", label: "접수일", width: "w-[110px]", align: "center" }, + { key: "category_name", label: "주문유형", width: "w-[115px]", align: "center" }, + { key: "product_name", label: "제품구분", width: "w-[115px]", align: "left" }, + { key: "area_name", label: "국내/해외", width: "w-[115px]", align: "center" }, + { key: "reg_date", label: "접수일", width: "w-[115px]", align: "center" }, { key: "customer_name", label: "고객사", width: "w-[140px]" }, - { key: "free_of_charge", label: "유/무상", width: "w-[80px]", align: "center" }, + { key: "free_of_charge", label: "유/무상", width: "w-[100px]", align: "center" }, { key: "product_item_code", label: "품번", width: "w-[150px]" }, { key: "product_item_name", label: "품명", width: "w-[180px]" }, { key: "serial_no", label: "S/N", width: "w-[150px]" }, - { key: "contract_qty", label: "수주수량", width: "w-[90px]", align: "right", formatNumber: true }, - { key: "req_del_date", label: "요청납기", width: "w-[110px]", align: "center" }, + { key: "contract_qty", label: "수주수량", width: "w-[115px]", align: "right", formatNumber: true }, + { key: "req_del_date", label: "요청납기", width: "w-[115px]", align: "center" }, // 설계 - { key: "ebom_status", label: "E-BOM", width: "w-[100px]", align: "center" }, + { key: "ebom_status", label: "E-BOM", width: "w-[115px]", align: "center" }, // 생산관리 - { key: "mbom_status", label: "M-BOM", width: "w-[100px]", align: "center" }, + { key: "mbom_status", label: "M-BOM", width: "w-[115px]", align: "center" }, // 구매 - { key: "order_date", label: "발주일", width: "w-[110px]", align: "center" }, - { key: "receiving_rate", label: "입고율", width: "w-[90px]", align: "right" }, + { key: "order_date", label: "발주일", width: "w-[115px]", align: "center" }, + { key: "receiving_rate", label: "입고율", width: "w-[100px]", align: "right" }, // 생산 - { key: "production_team_12", label: "제조1,2팀", width: "w-[100px]", align: "center" }, - { key: "production_team_3", label: "제조3팀", width: "w-[100px]", align: "center" }, + { key: "production_team_12", label: "제조1,2팀", width: "w-[120px]", align: "center" }, + { key: "production_team_3", label: "제조3팀", width: "w-[115px]", align: "center" }, // 장비 - { key: "assembly", label: "조립", width: "w-[90px]", align: "center" }, - { key: "verification", label: "검증", width: "w-[90px]", align: "center" }, + { key: "assembly", label: "조립", width: "w-[100px]", align: "center" }, + { key: "verification", label: "검증", width: "w-[100px]", align: "center" }, // 출하 - { key: "shipment_date", label: "출하일", width: "w-[110px]", align: "center" }, + { key: "shipment_date", label: "출하일", width: "w-[115px]", align: "center" }, ]; const CATEGORY_GROUP = "0000167"; // 주문유형 @@ -145,6 +146,24 @@ export default function ProjectProgressPage() { setTimeout(() => fetchList(), 0); }; + // ─── 하단 통계 ────────────────────────────────────────────── + // 프로젝트 건수 / 수주수량 합계 / 입고율 평균 + const progressSummary = useMemo(() => { + const count = rows.length; + const qtySum = rows.reduce((acc, r) => acc + Number(r.contract_qty || 0), 0); + // 입고율은 "85%" 같은 문자열 가능 → 숫자만 추출 + const rateNums = rows + .map((r) => parseFloat(String((r as any).receiving_rate ?? "").replace(/[^0-9.]/g, ""))) + .filter((n) => !Number.isNaN(n)); + const rateAvg = rateNums.length === 0 ? 0 : rateNums.reduce((a, b) => a + b, 0) / rateNums.length; + const intFmt = (n: number) => n.toLocaleString(); + return [ + { label: "프로젝트 건수", value: intFmt(count), suffix: "건" }, + { label: "수주수량 합계", value: intFmt(qtySum) }, + { label: "입고율 평균", value: rateAvg.toFixed(1), suffix: "%" }, + ]; + }, [rows]); + return (
{ + if (rows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; } + const exportRows = rows.map((r) => { + const out: Record = {}; + GRID_COLUMNS.forEach((col) => { out[col.label] = (r as any)[col.key] ?? ""; }); + return out; + }); + exportToExcel(exportRows, "진행관리.xlsx", "진행관리"); + }} + showChart />
diff --git a/frontend/app/(main)/COMPANY_16/project/wbs-template/page.tsx b/frontend/app/(main)/COMPANY_16/project/wbs-template/page.tsx index 6c5c6565..b0918928 100644 --- a/frontend/app/(main)/COMPANY_16/project/wbs-template/page.tsx +++ b/frontend/app/(main)/COMPANY_16/project/wbs-template/page.tsx @@ -10,7 +10,7 @@ // 검색: 제품구분 단일 // 등록/수정 통합 다이얼로그: WbsTemplateDialog (wace WBSExcelImportPopUp.jsp 1:1) -import React, { useCallback, useEffect, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; import { Plus, Trash2 } from "lucide-react"; import { toast } from "sonner"; @@ -20,6 +20,7 @@ import { PageHeader } from "@/components/common/PageHeader"; import { CompactFilterBar, CompactFilterField } from "@/components/common/CompactFilterBar"; import { wbsTemplateApi, TemplateRow } from "@/lib/api/wbsTemplate"; import { WbsTemplateDialog } from "@/components/project/WbsTemplateDialog"; +import { exportToExcel } from "@/lib/utils/excelExport"; const PRODUCT_GROUP = "0000001"; // 제품구분 @@ -29,12 +30,12 @@ const GRID_COLUMNS: DataGridColumn[] = [ { key: "wbs_task_cnt", label: "WBS", - width: "w-[100px]", + width: "w-[115px]", align: "center", renderType: "folder", // wace fnc_getFolderIcon }, { key: "writer_title", label: "등록자", width: "w-[180px]" }, - { key: "reg_date_title", label: "등록일", width: "w-[130px]", align: "center" }, + { key: "reg_date_title", label: "등록일", width: "w-[140px]", align: "center" }, ]; export default function WbsTemplatePage() { @@ -110,6 +111,20 @@ export default function WbsTemplatePage() { c.key === "wbs_task_cnt" ? { ...c, onClick: handleOpenEdit } : c ); + // ─── 하단 통계 ────────────────────────────────────────────── + // 템플릿 건수 / WBS 작업 합계 / 평균 WBS 작업수 + const templateSummary = useMemo(() => { + const count = rows.length; + const taskSum = rows.reduce((acc, r) => acc + Number((r as any).wbs_task_cnt || 0), 0); + const taskAvg = count === 0 ? 0 : taskSum / count; + const intFmt = (n: number) => n.toLocaleString(); + return [ + { label: "템플릿 건수", value: intFmt(count), suffix: "건" }, + { label: "WBS 작업 합계", value: intFmt(taskSum) }, + { label: "평균 WBS 작업수", value: taskAvg.toFixed(1) }, + ]; + }, [rows]); + return (
fetchList(filterProduct)} + onDownload={() => { + if (rows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; } + const exportRows = rows.map((r) => { + const out: Record = {}; + GRID_COLUMNS.forEach((col) => { out[col.label] = (r as any)[col.key] ?? ""; }); + return out; + }); + exportToExcel(exportRows, "WBS_템플릿.xlsx", "WBS_템플릿"); + }} + showChart />
From 350ddcd3b895bdd365d63951eac09f790f744d7d Mon Sep 17 00:00:00 2001 From: hjjeong Date: Thu, 14 May 2026 15:12:34 +0900 Subject: [PATCH 4/7] =?UTF-8?q?=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=E2=80=94=20DataGrid=20=ED=96=89=20id=20?= =?UTF-8?q?=EB=A7=A4=ED=95=91=20=EB=88=84=EB=9D=BD=20+=20selectedId=20?= =?UTF-8?q?=EA=B0=80=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 증상: 프로젝트관리 진행관리/WBS 그리드의 모든 행이 회색(bg-accent) 으로 표시. 원인 추적 결과 DataGrid 의 isSelected 평가식 `selectedId === row.id` 가 selectedId 도 row.id 도 둘 다 undefined 일 때 `undefined === undefined` = true 가 되어 모든 행이 selected 상태로 잡힘 (memory: feedback_datagrid_id_mapping 함정의 또 다른 발현 — 영업관리는 항상 id 매핑이 있어 우연히 회피). 수정: - project/progress/page.tsx, project/wbs-template/page.tsx - setRows 에서 `id: r.objid ?? "...-${i}"` 매핑 추가 - 부모 wrapper 도 영업관리 패턴 `overflow-hidden` + DataGrid 직접 자식으로 통일 - components/common/DataGrid.tsx - isSelected 가드 — selectedId/row.id 가 nullish 면 무조건 false 처리. 향후 다른 페이지에서 id 매핑 누락 시에도 그리드 색 폭주는 차단. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../COMPANY_16/project/progress/page.tsx | 53 ++++++++-------- .../COMPANY_16/project/wbs-template/page.tsx | 61 +++++++++---------- frontend/components/common/DataGrid.tsx | 5 +- 3 files changed, 60 insertions(+), 59 deletions(-) diff --git a/frontend/app/(main)/COMPANY_16/project/progress/page.tsx b/frontend/app/(main)/COMPANY_16/project/progress/page.tsx index 207fd6a1..99c8f260 100644 --- a/frontend/app/(main)/COMPANY_16/project/progress/page.tsx +++ b/frontend/app/(main)/COMPANY_16/project/progress/page.tsx @@ -126,7 +126,8 @@ export default function ProjectProgressPage() { setLoading(true); try { const data = await projectMgmtApi.list(filter); - setRows(data); + // DataGrid row 키 — objid 없을 수 있어 인덱스 fallback (id 누락 시 모든 행이 selected 로 잡힘) + setRows(data.map((r, i) => ({ ...r, id: r.objid ?? `prog-${i}` })) as any); } catch (e: any) { toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패"); } finally { setLoading(false); } @@ -165,7 +166,7 @@ export default function ProjectProgressPage() { }, [rows]); return ( -
+
-
- { - if (rows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; } - const exportRows = rows.map((r) => { - const out: Record = {}; - GRID_COLUMNS.forEach((col) => { out[col.label] = (r as any)[col.key] ?? ""; }); - return out; - }); - exportToExcel(exportRows, "진행관리.xlsx", "진행관리"); - }} - showChart - /> -
+ { + if (rows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; } + const exportRows = rows.map((r) => { + const out: Record = {}; + GRID_COLUMNS.forEach((col) => { out[col.label] = (r as any)[col.key] ?? ""; }); + return out; + }); + exportToExcel(exportRows, "진행관리.xlsx", "진행관리"); + }} + showChart + />
diff --git a/frontend/app/(main)/COMPANY_16/project/wbs-template/page.tsx b/frontend/app/(main)/COMPANY_16/project/wbs-template/page.tsx index b0918928..48592599 100644 --- a/frontend/app/(main)/COMPANY_16/project/wbs-template/page.tsx +++ b/frontend/app/(main)/COMPANY_16/project/wbs-template/page.tsx @@ -53,7 +53,8 @@ export default function WbsTemplatePage() { setLoading(true); try { const data = await wbsTemplateApi.list(product || undefined); - setRows(data); + // DataGrid row 키 — objid 없을 수 있어 인덱스 fallback (id 누락 시 모든 행이 selected 로 잡힘) + setRows(data.map((r, i) => ({ ...r, id: r.objid ?? `tpl-${i}` })) as any); setCheckedIds([]); } catch (e: any) { toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패"); @@ -126,7 +127,7 @@ export default function WbsTemplatePage() { }, [rows]); return ( -
+
-
- fetchList(filterProduct)} - onDownload={() => { - if (rows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; } - const exportRows = rows.map((r) => { - const out: Record = {}; - GRID_COLUMNS.forEach((col) => { out[col.label] = (r as any)[col.key] ?? ""; }); - return out; - }); - exportToExcel(exportRows, "WBS_템플릿.xlsx", "WBS_템플릿"); - }} - showChart - /> -
+ fetchList(filterProduct)} + onDownload={() => { + if (rows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; } + const exportRows = rows.map((r) => { + const out: Record = {}; + GRID_COLUMNS.forEach((col) => { out[col.label] = (r as any)[col.key] ?? ""; }); + return out; + }); + exportToExcel(exportRows, "WBS_템플릿.xlsx", "WBS_템플릿"); + }} + showChart + /> {/* 통합 팝업 */} ) : paginatedData.map((row, rowIdx) => { - const isSelected = selectedId === row.id || (showCheckbox && checkedIds.includes(row.id)); + // selectedId 또는 row.id 가 nullish 면 비교 결과를 무조건 false 로 — 둘 다 undefined 일 때 `undefined === undefined` 가 true 가 되어 모든 행이 selected 로 잡히는 함정 차단 + const isSelected = + (selectedId != null && row.id != null && selectedId === row.id) || + (showCheckbox && row.id != null && checkedIds.includes(row.id)); // sticky 셀에 alpha 없는 단색 배경 사용 (반투명이면 뒤 셀이 비침). // selected → bg-accent / hover(non-selected) → group-hover로 muted 적용 / 기본 → bg-background const stickyBgClass = isSelected ? "bg-accent" : "bg-background group-hover:bg-muted"; From c955fe0dac675a07d4d803e18a5093ac4ae15d0a Mon Sep 17 00:00:00 2001 From: hjjeong Date: Thu, 14 May 2026 15:23:33 +0900 Subject: [PATCH 5/7] =?UTF-8?q?=EA=B0=9C=EB=B0=9C=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=E2=80=94=205=EB=A9=94=EB=89=B4(PART/E-BOM/EO=EC=9D=B4=EB=A0=A5?= =?UTF-8?q?)=EC=97=90=20logicstudio=20=EC=8A=A4=ED=83=80=EC=9D=BC=20DataGr?= =?UTF-8?q?id=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 영업관리/프로젝트관리 패턴(fc959d88·6a181371)을 개발관리 5메뉴 전체로 확장. 공통 변경: - 부모 wrapper 영업관리 통일: flex h-full flex-col overflow-hidden p-2 gap-2 + DataGrid 를 직접 자식으로 (불필요한 min-h-0 flex-1 wrapper 제거) - DataGrid props 확장: - showColumnSettings · paginationStyle="range" · pageSizeOptions=[10,15,20,50,100] - onRefresh = fetchList(또는 runQuery) · onDownload = exportToExcel(GRID_COLUMNS 라벨 매핑) - showChart - 컬럼 폭: ⋮⋮ 드래그 핸들 추가로 좁아진 4글자 한국어/영문 라벨을 95~125px 로 보정 메뉴별 summaryStats: - PART 등록(M1): 전체·페이지 건수 / 환산수량·개당수량·BOM 수량 합계 - PART 조회(M2): 전체·페이지 건수 / BOM 수량·환산수량·개당수량 합계 - E-BOM 등록(M3): 전체·페이지 건수 + 상태별(status_title) 분포 4종 - E-BOM 조회(M4): 모드(정/역전개) + 표시·원본 행 + MAX_LEVEL + 수량·항목수량 합계 - 설계변경 리스트(M5): 전체·페이지 건수 / 수량·변경수량 합계 systemColumnKeys 분리 (컬럼 설정의 데이터/시스템 그룹 구분): - PART 등록: revision, status - PART 조회: revision, eo_no - E-BOM 등록: dept_user_name, reg_date, deploy_date, revision, status_title - 설계변경: writer_name, his_reg_date_title id 매핑 누락 보강 (350ddcd3 함정 재발 방지): - ebom-search: gridData 에 child_objid 또는 인덱스 fallback id 부여 - change-list: rows 에 objid 또는 인덱스 fallback id 부여 - (PART/E-BOM 등록은 이미 gridRows 매핑 있음) E-BOM 조회 특수: - defaultPageSize=100, pageSizeOptions=[20,50,100,200,500] — BOM 트리 가독성 - onDownload 는 PageHeader 의 정/역전개 엑셀 버튼과 동일 동작(현재 direction) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../development/change-list/page.tsx | 78 ++++++++++---- .../development/ebom-regist/page.tsx | 72 +++++++++---- .../development/ebom-search/page.tsx | 52 ++++++--- .../development/part-regist/page.tsx | 102 ++++++++++++------ .../development/part-search/page.tsx | 100 +++++++++++------ 5 files changed, 280 insertions(+), 124 deletions(-) diff --git a/frontend/app/(main)/COMPANY_16/development/change-list/page.tsx b/frontend/app/(main)/COMPANY_16/development/change-list/page.tsx index 18db5ddb..c7f1475e 100644 --- a/frontend/app/(main)/COMPANY_16/development/change-list/page.tsx +++ b/frontend/app/(main)/COMPANY_16/development/change-list/page.tsx @@ -15,6 +15,7 @@ import { PageHeader } from "@/components/common/PageHeader"; import { CompactFilterBar, CompactFilterField, CompactDateRange } from "@/components/common/CompactFilterBar"; import { devEoHistoryApi, EoHistoryListFilter, EoHistoryRow } from "@/lib/api/devEoHistory"; import { PartHisDetailDialog } from "@/components/development/PartHisDetailDialog"; +import { exportToExcel } from "@/lib/utils/excelExport"; // comm_code 그룹 (vexplor_rps) const GROUP_PART_TYPE = "0000062"; @@ -29,22 +30,22 @@ const YEAR_OPTIONS: SmartSelectOption[] = (() => { })(); const GRID_COLUMNS: DataGridColumn[] = [ - { key: "eo_no", label: "EO No", width: "w-[100px]", frozen: true }, - { key: "project_no", label: "프로젝트번호", width: "w-[120px]" }, + { key: "eo_no", label: "EO No", width: "w-[110px]", frozen: true }, + { key: "project_no", label: "프로젝트번호", width: "w-[140px]" }, { key: "project_name", label: "프로젝트명", width: "w-[180px]" }, { key: "unit_name", label: "유닛명", width: "w-[160px]" }, { key: "parent_part_info", label: "모품번", width: "w-[160px]" }, { key: "part_no_disp", label: "품번", width: "w-[160px]" }, { key: "part_name_disp", label: "품명", minWidth: "min-w-[180px]" }, - { key: "qty", label: "수량", width: "w-[70px]", align: "right", formatNumber: true }, - { key: "qty_temp", label: "변경수량", width: "w-[80px]", align: "right", formatNumber: true }, - { key: "change_type_name", label: "EO구분", width: "w-[90px]", align: "center" }, - { key: "change_option_name", label: "EO사유", width: "w-[100px]", align: "center" }, - { key: "revision_disp", label: "Revision", width: "w-[90px]", align: "center" }, - { key: "eo_date", label: "EO Date", width: "w-[100px]", align: "center" }, - { key: "part_type_name", label: "PART구분", width: "w-[90px]", align: "center" }, - { key: "writer_name", label: "담당자", width: "w-[90px]", align: "center" }, - { key: "his_reg_date_title", label: "실행일", width: "w-[100px]", align: "center" }, + { key: "qty", label: "수량", width: "w-[95px]", align: "right", formatNumber: true }, + { key: "qty_temp", label: "변경수량", width: "w-[115px]", align: "right", formatNumber: true }, + { key: "change_type_name", label: "EO구분", width: "w-[115px]", align: "center" }, + { key: "change_option_name", label: "EO사유", width: "w-[115px]", align: "center" }, + { key: "revision_disp", label: "Revision", width: "w-[115px]", align: "center" }, + { key: "eo_date", label: "EO Date", width: "w-[110px]", align: "center" }, + { key: "part_type_name", label: "PART구분", width: "w-[115px]", align: "center" }, + { key: "writer_name", label: "담당자", width: "w-[115px]", align: "center" }, + { key: "his_reg_date_title", label: "실행일", width: "w-[115px]", align: "center" }, ]; const EMPTY_FILTER: EoHistoryListFilter = { @@ -69,7 +70,9 @@ export default function EoHistoryPage() { try { const f = { ...filter, ...override }; const res = await devEoHistoryApi.list(f); - setRows(res.rows ?? []); + // DataGrid row 키 — objid 없을 수 있어 인덱스 fallback (id 누락 시 모든 행이 selected 로 잡힘) + const list = (res.rows ?? []).map((r, i) => ({ ...r, id: (r as any).objid ?? `eo-${i}` })); + setRows(list as any); setTotal(res.total ?? 0); } catch (e: any) { toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패"); @@ -90,8 +93,23 @@ export default function EoHistoryPage() { [], ); + // ─── 하단 통계 ────────────────────────────────────────────── + // 이력 건수(총·페이지) / 수량·변경수량 합계 + const eoSummary = useMemo(() => { + const pageCount = rows.length; + const qtySum = rows.reduce((acc, r: any) => acc + Number(r.qty || 0), 0); + const qtyTemp = rows.reduce((acc, r: any) => acc + Number(r.qty_temp || 0), 0); + const intFmt = (n: number) => n.toLocaleString(); + return [ + { label: "전체 건수", value: intFmt(total), suffix: "건" }, + { label: "페이지 건수", value: intFmt(pageCount), suffix: "건" }, + { label: "수량 합계", value: intFmt(qtySum) }, + { label: "변경수량 합계", value: intFmt(qtyTemp) }, + ]; + }, [rows, total]); + return ( -
+
fetchList()} @@ -145,16 +163,30 @@ export default function EoHistoryPage() { -
- -
+ fetchList()} + onDownload={() => { + if (rows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; } + const exportRows = rows.map((r: any) => { + const out: Record = {}; + GRID_COLUMNS.forEach((col) => { out[col.label] = r[col.key] ?? ""; }); + return out; + }); + exportToExcel(exportRows, "설계변경_리스트.xlsx", "설계변경_리스트"); + }} + showChart + /> rows.map((r) => ({ ...r, id: r.objid })), [rows]); + // ─── 하단 통계 ────────────────────────────────────────────── + // BOM 건수(총·페이지) / 상태별 분포 (간이 — 상태 라벨별 카운트) + const bomSummary = useMemo(() => { + const pageCount = gridRows.length; + const byStatus = new Map(); + gridRows.forEach((r: any) => { + const k = String(r.status_title ?? r.status ?? "-"); + byStatus.set(k, (byStatus.get(k) ?? 0) + 1); + }); + const intFmt = (n: number) => n.toLocaleString(); + const stats: Array<{ label: string; value: string; suffix?: string }> = [ + { label: "전체 건수", value: intFmt(total), suffix: "건" }, + { label: "페이지 건수", value: intFmt(pageCount), suffix: "건" }, + ]; + // 상태별 카운트(많이 노출되는 라벨만) + Array.from(byStatus.entries()).slice(0, 4).forEach(([k, v]) => { + stats.push({ label: `상태(${k})`, value: intFmt(v), suffix: "건" }); + }); + return stats; + }, [gridRows, total]); + return ( -
+
fetchList()} @@ -172,19 +194,33 @@ export default function EbomRegistPage() { -
- -
+ fetchList()} + onDownload={() => { + if (gridRows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; } + const exportRows = gridRows.map((r: any) => { + const out: Record = {}; + BASE_GRID_COLUMNS.forEach((col) => { out[col.label] = r[col.key] ?? ""; }); + return out; + }); + exportToExcel(exportRows, "E-BOM_등록.xlsx", "E-BOM_등록"); + }} + showChart + /> collapsedChildIds.has(a)); }) - .map((r) => { + .map((r, i) => { const expanded: any = { ...r }; const lev = Number(r.lev ?? 0); - for (let i = 1; i <= Math.max(1, maxLevel); i++) { - expanded[`__lev_${i}`] = lev === i ? "*" : ""; + for (let j = 1; j <= Math.max(1, maxLevel); j++) { + expanded[`__lev_${j}`] = lev === j ? "*" : ""; } const childId = r.child_objid ? String(r.child_objid) : ""; const hasChild = childId && hasChildSet.has(childId); expanded.__toggle = hasChild ? (collapsedChildIds.has(childId) ? "+" : "−") : ""; + // DataGrid row key — child_objid 우선, 없으면 인덱스 fallback + expanded.id = childId || `bom-${i}`; return expanded; }); }, [rows, maxLevel, hasChildSet, ancestorsByChildId, collapsedChildIds]); + // ─── 하단 통계 ────────────────────────────────────────────── + const treeSummary = useMemo(() => { + const intFmt = (n: number) => n.toLocaleString(); + const qtySum = gridData.reduce((acc, r: any) => acc + Number(r.qty || 0), 0); + const pQtySum = gridData.reduce((acc, r: any) => acc + Number(r.p_qty || 0), 0); + return [ + { label: "모드", value: direction === "ascending" ? "정전개" : "역전개" }, + { label: "표시 행", value: intFmt(gridData.length), suffix: "행" }, + { label: "원본 행", value: intFmt(rows.length), suffix: "행" }, + { label: "MAX_LEVEL", value: String(maxLevel) }, + { label: "수량 합계", value: intFmt(qtySum) }, + { label: "항목수량 합계", value: intFmt(pQtySum) }, + ]; + }, [gridData, rows.length, maxLevel, direction]); + return ( -
+
{ setFilter(EMPTY_FILTER); setRows([]); setMaxLevel(0); }} actions={ @@ -257,16 +275,22 @@ export default function EbomSearchPage() {
)} -
- -
+ runQuery(direction)} + onDownload={() => downloadExcel(direction)} + showChart + /> rows.map((r) => ({ ...r, id: r.objid })), [rows]); + // ─── 하단 통계 ────────────────────────────────────────────── + // PART 건수(총·페이지) / 환산수량·개당수량·BOM 수량 합계 + const partSummary = useMemo(() => { + const pageCount = gridRows.length; + const unitChng = gridRows.reduce((acc, r: any) => acc + Number(r.unitchng_nb || 0), 0); + const unitQty = gridRows.reduce((acc, r: any) => acc + Number(r.unit_qty || 0), 0); + const qQty = gridRows.reduce((acc, r: any) => acc + Number(r.q_qty || 0), 0); + const intFmt = (n: number) => n.toLocaleString(); + return [ + { label: "전체 건수", value: intFmt(total), suffix: "건" }, + { label: "페이지 건수", value: intFmt(pageCount), suffix: "건" }, + { label: "환산수량 합계", value: intFmt(unitChng) }, + { label: "개당수량 합계", value: intFmt(unitQty) }, + { label: "BOM 수량 합계", value: intFmt(qQty) }, + ]; + }, [gridRows, total]); + // 등록 const handleCreate = () => { setFormMode("create"); @@ -155,7 +173,7 @@ export default function PartRegistPage() { }; return ( -
+
fetchList()} @@ -209,19 +227,33 @@ export default function PartRegistPage() { -
- -
+ fetchList()} + onDownload={() => { + if (gridRows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; } + const exportRows = gridRows.map((r: any) => { + const out: Record = {}; + GRID_COLUMNS.forEach((col) => { out[col.label] = r[col.key] ?? ""; }); + return out; + }); + exportToExcel(exportRows, "PART_등록.xlsx", "PART_등록"); + }} + showChart + /> rows.map((r) => ({ ...r, id: r.objid })), [rows]); + // ─── 하단 통계 ────────────────────────────────────────────── + // PART 건수(총·페이지) / BOM 수량·환산수량·개당수량 합계 + const partSummary = useMemo(() => { + const pageCount = gridRows.length; + const bomQty = gridRows.reduce((acc, r: any) => acc + Number(r.bom_qty || 0), 0); + const unitChng = gridRows.reduce((acc, r: any) => acc + Number(r.unitchng_nb || 0), 0); + const unitQty = gridRows.reduce((acc, r: any) => acc + Number(r.unit_qty || 0), 0); + const intFmt = (n: number) => n.toLocaleString(); + return [ + { label: "전체 건수", value: intFmt(total), suffix: "건" }, + { label: "페이지 건수", value: intFmt(pageCount), suffix: "건" }, + { label: "BOM 수량 합계", value: intFmt(bomQty) }, + { label: "환산수량 합계", value: intFmt(unitChng) }, + { label: "개당수량 합계", value: intFmt(unitQty) }, + ]; + }, [gridRows, total]); + const handleCreate = () => { setFormMode("create"); setFormObjid(null); setFormOpen(true); }; @@ -124,7 +142,7 @@ export default function PartSearchPage() { }; return ( -
+
fetchList()} @@ -171,19 +189,33 @@ export default function PartSearchPage() { -
- -
+ fetchList()} + onDownload={() => { + if (gridRows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; } + const exportRows = gridRows.map((r: any) => { + const out: Record = {}; + GRID_COLUMNS.forEach((col) => { out[col.label] = r[col.key] ?? ""; }); + return out; + }); + exportToExcel(exportRows, "PART_조회.xlsx", "PART_조회"); + }} + showChart + /> Date: Thu, 14 May 2026 16:09:05 +0900 Subject: [PATCH 6/7] =?UTF-8?q?=EA=B3=B5=EC=9A=A9=20=E2=80=94=20getDirectF?= =?UTF-8?q?ileUrl=20=EB=A1=9C=EC=BB=AC=20origin=20=EC=9D=84=20NEXT=5FPUBLI?= =?UTF-8?q?C=5FAPI=5FURL=20fallback=20=EC=9C=BC=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit localhost/127.0.0.1 일 때 하드코딩된 http://localhost:8080 대신 NEXT_PUBLIC_API_URL 의 origin(/api suffix 제거) 을 우선 사용. 환경변수 없을 때만 기존 http://localhost:8080 으로 떨어짐. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/lib/api/file.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/lib/api/file.ts b/frontend/lib/api/file.ts index 0eaf5579..5173d5a4 100644 --- a/frontend/lib/api/file.ts +++ b/frontend/lib/api/file.ts @@ -269,6 +269,8 @@ export const getDirectFileUrl = (filePath: string): string => { // 로컬 개발환경 if (currentHost === "localhost" || currentHost === "127.0.0.1") { + const envOrigin = process.env.NEXT_PUBLIC_API_URL?.replace(/\/api$/, ""); + if (envOrigin) return `${envOrigin}${filePath}`; return `http://localhost:8080${filePath}`; } } From 7a7f4f03b53a2b72c9b4be90582b459293b0a045 Mon Sep 17 00:00:00 2001 From: hjjeong Date: Thu, 14 May 2026 16:26:20 +0900 Subject: [PATCH 7/7] =?UTF-8?q?=EC=83=9D=EC=82=B0=EA=B4=80=EB=A6=AC=20M-BO?= =?UTF-8?q?M=20=EB=B3=B8=20=ED=8E=B8=EC=A7=91(PR-B1)=20+=20=ED=8F=B4?= =?UTF-8?q?=EB=8D=94=20=EC=BB=AC=EB=9F=BC=20+=20DataGrid=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84=20=ED=8E=98=EC=9D=B4=EC=A7=80=EB=84=A4=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20+=20bigint=3Dvarchar=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR-B1 본 편집/저장 (운영 saveMbom.do 1:1) · 매퍼 7종 1:1 (insert/updateMbomHeader, insert/updateMbomDetail, deleteMbomDetailByObjid, insertMbomHistory, updateProjectMbomStatus) · 신규 CREATE: createObjId + generateMbomNo(M-{partNo}-YYMMDD-NN) + child_objid 재매핑 + detail 일괄 insert + history(CREATE) + project_mgmt.mbom_status='Y' · 수정 UPDATE: 기존 mbom_header.objid UPSERT(insert/update/delete) + history(UPDATE) · POST /api/production/mbom/save (BEGIN/COMMIT/ROLLBACK 트랜잭션) · MbomDetailDialog: '본 편집' 토글 + 13개 셀 인라인 편집 + 저장/취소 가드 M-BOM 컬럼 폴더 아이콘 · production/mbom/page.tsx: mbom_status 컬럼 → mbom_has(0/1) renderType=folder · onClick → MbomDetailDialog 오픈 (행 더블클릭도 그대로 유지) · 운영판 wace 견적/partMng 폴더 아이콘 패턴 1:1 DataGrid 서버 페이지네이션 · props 신설: serverPaging/serverPage/serverPageSize/serverTotalItems + onPageChange/onPageSizeChange · 5메뉴 적용: production/mbom, development/change-list/ebom-regist/part-search/part-regist · pageSizeOptions=[10,15,20,50,100,200,500] 통일 · 클라이언트 모드 하위호환 유지 bigint=varchar fix (mbom 트리 SQL 4종) · ATTACH_FILE_INFO 서브쿼리: P.OBJID(bigint) = F.TARGET_OBJID(varchar) → P.OBJID::varchar 캐스트 · EBOM_WORKING_TREE_SQL INNER JOIN: P.OBJID = COALESCE(V.LAST_PART_OBJID,V.PART_NO) → ::varchar 캐스트 · 사용자 보고: 폴더 클릭 시 'operator does not exist: bigint = character varying' 토스트 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/controllers/mbomController.ts | 19 + .../src/routes/productionMbomRoutes.ts | 1 + backend-node/src/services/mbomService.ts | 470 +++++++++++++++++- .../development/change-list/page.tsx | 8 +- .../development/ebom-regist/page.tsx | 8 +- .../development/part-regist/page.tsx | 8 +- .../development/part-search/page.tsx | 8 +- .../COMPANY_16/production/mbom/page.tsx | 64 ++- frontend/components/common/DataGrid.tsx | 62 ++- .../production/MbomDetailDialog.tsx | 269 ++++++++-- frontend/lib/api/mbom.ts | 53 ++ 11 files changed, 882 insertions(+), 88 deletions(-) diff --git a/backend-node/src/controllers/mbomController.ts b/backend-node/src/controllers/mbomController.ts index 0ad892ff..7fd963b9 100644 --- a/backend-node/src/controllers/mbomController.ts +++ b/backend-node/src/controllers/mbomController.ts @@ -52,3 +52,22 @@ export async function getTree(req: AuthenticatedRequest, res: Response) { return res.status(500).json({ success: false, message: e.message }); } } + +// PR-B1 — 본 편집 저장 (운영 saveMbom.do 1:1) +export async function save(req: AuthenticatedRequest, res: Response) { + try { + const payload = req.body as svc.MbomSavePayload; + if (!payload?.project_obj_id) { + return res.status(400).json({ success: false, message: "project_obj_id 누락" }); + } + if (!Array.isArray(payload.rows)) { + return res.status(400).json({ success: false, message: "rows 누락" }); + } + const userId = req.user?.userId ?? "system"; + const data = await svc.save(payload, userId); + return res.json({ success: true, data }); + } catch (e: any) { + logger.error("M-BOM 저장 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} diff --git a/backend-node/src/routes/productionMbomRoutes.ts b/backend-node/src/routes/productionMbomRoutes.ts index 392cd071..ee8e0892 100644 --- a/backend-node/src/routes/productionMbomRoutes.ts +++ b/backend-node/src/routes/productionMbomRoutes.ts @@ -13,5 +13,6 @@ router.use(authenticateToken); router.get("/list", ctrl.getList); router.get("/detail/:objid", ctrl.getDetail); router.get("/tree/:objid", ctrl.getTree); +router.post("/save", ctrl.save); // PR-B1 본 편집 저장 export default router; diff --git a/backend-node/src/services/mbomService.ts b/backend-node/src/services/mbomService.ts index 6a1f57f0..03aa0c1c 100644 --- a/backend-node/src/services/mbomService.ts +++ b/backend-node/src/services/mbomService.ts @@ -21,6 +21,7 @@ // ============================================================ import { getPool } from "../database/db"; +import { createObjId } from "../utils/objidUtil"; // ─── 필터/페이지 타입 ────────────────────────────────────────── @@ -611,9 +612,9 @@ SELECT P.SOURCING_CODE, P.HEAT_TREATMENT_HARDNESS, P.HEAT_TREATMENT_METHOD, P.SURFACE_TREATMENT, (SELECT CODE_NAME FROM COMM_CODE CC WHERE CC.CODE_ID = P.UNIT) AS unit_title, (SELECT CODE_NAME FROM COMM_CODE CC WHERE CC.CODE_ID = P.PART_TYPE) AS part_type_title, - (SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('3D_CAD')) AS cu01_cnt, - (SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_DRAWING_CAD')) AS cu02_cnt, - (SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_PDF_CAD')) AS cu03_cnt, + (SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID::varchar = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('3D_CAD')) AS cu01_cnt, + (SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID::varchar = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_DRAWING_CAD')) AS cu02_cnt, + (SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID::varchar = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_PDF_CAD')) AS cu03_cnt, COALESCE((SELECT NULLIF(MP.UNIT_QTY, '')::numeric FROM PART_MNG MP WHERE MP.PART_NO = V.RAW_MATERIAL_PART_NO LIMIT 1), 0) AS part_unit_qty, COALESCE((SELECT NULLIF(MP.UNIT_LENGTH, '')::numeric FROM PART_MNG MP WHERE MP.PART_NO = V.RAW_MATERIAL_PART_NO LIMIT 1), 0) AS part_unit_length FROM VIEW_BOM V @@ -686,9 +687,9 @@ SELECT P.SOURCING_CODE, P.HEAT_TREATMENT_HARDNESS, P.HEAT_TREATMENT_METHOD, P.SURFACE_TREATMENT, (SELECT CODE_NAME FROM COMM_CODE CC WHERE CC.CODE_ID = P.UNIT) AS unit_title, (SELECT CODE_NAME FROM COMM_CODE CC WHERE CC.CODE_ID = P.PART_TYPE) AS part_type_title, - (SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('3D_CAD')) AS cu01_cnt, - (SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_DRAWING_CAD')) AS cu02_cnt, - (SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_PDF_CAD')) AS cu03_cnt, + (SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID::varchar = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('3D_CAD')) AS cu01_cnt, + (SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID::varchar = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_DRAWING_CAD')) AS cu02_cnt, + (SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID::varchar = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_PDF_CAD')) AS cu03_cnt, COALESCE((SELECT NULLIF(MP.UNIT_QTY, '')::numeric FROM PART_MNG MP WHERE MP.PART_NO = V.RAW_MATERIAL_PART_NO LIMIT 1), 0) AS part_unit_qty, COALESCE((SELECT NULLIF(MP.UNIT_LENGTH, '')::numeric FROM PART_MNG MP WHERE MP.PART_NO = V.RAW_MATERIAL_PART_NO LIMIT 1), 0) AS part_unit_length FROM VIEW_BOM V @@ -770,9 +771,9 @@ SELECT P.SOURCING_CODE, P.HEAT_TREATMENT_HARDNESS, P.HEAT_TREATMENT_METHOD, P.SURFACE_TREATMENT, (SELECT CODE_NAME FROM COMM_CODE CC WHERE CC.CODE_ID = P.UNIT) AS unit_title, (SELECT CODE_NAME FROM COMM_CODE CC WHERE CC.CODE_ID = P.PART_TYPE) AS part_type_title, - (SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('3D_CAD')) AS cu01_cnt, - (SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_DRAWING_CAD')) AS cu02_cnt, - (SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_PDF_CAD')) AS cu03_cnt, + (SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID::varchar = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('3D_CAD')) AS cu01_cnt, + (SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID::varchar = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_DRAWING_CAD')) AS cu02_cnt, + (SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID::varchar = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_PDF_CAD')) AS cu03_cnt, COALESCE((SELECT NULLIF(MP.UNIT_QTY, '')::numeric FROM PART_MNG MP WHERE MP.PART_NO = V.RAW_MATERIAL_PART_NO LIMIT 1), 0) AS part_unit_qty, COALESCE((SELECT NULLIF(MP.UNIT_LENGTH, '')::numeric FROM PART_MNG MP WHERE MP.PART_NO = V.RAW_MATERIAL_PART_NO LIMIT 1), 0) AS part_unit_length FROM VIEW_BOM V @@ -780,6 +781,449 @@ LEFT JOIN PART_MNG P ON V.PART_OBJID = P.OBJID ORDER BY V.PATH2 `; +// ─── 저장 (PR-B1) ─────────────────────────────────────────── +// +// 운영판 ProductionPlanningController.saveMbom (1549~1645) + 서비스 saveMbom (1192~1574) 1:1. +// 매퍼: insertMbomHeader / updateMbomHeader / insertMbomDetail / updateMbomDetail +// / deleteMbomDetailByObjid / insertMbomHistory / updateProjectMbomStatus. +// +// 분기 처리: +// isUpdate=false (최초 저장) — 새 mbom_header 생성 + child_objid 재매핑 후 detail 일괄 insert +// + history(CREATE) + project_mgmt.mbom_status='Y' +// isUpdate=true (수정 저장) — 기존 mbom_header.objid 조회 → updateMbomHeader +// → mbom_data 의 objid 기준 UPSERT(insert/update) + 누락분 delete +// + history(UPDATE, 변경 행수 description) + +export interface MbomSaveRow { + objid?: string | null; + parent_objid?: string | null; + child_objid?: string | null; + seq?: number | string | null; + level?: number | string | null; + part_objid?: string | number | null; + part_no?: string | null; + part_name?: string | null; + qty?: number | string | null; + item_qty?: number | string | null; + unit?: string | null; + supply_type?: string | null; + make_or_buy?: string | null; + raw_material_no?: string | null; + raw_material_spec?: string | null; + raw_material?: string | null; + size?: string | null; // tree row alias → raw_material_size + raw_material_size?: string | null; + processing_vendor?: string | null; + processing_deadline?: string | null; + grinding_deadline?: string | null; + required_qty?: number | string | null; + order_qty?: number | string | null; + production_qty?: number | string | null; + stock_qty?: number | string | null; + shortage_qty?: number | string | null; + vendor?: string | null; + unit_price?: number | string | null; + processing_unit_price?: number | string | null; + total_price?: number | string | null; + currency?: string | null; + lead_time?: number | string | null; + min_order_qty?: number | string | null; + remark?: string | null; +} + +export interface MbomSavePayload { + project_obj_id: string; + is_update: boolean; + mbom_part_no?: string | null; // 최상위 제품 변경 시 (PR-B1 에서는 신규/유지만) + rows: MbomSaveRow[]; +} + +export interface MbomSaveResult { + mode: "CREATE" | "UPDATE"; + mbom_header_objid: string; + mbom_no: string; + inserted: number; + updated: number; + deleted: number; +} + +// generateMbomNo — wace generateMbomNo(EBOM/TEMPLATE) 1:1. +// 패턴: M-{cleanPartNo}-{YYMMDD}-{NN} (NN = 동일 prefix 마지막+1, 미존재 시 01) +async function generateMbomNo( + client: any, + sourceBomType: string, + basePartNo: string, +): Promise { + let cleanPartNo = (basePartNo || "").trim(); + if (cleanPartNo.startsWith("M-")) cleanPartNo = cleanPartNo.substring(2); + const now = new Date(); + const yy = String(now.getFullYear()).slice(-2); + const mm = String(now.getMonth() + 1).padStart(2, "0"); + const dd = String(now.getDate()).padStart(2, "0"); + const dateStr = `${yy}${mm}${dd}`; + const prefix = `M-${cleanPartNo}-${dateStr}`; + + const r = await client.query( + `SELECT MBOM_NO FROM MBOM_HEADER + WHERE MBOM_NO LIKE $1 || '-%' + ORDER BY MBOM_NO DESC LIMIT 1`, + [prefix], + ); + let seq = 1; + if (r.rows[0]?.mbom_no) { + const m = String(r.rows[0].mbom_no).match(/-(\d{2})$/); + if (m) seq = Math.min(99, Number(m[1]) + 1); + } + return `${prefix}-${String(seq).padStart(2, "0")}`; +} + +const DETAIL_INSERT_SQL = ` +INSERT INTO MBOM_DETAIL ( + OBJID, MBOM_HEADER_OBJID, PARENT_OBJID, CHILD_OBJID, SEQ, LEVEL, + PART_OBJID, PART_NO, PART_NAME, QTY, ITEM_QTY, UNIT, + SUPPLY_TYPE, MAKE_OR_BUY, + RAW_MATERIAL_PART_NO, RAW_MATERIAL_SPEC, RAW_MATERIAL, RAW_MATERIAL_SIZE, + PROCESSING_VENDOR, PROCESSING_DEADLINE, GRINDING_DEADLINE, + REQUIRED_QTY, ORDER_QTY, PRODUCTION_QTY, STOCK_QTY, SHORTAGE_QTY, + VENDOR, UNIT_PRICE, PROCESSING_UNIT_PRICE, TOTAL_PRICE, CURRENCY, + LEAD_TIME, MIN_ORDER_QTY, + STATUS, WRITER, REGDATE, EDITER, EDIT_DATE, REMARK +) VALUES ( + $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14, + $15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26, + $27,$28,$29,$30,$31,$32,$33, + 'ACTIVE', $34, NOW(), $34, NOW(), $35 +)`; + +const DETAIL_UPDATE_SQL = ` +UPDATE MBOM_DETAIL SET + PARENT_OBJID = NULLIF($1, ''), + SEQ = $2, + LEVEL = $3, + PART_OBJID = $4, + PART_NO = NULLIF($5, ''), + PART_NAME = NULLIF($6, ''), + QTY = $7, + ITEM_QTY = $8, + UNIT = NULLIF($9, ''), + SUPPLY_TYPE = NULLIF($10, ''), + MAKE_OR_BUY = NULLIF($11, ''), + RAW_MATERIAL_PART_NO = NULLIF($12, ''), + RAW_MATERIAL_SPEC = NULLIF($13, ''), + RAW_MATERIAL = NULLIF($14, ''), + RAW_MATERIAL_SIZE = NULLIF($15, ''), + PROCESSING_VENDOR = NULLIF($16, ''), + PROCESSING_DEADLINE = NULLIF($17, ''), + GRINDING_DEADLINE = NULLIF($18, ''), + REQUIRED_QTY = $19, + ORDER_QTY = $20, + PRODUCTION_QTY = $21, + STOCK_QTY = $22, + SHORTAGE_QTY = $23, + EDITER = $24, + EDIT_DATE = NOW(), + REMARK = NULLIF($25, '') +WHERE OBJID = $26`; + +function n(v: any): number | null { + if (v == null || v === "") return null; + const num = Number(v); + return Number.isFinite(num) ? num : null; +} +function s(v: any): string | null { + if (v == null) return null; + const str = String(v).trim(); + return str === "" ? null : str; +} +function bi(v: any): string | null { + // part_objid 는 bigint — 숫자/문자열 모두 수용, 빈값/NaN 은 null + if (v == null || v === "") return null; + const num = Number(v); + return Number.isFinite(num) ? String(Math.trunc(num)) : null; +} + +function detailInsertParams( + row: MbomSaveRow, + objid: string, + mbomHeaderObjid: string, + childObjid: string, + parentObjid: string | null, + userId: string, +): any[] { + return [ + objid, + mbomHeaderObjid, + parentObjid, + childObjid, + n(row.seq) ?? 999, + n(row.level) ?? 1, + bi(row.part_objid), + s(row.part_no), + s(row.part_name), + n(row.qty), + n(row.item_qty), + s(row.unit), + s(row.supply_type), + s(row.make_or_buy), + s(row.raw_material_no), + s(row.raw_material_spec), + s(row.raw_material), + s(row.raw_material_size ?? row.size), + s(row.processing_vendor), + s(row.processing_deadline), + s(row.grinding_deadline), + n(row.required_qty), + n(row.order_qty), + n(row.production_qty), + n(row.stock_qty), + n(row.shortage_qty), + s(row.vendor), + n(row.unit_price), + n(row.processing_unit_price), + n(row.total_price), + s(row.currency) ?? "KRW", + n(row.lead_time), + n(row.min_order_qty), + userId, + s(row.remark), + ]; +} + +function detailUpdateParams(row: MbomSaveRow, objid: string, userId: string): any[] { + return [ + s(row.parent_objid) ?? "", + n(row.seq) ?? 999, + n(row.level) ?? 1, + bi(row.part_objid), + s(row.part_no) ?? "", + s(row.part_name) ?? "", + n(row.qty), + n(row.item_qty), + s(row.unit) ?? "", + s(row.supply_type) ?? "", + s(row.make_or_buy) ?? "", + s(row.raw_material_no) ?? "", + s(row.raw_material_spec) ?? "", + s(row.raw_material) ?? "", + s(row.raw_material_size ?? row.size) ?? "", + s(row.processing_vendor) ?? "", + s(row.processing_deadline) ?? "", + s(row.grinding_deadline) ?? "", + n(row.required_qty), + n(row.order_qty), + n(row.production_qty), + n(row.stock_qty), + n(row.shortage_qty), + userId, + s(row.remark) ?? "", + objid, + ]; +} + +export async function save(payload: MbomSavePayload, sessionUserId: string): Promise { + const pool = getPool(); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + const projectObjId = String(payload.project_obj_id ?? "").trim(); + if (!projectObjId) throw new Error("project_obj_id 누락"); + const userId = String(sessionUserId ?? "").trim() || "system"; + + // 1) 프로젝트 + 할당 정보 조회 (sourceBomType + basePartNo) + const proj = await client.query( + `SELECT PM.OBJID::VARCHAR AS objid, + PM.PART_NO AS part_no, PM.PART_NAME AS part_name, + PM.SOURCE_BOM_TYPE AS source_bom_type, + PM.SOURCE_EBOM_OBJID AS source_ebom_objid, + PM.SOURCE_MBOM_OBJID AS source_mbom_objid, + CM.PRODUCT AS product_code, + (SELECT PBR.PART_NO FROM PART_BOM_REPORT PBR + WHERE PBR.OBJID::VARCHAR = PM.SOURCE_EBOM_OBJID LIMIT 1) AS ebom_part_no, + (SELECT MH.MBOM_NO FROM MBOM_HEADER MH + WHERE MH.OBJID = PM.SOURCE_MBOM_OBJID LIMIT 1) AS source_mbom_no + FROM PROJECT_MGMT PM + INNER JOIN CONTRACT_MGMT CM ON PM.CONTRACT_OBJID = CM.OBJID + WHERE PM.OBJID::VARCHAR = $1 LIMIT 1`, + [projectObjId], + ); + if (!proj.rows[0]) throw new Error("프로젝트 정보를 찾을 수 없습니다"); + const p = proj.rows[0]; + + let sourceBomType: string = p.source_bom_type ?? ""; + let sourceEbomObjid: string | null = null; + let sourceMbomObjid: string | null = null; + let basePartNo = ""; + + if (sourceBomType === "EBOM") { + sourceEbomObjid = p.source_ebom_objid ?? null; + basePartNo = p.ebom_part_no ?? p.part_no ?? ""; + } else if (sourceBomType === "MBOM") { + sourceMbomObjid = p.source_mbom_objid ?? null; + basePartNo = p.source_mbom_no ?? p.part_no ?? ""; + } else { + // Machine 이외(product != 0000928) + part_no 가 있으면 TEMPLATE + if (p.product_code !== "0000928" && p.part_no) { + sourceBomType = "TEMPLATE"; + basePartNo = p.part_no; + } else { + throw new Error("M-BOM 기준 정보가 없습니다. BOM 복사 팝업에서 먼저 기준을 설정해주세요."); + } + } + + let mbomHeaderObjid: string; + let mbomNo: string; + let inserted = 0, updated = 0, deleted = 0; + let mode: "CREATE" | "UPDATE"; + + if (payload.is_update) { + // ── UPDATE 분기 ── + const exist = await client.query( + `SELECT OBJID AS objid, MBOM_NO AS mbom_no FROM MBOM_HEADER + WHERE PROJECT_OBJID = $1 AND STATUS = 'Y' + ORDER BY REGDATE DESC LIMIT 1`, + [projectObjId], + ); + if (!exist.rows[0]) throw new Error("수정 대상 M-BOM 헤더를 찾을 수 없습니다"); + mbomHeaderObjid = exist.rows[0].objid; + mbomNo = exist.rows[0].mbom_no; + mode = "UPDATE"; + + // 헤더 update + await client.query( + `UPDATE MBOM_HEADER SET + PART_NO = $1, PART_NAME = $2, + EDITER = $3, EDIT_DATE = NOW() + WHERE OBJID = $4`, + [p.part_no ?? "", p.part_name ?? "", userId, mbomHeaderObjid], + ); + + // 기존 detail objid 수집 + const existRes = await client.query( + `SELECT OBJID AS objid FROM MBOM_DETAIL WHERE MBOM_HEADER_OBJID = $1`, + [mbomHeaderObjid], + ); + const existIds = new Set(existRes.rows.map((r: any) => r.objid)); + const incomingIds = new Set(); + + // UPSERT + for (const row of payload.rows ?? []) { + let objid = s(row.objid) ?? ""; + if (!objid) objid = createObjId(); + let childObjid = s(row.child_objid) ?? objid; + incomingIds.add(objid); + + if (existIds.has(objid)) { + await client.query(DETAIL_UPDATE_SQL, detailUpdateParams({ ...row, child_objid: childObjid }, objid, userId)); + updated++; + } else { + await client.query( + DETAIL_INSERT_SQL, + detailInsertParams(row, objid, mbomHeaderObjid, childObjid, s(row.parent_objid), userId), + ); + inserted++; + } + } + + // 누락 행 delete + for (const oldId of existIds) { + if (!incomingIds.has(oldId)) { + await client.query(`DELETE FROM MBOM_DETAIL WHERE OBJID = $1`, [oldId]); + deleted++; + } + } + + // history UPDATE + await insertHistory( + client, + mbomHeaderObjid, + "UPDATE", + `${inserted + updated + deleted}개 항목 처리 (insert=${inserted}, update=${updated}, delete=${deleted})`, + userId, + ); + } else { + // ── CREATE 분기 ── + mbomHeaderObjid = createObjId(); + mbomNo = await generateMbomNo(client, sourceBomType, basePartNo); + mode = "CREATE"; + + await client.query( + `INSERT INTO MBOM_HEADER ( + OBJID, MBOM_NO, SOURCE_BOM_TYPE, SOURCE_EBOM_OBJID, SOURCE_MBOM_OBJID, + PROJECT_OBJID, PART_NO, PART_NAME, STATUS, MBOM_STATUS, + WRITER, REGDATE + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'Y','DRAFT',$9,NOW())`, + [ + mbomHeaderObjid, mbomNo, sourceBomType, + sourceEbomObjid, sourceMbomObjid, + projectObjId, p.part_no ?? "", p.part_name ?? "", + userId, + ], + ); + + // child_objid 재매핑 (E-BOM/MBOM 의 기존 child_objid → 새 child_objid) + const childMap = new Map(); + for (const row of payload.rows ?? []) { + const oldChild = s(row.child_objid); + if (oldChild && !childMap.has(oldChild)) childMap.set(oldChild, createObjId()); + } + + for (const row of payload.rows ?? []) { + const oldChild = s(row.child_objid); + const oldParent = s(row.parent_objid); + const newChild = (oldChild && childMap.get(oldChild)) || createObjId(); + const newParent = oldParent ? (childMap.get(oldParent) ?? oldParent) : null; + + await client.query( + DETAIL_INSERT_SQL, + detailInsertParams(row, newChild, mbomHeaderObjid, newChild, newParent, userId), + ); + inserted++; + } + + // history CREATE + await insertHistory( + client, + mbomHeaderObjid, + "CREATE", + `M-BOM 신규 저장 (${inserted}개 항목)`, + userId, + ); + + // PROJECT_MGMT.MBOM_STATUS = 'Y' + await client.query( + `UPDATE PROJECT_MGMT SET MBOM_STATUS = 'Y', MBOM_WRITER = $1, MBOM_REGDATE = NOW() + WHERE OBJID::VARCHAR = $2`, + [userId, projectObjId], + ); + } + + await client.query("COMMIT"); + return { mode, mbom_header_objid: mbomHeaderObjid, mbom_no: mbomNo, inserted, updated, deleted }; + } catch (e) { + await client.query("ROLLBACK"); + throw e; + } finally { + client.release(); + } +} + +async function insertHistory( + client: any, + mbomHeaderObjid: string, + changeType: "CREATE" | "UPDATE", + description: string, + userId: string, +) { + await client.query( + `INSERT INTO MBOM_HISTORY ( + OBJID, MBOM_HEADER_OBJID, CHANGE_TYPE, CHANGE_DESCRIPTION, + BEFORE_DATA, AFTER_DATA, CHANGE_USER, CHANGE_DATE + ) VALUES ($1, $2, $3, $4, NULL, NULL, $5, NOW())`, + [createObjId(), mbomHeaderObjid, changeType, description, userId], + ); +} + // 매퍼 partMng.getBOMTreeList search_type='working' 1:1. // E-BOM 호환 — part_no 컬럼명 충돌 회피 위해 운영판처럼 V.PART_NO 는 PART_OBJID 로 alias. const EBOM_WORKING_TREE_SQL = ` @@ -844,9 +1288,9 @@ SELECT P.REMARK AS part_remark, P.THICKNESS, P.WIDTH, P.HEIGHT, P.OUT_DIAMETER, P.IN_DIAMETER, P.LENGTH, P.SOURCING_CODE, P.HEAT_TREATMENT_HARDNESS, P.HEAT_TREATMENT_METHOD, P.SURFACE_TREATMENT, - (SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('3D_CAD')) AS cu01_cnt, - (SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_DRAWING_CAD')) AS cu02_cnt, - (SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_PDF_CAD')) AS cu03_cnt, + (SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID::varchar = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('3D_CAD')) AS cu01_cnt, + (SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID::varchar = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_DRAWING_CAD')) AS cu02_cnt, + (SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID::varchar = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_PDF_CAD')) AS cu03_cnt, -- E-BOM 분기는 생산정보가 없으므로 NULL 로 채워 SAVED 와 동일한 키셋 유지 NULL::text AS supply_type, NULL::text AS make_or_buy, NULL::text AS raw_material_no, NULL::text AS raw_material_spec, @@ -861,6 +1305,6 @@ SELECT NULL::int AS lead_time, NULL::numeric AS min_order_qty, NULL::text AS writer, NULL::text AS regdate, NULL::text AS editer, NULL::text AS edit_date, NULL::text AS remark FROM VIEW_BOM V -INNER JOIN PART_MNG P ON P.OBJID = COALESCE(V.LAST_PART_OBJID, V.PART_NO) +INNER JOIN PART_MNG P ON P.OBJID::varchar = COALESCE(V.LAST_PART_OBJID, V.PART_NO) ORDER BY V.PATH2 `; diff --git a/frontend/app/(main)/COMPANY_16/development/change-list/page.tsx b/frontend/app/(main)/COMPANY_16/development/change-list/page.tsx index c7f1475e..3c238fd2 100644 --- a/frontend/app/(main)/COMPANY_16/development/change-list/page.tsx +++ b/frontend/app/(main)/COMPANY_16/development/change-list/page.tsx @@ -172,7 +172,13 @@ export default function EoHistoryPage() { gridId="development-eo-history" showColumnSettings paginationStyle="range" - pageSizeOptions={[10, 15, 20, 50, 100]} + pageSizeOptions={[10, 15, 20, 50, 100, 200, 500]} + serverPaging + serverPage={filter.page ?? 1} + serverPageSize={filter.page_size ?? 50} + serverTotalItems={total} + onPageChange={(p) => { setFilter(f => ({ ...f, page: p })); fetchList({ page: p }); }} + onPageSizeChange={(n) => { setFilter(f => ({ ...f, page: 1, page_size: n })); fetchList({ page: 1, page_size: n }); }} summaryStats={eoSummary} systemColumnKeys={["writer_name", "his_reg_date_title"]} onRefresh={() => fetchList()} diff --git a/frontend/app/(main)/COMPANY_16/development/ebom-regist/page.tsx b/frontend/app/(main)/COMPANY_16/development/ebom-regist/page.tsx index def8e6d3..ccf0a7ec 100644 --- a/frontend/app/(main)/COMPANY_16/development/ebom-regist/page.tsx +++ b/frontend/app/(main)/COMPANY_16/development/ebom-regist/page.tsx @@ -206,7 +206,13 @@ export default function EbomRegistPage() { gridId="development-ebom-regist" showColumnSettings paginationStyle="range" - pageSizeOptions={[10, 15, 20, 50, 100]} + pageSizeOptions={[10, 15, 20, 50, 100, 200, 500]} + serverPaging + serverPage={filter.page ?? 1} + serverPageSize={filter.page_size ?? 50} + serverTotalItems={total} + onPageChange={(p) => { setFilter(f => ({ ...f, page: p })); fetchList({ page: p }); }} + onPageSizeChange={(n) => { setFilter(f => ({ ...f, page: 1, page_size: n })); fetchList({ page: 1, page_size: n }); }} summaryStats={bomSummary} systemColumnKeys={["dept_user_name", "reg_date", "deploy_date", "revision", "status_title"]} onRefresh={() => fetchList()} diff --git a/frontend/app/(main)/COMPANY_16/development/part-regist/page.tsx b/frontend/app/(main)/COMPANY_16/development/part-regist/page.tsx index 970c6b06..8bcd87f8 100644 --- a/frontend/app/(main)/COMPANY_16/development/part-regist/page.tsx +++ b/frontend/app/(main)/COMPANY_16/development/part-regist/page.tsx @@ -239,7 +239,13 @@ export default function PartRegistPage() { gridId="development-part-regist" showColumnSettings paginationStyle="range" - pageSizeOptions={[10, 15, 20, 50, 100]} + pageSizeOptions={[10, 15, 20, 50, 100, 200, 500]} + serverPaging + serverPage={filter.page ?? 1} + serverPageSize={filter.page_size ?? 50} + serverTotalItems={total} + onPageChange={(p) => { setFilter(f => ({ ...f, page: p })); fetchList({ page: p }); }} + onPageSizeChange={(n) => { setFilter(f => ({ ...f, page: 1, page_size: n })); fetchList({ page: 1, page_size: n }); }} summaryStats={partSummary} systemColumnKeys={["revision", "status"]} onRefresh={() => fetchList()} diff --git a/frontend/app/(main)/COMPANY_16/development/part-search/page.tsx b/frontend/app/(main)/COMPANY_16/development/part-search/page.tsx index 9260a414..d41c5055 100644 --- a/frontend/app/(main)/COMPANY_16/development/part-search/page.tsx +++ b/frontend/app/(main)/COMPANY_16/development/part-search/page.tsx @@ -201,7 +201,13 @@ export default function PartSearchPage() { gridId="development-part-search" showColumnSettings paginationStyle="range" - pageSizeOptions={[10, 15, 20, 50, 100]} + pageSizeOptions={[10, 15, 20, 50, 100, 200, 500]} + serverPaging + serverPage={filter.page ?? 1} + serverPageSize={filter.page_size ?? 50} + serverTotalItems={total} + onPageChange={(p) => { setFilter(f => ({ ...f, page: p })); fetchList({ page: p }); }} + onPageSizeChange={(n) => { setFilter(f => ({ ...f, page: 1, page_size: n })); fetchList({ page: 1, page_size: n }); }} summaryStats={partSummary} systemColumnKeys={["revision", "eo_no"]} onRefresh={() => fetchList()} diff --git a/frontend/app/(main)/COMPANY_16/production/mbom/page.tsx b/frontend/app/(main)/COMPANY_16/production/mbom/page.tsx index 2ea1eb01..3d12ca50 100644 --- a/frontend/app/(main)/COMPANY_16/production/mbom/page.tsx +++ b/frontend/app/(main)/COMPANY_16/production/mbom/page.tsx @@ -51,27 +51,10 @@ const EMPTY_FILTER: MbomListFilter = { search_req_del_date_from: "", search_req_del_date_to: "", page: 1, - page_size: 50, + page_size: 50, // DataGrid 서버 페이지네이션 — 페이지 변경/사이즈 변경 시 부모가 직접 재요청 }; -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" }, -]; +// 그리드 컬럼은 useMemo 로 컴포넌트 내부에서 생성 — onClick(openDialog) 캡처 위해. export default function MbomMgmtPage() { const [rows, setRows] = useState([]); @@ -124,11 +107,43 @@ export default function MbomMgmtPage() { }, []); // DataGrid 키 부여 (objid + part_no 조합 — 같은 프로젝트 다중 행 unique) + // mbom_has: folder 컬럼이 숫자 > 0 일 때 파랑. mbom_header_objid 가 있으면 저장된 M-BOM. const gridRows = useMemo( - () => rows.map((r, i) => ({ ...r, id: `${r.objid}__${r.part_no ?? ""}__${i}` })), + () => rows.map((r, i) => ({ + ...r, + id: `${r.objid}__${r.part_no ?? ""}__${i}`, + mbom_has: r.mbom_header_objid ? 1 : 0, + })), [rows] ); + const openMbomDialog = useCallback((row: any) => { + if (!row?.objid) return; + setDialogObjid(String(row.objid)); + setDialogOpen(true); + }, []); + + const GRID_COLUMNS: DataGridColumn[] = useMemo(() => ([ + { 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]" }, + // M-BOM 컬럼 — 폴더 아이콘 (저장됨=파랑, 미저장=흰색). 클릭 시 본 편집 다이얼로그. + { key: "mbom_has", label: "M-BOM", width: "w-[80px]", align: "center", + renderType: "folder", onClick: openMbomDialog }, + { key: "mbom_regdate", label: "최종저장일", width: "w-[100px]", align: "center" }, + ]), [openMbomDialog]); + const handleSearch = () => { setFilter((f) => ({ ...f, page: 1 })); fetchList({ page: 1 }); @@ -227,6 +242,14 @@ export default function MbomMgmtPage() { showRowNumber emptyMessage="조건에 맞는 프로젝트가 없습니다." gridId="production-mbom-mgmt" + pageSizeOptions={[25, 50, 100, 200, 500]} + paginationStyle="range" + serverPaging + serverPage={filter.page ?? 1} + serverPageSize={filter.page_size ?? 50} + serverTotalItems={total} + onPageChange={(p) => { setFilter(f => ({ ...f, page: p })); fetchList({ page: p }); }} + onPageSizeChange={(n) => { setFilter(f => ({ ...f, page: 1, page_size: n })); fetchList({ page: 1, page_size: n }); }} onRowDoubleClick={(row: any) => { if (!row?.objid) return; setDialogObjid(String(row.objid)); @@ -239,6 +262,7 @@ export default function MbomMgmtPage() { open={dialogOpen} onOpenChange={setDialogOpen} projectObjid={dialogObjid} + onSaved={fetchList} />
); diff --git a/frontend/components/common/DataGrid.tsx b/frontend/components/common/DataGrid.tsx index b26ff93e..e6286394 100644 --- a/frontend/components/common/DataGrid.tsx +++ b/frontend/components/common/DataGrid.tsx @@ -94,6 +94,18 @@ export interface DataGridProps { onDownload?: () => void; /** 차트 분석 패널 활성화. 지정 시 toolbar에 📊 토글 + 그리드 하단에 패널 노출 */ showChart?: boolean; + /** 서버 페이지네이션 모드. true면 page/pageSize/totalItems 를 props로 받고 변경 시 콜백 호출 */ + serverPaging?: boolean; + /** 서버 페이지네이션 현재 페이지 (1-based, serverPaging=true일 때) */ + serverPage?: number; + /** 서버 페이지네이션 페이지 크기 (serverPaging=true일 때) */ + serverPageSize?: number; + /** 서버 페이지네이션 총 건수 (serverPaging=true일 때) */ + serverTotalItems?: number; + /** 페이지 변경 콜백 — 서버 페이지네이션 시 API 재호출용 */ + onPageChange?: (page: number) => void; + /** 페이지 크기 변경 콜백 — 서버 페이지네이션 시 API 재호출용 */ + onPageSizeChange?: (pageSize: number) => void; } const fmtNum = (val: any) => { @@ -303,6 +315,12 @@ export function DataGrid({ onRefresh, onDownload, showChart = false, + serverPaging = false, + serverPage, + serverPageSize, + serverTotalItems, + onPageChange, + onPageSizeChange, }: DataGridProps) { const [columns, setColumns] = useState(initialColumns); useEffect(() => { setColumns(initialColumns); }, [initialColumns]); @@ -360,10 +378,22 @@ export function DataGrid({ // 헤더 필터 (컬럼별 선택된 값 Set) const [headerFilters, setHeaderFilters] = useState>>({}); - // 페이지네이션 - const [currentPage, setCurrentPage] = useState(1); - const [pageSize, setPageSize] = useState(defaultPageSize); + // 페이지네이션 — 서버 모드면 props 사용, 아니면 로컬 state + const [localCurrentPage, setLocalCurrentPage] = useState(1); + const [localPageSize, setLocalPageSize] = useState(defaultPageSize); const [pageSizeInput, setPageSizeInput] = useState(String(defaultPageSize)); + const currentPage = serverPaging ? (serverPage ?? 1) : localCurrentPage; + const pageSize = serverPaging ? (serverPageSize ?? defaultPageSize) : localPageSize; + const setCurrentPage = useCallback((p: number | ((prev: number) => number)) => { + const next = typeof p === "function" ? (p as (n: number) => number)(currentPage) : p; + if (serverPaging) { onPageChange?.(next); } else { setLocalCurrentPage(next); } + }, [serverPaging, currentPage, onPageChange]); + const setPageSize = useCallback((n: number) => { + if (serverPaging) { onPageSizeChange?.(n); } else { setLocalPageSize(n); } + }, [serverPaging, onPageSizeChange]); + useEffect(() => { + if (serverPaging && serverPageSize) setPageSizeInput(String(serverPageSize)); + }, [serverPaging, serverPageSize]); // 인라인 편집 const [editingCell, setEditingCell] = useState<{ rowIdx: number; colKey: string } | null>(null); @@ -533,31 +563,34 @@ export function DataGrid({ return result; }, [data, headerFilters, sortKey, sortDir]); - // 필터/데이터 변경 시 1페이지로 리셋 + // 필터/데이터 변경 시 1페이지로 리셋 — 서버 페이지네이션에서는 부모가 직접 page 관리하므로 스킵 useEffect(() => { - setCurrentPage(1); - }, [data, headerFilters]); + if (serverPaging) return; + setLocalCurrentPage(1); + }, [data, headerFilters, serverPaging]); // 페이지네이션 계산 - const totalItems = processedData.length; + // 서버 모드: totalItems 는 prop, paginatedData 는 data 그대로(서버가 잘라준 한 페이지) + // 클라이언트 모드: totalItems = processedData.length, paginatedData = client slice + const totalItems = serverPaging ? (serverTotalItems ?? processedData.length) : processedData.length; const totalPages = Math.max(1, Math.ceil(totalItems / pageSize)); const safePage = Math.min(currentPage, totalPages); useEffect(() => { - if (currentPage > totalPages) setCurrentPage(totalPages); - }, [currentPage, totalPages]); + if (!serverPaging && currentPage > totalPages) setCurrentPage(totalPages); + }, [serverPaging, currentPage, totalPages, setCurrentPage]); const pageOffset = (safePage - 1) * pageSize; - const paginatedData = showPagination - ? processedData.slice(pageOffset, pageOffset + pageSize) - : processedData; + const paginatedData = serverPaging + ? processedData + : (showPagination ? processedData.slice(pageOffset, pageOffset + pageSize) : processedData); // 페이지 크기 입력 적용 const applyPageSize = () => { const n = parseInt(pageSizeInput, 10); if (!isNaN(n) && n >= 1) { setPageSize(n); - setCurrentPage(1); + if (!serverPaging) setLocalCurrentPage(1); setPageSizeInput(String(n)); } else { setPageSizeInput(String(pageSize)); @@ -1031,7 +1064,8 @@ export function DataGrid({ if (!isNaN(n) && n >= 1) { setPageSize(n); setPageSizeInput(String(n)); - setCurrentPage(1); + // 서버 모드는 부모가 page 리셋(또는 페이지 유지)을 직접 결정 + if (!serverPaging) setLocalCurrentPage(1); } }} > diff --git a/frontend/components/production/MbomDetailDialog.tsx b/frontend/components/production/MbomDetailDialog.tsx index 2da312a9..988077a9 100644 --- a/frontend/components/production/MbomDetailDialog.tsx +++ b/frontend/components/production/MbomDetailDialog.tsx @@ -1,34 +1,37 @@ "use client"; -// 생산관리 > M-BOM 관리 — 단건 상세 + read-only 트리 다이얼로그. +// 생산관리 > M-BOM 관리 — 단건 상세 + 트리 (read-only / 편집). // // 운영판 통합: // wace mBomHeaderPopup.jsp (헤더 메타) -// + wace mBomPopupLeft.jsp (read-only 트리 — 4분기 자동) +// + wace mBomPopupLeft.jsp (좌측 트리 — read-only/편집) // // 4분기 (운영판 mBomPopupLeft.do): -// SAVED 저장된 mbom_header.status='Y' 의 트리 (생산정보 포함) -// ASSIGNED_EBOM source_bom_type='EBOM' + source_ebom_objid → bom_part_qty 트리 +// SAVED 저장된 mbom_header.status='Y' 의 트리 (생산정보 포함, 편집 시 UPDATE) +// ASSIGNED_EBOM source_bom_type='EBOM' + source_ebom_objid → bom_part_qty 트리 (편집 시 신규 CREATE) // ASSIGNED_MBOM source_bom_type='MBOM' + source_mbom_objid → mbom_detail 구조만 // TEMPLATE Machine 이외 + 동일 part_no 의 mbom_header 템플릿 // NONE 빈 트리 // -// 본 편집 / BOM 복사 / 구매리스트 생성 / 변경이력 — PR-B 분리. +// PR-B1 — 셀 인라인 편집 + 저장 (운영 saveMbom.do 1:1). +// 행 추가/삭제(mBomCenterBtnPopup) / BOM 복사 / 구매리스트 / 변경이력 — PR-B2~ 분리. import React, { useEffect, useMemo, useState } from "react"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; -import { Loader2, Folder } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { Loader2, Folder, Pencil, Save, X } from "lucide-react"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; -import { mbomApi, MbomDetail, MbomTreeResponse, MbomBomDataType, MbomTreeRow } from "@/lib/api/mbom"; +import { mbomApi, MbomDetail, MbomTreeResponse, MbomBomDataType, MbomTreeRow, MbomSaveRow } from "@/lib/api/mbom"; interface Props { open: boolean; onOpenChange: (open: boolean) => void; projectObjid: string | null; + onSaved?: () => void; } const BOM_DATA_TYPE_LABEL: Record = { @@ -39,38 +42,55 @@ const BOM_DATA_TYPE_LABEL: Record(null); const [tree, setTree] = useState(null); const [loading, setLoading] = useState(false); + const [editMode, setEditMode] = useState(false); + const [saving, setSaving] = useState(false); + const [editableRows, setEditableRows] = useState([]); + const [dirty, setDirty] = useState(false); - useEffect(() => { - if (!open || !projectObjid) { - setDetail(null); setTree(null); - return; - } - let alive = true; + const loadTree = (objid: string) => { setLoading(true); - Promise.all([ - mbomApi.getDetail(projectObjid), - mbomApi.getTree(projectObjid), + return Promise.all([ + mbomApi.getDetail(objid), + mbomApi.getTree(objid), ]) .then(([d, t]) => { - if (!alive) return; setDetail(d); setTree(t); + setEditableRows((t?.rows ?? []).map(r => ({ ...r }))); + setDirty(false); }) .catch((e: any) => { toast.error(e?.response?.data?.message ?? e?.message ?? "M-BOM 조회 실패"); }) - .finally(() => { if (alive) setLoading(false); }); - return () => { alive = false; }; + .finally(() => setLoading(false)); + }; + + useEffect(() => { + if (!open || !projectObjid) { + setDetail(null); setTree(null); setEditableRows([]); + setEditMode(false); setDirty(false); + return; + } + void loadTree(projectObjid); }, [open, projectObjid]); const maxLevel = Math.max(1, tree?.max_level ?? 1); - const rows: MbomTreeRow[] = tree?.rows ?? []; + const rows = editMode ? editableRows : (tree?.rows ?? []); const bomDataType: MbomBomDataType = tree?.bom_data_type ?? "NONE"; const meta = BOM_DATA_TYPE_LABEL[bomDataType]; + const canEdit = bomDataType !== "NONE"; const levelHeaders = useMemo(() => { const h: number[] = []; @@ -78,13 +98,91 @@ export function MbomDetailDialog({ open, onOpenChange, projectObjid }: Props) { return h; }, [maxLevel]); + const updateRow = (idx: number, field: EditableField, value: any) => { + setEditableRows(prev => { + const next = [...prev]; + next[idx] = { ...next[idx], [field]: value }; + return next; + }); + setDirty(true); + }; + + const handleEditToggle = () => { + if (editMode && dirty) { + if (!window.confirm("편집 중인 변경사항이 사라집니다. 취소하시겠습니까?")) return; + setEditableRows((tree?.rows ?? []).map(r => ({ ...r }))); + setDirty(false); + } + setEditMode(!editMode); + }; + + const handleSave = async () => { + if (!projectObjid) return; + if (rows.length === 0) { + toast.error("저장할 트리가 비어있습니다"); + return; + } + setSaving(true); + try { + const isUpdate = bomDataType === "SAVED"; + const payload = { + project_obj_id: projectObjid, + is_update: isUpdate, + rows: editableRows.map(r => ({ + objid: r.objid, + parent_objid: r.parent_objid, + child_objid: r.child_objid, + seq: r.seq, + level: r.level, + part_objid: r.part_objid, + part_no: r.part_no, + part_name: r.part_name, + qty: r.qty, + item_qty: r.item_qty, + unit: r.unit, + supply_type: r.supply_type, + make_or_buy: r.make_or_buy, + raw_material_no: r.raw_material_no, + raw_material_spec: r.raw_material_spec, + raw_material: r.raw_material, + raw_material_size: (r as any).raw_material_size ?? r.size, + processing_vendor: r.processing_vendor, + processing_deadline: r.processing_deadline, + grinding_deadline: r.grinding_deadline, + required_qty: r.required_qty, + order_qty: r.order_qty, + production_qty: r.production_qty, + remark: r.remark, + })), + }; + const result = await mbomApi.save(payload); + toast.success(`M-BOM ${result.mode === "CREATE" ? "생성" : "수정"} 완료 (${result.mbom_no})`); + setEditMode(false); + setDirty(false); + await loadTree(projectObjid); + onSaved?.(); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "저장 실패"); + } finally { + setSaving(false); + } + }; + return ( - + { + if (!v && editMode && dirty) { + if (!window.confirm("저장하지 않은 변경사항이 있습니다. 닫으시겠습니까?")) return; + } + onOpenChange(v); + }}> - M-BOM 관리 — 단건 상세 + M-BOM 관리 — {editMode ? "본 편집" : "단건 상세"} {meta.text} + {editMode && dirty && ( + 변경됨 + )} @@ -115,6 +213,24 @@ export function MbomDetailDialog({ open, onOpenChange, projectObjid }: Props) { BOM_OBJID = {tree.bom_report_objid} )}
+
+ {canEdit && !editMode && ( + + )} + {editMode && ( + <> + + + + )} +
@@ -170,24 +286,36 @@ export function MbomDetailDialog({ open, onOpenChange, projectObjid }: Props) { ))}
- + updateRow(idx, "qty", v)} /> - - - - - - - - - - - + updateRow(idx, "supply_type", v)} /> + updateRow(idx, "make_or_buy", v)} /> + updateRow(idx, "raw_material_no", v)} /> + updateRow(idx, "raw_material", v)} /> + updateRow(idx, "raw_material_size", v)} /> + updateRow(idx, "required_qty", v)} /> + updateRow(idx, "order_qty", v)} /> + updateRow(idx, "production_qty", v)} /> + updateRow(idx, "processing_vendor", v)} /> + updateRow(idx, "processing_deadline", v)} /> + updateRow(idx, "grinding_deadline", v)} /> - + updateRow(idx, "remark", v)} /> ); })} @@ -204,6 +332,74 @@ export function MbomDetailDialog({ open, onOpenChange, projectObjid }: Props) { ); } +const SUPPLY_TYPE_OPTIONS = [ + { value: "", label: "—" }, + { value: "자급", label: "자급" }, + { value: "사급", label: "사급" }, +]; +const MAKE_OR_BUY_OPTIONS = [ + { value: "", label: "—" }, + { value: "Make", label: "Make" }, + { value: "Buy", label: "Buy" }, +]; + +function EditableNumCell({ editable, value, onChange }: { editable: boolean; value: any; onChange: (v: any) => void }) { + if (!editable) return ; + return ( + + ); +} +function EditableTextCell({ editable, value, onChange }: { editable: boolean; value: any; onChange: (v: any) => void }) { + if (!editable) return ; + return ( + + ); +} +function EditableDateCell({ editable, value, onChange }: { editable: boolean; value: any; onChange: (v: any) => void }) { + if (!editable) return ; + return ( + + ); +} +function EditableSelectCell({ + editable, value, options, onChange, +}: { editable: boolean; value: any; options: { value: string; label: string }[]; onChange: (v: any) => void }) { + if (!editable) return ; + return ( + + ); +} + function FolderCell({ n }: { n: any }) { const has = Number(n ?? 0) > 0; return ( @@ -229,7 +425,6 @@ function fmtNum(v: any): string { if (v == null || v === "") return ""; const n = Number(v); if (!isFinite(n)) return String(v); - // 정수면 천 단위, 소수가 있으면 그대로 (4자리 까지 표시) return Number.isInteger(n) ? n.toLocaleString() : n.toLocaleString(undefined, { maximumFractionDigits: 4 }); diff --git a/frontend/lib/api/mbom.ts b/frontend/lib/api/mbom.ts index 5d49098a..12e077ab 100644 --- a/frontend/lib/api/mbom.ts +++ b/frontend/lib/api/mbom.ts @@ -166,6 +166,55 @@ export interface MbomTreeResponse { rows: MbomTreeRow[]; } +// ─── 저장 (PR-B1) ─────────────────────────────────────────── +// 운영판 saveMbom.do 1:1 — 신규/수정 통합 엔드포인트. +// is_update=false → 새 mbom_header + child_objid 재매핑 후 detail 일괄 insert +// is_update=true → 기존 mbom_header.objid 조회 → UPSERT + 누락 행 delete + +export interface MbomSaveRow { + objid?: string | null; + parent_objid?: string | null; + child_objid?: string | null; + seq?: number | string | null; + level?: number | string | null; + part_objid?: string | number | null; + part_no?: string | null; + part_name?: string | null; + qty?: number | string | null; + item_qty?: number | string | null; + unit?: string | null; + supply_type?: string | null; + make_or_buy?: string | null; + raw_material_no?: string | null; + raw_material_spec?: string | null; + raw_material?: string | null; + size?: string | null; + raw_material_size?: string | null; + processing_vendor?: string | null; + processing_deadline?: string | null; + grinding_deadline?: string | null; + required_qty?: number | string | null; + order_qty?: number | string | null; + production_qty?: number | string | null; + remark?: string | null; +} + +export interface MbomSavePayload { + project_obj_id: string; + is_update: boolean; + mbom_part_no?: string | null; + rows: MbomSaveRow[]; +} + +export interface MbomSaveResult { + mode: "CREATE" | "UPDATE"; + mbom_header_objid: string; + mbom_no: string; + inserted: number; + updated: number; + deleted: number; +} + export const mbomApi = { async list(filter: MbomListFilter = {}): Promise { const res = await apiClient.get("/production/mbom/list", { params: filter }); @@ -179,4 +228,8 @@ export const mbomApi = { const res = await apiClient.get(`/production/mbom/tree/${encodeURIComponent(objid)}`); return res.data?.data as MbomTreeResponse; }, + async save(payload: MbomSavePayload): Promise { + const res = await apiClient.post("/production/mbom/save", payload); + return res.data?.data as MbomSaveResult; + }, };
{r.part_no} {r.part_name}{fmtNum(r.qty)}{fmtNum(r.item_qty)} {r.unit_title ?? r.unit ?? ""}{r.supply_type ?? ""}{r.make_or_buy ?? ""}{r.raw_material_no ?? ""}{r.raw_material ?? ""}{r.size ?? ""}{fmtNum(r.required_qty)}{fmtNum(r.order_qty)}{fmtNum(r.production_qty)}{r.processing_vendor_name ?? r.processing_vendor ?? ""}{r.processing_deadline ?? ""}{r.grinding_deadline ?? ""} {r.remark ?? ""}
{fmtNum(value)} + onChange(e.target.value === "" ? null : e.target.value)} + /> + {value ?? ""} + onChange(e.target.value)} + /> + {value ?? ""} + onChange(e.target.value)} + /> + {value ?? ""} + +