2026-01-12 15:26:17 +09:00
|
|
|
|
/**
|
|
|
|
|
|
* 견적 금액 요약 패널
|
|
|
|
|
|
*
|
|
|
|
|
|
* - 개소별 합계 (왼쪽) - 클릭하여 상세 확인
|
|
|
|
|
|
* - 상세별 합계 (오른쪽) - 선택 개소의 카테고리별 금액 및 품목 상세
|
|
|
|
|
|
* - 스크롤 가능한 상세 영역
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
"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;
|
2026-01-26 16:12:37 +09:00
|
|
|
|
const groupedItems = selectedLocation.bomResult.grouped_items;
|
2026-01-12 15:26:17 +09:00
|
|
|
|
const result: DetailCategory[] = [];
|
|
|
|
|
|
|
|
|
|
|
|
Object.entries(subtotals).forEach(([key, value]) => {
|
|
|
|
|
|
if (typeof value === "object" && value !== null) {
|
2026-01-26 16:12:37 +09:00
|
|
|
|
// 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,
|
|
|
|
|
|
}));
|
2026-01-12 15:26:17 +09:00
|
|
|
|
result.push({
|
|
|
|
|
|
label: value.name || key,
|
|
|
|
|
|
count: value.count || 0,
|
|
|
|
|
|
amount: value.subtotal || 0,
|
2026-01-26 16:12:37 +09:00
|
|
|
|
items: groupItems,
|
2026-01-12 15:26:17 +09:00
|
|
|
|
});
|
|
|
|
|
|
} 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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|