From ceeeeb1ef4b8e3612f55398380bc2c02136d06ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Fri, 20 Feb 2026 18:09:17 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat(WEB):=20=ED=85=8C=EC=9D=B4=EB=B8=94=20?= =?UTF-8?q?=EC=BB=AC=EB=9F=BC=20=ED=91=9C=EC=8B=9C/=EC=88=A8=EA=B9=80=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useColumnSettings 훅: 컬럼 가시성 토글 로직 - useTableColumnStore: Zustand 기반 컬럼 설정 영속화 (localStorage) - ColumnSettingsPopover: 컬럼 설정 UI 컴포넌트 - UniversalListPage/IntegratedListTemplateV2에 컬럼 설정 통합 Co-Authored-By: Claude Opus 4.6 --- .../molecules/ColumnSettingsPopover.tsx | 73 +++++++++++++ .../templates/IntegratedListTemplateV2.tsx | 49 ++++++++- .../templates/UniversalListPage/index.tsx | 68 +++++++++++- .../templates/UniversalListPage/types.ts | 4 + src/hooks/useColumnSettings.ts | 64 +++++++++++ src/stores/useTableColumnStore.ts | 101 ++++++++++++++++++ 6 files changed, 353 insertions(+), 6 deletions(-) create mode 100644 src/components/molecules/ColumnSettingsPopover.tsx create mode 100644 src/hooks/useColumnSettings.ts create mode 100644 src/stores/useTableColumnStore.ts diff --git a/src/components/molecules/ColumnSettingsPopover.tsx b/src/components/molecules/ColumnSettingsPopover.tsx new file mode 100644 index 00000000..9284795b --- /dev/null +++ b/src/components/molecules/ColumnSettingsPopover.tsx @@ -0,0 +1,73 @@ +'use client'; + +import { Settings2, RotateCcw } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import type { ColumnWithVisibility } from '@/hooks/useColumnSettings'; + +interface ColumnSettingsPopoverProps { + columns: ColumnWithVisibility[]; + onToggle: (key: string) => void; + onReset: () => void; + hasHiddenColumns: boolean; +} + +export function ColumnSettingsPopover({ + columns, + onToggle, + onReset, + hasHiddenColumns, +}: ColumnSettingsPopoverProps) { + return ( + + + + + +
+ 컬럼 표시 설정 + +
+
+ {columns.map((col) => ( + + ))} +
+
+
+ ); +} diff --git a/src/components/templates/IntegratedListTemplateV2.tsx b/src/components/templates/IntegratedListTemplateV2.tsx index 58e4665c..2fdca5ca 100644 --- a/src/components/templates/IntegratedListTemplateV2.tsx +++ b/src/components/templates/IntegratedListTemplateV2.tsx @@ -240,6 +240,13 @@ export interface IntegratedListTemplateV2Props { // 로딩 상태 isLoading?: boolean; + + // ===== 컬럼 리사이즈 & 가시성 설정 (opt-in) ===== + columnSettings?: { + columnWidths: Record; + onColumnResize: (columnKey: string, width: number) => void; + settingsPopover: ReactNode; + }; } export function IntegratedListTemplateV2({ @@ -299,6 +306,7 @@ export function IntegratedListTemplateV2({ pagination, devMetadata, isLoading, + columnSettings, }: IntegratedListTemplateV2Props) { const [showDeleteDialog, setShowDeleteDialog] = useState(false); @@ -785,6 +793,7 @@ export function IntegratedListTemplateV2({ ))} {tableHeaderActions} {renderAutoFilters()} + {columnSettings?.settingsPopover} @@ -929,6 +938,17 @@ export function IntegratedListTemplateV2({ /> ) : ( + {columnSettings && ( + + {showCheckbox && } + {tableColumns.map((col) => ( + + ))} + + )} {renderCustomTableHeader ? ( @@ -953,7 +973,7 @@ export function IntegratedListTemplateV2({ return ( onSort(column.key) : undefined} > {column.key === "actions" && selectedItems.size === 0 ? "" : ( @@ -974,6 +994,33 @@ export function IntegratedListTemplateV2({ )} )} + {columnSettings && ( +
{ + e.stopPropagation(); + e.preventDefault(); + const th = (e.target as HTMLElement).parentElement; + if (!th) return; + const startX = e.clientX; + const startWidth = th.offsetWidth; + const onMouseMove = (ev: MouseEvent) => { + const newWidth = Math.max(40, startWidth + ev.clientX - startX); + columnSettings.onColumnResize(column.key, newWidth); + }; + const onMouseUp = () => { + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + }; + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + }} + /> + )} ); })} diff --git a/src/components/templates/UniversalListPage/index.tsx b/src/components/templates/UniversalListPage/index.tsx index d1d08265..b864684a 100644 --- a/src/components/templates/UniversalListPage/index.tsx +++ b/src/components/templates/UniversalListPage/index.tsx @@ -11,9 +11,11 @@ * - 클라이언트 사이드 필터링/페이지네이션 (clientSideFiltering: true) */ -import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useRouter, useParams } from 'next/navigation'; import { usePermission } from '@/hooks/usePermission'; +import { useColumnSettings } from '@/hooks/useColumnSettings'; +import { ColumnSettingsPopover } from '@/components/molecules/ColumnSettingsPopover'; import { toast } from 'sonner'; import { Download, Loader2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -818,6 +820,46 @@ export function UniversalListPage({ })); }, [config.columns, config.columnsPerTab, activeTab]); + // ===== 컬럼 리사이즈 & 가시성 설정 (자동 활성화) ===== + const enableColumnSettings = !config.disableColumnSettings; + const alwaysVisibleKeys = useMemo( + () => effectiveColumns.filter(col => NON_SORTABLE_KEYS.includes(col.key)).map(col => col.key), + [effectiveColumns] + ); + const { + visibleColumns: colSettingsVisible, + allColumnsWithVisibility, + columnWidths, + setColumnWidth, + toggleColumnVisibility, + resetSettings, + hasHiddenColumns, + } = useColumnSettings({ + pageId: config.basePath, + columns: effectiveColumns, + alwaysVisibleKeys, + }); + const hiddenColumnKeys = useMemo( + () => allColumnsWithVisibility.filter(c => !c.visible).map(c => c.key), + [allColumnsWithVisibility] + ); + const templateColumns = enableColumnSettings ? colSettingsVisible : effectiveColumns; + const templateColumnSettings = useMemo(() => { + if (!enableColumnSettings) return undefined; + return { + columnWidths, + onColumnResize: setColumnWidth, + settingsPopover: ( + + ), + }; + }, [enableColumnSettings, columnWidths, setColumnWidth, allColumnsWithVisibility, toggleColumnVisibility, resetSettings, hasHiddenColumns]); + // ===== ID로 아이템 찾기 헬퍼 ===== const getItemById = useCallback( (id: string): T | undefined => { @@ -852,19 +894,33 @@ export function UniversalListPage({ ); // ===== 렌더링 함수 래퍼 ===== + const showCheckbox = config.showCheckbox !== false; const renderTableRow = useCallback( (item: T, index: number, globalIndex: number) => { const id = effectiveGetItemId(item); const isSelected = effectiveSelectedItems.has(id); - return config.renderTableRow(item, index, globalIndex, { + const row = config.renderTableRow(item, index, globalIndex, { isSelected, onToggle: () => toggleSelection(id), onRowClick: () => handleRowClick(item), onEdit: () => handleEdit(item), onDelete: permCanDelete ? () => handleDeleteClick(item) : undefined, }); + + // 컬럼 설정 활성화 시 숨긴 컬럼의 셀을 React.Children으로 제거 + if (!enableColumnSettings || hiddenColumnKeys.length === 0) return row; + if (!React.isValidElement(row)) return row; + + const children = React.Children.toArray((row as React.ReactElement<{ children?: React.ReactNode }>).props.children); + const offset = showCheckbox ? 1 : 0; + const filtered = children.filter((_, cellIndex) => { + if (showCheckbox && cellIndex === 0) return true; // 체크박스 유지 + const col = effectiveColumns[cellIndex - offset]; + return !col || !hiddenColumnKeys.includes(col.key); + }); + return React.cloneElement(row as React.ReactElement, {}, ...filtered); }, - [config, effectiveGetItemId, handleDeleteClick, handleEdit, handleRowClick, effectiveSelectedItems, toggleSelection] + [config, effectiveGetItemId, handleDeleteClick, handleEdit, handleRowClick, effectiveSelectedItems, toggleSelection, enableColumnSettings, hiddenColumnKeys, showCheckbox, effectiveColumns] ); const renderMobileCard = useCallback( @@ -955,8 +1011,10 @@ export function UniversalListPage({ }) : config.tableHeaderActions } - // 테이블 컬럼 (탭별 다른 컬럼 지원) - tableColumns={effectiveColumns} + // 테이블 컬럼 (가시성 필터링 적용) + tableColumns={templateColumns} + // 컬럼 리사이즈 & 가시성 설정 + columnSettings={templateColumnSettings} // 정렬 설정 (모든 페이지에서 활성화) sortBy={sortBy} sortOrder={sortOrder} diff --git a/src/components/templates/UniversalListPage/types.ts b/src/components/templates/UniversalListPage/types.ts index d4b1c709..8e42315f 100644 --- a/src/components/templates/UniversalListPage/types.ts +++ b/src/components/templates/UniversalListPage/types.ts @@ -413,6 +413,10 @@ export interface UniversalListConfig { /** 검색어 변경 콜백 (config 내부에서 설정, 서버 사이드 검색용) */ onSearchChange?: (search: string) => void; + // ===== 컬럼 리사이즈 & 가시성 설정 ===== + /** 컬럼 설정 비활성화 (기본: false = 자동 활성화) */ + disableColumnSettings?: boolean; + // ===== 커스텀 다이얼로그 슬롯 ===== /** * 커스텀 다이얼로그 렌더링 (DocumentDetailModal, SalaryDetailDialog 등) diff --git a/src/hooks/useColumnSettings.ts b/src/hooks/useColumnSettings.ts new file mode 100644 index 00000000..d7aa9ab3 --- /dev/null +++ b/src/hooks/useColumnSettings.ts @@ -0,0 +1,64 @@ +import { useMemo, useCallback } from 'react'; +import { useTableColumnStore } from '@/stores/useTableColumnStore'; +import type { TableColumn } from '@/components/templates/UniversalListPage/types'; + +export interface ColumnWithVisibility extends TableColumn { + visible: boolean; + locked: boolean; +} + +interface UseColumnSettingsParams { + pageId: string; + columns: TableColumn[]; + alwaysVisibleKeys?: string[]; +} + +export function useColumnSettings({ pageId, columns, alwaysVisibleKeys = [] }: UseColumnSettingsParams) { + const store = useTableColumnStore(); + const settings = store.getPageSettings(pageId); + + const visibleColumns = useMemo(() => { + return columns.filter((col) => !settings.hiddenColumns.includes(col.key)); + }, [columns, settings.hiddenColumns]); + + const allColumnsWithVisibility = useMemo((): ColumnWithVisibility[] => { + return columns.map((col) => ({ + ...col, + visible: !settings.hiddenColumns.includes(col.key), + locked: alwaysVisibleKeys.includes(col.key), + })); + }, [columns, settings.hiddenColumns, alwaysVisibleKeys]); + + const columnWidths = settings.columnWidths; + + const setColumnWidth = useCallback( + (key: string, width: number) => { + store.setColumnWidth(pageId, key, width); + }, + [store, pageId] + ); + + const toggleColumnVisibility = useCallback( + (key: string) => { + if (alwaysVisibleKeys.includes(key)) return; + store.toggleColumnVisibility(pageId, key); + }, + [store, pageId, alwaysVisibleKeys] + ); + + const resetSettings = useCallback(() => { + store.resetPageSettings(pageId); + }, [store, pageId]); + + const hasHiddenColumns = settings.hiddenColumns.length > 0; + + return { + visibleColumns, + allColumnsWithVisibility, + columnWidths, + setColumnWidth, + toggleColumnVisibility, + resetSettings, + hasHiddenColumns, + }; +} diff --git a/src/stores/useTableColumnStore.ts b/src/stores/useTableColumnStore.ts new file mode 100644 index 00000000..e7dc836a --- /dev/null +++ b/src/stores/useTableColumnStore.ts @@ -0,0 +1,101 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import { safeJsonParse } from '@/lib/utils'; + +interface PageColumnSettings { + columnWidths: Record; + hiddenColumns: string[]; +} + +interface TableColumnState { + pageSettings: Record; + setColumnWidth: (pageId: string, columnKey: string, width: number) => void; + toggleColumnVisibility: (pageId: string, columnKey: string) => void; + resetPageSettings: (pageId: string) => void; + getPageSettings: (pageId: string) => PageColumnSettings; +} + +function getUserId(): string { + if (typeof window === 'undefined') return 'default'; + const userStr = localStorage.getItem('user'); + if (!userStr) return 'default'; + const user = safeJsonParse | null>(userStr, null); + return user?.id ? String(user.id) : 'default'; +} + +function getStorageKey(): string { + return `sam-table-columns-${getUserId()}`; +} + +const DEFAULT_PAGE_SETTINGS: PageColumnSettings = { + columnWidths: {}, + hiddenColumns: [], +}; + +export const useTableColumnStore = create()( + persist( + (set, get) => ({ + pageSettings: {}, + + setColumnWidth: (pageId: string, columnKey: string, width: number) => { + const { pageSettings } = get(); + const current = pageSettings[pageId] || { ...DEFAULT_PAGE_SETTINGS }; + set({ + pageSettings: { + ...pageSettings, + [pageId]: { + ...current, + columnWidths: { ...current.columnWidths, [columnKey]: width }, + }, + }, + }); + }, + + toggleColumnVisibility: (pageId: string, columnKey: string) => { + const { pageSettings } = get(); + const current = pageSettings[pageId] || { ...DEFAULT_PAGE_SETTINGS }; + const hidden = current.hiddenColumns.includes(columnKey) + ? current.hiddenColumns.filter((k) => k !== columnKey) + : [...current.hiddenColumns, columnKey]; + set({ + pageSettings: { + ...pageSettings, + [pageId]: { ...current, hiddenColumns: hidden }, + }, + }); + }, + + resetPageSettings: (pageId: string) => { + const { pageSettings } = get(); + const { [pageId]: _, ...rest } = pageSettings; + set({ pageSettings: rest }); + }, + + getPageSettings: (pageId: string) => { + return get().pageSettings[pageId] || DEFAULT_PAGE_SETTINGS; + }, + }), + { + name: 'sam-table-columns', + storage: { + getItem: (name) => { + const key = getStorageKey(); + const str = localStorage.getItem(key); + if (!str) { + const fallback = localStorage.getItem(name); + return fallback ? JSON.parse(fallback) : null; + } + return JSON.parse(str); + }, + setItem: (name, value) => { + const key = getStorageKey(); + localStorage.setItem(key, JSON.stringify(value)); + }, + removeItem: (name) => { + const key = getStorageKey(); + localStorage.removeItem(key); + }, + }, + } + ) +); From 4c4f0678d273012879ab3b79ffcef20ac9d666ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Fri, 20 Feb 2026 23:52:12 +0900 Subject: [PATCH 2/7] =?UTF-8?q?fix(WEB):=20=EC=A0=88=EA=B3=A1=20=EC=9E=91?= =?UTF-8?q?=EC=97=85=EC=9D=BC=EC=A7=80=20=EB=A0=88=EA=B1=B0=EC=8B=9C=20?= =?UTF-8?q?=EC=9D=BC=EC=B9=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 셔터박스 테이블 구성요소 기준 정렬 (파트→길이 순서) - 마구리 무게: 원본 box 크기로 계산 (레거시 일치) - LOT 접두사 제거: 4개 섹션 모두 "-" 표시 - 가이드레일 하부BASE 치수 표시 수정 --- .../documents/bending/BottomBarSection.tsx | 4 +- .../documents/bending/GuideRailSection.tsx | 12 +- .../documents/bending/ShutterBoxSection.tsx | 4 +- .../documents/bending/SmokeBarrierSection.tsx | 2 +- .../WorkOrders/documents/bending/types.ts | 3 +- .../WorkOrders/documents/bending/utils.ts | 170 +++++++++--------- 6 files changed, 94 insertions(+), 101 deletions(-) diff --git a/src/components/production/WorkOrders/documents/bending/BottomBarSection.tsx b/src/components/production/WorkOrders/documents/bending/BottomBarSection.tsx index 0324a415..2ce79584 100644 --- a/src/components/production/WorkOrders/documents/bending/BottomBarSection.tsx +++ b/src/components/production/WorkOrders/documents/bending/BottomBarSection.tsx @@ -55,9 +55,7 @@ export function BottomBarSection({ bendingInfo, mapping }: BottomBarSectionProps
- + ))} diff --git a/src/components/production/WorkOrders/documents/bending/GuideRailSection.tsx b/src/components/production/WorkOrders/documents/bending/GuideRailSection.tsx index bad051fe..4fa924ad 100644 --- a/src/components/production/WorkOrders/documents/bending/GuideRailSection.tsx +++ b/src/components/production/WorkOrders/documents/bending/GuideRailSection.tsx @@ -60,9 +60,7 @@ function PartTable({ title, rows, imageUrl, lotNo, baseSize }: { {row.partName === '하부BASE' ? (baseSize || '-') : fmt(row.length)} - + ))} @@ -79,11 +77,11 @@ export function GuideRailSection({ bendingInfo, mapping, lotNo }: GuideRailSecti const productCode = bendingInfo.productCode; const wallRows = wall - ? buildWallGuideRailRows(wall.lengthData, wall.baseSize, mapping) + ? buildWallGuideRailRows(wall.lengthData, wall.baseDimension || '135*80', mapping) : []; const sideRows = side - ? buildSideGuideRailRows(side.lengthData, mapping) + ? buildSideGuideRailRows(side.lengthData, side.baseDimension || '135*130', mapping) : []; if (wallRows.length === 0 && sideRows.length === 0) return null; @@ -100,7 +98,7 @@ export function GuideRailSection({ bendingInfo, mapping, lotNo }: GuideRailSecti rows={wallRows} imageUrl={getBendingImageUrl('guiderail', productCode, 'wall')} lotNo={lotNo} - baseSize={wall?.baseSize} + baseSize={wall?.baseDimension || wall?.baseSize} /> )} @@ -110,7 +108,7 @@ export function GuideRailSection({ bendingInfo, mapping, lotNo }: GuideRailSecti rows={sideRows} imageUrl={getBendingImageUrl('guiderail', productCode, 'side')} lotNo={lotNo} - baseSize="135*130" + baseSize={side?.baseDimension || '135*130'} /> )} diff --git a/src/components/production/WorkOrders/documents/bending/ShutterBoxSection.tsx b/src/components/production/WorkOrders/documents/bending/ShutterBoxSection.tsx index ee302b98..2db3eff4 100644 --- a/src/components/production/WorkOrders/documents/bending/ShutterBoxSection.tsx +++ b/src/components/production/WorkOrders/documents/bending/ShutterBoxSection.tsx @@ -70,9 +70,7 @@ function ShutterBoxSubSection({ box, index }: { box: ShutterBoxData; index: numb - + ))} diff --git a/src/components/production/WorkOrders/documents/bending/SmokeBarrierSection.tsx b/src/components/production/WorkOrders/documents/bending/SmokeBarrierSection.tsx index 10b02b39..49518a54 100644 --- a/src/components/production/WorkOrders/documents/bending/SmokeBarrierSection.tsx +++ b/src/components/production/WorkOrders/documents/bending/SmokeBarrierSection.tsx @@ -55,7 +55,7 @@ export function SmokeBarrierSection({ bendingInfo }: SmokeBarrierSectionProps) { - + ))} diff --git a/src/components/production/WorkOrders/documents/bending/types.ts b/src/components/production/WorkOrders/documents/bending/types.ts index 91b171ca..f0dd4ed8 100644 --- a/src/components/production/WorkOrders/documents/bending/types.ts +++ b/src/components/production/WorkOrders/documents/bending/types.ts @@ -14,7 +14,8 @@ export interface LengthQuantity { // 가이드레일 타입별 데이터 export interface GuideRailTypeData { lengthData: LengthQuantity[]; - baseSize: string; // "135*80" 또는 "135*130" + baseSize: string; // BOM 프로파일 사이즈 "130*75" (섹션 제목용) + baseDimension?: string; // 실제 하부BASE 물리 치수 "135*130" (작업일지 표시/무게계산용) } // 셔터박스 데이터 diff --git a/src/components/production/WorkOrders/documents/bending/utils.ts b/src/components/production/WorkOrders/documents/bending/utils.ts index ee79d3ca..85152e8d 100644 --- a/src/components/production/WorkOrders/documents/bending/utils.ts +++ b/src/components/production/WorkOrders/documents/bending/utils.ts @@ -27,9 +27,8 @@ const EGI_DENSITY = 7.85; // g/cm3 // 가이드레일 const WALL_PART_WIDTH = 412; // mm - 벽면형 파트 폭 const SIDE_PART_WIDTH = 462; // mm - 측면형 파트 폭 -const WALL_BASE_HEIGHT_MIXED = 80; // 혼합형 벽면 하부BASE 높이 -const WALL_BASE_HEIGHT_ONLY = 130; // 벽면형 단독 하부BASE 높이 -const SIDE_BASE_HEIGHT = 130; // 측면형 하부BASE 높이 +const WALL_BASE_HEIGHT = 80; // 벽면형 하부BASE 높이 (legacy: wall_basesize 135*80) +const SIDE_BASE_HEIGHT = 130; // 측면형 하부BASE 높이 (legacy: side_basesize 135*130) const BASE_WIDTH = 135; // 하부BASE 폭 // 하단마감재 @@ -48,6 +47,21 @@ const GUIDE_RAIL_LENGTH_BUCKETS = [2438, 3000, 3500, 4000, 4300]; const SHUTTER_BOX_LENGTH_BUCKETS = [1219, 2438, 3000, 3500, 4000, 4150]; const SMOKE_W50_LENGTH_BUCKETS = [2438, 3000, 3500, 4000, 4300]; +// ============================================================ +// 하부BASE 치수 파싱 헬퍼 +// ============================================================ + +/** baseDimension 문자열("135*130")에서 width/height 파싱. 없으면 기본값 사용 */ +function parseBaseDimension(baseDimension: string | undefined, fallbackHeight: number): { width: number; height: number } { + if (baseDimension) { + const parts = baseDimension.split('*').map(Number); + if (parts.length === 2 && parts[0] > 0 && parts[1] > 0) { + return { width: parts[0], height: parts[1] }; + } + } + return { width: BASE_WIDTH, height: fallbackHeight }; +} + // ============================================================ // 핵심 함수: calWeight (PHP Lines 27-55) // ============================================================ @@ -165,12 +179,10 @@ export function getSLengthCode(length: number, category: string): string | null */ export function buildWallGuideRailRows( lengthData: LengthQuantity[], - baseSize: string, + baseDimension: string, mapping: MaterialMapping, ): GuideRailPartRow[] { const rows: GuideRailPartRow[] = []; - const baseHeight = baseSize === '135*80' ? WALL_BASE_HEIGHT_MIXED : WALL_BASE_HEIGHT_ONLY; - for (const ld of lengthData) { if (ld.quantity <= 0) continue; @@ -211,9 +223,11 @@ export function buildWallGuideRailRows( } // 하부BASE (길이 데이터와 무관하게 1행) + // baseDimension: "135*130" (KQTS01/KTE01) 또는 "135*80" (기타) const totalQty = lengthData.reduce((sum, ld) => sum + ld.quantity, 0); if (totalQty > 0) { - const baseW = calcWeight('EGI 1.55T', BASE_WIDTH, baseHeight); + const { width: bw, height: bh } = parseBaseDimension(baseDimension, WALL_BASE_HEIGHT); + const baseW = calcWeight('EGI 1.55T', bw, bh); rows.push({ partName: '하부BASE', lotPrefix: 'XX', material: 'EGI 1.55T', length: 0, quantity: totalQty, weight: Math.round(baseW.weight * totalQty * 100) / 100, @@ -228,6 +242,7 @@ export function buildWallGuideRailRows( */ export function buildSideGuideRailRows( lengthData: LengthQuantity[], + baseDimension: string, mapping: MaterialMapping, ): GuideRailPartRow[] { const rows: GuideRailPartRow[] = []; @@ -259,9 +274,11 @@ export function buildSideGuideRailRows( } // 하부BASE + // baseDimension: "135*130" (측면형은 항상 135*130) const totalQty = lengthData.reduce((sum, ld) => sum + ld.quantity, 0); if (totalQty > 0) { - const baseW = calcWeight('EGI 1.55T', BASE_WIDTH, SIDE_BASE_HEIGHT); + const { width: bw, height: bh } = parseBaseDimension(baseDimension, SIDE_BASE_HEIGHT); + const baseW = calcWeight('EGI 1.55T', bw, bh); rows.push({ partName: '하부BASE', lotPrefix: 'XX', material: 'EGI 1.55T', length: 0, quantity: totalQty, weight: Math.round(baseW.weight * totalQty * 100) / 100, @@ -341,93 +358,74 @@ export function buildShutterBoxRows(box: ShutterBoxData): ShutterBoxPartRow[] { const { width: boxWidth, height: boxHeight } = parseBoxSize(box.size); const isStandard = box.size === '500*380'; - for (const ld of box.lengthData) { - if (ld.quantity <= 0) continue; + // 방향별 파트 정의 + let parts: { name: string; prefix: string; dim: number }[]; - if (isStandard) { - // 표준 500*380 구성 - const parts = [ - { name: '①전면부', prefix: 'CF', dim: boxHeight + 122 }, - { name: '②린텔부', prefix: 'CL', dim: boxWidth - 330 }, - { name: '③⑤점검구', prefix: 'CP', dim: boxWidth - 200 }, - { name: '④후면코너부', prefix: 'CB', dim: 170 }, - ]; - for (const p of parts) { - const w = calcWeight(BOX_FINISH_MATERIAL, p.dim, ld.length); - rows.push({ - partName: p.name, lotPrefix: p.prefix, material: BOX_FINISH_MATERIAL, - dimension: `${ld.length}`, quantity: ld.quantity, - weight: Math.round(w.weight * ld.quantity * 100) / 100, - }); - } - } else if (box.direction === '양면') { - const parts = [ - { name: '①전면부', prefix: 'XX', dim: boxHeight + 122 }, - { name: '②린텔부', prefix: 'CL', dim: boxWidth - 330 }, - { name: '③점검구', prefix: 'XX', dim: boxWidth - 200 }, - { name: '④후면코너부', prefix: 'CB', dim: 170 }, - { name: '⑤점검구', prefix: 'XX', dim: boxHeight - 100 }, - ]; - for (const p of parts) { - const w = calcWeight(BOX_FINISH_MATERIAL, p.dim, ld.length); - rows.push({ - partName: p.name, lotPrefix: p.prefix, material: BOX_FINISH_MATERIAL, - dimension: `${ld.length}`, quantity: ld.quantity, - weight: Math.round(w.weight * ld.quantity * 100) / 100, - }); - } - } else if (box.direction === '밑면') { - const parts = [ - { name: '①전면부', prefix: 'XX', dim: boxHeight + 122 }, - { name: '②린텔부', prefix: 'CL', dim: boxWidth - 330 }, - { name: '③점검구', prefix: 'XX', dim: boxWidth - 200 }, - { name: '④후면부', prefix: 'CB', dim: boxHeight + 85 * 2 }, - ]; - for (const p of parts) { - const w = calcWeight(BOX_FINISH_MATERIAL, p.dim, ld.length); - rows.push({ - partName: p.name, lotPrefix: p.prefix, material: BOX_FINISH_MATERIAL, - dimension: `${ld.length}`, quantity: ld.quantity, - weight: Math.round(w.weight * ld.quantity * 100) / 100, - }); - } - } else if (box.direction === '후면') { - const parts = [ - { name: '①전면부', prefix: 'XX', dim: boxHeight + 122 }, - { name: '②린텔부', prefix: 'CL', dim: boxWidth + 85 * 2 }, - { name: '③점검구', prefix: 'XX', dim: boxHeight - 200 }, - { name: '④후면코너부', prefix: 'CB', dim: boxHeight + 85 * 2 }, - ]; - for (const p of parts) { - const w = calcWeight(BOX_FINISH_MATERIAL, p.dim, ld.length); - rows.push({ - partName: p.name, lotPrefix: p.prefix, material: BOX_FINISH_MATERIAL, - dimension: `${ld.length}`, quantity: ld.quantity, - weight: Math.round(w.weight * ld.quantity * 100) / 100, - }); - } + if (isStandard) { + parts = [ + { name: '①전면부', prefix: 'CF', dim: boxHeight + 122 }, + { name: '②린텔부', prefix: 'CL', dim: boxWidth - 330 }, + { name: '③⑤점검구', prefix: 'CP', dim: boxWidth - 200 }, + { name: '④후면코너부', prefix: 'CB', dim: 170 }, + ]; + } else if (box.direction === '양면') { + parts = [ + { name: '①전면부', prefix: 'XX', dim: boxHeight + 122 }, + { name: '②린텔부', prefix: 'CL', dim: boxWidth - 330 }, + { name: '③점검구', prefix: 'XX', dim: boxWidth - 200 }, + { name: '④후면코너부', prefix: 'CB', dim: 170 }, + { name: '⑤점검구', prefix: 'XX', dim: boxHeight - 100 }, + ]; + } else if (box.direction === '밑면') { + parts = [ + { name: '①전면부', prefix: 'XX', dim: boxHeight + 122 }, + { name: '②린텔부', prefix: 'CL', dim: boxWidth - 330 }, + { name: '③점검구', prefix: 'XX', dim: boxWidth - 200 }, + { name: '④후면부', prefix: 'CB', dim: boxHeight + 85 * 2 }, + ]; + } else if (box.direction === '후면') { + parts = [ + { name: '①전면부', prefix: 'XX', dim: boxHeight + 122 }, + { name: '②린텔부', prefix: 'CL', dim: boxWidth + 85 * 2 }, + { name: '③점검구', prefix: 'XX', dim: boxHeight - 200 }, + { name: '④후면코너부', prefix: 'CB', dim: boxHeight + 85 * 2 }, + ]; + } else { + parts = []; + } + + // 구성요소 기준 정렬: 파트 → 길이 순서 + for (const p of parts) { + for (const ld of box.lengthData) { + if (ld.quantity <= 0) continue; + const w = calcWeight(BOX_FINISH_MATERIAL, p.dim, ld.length); + rows.push({ + partName: p.name, lotPrefix: p.prefix, material: BOX_FINISH_MATERIAL, + dimension: `${ld.length}`, quantity: ld.quantity, + weight: Math.round(w.weight * ld.quantity * 100) / 100, + }); } } - // 상부덮개 (비표준일 때) - if (!isStandard && box.coverQty > 0) { + // 상부덮개 + if (box.coverQty > 0) { const coverWidth = boxWidth - 111; const w = calcWeight(BOX_FINISH_MATERIAL, coverWidth, BOX_COVER_LENGTH); rows.push({ - partName: '⑥상부덮개', lotPrefix: 'XX', material: BOX_FINISH_MATERIAL, + partName: isStandard ? '⑤상부덮개' : (box.direction === '양면' ? '⑥상부덮개' : '⑤상부덮개'), + lotPrefix: 'XX', material: BOX_FINISH_MATERIAL, dimension: `1219 * ${coverWidth}`, quantity: box.coverQty, weight: Math.round(w.weight * box.coverQty * 100) / 100, }); } - // 마구리 (비표준일 때) - if (!isStandard && box.finCoverQty > 0) { - const finWidth = boxWidth + 5; - const finHeight = boxHeight + 5; - const w = calcWeight(BOX_FINISH_MATERIAL, finWidth, finHeight); + // 마구리 (레거시: 무게는 원본 box 크기로 계산, 표시 치수만 +5) + if (box.finCoverQty > 0) { + const w = calcWeight(BOX_FINISH_MATERIAL, boxWidth, boxHeight); rows.push({ - partName: '⑦측면부(마구리)', lotPrefix: 'XX', material: BOX_FINISH_MATERIAL, - dimension: `${finWidth} * ${finHeight}`, quantity: box.finCoverQty, + partName: isStandard ? '⑥측면부(마구리)' : (box.direction === '양면' ? '⑦측면부(마구리)' : '⑥측면부(마구리)'), + lotPrefix: 'XX', material: BOX_FINISH_MATERIAL, + dimension: `${boxWidth + 5} * ${boxHeight + 5}`, quantity: box.finCoverQty, weight: Math.round(w.weight * box.finCoverQty * 100) / 100, }); } @@ -493,8 +491,6 @@ export function calculateProductionSummary( // 가이드레일 - 벽면형 if (bendingInfo.guideRail.wall) { - const baseHeight = bendingInfo.guideRail.wall.baseSize === '135*80' - ? WALL_BASE_HEIGHT_MIXED : WALL_BASE_HEIGHT_ONLY; for (const ld of bendingInfo.guideRail.wall.lengthData) { if (ld.quantity <= 0) continue; addWeight(mapping.guideRailFinish, WALL_PART_WIDTH, ld.length, ld.quantity); @@ -504,7 +500,8 @@ export function calculateProductionSummary( } } const totalWallQty = bendingInfo.guideRail.wall.lengthData.reduce((s, l) => s + l.quantity, 0); - addWeight('EGI 1.55T', BASE_WIDTH, baseHeight, totalWallQty); + const wallBase = parseBaseDimension(bendingInfo.guideRail.wall.baseDimension, WALL_BASE_HEIGHT); + addWeight('EGI 1.55T', wallBase.width, wallBase.height, totalWallQty); } // 가이드레일 - 측면형 @@ -515,7 +512,8 @@ export function calculateProductionSummary( addWeight(mapping.bodyMaterial, SIDE_PART_WIDTH, ld.length, ld.quantity * 3); } const totalSideQty = bendingInfo.guideRail.side.lengthData.reduce((s, l) => s + l.quantity, 0); - addWeight('EGI 1.55T', BASE_WIDTH, SIDE_BASE_HEIGHT, totalSideQty); + const sideBase = parseBaseDimension(bendingInfo.guideRail.side.baseDimension, SIDE_BASE_HEIGHT); + addWeight('EGI 1.55T', sideBase.width, sideBase.height, totalSideQty); } // 하단마감재 From 463da040386b682414c40661bcedb7b1e457c807 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Sat, 21 Feb 2026 01:06:34 +0900 Subject: [PATCH 3/7] =?UTF-8?q?chore(WEB):=20.gitignore=EC=97=90=20Serena?= =?UTF-8?q?=20MCP=20=EB=A9=94=EB=AA=A8=EB=A6=AC=20=EB=94=94=EB=A0=89?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 7bd3822f..27e96a6b 100644 --- a/.gitignore +++ b/.gitignore @@ -120,3 +120,6 @@ src/app/**/dev/page-builder/ # ---> Dev Dashboard Prototypes (디자인 프로토타입 - 로컬 전용) src/app/**/dev/dashboard/ + +# ---> Serena MCP memories +.serena/ From 0784b2a40e33cc6e604fb7ade1d343cf1286a184 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Sat, 21 Feb 2026 01:06:48 +0900 Subject: [PATCH 4/7] =?UTF-8?q?fix(WEB):=20=EA=B2=AC=EC=A0=81=20=EA=B0=9C?= =?UTF-8?q?=EC=86=8C=20=EC=9E=85=EB=A0=A5=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F?= =?UTF-8?q?=20BOM=20=EB=B3=80=ED=99=98=20=EC=95=88=EC=A0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 층/부호 필수 검증 제거, 빈값 시 "-" 대체 - DevFill 제품 1개 고정 + 수량 1 고정 (모델별 인증 반영) - note에서 "-" 값 필터링, formula_source 필드 추가 - FG 조회 시 has_bom 필터 제거 --- src/components/dev/generators/quoteData.ts | 24 ++++- src/components/quotes/LocationListPanel.tsx | 8 +- src/components/quotes/QuoteRegistration.tsx | 101 +++++++++++--------- src/components/quotes/actions.ts | 1 - src/components/quotes/types.ts | 17 +++- 5 files changed, 88 insertions(+), 63 deletions(-) diff --git a/src/components/dev/generators/quoteData.ts b/src/components/dev/generators/quoteData.ts index 2ffa3365..15cc4a6e 100644 --- a/src/components/dev/generators/quoteData.ts +++ b/src/components/dev/generators/quoteData.ts @@ -43,13 +43,16 @@ const WRITERS = ['드미트리', '김철수', '이영희', '박지민', '최서 export function generateQuoteFormItem( index: number, products?: Array<{ code: string; name: string; category?: string }>, - category?: string + category?: string, + fixedProductCode?: string ): QuoteFormItem { const selectedCategory = category || randomPick(PRODUCT_CATEGORIES); // 카테고리에 맞는 제품 필터링 let productCode = ''; - if (products && products.length > 0) { + if (fixedProductCode) { + productCode = fixedProductCode; + } else if (products && products.length > 0) { const categoryProducts = products.filter(p => p.category?.toUpperCase() === selectedCategory || !p.category ); @@ -70,7 +73,7 @@ export function generateQuoteFormItem( guideRailType: randomPick(GUIDE_RAIL_TYPES), motorPower: randomPick(MOTOR_POWERS), controller: randomPick(CONTROLLERS), - quantity: randomInt(1, 10), + quantity: 1, wingSize: '50', inspectionFee: 50000, }; @@ -104,11 +107,22 @@ export function generateQuoteData(options: GenerateQuoteDataOptions = {}): Quote // 품목 수 결정 const count = itemCount ?? randomInt(1, 5); - // 품목 생성 (동일 카테고리 사용) + // 제품 1개 고정 선택 (모델별 인증이라 섞을 수 없음) const selectedCategory = category || randomPick(PRODUCT_CATEGORIES); + let fixedProductCode = ''; + if (products && products.length > 0) { + const categoryProducts = products.filter(p => + p.category?.toUpperCase() === selectedCategory || !p.category + ); + if (categoryProducts.length > 0) { + fixedProductCode = randomPick(categoryProducts).code; + } + } + + // 품목 생성 (동일 제품, 수량 1) const items: QuoteFormItem[] = []; for (let i = 0; i < count; i++) { - items.push(generateQuoteFormItem(i, products, selectedCategory)); + items.push(generateQuoteFormItem(i, products, selectedCategory, fixedProductCode)); } return { diff --git a/src/components/quotes/LocationListPanel.tsx b/src/components/quotes/LocationListPanel.tsx index 264b868e..ebb5a7ca 100644 --- a/src/components/quotes/LocationListPanel.tsx +++ b/src/components/quotes/LocationListPanel.tsx @@ -134,10 +134,6 @@ export function LocationListPanel({ // 개소 추가 (BOM 계산 성공 시에만 폼 초기화) const handleAdd = useCallback(async () => { // 유효성 검사 - if (!formData.floor || !formData.code) { - toast.error("층과 부호를 입력해주세요."); - return; - } if (!formData.openWidth || !formData.openHeight) { toast.error("가로와 세로를 입력해주세요."); return; @@ -150,8 +146,8 @@ export function LocationListPanel({ const product = finishedGoods.find((fg) => fg.item_code === formData.productCode); const newLocation: Omit = { - floor: formData.floor, - code: formData.code, + floor: formData.floor || "-", + code: formData.code || "-", openWidth: parseFloat(formData.openWidth) || 0, openHeight: parseFloat(formData.openHeight) || 0, productCode: formData.productCode, diff --git a/src/components/quotes/QuoteRegistration.tsx b/src/components/quotes/QuoteRegistration.tsx index 627fae81..fce69942 100644 --- a/src/components/quotes/QuoteRegistration.tsx +++ b/src/components/quotes/QuoteRegistration.tsx @@ -221,47 +221,49 @@ export function QuoteRegistration({ // DevFill (개발/테스트용 자동 채우기) // --------------------------------------------------------------------------- useDevFill("quoteV2", useCallback(() => { - // BOM이 있는 제품만 필터링 - const productsWithBom = finishedGoods.filter((fg) => fg.has_bom === true || (fg.bom && Array.isArray(fg.bom) && fg.bom.length > 0)); + // 제품 1개 고정 선택 (모델별 인증이라 섞을 수 없음) + const fixedProduct = finishedGoods.length > 0 + ? finishedGoods[Math.floor(Math.random() * finishedGoods.length)] + : null; - // 랜덤 개소 생성 함수 - const createRandomLocation = (index: number): LocationItem => { - const floors = ["B2", "B1", "1F", "2F", "3F", "4F", "5F", "R"]; - const codePrefix = ["SD", "FSS", "FD", "SS", "DS"]; - const guideRailTypes = ["wall", "floor", "mixed"]; - const motorPowers = ["single", "three"]; - const controllers = ["basic", "smart", "premium"]; + // 층 순서 (정렬된 상태로 순차 할당) + const sortedFloors = ["B2", "B1", "1F", "2F", "3F", "4F", "5F", "R"]; + // 부호 접두사 1개 고정 + const codePrefixes = ["SD", "FSS", "FD", "SS", "DS"]; + const fixedPrefix = codePrefixes[Math.floor(Math.random() * codePrefixes.length)]; - const randomFloor = floors[Math.floor(Math.random() * floors.length)]; - const randomPrefix = codePrefix[Math.floor(Math.random() * codePrefix.length)]; - const randomWidth = (Math.floor(Math.random() * 40) + 20) * 100; // 2000~6000 (100단위) - const randomHeight = (Math.floor(Math.random() * 30) + 20) * 100; // 2000~5000 (100단위) - // BOM이 있는 제품 중에서 랜덤 선택 (없으면 전체에서 선택) - const productPool = productsWithBom.length > 0 ? productsWithBom : finishedGoods; - const randomProduct = productPool[Math.floor(Math.random() * productPool.length)]; + const guideRailTypes = ["wall", "floor", "mixed"]; + const motorPowers = ["single", "three"]; + const controllers = ["basic", "smart", "premium"]; - return { - id: `loc-${Date.now()}-${index}`, - floor: randomFloor, - code: `${randomPrefix}-${String(index + 1).padStart(2, "0")}`, + // 1~5개 랜덤 개소 생성 + const locationCount = Math.floor(Math.random() * 5) + 1; + + // 층을 순차 할당할 시작 인덱스 (랜덤 시작점, 순서대로 올라감) + const maxStartIdx = Math.max(0, sortedFloors.length - locationCount); + const floorStartIdx = Math.floor(Math.random() * (maxStartIdx + 1)); + + const testLocations: LocationItem[] = []; + for (let i = 0; i < locationCount; i++) { + const floorIdx = Math.min(floorStartIdx + i, sortedFloors.length - 1); + const randomWidth = (Math.floor(Math.random() * 40) + 20) * 100; + const randomHeight = (Math.floor(Math.random() * 30) + 20) * 100; + + testLocations.push({ + id: `loc-${Date.now()}-${i}`, + floor: sortedFloors[floorIdx], + code: `${fixedPrefix}-${String(i + 1).padStart(2, "0")}`, openWidth: randomWidth, openHeight: randomHeight, - productCode: randomProduct?.item_code || "FG-SCR-001", - productName: randomProduct?.item_name || "방화 스크린 셔터 (소형)", - quantity: Math.floor(Math.random() * 3) + 1, // 1~3 + productCode: fixedProduct?.item_code || "FG-SCR-001", + productName: fixedProduct?.item_name || "방화 스크린 셔터 (소형)", + quantity: 1, guideRailType: guideRailTypes[Math.floor(Math.random() * guideRailTypes.length)], motorPower: motorPowers[Math.floor(Math.random() * motorPowers.length)], controller: controllers[Math.floor(Math.random() * controllers.length)], wingSize: [50, 60, 70][Math.floor(Math.random() * 3)], inspectionFee: [50000, 60000, 70000][Math.floor(Math.random() * 3)], - }; - }; - - // 1~5개 랜덤 개소 생성 - const locationCount = Math.floor(Math.random() * 5) + 1; - const testLocations: LocationItem[] = []; - for (let i = 0; i < locationCount; i++) { - testLocations.push(createRandomLocation(i)); + }); } // 로그인 사용자 정보 가져오기 @@ -511,29 +513,36 @@ export function QuoteRegistration({ const source = formData.locations.find((loc) => loc.id === locationId); if (!source) return; - // 부호에서 접두어와 번호 분리 (예: "DS-01" → prefix="DS-", num=1) - const codeMatch = source.code.match(/^(.*?)(\d+)$/); - let newCode = source.code + "-copy"; + // 층/부호가 없거나 "-"이면 그대로 유지 + let newFloor = source.floor || "-"; + let newCode = source.code || "-"; - if (codeMatch) { - const prefix = codeMatch[1]; // "DS-" - const numLength = codeMatch[2].length; // 2 (자릿수 보존) + if (newCode !== "-") { + // 부호에서 접두어와 번호 분리 (예: "DS-01" → prefix="DS-", num=1) + const codeMatch = source.code.match(/^(.*?)(\d+)$/); + if (codeMatch) { + const prefix = codeMatch[1]; // "DS-" + const numLength = codeMatch[2].length; // 2 (자릿수 보존) - // 같은 접두어를 가진 부호 중 최대 번호 찾기 - let maxNum = 0; - formData.locations.forEach((loc) => { - const m = loc.code.match(new RegExp(`^${prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\d+)$`)); - if (m) { - maxNum = Math.max(maxNum, parseInt(m[1], 10)); - } - }); + // 같은 접두어를 가진 부호 중 최대 번호 찾기 + let maxNum = 0; + formData.locations.forEach((loc) => { + const m = loc.code.match(new RegExp(`^${prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\d+)$`)); + if (m) { + maxNum = Math.max(maxNum, parseInt(m[1], 10)); + } + }); - newCode = prefix + String(maxNum + 1).padStart(numLength, "0"); + newCode = prefix + String(maxNum + 1).padStart(numLength, "0"); + } else { + newCode = source.code + "-copy"; + } } const clonedLocation: LocationItem = { ...source, id: `loc-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + floor: newFloor, code: newCode, }; diff --git a/src/components/quotes/actions.ts b/src/components/quotes/actions.ts index 3ca7e558..d14f2d57 100644 --- a/src/components/quotes/actions.ts +++ b/src/components/quotes/actions.ts @@ -309,7 +309,6 @@ export async function getFinishedGoods(category?: string): Promise<{ const result = await executeServerAction[]>({ url: buildApiUrl('/api/v1/items', { item_type: 'FG', - has_bom: '1', item_category: category, size: '5000', }), diff --git a/src/components/quotes/types.ts b/src/components/quotes/types.ts index 27b07f69..373de7b7 100644 --- a/src/components/quotes/types.ts +++ b/src/components/quotes/types.ts @@ -766,6 +766,7 @@ export function transformV2ToApi( total_price: number; sort_order: number; note: string | null; + formula_source?: string; item_index?: number; finished_goods_code?: string; formula_category?: string; @@ -796,7 +797,8 @@ export function transformV2ToApi( unit_price: bomItem.unit_price, total_price: bomItem.unit_price * calcQty, sort_order: sortOrder++, - note: `${loc?.floor || ''} ${loc?.code || ''}`.trim() || null, + note: [loc?.floor, loc?.code].filter(v => v && v !== '-').join(' ') || null, + formula_source: `product_${locIndex}`, item_index: locIndex, finished_goods_code: bomResult.finished_goods.code, formula_category: bomItem.process_group || undefined, @@ -827,7 +829,8 @@ export function transformV2ToApi( unit_price: bomItem.unit_price, total_price: bomItem.unit_price * calcQty, sort_order: sortOrder++, - note: `${loc.floor || ''} ${loc.code || ''}`.trim() || null, + note: [loc.floor, loc.code].filter(v => v && v !== '-').join(' ') || null, + formula_source: `product_${locIndex}`, item_index: locIndex, finished_goods_code: loc.bomResult!.finished_goods.code, formula_category: bomItem.process_group || undefined, @@ -850,7 +853,8 @@ export function transformV2ToApi( unit_price: loc.unitPrice || loc.inspectionFee || 0, total_price: loc.totalPrice || (loc.unitPrice || loc.inspectionFee || 0) * loc.quantity, sort_order: index + 1, - note: `${loc.floor} ${loc.code}`.trim() || null, + note: [loc.floor, loc.code].filter(v => v && v !== '-').join(' ') || null, + formula_source: `product_${index}`, })); } @@ -1027,6 +1031,7 @@ export function transformFormDataToApi(formData: QuoteFormData): Record v && v !== '-').join(' ') || null, + formula_source: `product_${calcItem.index}`, item_index: calcItem.index, finished_goods_code: calcItem.result.finished_goods.code, formula_category: bomItem.process_group || undefined, @@ -1084,7 +1090,8 @@ export function transformFormDataToApi(formData: QuoteFormData): Record v && v !== '-').join(' ') || null, + formula_source: `product_${index}`, }; }); } From f35df29264ef15a7863a1687260c1c6bc7b6b36c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Sat, 21 Feb 2026 01:06:55 +0900 Subject: [PATCH 5/7] =?UTF-8?q?fix(WEB):=20=EC=88=98=EC=A3=BC=20=EA=B0=9C?= =?UTF-8?q?=EC=86=8C=20=EA=B7=B8=EB=A3=B9=ED=95=91=20=EA=B0=9C=EC=84=A0=20?= =?UTF-8?q?=EB=B0=8F=20=EC=A0=9C=ED=92=88=EB=AA=85=20=ED=91=9C=EC=8B=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - floor+code 동일 시 인덱스 기반 균등 분배 로직 추가 - 제품명을 root_nodes[0].options.product_name에서 가져오도록 변경 --- src/components/orders/OrderRegistration.tsx | 44 ++++++++++++++++++++- src/components/orders/actions.ts | 4 +- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/src/components/orders/OrderRegistration.tsx b/src/components/orders/OrderRegistration.tsx index ccc09b3d..4a373b99 100644 --- a/src/components/orders/OrderRegistration.tsx +++ b/src/components/orders/OrderRegistration.tsx @@ -234,13 +234,55 @@ export function OrderRegistration({ }, []) ); - // 아이템을 개소별(floor+code)로 그룹핑 + // 아이템을 개소별로 그룹핑 const itemGroups = useMemo(() => { const calcItems = form.selectedQuotation?.calculationInputs?.items; if (!calcItems || calcItems.length === 0) { return null; } + // floor+code 고유 키 수 확인 (모두 같은 값인지 판별) + const uniqueLocKeys = new Set( + calcItems.map(ci => `${ci.floor || '-'}|${ci.code || '-'}`) + ); + + // 개소가 여러 개인데 floor+code가 모두 동일 → 인덱스 기반 균등 분배 + const useIndexGrouping = calcItems.length > 1 && uniqueLocKeys.size === 1; + + if (useIndexGrouping) { + const itemsPerLocation = Math.ceil(form.items.length / calcItems.length); + const result: Array<{ + key: string; + label: string; + productCode: string; + locationCount: number; + quantity: number; + amount: number; + items: OrderItem[]; + }> = []; + + calcItems.forEach((ci, idx) => { + const start = idx * itemsPerLocation; + const end = Math.min(start + itemsPerLocation, form.items.length); + const groupItems = form.items.slice(start, end); + const amount = groupItems.reduce((sum, item) => sum + (item.amount ?? 0), 0); + const floor = ci.floor || '-'; + const code = ci.code || '-'; + + result.push({ + key: `loc_${idx}`, + label: `${idx + 1}. ${floor} / ${code}`, + productCode: ci.productName || ci.productCode || '', + locationCount: 1, + quantity: ci.quantity ?? 1, + amount, + items: groupItems, + }); + }); + + return result; + } + // floor+code → calculationInput 매핑 (개소 메타정보) const locationMetaMap = new Map Date: Sat, 21 Feb 2026 01:07:02 +0900 Subject: [PATCH 6/7] =?UTF-8?q?fix(WEB):=20=EC=9E=91=EC=97=85=EC=A7=80?= =?UTF-8?q?=EC=8B=9C=20=EA=B0=9C=EC=86=8C=20=EA=B7=A0=EB=93=B1=20=EB=B6=84?= =?UTF-8?q?=EB=B0=B0=20+=20=EC=9E=91=EC=97=85=EC=9D=BC=EC=A7=80/=EC=9E=91?= =?UTF-8?q?=EC=97=85=EC=9E=90=20=ED=99=94=EB=A9=B4=20=EA=B8=80=EC=9E=90=20?= =?UTF-8?q?=ED=81=AC=EA=B8=B0=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WorkOrderDetail 동일 그룹 시 인덱스 기반 균등 분배 로직 추가 - ScreenWorkLogContent LOT/품명 text-[10px] 제거 - WorkOrderListPanel 품목명 text-xs → text-sm --- .../production/WorkOrders/WorkOrderDetail.tsx | 35 +++++++++++++++---- .../documents/ScreenWorkLogContent.tsx | 4 +-- .../WorkerScreen/WorkOrderListPanel.tsx | 2 +- 3 files changed, 32 insertions(+), 9 deletions(-) diff --git a/src/components/production/WorkOrders/WorkOrderDetail.tsx b/src/components/production/WorkOrders/WorkOrderDetail.tsx index b381992d..f1968bf1 100644 --- a/src/components/production/WorkOrders/WorkOrderDetail.tsx +++ b/src/components/production/WorkOrders/WorkOrderDetail.tsx @@ -513,13 +513,36 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) { {(() => { // 개소(층/부호)별로 그룹화 const nodeGroups = new Map(); - for (const item of order.items) { - const key = item.floorCode !== '-' ? item.floorCode : (item.orderNodeId != null ? String(item.orderNodeId) : 'none'); - const label = item.floorCode !== '-' ? item.floorCode : item.orderNodeName; - if (!nodeGroups.has(key)) { - nodeGroups.set(key, { label, items: [] }); + + // 모든 아이템이 동일 그룹으로 들어가는지 확인 + const uniqueKeys = new Set(order.items.map(item => + item.floorCode !== '-' ? item.floorCode : String(item.orderNodeId ?? 'none') + )); + const allSameGroup = uniqueKeys.size <= 1; + const locationCount = order.shutterCount || 1; + + if (allSameGroup && locationCount > 1) { + // 인덱스 기반 균등 분배 + const itemsPerLoc = Math.ceil(order.items.length / locationCount); + for (let loc = 0; loc < locationCount; loc++) { + const start = loc * itemsPerLoc; + const end = Math.min(start + itemsPerLoc, order.items.length); + if (start >= order.items.length) break; + const key = `loc-${loc}`; + nodeGroups.set(key, { + label: `개소 ${loc + 1}`, + items: order.items.slice(start, end), + }); + } + } else { + for (const item of order.items) { + const key = item.floorCode !== '-' ? item.floorCode : (item.orderNodeId != null ? String(item.orderNodeId) : 'none'); + const label = item.floorCode !== '-' ? item.floorCode : item.orderNodeName; + if (!nodeGroups.has(key)) { + nodeGroups.set(key, { label, items: [] }); + } + nodeGroups.get(key)!.items.push(item); } - nodeGroups.get(key)!.items.push(item); } const rows: React.ReactNode[] = []; diff --git a/src/components/production/WorkOrders/documents/ScreenWorkLogContent.tsx b/src/components/production/WorkOrders/documents/ScreenWorkLogContent.tsx index e7064a17..5f282fb3 100644 --- a/src/components/production/WorkOrders/documents/ScreenWorkLogContent.tsx +++ b/src/components/production/WorkOrders/documents/ScreenWorkLogContent.tsx @@ -271,8 +271,8 @@ export function ScreenWorkLogContent({ data: order, materialLots = [] }: ScreenW return ( - - + + diff --git a/src/components/production/WorkerScreen/WorkOrderListPanel.tsx b/src/components/production/WorkerScreen/WorkOrderListPanel.tsx index 8ce2b26b..e9f0a881 100644 --- a/src/components/production/WorkerScreen/WorkOrderListPanel.tsx +++ b/src/components/production/WorkerScreen/WorkOrderListPanel.tsx @@ -75,7 +75,7 @@ export function WorkOrderListPanel({ {/* 품목명 */} -

{order.productName}

+

{order.productName}

{/* 현장명 + 수량 */}
From 51da042beb677030b71a359b17f580bce070631d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Sat, 21 Feb 2026 01:07:06 +0900 Subject: [PATCH 7/7] =?UTF-8?q?docs(WEB):=20CURRENT=5FWORKS.md=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20API=20=EC=BB=A4=EB=B0=8B=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CURRENT_WORKS.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CURRENT_WORKS.md b/CURRENT_WORKS.md index ed457cbd..ea34a647 100644 --- a/CURRENT_WORKS.md +++ b/CURRENT_WORKS.md @@ -40,6 +40,9 @@ - ✅ 견적 화면에서 조인트바 BOM 표시 확인 완료 - ⬜ 절곡 실 데이터 테스트 (bending_info 채워진 작업지시로 확인) +### 관련 API 커밋 +- `23029b1` (api) fix: 작업지시 단건조회(show)에 materialInputs eager loading 추가 + ### 관련 문서 - `docs/plans/bending-worklog-reimplementation-plan.md` (✅ 완료)
{row.material} {fmt(row.length)} {fmt(row.quantity)} - {row.lotPrefix}- - - {fmtWeight(row.weight)}
{fmt(row.quantity)} - {row.lotPrefix}- - - {fmtWeight(row.weight)}
{row.material} {row.dimension} {fmt(row.quantity)} - {row.lotPrefix}- - - {fmtWeight(row.weight)}
{row.material} {fmt(row.length)} {fmt(row.quantity)}{row.lotCode}- {fmtWeight(row.weight)}
{idx + 1}{lotNoDisplay}{item.productName}{lotNoDisplay}{item.productName} {getSymbolCode(item.floorCode)} {fmt(item.width)} {fmt(item.height)}