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:
byeongcheolryu
2026-01-12 15:26:17 +09:00
parent e56b7d53a4
commit d036ce4f42
40 changed files with 5292 additions and 141 deletions

View File

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