diff --git a/src/components/quotes/FormulaViewModal.tsx b/src/components/quotes/FormulaViewModal.tsx index 3212ffa2..89d5a52d 100644 --- a/src/components/quotes/FormulaViewModal.tsx +++ b/src/components/quotes/FormulaViewModal.tsx @@ -109,7 +109,7 @@ function LocationDetail({ location }: { location: LocationItem }) { const debugSteps = bom.debug_steps || []; return ( -
+
{/* 10단계 계산 과정 */} {debugSteps.length > 0 ? (
diff --git a/src/components/quotes/ItemSearchModal.tsx b/src/components/quotes/ItemSearchModal.tsx index 31d811bd..f18d17f4 100644 --- a/src/components/quotes/ItemSearchModal.tsx +++ b/src/components/quotes/ItemSearchModal.tsx @@ -22,6 +22,8 @@ interface ItemSearchModalProps { tabLabel?: string; /** 품목 유형 필터 (예: 'RM', 'SF', 'FG') */ itemType?: string; + /** BOM 카테고리 필터 (material, motor, controller, steel, parts, inspection) */ + bomCategory?: string; } // 검색어 유효성: 영문, 한글, 숫자 1자 이상 @@ -40,15 +42,17 @@ export function ItemSearchModal({ onSelectItem, tabLabel, itemType, + bomCategory, }: ItemSearchModalProps) { const handleFetchData = useCallback(async (query: string) => { const data = await fetchItems({ search: query || undefined, itemType: itemType as ItemType | undefined, + bom_category: bomCategory || undefined, per_page: 50, }); return data; - }, [itemType]); + }, [itemType, bomCategory]); const handleSelect = useCallback((item: ItemMaster) => { onSelectItem({ diff --git a/src/components/quotes/LocationDetailPanel.tsx b/src/components/quotes/LocationDetailPanel.tsx index 6ce3283e..14bdb580 100644 --- a/src/components/quotes/LocationDetailPanel.tsx +++ b/src/components/quotes/LocationDetailPanel.tsx @@ -38,6 +38,7 @@ import { ItemSearchModal } from "./ItemSearchModal"; import type { LocationItem } from "./QuoteRegistration"; import type { FinishedGoods } from "./actions"; import type { BomCalculationResultItem } from "./types"; +import { BOM_CATEGORY_ORDER, BOM_CATEGORY_LABELS } from "./types"; // 납품길이 옵션 const DELIVERY_LENGTH_OPTIONS = [ @@ -161,16 +162,34 @@ export function LocationDetailPanel({ const subtotals = location.bomResult.subtotals; const tabs: TabDefinition[] = []; + const remaining = new Set(Object.keys(subtotals)); - Object.entries(subtotals).forEach(([key, value]) => { + // 고정 순서대로 탭 추가 + for (const key of BOM_CATEGORY_ORDER) { + if (remaining.has(key)) { + const value = subtotals[key]; + if (typeof value === "object" && value !== null) { + const obj = value as { name?: string }; + tabs.push({ + value: key, + label: BOM_CATEGORY_LABELS[key] || obj.name || key, + }); + } + remaining.delete(key); + } + } + + // 미정의 카테고리 뒤에 추가 + for (const key of remaining) { + const value = subtotals[key]; if (typeof value === "object" && value !== null) { const obj = value as { name?: string }; tabs.push({ value: key, - label: obj.name || key, + label: BOM_CATEGORY_LABELS[key] || obj.name || key, }); } - }); + } // 기타 탭 추가 tabs.push({ value: "etc", label: "기타" }); @@ -751,6 +770,8 @@ export function LocationDetailPanel({ t.value === activeTab)?.label} + bomCategory={activeTab !== "etc" ? activeTab : undefined} onSelectItem={async (item) => { if (!location) return; @@ -834,7 +855,6 @@ export function LocationDetailPanel({ totalPrice: updatedGrandTotal * location.quantity, }); }} - tabLabel={detailTabs.find((t) => t.value === activeTab)?.label} />
); diff --git a/src/components/quotes/LocationListPanel.tsx b/src/components/quotes/LocationListPanel.tsx index d919755d..c033827e 100644 --- a/src/components/quotes/LocationListPanel.tsx +++ b/src/components/quotes/LocationListPanel.tsx @@ -155,9 +155,31 @@ export function LocationListPanel({ const product = finishedGoods.find((fg) => fg.item_code === formData.productCode); + // 층/부호 자동 채움: 비어있으면 마지막 개소 기준으로 복제 스타일 적용 + let autoFloor = formData.floor || "-"; + let autoCode = formData.code || "-"; + if (locations.length > 0 && !formData.floor && !formData.code) { + const lastLoc = locations[locations.length - 1]; + autoFloor = lastLoc.floor || "-"; + // 부호 +1: 접두어 + 숫자 패턴 분석 + const codeMatch = lastLoc.code.match(/^(.*?)(\d+)$/); + if (codeMatch) { + const prefix = codeMatch[1]; + const numLength = codeMatch[2].length; + let maxNum = 0; + locations.forEach((loc) => { + const m = loc.code.match(new RegExp(`^${prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\d+)$`)); + if (m) maxNum = Math.max(maxNum, parseInt(m[1], 10)); + }); + autoCode = prefix + String(maxNum + 1).padStart(numLength, "0"); + } else { + autoCode = lastLoc.code || "-"; + } + } + const newLocation: Omit = { - floor: formData.floor || "-", - code: formData.code || "-", + floor: autoFloor, + code: autoCode, openWidth: parseFloat(formData.openWidth) || 0, openHeight: parseFloat(formData.openHeight) || 0, productCode: formData.productCode, @@ -175,14 +197,11 @@ export function LocationListPanel({ const success = await onAddLocation(newLocation); if (success) { - // 폼 초기화 (일부 필드 유지) + // 폼 초기화 (층/부호만 초기화, 나머지 설정값 유지) setFormData((prev) => ({ ...prev, floor: "", code: "", - openWidth: "", - openHeight: "", - quantity: "1", })); } }, [formData, finishedGoods, onAddLocation]); @@ -361,11 +380,19 @@ export function LocationListPanel({ - {finishedGoods.map((fg) => ( - - {fg.item_code} - - ))} + {(() => { + // 기존 개소가 있으면 동일 모델 제품만 표시 (예: KSS01 → KSS01 변형만) + const existingCode = locations.length > 0 ? locations[0].productCode : null; + const existingModel = existingCode?.split('-')[1] ?? null; // FG-KSS01-... → KSS01 + const filtered = existingModel + ? finishedGoods.filter((fg) => fg.item_code.split('-')[1] === existingModel) + : finishedGoods; + return filtered.map((fg) => ( + + {fg.item_code} + + )); + })()}
diff --git a/src/components/quotes/QuoteFooterBar.tsx b/src/components/quotes/QuoteFooterBar.tsx index c03608b0..19d9ce55 100644 --- a/src/components/quotes/QuoteFooterBar.tsx +++ b/src/components/quotes/QuoteFooterBar.tsx @@ -39,6 +39,10 @@ interface QuoteFooterBarProps { onOrderView?: () => void; /** 연결된 수주 ID (있으면 수주 보기, 없으면 수주등록) */ orderId?: number | null; + /** 수정 가능 여부 (생산지시 존재 시 false) */ + isEditable?: boolean; + /** 연결된 수주에 생산지시 존재 여부 */ + hasWorkOrders?: boolean; /** 할인하기 */ onDiscount?: () => void; /** 수식보기 */ @@ -68,6 +72,8 @@ export function QuoteFooterBar({ onOrderRegister, onOrderView, orderId, + isEditable = true, + hasWorkOrders = false, onDiscount, onFormulaView, hasBomResult = false, @@ -139,8 +145,8 @@ export function QuoteFooterBar({ )} - {/* 수정 - view 모드에서만 표시, 수주 등록된 경우 숨김 */} - {isViewMode && onEdit && !orderId && ( + {/* 수정 - view 모드에서만 표시, 생산지시 존재 시 숨김 */} + {isViewMode && onEdit && isEditable && ( )} - {/* 견적확정 - final 상태가 아닐 때 표시 (view/edit 모두) */} - {status !== "final" && ( + {/* 견적확정 - draft 상태에서만 표시 (final/converted 제외) */} + {status !== "final" && status !== "converted" && ( )} diff --git a/src/components/quotes/QuoteManagementClient.tsx b/src/components/quotes/QuoteManagementClient.tsx index 56723345..15116405 100644 --- a/src/components/quotes/QuoteManagementClient.tsx +++ b/src/components/quotes/QuoteManagementClient.tsx @@ -315,10 +315,9 @@ export function QuoteManagementClient({ - 전체 - 철재 - 스크린 - 혼합 + 제품분류: 전체 + 제품분류: 철재 + 제품분류: 스크린 @@ -328,10 +327,10 @@ export function QuoteManagementClient({ - 전체 - 최초작성 - N차수정 - 견적완료 + 상태: 전체 + 상태: 최초작성 + 상태: N차수정 + 상태: 견적완료
diff --git a/src/components/quotes/QuoteRegistration.tsx b/src/components/quotes/QuoteRegistration.tsx index c810762a..edac1742 100644 --- a/src/components/quotes/QuoteRegistration.tsx +++ b/src/components/quotes/QuoteRegistration.tsx @@ -101,6 +101,8 @@ export interface QuoteFormDataV2 { discountAmount: number; // 할인 금액 locations: LocationItem[]; orderId?: number | null; // 연결된 수주 ID (수주전환 시 설정) + isEditable?: boolean; // 수정 가능 여부 (생산지시 존재 시 false) + hasWorkOrders?: boolean; // 연결된 수주에 생산지시 존재 여부 } // ============================================================================= @@ -380,7 +382,7 @@ export function QuoteRegistration({ // 거래처 로드 setIsLoadingClients(true); try { - const result = await getClients(); + const result = await getClients({ size: 200, only_active: true }); if (result.success) { setClients(result.data); } @@ -453,6 +455,16 @@ export function QuoteRegistration({ return false; } + // 동일 모델만 등록 가능 (예: KSS01 → KSS01 변형만) + if (formData.locations.length > 0) { + const existingModel = formData.locations[0].productCode?.split('-')[1]; + const newModel = newLocation.productCode?.split('-')[1]; + if (existingModel && newModel && existingModel !== newModel) { + toast.error(`동일 모델만 등록 가능합니다. 현재 모델: ${existingModel}`); + return false; + } + } + // 먼저 BOM 계산 API 호출 try { const bomItem = { @@ -984,6 +996,8 @@ export function QuoteRegistration({ onOrderRegister={onOrderRegister} onOrderView={onOrderView} orderId={formData.orderId} + isEditable={formData.isEditable} + hasWorkOrders={formData.hasWorkOrders} onDiscount={() => setDiscountModalOpen(true)} onFormulaView={() => setFormulaViewOpen(true)} hasBomResult={hasBomResult} diff --git a/src/components/quotes/types.ts b/src/components/quotes/types.ts index 32de0e42..353026aa 100644 --- a/src/components/quotes/types.ts +++ b/src/components/quotes/types.ts @@ -45,6 +45,19 @@ export const PRODUCT_CATEGORY_LABELS: Record = { steel: '철재', }; +// ===== BOM 카테고리 순서 (고정) ===== +// 주자재 → 모터 → 제어기 → 절곡품 → 부자재 → 검사비 → 기타 +export const BOM_CATEGORY_ORDER = ['material', 'motor', 'controller', 'steel', 'parts', 'inspection']; + +export const BOM_CATEGORY_LABELS: Record = { + material: '주자재', + motor: '모터', + controller: '제어기', + steel: '절곡품', + parts: '부자재', + inspection: '검사비', +}; + /** item_category(한글) → product_category 변환 (DB는 대문자) */ export function itemCategoryToProductCategory(itemCategory?: string): ProductCategory { if (itemCategory === '철재') return 'STEEL'; @@ -725,6 +738,8 @@ export interface QuoteFormDataV2 { discountAmount: number; // 할인 금액 locations: LocationItem[]; orderId?: number | null; // 연결된 수주 ID (수주전환 시 설정) + isEditable?: boolean; // 수정 가능 여부 (생산지시 존재 시 false) + hasWorkOrders?: boolean; // 연결된 수주에 생산지시 존재 여부 } // ============================================================================= @@ -1032,6 +1047,9 @@ export function transformApiToV2(apiData: QuoteApiData): QuoteFormDataV2 { locations: locations, // 연결된 수주 ID (raw API: order_id, transformed: orderId) orderId: apiData.order_id ?? transformed.orderId ?? null, + // 수정 가능 여부 (생산지시 존재 시 false) + isEditable: (apiData as unknown as { is_editable?: boolean }).is_editable ?? true, + hasWorkOrders: (apiData as unknown as { has_work_orders?: boolean }).has_work_orders ?? false, }; } diff --git a/src/types/item.ts b/src/types/item.ts index aee81ceb..e8102444 100644 --- a/src/types/item.ts +++ b/src/types/item.ts @@ -253,6 +253,7 @@ export interface FetchItemsParams { isActive?: boolean; page?: number; per_page?: number; + bom_category?: string; } /**