Files
sam-react-prod/src/components/quotes/QuoteSummaryPanel.tsx
권혁성 5af6500671 feat(WEB): 견적 자동산출 UI 개선 - datalist, 탭 카테고리, 스크롤 제거
- 층/부호 필드에 datalist 자동완성 추가 (B3~10F, 기존 부호코드)
- 부호 데이터 조회 API 신규 추가 (GET /quotes/reference-data)
- 제품코드 Select에 코드만 표시 (중복 제거)
- LocationDetailPanel 탭을 subtotals 기반으로 동적 생성 + 기타 탭
- grouped_items 기반 탭별 품목 매핑으로 카테고리 불일치 해소
- QuoteSummaryPanel 상세별 합계 스크롤 제거
- BomCalculationResult 타입에 grouped_items 필드 추가
2026-02-04 11:07:52 +09:00

276 lines
10 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[];
}
// Mock 데이터 제거 - bomResult 없으면 빈 배열 반환
// =============================================================================
// 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 [];
}
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">
{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>
);
}