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:
2026-01-26 21:34:13 +09:00
parent f9dafbc02c
commit 05b0ba73be
10 changed files with 991 additions and 1028 deletions

View File

@@ -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()}
/>
);
}
}

View File

@@ -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>
);
}

View File

@@ -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}
/>
);
}
}

View File

@@ -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}
/>
);
}

View File

@@ -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}
/>
);
}

View File

@@ -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>
);
}

View File

@@ -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()}
/>
);
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 결과 복원
};
});