- 수주 상세 뷰/수정 컴포넌트 개선 - 견적 위치 패널 업데이트 - PDF 생성 API 수정 - 레이아웃 및 공통코드 API 업데이트 - 패키지 의존성 업데이트
540 lines
19 KiB
TypeScript
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>
|
|
);
|
|
} |