diff --git a/src/components/quotes/QuoteDocument.tsx b/src/components/quotes/QuoteDocument.tsx index 2a9fee32..225bf840 100644 --- a/src/components/quotes/QuoteDocument.tsx +++ b/src/components/quotes/QuoteDocument.tsx @@ -11,12 +11,14 @@ */ import { QuoteFormData } from "./QuoteRegistration"; +import type { CompanyFormData } from "@/components/settings/CompanyInfoManagement/types"; interface QuoteDocumentProps { quote: QuoteFormData; + companyInfo?: CompanyFormData | null; } -export function QuoteDocument({ quote }: QuoteDocumentProps) { +export function QuoteDocument({ quote, companyInfo }: QuoteDocumentProps) { const formatAmount = (amount: number | undefined) => { if (amount === undefined || amount === null) return '0'; return amount.toLocaleString('ko-KR'); @@ -34,7 +36,7 @@ export function QuoteDocument({ quote }: QuoteDocumentProps) { itemName: item.productName || '스크린셔터', spec: `${item.openWidth}×${item.openHeight}`, quantity: item.quantity || 1, - unit: '개소', + unit: item.unit || '', // 각 품목의 단위 사용, 없으면 빈 문자열 unitPrice: item.unitPrice || 0, totalPrice: item.totalAmount || 0, })) || []; @@ -292,29 +294,29 @@ export function QuoteDocument({ quote }: QuoteDocumentProps) { 상호 - 동호기업 + {companyInfo?.companyName || '-'} 사업자등록번호 - 139-87-00333 + {companyInfo?.businessNumber || '-'} 대표자 - 이 광 호 + {companyInfo?.representativeName || '-'} 업태 - 제조 + {companyInfo?.businessType || '-'} 종목 - 방창, 셔터, 금속성호 + {companyInfo?.businessCategory || '-'} 사업장주소 - 경기도 안성시 공업용지 오성길 45-22 + {companyInfo?.address || '-'} 전화 - 031-983-5130 - 팩스 - 02-6911-6315 + {companyInfo?.managerPhone || '-'} + 이메일 + {companyInfo?.email || '-'} @@ -340,7 +342,7 @@ export function QuoteDocument({ quote }: QuoteDocumentProps) { 모델 {quote.items[0]?.productName || '스크린셔터'} 총 수량 - {quote.items.reduce((sum, item) => sum + (item.quantity || 0), 0)}개소 + {quote.items[0]?.quantity || ''}{quote.unitSymbol ? ` ${quote.unitSymbol}` : ''} 오픈사이즈 @@ -432,7 +434,7 @@ export function QuoteDocument({ quote }: QuoteDocumentProps) {
{formatDate(quote.registrationDate || '')}
- 공급자: 동호기업 (인) + 공급자: {companyInfo?.companyName || '-'} (인)
@@ -452,7 +454,7 @@ export function QuoteDocument({ quote }: QuoteDocumentProps) {

3. 제작 사양 및 수량 변경 시 견적 금액이 변동될 수 있습니다.

4. 현장 여건에 따라 추가 비용이 발생할 수 있습니다.

- 문의: {quote.writer || '담당자'} | {quote.contact || '031-983-5130'} + 문의: {companyInfo?.managerName || quote.writer || '담당자'} | {companyInfo?.managerPhone || '-'}

diff --git a/src/components/quotes/QuoteRegistration.tsx b/src/components/quotes/QuoteRegistration.tsx index 3af7094e..5eef02df 100644 --- a/src/components/quotes/QuoteRegistration.tsx +++ b/src/components/quotes/QuoteRegistration.tsx @@ -8,7 +8,7 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; import { Input } from "../ui/input"; import { Textarea } from "../ui/textarea"; import { @@ -28,7 +28,7 @@ import { Plus, Copy, Trash2, - Sparkles, + Loader2, } from "lucide-react"; import { toast } from "sonner"; @@ -50,6 +50,10 @@ import { FormFieldGrid, } from "../templates/ResponsiveFormTemplate"; import { FormField } from "../molecules/FormField"; +import { getFinishedGoods, calculateBomBulk, getSiteNames, type FinishedGoods, type BomCalculationResult } from "./actions"; +import { getClients } from "../accounting/VendorManagement/actions"; +import type { Vendor } from "../accounting/VendorManagement"; +import type { BomMaterial, CalculationResults } from "./types"; // 견적 항목 타입 export interface QuoteItem { @@ -64,6 +68,7 @@ export interface QuoteItem { motorPower: string; // 모터 전원 (MP) controller: string; // 연동제어기 (CT) quantity: number; // 수량 (QTY) + unit?: string; // 품목 단위 wingSize: string; // 마구리 날개치수 (WS) inspectionFee: number; // 검사비 (INSP) unitPrice?: number; // 단가 @@ -83,7 +88,10 @@ export interface QuoteFormData { contact: string; dueDate: string; remarks: string; + unitSymbol?: string; // 단위 (EA, 개소 등) - quotes.unit_symbol items: QuoteItem[]; + bomMaterials?: BomMaterial[]; // BOM 자재 목록 + calculationResults?: CalculationResults; // 견적 산출 결과 (저장 시 BOM 자재 변환용) } // 초기 견적 항목 @@ -117,58 +125,31 @@ export const INITIAL_QUOTE_FORM: QuoteFormData = { items: [createNewItem()], }; -// 샘플 발주처 데이터 (TODO: API에서 가져오기) -const SAMPLE_CLIENTS = [ - { id: "client-1", name: "인천건설 - 최담당" }, - { id: "client-2", name: "ABC건설" }, - { id: "client-3", name: "XYZ산업" }, -]; - -// 제품 카테고리 옵션 +// 제품 카테고리 옵션 (MNG 시뮬레이터와 동일) const PRODUCT_CATEGORIES = [ - { value: "screen", label: "스크린" }, - { value: "steel", label: "철재" }, - { value: "aluminum", label: "알루미늄" }, - { value: "etc", label: "기타" }, + { value: "ALL", label: "전체" }, + { value: "SCREEN", label: "스크린" }, + { value: "STEEL", label: "철재" }, + { value: "BENDING", label: "절곡" }, + { value: "ALUMINUM", label: "알루미늄" }, ]; -// 제품명 옵션 (카테고리별) -const PRODUCTS: Record = { - screen: [ - { value: "SCR-001", label: "스크린 A형" }, - { value: "SCR-002", label: "스크린 B형" }, - { value: "SCR-003", label: "스크린 C형" }, - ], - steel: [ - { value: "STL-001", label: "철재 도어 A" }, - { value: "STL-002", label: "철재 도어 B" }, - ], - aluminum: [ - { value: "ALU-001", label: "알루미늄 프레임" }, - ], - etc: [ - { value: "ETC-001", label: "기타 제품" }, - ], -}; - -// 가이드레일 설치 유형 +// 가이드레일 설치 유형 (API: wall, ceiling, floor) const GUIDE_RAIL_TYPES = [ - { value: "wall", label: "벽부착형" }, - { value: "ceiling", label: "천장매립형" }, - { value: "floor", label: "바닥매립형" }, + { value: "wall", label: "벽면형" }, + { value: "floor", label: "측면형" }, ]; -// 모터 전원 +// 모터 전원 (API: single=단상220V, three=삼상380V) const MOTOR_POWERS = [ - { value: "single", label: "단상 220V" }, - { value: "three", label: "삼상 380V" }, + { value: "single", label: "220V (단상)" }, + { value: "three", label: "380V (삼상)" }, ]; -// 연동제어기 +// 연동제어기 (API: basic, smart, premium) const CONTROLLERS = [ - { value: "basic", label: "기본 제어기" }, - { value: "smart", label: "스마트 제어기" }, - { value: "premium", label: "프리미엄 제어기" }, + { value: "basic", label: "단독" }, + { value: "smart", label: "연동" }, ]; interface QuoteRegistrationProps { @@ -191,13 +172,118 @@ export function QuoteRegistration({ const [isSaving, setIsSaving] = useState(false); const [activeItemIndex, setActiveItemIndex] = useState(0); - // editingQuote가 변경되면 formData 업데이트 + // 완제품 목록 상태 (API에서 로드) + const [finishedGoods, setFinishedGoods] = useState([]); + const [isLoadingProducts, setIsLoadingProducts] = useState(false); + const [isCalculating, setIsCalculating] = useState(false); + + // 거래처 목록 상태 (API에서 로드) + const [clients, setClients] = useState([]); + const [isLoadingClients, setIsLoadingClients] = useState(false); + + // 견적 산출 결과 상태 + const [calculationResults, setCalculationResults] = useState<{ + summary: { grand_total: number }; + items: Array<{ + index: number; + result: BomCalculationResult; + }>; + } | null>(null); + + // 현장명 자동완성 목록 상태 + const [siteNames, setSiteNames] = useState([]); + + // 수량 반영 총합계 계산 (useMemo로 최적화) + const calculatedGrandTotal = useMemo(() => { + if (!calculationResults?.items) return 0; + return calculationResults.items.reduce((sum, itemResult) => { + const formItem = formData.items[itemResult.index]; + return sum + (itemResult.result.grand_total * (formItem?.quantity || 1)); + }, 0); + }, [calculationResults, formData.items]); + + // 컴포넌트 마운트 시 완제품 목록 로드 useEffect(() => { + const loadFinishedGoods = async () => { + setIsLoadingProducts(true); + try { + const result = await getFinishedGoods(); + if (result.success) { + setFinishedGoods(result.data); + } else { + toast.error(`완제품 목록 로드 실패: ${result.error}`); + } + } catch (error) { + toast.error("완제품 목록을 불러오는데 실패했습니다."); + } finally { + setIsLoadingProducts(false); + } + }; + loadFinishedGoods(); + }, []); + + // 컴포넌트 마운트 시 거래처 목록 로드 + useEffect(() => { + const loadClients = async () => { + setIsLoadingClients(true); + try { + const result = await getClients(); + if (result.success) { + setClients(result.data); + } else { + toast.error(`거래처 목록 로드 실패: ${result.error}`); + } + } catch (error) { + toast.error("거래처 목록을 불러오는데 실패했습니다."); + } finally { + setIsLoadingClients(false); + } + }; + loadClients(); + }, []); + + // 컴포넌트 마운트 시 현장명 목록 로드 (자동완성용) + useEffect(() => { + const loadSiteNames = async () => { + try { + const result = await getSiteNames(); + if (result.success) { + setSiteNames(result.data); + } + } catch (error) { + // 현장명 로드 실패는 무시 (선택적 기능) + console.error("현장명 목록 로드 실패:", error); + } + }; + loadSiteNames(); + }, []); + + // editingQuote가 변경되면 formData 업데이트 및 calculationResults 초기화 + useEffect(() => { + console.log('[QuoteRegistration] useEffect editingQuote:', JSON.stringify({ + hasEditingQuote: !!editingQuote, + itemCount: editingQuote?.items?.length, + item0: editingQuote?.items?.[0] ? { + quantity: editingQuote.items[0].quantity, + wingSize: editingQuote.items[0].wingSize, + inspectionFee: editingQuote.items[0].inspectionFee, + } : null, + }, null, 2)); if (editingQuote) { setFormData(editingQuote); + // 수정 모드 진입 시 이전 산출 결과 초기화 + setCalculationResults(null); } }, [editingQuote]); + // 카테고리별 완제품 필터링 + const getFilteredProducts = (category: string) => { + if (!category || category === "ALL") { + return finishedGoods; // 전체 선택 시 모든 완제품 + } + return finishedGoods.filter(fg => fg.item_category === category); + }; + // 유효성 검사 const validateForm = (): boolean => { const newErrors: Record = {}; @@ -249,7 +335,13 @@ export function QuoteRegistration({ setErrors({}); setIsSaving(true); try { - await onSave(formData); + // calculationResults를 formData에 포함하여 저장 + // transformFormDataToApi에서 BOM 자재의 base_quantity, calculated_quantity를 제대로 설정하기 위함 + const dataToSave: QuoteFormData = { + ...formData, + calculationResults: calculationResults || undefined, + }; + await onSave(dataToSave); toast.success( editingQuote ? "견적이 수정되었습니다." : "견적이 등록되었습니다." ); @@ -265,6 +357,10 @@ export function QuoteRegistration({ field: keyof QuoteFormData, value: string | QuoteItem[] ) => { + // DEBUG: manager, contact, remarks 필드 변경 추적 + if (field === 'manager' || field === 'contact' || field === 'remarks') { + console.log(`[handleFieldChange] ${field} 변경:`, value); + } setFormData({ ...formData, [field]: value }); if (errors[field]) { setErrors((prev) => { @@ -277,11 +373,11 @@ export function QuoteRegistration({ // 발주처 선택 const handleClientChange = (clientId: string) => { - const client = SAMPLE_CLIENTS.find((c) => c.id === clientId); + const client = clients.find((c) => c.id === clientId); setFormData({ ...formData, clientId, - clientName: client?.name || "", + clientName: client?.vendorName || "", }); }; @@ -347,14 +443,90 @@ export function QuoteRegistration({ }; // 자동 견적 산출 - const handleAutoCalculate = () => { - toast.info(`자동 견적 산출 (${formData.items.length}개 항목) - API 연동 필요`); + const handleAutoCalculate = async () => { + // 필수 입력값 검사 + const incompleteItems = formData.items.filter( + (item) => !item.productName || !item.openWidth || !item.openHeight + ); + + if (incompleteItems.length > 0) { + toast.error("모든 견적 항목의 필수 입력값(제품명, 오픈사이즈)을 입력해주세요."); + return; + } + + setIsCalculating(true); + try { + // BOM 계산 요청 데이터 구성 (API는 플랫한 구조 기대) + const bomItems = formData.items.map((item) => ({ + finished_goods_code: item.productName, // item_code가 productName에 저장됨 + // React 필드명 (camelCase) 사용 - API가 W0/H0로 변환 + openWidth: parseFloat(item.openWidth) || 0, + openHeight: parseFloat(item.openHeight) || 0, + quantity: item.quantity, + guideRailType: item.guideRailType || undefined, + motorPower: item.motorPower || undefined, + controller: item.controller || undefined, + wingSize: parseFloat(item.wingSize) || undefined, + inspectionFee: item.inspectionFee || undefined, + })); + + const result = await calculateBomBulk(bomItems); + + if (result.success && result.data) { + // API 응답: { success, summary, items: [{ index, result: BomCalculationResult }] } + const apiData = result.data as { + summary?: { grand_total: number }; + items?: Array<{ index: number; result: BomCalculationResult }>; + }; + + const bomItems = apiData.items || []; + + // 계산 결과를 폼 데이터에 반영 + const updatedItems = formData.items.map((item, index) => { + const bomResult = bomItems.find((b) => b.index === index); + if (bomResult?.result) { + return { + ...item, + unitPrice: bomResult.result.grand_total, + totalAmount: bomResult.result.grand_total * item.quantity, + }; + } + return item; + }); + + setFormData({ ...formData, items: updatedItems }); + + // 전체 계산 결과 저장 + setCalculationResults({ + summary: apiData.summary || { grand_total: 0 }, + items: bomItems, + }); + + toast.success(`${formData.items.length}개 항목의 견적이 산출되었습니다.`); + + // 계산 결과 요약 표시 (수량 반영 총합계 계산 - updatedItems 사용) + const totalWithQuantity = bomItems.reduce((sum, itemResult) => { + const formItem = updatedItems[itemResult.index]; + return sum + (itemResult.result.grand_total * (formItem?.quantity || 1)); + }, 0); + toast.info(`총 견적 금액: ${totalWithQuantity.toLocaleString()}원`); + } else { + toast.error(`견적 산출 실패: ${result.error || "알 수 없는 오류"}`); + } + } catch (error) { + console.error("견적 산출 오류:", error); + toast.error("견적 산출 중 오류가 발생했습니다."); + } finally { + setIsCalculating(false); + } }; - // 샘플 데이터 생성 - const handleGenerateSample = () => { - toast.info("완벽한 샘플 생성 - API 연동 필요"); - }; + // 렌더링 직전 디버그 로그 + console.log('[QuoteRegistration] 렌더링 직전 formData.items[0]:', JSON.stringify({ + quantity: formData.items[0]?.quantity, + wingSize: formData.items[0]?.wingSize, + inspectionFee: formData.items[0]?.inspectionFee, + }, null, 2)); return ( - + - + - {SAMPLE_CLIENTS.map((client) => ( + {clients.map((client) => ( - {client.name} + {client.vendorName} ))} @@ -459,16 +633,22 @@ export function QuoteRegistration({ - + handleFieldChange("siteName", e.target.value)} /> + + {siteNames.map((name) => ( + - + - + - + - +