feat: Layout Designer enhancements (#1)
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

- Added ability to create new Floors and properties directly in Layout Designer
- Implemented AddFloorModal
- Updated FloorSelector to support adding properties/floors
- Fixed missing capabilities in PropertySetup
- Resolved circular dependencies and duplicate imports
This commit is contained in:
fullsizemalt 2025-12-11 11:31:07 -08:00
parent 35162d565d
commit f8a368be62
4 changed files with 317 additions and 11 deletions

124
.agent/workflows/deploy.md Normal file
View file

@ -0,0 +1,124 @@
---
description: Deploy ca-grow-ops-manager to nexus-vector via Forgejo
---
# Deploying CA Grow Ops Manager
## Prerequisites
- SSH access to nexus-vector (via Tailscale)
- Forgejo accessible at <https://git.runfoo.run>
- Deploy key authorized for the repository
## Standard Deployment (CI/CD Automatic)
Pushing to `main` branch triggers automatic deployment via Forgejo Actions:
```bash
# From local machine
cd /Users/ten/ANTIGRAVITY/777wolfpack/ca-grow-ops-manager
git add -A
git commit -m "your commit message"
git push origin main
```
The CI/CD workflow will:
1. Pull latest code on nexus-vector
2. Rebuild containers
3. Run database migrations
4. Seed demo data (if needed)
5. Restart services
## Manual Deployment (When CI/CD Fails)
// turbo-all
### 1. SSH into nexus-vector
```bash
ssh nexus-vector
```
### 2. Navigate to project
```bash
cd /srv/containers/ca-grow-ops-manager
```
### 3. Pull latest code (use admin user for Forgejo access)
```bash
# If deploy key is set up:
sudo -u admin git pull origin main
# If that fails, use HTTPS with token:
git pull https://git.runfoo.run/malty/ca-grow-ops-manager.git main
```
### 4. Rebuild and deploy
```bash
docker compose build --no-cache
docker compose up -d db redis
sleep 5
docker compose run --rm backend npx prisma migrate deploy
docker compose run --rm backend node prisma/seed.js
docker compose up -d
```
### 5. Verify deployment
```bash
docker compose ps
curl -s http://localhost:8010/api/healthz
```
## Troubleshooting
### Forgejo 502 Error
Forgejo container may need restart:
```bash
cd /srv/containers/forgejo
docker compose restart forgejo
```
### Git Permission Denied
Deploy key not authorized. Either:
1. Add deploy key to repo in Forgejo UI (Settings > Deploy Keys)
2. Use HTTPS with personal access token
### SSH Host Key Changed
```bash
ssh-keygen -f ~/.ssh/known_hosts -R '[localhost]:2222'
ssh-keyscan -p 2222 localhost >> ~/.ssh/known_hosts
```
## Useful Commands
```bash
# View logs
docker compose logs -f frontend
docker compose logs -f backend
# Check container health
docker compose ps
# Run database commands
docker compose exec db psql -U ca_grow_ops -d ca_grow_ops
# Restart specific service
docker compose restart backend
```
## URLs
- **Live Site**: <https://777wolfpack.runfoo.run>
- **API Health**: <https://777wolfpack.runfoo.run/api/healthz>
- **Forgejo**: <https://git.runfoo.run/malty/ca-grow-ops-manager>
- **Forgejo SSH**: ssh://git@localhost:2222/malty/ca-grow-ops-manager.git (from nexus-vector)

View file

@ -12,6 +12,7 @@ import { ElementsPanel } from './components/ElementsPanel';
import { InspectorPanel } from './components/InspectorPanel';
import { FloorSelector } from './components/FloorSelector';
import { PropertySetup } from './components/PropertySetup';
import { AddFloorModal } from './components/AddFloorModal';
import { PlantInventory } from './components/PlantInventory';
import { layoutApi } from '../../lib/layoutApi';
@ -40,6 +41,8 @@ export default function LayoutDesignerPage() {
const [saving, setSaving] = useState(false);
const [showFloorSelector, setShowFloorSelector] = useState(false);
const [showPropertySetup, setShowPropertySetup] = useState(false);
const [showAddFloorModal, setShowAddFloorModal] = useState(false);
const [buildingForFloor, setBuildingForFloor] = useState<string | null>(null);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [sidebarMode, setSidebarMode] = useState<'design' | 'plants'>('design');
@ -491,12 +494,29 @@ export default function LayoutDesignerPage() {
isOpen={showFloorSelector}
onClose={() => setShowFloorSelector(false)}
onSelectFloor={handleSelectFloor}
onAddProperty={() => setShowPropertySetup(true)}
onAddFloor={(bId) => {
setBuildingForFloor(bId);
setShowAddFloorModal(true);
}}
/>
<PropertySetup
isOpen={showPropertySetup}
onClose={() => setShowPropertySetup(false)}
onCreated={loadProperties}
/>
<AddFloorModal
isOpen={showAddFloorModal}
onClose={() => setShowAddFloorModal(false)}
buildingId={buildingForFloor}
onCreated={(newFloorId) => {
loadProperties().then(() => {
if (buildingForFloor) {
handleSelectFloor(newFloorId, buildingForFloor);
}
});
}}
/>
</div>
);
}

View file

@ -0,0 +1,125 @@
import { useState } from 'react';
import { X, Layers, ArrowRight, Loader2 } from 'lucide-react';
import { layoutApi } from '../../../lib/layoutApi';
interface AddFloorModalProps {
isOpen: boolean;
onClose: () => void;
buildingId: string | null;
onCreated: (floorId: string) => void;
}
export function AddFloorModal({ isOpen, onClose, buildingId, onCreated }: AddFloorModalProps) {
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState({
name: '',
number: 1,
width: 100,
height: 100
});
if (!isOpen || !buildingId) return null;
const handleCreate = async () => {
if (!formData.name.trim()) return;
setLoading(true);
try {
const floor = await layoutApi.createFloor(buildingId, {
name: formData.name.trim(),
number: formData.number,
width: formData.width,
height: formData.height
});
onCreated(floor.id);
onClose();
// Reset form
setFormData({ name: '', number: formData.number + 1, width: 100, height: 100 });
} catch (error) {
console.error('Failed to create floor:', error);
} finally {
setLoading(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="bg-slate-900 border border-white/10 rounded-2xl w-full max-w-md mx-4 overflow-hidden shadow-2xl">
{/* Header */}
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-emerald-600/20 flex items-center justify-center">
<Layers className="text-emerald-400" size={20} />
</div>
<div>
<h2 className="text-lg font-bold text-white">Add New Floor</h2>
<p className="text-xs text-slate-400">Define floor dimensions</p>
</div>
</div>
<button onClick={onClose} className="p-2 hover:bg-white/5 rounded-lg transition-colors">
<X size={20} className="text-slate-400" />
</button>
</div>
{/* Content */}
<div className="p-6 space-y-4">
<div>
<label className="block text-sm text-slate-400 mb-2">Floor Name</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="Floor 2"
className="w-full bg-slate-800 border border-slate-700 rounded-lg px-4 py-3 text-white focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 outline-none"
/>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm text-slate-400 mb-2">Number</label>
<input
type="number"
value={formData.number}
onChange={(e) => setFormData({ ...formData, number: parseInt(e.target.value) || 0 })}
className="w-full bg-slate-800 border border-slate-700 rounded-lg px-4 py-3 text-white focus:border-emerald-500 outline-none"
/>
</div>
<div>
<label className="block text-sm text-slate-400 mb-2">Width (ft)</label>
<input
type="number"
value={formData.width}
onChange={(e) => setFormData({ ...formData, width: parseInt(e.target.value) || 0 })}
className="w-full bg-slate-800 border border-slate-700 rounded-lg px-4 py-3 text-white focus:border-emerald-500 outline-none"
/>
</div>
<div>
<label className="block text-sm text-slate-400 mb-2">Height (ft)</label>
<input
type="number"
value={formData.height}
onChange={(e) => setFormData({ ...formData, height: parseInt(e.target.value) || 0 })}
className="w-full bg-slate-800 border border-slate-700 rounded-lg px-4 py-3 text-white focus:border-emerald-500 outline-none"
/>
</div>
</div>
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-white/10 flex justify-end gap-3">
<button onClick={onClose} className="px-4 py-2 text-sm text-slate-400 hover:text-white transition-colors">
Cancel
</button>
<button
onClick={handleCreate}
disabled={!formData.name.trim() || loading}
className="flex items-center gap-2 px-5 py-2 bg-emerald-600 hover:bg-emerald-500 disabled:bg-slate-700 disabled:text-slate-500 rounded-lg text-sm font-medium transition-colors"
>
{loading ? <Loader2 size={16} className="animate-spin" /> : <ArrowRight size={16} />}
Create Floor
</button>
</div>
</div>
</div>
);
}

View file

@ -6,9 +6,17 @@ interface FloorSelectorProps {
isOpen: boolean;
onClose: () => void;
onSelectFloor: (floorId: string, buildingId: string) => void;
onAddProperty?: () => void;
onAddFloor?: (buildingId: string) => void;
}
export function FloorSelector({ isOpen, onClose, onSelectFloor }: FloorSelectorProps) {
export function FloorSelector({
isOpen,
onClose,
onSelectFloor,
onAddProperty,
onAddFloor
}: FloorSelectorProps) {
const { property, buildingId, floorId } = useLayoutStore();
const [expandedBuildings, setExpandedBuildings] = useState<Set<string>>(new Set());
@ -49,17 +57,28 @@ export function FloorSelector({ isOpen, onClose, onSelectFloor }: FloorSelectorP
</div>
{/* Property Info */}
{property && (
<div className="px-6 py-3 bg-slate-800/50 border-b border-white/10 flex items-center gap-3">
<div className="px-6 py-3 bg-slate-800/50 border-b border-white/10 flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<MapPin size={16} className="text-emerald-500" />
<div>
<p className="text-sm font-medium text-white">{property.name}</p>
{property.address && (
<p className="text-sm font-medium text-white">{property?.name || 'No Property'}</p>
{property?.address && (
<p className="text-xs text-slate-400">{property.address}</p>
)}
</div>
</div>
)}
{onAddProperty && (
<button
onClick={() => {
onAddProperty();
onClose();
}}
className="text-xs px-2 py-1 bg-slate-700 hover:bg-slate-600 rounded text-slate-300 transition-colors"
>
Change/New
</button>
)}
</div>
{/* Buildings & Floors List */}
<div className="max-h-96 overflow-auto p-4">
@ -116,10 +135,18 @@ export function FloorSelector({ isOpen, onClose, onSelectFloor }: FloorSelectorP
})}
{/* Add Floor Button */}
<button className="w-full flex items-center gap-3 px-3 py-2 text-slate-500 hover:text-slate-300 transition-colors">
<Plus size={16} />
<span className="text-sm">Add Floor</span>
</button>
{onAddFloor && (
<button
onClick={() => {
onAddFloor(building.id);
onClose();
}}
className="w-full flex items-center gap-3 px-3 py-2 text-slate-500 hover:text-emerald-400 hover:bg-white/5 rounded-lg transition-colors"
>
<Plus size={16} />
<span className="text-sm">Add Floor</span>
</button>
)}
</div>
)}
</div>
@ -130,7 +157,17 @@ export function FloorSelector({ isOpen, onClose, onSelectFloor }: FloorSelectorP
<div className="text-center py-8">
<Building2 size={32} className="text-slate-600 mx-auto mb-3" />
<p className="text-slate-400 mb-2">No buildings found</p>
<p className="text-xs text-slate-500">Create a property first</p>
<button
onClick={() => {
if (onAddProperty) {
onAddProperty();
onClose();
}
}}
className="text-sm text-emerald-400 hover:text-emerald-300"
>
Setup Property
</button>
</div>
)}
</div>