Files
sam-react-prod/src/components/quotes/QuoteSummaryPanel.tsx
권혁성 6402a38cb4 fix: 견적 V2 자동 견적 산출 UI 오류 수정
- actions.ts: BomBulkResponse 타입, FinishedGoods에 has_bom/bom 필드 추가
- QuoteRegistrationV2.tsx: handleCalculate 응답 처리, DevFill BOM 필터링
- LocationDetailPanel.tsx: bomItemsByTab process_group 기반 매핑
- QuoteSummaryPanel.tsx: detailTotals grouped_items 기반 계산

해결된 문제:
1. 오른쪽 패널 제품 리스트 미표시
2. 개소별 합계(상세소계) 미표시
3. 상세별 합계(그룹) 미표시
4. 예상 견적금액 0원 표시
2026-01-26 16:12:37 +09:00

328 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 견적 금액 요약 패널
*
* - 개소별 합계 (왼쪽) - 클릭하여 상세 확인
* - 상세별 합계 (오른쪽) - 선택 개소의 카테고리별 금액 및 품목 상세
* - 스크롤 가능한 상세 영역
*/
"use client";
import { useMemo } from "react";
import { Coins } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
import type { LocationItem } from "./QuoteRegistrationV2";
// =============================================================================
// 목데이터 - 상세별 합계 (공정별 + 품목 상세)
// =============================================================================
interface DetailItem {
name: string;
quantity: number;
unitPrice: number;
totalPrice: number;
}
interface DetailCategory {
label: string;
count: number;
amount: number;
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 },
]
},
];
// =============================================================================
// Props
// =============================================================================
interface QuoteSummaryPanelProps {
locations: LocationItem[];
selectedLocationId: string | null;
onSelectLocation: (id: string) => void;
}
// =============================================================================
// 컴포넌트
// =============================================================================
export function QuoteSummaryPanel({
locations,
selectedLocationId,
onSelectLocation,
}: QuoteSummaryPanelProps) {
// ---------------------------------------------------------------------------
// 계산된 값
// ---------------------------------------------------------------------------
// 선택된 개소
const selectedLocation = useMemo(() => {
return locations.find((loc) => loc.id === selectedLocationId) || null;
}, [locations, selectedLocationId]);
// 총 금액
const totalAmount = useMemo(() => {
return locations.reduce((sum, loc) => sum + (loc.totalPrice || 0), 0);
}, [locations]);
// 개소별 합계
const locationTotals = useMemo(() => {
return locations.map((loc) => ({
id: loc.id,
label: `${loc.floor} / ${loc.code}`,
productCode: loc.productCode,
quantity: loc.quantity,
unitPrice: loc.unitPrice || 0,
totalPrice: loc.totalPrice || 0,
}));
}, [locations]);
// 선택 개소의 상세별 합계 (공정별) - 목데이터 포함
const detailTotals = useMemo((): DetailCategory[] => {
// bomResult가 없으면 목데이터 사용
if (!selectedLocation?.bomResult?.subtotals) {
return selectedLocation ? MOCK_DETAIL_TOTALS : [];
}
const subtotals = selectedLocation.bomResult.subtotals;
const groupedItems = selectedLocation.bomResult.grouped_items;
const result: DetailCategory[] = [];
Object.entries(subtotals).forEach(([key, value]) => {
if (typeof value === "object" && value !== null) {
// grouped_items에서 items 가져오기 (subtotals에는 items가 없을 수 있음)
const groupItemsRaw = groupedItems?.[key]?.items || value.items || [];
// DetailItem 형식으로 변환
const groupItems: DetailItem[] = (groupItemsRaw as Array<{
item_name?: string;
name?: string;
quantity?: number;
unit_price?: number;
total_price?: number;
}>).map((item) => ({
name: item.item_name || item.name || "",
quantity: item.quantity || 0,
unitPrice: item.unit_price || 0,
totalPrice: item.total_price || 0,
}));
result.push({
label: value.name || key,
count: value.count || 0,
amount: value.subtotal || 0,
items: groupItems,
});
} else if (typeof value === "number") {
result.push({
label: key,
count: 0,
amount: value,
items: [],
});
}
});
return result;
}, [selectedLocation]);
// ---------------------------------------------------------------------------
// 렌더링
// ---------------------------------------------------------------------------
return (
<Card className="border-gray-200">
<CardHeader className="pb-3 bg-gradient-to-r from-blue-50 to-indigo-50 border-b border-blue-200">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<Coins className="h-5 w-5 text-blue-600" />
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{/* 좌우 분할 */}
<div className="grid grid-cols-1 lg:grid-cols-2 divide-y lg:divide-y-0 lg:divide-x divide-gray-200">
{/* 왼쪽: 개소별 합계 */}
<div className="p-4">
<div className="flex items-center gap-2 mb-3">
<span className="text-blue-500">📍</span>
<h4 className="font-semibold text-gray-700"> </h4>
</div>
{locations.length === 0 ? (
<div className="text-center text-gray-500 py-6">
<p className="text-sm"> </p>
</div>
) : (
<div className="space-y-2 max-h-[300px] overflow-y-auto pr-2">
{locationTotals.map((loc) => (
<div
key={loc.id}
className={`flex items-center justify-between p-3 rounded-lg cursor-pointer transition-colors ${
selectedLocationId === loc.id
? "bg-blue-100 border border-blue-300"
: "bg-gray-50 hover:bg-gray-100 border border-transparent"
}`}
onClick={() => onSelectLocation(loc.id)}
>
<div>
<p className="font-medium">{loc.label}</p>
<p className="text-xs text-gray-500">
{loc.productCode} × {loc.quantity}
</p>
</div>
<div className="text-right">
<p className="text-xs text-gray-500"></p>
<p className="font-bold text-blue-600">
{loc.totalPrice.toLocaleString()}
</p>
{loc.unitPrice > 0 && (
<p className="text-xs text-gray-400">
: {(loc.unitPrice * loc.quantity).toLocaleString()}
</p>
)}
</div>
</div>
))}
</div>
)}
</div>
{/* 오른쪽: 상세별 합계 */}
<div className="p-4">
<div className="flex items-center gap-2 mb-3">
<span className="text-blue-500"></span>
<h4 className="font-semibold text-gray-700">
{selectedLocation && (
<span className="text-sm font-normal text-gray-500 ml-2">
({selectedLocation.floor} / {selectedLocation.code})
</span>
)}
</h4>
</div>
{!selectedLocation ? (
<div className="text-center text-gray-500 py-6">
<p className="text-sm"> </p>
</div>
) : detailTotals.length === 0 ? (
<div className="text-center text-gray-500 py-6">
<p className="text-sm"> </p>
</div>
) : (
<div className="space-y-3 max-h-[300px] overflow-y-auto pr-2">
{detailTotals.map((category, index) => (
<div key={index} className="bg-blue-50 rounded-lg overflow-hidden border border-blue-200">
{/* 카테고리 헤더 */}
<div className="flex items-center justify-between px-3 py-2 bg-blue-100/50">
<span className="font-semibold text-gray-700">{category.label}</span>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">({category.count})</span>
<span className="font-bold text-blue-600">
{category.amount.toLocaleString()}
</span>
</div>
</div>
{/* 품목 상세 목록 */}
<div className="divide-y divide-blue-100">
{category.items.map((item, itemIndex) => (
<div key={itemIndex} className="flex items-center justify-between px-3 py-2 bg-white">
<div>
<span className="text-sm text-gray-700">{item.name}</span>
<p className="text-xs text-gray-400">
: {item.quantity} × : {item.unitPrice.toLocaleString()}
</p>
</div>
<span className="text-sm font-medium text-blue-600">
{item.totalPrice.toLocaleString()}
</span>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* 하단 바: 총 개소 수, 예상 견적금액, 견적 상태 */}
<div className="bg-gray-900 text-white px-6 py-5 flex items-center justify-between">
<div className="flex items-center gap-10">
<div>
<p className="text-sm text-gray-400"> </p>
<p className="text-4xl font-bold">{locations.length}</p>
</div>
<div>
<p className="text-sm text-gray-400"> </p>
<p className="text-4xl font-bold text-blue-400">
{totalAmount.toLocaleString()}
<span className="text-xl ml-1"></span>
</p>
</div>
</div>
<div className="text-right">
<p className="text-sm text-gray-400"> </p>
<span className="inline-block bg-blue-500/20 text-blue-300 border border-blue-500/50 text-lg px-4 py-1 rounded">
</span>
</div>
</div>
</CardContent>
</Card>
);
}