/** * 견적 등록/수정 컴포넌트 V2 * * 새로운 레이아웃: * - 좌우 분할: 발주 개소 목록 | 선택 개소 상세 * - 하단: 견적 금액 요약 (개소별 + 상세별) * - 푸터: 총 금액 + 버튼들 (견적서 산출, 임시저장, 최종저장) */ "use client"; import { useState, useEffect, useMemo, useCallback, useRef } from "react"; import { FileText, Calculator, Download, Save, Check } from "lucide-react"; import { toast } from "sonner"; import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"; import { Button } from "../ui/button"; import { Badge } from "../ui/badge"; import { Input } from "../ui/input"; import { Textarea } from "../ui/textarea"; import { PhoneInput } from "../ui/phone-input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "../ui/select"; // FormField는 실제로 사용되지 않으므로 제거 import { LocationListPanel } from "./LocationListPanel"; import { LocationDetailPanel } from "./LocationDetailPanel"; import { QuoteSummaryPanel } from "./QuoteSummaryPanel"; import { QuoteFooterBar } from "./QuoteFooterBar"; import { QuotePreviewModal } from "./QuotePreviewModal"; import { QuoteTransactionModal } from "./QuoteTransactionModal"; import { DiscountModal } from "./DiscountModal"; import { getFinishedGoods, calculateBomBulk, getSiteNames, type FinishedGoods, type BomCalculationResult, type BomBulkResponse, } from "./actions"; import { getClients } from "../accounting/VendorManagement/actions"; import { isNextRedirectError } from "@/lib/utils/redirect-error"; // 실제 로그인 사용자 정보는 localStorage('user')에 저장됨 (LoginPage.tsx 참조) import { useDevFill } from "@/components/dev/useDevFill"; import type { Vendor } from "../accounting/VendorManagement"; import type { BomMaterial, CalculationResults, BomCalculationResultItem } from "./types"; import { getLocalDateString, getDateAfterDays } from "@/utils/date"; // ============================================================================= // 타입 정의 // ============================================================================= // 발주 개소 항목 export interface LocationItem { id: string; floor: string; // 층 code: string; // 부호 openWidth: number; // 가로 (오픈사이즈 W) openHeight: number; // 세로 (오픈사이즈 H) productCode: string; // 제품코드 productName: string; // 제품명 quantity: number; // 수량 guideRailType: string; // 가이드레일 설치 유형 motorPower: string; // 모터 전원 controller: string; // 연동제어기 wingSize: number; // 마구리 날개치수 inspectionFee: number; // 검사비 // 계산 결과 manufactureWidth?: number; // 제작사이즈 W manufactureHeight?: number; // 제작사이즈 H weight?: number; // 산출중량 (kg) area?: number; // 산출면적 (m²) unitPrice?: number; // 단가 totalPrice?: number; // 합계 bomResult?: BomCalculationResult; // BOM 계산 결과 } // 견적 폼 데이터 V2 export interface QuoteFormDataV2 { id?: string; quoteNumber: string; // 견적번호 registrationDate: string; // 접수일 writer: string; // 작성자 clientId: string; // 수주처 ID clientName: string; // 수주처명 siteName: string; // 현장명 manager: string; // 담당자 contact: string; // 연락처 vatType: "included" | "excluded"; // 부가세 (포함/별도) remarks: string; // 비고 status: "draft" | "temporary" | "final"; // 작성중, 임시저장, 최종저장 locations: LocationItem[]; } // ============================================================================= // 상수 // ============================================================================= // 초기 개소 항목 const createNewLocation = (): LocationItem => ({ id: `loc-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, floor: "", code: "", openWidth: 0, openHeight: 0, productCode: "", productName: "", quantity: 1, guideRailType: "wall", motorPower: "single", controller: "basic", wingSize: 50, inspectionFee: 50000, }); // 초기 폼 데이터 const INITIAL_FORM_DATA: QuoteFormDataV2 = { quoteNumber: "", // 자동생성 또는 서버에서 부여 registrationDate: getLocalDateString(new Date()), writer: "", // useAuth()에서 currentUser.name으로 설정됨 clientId: "", clientName: "", siteName: "", manager: "", contact: "", vatType: "included", // 기본값: 부가세 포함 remarks: "", status: "draft", locations: [], }; // ============================================================================= // Props // ============================================================================= interface QuoteRegistrationV2Props { mode: "create" | "view" | "edit"; onBack: () => void; onSave?: (data: QuoteFormDataV2, saveType: "temporary" | "final") => Promise; onCalculate?: () => void; onEdit?: () => void; onOrderRegister?: () => void; initialData?: QuoteFormDataV2 | null; isLoading?: boolean; /** IntegratedDetailTemplate 사용 시 타이틀 영역 숨김 */ hideHeader?: boolean; } // ============================================================================= // 메인 컴포넌트 // ============================================================================= export function QuoteRegistrationV2({ mode, onBack, onSave, onCalculate, onEdit, onOrderRegister, initialData, isLoading = false, hideHeader = false, }: QuoteRegistrationV2Props) { // --------------------------------------------------------------------------- // 상태 // --------------------------------------------------------------------------- const [formData, setFormData] = useState( initialData || INITIAL_FORM_DATA ); const [selectedLocationId, setSelectedLocationId] = useState(null); const [isSaving, setIsSaving] = useState(false); const [isCalculating, setIsCalculating] = useState(false); const [quotePreviewOpen, setQuotePreviewOpen] = useState(false); const [transactionPreviewOpen, setTransactionPreviewOpen] = useState(false); const [discountModalOpen, setDiscountModalOpen] = useState(false); const [discountRate, setDiscountRate] = useState(0); const [discountAmount, setDiscountAmount] = useState(0); const pendingAutoCalculateRef = useRef(false); // API 데이터 const [clients, setClients] = useState([]); const [finishedGoods, setFinishedGoods] = useState([]); const [siteNames, setSiteNames] = useState([]); const [isLoadingClients, setIsLoadingClients] = useState(false); const [isLoadingProducts, setIsLoadingProducts] = useState(false); // handleCalculate 참조 (DevFill에서 사용) const calculateRef = useRef<(() => Promise) | null>(null); // 디버그용: formData를 window에 노출 useEffect(() => { if (typeof window !== "undefined") { (window as unknown as { __QUOTE_DEBUG__: { formData: QuoteFormDataV2; selectedLocationId: string | null } }).__QUOTE_DEBUG__ = { formData, selectedLocationId, }; } }, [formData, selectedLocationId]); // --------------------------------------------------------------------------- // DevFill (개발/테스트용 자동 채우기) // --------------------------------------------------------------------------- useDevFill("quoteV2", useCallback(() => { // BOM이 있는 제품만 필터링 const productsWithBom = finishedGoods.filter((fg) => fg.has_bom === true || (fg.bom && Array.isArray(fg.bom) && fg.bom.length > 0)); console.log(`[DevFill] BOM 있는 제품: ${productsWithBom.length}개 / 전체: ${finishedGoods.length}개`); // 랜덤 개소 생성 함수 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"]; const motorPowers = ["single", "three"]; const controllers = ["basic", "smart", "premium"]; const randomFloor = floors[Math.floor(Math.random() * floors.length)]; const randomPrefix = codePrefix[Math.floor(Math.random() * codePrefix.length)]; const randomWidth = Math.floor(Math.random() * 4000) + 2000; // 2000~6000 const randomHeight = Math.floor(Math.random() * 3000) + 2000; // 2000~5000 // BOM이 있는 제품 중에서 랜덤 선택 (없으면 전체에서 선택) const productPool = productsWithBom.length > 0 ? productsWithBom : finishedGoods; const randomProduct = productPool[Math.floor(Math.random() * productPool.length)]; return { id: `loc-${Date.now()}-${index}`, floor: randomFloor, code: `${randomPrefix}-${String(index + 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 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)); } // 로그인 사용자 정보 가져오기 let writerName = ""; try { const userStr = localStorage.getItem("user"); if (userStr) { const user = JSON.parse(userStr); writerName = user?.name || ""; } } catch (e) { console.error("[DevFill] 사용자 정보 로드 실패:", e); } const testData: QuoteFormDataV2 = { registrationDate: getLocalDateString(new Date()), writer: writerName, clientId: clients[0]?.id?.toString() || "", clientName: clients[0]?.company_name || "테스트 거래처", siteName: "테스트 현장", manager: "홍길동", contact: "010-1234-5678", dueDate: getDateAfterDays(7), remarks: "[DevFill] 테스트 견적입니다.", status: "draft", locations: testLocations, }; setFormData(testData); setSelectedLocationId(testLocations[0].id); toast.success(`[DevFill] 테스트 데이터가 채워졌습니다. (${locationCount}개 개소)`); // 자동 견적 산출 트리거 pendingAutoCalculateRef.current = true; }, [clients, finishedGoods])); // --------------------------------------------------------------------------- // 계산된 값 // --------------------------------------------------------------------------- // 선택된 개소 const selectedLocation = useMemo(() => { return formData.locations.find((loc) => loc.id === selectedLocationId) || null; }, [formData.locations, selectedLocationId]); // 총 금액 (할인 전) const totalAmount = useMemo(() => { return formData.locations.reduce((sum, loc) => sum + (loc.totalPrice || 0), 0); }, [formData.locations]); // 할인 적용 후 총 금액 const discountedTotalAmount = useMemo(() => { return totalAmount - discountAmount; }, [totalAmount, discountAmount]); // 할인 적용 핸들러 const handleApplyDiscount = useCallback((rate: number, amount: number) => { setDiscountRate(rate); setDiscountAmount(amount); toast.success(`할인이 적용되었습니다. (${rate.toFixed(1)}%, ${amount.toLocaleString()}원)`); }, []); // 개소별 합계 const locationTotals = useMemo(() => { return formData.locations.map((loc) => ({ id: loc.id, label: `${loc.floor} / ${loc.code}`, productCode: loc.productCode, quantity: loc.quantity, unitPrice: loc.unitPrice || 0, totalPrice: loc.totalPrice || 0, })); }, [formData.locations]); // --------------------------------------------------------------------------- // 작성자 자동 설정 (create 모드에서 로그인 사용자 정보 로드) // --------------------------------------------------------------------------- useEffect(() => { if (mode === "create" && !formData.writer) { // 실제 로그인 사용자 정보는 localStorage('user')에 저장됨 try { const userStr = localStorage.getItem("user"); if (userStr) { const user = JSON.parse(userStr); if (user?.name) { setFormData((prev) => ({ ...prev, writer: user.name })); } } } catch (e) { console.error("[QuoteRegistrationV2] 사용자 정보 로드 실패:", e); } } }, [mode, formData.writer]); // --------------------------------------------------------------------------- // 초기 데이터 로드 // --------------------------------------------------------------------------- useEffect(() => { const loadInitialData = async () => { // 거래처 로드 setIsLoadingClients(true); try { const result = await getClients(); if (result.success) { setClients(result.data); } } catch (error) { if (isNextRedirectError(error)) throw error; console.error("거래처 로드 실패:", error); } finally { setIsLoadingClients(false); } // 완제품 로드 setIsLoadingProducts(true); try { const result = await getFinishedGoods(); if (result.success) { setFinishedGoods(result.data); } } catch (error) { if (isNextRedirectError(error)) throw error; console.error("완제품 로드 실패:", error); } finally { setIsLoadingProducts(false); } // 현장명 로드 try { const result = await getSiteNames(); if (result.success) { setSiteNames(result.data); } } catch (error) { if (isNextRedirectError(error)) throw error; } }; loadInitialData(); }, []); // initialData 변경 시 formData 업데이트 useEffect(() => { if (initialData) { setFormData(initialData); // 첫 번째 개소 자동 선택 if (initialData.locations.length > 0 && !selectedLocationId) { setSelectedLocationId(initialData.locations[0].id); } } }, [initialData]); // --------------------------------------------------------------------------- // 핸들러 // --------------------------------------------------------------------------- // 기본 정보 변경 const handleFieldChange = useCallback((field: keyof QuoteFormDataV2, value: string) => { setFormData((prev) => ({ ...prev, [field]: value })); }, []); // 발주처 선택 const handleClientChange = useCallback((clientId: string) => { const client = clients.find((c) => c.id === clientId); setFormData((prev) => ({ ...prev, clientId, clientName: client?.vendorName || "", })); }, [clients]); // 개소 추가 (BOM 계산 성공 시에만 추가, 성공/실패 반환) const handleAddLocation = useCallback(async (location: Omit): Promise => { const newLocation: LocationItem = { ...location, id: `loc-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, }; // BOM 계산 필수 조건 체크 if (!newLocation.productCode || newLocation.openWidth <= 0 || newLocation.openHeight <= 0) { toast.error("제품, 가로, 세로를 모두 입력해주세요."); return false; } // 먼저 BOM 계산 API 호출 try { const bomItem = { finished_goods_code: newLocation.productCode, openWidth: newLocation.openWidth, openHeight: newLocation.openHeight, quantity: newLocation.quantity, guideRailType: newLocation.guideRailType, motorPower: newLocation.motorPower, controller: newLocation.controller, wingSize: newLocation.wingSize, inspectionFee: newLocation.inspectionFee, }; const result = await calculateBomBulk([bomItem]); if (result.success && result.data) { const apiData = result.data as BomBulkResponse; const bomResponseItems = apiData.items || []; const bomResult = bomResponseItems[0]?.result; if (bomResult) { // BOM 계산 성공 시에만 개소 추가 const locationWithBom: LocationItem = { ...newLocation, unitPrice: bomResult.grand_total, totalPrice: bomResult.grand_total * newLocation.quantity, bomResult: bomResult, }; setFormData((prev) => ({ ...prev, locations: [...prev.locations, locationWithBom], })); setSelectedLocationId(newLocation.id); toast.success("개소가 추가되고 BOM이 계산되었습니다."); return true; } } // API 에러 메시지 표시 (개소 추가 안 함) toast.error(result.error || "BOM 계산 실패 - 개소가 추가되지 않았습니다."); return false; } catch (error) { console.error("[handleAddLocation] BOM 계산 실패:", error); toast.error("BOM 계산 중 오류가 발생했습니다."); return false; } }, []); // 개소 삭제 const handleDeleteLocation = useCallback((locationId: string) => { setFormData((prev) => ({ ...prev, locations: prev.locations.filter((loc) => loc.id !== locationId), })); if (selectedLocationId === locationId) { setSelectedLocationId(formData.locations[0]?.id || null); } toast.success("개소가 삭제되었습니다."); }, [selectedLocationId, formData.locations]); // 개소 수정 const handleUpdateLocation = useCallback((locationId: string, updates: Partial) => { setFormData((prev) => ({ ...prev, locations: prev.locations.map((loc) => loc.id === locationId ? { ...loc, ...updates } : loc ), })); }, []); // 엑셀 업로드 const handleExcelUpload = useCallback((locations: Omit[]) => { const newLocations: LocationItem[] = locations.map((loc) => ({ ...loc, id: `loc-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, })); setFormData((prev) => ({ ...prev, locations: [...prev.locations, ...newLocations], })); if (newLocations.length > 0) { setSelectedLocationId(newLocations[0].id); } toast.success(`${newLocations.length}개 개소가 추가되었습니다.`); }, []); // 견적 산출 const handleCalculate = useCallback(async () => { if (formData.locations.length === 0) { toast.error("산출할 개소가 없습니다."); return; } setIsCalculating(true); try { const bomItems = formData.locations.map((loc) => ({ finished_goods_code: loc.productCode, openWidth: loc.openWidth, openHeight: loc.openHeight, quantity: loc.quantity, guideRailType: loc.guideRailType, motorPower: loc.motorPower, controller: loc.controller, wingSize: loc.wingSize, inspectionFee: loc.inspectionFee, })); const result = await calculateBomBulk(bomItems); if (result.success && result.data) { // API 응답: { success, summary: { grand_total, ... }, items: [{ index, result: BomCalculationResult }] } const apiData = result.data as BomBulkResponse; const bomResponseItems = apiData.items || []; console.log('[QuoteRegistrationV2] BOM 계산 결과:', { success: apiData.success, summary: apiData.summary, itemsCount: bomResponseItems.length, firstItem: bomResponseItems[0], }); // 결과 반영 (수동 추가 품목 보존) const updatedLocations = formData.locations.map((loc, index) => { const bomItem = bomResponseItems.find((item) => item.index === index); const bomResult = bomItem?.result; if (bomResult) { // 기존 수동 추가 품목 추출 (is_manual: true) const manualItems = (loc.bomResult?.items || []).filter( (item: BomCalculationResultItem & { is_manual?: boolean }) => item.is_manual === true ); // 수동 추가 품목의 총 금액 const manualTotal = manualItems.reduce( (sum: number, item: BomCalculationResultItem) => sum + (item.total_price || 0), 0 ); // 새 BOM 결과에 수동 품목 병합 const mergedItems = [...(bomResult.items || []), ...manualItems]; const mergedGrandTotal = bomResult.grand_total + manualTotal; console.log(`[QuoteRegistrationV2] Location ${index} bomResult:`, { items: bomResult.items?.length, manualItems: manualItems.length, mergedItems: mergedItems.length, subtotals: bomResult.subtotals, grand_total: mergedGrandTotal, }); return { ...loc, unitPrice: mergedGrandTotal, totalPrice: mergedGrandTotal * loc.quantity, bomResult: { ...bomResult, items: mergedItems, grand_total: mergedGrandTotal, }, }; } return loc; }); setFormData((prev) => ({ ...prev, locations: updatedLocations })); toast.success(`${formData.locations.length}개 개소의 견적이 산출되었습니다.`); } else { toast.error(`견적 산출 실패: ${result.error}`); } } catch (error) { if (isNextRedirectError(error)) throw error; toast.error("견적 산출 중 오류가 발생했습니다."); } finally { setIsCalculating(false); } }, [formData.locations]); // handleCalculate 참조 업데이트 useEffect(() => { calculateRef.current = handleCalculate; }, [handleCalculate]); // DevFill 후 자동 견적 산출 useEffect(() => { if (pendingAutoCalculateRef.current && formData.locations.length > 0) { pendingAutoCalculateRef.current = false; // 상태 업데이트 완료 후 산출 실행 setTimeout(() => { handleCalculate(); }, 50); } }, [formData.locations, handleCalculate]); // 저장 (임시/최종) const handleSave = useCallback(async (saveType: "temporary" | "final") => { if (!onSave) return; setIsSaving(true); try { const dataToSave: QuoteFormDataV2 = { ...formData, status: saveType === "temporary" ? "temporary" : "final", }; await onSave(dataToSave, saveType); toast.success(saveType === "temporary" ? "저장되었습니다." : "견적이 확정되었습니다."); } catch (error) { if (isNextRedirectError(error)) throw error; toast.error("저장 중 오류가 발생했습니다."); } finally { setIsSaving(false); } }, [formData, onSave]); // --------------------------------------------------------------------------- // 렌더링 // --------------------------------------------------------------------------- const isViewMode = mode === "view"; const pageTitle = mode === "create" ? "견적 등록 (V2 테스트)" : mode === "edit" ? "견적 수정 (V2 테스트)" : "견적 상세 (V2 테스트)"; return (
{/* 기본 정보 섹션 */}
{/* 타이틀 영역 - hideHeader 시 IntegratedDetailTemplate이 담당 */} {!hideHeader && (

{pageTitle}

{formData.status === "final" ? "견적 확정" : formData.status === "temporary" ? "저장됨" : "작성중"}
)} {/* 기본 정보 */} 기본 정보 {/* 1행: 견적번호 | 접수일 | 수주처 | 현장명 */}
handleFieldChange("registrationDate", e.target.value)} disabled={isViewMode} />
handleFieldChange("siteName", e.target.value)} disabled={isViewMode} /> {siteNames.map((name) => (
{/* 2행: 담당자 | 연락처 | 작성자 | 부가세 */}
handleFieldChange("manager", e.target.value)} disabled={isViewMode} />
handleFieldChange("contact", value)} disabled={isViewMode} />
{/* 3행: 상태 | 비고 */}
handleFieldChange("remarks", e.target.value)} disabled={isViewMode} />
{/* 자동 견적 산출 섹션 */} 자동 견적 산출 {/* 좌우 분할 레이아웃 */}
{/* 왼쪽: 발주 개소 목록 + 추가 폼 */} {/* 오른쪽: 선택 개소 상세 */} { // 단일 개소 산출 const location = formData.locations.find((loc) => loc.id === locationId); if (!location) return; setIsCalculating(true); try { const bomItem = { finished_goods_code: location.productCode, openWidth: location.openWidth, openHeight: location.openHeight, quantity: location.quantity, guideRailType: location.guideRailType, motorPower: location.motorPower, controller: location.controller, wingSize: location.wingSize, inspectionFee: location.inspectionFee, }; const result = await calculateBomBulk([bomItem]); if (result.success && result.data) { const apiData = result.data as BomBulkResponse; const bomResult = apiData.items?.[0]?.result; if (bomResult) { handleUpdateLocation(locationId, { unitPrice: bomResult.grand_total, totalPrice: bomResult.grand_total * location.quantity, bomResult: bomResult, }); toast.success("견적이 산출되었습니다."); } } else { toast.error(result.error || "산출 실패"); } } catch (error) { console.error("산출 오류:", error); toast.error("산출 중 오류가 발생했습니다."); } finally { setIsCalculating(false); } }} onSaveItems={() => { toast.success("품목이 저장되었습니다."); }} finishedGoods={finishedGoods} disabled={isViewMode} isCalculating={isCalculating} />
{/* 견적 금액 요약 */}
{/* 푸터 바 (고정) */} setQuotePreviewOpen(true)} onTransactionView={() => setTransactionPreviewOpen(true)} onSave={() => handleSave("temporary")} onFinalize={() => handleSave("final")} onBack={onBack} onEdit={onEdit} onOrderRegister={onOrderRegister} onDiscount={() => setDiscountModalOpen(true)} isSaving={isSaving} isViewMode={isViewMode} /> {/* 견적서 보기 모달 */} {/* 거래명세서 보기 모달 */} {/* 할인하기 모달 */}
); }