462 lines
16 KiB
TypeScript
462 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { ArrowLeft, Save, Loader2, X } from "lucide-react";
|
|
import { yardLayoutApi, materialApi } from "@/lib/api/yardLayoutApi";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import dynamic from "next/dynamic";
|
|
|
|
const Yard3DCanvas = dynamic(() => import("./Yard3DCanvas"), {
|
|
ssr: false,
|
|
loading: () => (
|
|
<div className="flex h-full items-center justify-center bg-gray-900">
|
|
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
|
|
</div>
|
|
),
|
|
});
|
|
|
|
interface TempMaterial {
|
|
id: number;
|
|
material_code: string;
|
|
material_name: string;
|
|
category: string;
|
|
unit: string;
|
|
default_color: string;
|
|
description: string;
|
|
}
|
|
|
|
interface YardLayout {
|
|
id: number;
|
|
name: string;
|
|
description: string;
|
|
placement_count?: number;
|
|
updated_at: string;
|
|
}
|
|
|
|
interface YardPlacement {
|
|
id: number;
|
|
yard_layout_id: number;
|
|
external_material_id: string;
|
|
material_code: string;
|
|
material_name: string;
|
|
quantity: number;
|
|
unit: string;
|
|
position_x: number;
|
|
position_y: number;
|
|
position_z: number;
|
|
size_x: number;
|
|
size_y: number;
|
|
size_z: number;
|
|
color: string;
|
|
memo?: string;
|
|
}
|
|
|
|
interface YardEditorProps {
|
|
layout: YardLayout;
|
|
onBack: () => void;
|
|
}
|
|
|
|
export default function YardEditor({ layout, onBack }: YardEditorProps) {
|
|
const [placements, setPlacements] = useState<YardPlacement[]>([]);
|
|
const [materials, setMaterials] = useState<TempMaterial[]>([]);
|
|
const [selectedPlacement, setSelectedPlacement] = useState<YardPlacement | null>(null);
|
|
const [selectedMaterial, setSelectedMaterial] = useState<TempMaterial | null>(null);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const [searchTerm, setSearchTerm] = useState("");
|
|
|
|
// 배치 목록 & 자재 목록 로드
|
|
useEffect(() => {
|
|
const loadData = async () => {
|
|
try {
|
|
setIsLoading(true);
|
|
const [placementsRes, materialsRes] = await Promise.all([
|
|
yardLayoutApi.getPlacementsByLayoutId(layout.id),
|
|
materialApi.getTempMaterials({ limit: 100 }),
|
|
]);
|
|
|
|
if (placementsRes.success) {
|
|
setPlacements(placementsRes.data);
|
|
}
|
|
if (materialsRes.success) {
|
|
setMaterials(materialsRes.data);
|
|
}
|
|
} catch (error) {
|
|
console.error("데이터 로드 실패:", error);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
loadData();
|
|
}, [layout.id]);
|
|
|
|
// 자재 클릭 → 배치 추가
|
|
const handleMaterialClick = async (material: TempMaterial) => {
|
|
// 이미 배치되었는지 확인
|
|
const alreadyPlaced = placements.find((p) => p.material_code === material.material_code);
|
|
if (alreadyPlaced) {
|
|
alert("이미 배치된 자재입니다.");
|
|
return;
|
|
}
|
|
|
|
setSelectedMaterial(material);
|
|
|
|
// 기본 위치에 배치
|
|
const placementData = {
|
|
external_material_id: `TEMP-${material.id}`,
|
|
material_code: material.material_code,
|
|
material_name: material.material_name,
|
|
quantity: 1,
|
|
unit: material.unit,
|
|
position_x: 0,
|
|
position_y: 0,
|
|
position_z: 0,
|
|
size_x: 5,
|
|
size_y: 5,
|
|
size_z: 5,
|
|
color: material.default_color,
|
|
};
|
|
|
|
try {
|
|
const response = await yardLayoutApi.addMaterialPlacement(layout.id, placementData);
|
|
if (response.success) {
|
|
setPlacements((prev) => [...prev, response.data]);
|
|
setSelectedPlacement(response.data);
|
|
setSelectedMaterial(null);
|
|
}
|
|
} catch (error: any) {
|
|
console.error("자재 배치 실패:", error);
|
|
alert("자재 배치에 실패했습니다.");
|
|
}
|
|
};
|
|
|
|
// 자재 드래그 (3D 캔버스에서)
|
|
const handlePlacementDrag = (id: number, position: { x: number; y: number; z: number }) => {
|
|
const updatedPosition = {
|
|
position_x: Math.round(position.x * 2) / 2,
|
|
position_y: position.y,
|
|
position_z: Math.round(position.z * 2) / 2,
|
|
};
|
|
|
|
setPlacements((prev) =>
|
|
prev.map((p) =>
|
|
p.id === id
|
|
? {
|
|
...p,
|
|
...updatedPosition,
|
|
}
|
|
: p,
|
|
),
|
|
);
|
|
|
|
// 선택된 자재도 업데이트
|
|
if (selectedPlacement?.id === id) {
|
|
setSelectedPlacement((prev) =>
|
|
prev
|
|
? {
|
|
...prev,
|
|
...updatedPosition,
|
|
}
|
|
: null,
|
|
);
|
|
}
|
|
};
|
|
|
|
// 자재 배치 해제
|
|
const handlePlacementRemove = async (id: number) => {
|
|
try {
|
|
const response = await yardLayoutApi.removePlacement(id);
|
|
if (response.success) {
|
|
setPlacements((prev) => prev.filter((p) => p.id !== id));
|
|
setSelectedPlacement(null);
|
|
}
|
|
} catch (error) {
|
|
console.error("배치 해제 실패:", error);
|
|
alert("배치 해제에 실패했습니다.");
|
|
}
|
|
};
|
|
|
|
// 위치/크기/색상 업데이트
|
|
const handlePlacementUpdate = (id: number, updates: Partial<YardPlacement>) => {
|
|
setPlacements((prev) => prev.map((p) => (p.id === id ? { ...p, ...updates } : p)));
|
|
};
|
|
|
|
// 저장
|
|
const handleSave = async () => {
|
|
setIsSaving(true);
|
|
try {
|
|
const response = await yardLayoutApi.batchUpdatePlacements(
|
|
layout.id,
|
|
placements.map((p) => ({
|
|
id: p.id,
|
|
position_x: p.position_x,
|
|
position_y: p.position_y,
|
|
position_z: p.position_z,
|
|
size_x: p.size_x,
|
|
size_y: p.size_y,
|
|
size_z: p.size_z,
|
|
color: p.color,
|
|
})),
|
|
);
|
|
|
|
if (response.success) {
|
|
alert("저장되었습니다");
|
|
}
|
|
} catch (error) {
|
|
console.error("저장 실패:", error);
|
|
alert("저장에 실패했습니다");
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
// 필터링된 자재 목록
|
|
const filteredMaterials = materials.filter(
|
|
(m) =>
|
|
m.material_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
m.material_code.toLowerCase().includes(searchTerm.toLowerCase()),
|
|
);
|
|
|
|
return (
|
|
<div className="flex h-full flex-col bg-white">
|
|
{/* 상단 툴바 */}
|
|
<div className="flex items-center justify-between border-b p-4">
|
|
<div className="flex items-center gap-4">
|
|
<Button variant="ghost" size="sm" onClick={onBack}>
|
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
목록으로
|
|
</Button>
|
|
<div>
|
|
<h2 className="text-lg font-semibold">{layout.name}</h2>
|
|
{layout.description && <p className="text-sm text-gray-500">{layout.description}</p>}
|
|
</div>
|
|
</div>
|
|
|
|
<Button size="sm" onClick={handleSave} disabled={isSaving}>
|
|
{isSaving ? (
|
|
<>
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
저장 중...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Save className="mr-2 h-4 w-4" />
|
|
저장
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 메인 컨텐츠 영역 */}
|
|
<div className="flex flex-1 overflow-hidden">
|
|
{/* 좌측: 3D 캔버스 */}
|
|
<div className="flex-1">
|
|
{isLoading ? (
|
|
<div className="flex h-full items-center justify-center bg-gray-50">
|
|
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
|
|
</div>
|
|
) : (
|
|
<Yard3DCanvas
|
|
placements={placements}
|
|
selectedPlacementId={selectedPlacement?.id || null}
|
|
onPlacementClick={setSelectedPlacement}
|
|
onPlacementDrag={handlePlacementDrag}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* 우측: 자재 목록 또는 편집 패널 */}
|
|
<div className="w-80 border-l bg-white">
|
|
{selectedPlacement ? (
|
|
// 선택된 자재 편집 패널
|
|
<div className="flex h-full flex-col">
|
|
<div className="flex items-center justify-between border-b p-4">
|
|
<h3 className="text-sm font-semibold">자재 정보</h3>
|
|
<Button variant="ghost" size="sm" onClick={() => setSelectedPlacement(null)}>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-auto p-4">
|
|
<div className="space-y-4">
|
|
{/* 읽기 전용 정보 */}
|
|
<div>
|
|
<Label className="text-xs text-gray-500">자재 코드</Label>
|
|
<div className="mt-1 text-sm font-medium">{selectedPlacement.material_code}</div>
|
|
</div>
|
|
|
|
<div>
|
|
<Label className="text-xs text-gray-500">자재명</Label>
|
|
<div className="mt-1 text-sm font-medium">{selectedPlacement.material_name}</div>
|
|
</div>
|
|
|
|
<div>
|
|
<Label className="text-xs text-gray-500">수량 (변경 불가)</Label>
|
|
<div className="mt-1 text-sm">
|
|
{selectedPlacement.quantity} {selectedPlacement.unit}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 편집 가능 정보 */}
|
|
<div className="space-y-3 border-t pt-4">
|
|
<Label className="text-sm font-semibold">배치 정보</Label>
|
|
|
|
<div className="grid grid-cols-3 gap-2">
|
|
<div>
|
|
<Label className="text-xs">X</Label>
|
|
<Input
|
|
type="number"
|
|
step="0.5"
|
|
value={selectedPlacement.position_x}
|
|
onChange={(e) =>
|
|
handlePlacementUpdate(selectedPlacement.id, {
|
|
position_x: parseFloat(e.target.value),
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs">Y</Label>
|
|
<Input
|
|
type="number"
|
|
step="0.5"
|
|
value={selectedPlacement.position_y}
|
|
onChange={(e) =>
|
|
handlePlacementUpdate(selectedPlacement.id, {
|
|
position_y: parseFloat(e.target.value),
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs">Z</Label>
|
|
<Input
|
|
type="number"
|
|
step="0.5"
|
|
value={selectedPlacement.position_z}
|
|
onChange={(e) =>
|
|
handlePlacementUpdate(selectedPlacement.id, {
|
|
position_z: parseFloat(e.target.value),
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-3 gap-2">
|
|
<div>
|
|
<Label className="text-xs">너비</Label>
|
|
<Input
|
|
type="number"
|
|
step="1"
|
|
value={selectedPlacement.size_x}
|
|
onChange={(e) =>
|
|
handlePlacementUpdate(selectedPlacement.id, {
|
|
size_x: parseFloat(e.target.value),
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs">높이</Label>
|
|
<Input
|
|
type="number"
|
|
step="1"
|
|
value={selectedPlacement.size_y}
|
|
onChange={(e) =>
|
|
handlePlacementUpdate(selectedPlacement.id, {
|
|
size_y: parseFloat(e.target.value),
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs">깊이</Label>
|
|
<Input
|
|
type="number"
|
|
step="1"
|
|
value={selectedPlacement.size_z}
|
|
onChange={(e) =>
|
|
handlePlacementUpdate(selectedPlacement.id, {
|
|
size_z: parseFloat(e.target.value),
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<Label className="text-xs">색상</Label>
|
|
<Input
|
|
type="color"
|
|
value={selectedPlacement.color}
|
|
onChange={(e) => handlePlacementUpdate(selectedPlacement.id, { color: e.target.value })}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<Button
|
|
variant="destructive"
|
|
size="sm"
|
|
className="w-full"
|
|
onClick={() => handlePlacementRemove(selectedPlacement.id)}
|
|
>
|
|
배치 해제
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
// 자재 목록
|
|
<div className="flex h-full flex-col">
|
|
<div className="border-b p-4">
|
|
<h3 className="mb-2 text-sm font-semibold">자재 목록</h3>
|
|
<Input
|
|
placeholder="자재 검색..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="text-sm"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-auto">
|
|
{filteredMaterials.length === 0 ? (
|
|
<div className="flex h-full items-center justify-center p-4 text-center text-sm text-gray-500">
|
|
검색 결과가 없습니다
|
|
</div>
|
|
) : (
|
|
<div className="p-2">
|
|
{filteredMaterials.map((material) => {
|
|
const isPlaced = placements.some((p) => p.material_code === material.material_code);
|
|
return (
|
|
<button
|
|
key={material.id}
|
|
onClick={() => !isPlaced && handleMaterialClick(material)}
|
|
disabled={isPlaced}
|
|
className={`mb-2 w-full rounded-lg border p-3 text-left transition-all ${
|
|
isPlaced
|
|
? "cursor-not-allowed border-gray-200 bg-gray-50 opacity-50"
|
|
: "cursor-pointer border-gray-200 bg-white hover:border-blue-500 hover:shadow-sm"
|
|
}`}
|
|
>
|
|
<div className="mb-1 text-sm font-medium text-gray-900">{material.material_name}</div>
|
|
<div className="text-xs text-gray-500">{material.material_code}</div>
|
|
<div className="mt-1 text-xs text-gray-400">{material.category}</div>
|
|
{isPlaced && <div className="mt-1 text-xs text-blue-600">배치됨</div>}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|