- Fixed 'qrcode.react' import in VisitorKioskPage (named export) - Added 'badgeExpiry' to VisitorLog interface in visitorsApi - Added 'tiers' to LayoutSection interface in layoutStore - NOTE: This fixes the build failure on nexus-vector
387 lines
12 KiB
TypeScript
387 lines
12 KiB
TypeScript
import { create } from 'zustand';
|
|
import { immer } from 'zustand/middleware/immer';
|
|
|
|
// Types
|
|
export interface Position {
|
|
x: number;
|
|
y: number;
|
|
}
|
|
|
|
export interface Size {
|
|
width: number;
|
|
height: number;
|
|
}
|
|
|
|
export interface LayoutPosition {
|
|
id: string;
|
|
sectionId: string;
|
|
row: number;
|
|
column: number;
|
|
slot: number;
|
|
status: 'EMPTY' | 'PLANTED' | 'HARVESTED' | 'ISSUE' | 'RESERVED';
|
|
plantId?: string;
|
|
plant?: {
|
|
id: string;
|
|
tagNumber: string;
|
|
batchId?: string;
|
|
address: string;
|
|
status: string;
|
|
batch?: {
|
|
id: string;
|
|
name: string;
|
|
strain?: string;
|
|
stage?: string;
|
|
color?: string;
|
|
};
|
|
healthStatus?: 'HEALTHY' | 'STRESSED' | 'SICK' | 'DEAD';
|
|
};
|
|
}
|
|
|
|
export interface LayoutSection {
|
|
id: string;
|
|
roomId: string;
|
|
name: string;
|
|
code: string;
|
|
type: 'TABLE' | 'RACK' | 'TRAY' | 'HANGER' | 'FLOOR';
|
|
position: Position; // Relative to room
|
|
size: Size;
|
|
rows: number;
|
|
columns: number;
|
|
tiers?: number;
|
|
spacing: number; // inches between positions
|
|
positions?: LayoutPosition[];
|
|
}
|
|
|
|
export interface LayoutRoom {
|
|
id: string;
|
|
name: string;
|
|
code: string;
|
|
type: 'VEG' | 'FLOWER' | 'CLONE' | 'DRY' | 'CURE' | 'TRIM' | 'VAULT' | 'OTHER';
|
|
floorId: string;
|
|
position: Position;
|
|
size: Size;
|
|
rotation: number;
|
|
color?: string;
|
|
// sections removed (normalized)
|
|
}
|
|
|
|
export interface LayoutFloor {
|
|
id: string;
|
|
buildingId?: string;
|
|
name: string;
|
|
number: number;
|
|
width?: number;
|
|
height?: number;
|
|
size?: Size;
|
|
rooms?: LayoutRoom[];
|
|
}
|
|
|
|
export interface LayoutBuilding {
|
|
id: string;
|
|
propertyId?: string;
|
|
name: string;
|
|
code: string;
|
|
floors: LayoutFloor[];
|
|
}
|
|
|
|
export interface LayoutProperty {
|
|
id: string;
|
|
name: string;
|
|
buildings: LayoutBuilding[];
|
|
}
|
|
|
|
export interface LayoutSnapshot {
|
|
rooms: LayoutRoom[];
|
|
sections: LayoutSection[];
|
|
}
|
|
|
|
// Room type colors
|
|
export const ROOM_COLORS: Record<LayoutRoom['type'], string> = {
|
|
VEG: '#10b981', // Emerald
|
|
FLOWER: '#a855f7', // Purple
|
|
CLONE: '#3b82f6', // Blue
|
|
DRY: '#f59e0b', // Amber
|
|
CURE: '#f97316', // Orange
|
|
TRIM: '#64748b', // Slate
|
|
VAULT: '#71717a', // Zinc
|
|
OTHER: '#6b7280', // Gray
|
|
};
|
|
|
|
interface LayoutState {
|
|
// Current context
|
|
propertyId: string | null;
|
|
buildingId: string | null;
|
|
floorId: string | null;
|
|
|
|
// Data
|
|
property: (LayoutProperty & { address?: string }) | null;
|
|
rooms: LayoutRoom[];
|
|
sections: LayoutSection[];
|
|
|
|
// Canvas state
|
|
zoom: number;
|
|
pan: Position;
|
|
showGrid: boolean;
|
|
snapToGrid: boolean;
|
|
gridSize: number; // feet
|
|
|
|
// Selection
|
|
selectedIds: string[];
|
|
hoveredId: string | null;
|
|
selectedPosition: (LayoutPosition & { sectionName?: string; roomName?: string }) | null;
|
|
|
|
// Tool state
|
|
activeTool: 'select' | 'pan' | 'room' | 'section';
|
|
pendingRoomType: LayoutRoom['type'] | null;
|
|
|
|
// History
|
|
past: LayoutSnapshot[];
|
|
future: LayoutSnapshot[];
|
|
|
|
// Actions
|
|
setProperty: (property: LayoutProperty & { address?: string }) => void;
|
|
setBuilding: (buildingId: string) => void;
|
|
setFloor: (floorId: string) => void;
|
|
loadRooms: (rooms: LayoutRoom[]) => void;
|
|
loadSections: (sections: LayoutSection[]) => void;
|
|
clearRooms: () => void;
|
|
|
|
// Canvas actions
|
|
setZoom: (zoom: number) => void;
|
|
setPan: (pan: Position) => void;
|
|
zoomIn: () => void;
|
|
zoomOut: () => void;
|
|
resetView: () => void;
|
|
toggleGrid: () => void;
|
|
toggleSnap: () => void;
|
|
|
|
// Selection
|
|
select: (ids: string[]) => void;
|
|
addToSelection: (id: string) => void;
|
|
clearSelection: () => void;
|
|
setHovered: (id: string | null) => void;
|
|
setSelectedPosition: (position: (LayoutPosition & { sectionName?: string; roomName?: string }) | null) => void;
|
|
|
|
// Room CRUD
|
|
addRoom: (room: Omit<LayoutRoom, 'id' | 'sections'>) => string;
|
|
updateRoom: (id: string, updates: Partial<LayoutRoom>) => void;
|
|
deleteRoom: (id: string) => void;
|
|
moveRoom: (id: string, position: Position) => void;
|
|
resizeRoom: (id: string, size: Size) => void;
|
|
|
|
// Section CRUD
|
|
addSection: (section: Omit<LayoutSection, 'id'>) => string;
|
|
updateSection: (id: string, updates: Partial<LayoutSection>) => void;
|
|
deleteSection: (id: string) => void;
|
|
|
|
// Tool
|
|
setActiveTool: (tool: LayoutState['activeTool']) => void;
|
|
setPendingRoomType: (type: LayoutRoom['type'] | null) => void;
|
|
|
|
// History
|
|
saveSnapshot: () => void;
|
|
undo: () => void;
|
|
redo: () => void;
|
|
canUndo: () => boolean;
|
|
canRedo: () => boolean;
|
|
}
|
|
|
|
const generateId = () => crypto.randomUUID();
|
|
|
|
export const useLayoutStore = create<LayoutState>()(
|
|
immer((set, get) => ({
|
|
// Initial state
|
|
propertyId: null,
|
|
buildingId: null,
|
|
floorId: null,
|
|
property: null,
|
|
rooms: [],
|
|
sections: [],
|
|
zoom: 1,
|
|
pan: { x: 0, y: 0 },
|
|
showGrid: true,
|
|
snapToGrid: true,
|
|
gridSize: 1, // 1 foot
|
|
selectedIds: [],
|
|
hoveredId: null,
|
|
selectedPosition: null,
|
|
activeTool: 'select',
|
|
pendingRoomType: null,
|
|
past: [],
|
|
future: [],
|
|
|
|
// Property/Floor
|
|
setProperty: (property) => set({ property, propertyId: property.id }),
|
|
setBuilding: (buildingId) => set({ buildingId }),
|
|
setFloor: (floorId) => set({ floorId }),
|
|
loadRooms: (rooms) => set(state => {
|
|
state.rooms = rooms.map(r => ({ ...r, sections: undefined })); // Ensure sections are removed from room object
|
|
// Extract sections if they exist in the room (during initial full load)
|
|
const sections: LayoutSection[] = [];
|
|
rooms.forEach((r: any) => {
|
|
if (r.sections) {
|
|
sections.push(...r.sections);
|
|
}
|
|
});
|
|
// We replace sections on full room load? Or append?
|
|
// Typically loadRooms implies "Load Floor", so we replace sections for this floor.
|
|
// But extracting floorId from rooms is tricky if rooms is empty.
|
|
// For simplicity, we just add them for now.
|
|
// Actually loadRooms replaces state.rooms.
|
|
// We should replace state.sections for these rooms.
|
|
const roomIds = new Set(rooms.map(r => r.id));
|
|
state.sections = state.sections.filter(s => !roomIds.has(s.roomId));
|
|
state.sections.push(...sections);
|
|
state.past = [];
|
|
state.future = [];
|
|
}),
|
|
loadSections: (newSections) => set(state => {
|
|
newSections.forEach(ns => {
|
|
const index = state.sections.findIndex(s => s.id === ns.id);
|
|
if (index !== -1) {
|
|
state.sections[index] = ns;
|
|
} else {
|
|
state.sections.push(ns);
|
|
}
|
|
});
|
|
}),
|
|
clearRooms: () => set({ rooms: [], sections: [], past: [], future: [] }),
|
|
|
|
// Canvas
|
|
setZoom: (zoom) => set({ zoom: Math.max(0.1, Math.min(5, zoom)) }),
|
|
setPan: (pan) => set({ pan }),
|
|
zoomIn: () => set((state) => ({ zoom: Math.min(5, state.zoom * 1.2) })),
|
|
zoomOut: () => set((state) => ({ zoom: Math.max(0.1, state.zoom / 1.2) })),
|
|
resetView: () => set({ zoom: 1, pan: { x: 0, y: 0 } }),
|
|
toggleGrid: () => set((state) => ({ showGrid: !state.showGrid })),
|
|
toggleSnap: () => set((state) => ({ snapToGrid: !state.snapToGrid })),
|
|
|
|
// Selection
|
|
select: (ids) => set({ selectedIds: ids }),
|
|
addToSelection: (id) => set((state) => ({
|
|
selectedIds: [...state.selectedIds, id]
|
|
})),
|
|
clearSelection: () => set({ selectedIds: [], selectedPosition: null }),
|
|
setHovered: (id) => set({ hoveredId: id }),
|
|
setSelectedPosition: (position) => set({ selectedPosition: position }),
|
|
|
|
// Room CRUD
|
|
addRoom: (room) => {
|
|
const id = generateId();
|
|
get().saveSnapshot();
|
|
set((state) => {
|
|
state.rooms.push({ ...room, id });
|
|
});
|
|
return id;
|
|
},
|
|
updateRoom: (id, updates) => {
|
|
get().saveSnapshot();
|
|
set((state) => {
|
|
const room = state.rooms.find(r => r.id === id);
|
|
if (room) Object.assign(room, updates);
|
|
});
|
|
},
|
|
deleteRoom: (id) => {
|
|
get().saveSnapshot();
|
|
set((state) => {
|
|
state.rooms = state.rooms.filter(r => r.id !== id);
|
|
state.sections = state.sections.filter(s => s.roomId !== id);
|
|
state.selectedIds = state.selectedIds.filter(sid => sid !== id);
|
|
});
|
|
},
|
|
moveRoom: (id, position) => {
|
|
set((state) => {
|
|
const room = state.rooms.find(r => r.id === id);
|
|
if (room) {
|
|
if (state.snapToGrid) {
|
|
const gridPx = state.gridSize * 20; // 20px per foot
|
|
room.position = {
|
|
x: Math.round(position.x / gridPx) * gridPx,
|
|
y: Math.round(position.y / gridPx) * gridPx,
|
|
};
|
|
} else {
|
|
room.position = position;
|
|
}
|
|
}
|
|
});
|
|
},
|
|
resizeRoom: (id, size) => {
|
|
set((state) => {
|
|
const room = state.rooms.find(r => r.id === id);
|
|
if (room) {
|
|
if (state.snapToGrid) {
|
|
const gridPx = state.gridSize * 20;
|
|
room.size = {
|
|
width: Math.round(size.width / gridPx) * gridPx,
|
|
height: Math.round(size.height / gridPx) * gridPx,
|
|
};
|
|
} else {
|
|
room.size = size;
|
|
}
|
|
}
|
|
});
|
|
},
|
|
|
|
// Section CRUD
|
|
addSection: (section) => {
|
|
const id = generateId();
|
|
get().saveSnapshot();
|
|
set((state) => {
|
|
state.sections.push({ ...section, id });
|
|
});
|
|
return id;
|
|
},
|
|
updateSection: (id, updates) => {
|
|
get().saveSnapshot();
|
|
set((state) => {
|
|
const section = state.sections.find(s => s.id === id);
|
|
if (section) Object.assign(section, updates);
|
|
});
|
|
},
|
|
deleteSection: (id) => {
|
|
get().saveSnapshot();
|
|
set((state) => {
|
|
state.sections = state.sections.filter(s => s.id !== id);
|
|
});
|
|
},
|
|
|
|
// Tool
|
|
setActiveTool: (tool) => set({ activeTool: tool }),
|
|
setPendingRoomType: (type) => set({ pendingRoomType: type }),
|
|
|
|
// History
|
|
saveSnapshot: () => {
|
|
const { rooms, sections, past } = get();
|
|
set({
|
|
past: [...past.slice(-49), { rooms: JSON.parse(JSON.stringify(rooms)), sections: JSON.parse(JSON.stringify(sections)) }],
|
|
future: [],
|
|
});
|
|
},
|
|
undo: () => {
|
|
const { past, rooms, sections } = get();
|
|
if (past.length === 0) return;
|
|
|
|
const previous = past[past.length - 1];
|
|
set({
|
|
past: past.slice(0, -1),
|
|
future: [{ rooms: JSON.parse(JSON.stringify(rooms)), sections: JSON.parse(JSON.stringify(sections)) }, ...get().future],
|
|
rooms: previous.rooms,
|
|
sections: previous.sections,
|
|
});
|
|
},
|
|
redo: () => {
|
|
const { future, rooms, sections } = get();
|
|
if (future.length === 0) return;
|
|
|
|
const next = future[0];
|
|
set({
|
|
future: future.slice(1),
|
|
past: [...get().past, { rooms: JSON.parse(JSON.stringify(rooms)), sections: JSON.parse(JSON.stringify(sections)) }],
|
|
rooms: next.rooms,
|
|
sections: next.sections,
|
|
});
|
|
},
|
|
canUndo: () => get().past.length > 0,
|
|
canRedo: () => get().future.length > 0,
|
|
}))
|
|
);
|