feat: Wire action buttons and add CreateBatchModal
- Wire 'Add Zone' button on RoomsPage to open wizard - Create CreateBatchModal for batch creation form - Wire 'New Batch' button on BatchesPage to open modal - Modal includes strain, plant count, source, stage, room, METRC tags
This commit is contained in:
parent
d2abe033f2
commit
eca58ecbc2
3 changed files with 226 additions and 2 deletions
208
frontend/src/components/CreateBatchModal.tsx
Normal file
208
frontend/src/components/CreateBatchModal.tsx
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { X, Loader2 } from 'lucide-react';
|
||||||
|
import { batchesApi } from '../lib/batchesApi';
|
||||||
|
import api from '../lib/api';
|
||||||
|
import { useToast } from '../context/ToastContext';
|
||||||
|
|
||||||
|
interface CreateBatchModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CreateBatchModal({ isOpen, onClose, onSuccess }: CreateBatchModalProps) {
|
||||||
|
const { addToast } = useToast();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [rooms, setRooms] = useState<any[]>([]);
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: '',
|
||||||
|
strain: '',
|
||||||
|
plantCount: 1,
|
||||||
|
source: 'CLONE' as 'CLONE' | 'SEED',
|
||||||
|
stage: 'CLONE_IN' as string,
|
||||||
|
roomId: '',
|
||||||
|
metrcTags: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
loadRooms();
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const loadRooms = async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/rooms');
|
||||||
|
setRooms(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load rooms:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
...formData,
|
||||||
|
plantCount: parseInt(String(formData.plantCount)),
|
||||||
|
roomId: formData.roomId || undefined,
|
||||||
|
metrcTags: formData.metrcTags ? formData.metrcTags.split(',').map(t => t.trim()) : undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
await batchesApi.create(payload);
|
||||||
|
addToast('Batch created successfully', 'success');
|
||||||
|
onSuccess();
|
||||||
|
onClose();
|
||||||
|
} catch (err: any) {
|
||||||
|
addToast(err.response?.data?.message || 'Failed to create batch', 'error');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||||
|
<div className="bg-[var(--color-bg-elevated)] rounded-2xl shadow-xl w-full max-w-md mx-4 overflow-hidden border border-[var(--color-border-subtle)]">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-[var(--color-border-subtle)]">
|
||||||
|
<h2 className="text-lg font-bold text-[var(--color-text-primary)]">Create New Batch</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1 rounded-lg hover:bg-[var(--color-bg-tertiary)] transition-colors"
|
||||||
|
>
|
||||||
|
<X size={20} className="text-[var(--color-text-tertiary)]" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<form onSubmit={handleSubmit} className="p-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-[var(--color-text-tertiary)] uppercase tracking-wider mb-1.5">
|
||||||
|
Batch Name *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={formData.name}
|
||||||
|
onChange={e => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
placeholder="e.g., B001-OG Kush"
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-bg-primary)] text-[var(--color-text-primary)] focus:ring-2 focus:ring-[var(--color-primary)]/20 focus:border-[var(--color-primary)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-[var(--color-text-tertiary)] uppercase tracking-wider mb-1.5">
|
||||||
|
Strain *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={formData.strain}
|
||||||
|
onChange={e => setFormData({ ...formData, strain: e.target.value })}
|
||||||
|
placeholder="e.g., OG Kush"
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-bg-primary)] text-[var(--color-text-primary)] focus:ring-2 focus:ring-[var(--color-primary)]/20 focus:border-[var(--color-primary)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-[var(--color-text-tertiary)] uppercase tracking-wider mb-1.5">
|
||||||
|
Plant Count *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
required
|
||||||
|
min={1}
|
||||||
|
value={formData.plantCount}
|
||||||
|
onChange={e => setFormData({ ...formData, plantCount: parseInt(e.target.value) || 1 })}
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-bg-primary)] text-[var(--color-text-primary)] focus:ring-2 focus:ring-[var(--color-primary)]/20 focus:border-[var(--color-primary)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-[var(--color-text-tertiary)] uppercase tracking-wider mb-1.5">
|
||||||
|
Source *
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={formData.source}
|
||||||
|
onChange={e => setFormData({ ...formData, source: e.target.value as 'CLONE' | 'SEED' })}
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-bg-primary)] text-[var(--color-text-primary)] focus:ring-2 focus:ring-[var(--color-primary)]/20 focus:border-[var(--color-primary)]"
|
||||||
|
>
|
||||||
|
<option value="CLONE">Clone</option>
|
||||||
|
<option value="SEED">Seed</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-[var(--color-text-tertiary)] uppercase tracking-wider mb-1.5">
|
||||||
|
Starting Stage
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={formData.stage}
|
||||||
|
onChange={e => setFormData({ ...formData, stage: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-bg-primary)] text-[var(--color-text-primary)] focus:ring-2 focus:ring-[var(--color-primary)]/20 focus:border-[var(--color-primary)]"
|
||||||
|
>
|
||||||
|
<option value="CLONE_IN">Clone In</option>
|
||||||
|
<option value="VEGETATIVE">Vegetative</option>
|
||||||
|
<option value="FLOWERING">Flowering</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-[var(--color-text-tertiary)] uppercase tracking-wider mb-1.5">
|
||||||
|
Assign to Room
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={formData.roomId}
|
||||||
|
onChange={e => setFormData({ ...formData, roomId: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-bg-primary)] text-[var(--color-text-primary)] focus:ring-2 focus:ring-[var(--color-primary)]/20 focus:border-[var(--color-primary)]"
|
||||||
|
>
|
||||||
|
<option value="">Unassigned</option>
|
||||||
|
{rooms.map(room => (
|
||||||
|
<option key={room.id} value={room.id}>{room.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-[var(--color-text-tertiary)] uppercase tracking-wider mb-1.5">
|
||||||
|
METRC Tags (comma-separated)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.metrcTags}
|
||||||
|
onChange={e => setFormData({ ...formData, metrcTags: e.target.value })}
|
||||||
|
placeholder="e.g., 1A400100001234, 1A400100001235"
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-bg-primary)] text-[var(--color-text-primary)] focus:ring-2 focus:ring-[var(--color-primary)]/20 focus:border-[var(--color-primary)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex-1 px-4 py-2.5 rounded-xl border border-[var(--color-border-subtle)] text-[var(--color-text-secondary)] font-medium hover:bg-[var(--color-bg-tertiary)] transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="flex-1 px-4 py-2.5 rounded-xl bg-[var(--color-primary)] text-[var(--color-text-inverse)] font-bold hover:bg-[var(--color-primary-hover)] transition-colors disabled:opacity-50 flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{loading && <Loader2 size={16} className="animate-spin" />}
|
||||||
|
Create Batch
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ import { useToast } from '../context/ToastContext';
|
||||||
import BatchTransitionModal from '../components/BatchTransitionModal';
|
import BatchTransitionModal from '../components/BatchTransitionModal';
|
||||||
import WeightLogModal from '../components/WeightLogModal';
|
import WeightLogModal from '../components/WeightLogModal';
|
||||||
import CreateTaskModal from '../components/tasks/CreateTaskModal';
|
import CreateTaskModal from '../components/tasks/CreateTaskModal';
|
||||||
|
import CreateBatchModal from '../components/CreateBatchModal';
|
||||||
import IPMScheduleModal from '../components/IPMScheduleModal';
|
import IPMScheduleModal from '../components/IPMScheduleModal';
|
||||||
import ScoutingModal from '../components/ipm/ScoutingModal';
|
import ScoutingModal from '../components/ipm/ScoutingModal';
|
||||||
import { DataTable, Column } from '../components/ui/DataTable';
|
import { DataTable, Column } from '../components/ui/DataTable';
|
||||||
|
|
@ -63,6 +64,7 @@ export default function BatchesPage() {
|
||||||
const [createTaskBatch, setCreateTaskBatch] = useState<Batch | null>(null);
|
const [createTaskBatch, setCreateTaskBatch] = useState<Batch | null>(null);
|
||||||
const [ipmBatch, setIpmBatch] = useState<Batch | null>(null);
|
const [ipmBatch, setIpmBatch] = useState<Batch | null>(null);
|
||||||
const [scoutingBatch, setScoutingBatch] = useState<Batch | null>(null);
|
const [scoutingBatch, setScoutingBatch] = useState<Batch | null>(null);
|
||||||
|
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchBatches();
|
fetchBatches();
|
||||||
|
|
@ -205,7 +207,10 @@ export default function BatchesPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button className="flex items-center gap-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-[var(--color-text-inverse)] px-4 py-2.5 rounded-xl font-bold text-sm shadow-lg transition-all">
|
<button
|
||||||
|
onClick={() => setIsCreateOpen(true)}
|
||||||
|
className="flex items-center gap-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-[var(--color-text-inverse)] px-4 py-2.5 rounded-xl font-bold text-sm shadow-lg transition-all"
|
||||||
|
>
|
||||||
<Plus size={18} />
|
<Plus size={18} />
|
||||||
New Batch
|
New Batch
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -304,6 +309,14 @@ export default function BatchesPage() {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<CreateBatchModal
|
||||||
|
isOpen={isCreateOpen}
|
||||||
|
onClose={() => setIsCreateOpen(false)}
|
||||||
|
onSuccess={() => {
|
||||||
|
fetchBatches();
|
||||||
|
setIsCreateOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,10 @@ export default function RoomsPage() {
|
||||||
Generate Zone
|
Generate Zone
|
||||||
</button>
|
</button>
|
||||||
{isManager && (
|
{isManager && (
|
||||||
<button className="flex items-center gap-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-[var(--color-text-inverse)] px-4 py-2.5 rounded-xl font-bold text-sm shadow-lg transition-all">
|
<button
|
||||||
|
onClick={() => setIsWizardOpen(true)}
|
||||||
|
className="flex items-center gap-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-[var(--color-text-inverse)] px-4 py-2.5 rounded-xl font-bold text-sm shadow-lg transition-all"
|
||||||
|
>
|
||||||
<Plus size={18} />
|
<Plus size={18} />
|
||||||
Add Zone
|
Add Zone
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue