vat_wms/resources/js/Pages/FloorPlan.tsx
2025-07-09 15:42:15 +02:00

640 lines
26 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, StockPosition} from '@/types';
import RackModalDetails from "@/Components/RackModalDetails";
import EditStockSections from '@/Components/modals/EditStockSections';
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
export default function FloorPlan() {
// at the top of FloorPlan()
const [showEditSectionsModal, setShowEditSectionsModal] = useState(false);
const [selectedPositionForEditing, setSelectedPositionForEditing] = useState<StockPosition | null>(null);
// fire this when RackModalDetails tells us “user clicked a position”
const handleOpenSections = async (posId: number) => {
try {
console.log(posId);
// 1⃣ pull down the fresh position (with capacity & sections)
const { data: freshPos } = await axios.get<StockPosition>(
`/api/stockPositions/${posId}`,
{ withCredentials: true }
);
// 2⃣ stash it & open the modal
setSelectedPositionForEditing(freshPos);
setShowEditSectionsModal(true);
} catch (err) {
console.error(err);
alert('Could not load position details. Please try again.');
}
};
// pass this into EditStockSections so that onClose we go back to RackModalDetails
const handleCloseSections = () => {
setShowEditSectionsModal(false);
};
// 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);
}, []);
useEffect(() => {
if (!showEditSectionsModal && selectedRoom) {
loadLayout(selectedRoom.room_id);
}
}, [showEditSectionsModal, selectedRoom]);
useEffect(() => {
if (!showEditSectionsModal && editModal.visible && editModal.type === 'rack' && editModal.item_obj) {
// grab the just-saved rack by its ID:
axios.get<StockRack>(`/api/stock-racks/${(editModal.item_obj as StockRack).rack_id}`, {
withCredentials: true
})
.then(({ data: freshRack }) => {
setEditModal(m => ({ ...m, item_obj: freshRack }));
})
.catch(err => {
console.error('Could not refresh rack details:', err);
});
}
}, [showEditSectionsModal, editModal.visible]);
// 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');
}
};
// ➊ add a new shelf
const handleAddShelf = async (rackId: number) => {
// compute next symbol & name from current editModal.item_obj
const rack = editModal.item_obj as StockRack;
const next = rack.shelves ? rack.shelves.length + 1 : 1;
const symbol = String(next);
const name = `Shelf ${symbol}`;
try {
await axios.post(
'/api/stock-shelves',
{ rack_id: rackId, shelf_symbol: symbol, shelf_name: name },
{ withCredentials: true }
);
// refresh just that rack
const { data: freshRack } = await axios.get<StockRack>(
`/api/stock-racks/${rackId}`,
{ withCredentials: true }
);
setEditModal(m => ({ ...m, item_obj: freshRack }));
} catch (err) {
console.error(err);
alert('Could not add shelf.');
}
};
// ➋ delete a shelf
const handleDeleteShelf = async (shelfId: number) => {
try {
await axios.delete(`/api/stock-shelves/${shelfId}`, {
withCredentials: true
});
// after delete, re-fetch rack
const rack = editModal.item_obj as StockRack;
const { data: freshRack } = await axios.get<StockRack>(
`/api/stock-racks/${rack.rack_id}`,
{ withCredentials: true }
);
setEditModal(m => ({ ...m, item_obj: freshRack }));
} catch (err) {
console.error(err);
alert('Could not delete shelf.');
}
};
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 max-w-[50%]">
<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} onPositionClick={handleOpenSections} onAddShelf={handleAddShelf} onDeleteShelf={handleDeleteShelf} />
</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>
)}
{/* === Sections Editor Nested Modal === */}
{showEditSectionsModal && selectedPositionForEditing && (
<div className="modal modal-open">
<div className="modal-box max-w-[50%]">
<EditStockSections
selectedPosition={selectedPositionForEditing}
onClose={handleCloseSections}
/>
</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>
);
}