Merge remote-tracking branch 'origin/master'

# Conflicts:
#	src/app/[locale]/(protected)/construction/project/bidding/estimates/[id]/edit/page.tsx
#	src/app/[locale]/(protected)/construction/project/bidding/estimates/[id]/page.tsx
This commit is contained in:
2026-01-13 19:58:09 +09:00
132 changed files with 19588 additions and 1251 deletions

View File

@@ -0,0 +1,128 @@
/**
* 품목 검색 모달
*
* - 품목 코드로 검색
* - 품목 목록에서 선택
*/
"use client";
import { useState, useMemo } from "react";
import { Search, X } from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "../ui/dialog";
import { Input } from "../ui/input";
// =============================================================================
// 목데이터 - 품목 목록
// =============================================================================
const MOCK_ITEMS = [
{ code: "KSS01", name: "스크린", description: "방화스크린 기본형" },
{ code: "KSS02", name: "스크린", description: "방화스크린 고급형" },
{ code: "KSS03", name: "슬랫", description: "방화슬랫 기본형" },
{ code: "KSS04", name: "스크린", description: "방화스크린 특수형" },
];
// =============================================================================
// Props
// =============================================================================
interface ItemSearchModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSelectItem: (item: { code: string; name: string }) => void;
tabLabel?: string;
}
// =============================================================================
// 컴포넌트
// =============================================================================
export function ItemSearchModal({
open,
onOpenChange,
onSelectItem,
tabLabel,
}: ItemSearchModalProps) {
const [searchQuery, setSearchQuery] = useState("");
// 검색 필터링
const filteredItems = useMemo(() => {
if (!searchQuery) return MOCK_ITEMS;
const query = searchQuery.toLowerCase();
return MOCK_ITEMS.filter(
(item) =>
item.code.toLowerCase().includes(query) ||
item.name.toLowerCase().includes(query) ||
item.description.toLowerCase().includes(query)
);
}, [searchQuery]);
const handleSelect = (item: (typeof MOCK_ITEMS)[0]) => {
onSelectItem({ code: item.code, name: item.name });
onOpenChange(false);
setSearchQuery("");
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle> </DialogTitle>
</DialogHeader>
{/* 검색 입력 */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
placeholder="원하는 검색어..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery("")}
className="absolute right-3 top-1/2 -translate-y-1/2"
>
<X className="h-4 w-4 text-gray-400 hover:text-gray-600" />
</button>
)}
</div>
{/* 품목 목록 */}
<div className="max-h-[300px] overflow-y-auto border rounded-lg divide-y">
{filteredItems.length === 0 ? (
<div className="p-4 text-center text-gray-500 text-sm">
</div>
) : (
filteredItems.map((item) => (
<div
key={item.code}
onClick={() => handleSelect(item)}
className="p-3 hover:bg-blue-50 cursor-pointer transition-colors"
>
<div className="flex items-center justify-between">
<div>
<span className="font-semibold text-gray-900">{item.code}</span>
<span className="ml-2 text-sm text-gray-500">{item.name}</span>
</div>
</div>
{item.description && (
<p className="text-xs text-gray-400 mt-1">{item.description}</p>
)}
</div>
))
)}
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,623 @@
/**
* 선택 개소 상세 정보 패널
*
* - 제품 정보 (제품명, 오픈사이즈, 제작사이즈, 산출중량, 산출면적, 수량)
* - 필수 설정 (가이드레일, 전원, 제어기)
* - 탭: 본체(스크린/슬랫), 절곡품-가이드레일, 절곡품-케이스, 절곡품-하단마감재, 모터&제어기, 부자재
* - 탭별 품목 테이블 (각 탭마다 다른 컬럼 구조)
*/
"use client";
import { useState, useMemo } from "react";
import { Package, Settings, Plus, Trash2 } from "lucide-react";
import { Badge } from "../ui/badge";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
import { ItemSearchModal } from "./ItemSearchModal";
// 납품길이 옵션
const DELIVERY_LENGTH_OPTIONS = [
{ value: "3000", label: "3000" },
{ value: "4000", label: "4000" },
{ value: "5000", label: "5000" },
{ 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 },
],
};
import type { LocationItem } from "./QuoteRegistrationV2";
import type { FinishedGoods } from "./actions";
// =============================================================================
// 상수
// =============================================================================
// 가이드레일 설치 유형
const GUIDE_RAIL_TYPES = [
{ value: "wall", label: "벽면형" },
{ value: "floor", label: "측면형" },
];
// 모터 전원
const MOTOR_POWERS = [
{ value: "single", label: "단상(220V)" },
{ value: "three", label: "삼상(380V)" },
];
// 연동제어기
const CONTROLLERS = [
{ value: "basic", label: "단독" },
{ value: "smart", label: "연동" },
{ value: "premium", label: "매립형-뒷박스포함" },
];
// 탭 정의 (6개)
const DETAIL_TABS = [
{ value: "body", label: "본체 (스크린/슬랫)" },
{ value: "guide-rail", label: "절곡품 - 가이드레일" },
{ value: "case", label: "절곡품 - 케이스" },
{ value: "bottom", label: "절곡품 - 하단마감재" },
{ value: "motor", label: "모터 & 제어기" },
{ value: "accessory", label: "부자재" },
];
// =============================================================================
// Props
// =============================================================================
interface LocationDetailPanelProps {
location: LocationItem | null;
onUpdateLocation: (locationId: string, updates: Partial<LocationItem>) => void;
finishedGoods: FinishedGoods[];
disabled?: boolean;
}
// =============================================================================
// 컴포넌트
// =============================================================================
export function LocationDetailPanel({
location,
onUpdateLocation,
finishedGoods,
disabled = false,
}: LocationDetailPanelProps) {
// ---------------------------------------------------------------------------
// 상태
// ---------------------------------------------------------------------------
const [activeTab, setActiveTab] = useState("body");
const [itemSearchOpen, setItemSearchOpen] = useState(false);
// ---------------------------------------------------------------------------
// 계산된 값
// ---------------------------------------------------------------------------
// 제품 정보
const product = useMemo(() => {
if (!location?.productCode) return null;
return finishedGoods.find((fg) => fg.item_code === location.productCode);
}, [location?.productCode, finishedGoods]);
// BOM 아이템을 탭별로 분류 (목데이터 사용)
const bomItemsByTab = useMemo(() => {
// bomResult가 없으면 목데이터 사용
if (!location?.bomResult?.items) {
return MOCK_BOM_ITEMS;
}
const items = location.bomResult.items;
const result: Record<string, typeof items> = {
body: [],
"guide-rail": [],
case: [],
bottom: [],
};
items.forEach((item) => {
const processGroup = item.process_group?.toLowerCase() || "";
if (processGroup.includes("본체") || processGroup.includes("스크린") || processGroup.includes("슬랫")) {
result.body.push(item);
} else if (processGroup.includes("가이드") || processGroup.includes("레일")) {
result["guide-rail"].push(item);
} else if (processGroup.includes("케이스")) {
result.case.push(item);
} else if (processGroup.includes("하단") || processGroup.includes("마감")) {
result.bottom.push(item);
} else {
// 기타 항목은 본체에 포함
result.body.push(item);
}
});
return result;
}, [location?.bomResult?.items]);
// 탭별 소계
const tabSubtotals = useMemo(() => {
const result: Record<string, number> = {};
Object.entries(bomItemsByTab).forEach(([tab, items]) => {
result[tab] = items.reduce((sum, item) => sum + (item.total_price || 0), 0);
});
return result;
}, [bomItemsByTab]);
// ---------------------------------------------------------------------------
// 핸들러
// ---------------------------------------------------------------------------
const handleFieldChange = (field: keyof LocationItem, value: string | number) => {
if (!location || disabled) return;
onUpdateLocation(location.id, { [field]: value });
};
// ---------------------------------------------------------------------------
// 렌더링: 빈 상태
// ---------------------------------------------------------------------------
if (!location) {
return (
<div className="flex flex-col items-center justify-center h-full bg-gray-50 text-gray-500">
<Package className="h-12 w-12 mb-4 text-gray-300" />
<p className="text-lg font-medium"> </p>
<p className="text-sm"> </p>
</div>
);
}
// ---------------------------------------------------------------------------
// 렌더링: 상세 정보
// ---------------------------------------------------------------------------
return (
<div className="flex flex-col h-full overflow-hidden">
{/* 헤더 */}
<div className="bg-white px-4 py-3 border-b">
<div className="flex items-center justify-between">
<h3 className="text-lg font-bold">
{location.floor} / {location.code}
</h3>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500">:</span>
<Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-200">
{location.productCode}
</Badge>
{location.bomResult && (
<Badge variant="default" className="bg-green-600">
</Badge>
)}
</div>
</div>
</div>
{/* 제품 정보 */}
<div className="bg-gray-50 px-4 py-3 border-b space-y-3">
{/* 오픈사이즈 */}
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600 w-20"></span>
<div className="flex items-center gap-2">
<Input
type="number"
value={location.openWidth}
onChange={(e) => handleFieldChange("openWidth", parseFloat(e.target.value) || 0)}
disabled={disabled}
className="w-24 h-8 text-center font-bold"
/>
<span className="text-gray-400">×</span>
<Input
type="number"
value={location.openHeight}
onChange={(e) => handleFieldChange("openHeight", parseFloat(e.target.value) || 0)}
disabled={disabled}
className="w-24 h-8 text-center font-bold"
/>
{!disabled && (
<Badge variant="secondary" className="text-xs"></Badge>
)}
</div>
</div>
{/* 제작사이즈, 산출중량, 산출면적, 수량 */}
<div className="grid grid-cols-4 gap-4 text-sm">
<div>
<span className="text-gray-500"></span>
<p className="font-semibold">
{location.manufactureWidth || location.openWidth + 280} × {location.manufactureHeight || location.openHeight + 280}
</p>
</div>
<div>
<span className="text-gray-500"></span>
<p className="font-semibold">{location.weight?.toFixed(1) || "-"} <span className="text-xs text-gray-400">kg</span></p>
</div>
<div>
<span className="text-gray-500"></span>
<p className="font-semibold">{location.area?.toFixed(1) || "-"} <span className="text-xs text-gray-400">m²</span></p>
</div>
<div>
<span className="text-gray-500"></span>
<Input
type="number"
min="1"
value={location.quantity}
onChange={(e) => handleFieldChange("quantity", parseInt(e.target.value) || 1)}
disabled={disabled}
className="w-24 h-7 text-center font-semibold"
/>
</div>
</div>
</div>
{/* 필수 설정 (읽기 전용) */}
<div className="bg-white px-4 py-3 border-b">
<h4 className="text-sm font-semibold text-gray-700 mb-2 flex items-center gap-1">
<Settings className="h-4 w-4" />
</h4>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="text-xs text-gray-500 flex items-center gap-1 mb-1">
🔧
</label>
<Badge variant="outline" className="bg-gray-50 text-gray-700 border-gray-300 px-3 py-1.5">
{GUIDE_RAIL_TYPES.find(t => t.value === location.guideRailType)?.label || location.guideRailType}
</Badge>
</div>
<div>
<label className="text-xs text-gray-500 flex items-center gap-1 mb-1">
</label>
<Badge variant="outline" className="bg-gray-50 text-gray-700 border-gray-300 px-3 py-1.5">
{MOTOR_POWERS.find(p => p.value === location.motorPower)?.label || location.motorPower}
</Badge>
</div>
<div>
<label className="text-xs text-gray-500 flex items-center gap-1 mb-1">
📦
</label>
<Badge variant="outline" className="bg-gray-50 text-gray-700 border-gray-300 px-3 py-1.5">
{CONTROLLERS.find(c => c.value === location.controller)?.label || location.controller}
</Badge>
</div>
</div>
</div>
{/* 탭 및 품목 테이블 */}
<div className="flex-1 overflow-hidden flex flex-col">
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col h-full">
{/* 탭 목록 - 스크롤 가능 */}
<div className="border-b bg-white overflow-x-auto">
<TabsList className="w-max min-w-full justify-start rounded-none bg-transparent h-auto p-0">
{DETAIL_TABS.map((tab) => (
<TabsTrigger
key={tab.value}
value={tab.value}
className="rounded-none border-b-2 border-transparent data-[state=active]:border-blue-500 data-[state=active]:bg-blue-50 data-[state=active]:text-blue-700 px-4 py-2 text-sm whitespace-nowrap"
>
{tab.label}
</TabsTrigger>
))}
</TabsList>
</div>
{/* 본체 (스크린/슬랫) 탭 */}
<TabsContent value="body" className="flex-1 overflow-auto m-0 p-0">
<div className="bg-amber-50 border border-amber-200 rounded-lg m-4">
<Table>
<TableHeader>
<TableRow className="bg-amber-100/50">
<TableHead className="font-semibold"></TableHead>
<TableHead className="text-center font-semibold"></TableHead>
<TableHead className="text-center font-semibold w-24"></TableHead>
<TableHead className="text-center font-semibold w-20"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{bomItemsByTab.body.map((item: any) => (
<TableRow key={item.id} className="bg-white">
<TableCell className="font-medium">{item.item_name}</TableCell>
<TableCell className="text-center text-gray-600">{item.manufacture_size || "-"}</TableCell>
<TableCell className="text-center">
<Input
type="number"
defaultValue={item.quantity}
className="w-16 h-8 text-center"
min={1}
readOnly={disabled}
/>
</TableCell>
<TableCell className="text-center">
<Button variant="ghost" size="sm" className="text-red-500 hover:text-red-700 hover:bg-red-50 p-1" disabled={disabled}>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{/* 품목 추가 버튼 + 안내 */}
<div className="p-3 flex items-center justify-between border-t border-amber-200">
<Button
variant="outline"
size="sm"
className="bg-blue-100 border-blue-300 text-blue-700 hover:bg-blue-200"
onClick={() => setItemSearchOpen(true)}
disabled={disabled}
>
<Plus className="h-4 w-4 mr-1" />
</Button>
<span className="text-sm text-gray-500 flex items-center gap-1">
💡
</span>
</div>
</div>
</TabsContent>
{/* 절곡품 - 가이드레일, 케이스, 하단마감재 탭 */}
{["guide-rail", "case", "bottom"].map((tabValue) => (
<TabsContent key={tabValue} value={tabValue} className="flex-1 overflow-auto m-0 p-0">
<div className="bg-amber-50 border border-amber-200 rounded-lg m-4">
<Table>
<TableHeader>
<TableRow className="bg-amber-100/50">
<TableHead className="font-semibold"></TableHead>
<TableHead className="text-center font-semibold"></TableHead>
<TableHead className="text-center font-semibold"></TableHead>
<TableHead className="text-center font-semibold w-28"></TableHead>
<TableHead className="text-center font-semibold w-24"></TableHead>
<TableHead className="text-center font-semibold w-20"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{bomItemsByTab[tabValue]?.map((item: any) => (
<TableRow key={item.id} className="bg-white">
<TableCell className="font-medium">{item.item_name}</TableCell>
<TableCell className="text-center text-gray-600">{item.material || "-"}</TableCell>
<TableCell className="text-center text-gray-600">{item.spec || "-"}</TableCell>
<TableCell className="text-center">
<Select defaultValue={item.delivery_length} disabled={disabled}>
<SelectTrigger className="w-24 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DELIVERY_LENGTH_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell className="text-center">
<Input
type="number"
defaultValue={item.quantity}
className="w-16 h-8 text-center"
min={1}
readOnly={disabled}
/>
</TableCell>
<TableCell className="text-center">
<Button variant="ghost" size="sm" className="text-red-500 hover:text-red-700 hover:bg-red-50 p-1" disabled={disabled}>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{/* 품목 추가 버튼 + 안내 */}
<div className="p-3 flex items-center justify-between border-t border-amber-200">
<Button
variant="outline"
size="sm"
className="bg-blue-100 border-blue-300 text-blue-700 hover:bg-blue-200"
onClick={() => setItemSearchOpen(true)}
disabled={disabled}
>
<Plus className="h-4 w-4 mr-1" />
</Button>
<span className="text-sm text-gray-500 flex items-center gap-1">
💡
</span>
</div>
</div>
</TabsContent>
))}
{/* 모터 & 제어기 탭 */}
<TabsContent value="motor" className="flex-1 overflow-auto m-0 p-0">
<div className="bg-amber-50 border border-amber-200 rounded-lg m-4">
<Table>
<TableHeader>
<TableRow className="bg-amber-100/50">
<TableHead className="font-semibold"></TableHead>
<TableHead className="text-center font-semibold"></TableHead>
<TableHead className="text-center font-semibold"></TableHead>
<TableHead className="text-center font-semibold w-24"></TableHead>
<TableHead className="text-center font-semibold w-20"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{bomItemsByTab.motor?.map((item: any) => (
<TableRow key={item.id} className="bg-white">
<TableCell className="font-medium">{item.item_name}</TableCell>
<TableCell className="text-center text-gray-600">{item.type || "-"}</TableCell>
<TableCell className="text-center text-gray-600">{item.spec || "-"}</TableCell>
<TableCell className="text-center">
<Input
type="number"
defaultValue={item.quantity}
className="w-16 h-8 text-center"
min={1}
readOnly={disabled}
/>
</TableCell>
<TableCell className="text-center">
<Button variant="ghost" size="sm" className="text-red-500 hover:text-red-700 hover:bg-red-50 p-1" disabled={disabled}>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{/* 품목 추가 버튼 + 안내 */}
<div className="p-3 flex items-center justify-between border-t border-amber-200">
<Button
variant="outline"
size="sm"
className="bg-blue-100 border-blue-300 text-blue-700 hover:bg-blue-200"
onClick={() => setItemSearchOpen(true)}
disabled={disabled}
>
<Plus className="h-4 w-4 mr-1" />
</Button>
<span className="text-sm text-gray-500 flex items-center gap-1">
💡
</span>
</div>
</div>
</TabsContent>
{/* 부자재 탭 */}
<TabsContent value="accessory" className="flex-1 overflow-auto m-0 p-0">
<div className="bg-amber-50 border border-amber-200 rounded-lg m-4">
<Table>
<TableHeader>
<TableRow className="bg-amber-100/50">
<TableHead className="font-semibold"></TableHead>
<TableHead className="text-center font-semibold"></TableHead>
<TableHead className="text-center font-semibold w-28"></TableHead>
<TableHead className="text-center font-semibold w-24"></TableHead>
<TableHead className="text-center font-semibold w-20"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{bomItemsByTab.accessory?.map((item: any) => (
<TableRow key={item.id} className="bg-white">
<TableCell className="font-medium">{item.item_name}</TableCell>
<TableCell className="text-center text-gray-600">{item.spec || "-"}</TableCell>
<TableCell className="text-center">
<Select defaultValue={item.delivery_length} disabled={disabled}>
<SelectTrigger className="w-24 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DELIVERY_LENGTH_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell className="text-center">
<Input
type="number"
defaultValue={item.quantity}
className="w-16 h-8 text-center"
min={1}
readOnly={disabled}
/>
</TableCell>
<TableCell className="text-center">
<Button variant="ghost" size="sm" className="text-red-500 hover:text-red-700 hover:bg-red-50 p-1" disabled={disabled}>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{/* 품목 추가 버튼 + 안내 */}
<div className="p-3 flex items-center justify-between border-t border-amber-200">
<Button
variant="outline"
size="sm"
className="bg-blue-100 border-blue-300 text-blue-700 hover:bg-blue-200"
onClick={() => setItemSearchOpen(true)}
disabled={disabled}
>
<Plus className="h-4 w-4 mr-1" />
</Button>
<span className="text-sm text-gray-500 flex items-center gap-1">
💡
</span>
</div>
</div>
</TabsContent>
</Tabs>
</div>
{/* 금액 안내 */}
{!location.bomResult && (
<div className="bg-blue-50 px-4 py-2 border-t border-blue-200 text-center text-sm text-blue-700">
💡
</div>
)}
{/* 품목 검색 모달 */}
<ItemSearchModal
open={itemSearchOpen}
onOpenChange={setItemSearchOpen}
onSelectItem={(item) => {
console.log(`[테스트] 품목 선택: ${item.code} - ${item.name} (탭: ${activeTab})`);
}}
tabLabel={DETAIL_TABS.find((t) => t.value === activeTab)?.label}
/>
</div>
);
}

View File

@@ -0,0 +1,548 @@
/**
* 발주 개소 목록 패널
*
* - 개소 목록 테이블
* - 품목 추가 폼
* - 엑셀 업로드/다운로드
*/
"use client";
import { useState, useCallback } from "react";
import { Plus, Upload, Download, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../ui/table";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "../ui/alert-dialog";
import type { LocationItem } from "./QuoteRegistrationV2";
import type { FinishedGoods } from "./actions";
import * as XLSX from "xlsx";
// =============================================================================
// 상수
// =============================================================================
// 가이드레일 설치 유형
const GUIDE_RAIL_TYPES = [
{ value: "wall", label: "벽면형" },
{ value: "floor", label: "측면형" },
];
// 모터 전원
const MOTOR_POWERS = [
{ value: "single", label: "단상(220V)" },
{ value: "three", label: "삼상(380V)" },
];
// 연동제어기
const CONTROLLERS = [
{ value: "basic", label: "단독" },
{ value: "smart", label: "연동" },
{ value: "premium", label: "매립형-뒷박스포함" },
];
// =============================================================================
// Props
// =============================================================================
interface LocationListPanelProps {
locations: LocationItem[];
selectedLocationId: string | null;
onSelectLocation: (id: string) => void;
onAddLocation: (location: Omit<LocationItem, "id">) => void;
onDeleteLocation: (id: string) => void;
onExcelUpload: (locations: Omit<LocationItem, "id">[]) => void;
finishedGoods: FinishedGoods[];
disabled?: boolean;
}
// =============================================================================
// 컴포넌트
// =============================================================================
export function LocationListPanel({
locations,
selectedLocationId,
onSelectLocation,
onAddLocation,
onDeleteLocation,
onExcelUpload,
finishedGoods,
disabled = false,
}: LocationListPanelProps) {
// ---------------------------------------------------------------------------
// 상태
// ---------------------------------------------------------------------------
// 추가 폼 상태
const [formData, setFormData] = useState({
floor: "",
code: "",
openWidth: "",
openHeight: "",
productCode: "",
quantity: "1",
guideRailType: "wall",
motorPower: "single",
controller: "basic",
});
// 삭제 확인 다이얼로그
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
// ---------------------------------------------------------------------------
// 핸들러
// ---------------------------------------------------------------------------
// 폼 필드 변경
const handleFormChange = useCallback((field: string, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
}, []);
// 개소 추가
const handleAdd = useCallback(() => {
// 유효성 검사
if (!formData.floor || !formData.code) {
toast.error("층과 부호를 입력해주세요.");
return;
}
if (!formData.openWidth || !formData.openHeight) {
toast.error("가로와 세로를 입력해주세요.");
return;
}
if (!formData.productCode) {
toast.error("제품을 선택해주세요.");
return;
}
const product = finishedGoods.find((fg) => fg.item_code === formData.productCode);
const newLocation: Omit<LocationItem, "id"> = {
floor: formData.floor,
code: formData.code,
openWidth: parseFloat(formData.openWidth) || 0,
openHeight: parseFloat(formData.openHeight) || 0,
productCode: formData.productCode,
productName: product?.item_name || formData.productCode,
quantity: parseInt(formData.quantity) || 1,
guideRailType: formData.guideRailType,
motorPower: formData.motorPower,
controller: formData.controller,
wingSize: 50,
inspectionFee: 50000,
};
onAddLocation(newLocation);
// 폼 초기화 (일부 필드 유지)
setFormData((prev) => ({
...prev,
floor: "",
code: "",
openWidth: "",
openHeight: "",
quantity: "1",
}));
}, [formData, finishedGoods, onAddLocation]);
// 엑셀 양식 다운로드
const handleDownloadTemplate = useCallback(() => {
const templateData = [
{
: "1층",
: "FSS-01",
가로: 5000,
세로: 3000,
: "KSS01",
수량: 1,
: "wall",
: "single",
: "basic",
},
];
const ws = XLSX.utils.json_to_sheet(templateData);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, "개소목록");
// 컬럼 너비 설정
ws["!cols"] = [
{ wch: 10 }, // 층
{ wch: 12 }, // 부호
{ wch: 10 }, // 가로
{ wch: 10 }, // 세로
{ wch: 15 }, // 제품코드
{ wch: 8 }, // 수량
{ wch: 12 }, // 가이드레일
{ wch: 12 }, // 전원
{ wch: 12 }, // 제어기
];
XLSX.writeFile(wb, "견적_개소목록_양식.xlsx");
toast.success("엑셀 양식이 다운로드되었습니다.");
}, []);
// 엑셀 업로드
const handleFileUpload = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = new Uint8Array(e.target?.result as ArrayBuffer);
const workbook = XLSX.read(data, { type: "array" });
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
const jsonData = XLSX.utils.sheet_to_json(worksheet);
const parsedLocations: Omit<LocationItem, "id">[] = jsonData.map((row: any) => {
const productCode = row["제품코드"] || "";
const product = finishedGoods.find((fg) => fg.item_code === productCode);
return {
floor: String(row["층"] || ""),
code: String(row["부호"] || ""),
openWidth: parseFloat(row["가로"]) || 0,
openHeight: parseFloat(row["세로"]) || 0,
productCode: productCode,
productName: product?.item_name || productCode,
quantity: parseInt(row["수량"]) || 1,
guideRailType: row["가이드레일"] || "wall",
motorPower: row["전원"] || "single",
controller: row["제어기"] || "basic",
wingSize: 50,
inspectionFee: 50000,
};
});
// 유효한 데이터만 필터링
const validLocations = parsedLocations.filter(
(loc) => loc.floor && loc.code && loc.openWidth > 0 && loc.openHeight > 0
);
if (validLocations.length === 0) {
toast.error("유효한 데이터가 없습니다. 양식을 확인해주세요.");
return;
}
onExcelUpload(validLocations);
} catch (error) {
console.error("엑셀 파싱 오류:", error);
toast.error("엑셀 파일을 읽는 중 오류가 발생했습니다.");
}
};
reader.readAsArrayBuffer(file);
// 파일 입력 초기화
event.target.value = "";
},
[finishedGoods, onExcelUpload]
);
// ---------------------------------------------------------------------------
// 렌더링
// ---------------------------------------------------------------------------
return (
<div className="border-r border-gray-200 flex flex-col">
{/* 헤더 */}
<div className="bg-blue-100 px-4 py-3 border-b border-blue-200">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-blue-800">
📋 ({locations.length})
</h3>
<div className="flex gap-1">
<Button
variant="outline"
size="sm"
onClick={handleDownloadTemplate}
disabled={disabled}
className="text-xs"
>
<Download className="h-3 w-3 mr-1" />
</Button>
<label>
<input
type="file"
accept=".xlsx,.xls"
onChange={handleFileUpload}
disabled={disabled}
className="hidden"
/>
<Button
variant="outline"
size="sm"
disabled={disabled}
className="text-xs"
asChild
>
<span>
<Upload className="h-3 w-3 mr-1" />
</span>
</Button>
</label>
</div>
</div>
</div>
{/* 개소 목록 테이블 */}
<div className="flex-1 overflow-auto">
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
<TableHead className="w-[60px] text-center"></TableHead>
<TableHead className="w-[80px] text-center"></TableHead>
<TableHead className="w-[100px] text-center"></TableHead>
<TableHead className="w-[80px] text-center"></TableHead>
<TableHead className="w-[50px] text-center"></TableHead>
<TableHead className="w-[40px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{locations.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-gray-500 py-8">
</TableCell>
</TableRow>
) : (
locations.map((loc) => (
<TableRow
key={loc.id}
className={`cursor-pointer hover:bg-blue-50 ${
selectedLocationId === loc.id ? "bg-blue-100" : ""
}`}
onClick={() => onSelectLocation(loc.id)}
>
<TableCell className="text-center font-medium">{loc.floor}</TableCell>
<TableCell className="text-center">{loc.code}</TableCell>
<TableCell className="text-center text-sm">
{loc.openWidth}×{loc.openHeight}
</TableCell>
<TableCell className="text-center text-sm">{loc.productCode}</TableCell>
<TableCell className="text-center">{loc.quantity}</TableCell>
<TableCell className="text-center">
{!disabled && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-red-500 hover:text-red-600"
onClick={(e) => {
e.stopPropagation();
setDeleteTarget(loc.id);
}}
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* 추가 폼 */}
{!disabled && (
<div className="border-t border-blue-200 bg-blue-50 p-4 space-y-3">
{/* 1행: 층, 부호, 가로, 세로, 제품명, 수량 */}
<div className="grid grid-cols-6 gap-2">
<div>
<label className="text-xs text-gray-600"></label>
<Input
placeholder="1층"
value={formData.floor}
onChange={(e) => handleFormChange("floor", e.target.value)}
className="h-8 text-sm"
/>
</div>
<div>
<label className="text-xs text-gray-600"></label>
<Input
placeholder="FSS-01"
value={formData.code}
onChange={(e) => handleFormChange("code", e.target.value)}
className="h-8 text-sm"
/>
</div>
<div>
<label className="text-xs text-gray-600"></label>
<Input
type="number"
placeholder="5000"
value={formData.openWidth}
onChange={(e) => handleFormChange("openWidth", e.target.value)}
className="h-8 text-sm"
/>
</div>
<div>
<label className="text-xs text-gray-600"></label>
<Input
type="number"
placeholder="3000"
value={formData.openHeight}
onChange={(e) => handleFormChange("openHeight", e.target.value)}
className="h-8 text-sm"
/>
</div>
<div>
<label className="text-xs text-gray-600"></label>
<Select
value={formData.productCode}
onValueChange={(value) => handleFormChange("productCode", value)}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{finishedGoods.map((fg) => (
<SelectItem key={fg.item_code} value={fg.item_code}>
{fg.item_code}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="text-xs text-gray-600"></label>
<Input
type="number"
min="1"
value={formData.quantity}
onChange={(e) => handleFormChange("quantity", e.target.value)}
className="h-8 text-sm"
/>
</div>
</div>
{/* 2행: 가이드레일, 전원, 제어기, 버튼 */}
<div className="flex items-end gap-2">
<div className="flex-1">
<label className="text-xs text-gray-600 flex items-center gap-1">
🔧
</label>
<Select
value={formData.guideRailType}
onValueChange={(value) => handleFormChange("guideRailType", value)}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{GUIDE_RAIL_TYPES.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex-1">
<label className="text-xs text-gray-600 flex items-center gap-1">
</label>
<Select
value={formData.motorPower}
onValueChange={(value) => handleFormChange("motorPower", value)}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{MOTOR_POWERS.map((power) => (
<SelectItem key={power.value} value={power.value}>
{power.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex-1">
<label className="text-xs text-gray-600 flex items-center gap-1">
📦
</label>
<Select
value={formData.controller}
onValueChange={(value) => handleFormChange("controller", value)}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CONTROLLERS.map((ctrl) => (
<SelectItem key={ctrl.value} value={ctrl.value}>
{ctrl.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
onClick={handleAdd}
className="h-8 bg-green-500 hover:bg-green-600"
>
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
)}
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (deleteTarget) {
onDeleteLocation(deleteTarget);
setDeleteTarget(null);
}
}}
className="bg-red-500 hover:bg-red-600"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -0,0 +1,136 @@
/**
* 견적 푸터 바
*
* - 예상 전체 견적금액 표시
* - 버튼: 견적서 산출, 임시저장, 최종저장
* - 뒤로가기 버튼
*/
"use client";
import { Download, Save, Check, ArrowLeft, Loader2, Calculator, Eye } from "lucide-react";
import { Button } from "../ui/button";
// =============================================================================
// Props
// =============================================================================
interface QuoteFooterBarProps {
totalLocations: number;
totalAmount: number;
status: "draft" | "temporary" | "final";
onCalculate: () => void;
onPreview: () => void;
onSaveTemporary: () => void;
onSaveFinal: () => void;
onBack: () => void;
isCalculating?: boolean;
isSaving?: boolean;
disabled?: boolean;
}
// =============================================================================
// 컴포넌트
// =============================================================================
export function QuoteFooterBar({
totalLocations,
totalAmount,
status,
onCalculate,
onPreview,
onSaveTemporary,
onSaveFinal,
onBack,
isCalculating = false,
isSaving = false,
disabled = false,
}: QuoteFooterBarProps) {
return (
<div className="sticky bottom-0 bg-gradient-to-r from-blue-50 to-indigo-50 border-t border-blue-200 shadow-lg">
<div className="px-6 py-4 flex items-center justify-between">
{/* 왼쪽: 뒤로가기 + 금액 표시 */}
<div className="flex items-center gap-6">
<Button
variant="outline"
onClick={onBack}
className="gap-2"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<p className="text-sm text-gray-600"> </p>
<p className="text-3xl font-bold text-blue-600">
{totalAmount.toLocaleString()}
<span className="text-lg font-normal text-gray-500 ml-1"></span>
</p>
</div>
</div>
{/* 오른쪽: 버튼들 */}
<div className="flex items-center gap-3">
{/* 견적서 산출 */}
<Button
onClick={onCalculate}
disabled={disabled || isCalculating || totalLocations === 0}
className="bg-indigo-600 hover:bg-indigo-700 text-white gap-2 px-6"
>
{isCalculating ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Calculator className="h-4 w-4" />
</>
)}
</Button>
{/* 미리보기 */}
<Button
onClick={onPreview}
disabled={disabled || totalLocations === 0}
variant="outline"
className="gap-2 px-6"
>
<Eye className="h-4 w-4" />
</Button>
{/* 임시저장 */}
<Button
onClick={onSaveTemporary}
disabled={disabled || isSaving}
className="bg-slate-500 hover:bg-slate-600 text-white gap-2 px-6"
>
{isSaving ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Save className="h-4 w-4" />
)}
</Button>
{/* 최종저장 */}
<Button
onClick={onSaveFinal}
disabled={disabled || isSaving || totalAmount === 0}
className="bg-blue-600 hover:bg-blue-700 text-white gap-2 px-6"
>
{isSaving ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Check className="h-4 w-4" />
)}
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,303 @@
/**
* 견적서 미리보기 모달
*
* - 견적서 문서 형식으로 미리보기
* - PDF, 이메일 전송 버튼
* - 인쇄 기능
*/
"use client";
import { Download, Mail, Printer, X as XIcon } from "lucide-react";
import {
Dialog,
DialogContent,
DialogTitle,
VisuallyHidden,
} from "../ui/dialog";
import { Button } from "../ui/button";
import type { QuoteFormDataV2 } from "./QuoteRegistrationV2";
// =============================================================================
// Props
// =============================================================================
interface QuotePreviewModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
quoteData: QuoteFormDataV2 | null;
}
// =============================================================================
// 컴포넌트
// =============================================================================
export function QuotePreviewModal({
open,
onOpenChange,
quoteData,
}: QuotePreviewModalProps) {
if (!quoteData) return null;
// 총 금액 계산
const totalAmount = quoteData.locations.reduce(
(sum, loc) => sum + (loc.totalPrice || 0),
0
);
// 부가세
const vat = Math.round(totalAmount * 0.1);
const grandTotal = totalAmount + vat;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] md:max-w-[800px] lg:max-w-[900px] h-[95vh] p-0 flex flex-col gap-0 overflow-hidden [&>button]:hidden">
<VisuallyHidden>
<DialogTitle> </DialogTitle>
</VisuallyHidden>
{/* 헤더 영역 - 제목 + 닫기 버튼 */}
<div className="flex-shrink-0 flex items-center justify-between px-6 py-4 border-b bg-white">
<h2 className="text-lg font-semibold"></h2>
<Button
variant="ghost"
size="icon"
onClick={() => onOpenChange(false)}
className="h-8 w-8"
>
<XIcon className="h-4 w-4" />
</Button>
</div>
{/* 버튼 영역 - PDF, 이메일, 인쇄 */}
<div className="flex-shrink-0 flex items-center gap-2 px-6 py-3 bg-muted/30 border-b">
<Button
variant="outline"
size="sm"
className="bg-red-500 hover:bg-red-600 text-white border-red-500"
onClick={() => console.log("[테스트] PDF 다운로드")}
>
<Download className="h-4 w-4 mr-1" />
PDF
</Button>
<Button
variant="outline"
size="sm"
className="bg-yellow-500 hover:bg-yellow-600 text-white border-yellow-500"
onClick={() => console.log("[테스트] 이메일 전송")}
>
<Mail className="h-4 w-4 mr-1" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => console.log("[테스트] 인쇄")}
>
<Printer className="h-4 w-4 mr-1" />
</Button>
</div>
{/* 문서 영역 - 스크롤 */}
<div className="flex-1 overflow-y-auto bg-gray-100 p-6">
<div className="max-w-[210mm] mx-auto bg-white shadow-lg rounded-lg p-8">
{/* 제목 */}
<div className="text-center mb-6">
<h1 className="text-3xl font-bold tracking-widest"> </h1>
<p className="text-sm text-gray-500 mt-2">
: {quoteData.id || "-"} | : {quoteData.registrationDate || "-"}
</p>
</div>
{/* 수요자 정보 */}
<div className="border border-gray-300 mb-4">
<div className="bg-gray-100 px-3 py-2 font-semibold border-b border-gray-300">
</div>
<div className="p-3 grid grid-cols-2 gap-2 text-sm">
<div className="flex">
<span className="w-20 text-gray-600"></span>
<span className="font-medium">{quoteData.clientName || "-"}</span>
</div>
<div className="flex">
<span className="w-20 text-gray-600"></span>
<span className="font-medium">{quoteData.manager || "-"}</span>
</div>
<div className="flex">
<span className="w-20 text-gray-600"></span>
<span className="font-medium">{quoteData.siteName || "-"}</span>
</div>
<div className="flex">
<span className="w-20 text-gray-600"></span>
<span className="font-medium">{quoteData.contact || "-"}</span>
</div>
<div className="flex">
<span className="w-20 text-gray-600"></span>
<span className="font-medium">{quoteData.registrationDate || "-"}</span>
</div>
<div className="flex">
<span className="w-20 text-gray-600"></span>
<span className="font-medium">{quoteData.dueDate || "-"}</span>
</div>
</div>
</div>
{/* 공급자 정보 */}
<div className="border border-gray-300 mb-6">
<div className="bg-gray-100 px-3 py-2 font-semibold border-b border-gray-300">
</div>
<div className="p-3 grid grid-cols-2 gap-2 text-sm">
<div className="flex">
<span className="w-24 text-gray-600"></span>
<span className="font-medium">_테스트회사</span>
</div>
<div className="flex">
<span className="w-24 text-gray-600"></span>
<span className="font-medium">123-45-67890</span>
</div>
<div className="flex">
<span className="w-24 text-gray-600"></span>
<span className="font-medium"></span>
</div>
<div className="flex">
<span className="w-24 text-gray-600"></span>
<span className="font-medium"></span>
</div>
<div className="flex col-span-2">
<span className="w-24 text-gray-600"></span>
<span className="font-medium"></span>
</div>
<div className="flex col-span-2">
<span className="w-24 text-gray-600"></span>
<span className="font-medium">07547 583 B-1602</span>
</div>
<div className="flex">
<span className="w-24 text-gray-600"></span>
<span className="font-medium">01048209104</span>
</div>
<div className="flex">
<span className="w-24 text-gray-600"></span>
<span className="font-medium">codebridgex@codebridge-x.com</span>
</div>
</div>
</div>
{/* 총 견적금액 */}
<div className="border-2 border-gray-800 p-4 mb-6 text-center">
<p className="text-sm text-gray-600 mb-1"> </p>
<p className="text-3xl font-bold">
{grandTotal.toLocaleString()}
</p>
<p className="text-xs text-gray-500 mt-1"> </p>
</div>
{/* 제품 구성정보 */}
<div className="border border-gray-300 mb-6">
<div className="bg-gray-800 text-white px-3 py-2 font-semibold">
</div>
<div className="p-3 grid grid-cols-2 gap-2 text-sm">
<div className="flex">
<span className="w-20 text-gray-600"></span>
<span className="font-medium">
{quoteData.locations[0]?.productCode || "-"}
</span>
</div>
<div className="flex">
<span className="w-20 text-gray-600"> </span>
<span className="font-medium">{quoteData.locations.length}</span>
</div>
<div className="flex">
<span className="w-20 text-gray-600"></span>
<span className="font-medium">
{quoteData.locations[0]?.openWidth || "-"} × {quoteData.locations[0]?.openHeight || "-"}
</span>
</div>
<div className="flex">
<span className="w-20 text-gray-600"></span>
<span className="font-medium">-</span>
</div>
</div>
</div>
{/* 품목 내역 */}
<div className="border border-gray-300 mb-6">
<div className="bg-gray-800 text-white px-3 py-2 font-semibold">
</div>
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-100 border-b border-gray-300">
<th className="px-3 py-2 text-left">No.</th>
<th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-center"></th>
<th className="px-3 py-2 text-center"></th>
<th className="px-3 py-2 text-center"></th>
<th className="px-3 py-2 text-right"></th>
<th className="px-3 py-2 text-right"></th>
</tr>
</thead>
<tbody>
{quoteData.locations.map((loc, index) => (
<tr key={loc.id} className="border-b border-gray-200">
<td className="px-3 py-2">{index + 1}</td>
<td className="px-3 py-2">{loc.productCode}</td>
<td className="px-3 py-2 text-center">
{loc.openWidth}×{loc.openHeight}
</td>
<td className="px-3 py-2 text-center">{loc.quantity}</td>
<td className="px-3 py-2 text-center">EA</td>
<td className="px-3 py-2 text-right">
{(loc.unitPrice || 0).toLocaleString()}
</td>
<td className="px-3 py-2 text-right">
{(loc.totalPrice || 0).toLocaleString()}
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t-2 border-gray-400">
<td colSpan={5}></td>
<td className="px-3 py-2 text-right font-medium"> </td>
<td className="px-3 py-2 text-right font-bold">
{totalAmount.toLocaleString()}
</td>
</tr>
<tr>
<td colSpan={5}></td>
<td className="px-3 py-2 text-right font-medium"> (10%)</td>
<td className="px-3 py-2 text-right font-bold">
{vat.toLocaleString()}
</td>
</tr>
<tr className="bg-gray-100">
<td colSpan={5}></td>
<td className="px-3 py-2 text-right font-medium"> </td>
<td className="px-3 py-2 text-right font-bold text-lg">
{grandTotal.toLocaleString()}
</td>
</tr>
</tfoot>
</table>
</div>
{/* 비고사항 */}
<div className="border border-gray-300">
<div className="bg-gray-800 text-white px-3 py-2 font-semibold">
</div>
<div className="p-3 min-h-[80px] text-sm text-gray-600">
{quoteData.remarks || "비고 테스트"}
</div>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,603 @@
/**
* 견적 등록/수정 컴포넌트 V2
*
* 새로운 레이아웃:
* - 좌우 분할: 발주 개소 목록 | 선택 개소 상세
* - 하단: 견적 금액 요약 (개소별 + 상세별)
* - 푸터: 총 금액 + 버튼들 (견적서 산출, 임시저장, 최종저장)
*/
"use client";
import { useState, useEffect, useMemo, useCallback } from "react";
import { FileText, Calculator, Download, Save, Check } from "lucide-react";
import { toast } from "sonner";
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
import { Button } from "../ui/button";
import { Badge } from "../ui/badge";
import { Input } from "../ui/input";
import { Textarea } from "../ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
import {
ResponsiveFormTemplate,
FormSection,
FormFieldGrid,
} from "../templates/ResponsiveFormTemplate";
import { FormField } from "../molecules/FormField";
import { LocationListPanel } from "./LocationListPanel";
import { LocationDetailPanel } from "./LocationDetailPanel";
import { QuoteSummaryPanel } from "./QuoteSummaryPanel";
import { QuoteFooterBar } from "./QuoteFooterBar";
import { QuotePreviewModal } from "./QuotePreviewModal";
import {
getFinishedGoods,
calculateBomBulk,
getSiteNames,
type FinishedGoods,
type BomCalculationResult,
} from "./actions";
import { getClients } from "../accounting/VendorManagement/actions";
import { isNextRedirectError } from "@/lib/utils/redirect-error";
import type { Vendor } from "../accounting/VendorManagement";
import type { BomMaterial, CalculationResults } from "./types";
// =============================================================================
// 타입 정의
// =============================================================================
// 발주 개소 항목
export interface LocationItem {
id: string;
floor: string; // 층
code: string; // 부호
openWidth: number; // 가로 (오픈사이즈 W)
openHeight: number; // 세로 (오픈사이즈 H)
productCode: string; // 제품코드
productName: string; // 제품명
quantity: number; // 수량
guideRailType: string; // 가이드레일 설치 유형
motorPower: string; // 모터 전원
controller: string; // 연동제어기
wingSize: number; // 마구리 날개치수
inspectionFee: number; // 검사비
// 계산 결과
manufactureWidth?: number; // 제작사이즈 W
manufactureHeight?: number; // 제작사이즈 H
weight?: number; // 산출중량 (kg)
area?: number; // 산출면적 (m²)
unitPrice?: number; // 단가
totalPrice?: number; // 합계
bomResult?: BomCalculationResult; // BOM 계산 결과
}
// 견적 폼 데이터 V2
export interface QuoteFormDataV2 {
id?: string;
registrationDate: string;
writer: string;
clientId: string;
clientName: string;
siteName: string;
manager: string;
contact: string;
dueDate: string;
remarks: string;
status: "draft" | "temporary" | "final"; // 작성중, 임시저장, 최종저장
locations: LocationItem[];
}
// =============================================================================
// 상수
// =============================================================================
// 초기 개소 항목
const createNewLocation = (): LocationItem => ({
id: `loc-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
floor: "",
code: "",
openWidth: 0,
openHeight: 0,
productCode: "",
productName: "",
quantity: 1,
guideRailType: "wall",
motorPower: "single",
controller: "basic",
wingSize: 50,
inspectionFee: 50000,
});
// 초기 폼 데이터
const INITIAL_FORM_DATA: QuoteFormDataV2 = {
registrationDate: new Date().toISOString().split("T")[0],
writer: "드미트리", // TODO: 로그인 사용자 정보
clientId: "",
clientName: "",
siteName: "",
manager: "",
contact: "",
dueDate: "",
remarks: "",
status: "draft",
locations: [],
};
// =============================================================================
// Props
// =============================================================================
interface QuoteRegistrationV2Props {
mode: "create" | "view" | "edit";
onBack: () => void;
onSave?: (data: QuoteFormDataV2, saveType: "temporary" | "final") => Promise<void>;
onCalculate?: () => void;
initialData?: QuoteFormDataV2 | null;
isLoading?: boolean;
}
// =============================================================================
// 메인 컴포넌트
// =============================================================================
export function QuoteRegistrationV2({
mode,
onBack,
onSave,
onCalculate,
initialData,
isLoading = false,
}: QuoteRegistrationV2Props) {
// ---------------------------------------------------------------------------
// 상태
// ---------------------------------------------------------------------------
const [formData, setFormData] = useState<QuoteFormDataV2>(
initialData || INITIAL_FORM_DATA
);
const [selectedLocationId, setSelectedLocationId] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [isCalculating, setIsCalculating] = useState(false);
const [previewModalOpen, setPreviewModalOpen] = useState(false);
// API 데이터
const [clients, setClients] = useState<Vendor[]>([]);
const [finishedGoods, setFinishedGoods] = useState<FinishedGoods[]>([]);
const [siteNames, setSiteNames] = useState<string[]>([]);
const [isLoadingClients, setIsLoadingClients] = useState(false);
const [isLoadingProducts, setIsLoadingProducts] = useState(false);
// ---------------------------------------------------------------------------
// 계산된 값
// ---------------------------------------------------------------------------
// 선택된 개소
const selectedLocation = useMemo(() => {
return formData.locations.find((loc) => loc.id === selectedLocationId) || null;
}, [formData.locations, selectedLocationId]);
// 총 금액
const totalAmount = useMemo(() => {
return formData.locations.reduce((sum, loc) => sum + (loc.totalPrice || 0), 0);
}, [formData.locations]);
// 개소별 합계
const locationTotals = useMemo(() => {
return formData.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,
}));
}, [formData.locations]);
// ---------------------------------------------------------------------------
// 초기 데이터 로드
// ---------------------------------------------------------------------------
useEffect(() => {
const loadInitialData = async () => {
// 거래처 로드
setIsLoadingClients(true);
try {
const result = await getClients();
if (result.success) {
setClients(result.data);
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error("거래처 로드 실패:", error);
} finally {
setIsLoadingClients(false);
}
// 완제품 로드
setIsLoadingProducts(true);
try {
const result = await getFinishedGoods();
if (result.success) {
setFinishedGoods(result.data);
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error("완제품 로드 실패:", error);
} finally {
setIsLoadingProducts(false);
}
// 현장명 로드
try {
const result = await getSiteNames();
if (result.success) {
setSiteNames(result.data);
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
}
};
loadInitialData();
}, []);
// initialData 변경 시 formData 업데이트
useEffect(() => {
if (initialData) {
setFormData(initialData);
// 첫 번째 개소 자동 선택
if (initialData.locations.length > 0 && !selectedLocationId) {
setSelectedLocationId(initialData.locations[0].id);
}
}
}, [initialData]);
// ---------------------------------------------------------------------------
// 핸들러
// ---------------------------------------------------------------------------
// 기본 정보 변경
const handleFieldChange = useCallback((field: keyof QuoteFormDataV2, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
}, []);
// 발주처 선택
const handleClientChange = useCallback((clientId: string) => {
const client = clients.find((c) => c.id === clientId);
setFormData((prev) => ({
...prev,
clientId,
clientName: client?.vendorName || "",
}));
}, [clients]);
// 개소 추가
const handleAddLocation = useCallback((location: Omit<LocationItem, "id">) => {
const newLocation: LocationItem = {
...location,
id: `loc-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
};
setFormData((prev) => ({
...prev,
locations: [...prev.locations, newLocation],
}));
setSelectedLocationId(newLocation.id);
toast.success("개소가 추가되었습니다.");
}, []);
// 개소 삭제
const handleDeleteLocation = useCallback((locationId: string) => {
setFormData((prev) => ({
...prev,
locations: prev.locations.filter((loc) => loc.id !== locationId),
}));
if (selectedLocationId === locationId) {
setSelectedLocationId(formData.locations[0]?.id || null);
}
toast.success("개소가 삭제되었습니다.");
}, [selectedLocationId, formData.locations]);
// 개소 수정
const handleUpdateLocation = useCallback((locationId: string, updates: Partial<LocationItem>) => {
setFormData((prev) => ({
...prev,
locations: prev.locations.map((loc) =>
loc.id === locationId ? { ...loc, ...updates } : loc
),
}));
}, []);
// 엑셀 업로드
const handleExcelUpload = useCallback((locations: Omit<LocationItem, "id">[]) => {
const newLocations: LocationItem[] = locations.map((loc) => ({
...loc,
id: `loc-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
}));
setFormData((prev) => ({
...prev,
locations: [...prev.locations, ...newLocations],
}));
if (newLocations.length > 0) {
setSelectedLocationId(newLocations[0].id);
}
toast.success(`${newLocations.length}개 개소가 추가되었습니다.`);
}, []);
// 견적 산출
const handleCalculate = useCallback(async () => {
if (formData.locations.length === 0) {
toast.error("산출할 개소가 없습니다.");
return;
}
setIsCalculating(true);
try {
const bomItems = formData.locations.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,
}));
const result = await calculateBomBulk(bomItems);
if (result.success && result.data) {
const apiData = result.data as {
summary?: { grand_total: number };
items?: Array<{ index: number; result: BomCalculationResult }>;
};
// 결과 반영
const updatedLocations = formData.locations.map((loc, index) => {
const bomResult = apiData.items?.find((item) => item.index === index);
if (bomResult?.result) {
return {
...loc,
unitPrice: bomResult.result.grand_total,
totalPrice: bomResult.result.grand_total * loc.quantity,
bomResult: bomResult.result,
};
}
return loc;
});
setFormData((prev) => ({ ...prev, locations: updatedLocations }));
toast.success(`${formData.locations.length}개 개소의 견적이 산출되었습니다.`);
} else {
toast.error(`견적 산출 실패: ${result.error}`);
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
toast.error("견적 산출 중 오류가 발생했습니다.");
} finally {
setIsCalculating(false);
}
}, [formData.locations]);
// 저장 (임시/최종)
const handleSave = useCallback(async (saveType: "temporary" | "final") => {
if (!onSave) return;
setIsSaving(true);
try {
const dataToSave: QuoteFormDataV2 = {
...formData,
status: saveType === "temporary" ? "temporary" : "final",
};
await onSave(dataToSave, saveType);
toast.success(saveType === "temporary" ? "임시 저장되었습니다." : "최종 저장되었습니다.");
} catch (error) {
if (isNextRedirectError(error)) throw error;
toast.error("저장 중 오류가 발생했습니다.");
} finally {
setIsSaving(false);
}
}, [formData, onSave]);
// ---------------------------------------------------------------------------
// 렌더링
// ---------------------------------------------------------------------------
const isViewMode = mode === "view";
const pageTitle = mode === "create" ? "견적 등록 (V2 테스트)" : mode === "edit" ? "견적 수정 (V2 테스트)" : "견적 상세 (V2 테스트)";
return (
<div className="flex flex-col h-full">
{/* 기본 정보 섹션 */}
<div className="p-4 md:p-6 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold flex items-center gap-2">
<FileText className="h-6 w-6" />
{pageTitle}
</h1>
<Badge variant={formData.status === "final" ? "default" : formData.status === "temporary" ? "secondary" : "outline"}>
{formData.status === "final" ? "최종저장" : formData.status === "temporary" ? "임시저장" : "작성중"}
</Badge>
</div>
{/* 기본 정보 */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<FileText className="h-5 w-5" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="text-sm font-medium text-gray-700"></label>
<Input
type="date"
value={formData.registrationDate}
disabled
className="bg-gray-50"
/>
</div>
<div>
<label className="text-sm font-medium text-gray-700"></label>
<Input
value={formData.writer}
disabled
className="bg-gray-50"
/>
</div>
<div>
<label className="text-sm font-medium text-gray-700"> <span className="text-red-500">*</span></label>
<Select
value={formData.clientId}
onValueChange={handleClientChange}
disabled={isViewMode || isLoadingClients}
>
<SelectTrigger>
<SelectValue placeholder={isLoadingClients ? "로딩 중..." : "발주처를 선택하세요"} />
</SelectTrigger>
<SelectContent>
{clients.map((client) => (
<SelectItem key={client.id} value={client.id}>
{client.vendorName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="text-sm font-medium text-gray-700"></label>
<Input
list="siteNameList"
placeholder="현장명을 입력하세요"
value={formData.siteName}
onChange={(e) => handleFieldChange("siteName", e.target.value)}
disabled={isViewMode}
/>
<datalist id="siteNameList">
{siteNames.map((name) => (
<option key={name} value={name} />
))}
</datalist>
</div>
<div>
<label className="text-sm font-medium text-gray-700"> </label>
<Input
placeholder="담당자명을 입력하세요"
value={formData.manager}
onChange={(e) => handleFieldChange("manager", e.target.value)}
disabled={isViewMode}
/>
</div>
<div>
<label className="text-sm font-medium text-gray-700"></label>
<Input
placeholder="010-1234-5678"
value={formData.contact}
onChange={(e) => handleFieldChange("contact", e.target.value)}
disabled={isViewMode}
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="text-sm font-medium text-gray-700"></label>
<Input
type="date"
value={formData.dueDate}
onChange={(e) => handleFieldChange("dueDate", e.target.value)}
disabled={isViewMode}
/>
</div>
<div className="md:col-span-2">
<label className="text-sm font-medium text-gray-700"></label>
<Textarea
placeholder="특이사항을 입력하세요"
value={formData.remarks}
onChange={(e) => handleFieldChange("remarks", e.target.value)}
disabled={isViewMode}
rows={2}
/>
</div>
</div>
</CardContent>
</Card>
{/* 자동 견적 산출 섹션 */}
<Card>
<CardHeader className="pb-3 bg-orange-50 border-b border-orange-200">
<CardTitle className="text-base font-semibold flex items-center gap-2 text-orange-800">
<Calculator className="h-5 w-5" />
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{/* 좌우 분할 레이아웃 */}
<div className="grid grid-cols-1 lg:grid-cols-2 min-h-[500px]">
{/* 왼쪽: 발주 개소 목록 + 추가 폼 */}
<LocationListPanel
locations={formData.locations}
selectedLocationId={selectedLocationId}
onSelectLocation={setSelectedLocationId}
onAddLocation={handleAddLocation}
onDeleteLocation={handleDeleteLocation}
onExcelUpload={handleExcelUpload}
finishedGoods={finishedGoods}
disabled={isViewMode}
/>
{/* 오른쪽: 선택 개소 상세 */}
<LocationDetailPanel
location={selectedLocation}
onUpdateLocation={handleUpdateLocation}
finishedGoods={finishedGoods}
disabled={isViewMode}
/>
</div>
</CardContent>
</Card>
{/* 견적 금액 요약 */}
<QuoteSummaryPanel
locations={formData.locations}
selectedLocationId={selectedLocationId}
onSelectLocation={setSelectedLocationId}
/>
</div>
{/* 푸터 바 (고정) */}
<QuoteFooterBar
totalLocations={formData.locations.length}
totalAmount={totalAmount}
status={formData.status}
onCalculate={handleCalculate}
onPreview={() => setPreviewModalOpen(true)}
onSaveTemporary={() => handleSave("temporary")}
onSaveFinal={() => handleSave("final")}
onBack={onBack}
isCalculating={isCalculating}
isSaving={isSaving}
disabled={isViewMode}
/>
{/* 견적서 미리보기 모달 */}
<QuotePreviewModal
open={previewModalOpen}
onOpenChange={setPreviewModalOpen}
quoteData={formData}
/>
</div>
);
}

View File

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