Files
sam-react-prod/src/components/quotes/LocationListPanel.tsx
권혁성 6bcd298995 feat: 수주/견적 기능 개선 및 PDF 생성 업데이트
- 수주 상세 뷰/수정 컴포넌트 개선
- 견적 위치 패널 업데이트
- PDF 생성 API 수정
- 레이아웃 및 공통코드 API 업데이트
- 패키지 의존성 업데이트
2026-01-29 01:12:58 +09:00

540 lines
19 KiB
TypeScript

/**
* 발주 개소 목록 패널
*
* - 개소 목록 테이블
* - 품목 추가 폼
* - 엑셀 업로드/다운로드
*/
"use client";
import { useState, useCallback } from "react";
import { Plus, Upload, Download, Pencil, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { NumberInput } from "../ui/number-input";
import { QuantityInput } from "../ui/quantity-input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../ui/table";
import { DeleteConfirmDialog } from "../ui/confirm-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">) => Promise<boolean>;
onDeleteLocation: (id: string) => void;
onUpdateLocation: (locationId: string, updates: Partial<LocationItem>) => void;
onExcelUpload: (locations: Omit<LocationItem, "id">[]) => void;
finishedGoods: FinishedGoods[];
disabled?: boolean;
}
// =============================================================================
// 컴포넌트
// =============================================================================
export function LocationListPanel({
locations,
selectedLocationId,
onSelectLocation,
onAddLocation,
onDeleteLocation,
onUpdateLocation,
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 }));
}, []);
// 개소 추가 (BOM 계산 성공 시에만 폼 초기화)
const handleAdd = useCallback(async () => {
// 유효성 검사
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,
};
// BOM 계산 성공 시에만 폼 초기화
const success = await onAddLocation(newLocation);
if (success) {
// 폼 초기화 (일부 필드 유지)
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">
{/* ① 입력 영역 (상단으로 이동) */}
{!disabled && (
<div className="bg-gray-50 p-4 space-y-3 border-b border-gray-200">
{/* 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 placeholder:text-gray-300"
/>
</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 placeholder:text-gray-300"
/>
</div>
<div>
<label className="text-xs text-gray-600"></label>
<NumberInput
placeholder="예: 5000"
value={formData.openWidth ? Number(formData.openWidth) : undefined}
onChange={(value) => handleFormChange("openWidth", value?.toString() ?? "")}
className="h-8 text-sm placeholder:text-gray-300"
/>
</div>
<div>
<label className="text-xs text-gray-600"></label>
<NumberInput
placeholder="예: 3000"
value={formData.openHeight ? Number(formData.openHeight) : undefined}
onChange={(value) => handleFormChange("openHeight", value?.toString() ?? "")}
className="h-8 text-sm placeholder:text-gray-300"
/>
</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} {fg.item_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="text-xs text-gray-600"></label>
<QuantityInput
value={formData.quantity ? Number(formData.quantity) : undefined}
onChange={(value) => handleFormChange("quantity", value?.toString() ?? "")}
className="h-8 text-sm"
min={1}
/>
</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 px-4"
>
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
)}
{/* 발주 개소 목록 헤더 */}
<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-800 text-white">
<TableHead className="text-center text-white"></TableHead>
<TableHead className="text-center text-white"></TableHead>
<TableHead className="text-center text-white"></TableHead>
<TableHead className="text-center text-white"></TableHead>
<TableHead className="text-center text-white"></TableHead>
{!disabled && <TableHead className="w-[80px] text-center text-white"></TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{locations.length === 0 ? (
<TableRow>
<TableCell colSpan={disabled ? 5 : 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-orange-100 border-l-4 border-l-orange-500" : ""
}`}
onClick={() => onSelectLocation(loc.id)}
>
<TableCell className="text-center">{loc.floor}</TableCell>
<TableCell className="text-center font-medium">{loc.code}</TableCell>
<TableCell className="text-center">{loc.openWidth}X{loc.openHeight}</TableCell>
<TableCell className="text-center">{loc.productCode}</TableCell>
<TableCell className="text-center">{loc.quantity}</TableCell>
{!disabled && (
<TableCell className="text-center">
<div className="flex items-center justify-center gap-1">
<button
onClick={(e) => {
e.stopPropagation();
onSelectLocation(loc.id);
}}
className="p-1 hover:bg-gray-200 rounded"
title="수정"
>
<Pencil className="h-4 w-4 text-gray-600" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
setDeleteTarget(loc.id);
}}
className="p-1 hover:bg-red-100 rounded"
title="삭제"
>
<Trash2 className="h-4 w-4 text-red-500" />
</button>
</div>
</TableCell>
)}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* 삭제 확인 다이얼로그 */}
<DeleteConfirmDialog
open={!!deleteTarget}
onOpenChange={() => setDeleteTarget(null)}
onConfirm={() => {
if (deleteTarget) {
onDeleteLocation(deleteTarget);
setDeleteTarget(null);
}
}}
title="개소 삭제"
description="선택한 개소를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다."
/>
</div>
);
}