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:
128
src/components/quotes/ItemSearchModal.tsx
Normal file
128
src/components/quotes/ItemSearchModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
623
src/components/quotes/LocationDetailPanel.tsx
Normal file
623
src/components/quotes/LocationDetailPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
548
src/components/quotes/LocationListPanel.tsx
Normal file
548
src/components/quotes/LocationListPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
136
src/components/quotes/QuoteFooterBar.tsx
Normal file
136
src/components/quotes/QuoteFooterBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
303
src/components/quotes/QuotePreviewModal.tsx
Normal file
303
src/components/quotes/QuotePreviewModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
603
src/components/quotes/QuoteRegistrationV2.tsx
Normal file
603
src/components/quotes/QuoteRegistrationV2.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
311
src/components/quotes/QuoteSummaryPanel.tsx
Normal file
311
src/components/quotes/QuoteSummaryPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user