594 lines
24 KiB
TypeScript
594 lines
24 KiB
TypeScript
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 parent‐line 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 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 & Sections</h4>
|
||
<RackModalDetails rack={editModal.item_obj} onPositionClick={handleOpenSections} />
|
||
</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>
|
||
);
|
||
}
|