/** * 견적 등록/수정 컴포넌트 * * IntegratedDetailTemplate 마이그레이션 (2026-01-20) * - 기본 정보 섹션 * - 자동 견적 산출 섹션 (동적 항목 추가/삭제) */ "use client"; import { useState, useEffect, useMemo, useCallback } from "react"; import { Input } from "../ui/input"; import { Textarea } from "../ui/textarea"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "../ui/select"; import { Button } from "../ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"; import { Badge } from "../ui/badge"; import { Alert, AlertDescription } from "../ui/alert"; import { FileText, Calculator, Plus, Copy, Trash2, Loader2, } from "lucide-react"; import { toast } from "sonner"; // 필드명 매핑 const FIELD_NAME_MAP: Record = { clientId: "발주처", productCategory: "제품 카테고리", productName: "제품명", openWidth: "오픈사이즈(W)", openHeight: "오픈사이즈(H)", guideRailType: "가이드레일 설치 유형", motorPower: "모터 전원", controller: "연동제어기", quantity: "수량", }; import { IntegratedDetailTemplate } from "@/components/templates/IntegratedDetailTemplate"; import { quoteRegistrationCreateConfig, quoteRegistrationEditConfig } from "./quoteConfig"; import { FormSection } from "@/components/organisms/FormSection"; import { FormFieldGrid } from "@/components/organisms/FormFieldGrid"; import { FormField } from "../molecules/FormField"; import { getFinishedGoods, calculateBomBulk, getSiteNames, type FinishedGoods, type BomCalculationResult } from "./actions"; import { getClients } from "../accounting/VendorManagement/actions"; import { isNextRedirectError } from "@/lib/utils/redirect-error"; import type { Vendor } from "../accounting/VendorManagement"; import type { BomMaterial, CalculationResults } from "./types"; import { useDevFill } from "@/components/dev"; import { generateQuoteData } from "@/components/dev/generators/quoteData"; // 견적 항목 타입 export interface QuoteItem { id: string; floor: string; // 층수 code: string; // 부호 productCategory: string; // 제품 카테고리 (PC) productName: string; // 제품명 openWidth: string; // 오픈사이즈 W0 openHeight: string; // 오픈사이즈 H0 guideRailType: string; // 가이드레일 설치 유형 (GT) motorPower: string; // 모터 전원 (MP) controller: string; // 연동제어기 (CT) quantity: number; // 수량 (QTY) unit?: string; // 품목 단위 wingSize: string; // 마구리 날개치수 (WS) inspectionFee: number; // 검사비 (INSP) unitPrice?: number; // 단가 totalAmount?: number; // 합계 installType?: string; // 설치유형 } // 견적 폼 데이터 타입 export interface QuoteFormData { id?: string; registrationDate: string; writer: string; clientId: string; clientName: string; siteName: string; // 현장명 (직접 입력) manager: string; contact: string; dueDate: string; remarks: string; unitSymbol?: string; // 단위 (EA, 개소 등) - quotes.unit_symbol items: QuoteItem[]; bomMaterials?: BomMaterial[]; // BOM 자재 목록 calculationResults?: CalculationResults; // 견적 산출 결과 (저장 시 BOM 자재 변환용) } // 초기 견적 항목 const createNewItem = (): QuoteItem => ({ id: `item-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, floor: "", code: "", productCategory: "", productName: "", openWidth: "", openHeight: "", guideRailType: "", motorPower: "", controller: "", quantity: 1, wingSize: "50", inspectionFee: 50000, }); // 초기 폼 데이터 export const INITIAL_QUOTE_FORM: QuoteFormData = { registrationDate: new Date().toISOString().split("T")[0], writer: "드미트리", // TODO: 로그인 사용자 정보로 대체 clientId: "", clientName: "", siteName: "", // 현장명 (직접 입력) manager: "", contact: "", dueDate: "", remarks: "", items: [createNewItem()], }; // 제품 카테고리 옵션 (MNG 시뮬레이터와 동일) const PRODUCT_CATEGORIES = [ { value: "ALL", label: "전체" }, { value: "SCREEN", label: "스크린" }, { value: "STEEL", label: "철재" }, { value: "BENDING", label: "절곡" }, { value: "ALUMINUM", label: "알루미늄" }, ]; // 가이드레일 설치 유형 (API: wall, ceiling, floor) const GUIDE_RAIL_TYPES = [ { value: "wall", label: "벽면형" }, { value: "floor", label: "측면형" }, ]; // 모터 전원 (API: single=단상220V, three=삼상380V) const MOTOR_POWERS = [ { value: "single", label: "220V (단상)" }, { value: "three", label: "380V (삼상)" }, ]; // 연동제어기 (API: basic, smart, premium) const CONTROLLERS = [ { value: "basic", label: "단독" }, { value: "smart", label: "연동" }, ]; interface QuoteRegistrationProps { onBack: () => void; onSave: (quote: QuoteFormData) => Promise; editingQuote?: QuoteFormData | null; isLoading?: boolean; } export function QuoteRegistration({ onBack, onSave, editingQuote, isLoading = false, }: QuoteRegistrationProps) { const [formData, setFormData] = useState( editingQuote || INITIAL_QUOTE_FORM ); const [errors, setErrors] = useState>({}); const [isSaving, setIsSaving] = useState(false); const [activeItemIndex, setActiveItemIndex] = useState(0); // Config 선택 const config = editingQuote ? quoteRegistrationEditConfig : quoteRegistrationCreateConfig; // 완제품 목록 상태 (API에서 로드) const [finishedGoods, setFinishedGoods] = useState([]); const [isLoadingProducts, setIsLoadingProducts] = useState(false); const [isCalculating, setIsCalculating] = useState(false); // 카테고리별 완제품 캐시 (API 재호출 최소화) const [categoryProductsCache, setCategoryProductsCache] = useState>({}); // 거래처 목록 상태 (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([]); // DevToolbar용 폼 자동 채우기 등록 useDevFill( 'quote', useCallback(() => { // 실제 로드된 데이터를 기반으로 샘플 데이터 생성 const sampleData = generateQuoteData({ clients: clients.map(c => ({ id: c.id, name: c.vendorName })), products: finishedGoods.map(p => ({ code: p.item_code, name: p.item_name, category: p.category })), }); setFormData(sampleData); toast.success('[Dev] 견적 폼이 자동으로 채워졌습니다.'); }, [clients, finishedGoods]) ); // 수량 반영 총합계 계산 (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]); // 컴포넌트 마운트 시 완제품 목록 로드 (초기 로드는 size 제한 없이 - 카테고리별 호출로 대체됨) useEffect(() => { const loadInitialProducts = async () => { setIsLoadingProducts(true); try { // 초기에는 ALL 카테고리로 로드 (size 제한 내에서) const result = await getFinishedGoods(); if (result.success) { setFinishedGoods(result.data); // 캐시에도 저장 setCategoryProductsCache(prev => ({ ...prev, "ALL": result.data })); } else { toast.error(`완제품 목록 로드 실패: ${result.error}`); } } catch (error) { if (isNextRedirectError(error)) throw error; toast.error("완제품 목록을 불러오는데 실패했습니다."); } finally { setIsLoadingProducts(false); } }; loadInitialProducts(); }, []); // 컴포넌트 마운트 시 거래처 목록 로드 useEffect(() => { const loadClients = async () => { setIsLoadingClients(true); try { const result = await getClients(); if (result.success) { setClients(result.data); } else { toast.error(`거래처 목록 로드 실패: ${result.error}`); } } catch (error) { if (isNextRedirectError(error)) throw error; toast.error("거래처 목록을 불러오는데 실패했습니다."); } finally { setIsLoadingClients(false); } }; loadClients(); }, []); // 컴포넌트 마운트 시 현장명 목록 로드 (자동완성용) useEffect(() => { const loadSiteNames = async () => { try { const result = await getSiteNames(); if (result.success) { setSiteNames(result.data); } } catch (error) { if (isNextRedirectError(error)) throw 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]); // 카테고리별 완제품 로드 (API 호출) const loadProductsByCategory = async (category: string) => { // 이미 캐시에 있으면 스킵 if (categoryProductsCache[category]) { return; } setIsLoadingProducts(true); try { // 카테고리가 ALL이면 전체, 아니면 해당 카테고리만 조회 const result = await getFinishedGoods(category === "ALL" ? undefined : category); if (result.success) { setCategoryProductsCache(prev => ({ ...prev, [category]: result.data })); } else { toast.error(`완제품 목록 로드 실패: ${result.error}`); } } catch (error) { if (isNextRedirectError(error)) throw error; toast.error("완제품 목록을 불러오는데 실패했습니다."); } finally { setIsLoadingProducts(false); } }; // 카테고리별 완제품 조회 (캐시 기반) const getFilteredProducts = (category: string) => { if (!category || category === "ALL") { // 전체 선택 시 캐시된 ALL 데이터 또는 초기 finishedGoods return categoryProductsCache["ALL"] || finishedGoods; } // 카테고리별 캐시 반환 return categoryProductsCache[category] || []; }; // 유효성 검사 const validateForm = useCallback((): boolean => { const newErrors: Record = {}; if (!formData.clientId) { newErrors.clientId = "발주처를 선택해주세요"; } // 견적 항목 검사 formData.items.forEach((item, index) => { if (!item.productCategory) { newErrors[`item-${index}-productCategory`] = "제품 카테고리를 선택해주세요"; } if (!item.productName) { newErrors[`item-${index}-productName`] = "제품명을 선택해주세요"; } if (!item.openWidth) { newErrors[`item-${index}-openWidth`] = "오픈사이즈(W)를 입력해주세요"; } if (!item.openHeight) { newErrors[`item-${index}-openHeight`] = "오픈사이즈(H)를 입력해주세요"; } if (!item.guideRailType) { newErrors[`item-${index}-guideRailType`] = "설치 유형을 선택해주세요"; } if (!item.motorPower) { newErrors[`item-${index}-motorPower`] = "모터 전원을 선택해주세요"; } if (!item.controller) { newErrors[`item-${index}-controller`] = "제어기를 선택해주세요"; } if (item.quantity < 1) { newErrors[`item-${index}-quantity`] = "수량은 1 이상이어야 합니다"; } }); setErrors(newErrors); return Object.keys(newErrors).length === 0; }, [formData]); const handleSubmit = useCallback(async () => { if (!validateForm()) { // 페이지 상단으로 스크롤 window.scrollTo({ top: 0, behavior: 'smooth' }); return; } // 에러 초기화 setErrors({}); setIsSaving(true); try { // calculationResults를 formData에 포함하여 저장 // transformFormDataToApi에서 BOM 자재의 base_quantity, calculated_quantity를 제대로 설정하기 위함 const dataToSave: QuoteFormData = { ...formData, calculationResults: calculationResults || undefined, }; await onSave(dataToSave); toast.success( editingQuote ? "견적이 수정되었습니다." : "견적이 등록되었습니다." ); onBack(); } catch (error) { if (isNextRedirectError(error)) throw error; toast.error("저장 중 오류가 발생했습니다."); } finally { setIsSaving(false); } }, [formData, calculationResults, validateForm, onSave, editingQuote, onBack]); const handleFieldChange = ( 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) => { const newErrors = { ...prev }; delete newErrors[field]; return newErrors; }); } }; // 발주처 선택 const handleClientChange = (clientId: string) => { const client = clients.find((c) => c.id === clientId); setFormData({ ...formData, clientId, clientName: client?.vendorName || "", }); }; // 견적 항목 변경 const handleItemChange = ( index: number, field: keyof QuoteItem, value: string | number ) => { const newItems = [...formData.items]; newItems[index] = { ...newItems[index], [field]: value }; // 제품 카테고리 변경 시 제품명 초기화 및 해당 카테고리 제품 로드 if (field === "productCategory" && typeof value === "string") { newItems[index].productName = ""; // 해당 카테고리 제품 목록 API 호출 (캐시 없으면) loadProductsByCategory(value); } setFormData({ ...formData, items: newItems }); // 에러 클리어 const errorKey = `item-${index}-${field}`; if (errors[errorKey]) { setErrors((prev) => { const newErrors = { ...prev }; delete newErrors[errorKey]; return newErrors; }); } }; // 견적 항목 추가 const handleAddItem = () => { const newItems = [...formData.items, createNewItem()]; setFormData({ ...formData, items: newItems }); setActiveItemIndex(newItems.length - 1); }; // 견적 항목 복사 const handleCopyItem = (index: number) => { const itemToCopy = formData.items[index]; const newItem: QuoteItem = { ...itemToCopy, id: `item-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, }; const newItems = [...formData.items, newItem]; setFormData({ ...formData, items: newItems }); setActiveItemIndex(newItems.length - 1); toast.success("견적 항목이 복사되었습니다."); }; // 견적 항목 삭제 const handleDeleteItem = (index: number) => { if (formData.items.length === 1) { toast.error("최소 1개의 견적 항목이 필요합니다."); return; } const newItems = formData.items.filter((_, i) => i !== index); setFormData({ ...formData, items: newItems }); if (activeItemIndex >= newItems.length) { setActiveItemIndex(newItems.length - 1); } toast.success("견적 항목이 삭제되었습니다."); }; // 자동 견적 산출 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) { if (isNextRedirectError(error)) throw error; console.error("견적 산출 오류:", error); toast.error("견적 산출 중 오류가 발생했습니다."); } finally { setIsCalculating(false); } }; // 렌더링 직전 디버그 로그 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)); // 폼 콘텐츠 렌더링 const renderFormContent = useCallback( () => (
{/* Validation 에러 표시 */} {Object.keys(errors).length > 0 && (
⚠️
입력 내용을 확인해주세요 ({Object.keys(errors).length}개 오류)
    {Object.entries(errors).map(([field, message]) => { // item-0-productCategory 형태의 키에서 필드명 추출 const fieldParts = field.split("-"); let fieldName = field; if (fieldParts.length === 3) { const itemIndex = parseInt(fieldParts[1]) + 1; const fieldKey = fieldParts[2]; fieldName = `견적 ${itemIndex} - ${FIELD_NAME_MAP[fieldKey] || fieldKey}`; } else { fieldName = FIELD_NAME_MAP[field] || field; } return (
  • {fieldName}: {message}
  • ); })}
)} {/* 1. 기본 정보 */} handleFieldChange("siteName", e.target.value)} /> {siteNames.map((name) => ( handleFieldChange("manager", e.target.value)} /> handleFieldChange("contact", e.target.value)} /> handleFieldChange("dueDate", e.target.value)} />