feat: Layout Designer enhancements (#1)
- 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:
parent
35162d565d
commit
f8a368be62
4 changed files with 317 additions and 11 deletions
124
.agent/workflows/deploy.md
Normal file
124
.agent/workflows/deploy.md
Normal 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)
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">
|
||||
{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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue