feat(WEB): 견적서 V2 컴포넌트 개선 및 미리보기 모달 패턴 적용
- LocationDetailPanel: 6개 탭 구현 (본체, 가이드레일, 케이스, 하단마감재, 모터&제어기, 부자재) - 각 탭별 다른 테이블 컬럼 구조 적용 - QuoteSummaryPanel: 개소별/상세별 합계 패널 개선 - QuotePreviewModal: EstimateDocumentModal 패턴 적용 (헤더+버튼 영역 분리) - Input value → defaultValue 변경으로 React 경고 해결 - 팩스/카카오톡 버튼 제거 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user