refactor(WEB): 견적관리 URL 구조 마이그레이션 Phase 2 완료
- test-new → new 경로 정식화 (V2 등록 페이지) - test/[id] → [id] 경로 정식화 (V2 상세/수정 페이지) - test 폴더 삭제 (test-new/, test/[id]/) - V1 페이지 백업 파일 보존 (.v1-backup) - LocationDetailPanel, QuoteSummaryPanel 개선 - types.ts 변환 함수 정리 V2 URL 패턴: - 등록: /sales/quote-management/new - 상세: /sales/quote-management/[id] - 수정: /sales/quote-management/[id]?mode=edit
This commit is contained in:
@@ -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<QuoteFormData | null>(null);
|
||||
const [companyInfo, setCompanyInfo] = useState<CompanyFormData | null>(null);
|
||||
const [quote, setQuote] = useState<QuoteFormDataV2 | null>(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<typeof transformApiToV2>[0]);
|
||||
|
||||
// 공통 코드 (item_type)
|
||||
const [itemTypeCodes, setItemTypeCodes] = useState<CommonCode[]>([]);
|
||||
// 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 <DetailPageSkeleton sections={2} fieldsPerSection={6} />;
|
||||
}
|
||||
|
||||
if (!quote) {
|
||||
// 커스텀 헤더 액션 (상태 뱃지)
|
||||
const customHeaderActions = useMemo(() => {
|
||||
if (!quote) return null;
|
||||
return (
|
||||
<div className="p-6">
|
||||
<p className="text-gray-500">견적 정보를 찾을 수 없습니다.</p>
|
||||
<Button onClick={handleBack} className="mt-4">
|
||||
목록으로 돌아가기
|
||||
</Button>
|
||||
</div>
|
||||
<Badge variant={quote.status === "final" ? "default" : quote.status === "temporary" ? "secondary" : "outline"}>
|
||||
{quote.status === "final" ? "최종저장" : quote.status === "temporary" ? "임시저장" : "작성중"}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
}, [quote]);
|
||||
|
||||
// V2 패턴: Edit 모드일 때 QuoteRegistration 컴포넌트 렌더링
|
||||
if (isEditMode) {
|
||||
// 폼 콘텐츠 렌더링
|
||||
const renderFormContent = useCallback(() => {
|
||||
return (
|
||||
<QuoteRegistration
|
||||
<QuoteRegistrationV2
|
||||
mode={isEditMode ? "edit" : "view"}
|
||||
onBack={handleBack}
|
||||
onSave={handleSave}
|
||||
editingQuote={quote}
|
||||
onSave={isEditMode ? handleSave : undefined}
|
||||
initialData={quote}
|
||||
isLoading={isSaving}
|
||||
hideHeader={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}, [isEditMode, handleBack, handleSave, quote, isSaving]);
|
||||
|
||||
// View 모드: 상세 보기
|
||||
// IntegratedDetailTemplate 사용
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<FileText className="w-6 h-6" />
|
||||
견적 상세
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-1">견적번호: {quote.id}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{/* 문서 버튼들 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsQuoteDocumentOpen(true)}
|
||||
>
|
||||
<Printer className="w-4 h-4 mr-2" />
|
||||
견적서
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsCalculationReportOpen(true)}
|
||||
>
|
||||
<FileText className="w-4 h-4 mr-2" />
|
||||
산출내역서
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsPurchaseOrderOpen(true)}
|
||||
>
|
||||
<FileOutput className="w-4 h-4 mr-2" />
|
||||
발주서
|
||||
</Button>
|
||||
|
||||
{/* 액션 버튼들 */}
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
<List className="w-4 h-4 mr-2" />
|
||||
목록
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleEdit}>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleFinalize}
|
||||
disabled={isProcessing}
|
||||
className="bg-green-600 hover:bg-green-700 text-white"
|
||||
>
|
||||
<FileCheck className="w-4 h-4 mr-2" />
|
||||
최종확정
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 기본 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<Label>견적번호</Label>
|
||||
<Input
|
||||
value={quote.id || "-"}
|
||||
disabled
|
||||
className="bg-gray-50 text-black font-medium"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>작성자</Label>
|
||||
<Input
|
||||
value={quote.writer || "-"}
|
||||
disabled
|
||||
className="bg-gray-50 text-black font-medium"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>발주처</Label>
|
||||
<Input
|
||||
value={quote.clientName || "-"}
|
||||
disabled
|
||||
className="bg-gray-50 text-black font-medium"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<Label>담당자</Label>
|
||||
<Input
|
||||
value={quote.manager || "-"}
|
||||
disabled
|
||||
className="bg-gray-50 text-black font-medium"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>연락처</Label>
|
||||
<Input
|
||||
value={quote.contact || "-"}
|
||||
disabled
|
||||
className="bg-gray-50 text-black font-medium"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>현장명</Label>
|
||||
<Input
|
||||
value={quote.siteName || "-"}
|
||||
disabled
|
||||
className="bg-gray-50 text-black font-medium"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<Label>등록일</Label>
|
||||
<Input
|
||||
value={formatDate(quote.registrationDate || "")}
|
||||
disabled
|
||||
className="bg-gray-50 text-black font-medium"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>납기일</Label>
|
||||
<Input
|
||||
value={formatDate(quote.dueDate || "")}
|
||||
disabled
|
||||
className="bg-gray-50 text-black font-medium"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{quote.remarks && (
|
||||
<div>
|
||||
<Label>비고</Label>
|
||||
<Textarea
|
||||
value={quote.remarks}
|
||||
disabled
|
||||
className="bg-gray-50 text-black font-medium min-h-[80px]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 자동 견적 산출 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>자동 견적 산출 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{quote.items && quote.items.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{quote.items.map((item, index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="border rounded-lg p-4 bg-gray-50"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<Badge variant="outline">항목 {index + 1}</Badge>
|
||||
<Badge variant="secondary">{item.floor}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">제품명</span>
|
||||
<p className="font-medium">{item.productName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">오픈사이즈</span>
|
||||
<p className="font-medium">
|
||||
{item.openWidth} × {item.openHeight} mm
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">수량</span>
|
||||
<p className="font-medium">{item.quantity} SET</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">금액</span>
|
||||
<p className="font-medium text-blue-600">
|
||||
₩{formatAmount(item.totalAmount || (item.unitPrice || 0) * (item.quantity || 1) || (item.inspectionFee || 0) * (item.quantity || 1))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 합계 */}
|
||||
<div className="border-t pt-4 mt-4">
|
||||
<div className="flex justify-between items-center text-lg font-bold">
|
||||
<span>총 견적금액</span>
|
||||
<span className="text-blue-600">
|
||||
₩{formatAmount(totalAmount)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-center py-8">
|
||||
산출 항목이 없습니다.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* BOM 자재 상세 */}
|
||||
{quote.bomMaterials && quote.bomMaterials.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Package className="h-5 w-5" />
|
||||
BOM 자재 상세
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
{quote.bomMaterials.length}개 품목
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsBomExpanded(!isBomExpanded)}
|
||||
>
|
||||
{isBomExpanded ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{isBomExpanded && (
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="text-left p-2 font-medium">No</th>
|
||||
<th className="text-left p-2 font-medium">품목코드</th>
|
||||
<th className="text-left p-2 font-medium">품목명</th>
|
||||
<th className="text-left p-2 font-medium">유형</th>
|
||||
<th className="text-left p-2 font-medium">규격</th>
|
||||
<th className="text-center p-2 font-medium">단위</th>
|
||||
<th className="text-right p-2 font-medium">수량</th>
|
||||
<th className="text-right p-2 font-medium">단가</th>
|
||||
<th className="text-right p-2 font-medium">금액</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{quote.bomMaterials.map((material, index) => (
|
||||
<tr key={index} className="border-b hover:bg-muted/30">
|
||||
<td className="p-2 text-muted-foreground">{index + 1}</td>
|
||||
<td className="p-2 font-mono text-xs">{material.itemCode}</td>
|
||||
<td className="p-2">{material.itemName}</td>
|
||||
<td className="p-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{getItemTypeLabel(material.itemType)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-2 text-muted-foreground">{material.specification || '-'}</td>
|
||||
<td className="p-2 text-center">{material.unit}</td>
|
||||
<td className="p-2 text-right">{material.quantity.toLocaleString()}</td>
|
||||
<td className="p-2 text-right">₩{material.unitPrice.toLocaleString()}</td>
|
||||
<td className="p-2 text-right font-medium">₩{material.totalPrice.toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t-2 bg-muted/30">
|
||||
<td colSpan={8} className="p-2 text-right font-medium">합계</td>
|
||||
<td className="p-2 text-right font-bold text-blue-600">
|
||||
₩{quote.bomMaterials.reduce((sum, m) => sum + m.totalPrice, 0).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 견적서 다이얼로그 */}
|
||||
<DocumentViewer
|
||||
title="견적서"
|
||||
preset="quote"
|
||||
open={isQuoteDocumentOpen}
|
||||
onOpenChange={setIsQuoteDocumentOpen}
|
||||
onPdf={() => {
|
||||
toast.info('인쇄 대화상자에서 "PDF로 저장" 옵션을 선택하세요.');
|
||||
window.print();
|
||||
}}
|
||||
onEmail={handleSendEmail}
|
||||
onFax={() => toast.info("팩스 전송 기능은 준비 중입니다.")}
|
||||
onKakao={handleSendKakao}
|
||||
>
|
||||
<QuoteDocument quote={quote} companyInfo={companyInfo} />
|
||||
</DocumentViewer>
|
||||
|
||||
{/* 산출내역서 다이얼로그 */}
|
||||
<DocumentViewer
|
||||
title="산출내역서"
|
||||
preset="quote"
|
||||
open={isCalculationReportOpen}
|
||||
onOpenChange={setIsCalculationReportOpen}
|
||||
onPdf={() => {
|
||||
toast.info('인쇄 대화상자에서 "PDF로 저장" 옵션을 선택하세요.');
|
||||
window.print();
|
||||
}}
|
||||
onEmail={handleSendEmail}
|
||||
onFax={() => toast.info("팩스 전송 기능은 준비 중입니다.")}
|
||||
onKakao={handleSendKakao}
|
||||
toolbarExtra={
|
||||
<>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showDetailedBreakdown}
|
||||
onChange={(e) => setShowDetailedBreakdown(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-sm">산출내역서</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showMaterialList}
|
||||
onChange={(e) => setShowMaterialList(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-sm">소요자재 내역</span>
|
||||
</label>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<QuoteCalculationReport
|
||||
quote={quote}
|
||||
companyInfo={companyInfo}
|
||||
documentType="견적산출내역서"
|
||||
showDetailedBreakdown={showDetailedBreakdown}
|
||||
showMaterialList={showMaterialList}
|
||||
/>
|
||||
</DocumentViewer>
|
||||
|
||||
{/* 발주서 다이얼로그 */}
|
||||
<DocumentViewer
|
||||
title="발주서"
|
||||
preset="quote"
|
||||
open={isPurchaseOrderOpen}
|
||||
onOpenChange={setIsPurchaseOrderOpen}
|
||||
onPdf={() => {
|
||||
toast.info('인쇄 대화상자에서 "PDF로 저장" 옵션을 선택하세요.');
|
||||
window.print();
|
||||
}}
|
||||
onEmail={handleSendEmail}
|
||||
onFax={() => toast.info("팩스 전송 기능은 준비 중입니다.")}
|
||||
onKakao={handleSendKakao}
|
||||
>
|
||||
<PurchaseOrderDocument quote={quote} companyInfo={companyInfo} />
|
||||
</DocumentViewer>
|
||||
</div>
|
||||
<IntegratedDetailTemplate
|
||||
config={dynamicConfig}
|
||||
mode={isEditMode ? "edit" : "view"}
|
||||
initialData={quote || {}}
|
||||
itemId={quoteId}
|
||||
isLoading={isLoading}
|
||||
headerActions={customHeaderActions}
|
||||
renderView={() => renderFormContent()}
|
||||
renderForm={() => renderFormContent()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,688 @@
|
||||
/**
|
||||
* 견적 상세/수정 페이지 (V2 통합)
|
||||
* - 기본 정보 표시 (view mode)
|
||||
* - 자동 견적 산출 정보
|
||||
* - 견적서 / 산출내역서 / 발주서 모달
|
||||
* - 수정 모드 (edit mode)
|
||||
*
|
||||
* URL 패턴:
|
||||
* - /quote-management/[id] → 상세 보기 (view)
|
||||
* - /quote-management/[id]?mode=edit → 수정 모드 (edit)
|
||||
*/
|
||||
|
||||
"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 { 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";
|
||||
|
||||
export default function QuoteDetailPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const quoteId = params.id as string;
|
||||
|
||||
// V2 패턴: mode 체크
|
||||
const mode = searchParams.get("mode") || "view";
|
||||
const isEditMode = mode === "edit";
|
||||
|
||||
const [quote, setQuote] = useState<QuoteFormData | null>(null);
|
||||
const [companyInfo, setCompanyInfo] = useState<CompanyFormData | null>(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);
|
||||
|
||||
// 산출내역서 표시 옵션
|
||||
const [showDetailedBreakdown, setShowDetailedBreakdown] = useState(true);
|
||||
const [showMaterialList, setShowMaterialList] = useState(true);
|
||||
|
||||
// BOM 자재 상세 펼침/접힘 상태
|
||||
const [isBomExpanded, setIsBomExpanded] = useState(true);
|
||||
|
||||
// 공통 코드 (item_type)
|
||||
const [itemTypeCodes, setItemTypeCodes] = useState<CommonCode[]>([]);
|
||||
|
||||
// 견적 데이터 조회
|
||||
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,
|
||||
})),
|
||||
});
|
||||
|
||||
const formData = transformQuoteToFormData(result.data);
|
||||
|
||||
// 디버깅: 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,
|
||||
})),
|
||||
});
|
||||
|
||||
setQuote(formData);
|
||||
} else {
|
||||
toast.error(result.error || "견적 정보를 불러오는데 실패했습니다.");
|
||||
router.push("/sales/quote-management");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("견적 정보를 불러오는데 실패했습니다.");
|
||||
router.push("/sales/quote-management");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [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 = () => {
|
||||
router.push("/sales/quote-management");
|
||||
};
|
||||
|
||||
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: '저장 중입니다.' };
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
const apiData = transformFormDataToApi(formData);
|
||||
const result = await updateQuote(quoteId, apiData as any);
|
||||
|
||||
if (result.success) {
|
||||
// toast는 IntegratedDetailTemplate에서 처리
|
||||
router.push(`/sales/quote-management/${quoteId}`);
|
||||
return { success: true };
|
||||
} else {
|
||||
return { success: false, error: result.error || "견적 수정에 실패했습니다." };
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: "견적 수정에 실패했습니다." };
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
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 <DetailPageSkeleton sections={2} fieldsPerSection={6} />;
|
||||
}
|
||||
|
||||
if (!quote) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<p className="text-gray-500">견적 정보를 찾을 수 없습니다.</p>
|
||||
<Button onClick={handleBack} className="mt-4">
|
||||
목록으로 돌아가기
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// V2 패턴: Edit 모드일 때 QuoteRegistration 컴포넌트 렌더링
|
||||
if (isEditMode) {
|
||||
return (
|
||||
<QuoteRegistration
|
||||
onBack={handleBack}
|
||||
onSave={handleSave}
|
||||
editingQuote={quote}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// View 모드: 상세 보기
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<FileText className="w-6 h-6" />
|
||||
견적 상세
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-1">견적번호: {quote.id}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{/* 문서 버튼들 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsQuoteDocumentOpen(true)}
|
||||
>
|
||||
<Printer className="w-4 h-4 mr-2" />
|
||||
견적서
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsCalculationReportOpen(true)}
|
||||
>
|
||||
<FileText className="w-4 h-4 mr-2" />
|
||||
산출내역서
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsPurchaseOrderOpen(true)}
|
||||
>
|
||||
<FileOutput className="w-4 h-4 mr-2" />
|
||||
발주서
|
||||
</Button>
|
||||
|
||||
{/* 액션 버튼들 */}
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
<List className="w-4 h-4 mr-2" />
|
||||
목록
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleEdit}>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleFinalize}
|
||||
disabled={isProcessing}
|
||||
className="bg-green-600 hover:bg-green-700 text-white"
|
||||
>
|
||||
<FileCheck className="w-4 h-4 mr-2" />
|
||||
최종확정
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 기본 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<Label>견적번호</Label>
|
||||
<Input
|
||||
value={quote.id || "-"}
|
||||
disabled
|
||||
className="bg-gray-50 text-black font-medium"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>작성자</Label>
|
||||
<Input
|
||||
value={quote.writer || "-"}
|
||||
disabled
|
||||
className="bg-gray-50 text-black font-medium"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>발주처</Label>
|
||||
<Input
|
||||
value={quote.clientName || "-"}
|
||||
disabled
|
||||
className="bg-gray-50 text-black font-medium"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<Label>담당자</Label>
|
||||
<Input
|
||||
value={quote.manager || "-"}
|
||||
disabled
|
||||
className="bg-gray-50 text-black font-medium"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>연락처</Label>
|
||||
<Input
|
||||
value={quote.contact || "-"}
|
||||
disabled
|
||||
className="bg-gray-50 text-black font-medium"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>현장명</Label>
|
||||
<Input
|
||||
value={quote.siteName || "-"}
|
||||
disabled
|
||||
className="bg-gray-50 text-black font-medium"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<Label>등록일</Label>
|
||||
<Input
|
||||
value={formatDate(quote.registrationDate || "")}
|
||||
disabled
|
||||
className="bg-gray-50 text-black font-medium"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>납기일</Label>
|
||||
<Input
|
||||
value={formatDate(quote.dueDate || "")}
|
||||
disabled
|
||||
className="bg-gray-50 text-black font-medium"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{quote.remarks && (
|
||||
<div>
|
||||
<Label>비고</Label>
|
||||
<Textarea
|
||||
value={quote.remarks}
|
||||
disabled
|
||||
className="bg-gray-50 text-black font-medium min-h-[80px]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 자동 견적 산출 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>자동 견적 산출 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{quote.items && quote.items.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{quote.items.map((item, index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="border rounded-lg p-4 bg-gray-50"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<Badge variant="outline">항목 {index + 1}</Badge>
|
||||
<Badge variant="secondary">{item.floor}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">제품명</span>
|
||||
<p className="font-medium">{item.productName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">오픈사이즈</span>
|
||||
<p className="font-medium">
|
||||
{item.openWidth} × {item.openHeight} mm
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">수량</span>
|
||||
<p className="font-medium">{item.quantity} SET</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">금액</span>
|
||||
<p className="font-medium text-blue-600">
|
||||
₩{formatAmount(item.totalAmount || (item.unitPrice || 0) * (item.quantity || 1) || (item.inspectionFee || 0) * (item.quantity || 1))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 합계 */}
|
||||
<div className="border-t pt-4 mt-4">
|
||||
<div className="flex justify-between items-center text-lg font-bold">
|
||||
<span>총 견적금액</span>
|
||||
<span className="text-blue-600">
|
||||
₩{formatAmount(totalAmount)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-center py-8">
|
||||
산출 항목이 없습니다.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* BOM 자재 상세 */}
|
||||
{quote.bomMaterials && quote.bomMaterials.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Package className="h-5 w-5" />
|
||||
BOM 자재 상세
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
{quote.bomMaterials.length}개 품목
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsBomExpanded(!isBomExpanded)}
|
||||
>
|
||||
{isBomExpanded ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{isBomExpanded && (
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="text-left p-2 font-medium">No</th>
|
||||
<th className="text-left p-2 font-medium">품목코드</th>
|
||||
<th className="text-left p-2 font-medium">품목명</th>
|
||||
<th className="text-left p-2 font-medium">유형</th>
|
||||
<th className="text-left p-2 font-medium">규격</th>
|
||||
<th className="text-center p-2 font-medium">단위</th>
|
||||
<th className="text-right p-2 font-medium">수량</th>
|
||||
<th className="text-right p-2 font-medium">단가</th>
|
||||
<th className="text-right p-2 font-medium">금액</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{quote.bomMaterials.map((material, index) => (
|
||||
<tr key={index} className="border-b hover:bg-muted/30">
|
||||
<td className="p-2 text-muted-foreground">{index + 1}</td>
|
||||
<td className="p-2 font-mono text-xs">{material.itemCode}</td>
|
||||
<td className="p-2">{material.itemName}</td>
|
||||
<td className="p-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{getItemTypeLabel(material.itemType)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-2 text-muted-foreground">{material.specification || '-'}</td>
|
||||
<td className="p-2 text-center">{material.unit}</td>
|
||||
<td className="p-2 text-right">{material.quantity.toLocaleString()}</td>
|
||||
<td className="p-2 text-right">₩{material.unitPrice.toLocaleString()}</td>
|
||||
<td className="p-2 text-right font-medium">₩{material.totalPrice.toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t-2 bg-muted/30">
|
||||
<td colSpan={8} className="p-2 text-right font-medium">합계</td>
|
||||
<td className="p-2 text-right font-bold text-blue-600">
|
||||
₩{quote.bomMaterials.reduce((sum, m) => sum + m.totalPrice, 0).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 견적서 다이얼로그 */}
|
||||
<DocumentViewer
|
||||
title="견적서"
|
||||
preset="quote"
|
||||
open={isQuoteDocumentOpen}
|
||||
onOpenChange={setIsQuoteDocumentOpen}
|
||||
onPdf={() => {
|
||||
toast.info('인쇄 대화상자에서 "PDF로 저장" 옵션을 선택하세요.');
|
||||
window.print();
|
||||
}}
|
||||
onEmail={handleSendEmail}
|
||||
onFax={() => toast.info("팩스 전송 기능은 준비 중입니다.")}
|
||||
onKakao={handleSendKakao}
|
||||
>
|
||||
<QuoteDocument quote={quote} companyInfo={companyInfo} />
|
||||
</DocumentViewer>
|
||||
|
||||
{/* 산출내역서 다이얼로그 */}
|
||||
<DocumentViewer
|
||||
title="산출내역서"
|
||||
preset="quote"
|
||||
open={isCalculationReportOpen}
|
||||
onOpenChange={setIsCalculationReportOpen}
|
||||
onPdf={() => {
|
||||
toast.info('인쇄 대화상자에서 "PDF로 저장" 옵션을 선택하세요.');
|
||||
window.print();
|
||||
}}
|
||||
onEmail={handleSendEmail}
|
||||
onFax={() => toast.info("팩스 전송 기능은 준비 중입니다.")}
|
||||
onKakao={handleSendKakao}
|
||||
toolbarExtra={
|
||||
<>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showDetailedBreakdown}
|
||||
onChange={(e) => setShowDetailedBreakdown(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-sm">산출내역서</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showMaterialList}
|
||||
onChange={(e) => setShowMaterialList(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-sm">소요자재 내역</span>
|
||||
</label>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<QuoteCalculationReport
|
||||
quote={quote}
|
||||
companyInfo={companyInfo}
|
||||
documentType="견적산출내역서"
|
||||
showDetailedBreakdown={showDetailedBreakdown}
|
||||
showMaterialList={showMaterialList}
|
||||
/>
|
||||
</DocumentViewer>
|
||||
|
||||
{/* 발주서 다이얼로그 */}
|
||||
<DocumentViewer
|
||||
title="발주서"
|
||||
preset="quote"
|
||||
open={isPurchaseOrderOpen}
|
||||
onOpenChange={setIsPurchaseOrderOpen}
|
||||
onPdf={() => {
|
||||
toast.info('인쇄 대화상자에서 "PDF로 저장" 옵션을 선택하세요.');
|
||||
window.print();
|
||||
}}
|
||||
onEmail={handleSendEmail}
|
||||
onFax={() => toast.info("팩스 전송 기능은 준비 중입니다.")}
|
||||
onKakao={handleSendKakao}
|
||||
>
|
||||
<PurchaseOrderDocument quote={quote} companyInfo={companyInfo} />
|
||||
</DocumentViewer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,67 +1,82 @@
|
||||
/**
|
||||
* 견적 등록 페이지
|
||||
* 견적 등록 페이지 (V2 UI)
|
||||
*
|
||||
* IntegratedDetailTemplate + QuoteRegistrationV2
|
||||
* URL: /sales/quote-management/new
|
||||
*/
|
||||
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { QuoteRegistration, QuoteFormData } from "@/components/quotes/QuoteRegistration";
|
||||
import { createQuote, transformFormDataToApi } from "@/components/quotes";
|
||||
import { toast } from "sonner";
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { QuoteRegistrationV2, QuoteFormDataV2 } from '@/components/quotes/QuoteRegistrationV2';
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { quoteCreateConfig } from '@/components/quotes/quoteConfig';
|
||||
import { toast } from 'sonner';
|
||||
import { createQuote } from '@/components/quotes/actions';
|
||||
import { transformV2ToApi } from '@/components/quotes/types';
|
||||
|
||||
export default function QuoteNewPage() {
|
||||
const router = useRouter();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const handleBack = () => {
|
||||
router.push("/sales/quote-management");
|
||||
};
|
||||
const handleBack = useCallback(() => {
|
||||
router.push('/sales/quote-management');
|
||||
}, [router]);
|
||||
|
||||
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 {
|
||||
// DEBUG: 원본 formData 확인
|
||||
console.log('[QuoteNewPage] formData 원본:', {
|
||||
writer: formData.writer,
|
||||
manager: formData.manager,
|
||||
contact: formData.contact,
|
||||
remarks: formData.remarks,
|
||||
});
|
||||
// V2 폼 데이터를 API 형식으로 변환
|
||||
const updatedData = { ...data, status: saveType };
|
||||
const apiData = transformV2ToApi(updatedData);
|
||||
|
||||
// FormData를 API 요청 형식으로 변환
|
||||
const apiData = transformFormDataToApi(formData);
|
||||
console.log('[QuoteNewPage] 저장 데이터:', apiData);
|
||||
console.log('[QuoteNewPage] 저장 타입:', saveType);
|
||||
|
||||
// DEBUG: 변환된 apiData 확인
|
||||
console.log('[QuoteNewPage] apiData 변환 후:', {
|
||||
author: (apiData as any).author,
|
||||
manager: (apiData as any).manager,
|
||||
contact: (apiData as any).contact,
|
||||
remarks: (apiData as any).remarks,
|
||||
});
|
||||
// API 호출
|
||||
const result = await createQuote(apiData);
|
||||
|
||||
const result = await createQuote(apiData as any);
|
||||
if (!result.success) {
|
||||
toast.error(result.error || '저장 중 오류가 발생했습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.success && result.data) {
|
||||
// toast는 IntegratedDetailTemplate에서 처리
|
||||
toast.success(`${saveType === 'temporary' ? '임시' : '최종'} 저장 완료`);
|
||||
|
||||
// 저장 후 상세 페이지로 이동
|
||||
if (result.data?.id) {
|
||||
router.push(`/sales/quote-management/${result.data.id}`);
|
||||
return { success: true };
|
||||
} else {
|
||||
return { success: false, error: result.error || "견적 등록에 실패했습니다." };
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: "견적 등록에 실패했습니다." };
|
||||
console.error('[QuoteNewPage] 저장 오류:', error);
|
||||
toast.error('저장 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
}, [router]);
|
||||
|
||||
// 폼 콘텐츠 렌더링
|
||||
const renderFormContent = useCallback(() => {
|
||||
return (
|
||||
<QuoteRegistrationV2
|
||||
mode="create"
|
||||
onBack={handleBack}
|
||||
onSave={handleSave}
|
||||
isLoading={isSaving}
|
||||
hideHeader={true}
|
||||
/>
|
||||
);
|
||||
}, [handleBack, handleSave, isSaving]);
|
||||
|
||||
return (
|
||||
<QuoteRegistration
|
||||
<IntegratedDetailTemplate
|
||||
config={quoteCreateConfig}
|
||||
mode="create"
|
||||
isLoading={false}
|
||||
isSubmitting={isSaving}
|
||||
onBack={handleBack}
|
||||
onSave={handleSave}
|
||||
renderForm={renderFormContent}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* 견적 등록 페이지
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { QuoteRegistration, QuoteFormData } from "@/components/quotes/QuoteRegistration";
|
||||
import { createQuote, transformFormDataToApi } from "@/components/quotes";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function QuoteNewPage() {
|
||||
const router = useRouter();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const handleBack = () => {
|
||||
router.push("/sales/quote-management");
|
||||
};
|
||||
|
||||
const handleSave = async (formData: QuoteFormData): Promise<{ success: boolean; error?: string }> => {
|
||||
if (isSaving) return { success: false, error: '저장 중입니다.' };
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
// DEBUG: 원본 formData 확인
|
||||
console.log('[QuoteNewPage] formData 원본:', {
|
||||
writer: formData.writer,
|
||||
manager: formData.manager,
|
||||
contact: formData.contact,
|
||||
remarks: formData.remarks,
|
||||
});
|
||||
|
||||
// FormData를 API 요청 형식으로 변환
|
||||
const apiData = transformFormDataToApi(formData);
|
||||
|
||||
// DEBUG: 변환된 apiData 확인
|
||||
console.log('[QuoteNewPage] apiData 변환 후:', {
|
||||
author: (apiData as any).author,
|
||||
manager: (apiData as any).manager,
|
||||
contact: (apiData as any).contact,
|
||||
remarks: (apiData as any).remarks,
|
||||
});
|
||||
|
||||
const result = await createQuote(apiData as any);
|
||||
|
||||
if (result.success && result.data) {
|
||||
// toast는 IntegratedDetailTemplate에서 처리
|
||||
router.push(`/sales/quote-management/${result.data.id}`);
|
||||
return { success: true };
|
||||
} else {
|
||||
return { success: false, error: result.error || "견적 등록에 실패했습니다." };
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: "견적 등록에 실패했습니다." };
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<QuoteRegistration
|
||||
onBack={handleBack}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
/**
|
||||
* 견적 등록 테스트 페이지 (V2 UI)
|
||||
*
|
||||
* IntegratedDetailTemplate 마이그레이션 (2026-01-20)
|
||||
* 새로운 자동 견적 산출 UI 테스트용
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { QuoteRegistrationV2, QuoteFormDataV2 } from '@/components/quotes/QuoteRegistrationV2';
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { quoteCreateConfig } from '@/components/quotes/quoteConfig';
|
||||
import { toast } from 'sonner';
|
||||
import { createQuote } from '@/components/quotes/actions';
|
||||
import { transformV2ToApi } from '@/components/quotes/types';
|
||||
|
||||
export default function QuoteTestNewPage() {
|
||||
const router = useRouter();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
router.push('/sales/quote-management');
|
||||
}, [router]);
|
||||
|
||||
const handleSave = useCallback(async (data: QuoteFormDataV2, saveType: 'temporary' | 'final') => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
// V2 폼 데이터를 API 형식으로 변환
|
||||
const updatedData = { ...data, status: saveType };
|
||||
const apiData = transformV2ToApi(updatedData);
|
||||
|
||||
console.log('[QuoteTestNewPage] 저장 데이터:', apiData);
|
||||
console.log('[QuoteTestNewPage] 저장 타입:', saveType);
|
||||
|
||||
// API 호출
|
||||
const result = await createQuote(apiData);
|
||||
|
||||
if (!result.success) {
|
||||
toast.error(result.error || '저장 중 오류가 발생했습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(`${saveType === 'temporary' ? '임시' : '최종'} 저장 완료`);
|
||||
|
||||
// 저장 후 상세 페이지로 이동 (실제 생성된 ID 사용)
|
||||
if (result.data?.id) {
|
||||
router.push(`/sales/quote-management/test/${result.data.id}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[QuoteTestNewPage] 저장 오류:', error);
|
||||
toast.error('저장 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
// 폼 콘텐츠 렌더링
|
||||
const renderFormContent = useCallback(() => {
|
||||
return (
|
||||
<QuoteRegistrationV2
|
||||
mode="create"
|
||||
onBack={handleBack}
|
||||
onSave={handleSave}
|
||||
isLoading={isSaving}
|
||||
hideHeader={true}
|
||||
/>
|
||||
);
|
||||
}, [handleBack, handleSave, isSaving]);
|
||||
|
||||
return (
|
||||
<IntegratedDetailTemplate
|
||||
config={quoteCreateConfig}
|
||||
mode="create"
|
||||
isLoading={false}
|
||||
isSubmitting={isSaving}
|
||||
onBack={handleBack}
|
||||
renderForm={renderFormContent}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
interface EditQuoteTestPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 하위 호환성을 위한 리다이렉트 페이지
|
||||
* /quote-management/test/[id]/edit → /quote-management/test/[id]?mode=edit
|
||||
*/
|
||||
export default function EditQuoteTestPage({ params }: EditQuoteTestPageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
router.replace(`/ko/sales/quote-management/test/${id}?mode=edit`);
|
||||
}, [id, router]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">리다이렉트 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
/**
|
||||
* 견적 상세/수정 테스트 페이지 (V2 UI 통합)
|
||||
*
|
||||
* IntegratedDetailTemplate 마이그레이션 완료 (2026-01-20)
|
||||
* 새로운 자동 견적 산출 UI 테스트용
|
||||
* URL 패턴:
|
||||
* - /quote-management/test/[id] → 상세 보기 (view)
|
||||
* - /quote-management/test/[id]?mode=edit → 수정 모드 (edit)
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useRouter, useParams, useSearchParams } from "next/navigation";
|
||||
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 { toast } from "sonner";
|
||||
import { getQuoteById, updateQuote } from "@/components/quotes/actions";
|
||||
import { transformApiToV2, transformV2ToApi } from "@/components/quotes/types";
|
||||
|
||||
export default function QuoteTestDetailPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const quoteId = params.id as string;
|
||||
|
||||
// V2 패턴: mode 체크
|
||||
const mode = searchParams.get("mode") || "view";
|
||||
const isEditMode = mode === "edit";
|
||||
|
||||
const [quote, setQuote] = useState<QuoteFormDataV2 | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadQuote = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await getQuoteById(quoteId);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
toast.error(result.error || "견적 정보를 불러오는데 실패했습니다.");
|
||||
router.push("/sales/quote-management");
|
||||
return;
|
||||
}
|
||||
|
||||
// API 응답을 V2 폼 데이터로 변환
|
||||
const v2Data = transformApiToV2(result.data as unknown as Parameters<typeof transformApiToV2>[0]);
|
||||
setQuote(v2Data);
|
||||
} catch (error) {
|
||||
console.error("[QuoteTestDetailPage] 로드 오류:", error);
|
||||
toast.error("견적 정보를 불러오는데 실패했습니다.");
|
||||
router.push("/sales/quote-management");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (quoteId) {
|
||||
loadQuote();
|
||||
}
|
||||
}, [quoteId, router]);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
router.push("/sales/quote-management");
|
||||
}, [router]);
|
||||
|
||||
// V2 패턴: 수정 저장 핸들러
|
||||
const handleSave = useCallback(async (data: QuoteFormDataV2, saveType: "temporary" | "final") => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
// V2 폼 데이터를 API 형식으로 변환
|
||||
const updatedData = { ...data, status: saveType };
|
||||
const apiData = transformV2ToApi(updatedData);
|
||||
|
||||
console.log("[QuoteTestDetailPage] 수정 데이터:", apiData);
|
||||
console.log("[QuoteTestDetailPage] 저장 타입:", 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/test/${quoteId}`);
|
||||
} catch (error) {
|
||||
console.error("[QuoteTestDetailPage] 저장 오류:", error);
|
||||
toast.error("저장 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [router, quoteId]);
|
||||
|
||||
// 동적 config (모드별 타이틀)
|
||||
const dynamicConfig = useMemo(() => {
|
||||
const title = isEditMode ? '견적 수정 (V2 테스트)' : '견적 상세 (V2 테스트)';
|
||||
return {
|
||||
...quoteConfig,
|
||||
title,
|
||||
};
|
||||
}, [isEditMode]);
|
||||
|
||||
// 커스텀 헤더 액션 (상태 뱃지)
|
||||
const customHeaderActions = useMemo(() => {
|
||||
if (!quote) return null;
|
||||
return (
|
||||
<Badge variant={quote.status === "final" ? "default" : quote.status === "temporary" ? "secondary" : "outline"}>
|
||||
{quote.status === "final" ? "최종저장" : quote.status === "temporary" ? "임시저장" : "작성중"}
|
||||
</Badge>
|
||||
);
|
||||
}, [quote]);
|
||||
|
||||
// 폼 콘텐츠 렌더링
|
||||
const renderFormContent = useCallback(() => {
|
||||
return (
|
||||
<QuoteRegistrationV2
|
||||
mode={isEditMode ? "edit" : "view"}
|
||||
onBack={handleBack}
|
||||
onSave={isEditMode ? handleSave : undefined}
|
||||
initialData={quote}
|
||||
isLoading={isSaving}
|
||||
hideHeader={true}
|
||||
/>
|
||||
);
|
||||
}, [isEditMode, handleBack, handleSave, quote, isSaving]);
|
||||
|
||||
// IntegratedDetailTemplate 사용
|
||||
return (
|
||||
<IntegratedDetailTemplate
|
||||
config={dynamicConfig}
|
||||
mode={isEditMode ? "edit" : "view"}
|
||||
initialData={quote || {}}
|
||||
itemId={quoteId}
|
||||
isLoading={isLoading}
|
||||
headerActions={customHeaderActions}
|
||||
renderView={() => renderFormContent()}
|
||||
renderForm={() => renderFormContent()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -35,6 +35,10 @@ import {
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
|
||||
import { ItemSearchModal } from "./ItemSearchModal";
|
||||
|
||||
import type { LocationItem } from "./QuoteRegistrationV2";
|
||||
import type { FinishedGoods } from "./actions";
|
||||
import type { BomCalculationResultItem } from "./types";
|
||||
|
||||
// 납품길이 옵션
|
||||
const DELIVERY_LENGTH_OPTIONS = [
|
||||
{ value: "3000", label: "3000" },
|
||||
@@ -43,40 +47,16 @@ const DELIVERY_LENGTH_OPTIONS = [
|
||||
{ value: "6000", label: "6000" },
|
||||
];
|
||||
|
||||
// 목데이터 - 탭별 품목 아이템 (각 탭마다 다른 구조)
|
||||
const MOCK_BOM_ITEMS = {
|
||||
// 본체 (스크린/슬랫): 품목명, 제작사이즈, 수량, 작업
|
||||
body: [
|
||||
{ id: "b1", item_name: "실리카 스크린", manufacture_size: "5280*3280", quantity: 1, unit: "EA", total_price: 1061676 },
|
||||
],
|
||||
// 절곡품 - 가이드레일: 품목명, 재질, 규격, 납품길이, 수량, 작업
|
||||
"guide-rail": [
|
||||
{ id: "g1", item_name: "벽면형 마감재", material: "알루미늄", spec: "50mm", delivery_length: "4000", quantity: 2, unit: "EA", total_price: 84048 },
|
||||
{ id: "g2", item_name: "본체 가이드 레일", material: "스틸", spec: "20mm", delivery_length: "4000", quantity: 2, unit: "EA", total_price: 32508 },
|
||||
],
|
||||
// 절곡품 - 케이스: 품목명, 재질, 규격, 납품길이, 수량, 작업
|
||||
case: [
|
||||
{ id: "c1", item_name: "전면부 케이스", material: "알루미늄", spec: "30mm", delivery_length: "4000", quantity: 1, unit: "EA", total_price: 30348 },
|
||||
],
|
||||
// 절곡품 - 하단마감재: 품목명, 재질, 규격, 납품길이, 수량, 작업
|
||||
bottom: [
|
||||
{ id: "bt1", item_name: "하단 하우징", material: "스틸", spec: "40mm", delivery_length: "4000", quantity: 1, unit: "EA", total_price: 15420 },
|
||||
],
|
||||
// 모터 & 제어기: 품목명, 유형, 사양, 수량, 작업
|
||||
motor: [
|
||||
{ id: "m1", item_name: "직류 모터", type: "220V", spec: "1/2HP", quantity: 1, unit: "EA", total_price: 250000 },
|
||||
{ id: "m2", item_name: "제어기", type: "디지털", spec: "", quantity: 1, unit: "EA", total_price: 150000 },
|
||||
],
|
||||
// 부자재: 품목명, 규격, 납품길이, 수량, 작업
|
||||
accessory: [
|
||||
{ id: "a1", item_name: "각파이프 25mm", spec: "25*25*2.0t", delivery_length: "4000", quantity: 2, unit: "EA", total_price: 17000 },
|
||||
{ id: "a2", item_name: "플랫바 20mm", spec: "20*3.0t", delivery_length: "4000", quantity: 1, unit: "EA", total_price: 4200 },
|
||||
],
|
||||
// 빈 BOM 아이템 (bomResult 없을 때 사용)
|
||||
const EMPTY_BOM_ITEMS: Record<string, BomCalculationResultItem[]> = {
|
||||
body: [],
|
||||
"guide-rail": [],
|
||||
case: [],
|
||||
bottom: [],
|
||||
motor: [],
|
||||
accessory: [],
|
||||
};
|
||||
|
||||
import type { LocationItem } from "./QuoteRegistrationV2";
|
||||
import type { FinishedGoods } from "./actions";
|
||||
|
||||
// =============================================================================
|
||||
// 상수
|
||||
// =============================================================================
|
||||
@@ -148,11 +128,11 @@ export function LocationDetailPanel({
|
||||
return finishedGoods.find((fg) => fg.item_code === location.productCode);
|
||||
}, [location?.productCode, finishedGoods]);
|
||||
|
||||
// BOM 아이템을 탭별로 분류 (목데이터 사용)
|
||||
// BOM 아이템을 탭별로 분류
|
||||
const bomItemsByTab = useMemo(() => {
|
||||
// bomResult가 없으면 목데이터 사용
|
||||
// bomResult가 없으면 빈 배열 반환
|
||||
if (!location?.bomResult?.items) {
|
||||
return MOCK_BOM_ITEMS;
|
||||
return EMPTY_BOM_ITEMS;
|
||||
}
|
||||
|
||||
const items = location.bomResult.items;
|
||||
|
||||
@@ -33,59 +33,7 @@ interface DetailCategory {
|
||||
items: DetailItem[];
|
||||
}
|
||||
|
||||
const MOCK_DETAIL_TOTALS: DetailCategory[] = [
|
||||
{
|
||||
label: "본체 (스크린/슬랫)",
|
||||
count: 1,
|
||||
amount: 1061676,
|
||||
items: [
|
||||
{ name: "실리카 스크린", quantity: 1, unitPrice: 1061676, totalPrice: 1061676 },
|
||||
]
|
||||
},
|
||||
{
|
||||
label: "절곡품 - 가이드레일",
|
||||
count: 2,
|
||||
amount: 116556,
|
||||
items: [
|
||||
{ name: "벽면형 마감재", quantity: 2, unitPrice: 42024, totalPrice: 84048 },
|
||||
{ name: "본체 가이드 레일", quantity: 2, unitPrice: 16254, totalPrice: 32508 },
|
||||
]
|
||||
},
|
||||
{
|
||||
label: "절곡품 - 케이스",
|
||||
count: 1,
|
||||
amount: 30348,
|
||||
items: [
|
||||
{ name: "전면부 케이스", quantity: 1, unitPrice: 30348, totalPrice: 30348 },
|
||||
]
|
||||
},
|
||||
{
|
||||
label: "절곡품 - 하단마감재",
|
||||
count: 1,
|
||||
amount: 15420,
|
||||
items: [
|
||||
{ name: "하단 하우징", quantity: 1, unitPrice: 15420, totalPrice: 15420 },
|
||||
]
|
||||
},
|
||||
{
|
||||
label: "모터 & 제어기",
|
||||
count: 2,
|
||||
amount: 400000,
|
||||
items: [
|
||||
{ name: "직류 모터", quantity: 1, unitPrice: 250000, totalPrice: 250000 },
|
||||
{ name: "제어기", quantity: 1, unitPrice: 150000, totalPrice: 150000 },
|
||||
]
|
||||
},
|
||||
{
|
||||
label: "부자재",
|
||||
count: 2,
|
||||
amount: 21200,
|
||||
items: [
|
||||
{ name: "각파이프 25mm", quantity: 2, unitPrice: 8500, totalPrice: 17000 },
|
||||
{ name: "플랫바 20mm", quantity: 1, unitPrice: 4200, totalPrice: 4200 },
|
||||
]
|
||||
},
|
||||
];
|
||||
// Mock 데이터 제거 - bomResult 없으면 빈 배열 반환
|
||||
|
||||
// =============================================================================
|
||||
// Props
|
||||
@@ -132,11 +80,11 @@ export function QuoteSummaryPanel({
|
||||
}));
|
||||
}, [locations]);
|
||||
|
||||
// 선택 개소의 상세별 합계 (공정별) - 목데이터 포함
|
||||
// 선택 개소의 상세별 합계 (공정별)
|
||||
const detailTotals = useMemo((): DetailCategory[] => {
|
||||
// bomResult가 없으면 목데이터 사용
|
||||
// bomResult가 없으면 빈 배열 반환
|
||||
if (!selectedLocation?.bomResult?.subtotals) {
|
||||
return selectedLocation ? MOCK_DETAIL_TOTALS : [];
|
||||
return [];
|
||||
}
|
||||
|
||||
const subtotals = selectedLocation.bomResult.subtotals;
|
||||
|
||||
@@ -704,6 +704,7 @@ export function transformV2ToApi(
|
||||
const calculationInputs: CalculationInputs & { bomResults?: BomCalculationResult[] } = {
|
||||
items: data.locations.map(loc => ({
|
||||
productCategory: 'screen', // TODO: 동적으로 결정
|
||||
productCode: loc.productCode, // BOM 재계산용
|
||||
productName: loc.productName,
|
||||
openWidth: String(loc.openWidth),
|
||||
openHeight: String(loc.openHeight),
|
||||
@@ -868,26 +869,41 @@ export function transformApiToV2(apiData: QuoteApiData): QuoteFormDataV2 {
|
||||
|
||||
if (calcInputs.length > 0) {
|
||||
locations = calcInputs.map((ci, index) => {
|
||||
// 해당 인덱스의 BOM 자재에서 금액 계산
|
||||
const relatedItems = (apiData.items || []).filter(
|
||||
item => (item as QuoteItemApiData & { item_index?: number }).item_index === index ||
|
||||
(item.note && ci.floor && item.note.includes(ci.floor))
|
||||
);
|
||||
const totalPrice = relatedItems.reduce(
|
||||
(sum, item) => sum + parseFloat(String(item.total_price ?? item.total_amount ?? 0)), 0
|
||||
);
|
||||
const qty = ci.quantity || 1;
|
||||
|
||||
// 해당 인덱스의 BOM 결과 복원
|
||||
const bomResult = savedBomResults[index];
|
||||
|
||||
// 금액 계산: bomResult.grand_total 우선, 없으면 apiData.items에서 계산
|
||||
let unitPrice: number | undefined;
|
||||
let totalPrice: number | undefined;
|
||||
|
||||
if (bomResult?.grand_total) {
|
||||
// BOM 결과에서 금액 가져오기
|
||||
unitPrice = Math.round(bomResult.grand_total);
|
||||
totalPrice = Math.round(bomResult.grand_total * qty);
|
||||
} else {
|
||||
// Fallback: apiData.items에서 계산
|
||||
const relatedItems = (apiData.items || []).filter(
|
||||
item => (item as QuoteItemApiData & { item_index?: number }).item_index === index ||
|
||||
(item.note && ci.floor && item.note.includes(ci.floor))
|
||||
);
|
||||
const itemsTotal = relatedItems.reduce(
|
||||
(sum, item) => sum + parseFloat(String(item.total_price ?? item.total_amount ?? 0)), 0
|
||||
);
|
||||
if (itemsTotal > 0) {
|
||||
unitPrice = Math.round(itemsTotal / qty);
|
||||
totalPrice = itemsTotal;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: `loc-${index}`,
|
||||
floor: ci.floor || '',
|
||||
code: ci.code || '',
|
||||
openWidth: parseInt(ci.openWidth || '0', 10),
|
||||
openHeight: parseInt(ci.openHeight || '0', 10),
|
||||
productCode: '', // calculation_inputs에 없음, 필요시 items에서 추출
|
||||
productCode: (ci as { productCode?: string }).productCode || bomResult?.finished_goods?.code || '',
|
||||
productName: ci.productName || '',
|
||||
quantity: qty,
|
||||
guideRailType: ci.guideRailType || 'wall',
|
||||
@@ -895,8 +911,8 @@ export function transformApiToV2(apiData: QuoteApiData): QuoteFormDataV2 {
|
||||
controller: ci.controller || 'basic',
|
||||
wingSize: parseInt(ci.wingSize || '50', 10),
|
||||
inspectionFee: ci.inspectionFee || 50000,
|
||||
unitPrice: totalPrice > 0 ? Math.round(totalPrice / qty) : undefined,
|
||||
totalPrice: totalPrice > 0 ? totalPrice : undefined,
|
||||
unitPrice,
|
||||
totalPrice,
|
||||
bomResult: bomResult, // BOM 결과 복원
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user