diff --git a/src/app/[locale]/(protected)/sales/quote-management/[id]/page.tsx b/src/app/[locale]/(protected)/sales/quote-management/[id]/page.tsx index 1a167b2c..7c6e8872 100644 --- a/src/app/[locale]/(protected)/sales/quote-management/[id]/page.tsx +++ b/src/app/[locale]/(protected)/sales/quote-management/[id]/page.tsx @@ -1,10 +1,7 @@ /** - * 견적 상세/수정 페이지 (V2 통합) - * - 기본 정보 표시 (view mode) - * - 자동 견적 산출 정보 - * - 견적서 / 산출내역서 / 발주서 모달 - * - 수정 모드 (edit mode) + * 견적 상세/수정 페이지 (V2 UI) * + * IntegratedDetailTemplate + QuoteRegistrationV2 * URL 패턴: * - /quote-management/[id] → 상세 보기 (view) * - /quote-management/[id]?mode=edit → 수정 모드 (edit) @@ -13,44 +10,14 @@ "use client"; import { useRouter, useParams, useSearchParams } from "next/navigation"; -import { useState, useEffect, useCallback } from "react"; -import { QuoteRegistration, QuoteFormData } from "@/components/quotes/QuoteRegistration"; -import { QuoteDocument } from "@/components/quotes/QuoteDocument"; -import { QuoteCalculationReport } from "@/components/quotes/QuoteCalculationReport"; -import { PurchaseOrderDocument } from "@/components/quotes/PurchaseOrderDocument"; -import { - getQuoteById, - finalizeQuote, - convertQuoteToOrder, - sendQuoteEmail, - sendQuoteKakao, - transformQuoteToFormData, - updateQuote, - transformFormDataToApi, -} from "@/components/quotes"; -import { getCompanyInfo } from "@/components/settings/CompanyInfoManagement/actions"; -import type { CompanyFormData } from "@/components/settings/CompanyInfoManagement/types"; -import { getItemTypeCodes, type CommonCode } from "@/lib/api/common-codes"; -import { toast } from "sonner"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { useState, useEffect, useMemo, useCallback } from "react"; +import { QuoteRegistrationV2, QuoteFormDataV2 } from "@/components/quotes/QuoteRegistrationV2"; +import { IntegratedDetailTemplate } from "@/components/templates/IntegratedDetailTemplate"; +import { quoteConfig } from "@/components/quotes/quoteConfig"; import { Badge } from "@/components/ui/badge"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Textarea } from "@/components/ui/textarea"; -import { DocumentViewer } from "@/components/document-system"; -import { - FileText, - Edit, - List, - Printer, - FileOutput, - FileCheck, - Package, - ChevronDown, - ChevronUp, -} from "lucide-react"; -import { DetailPageSkeleton } from "@/components/ui/skeleton"; +import { toast } from "sonner"; +import { getQuoteById, updateQuote, calculateBomBulk, BomCalculateItem } from "@/components/quotes/actions"; +import { transformApiToV2, transformV2ToApi } from "@/components/quotes/types"; export default function QuoteDetailPage() { const router = useRouter(); @@ -58,631 +25,169 @@ export default function QuoteDetailPage() { const searchParams = useSearchParams(); const quoteId = params.id as string; - // V2 패턴: mode 체크 + // mode 체크 const mode = searchParams.get("mode") || "view"; const isEditMode = mode === "edit"; - const [quote, setQuote] = useState(null); - const [companyInfo, setCompanyInfo] = useState(null); + const [quote, setQuote] = useState(null); const [isLoading, setIsLoading] = useState(true); - const [isProcessing, setIsProcessing] = useState(false); const [isSaving, setIsSaving] = useState(false); - // 다이얼로그 상태 - const [isQuoteDocumentOpen, setIsQuoteDocumentOpen] = useState(false); - const [isCalculationReportOpen, setIsCalculationReportOpen] = useState(false); - const [isPurchaseOrderOpen, setIsPurchaseOrderOpen] = useState(false); + useEffect(() => { + const loadQuote = async () => { + setIsLoading(true); + try { + const result = await getQuoteById(quoteId); - // 산출내역서 표시 옵션 - const [showDetailedBreakdown, setShowDetailedBreakdown] = useState(true); - const [showMaterialList, setShowMaterialList] = useState(true); + if (!result.success || !result.data) { + toast.error(result.error || "견적 정보를 불러오는데 실패했습니다."); + router.push("/sales/quote-management"); + return; + } - // BOM 자재 상세 펼침/접힘 상태 - const [isBomExpanded, setIsBomExpanded] = useState(true); + // API 응답을 V2 폼 데이터로 변환 + const v2Data = transformApiToV2(result.data as unknown as Parameters[0]); - // 공통 코드 (item_type) - const [itemTypeCodes, setItemTypeCodes] = useState([]); + // bomResult 없는 개소가 있으면 자동 재계산 + const locationsNeedingRecalc = v2Data.locations.filter( + loc => !loc.bomResult && loc.productCode && loc.openWidth > 0 && loc.openHeight > 0 + ); - // 견적 데이터 조회 - const fetchQuote = useCallback(async () => { - setIsLoading(true); - try { - const result = await getQuoteById(quoteId); - if (result.success && result.data) { - // 디버깅: Quote 변환 전 데이터 - console.log('[QuoteDetail] Quote data:', { - clientId: result.data.clientId, - clientName: result.data.clientName, - calculationInputs: result.data.calculationInputs, - items: result.data.items?.map(item => ({ - productName: item.productName, - quantity: item.quantity, - unitPrice: item.unitPrice, - totalAmount: item.totalAmount, - })), - }); + if (locationsNeedingRecalc.length > 0) { + console.log("[QuoteDetailPage] BOM 재계산 필요:", locationsNeedingRecalc.length, "개"); - const formData = transformQuoteToFormData(result.data); + // BOM 계산 요청 데이터 생성 + const bomItems: BomCalculateItem[] = locationsNeedingRecalc.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, + })); - // 디버깅: QuoteFormData 변환 후 데이터 - console.log('[QuoteDetail] FormData:', { - clientId: formData.clientId, - clientName: formData.clientName, - items: formData.items?.map(item => ({ - productName: item.productName, - quantity: item.quantity, - inspectionFee: item.inspectionFee, - totalAmount: item.totalAmount, - })), - }); + const calcResult = await calculateBomBulk(bomItems); - setQuote(formData); - } else { - toast.error(result.error || "견적 정보를 불러오는데 실패했습니다."); + if (calcResult.success && calcResult.data?.items) { + console.log("[QuoteDetailPage] BOM 재계산 성공:", calcResult.data.items.length, "개"); + + // 재계산 결과를 locations에 적용 + const updatedLocations = v2Data.locations.map((loc, index) => { + // productCode가 있고 bomResult가 없는 경우에만 업데이트 + if (!loc.bomResult && loc.productCode) { + const calcItem = calcResult.data?.items.find( + item => item.finished_goods_code === loc.productCode + ); + if (calcItem?.result) { + return { ...loc, bomResult: calcItem.result }; + } + } + return loc; + }); + + v2Data.locations = updatedLocations; + } else { + console.log("[QuoteDetailPage] BOM 재계산 실패 또는 결과 없음"); + } + } + + setQuote(v2Data); + } catch (error) { + console.error("[QuoteDetailPage] 로드 오류:", error); + toast.error("견적 정보를 불러오는데 실패했습니다."); router.push("/sales/quote-management"); + } finally { + setIsLoading(false); } - } catch (error) { - toast.error("견적 정보를 불러오는데 실패했습니다."); - router.push("/sales/quote-management"); - } finally { - setIsLoading(false); + }; + + if (quoteId) { + loadQuote(); } }, [quoteId, router]); - // 회사 정보 조회 - const fetchCompanyInfo = useCallback(async () => { - try { - const result = await getCompanyInfo(); - if (result.success && result.data) { - setCompanyInfo(result.data); - } - } catch (error) { - console.error('[QuoteDetail] Failed to fetch company info:', error); - } - }, []); - - // 공통 코드 조회 - const fetchItemTypeCodes = useCallback(async () => { - const result = await getItemTypeCodes(); - if (result.success && result.data) { - setItemTypeCodes(result.data); - } - }, []); - - // item_type 코드 → 이름 변환 헬퍼 - const getItemTypeLabel = useCallback((code: string | undefined | null): string => { - if (!code) return '-'; - const found = itemTypeCodes.find(item => item.code === code); - return found?.name || code; - }, [itemTypeCodes]); - - useEffect(() => { - fetchQuote(); - fetchCompanyInfo(); - fetchItemTypeCodes(); - }, [fetchQuote, fetchCompanyInfo, fetchItemTypeCodes]); - - const handleBack = () => { + const handleBack = useCallback(() => { router.push("/sales/quote-management"); - }; + }, [router]); - const handleEdit = () => { - router.push(`/sales/quote-management/${quoteId}?mode=edit`); - }; - - // V2 패턴: 수정 저장 핸들러 - const handleSave = async (formData: QuoteFormData): Promise<{ success: boolean; error?: string }> => { - if (isSaving) return { success: false, error: '저장 중입니다.' }; + // 수정 저장 핸들러 + const handleSave = useCallback(async (data: QuoteFormDataV2, saveType: "temporary" | "final") => { setIsSaving(true); - try { - const apiData = transformFormDataToApi(formData); - const result = await updateQuote(quoteId, apiData as any); + // V2 폼 데이터를 API 형식으로 변환 + const updatedData = { ...data, status: saveType }; + const apiData = transformV2ToApi(updatedData); - if (result.success) { - // toast는 IntegratedDetailTemplate에서 처리 - router.push(`/sales/quote-management/${quoteId}`); - return { success: true }; - } else { - return { success: false, error: result.error || "견적 수정에 실패했습니다." }; + console.log("[QuoteDetailPage] 수정 데이터:", apiData); + console.log("[QuoteDetailPage] 저장 타입:", saveType); + + // API 호출 + const result = await updateQuote(quoteId, apiData); + + if (!result.success) { + toast.error(result.error || "저장 중 오류가 발생했습니다."); + return; } + + toast.success(`${saveType === "temporary" ? "임시" : "최종"} 저장 완료`); + + // 저장 후 view 모드로 전환 + router.push(`/sales/quote-management/${quoteId}`); } catch (error) { - return { success: false, error: "견적 수정에 실패했습니다." }; + console.error("[QuoteDetailPage] 저장 오류:", error); + toast.error("저장 중 오류가 발생했습니다."); } finally { setIsSaving(false); } - }; + }, [router, quoteId]); - const handleFinalize = async () => { - if (isProcessing) return; - setIsProcessing(true); - try { - const result = await finalizeQuote(quoteId); - if (result.success) { - toast.success("견적이 최종 확정되었습니다."); - fetchQuote(); // 데이터 새로고침 - } else { - toast.error(result.error || "견적 확정에 실패했습니다."); - } - } catch (error) { - toast.error("견적 확정에 실패했습니다."); - } finally { - setIsProcessing(false); - } - }; + // 동적 config (모드별 타이틀) + const dynamicConfig = useMemo(() => { + const title = isEditMode ? '견적 수정' : '견적 상세'; + return { + ...quoteConfig, + title, + }; + }, [isEditMode]); - const handleConvertToOrder = async () => { - if (isProcessing) return; - setIsProcessing(true); - try { - const result = await convertQuoteToOrder(quoteId); - if (result.success) { - toast.success("수주로 전환되었습니다."); - if (result.orderId) { - router.push(`/sales/order-management/${result.orderId}`); - } else { - router.push("/sales/order-management"); - } - } else { - toast.error(result.error || "수주 전환에 실패했습니다."); - } - } catch (error) { - toast.error("수주 전환에 실패했습니다."); - } finally { - setIsProcessing(false); - } - }; - - const handleSendEmail = async () => { - if (isProcessing) return; - // TODO: 이메일 입력 다이얼로그 추가 - const email = prompt("발송할 이메일 주소를 입력하세요:"); - if (!email) return; - - setIsProcessing(true); - try { - const result = await sendQuoteEmail(quoteId, { email }); - if (result.success) { - toast.success("이메일이 발송되었습니다."); - } else { - toast.error(result.error || "이메일 발송에 실패했습니다."); - } - } catch (error) { - toast.error("이메일 발송에 실패했습니다."); - } finally { - setIsProcessing(false); - } - }; - - const handleSendKakao = async () => { - if (isProcessing) return; - // TODO: 카카오 발송 다이얼로그 추가 - const phone = prompt("발송할 전화번호를 입력하세요:"); - if (!phone) return; - - setIsProcessing(true); - try { - const result = await sendQuoteKakao(quoteId, { phone }); - if (result.success) { - toast.success("카카오톡이 발송되었습니다."); - } else { - toast.error(result.error || "카카오톡 발송에 실패했습니다."); - } - } catch (error) { - toast.error("카카오톡 발송에 실패했습니다."); - } finally { - setIsProcessing(false); - } - }; - - const formatDate = (dateStr: string) => { - if (!dateStr) return "-"; - return dateStr; - }; - - const formatAmount = (amount: number | undefined) => { - if (!amount) return "0"; - return amount.toLocaleString("ko-KR"); - }; - - // 총 금액 계산 (실제 금액 우선, 없으면 검사비 사용) - const totalAmount = - quote?.items?.reduce((sum, item) => { - // totalAmount가 있으면 사용, 없으면 unitPrice * quantity, 마지막으로 inspectionFee - const itemAmount = item.totalAmount || (item.unitPrice || 0) * (item.quantity || 1) || (item.inspectionFee || 0) * (item.quantity || 1); - return sum + itemAmount; - }, 0) || 0; - - if (isLoading) { - return ; - } - - if (!quote) { + // 커스텀 헤더 액션 (상태 뱃지) + const customHeaderActions = useMemo(() => { + if (!quote) return null; return ( -
-

견적 정보를 찾을 수 없습니다.

- -
+ + {quote.status === "final" ? "최종저장" : quote.status === "temporary" ? "임시저장" : "작성중"} + ); - } + }, [quote]); - // V2 패턴: Edit 모드일 때 QuoteRegistration 컴포넌트 렌더링 - if (isEditMode) { + // 폼 콘텐츠 렌더링 + const renderFormContent = useCallback(() => { return ( - ); - } + }, [isEditMode, handleBack, handleSave, quote, isSaving]); - // View 모드: 상세 보기 + // IntegratedDetailTemplate 사용 return ( -
- {/* 헤더 */} -
-
-

- - 견적 상세 -

-

견적번호: {quote.id}

-
- -
- {/* 문서 버튼들 */} - - - - - {/* 액션 버튼들 */} - - - -
-
- - {/* 기본 정보 */} - - - 기본 정보 - - -
-
- - -
-
- - -
-
- - -
-
- -
-
- - -
-
- - -
-
- - -
-
- -
-
- - -
-
- - -
-
- - {quote.remarks && ( -
- -