- 개소 추가 시 BOM 계산 자동 실행 (성공 시에만 추가) - BOM 계산 실패 시 폼 초기화 방지, 에러 메시지 표시 - getFinishedGoods에 has_bom=1 파라미터 추가 - 제품 드롭다운에 코드+이름 함께 표시 - handleAddLocation을 async/await로 변경, boolean 반환
560 lines
19 KiB
TypeScript
560 lines
19 KiB
TypeScript
/**
|
||
* 발주 개소 목록 패널
|
||
*
|
||
* - 개소 목록 테이블
|
||
* - 품목 추가 폼
|
||
* - 엑셀 업로드/다운로드
|
||
*/
|
||
|
||
"use client";
|
||
|
||
import { useState, useCallback } from "react";
|
||
import { Plus, Upload, Download, Trash2, Pencil } 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 { LocationEditModal } from "./LocationEditModal";
|
||
|
||
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 [editTarget, setEditTarget] = useState<LocationItem | 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">
|
||
{/* 헤더 */}
|
||
<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 && (
|
||
<div className="flex items-center justify-center gap-1">
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-6 w-6 text-gray-500 hover:text-blue-600"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setEditTarget(loc);
|
||
}}
|
||
>
|
||
<Pencil className="h-3 w-3" />
|
||
</Button>
|
||
<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>
|
||
</div>
|
||
)}
|
||
</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>
|
||
<NumberInput
|
||
placeholder="5000"
|
||
value={formData.openWidth ? Number(formData.openWidth) : undefined}
|
||
onChange={(value) => handleFormChange("openWidth", value?.toString() ?? "")}
|
||
className="h-8 text-sm"
|
||
/>
|
||
</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"
|
||
/>
|
||
</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"
|
||
>
|
||
<Plus className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 삭제 확인 다이얼로그 */}
|
||
<DeleteConfirmDialog
|
||
open={!!deleteTarget}
|
||
onOpenChange={() => setDeleteTarget(null)}
|
||
onConfirm={() => {
|
||
if (deleteTarget) {
|
||
onDeleteLocation(deleteTarget);
|
||
setDeleteTarget(null);
|
||
}
|
||
}}
|
||
title="개소 삭제"
|
||
description="선택한 개소를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다."
|
||
/>
|
||
|
||
{/* 개소 정보 수정 모달 */}
|
||
<LocationEditModal
|
||
open={!!editTarget}
|
||
onOpenChange={(open) => !open && setEditTarget(null)}
|
||
location={editTarget}
|
||
onSave={(locationId, updates) => {
|
||
onUpdateLocation(locationId, updates);
|
||
setEditTarget(null);
|
||
toast.success("개소 정보가 수정되었습니다.");
|
||
}}
|
||
/>
|
||
</div>
|
||
);
|
||
} |