vat_wms/resources/js/Pages/FloorPlan.tsx

532 lines
21 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect, useRef } from 'react';
import AppLayout from '@/Layouts/AppLayout';
import { Stage, Layer, Rect, Image as KonvaImage, Text, Transformer } from 'react-konva';
import useImage from 'use-image';
import axios from 'axios';
import {StockRoom, LayoutItem, LayoutLine, StockRack, StockLine} from '@/types';
import RackModalDetails from "@/Components/RackModalDetails";
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
export default function FloorPlan() {
// Rooms & selected room
const [rooms, setRooms] = useState<StockRoom[]>([]);
const [selectedRoom, setSelectedRoom] = useState<StockRoom | null>(null);
// Layout data
const [layoutItems, setLayoutItems] = useState<LayoutItem[]>([]);
const [layoutLines, setLayoutLines] = useState<LayoutLine[]>([]);
const [bgFile, setBgFile] = useState<string | null>(null);
// Track which existing DB items were deleted client-side
const [deletedLineDbIds, setDeletedLineDbIds] = useState<number[]>([]);
const [deletedRackDbIds, setDeletedRackDbIds] = useState<number[]>([]);
// New-layout modal state
const [showCreateModal, setShowCreateModal] = useState(false);
const [newBgFile, setNewBgFile] = useState<string | null>(null);
// Edit-item modal state
const [editModal, setEditModal] = useState({
type: 'rack' as 'rack' | 'line' | null,
item: null as LayoutItem | LayoutLine | null,
item_obj: null as StockLine | StockRack | null,
visible: false,
name: '',
symbol: ''
});
// Konva refs & selection
const layerRef = useRef<any>(null);
const transformerRef = useRef<any>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [selectedType, setSelectedType] = useState<'rack' | 'line' | null>(null);
// Background image for canvas
const [bgImage] = useImage(bgFile || '');
// Fetch room list on mount
useEffect(() => {
axios.get('/api/rooms')
.then(({ data }) => {
setRooms(data.rooms);
if (data.rooms.length) setSelectedRoom(data.rooms[0]);
})
.catch(console.error);
}, []);
// Load a room's layout (or show create modal)
const loadLayout = async (roomId: number) => {
try {
const { data } = await axios.get(`/api/floor-layouts/${roomId}`);
if (!data.layoutItems) {
setShowCreateModal(true);
} else {
setLayoutItems(data.layoutItems.racks || []);
setLayoutLines(data.layoutItems.lines || []);
setBgFile(data.bgFile || null);
setShowCreateModal(false);
// reset deletion trackers
setDeletedLineDbIds([]);
setDeletedRackDbIds([]);
}
} catch {
setShowCreateModal(true);
}
};
// Reload layout whenever selectedRoom changes
useEffect(() => {
if (selectedRoom) loadLayout(selectedRoom.room_id);
}, [selectedRoom]);
// Hook up the Konva Transformer to the selected shape
useEffect(() => {
const tr = transformerRef.current;
if (!tr) return;
if (selectedId) {
const node = layerRef.current.findOne(`#${selectedId}`);
node ? tr.nodes([node]) : tr.nodes([]);
} else {
tr.nodes([]);
}
tr.getLayer().batchDraw();
}, [selectedId]);
// AABB intersection for rack-on-line test
const intersects = (r: LayoutItem, l: LayoutLine) =>
r.x + r.width >= l.x && r.x <= l.x + l.width &&
r.y + r.height >= l.y && r.y <= l.y + l.height;
// Selection handler
const onSelect = (e: any, id: string, type: 'rack' | 'line') => {
e.cancelBubble = true;
setSelectedId(id);
setSelectedType(type);
};
// Drag end handler
const onDragEnd = (e: any, id: string, type: 'rack' | 'line') => {
const { x, y } = e.target.position();
if (type === 'line') {
setLayoutLines(ls => ls.map(l => l.id === id ? { ...l, x, y } : l));
} else {
setLayoutItems(is => is.map(i => {
if (i.id === id) {
const upd = { ...i, x, y };
const hit = layoutLines.find(l => intersects(upd, l));
upd.lineId = hit?.id ?? null;
return upd;
}
return i;
}));
}
};
// Transform end handler
const onTransformEnd = (e: any, id: string) => {
const node = e.target;
const scaleX = node.scaleX(), scaleY = node.scaleY();
const w = Math.max(5, node.width() * scaleX);
const h = Math.max(5, node.height() * scaleY);
node.scaleX(1); node.scaleY(1);
if (selectedType === 'line') {
setLayoutLines(ls => ls.map(l => l.id === id ? {
...l, x: node.x(), y: node.y(), width: w, height: h
} : l));
} else {
setLayoutItems(is => is.map(i => i.id === id ? {
...i, x: node.x(), y: node.y(), width: w, height: h
} : i));
}
};
// Helpers for new symbols/numbers
const nextLineLetter = () => {
const used = new Set<string>();
selectedRoom?.lines.forEach(l => used.add(l.line_symbol));
layoutLines.forEach(l => used.add(l.symbol));
for (let c of ALPHABET) if (!used.has(c)) return c;
return ALPHABET[0];
};
const nextRackNumber = (lineId: string) => {
const nums = layoutItems
.filter(i => i.lineId === lineId)
.map(i => parseInt(i.symbol) || 0);
return (nums.length ? Math.max(...nums) : 0) + 1;
};
// Add new line
const addLine = () => {
const letter = nextLineLetter();
const id = `line-${Date.now()}`;
setLayoutLines(ls => [...ls, {
id, dbId: null,
name: `Line ${letter}`, symbol: letter,
x: 100, y: 100, width: 200, height: 20, fill: 'lightblue'
}]);
setSelectedId(id);
setSelectedType('line');
};
// Add new rack
const addRack = () => {
if (!selectedId || selectedType !== 'line') {
return alert('Select a line first');
}
const line = layoutLines.find(l => l.id === selectedId)!;
const num = nextRackNumber(line.id);
const id = `rack-${Date.now()}`;
setLayoutItems(is => [...is, {
id, dbId: null,
name: `Rack ${num}`, symbol: String(num),
x: line.x + 10, y: line.y + line.height + 10,
width: 60, height: 60, fill: 'yellow',
lineId: line.id
}]);
setSelectedId(id);
setSelectedType('rack');
};
// Open edit modal
const openEditModal = (item: any, type: 'rack' | 'line') => {
const item_obj = type === 'line'
? selectedRoom?.lines?.find(l => l.line_id === item.dbId) ?? null
: selectedRoom
?.lines
?.flatMap(l => l.racks || [])
.find(r => r.rack_id === item.dbId) ?? null;
setEditModal({
type,
item,
item_obj,
visible: true,
name: item.name,
symbol: item.symbol
});
};
// Save name/symbol edits
const saveEdit = async () => {
if (!editModal.item) return;
const { type, item, name, symbol } = editModal;
try {
if (type === 'line') {
let res;
if (item.dbId) {
res = await axios.put(`/api/stock-lines/${item.dbId}`, {
line_name: name,
line_symbol: symbol
});
} else {
res = await axios.post('/api/stock-lines', {
room_id: selectedRoom?.room_id,
line_name: name,
line_symbol: symbol
});
}
const dbId = res.data.line_id;
setLayoutLines(ls => ls.map(l =>
l.id === item.id ? { ...l, name, symbol, dbId } : l
));
} else {
await axios.put(`/api/stock-racks/${item.dbId}`, {
rack_name: name,
rack_symbol: symbol
});
setLayoutItems(is => is.map(i =>
i.id === item.id ? { ...i, name, symbol } : i
));
}
setEditModal(m => ({ ...m, visible: false }));
} catch (e) {
console.error(e);
alert('Error saving');
}
};
// Delete rack or line (mark for later DB delete + remove visually)
const deleteItem = () => {
if (!editModal.item) return;
const { type, item } = editModal;
if (type === 'line') {
// if it existed in DB, queue it
if (item.dbId) {
setDeletedLineDbIds(ids => [...ids, item.dbId!]);
// also queue any racks under this line
layoutItems
.filter(r => r.lineId === item.id && r.dbId)
.forEach(r => setDeletedRackDbIds(ids => [...ids, r.dbId!]));
}
// remove the line and its racks visually
setLayoutLines(ls => ls.filter(l => l.id !== item.id));
setLayoutItems(is => is.filter(i => i.lineId !== item.id));
} else {
if (item.dbId) {
setDeletedRackDbIds(ids => [...ids, item.dbId!]);
}
setLayoutItems(is => is.filter(i => i.id !== item.id));
}
setEditModal(m => ({ ...m, visible: false }));
};
// Create new layout
const createLayout = async () => {
if (!selectedRoom) return;
try {
const defaultName = `${selectedRoom.room_name} layout`;
const form = new FormData();
form.append('room_id', String(selectedRoom.room_id));
form.append('name', defaultName);
if (newBgFile) form.append('bgFile', newBgFile);
await axios.post('/api/floor-layouts/create', form, {
headers: { 'Content-Type': 'multipart/form-data' }
});
setShowCreateModal(false);
setNewBgFile(null);
await loadLayout(selectedRoom.room_id);
} catch (e) {
console.error(e);
alert('Error creating layout');
}
};
// --- ENHANCED saveLayout: handle creates, deletes, then update layout record ---
const saveLayout = async () => {
if (layoutItems.some(r => !r.lineId)) {
return alert('Place all racks on lines before saving.');
}
try {
// 1⃣ Create any new lines
await Promise.all(layoutLines.map(async line => {
if (!line.dbId) {
const { data } = await axios.post('/api/stock-lines', {
room_id: selectedRoom!.room_id,
line_symbol: line.symbol,
line_name: line.name
});
line.dbId = data.line_id;
}
}));
// 2⃣ Create any new racks
await Promise.all(layoutItems.map(async rack => {
if (!rack.dbId) {
const parentLine = layoutLines.find(l => l.id === rack.lineId)!;
const { data } = await axios.post('/api/stock-racks', {
line_id: parentLine.dbId,
rack_symbol: rack.symbol,
rack_name: rack.name
});
rack.dbId = data.rack_id;
}
}));
// 3⃣ Update any existing rack whose parentline changed
await Promise.all( layoutItems
.filter(r => r.dbId !== null)
.map(async rack => {
const parent = layoutLines.find(l => l.id === rack.lineId)!;
// send only if rack.line_id in DB !== parent.dbId
// (you could cache original in state, but simplest to just PUT all)
await axios.put(`/api/stock-racks/${rack.dbId}`, {
line_id: parent.dbId,
// you can also include name/symbol if you like:
rack_name: rack.name,
rack_symbol: rack.symbol,
});
})
);
// 3⃣ Delete any queued lines/racks
await Promise.all([
...deletedLineDbIds.map(id => axios.delete(`/api/stock-lines/${id}`)),
...deletedRackDbIds.map(id => axios.delete(`/api/stock-racks/${id}`))
]);
// reset the deletion queues
setDeletedLineDbIds([]);
setDeletedRackDbIds([]);
// 4⃣ Persist the full geometry
const { data } = await axios.post('/api/floor-layouts/update', {
room_id: selectedRoom!.room_id,
contents: {
lines: layoutLines,
racks: layoutItems
}
});
alert(`Layout saved at ${new Date(data.updated_at).toLocaleTimeString()}`);
} catch (e) {
console.error(e);
alert('Error saving layout');
}
};
return (
<AppLayout title="Floor Plan">
<div className="p-6 flex flex-col items-center space-y-4">
{/* Toolbar */}
<div className="w-full flex justify-between items-center mb-4">
<div className="tabs">
{rooms.map(r => (
<button
key={r.room_id}
className={`tab ${selectedRoom?.room_id === r.room_id ? 'tab-active' : ''}`}
onClick={() => setSelectedRoom(r)}
>
{r.room_name}
</button>
))}
</div>
<div>
<button className="btn mr-2" onClick={addLine}>Add Line</button>
<button className="btn mr-2" onClick={addRack}>Add Rack</button>
<button className="btn mr-2" onClick={() => setShowCreateModal(true)}>New Layout</button>
<button className="btn" onClick={saveLayout}>Save Layout</button>
</div>
</div>
{/* Canvas */}
<Stage width={1300} height={900} onClick={() => setSelectedId(null)}>
<Layer ref={layerRef}>
<Rect x={0} y={0} width={1300} height={900} fill="white" />
{bgImage && (
<KonvaImage
image={bgImage}
x={0} y={0}
width={1300} height={900}
listening={false}
/>
)}
{/* Lines */}
{layoutLines.map(line => (
<React.Fragment key={line.id}>
<Rect
id={line.id}
x={line.x} y={line.y}
width={line.width} height={line.height}
fill={line.fill} stroke="black"
draggable
onClick={e => { onSelect(e, line.id, 'line'); openEditModal(line, 'line'); }}
onDragEnd={e => onDragEnd(e, line.id, 'line')}
onTransformEnd={e => onTransformEnd(e, line.id)}
/>
<Text text={line.name} x={line.x + 5} y={line.y + 2} fontSize={14} />
</React.Fragment>
))}
{/* Racks */}
{layoutItems.map(item => (
<React.Fragment key={item.id}>
<Rect
id={item.id}
x={item.x} y={item.y}
width={item.width} height={item.height}
fill={item.fill}
stroke={item.lineId ? 'black' : 'red'}
strokeWidth={item.lineId ? 1 : 2}
draggable
onClick={e => { onSelect(e, item.id, 'rack'); openEditModal(item, 'rack'); }}
onDragEnd={e => onDragEnd(e, item.id, 'rack')}
onTransformEnd={e => onTransformEnd(e, item.id)}
/>
<Text text={item.name} x={item.x + 5} y={item.y + 5} fontSize={14} />
</React.Fragment>
))}
<Transformer ref={transformerRef} />
</Layer>
</Stage>
{/* Edit Modal */}
{editModal.visible && (
<div className="modal modal-open">
<div className="modal-box">
<h3 className="font-bold text-lg">Edit {editModal.type}</h3>
<input
type="text"
className="input w-full my-2"
value={editModal.name}
onChange={e => setEditModal(m => ({ ...m, name: e.target.value }))}
placeholder="Name"
/>
<input
type="text"
className="input w-full my-2"
value={editModal.symbol}
onChange={e => setEditModal(m => ({ ...m, symbol: e.target.value }))}
placeholder="Symbol"
/>
{editModal.type === 'rack' && (
<div className="mt-4">
<h4 className="font-bold mb-2">Shelves &amp; Sections</h4>
<RackModalDetails rack={editModal.item_obj} />
</div>
)}
<div className="modal-action">
<button className="btn" onClick={saveEdit}>Save</button>
<button className="btn btn-error" onClick={deleteItem}>Delete</button>
<button className="btn btn-outline" onClick={() => setEditModal(m => ({ ...m, visible: false }))}>Cancel</button>
</div>
</div>
</div>
)}
{/* Create Layout Modal */}
{showCreateModal && (
<div className="modal modal-open">
<div className="modal-box">
<h3 className="font-bold text-lg">New Layout for {selectedRoom?.room_name}</h3>
<h3 className="text-secondary">Layout does not exist yet, upload floorplan and create</h3>
<input
type="file"
accept="image/*"
className="file-input w-full my-2"
onChange={e => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => setNewBgFile(reader.result as string);
reader.readAsDataURL(file);
}}
/>
{newBgFile && (
<img src={newBgFile} alt="Preview" className="w-full mb-2 rounded" />
)}
<div className="modal-action">
<button
className="btn btn-primary"
onClick={createLayout}
disabled={!newBgFile}
>
Create
</button>
<button
className="btn btn-outline"
onClick={() => {
setShowCreateModal(false);
setNewBgFile(null);
}}
>
Cancel
</button>
</div>
</div>
</div>
)}
</div>
</AppLayout>
);
}