From 5af650067140c315782d31f7abdf38ab59bf2088 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Wed, 4 Feb 2026 11:07:52 +0900 Subject: [PATCH] =?UTF-8?q?feat(WEB):=20=EA=B2=AC=EC=A0=81=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=EC=82=B0=EC=B6=9C=20UI=20=EA=B0=9C=EC=84=A0=20-=20dat?= =?UTF-8?q?alist,=20=ED=83=AD=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC,=20?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 층/부호 필드에 datalist 자동완성 추가 (B3~10F, 기존 부호코드) - 부호 데이터 조회 API 신규 추가 (GET /quotes/reference-data) - 제품코드 Select에 코드만 표시 (중복 제거) - LocationDetailPanel 탭을 subtotals 기반으로 동적 생성 + 기타 탭 - grouped_items 기반 탭별 품목 매핑으로 카테고리 불일치 해소 - QuoteSummaryPanel 상세별 합계 스크롤 제거 - BomCalculationResult 타입에 grouped_items 필드 추가 --- src/components/quotes/LocationDetailPanel.tsx | 277 +++++++----------- src/components/quotes/LocationListPanel.tsx | 25 +- src/components/quotes/QuoteRegistrationV2.tsx | 12 +- src/components/quotes/QuoteSummaryPanel.tsx | 2 +- src/components/quotes/actions.ts | 110 ++++--- src/components/quotes/types.ts | 1 + 6 files changed, 211 insertions(+), 216 deletions(-) diff --git a/src/components/quotes/LocationDetailPanel.tsx b/src/components/quotes/LocationDetailPanel.tsx index ffd7d05c..7efcf2c9 100644 --- a/src/components/quotes/LocationDetailPanel.tsx +++ b/src/components/quotes/LocationDetailPanel.tsx @@ -3,18 +3,16 @@ * * - 제품 정보 (제품명, 오픈사이즈, 제작사이즈, 산출중량, 산출면적, 수량) * - 필수 설정 (가이드레일, 전원, 제어기) - * - 탭: 본체(스크린/슬랫), 절곡품-가이드레일, 절곡품-케이스, 절곡품-하단마감재, 모터&제어기, 부자재 - * - 탭별 품목 테이블 (각 탭마다 다른 컬럼 구조) + * - 탭: BOM subtotals 기반 동적 생성 (주자재, 모터, 제어기, 절곡품, 부자재, 검사비, 기타) + * - 탭별 품목 테이블 (grouped_items 기반) */ "use client"; import { useState, useMemo, useEffect } from "react"; -import { Package, Settings, Plus, Trash2, Loader2, Calculator, Save } from "lucide-react"; -import { getItemCategoryTree, type ItemCategoryNode } from "./actions"; +import { Package, Plus, Trash2, Loader2, Calculator, Save } from "lucide-react"; import { fetchItemPrices } from "@/lib/api/items"; -import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { NumberInput } from "../ui/number-input"; @@ -62,6 +60,13 @@ function createEmptyBomItems(tabs: TabDefinition[]): Record { - if (category.code === "BENDING") { - // 절곡품: 하위 카테고리를 탭으로 변환 - category.children.forEach((subCategory) => { - tabs.push({ - value: subCategory.code, - label: `절곡품 - ${subCategory.name}`, - categoryId: subCategory.id, - parentCode: category.code, - }); - }); - } else { - // 본체, 모터&제어기, 부자재: 1depth 탭 - tabs.push({ - value: category.code, - label: category.name, - categoryId: category.id, - }); - } - }); - - return tabs; -} - -// 기본 탭 정의 (API 로딩 전 또는 실패 시 fallback) +// 기본 탭 정의 (BOM 계산 전 fallback) const DEFAULT_TABS: TabDefinition[] = [ - { value: "BODY", label: "본체", categoryId: 0 }, - { value: "BENDING_GUIDE", label: "절곡품 - 가이드레일", categoryId: 0, parentCode: "BENDING" }, - { value: "BENDING_CASE", label: "절곡품 - 케이스", categoryId: 0, parentCode: "BENDING" }, - { value: "BENDING_BOTTOM", label: "절곡품 - 하단마감재", categoryId: 0, parentCode: "BENDING" }, - { value: "MOTOR_CTRL", label: "모터 & 제어기", categoryId: 0 }, - { value: "ACCESSORY", label: "부자재", categoryId: 0 }, + { value: "material", label: "주자재" }, + { value: "motor", label: "모터" }, + { value: "controller", label: "제어기" }, + { value: "steel", label: "절곡품" }, + { value: "parts", label: "부자재" }, + { value: "inspection", label: "검사비" }, + { value: "etc", label: "기타" }, ]; // ============================================================================= @@ -142,6 +114,7 @@ interface LocationDetailPanelProps { onCalculateLocation?: (locationId: string) => Promise; onSaveItems?: () => void; finishedGoods: FinishedGoods[]; + locationCodes?: string[]; disabled?: boolean; isCalculating?: boolean; } @@ -157,51 +130,49 @@ export function LocationDetailPanel({ onCalculateLocation, onSaveItems, finishedGoods, + locationCodes = [], disabled = false, isCalculating = false, }: LocationDetailPanelProps) { // --------------------------------------------------------------------------- // 상태 // --------------------------------------------------------------------------- - - const [activeTab, setActiveTab] = useState("BODY"); + const [activeTab, setActiveTab] = useState("material"); const [itemSearchOpen, setItemSearchOpen] = useState(false); - const [itemCategories, setItemCategories] = useState([]); - const [categoriesLoading, setCategoriesLoading] = useState(true); // --------------------------------------------------------------------------- - // 카테고리 로드 + // 탭 정의 (bomResult.subtotals 기반 동적 생성 + 기타 탭) // --------------------------------------------------------------------------- - useEffect(() => { - async function loadCategories() { - try { - setCategoriesLoading(true); - const result = await getItemCategoryTree(); - if (result.success && result.data) { - setItemCategories(result.data); - } else { - console.error("[카테고리 로드 실패]", result.error); - } - } catch (error) { - console.error("[카테고리 로드 에러]", error); - } finally { - setCategoriesLoading(false); - } - } - loadCategories(); - }, []); - - // --------------------------------------------------------------------------- - // 탭 정의 (카테고리 기반 동적 생성) - // --------------------------------------------------------------------------- - - const detailTabs = useMemo(() => { - if (itemCategories.length === 0) { + const detailTabs = useMemo((): TabDefinition[] => { + if (!location?.bomResult?.subtotals) { return DEFAULT_TABS; } - return convertCategoryTreeToTabs(itemCategories); - }, [itemCategories]); + + const subtotals = location.bomResult.subtotals; + const tabs: TabDefinition[] = []; + + Object.entries(subtotals).forEach(([key, value]) => { + if (typeof value === "object" && value !== null) { + tabs.push({ + value: key, + label: value.name || key, + }); + } + }); + + // 기타 탭 추가 + tabs.push({ value: "etc", label: "기타" }); + + return tabs; + }, [location?.bomResult?.subtotals]); + + // location 변경 시 activeTab이 유효한 탭인지 확인하고 리셋 + useEffect(() => { + if (detailTabs.length > 0 && !detailTabs.some((t) => t.value === activeTab)) { + setActiveTab(detailTabs[0].value); + } + }, [location?.id, detailTabs]); // --------------------------------------------------------------------------- // 계산된 값 @@ -213,97 +184,56 @@ export function LocationDetailPanel({ return finishedGoods.find((fg) => fg.item_code === location.productCode); }, [location?.productCode, finishedGoods]); - // BOM 아이템을 탭별로 분류 (카테고리 코드 기반) + // BOM 아이템을 탭별로 분류 (grouped_items 기반) const bomItemsByTab = useMemo(() => { - // bomResult가 없으면 빈 배열 반환 - if (!location?.bomResult?.items) { + if (!location?.bomResult?.grouped_items) { return createEmptyBomItems(detailTabs); } - const items = location.bomResult.items; - const result: Record = {}; + const groupedItems = location.bomResult.grouped_items; + const result: Record = {}; // 탭별 빈 배열 초기화 detailTabs.forEach((tab) => { result[tab.value] = []; }); - // 카테고리 코드 → 탭 value 매핑 생성 - // (절곡품 하위 카테고리 매핑 포함) - const categoryCodeToTab: Record = {}; - itemCategories.forEach((category) => { - if (category.code === "BENDING") { - // 절곡품: 하위 카테고리별로 탭 매핑 - category.children.forEach((subCategory) => { - categoryCodeToTab[subCategory.code] = subCategory.code; - // 하위의 세부 카테고리도 매핑 - subCategory.children?.forEach((detailCategory) => { - categoryCodeToTab[detailCategory.code] = subCategory.code; - }); - }); + // grouped_items에서 각 카테고리의 items를 탭에 매핑 + Object.entries(groupedItems).forEach(([key, group]) => { + const items = (group as { items?: unknown[] })?.items || []; + if (result[key]) { + result[key] = items as BomCalculationResultItem[]; } else { - // 일반 카테고리 - categoryCodeToTab[category.code] = category.code; - // 하위 카테고리도 상위 탭에 매핑 - category.children?.forEach((child) => { - categoryCodeToTab[child.code] = category.code; - }); + // subtotals에 없는 카테고리 → 기타에 추가 + result["etc"] = [ + ...(result["etc"] || []), + ...(items as BomCalculationResultItem[]), + ]; } }); - items.forEach((item) => { - // 1. category_code로 분류 (우선) - const categoryCode = (item as { category_code?: string }).category_code?.toUpperCase() || ""; - const mappedTab = categoryCodeToTab[categoryCode]; - - if (mappedTab && result[mappedTab]) { - result[mappedTab].push(item); - return; - } - - // 2. process_group_key로 분류 (legacy fallback) - const processGroupKey = (item as { process_group_key?: string }).process_group_key?.toUpperCase() || ""; - - // 기존 process_group_key → 새 카테고리 코드 매핑 - const legacyMapping: Record = { - "SCREEN": "BODY", - "ASSEMBLY": "BODY", - "BENDING": "BENDING_GUIDE", // 기본적으로 가이드레일에 배치 - "STEEL": "BENDING_CASE", - "ELECTRIC": "BENDING_BOTTOM", - "MOTOR": "MOTOR_CTRL", - "ACCESSORY": "ACCESSORY", - }; - - const legacyTab = legacyMapping[processGroupKey]; - if (legacyTab && result[legacyTab]) { - result[legacyTab].push(item); - return; - } - - // 3. process_group (한글명) 기반 분류 (최종 fallback) - const processGroup = item.process_group?.toLowerCase() || ""; - - if (processGroup.includes("본체") || processGroup.includes("스크린") || processGroup.includes("슬랫") || processGroup.includes("조립")) { - result["BODY"]?.push(item); - } else if (processGroup.includes("가이드") || processGroup.includes("레일")) { - result["BENDING_GUIDE"]?.push(item); - } else if (processGroup.includes("케이스") || processGroup.includes("철재")) { - result["BENDING_CASE"]?.push(item); - } else if (processGroup.includes("하단") || processGroup.includes("마감")) { - result["BENDING_BOTTOM"]?.push(item); - } else if (processGroup.includes("모터") || processGroup.includes("제어기")) { - result["MOTOR_CTRL"]?.push(item); - } else if (processGroup.includes("부자재")) { - result["ACCESSORY"]?.push(item); + // 수동 추가 품목 (is_manual) 중 grouped_items에 없는 것 → 해당 탭 or 기타 + const allItems = location.bomResult.items || []; + const manualItems = allItems.filter( + (item: BomCalculationResultItem & { is_manual?: boolean }) => item.is_manual === true + ); + manualItems.forEach((item: BomCalculationResultItem & { category_code?: string }) => { + const tabKey = item.category_code || "etc"; + if (result[tabKey]) { + // 이미 grouped_items에서 추가되었는지 확인 + const exists = result[tabKey].some( + (existing) => existing.item_code === item.item_code && existing.item_name === item.item_name + ); + if (!exists) { + result[tabKey].push(item); + } } else { - // 기타 항목은 본체에 포함 - result["BODY"]?.push(item); + result["etc"] = [...(result["etc"] || []), item]; } }); return result; - }, [location?.bomResult?.items, detailTabs, itemCategories]); + }, [location?.bomResult?.grouped_items, location?.bomResult?.items, detailTabs]); // 탭별 소계 const tabSubtotals = useMemo(() => { @@ -351,20 +281,32 @@ export function LocationDetailPanel({
handleFieldChange("floor", e.target.value)} disabled={disabled} className="h-8 text-sm" /> + + {FLOOR_OPTIONS.map((f) => ( +
handleFieldChange("code", e.target.value)} disabled={disabled} className="h-8 text-sm" /> + + {locationCodes.map((code) => ( +
@@ -403,7 +345,7 @@ export function LocationDetailPanel({ {finishedGoods.map((fg) => ( - {fg.item_code} {fg.item_name} + {fg.item_code} ))} @@ -550,30 +492,23 @@ export function LocationDetailPanel({ {/* 탭 목록 */}
- {categoriesLoading ? ( -
- - 카테고리 로딩 중... -
- ) : ( - - {detailTabs.map((tab) => ( - - {tab.label} - - ))} - - )} + + {detailTabs.map((tab) => ( + + {tab.label} + + ))} +
{/* 탭 콘텐츠 */} {detailTabs.map((tab) => { const items = bomItemsByTab[tab.value] || []; - const isBendingTab = tab.parentCode === "BENDING"; + const isBendingTab = tab.value === "steel"; return ( diff --git a/src/components/quotes/LocationListPanel.tsx b/src/components/quotes/LocationListPanel.tsx index 7df9ae89..223c35a7 100644 --- a/src/components/quotes/LocationListPanel.tsx +++ b/src/components/quotes/LocationListPanel.tsx @@ -41,6 +41,13 @@ import * as XLSX from "xlsx"; // 상수 // ============================================================================= +// 층 옵션 (지하3층 ~ 지상10층) +const FLOOR_OPTIONS = [ + "B3", "B2", "B1", + "1F", "2F", "3F", "4F", "5F", "6F", "7F", "8F", "9F", "10F", + "R", +]; + // 가이드레일 설치 유형 const GUIDE_RAIL_TYPES = [ { value: "wall", label: "벽면형" }, @@ -73,6 +80,7 @@ interface LocationListPanelProps { onUpdateLocation: (locationId: string, updates: Partial) => void; onExcelUpload: (locations: Omit[]) => void; finishedGoods: FinishedGoods[]; + locationCodes?: string[]; disabled?: boolean; } @@ -89,6 +97,7 @@ export function LocationListPanel({ onUpdateLocation, onExcelUpload, finishedGoods, + locationCodes = [], disabled = false, }: LocationListPanelProps) { // --------------------------------------------------------------------------- @@ -279,20 +288,32 @@ export function LocationListPanel({
handleFormChange("floor", e.target.value)} className="h-8 text-sm placeholder:text-gray-300" /> + + {FLOOR_OPTIONS.map((f) => ( +
handleFormChange("code", e.target.value)} className="h-8 text-sm placeholder:text-gray-300" /> + + {locationCodes.map((code) => ( +
@@ -324,7 +345,7 @@ export function LocationListPanel({ {finishedGoods.map((fg) => ( - {fg.item_code} {fg.item_name} + {fg.item_code} ))} diff --git a/src/components/quotes/QuoteRegistrationV2.tsx b/src/components/quotes/QuoteRegistrationV2.tsx index 078c998f..4ec25b7a 100644 --- a/src/components/quotes/QuoteRegistrationV2.tsx +++ b/src/components/quotes/QuoteRegistrationV2.tsx @@ -40,7 +40,7 @@ import { FormulaViewModal } from "./FormulaViewModal"; import { getFinishedGoods, calculateBomBulk, - getSiteNames, + getQuoteReferenceData, type FinishedGoods, type BomCalculationResult, type BomBulkResponse, @@ -194,6 +194,7 @@ export function QuoteRegistrationV2({ const [clients, setClients] = useState([]); const [finishedGoods, setFinishedGoods] = useState([]); const [siteNames, setSiteNames] = useState([]); + const [locationCodes, setLocationCodes] = useState([]); const [isLoadingClients, setIsLoadingClients] = useState(false); const [isLoadingProducts, setIsLoadingProducts] = useState(false); @@ -391,11 +392,12 @@ export function QuoteRegistrationV2({ setIsLoadingProducts(false); } - // 현장명 로드 + // 참조 데이터 로드 (현장명, 부호) try { - const result = await getSiteNames(); + const result = await getQuoteReferenceData(); if (result.success) { - setSiteNames(result.data); + setSiteNames(result.data.siteNames); + setLocationCodes(result.data.locationCodes); } } catch (error) { if (isNextRedirectError(error)) throw error; @@ -841,6 +843,7 @@ export function QuoteRegistrationV2({ onUpdateLocation={handleUpdateLocation} onExcelUpload={handleExcelUpload} finishedGoods={finishedGoods} + locationCodes={locationCodes} disabled={isViewMode} /> @@ -849,6 +852,7 @@ export function QuoteRegistrationV2({ location={selectedLocation} onUpdateLocation={handleUpdateLocation} onDeleteLocation={handleDeleteLocation} + locationCodes={locationCodes} onCalculateLocation={async (locationId) => { // 단일 개소 산출 const location = formData.locations.find((loc) => loc.id === locationId); diff --git a/src/components/quotes/QuoteSummaryPanel.tsx b/src/components/quotes/QuoteSummaryPanel.tsx index 297df8f8..f286f4ac 100644 --- a/src/components/quotes/QuoteSummaryPanel.tsx +++ b/src/components/quotes/QuoteSummaryPanel.tsx @@ -211,7 +211,7 @@ export function QuoteSummaryPanel({

견적 산출 후 상세 금액이 표시됩니다

) : ( -
+
{detailTotals.map((category, index) => (
{/* 카테고리 헤더 */} diff --git a/src/components/quotes/actions.ts b/src/components/quotes/actions.ts index b4f795eb..c7eac8b5 100644 --- a/src/components/quotes/actions.ts +++ b/src/components/quotes/actions.ts @@ -1164,50 +1164,84 @@ export async function getQuotesSummary(params?: { } } -// ===== 현장명 목록 조회 (자동완성용) ===== +// ===== 견적 참조 데이터 조회 (현장명, 부호 목록) ===== +export interface QuoteReferenceData { + siteNames: string[]; + locationCodes: string[]; +} + +export async function getQuoteReferenceData(): Promise<{ + success: boolean; + data: QuoteReferenceData; + error?: string; + __authError?: boolean; +}> { + try { + const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/quotes/reference-data`; + + const { response, error } = await serverFetch(url, { + method: 'GET', + }); + + if (error) { + return { + success: false, + data: { siteNames: [], locationCodes: [] }, + error: error.message, + __authError: error.code === 'UNAUTHORIZED', + }; + } + + if (!response?.ok) { + return { + success: false, + data: { siteNames: [], locationCodes: [] }, + error: `API 오류: ${response?.status}`, + }; + } + + const result = await response.json(); + + if (!result.success) { + return { + success: false, + data: { siteNames: [], locationCodes: [] }, + error: result.message || '참조 데이터 조회 실패', + }; + } + + return { + success: true, + data: { + siteNames: result.data?.site_names || [], + locationCodes: result.data?.location_codes || [], + }, + }; + } catch (error) { + if (isNextRedirectError(error)) throw error; + console.error('[QuoteActions] getQuoteReferenceData error:', error); + return { + success: false, + data: { siteNames: [], locationCodes: [] }, + error: '서버 오류가 발생했습니다.', + }; + } +} + +/** @deprecated getQuoteReferenceData 사용 */ export async function getSiteNames(): Promise<{ success: boolean; data: string[]; error?: string; __authError?: boolean; }> { - try { - // 기존 견적에서 현장명 수집 (중복 제거) - const listResult = await getQuotes({ - perPage: 500, - }); - - if (!listResult.success) { - return { - success: false, - data: [], - error: listResult.error, - __authError: listResult.__authError, - }; - } - - // 현장명 추출 및 중복 제거 - const siteNames = Array.from( - new Set( - listResult.data - .map(q => q.siteName) - .filter((name): name is string => !!name && name.trim() !== '') - ) - ).sort(); - - return { - success: true, - data: siteNames, - }; - } catch (error) { - if (isNextRedirectError(error)) throw error; - console.error('[QuoteActions] getSiteNames error:', error); - return { - success: false, - data: [], - error: '서버 오류가 발생했습니다.', - }; - } + const result = await getQuoteReferenceData(); + return { + success: result.success, + data: result.data.siteNames, + error: result.error, + __authError: result.__authError, + }; } // ===== 품목 카테고리 트리 조회 ===== diff --git a/src/components/quotes/types.ts b/src/components/quotes/types.ts index 9000fd1a..548251b3 100644 --- a/src/components/quotes/types.ts +++ b/src/components/quotes/types.ts @@ -434,6 +434,7 @@ export interface BomCalculationResult { }; items: BomCalculationResultItem[]; subtotals: Record; + grouped_items?: Record; grand_total: number; variables?: Record; // 계산된 변수들 debug_steps?: BomDebugStep[]; // 디버그 스텝 (개발용)