ca-grow-ops-manager/frontend/src/stores/layoutStore.ts
fullsizemalt 2ffc4edbcd
Some checks failed
Deploy to Production / deploy (push) Failing after 0s
Test / backend-test (push) Failing after 0s
Test / frontend-test (push) Failing after 0s
fix(build): Resolve TypeScript and Import errors
- 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
2025-12-11 14:50:42 -08:00

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,
}))
);