- ThemeToggle: Single button cycle instead of 3-button bar - UserMenu: Cleaner styling with accent avatar - MobileNavSheet: Consistent Linear tokens - Walkthrough checklists: Desktop two-column layout - RoleModal: Toggle buttons instead of tiny checkboxes - IPMScheduleModal: Toggle buttons instead of checkbox - ScoutingModal: Toggle buttons instead of checkbox
288 lines
13 KiB
TypeScript
288 lines
13 KiB
TypeScript
import { useState } from 'react';
|
|
import { ArrowLeft, ChevronRight, Camera, X, Check, AlertTriangle, Minus, Plus } from 'lucide-react';
|
|
|
|
interface Zone {
|
|
name: string;
|
|
defaultDrippers: number;
|
|
}
|
|
|
|
interface IrrigationCheckData {
|
|
zoneName: string;
|
|
drippersTotal: number;
|
|
drippersWorking: number;
|
|
drippersFailed: string[];
|
|
waterFlow: boolean;
|
|
nutrientsMixed: boolean;
|
|
scheduleActive: boolean;
|
|
photoUrl?: string;
|
|
issues?: string;
|
|
}
|
|
|
|
interface IrrigationChecklistProps {
|
|
onComplete: (checks: IrrigationCheckData[]) => void;
|
|
onBack: () => void;
|
|
isPhotoRequired: boolean;
|
|
}
|
|
|
|
export default function IrrigationChecklist({ onComplete, onBack, isPhotoRequired }: IrrigationChecklistProps) {
|
|
const zones: Zone[] = [
|
|
{ name: 'Veg Upstairs', defaultDrippers: 48 },
|
|
{ name: 'Veg Downstairs', defaultDrippers: 48 },
|
|
{ name: 'Flower Upstairs', defaultDrippers: 64 },
|
|
{ name: 'Flower Downstairs', defaultDrippers: 64 },
|
|
];
|
|
|
|
const [checks, setChecks] = useState<Map<string, IrrigationCheckData>>(new Map());
|
|
const [currentZoneIndex, setCurrentZoneIndex] = useState(0);
|
|
const [drippersWorking, setDrippersWorking] = useState(zones[0].defaultDrippers);
|
|
const [waterFlow, setWaterFlow] = useState(true);
|
|
const [nutrientsMixed, setNutrientsMixed] = useState(true);
|
|
const [scheduleActive, setScheduleActive] = useState(true);
|
|
const [issues, setIssues] = useState('');
|
|
const [photo, setPhoto] = useState<string | null>(null);
|
|
|
|
const currentZone = zones[currentZoneIndex];
|
|
const isLastZone = currentZoneIndex === zones.length - 1;
|
|
const drippersFailed = currentZone.defaultDrippers - drippersWorking;
|
|
const allGood = waterFlow && nutrientsMixed && scheduleActive && drippersFailed === 0;
|
|
|
|
const handleNext = () => {
|
|
if (isPhotoRequired && !photo) {
|
|
alert('Photo is required for this step.');
|
|
return;
|
|
}
|
|
|
|
const checkData: IrrigationCheckData = {
|
|
zoneName: currentZone.name,
|
|
drippersTotal: currentZone.defaultDrippers,
|
|
drippersWorking,
|
|
drippersFailed: drippersFailed > 0 ? [`${drippersFailed} drippers`] : [],
|
|
waterFlow,
|
|
nutrientsMixed,
|
|
scheduleActive,
|
|
issues: issues || undefined,
|
|
photoUrl: photo || undefined
|
|
};
|
|
|
|
const newChecks = new Map(checks);
|
|
newChecks.set(currentZone.name, checkData);
|
|
setChecks(newChecks);
|
|
|
|
if (isLastZone) {
|
|
onComplete(Array.from(newChecks.values()));
|
|
} else {
|
|
const nextZone = zones[currentZoneIndex + 1];
|
|
setCurrentZoneIndex(currentZoneIndex + 1);
|
|
setDrippersWorking(nextZone.defaultDrippers);
|
|
setWaterFlow(true);
|
|
setNutrientsMixed(true);
|
|
setScheduleActive(true);
|
|
setIssues('');
|
|
setPhoto(null);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-screen bg-primary p-4 md:p-8 lg:p-12 animate-in">
|
|
<div className="max-w-4xl mx-auto">
|
|
{/* Header */}
|
|
<div className="mb-6 md:mb-10">
|
|
<div className="flex items-center gap-4 mb-4">
|
|
<button
|
|
onClick={onBack}
|
|
className="btn btn-secondary p-2"
|
|
aria-label="Back"
|
|
>
|
|
<ArrowLeft size={18} />
|
|
</button>
|
|
<div className="flex-1">
|
|
<h1 className="text-2xl md:text-3xl font-semibold text-primary">Irrigation System</h1>
|
|
<p className="text-secondary text-sm mt-0.5">
|
|
Zone {currentZoneIndex + 1} of {zones.length}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Progress Bar */}
|
|
<div className="h-1 bg-tertiary rounded-full overflow-hidden">
|
|
<div
|
|
className="h-full bg-accent transition-all duration-normal"
|
|
style={{ width: `${((currentZoneIndex + 1) / zones.length) * 100}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Main Content - Two Column on Desktop */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 lg:gap-8">
|
|
{/* Left Column - Drippers & Status */}
|
|
<div className="space-y-6">
|
|
{/* Zone Info Card */}
|
|
<div className="card p-6 md:p-8">
|
|
<div className="text-center mb-6">
|
|
<div className="text-4xl mb-3">🚿</div>
|
|
<h2 className="text-xl font-semibold text-primary">{currentZone.name}</h2>
|
|
<p className="text-sm text-tertiary">{currentZone.defaultDrippers} drippers total</p>
|
|
</div>
|
|
|
|
{/* Status Badge */}
|
|
<div className={`flex items-center justify-center gap-2 py-3 rounded-md font-medium ${allGood
|
|
? 'bg-success-muted text-success'
|
|
: 'bg-warning-muted text-warning'
|
|
}`}>
|
|
{allGood ? <Check size={16} /> : <AlertTriangle size={16} />}
|
|
{allGood ? 'All Systems Operational' : 'Issues Detected'}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Drippers Counter */}
|
|
<div className="card p-6">
|
|
<label className="block text-sm font-medium text-secondary mb-4">
|
|
Drippers Working
|
|
</label>
|
|
<div className="flex items-center gap-4">
|
|
<button
|
|
onClick={() => setDrippersWorking(Math.max(0, drippersWorking - 1))}
|
|
className="btn btn-secondary w-12 h-12 p-0"
|
|
>
|
|
<Minus size={18} />
|
|
</button>
|
|
<div className="flex-1 text-center">
|
|
<div className="text-4xl font-semibold text-primary">{drippersWorking}</div>
|
|
{drippersFailed > 0 && (
|
|
<div className="text-sm text-destructive mt-1">{drippersFailed} failed</div>
|
|
)}
|
|
</div>
|
|
<button
|
|
onClick={() => setDrippersWorking(Math.min(currentZone.defaultDrippers, drippersWorking + 1))}
|
|
className="btn btn-secondary w-12 h-12 p-0"
|
|
>
|
|
<Plus size={18} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right Column - System Checks */}
|
|
<div className="space-y-6">
|
|
{/* System Status Toggles */}
|
|
<div className="card p-6">
|
|
<label className="block text-sm font-medium text-secondary mb-4">System Status</label>
|
|
<div className="space-y-3">
|
|
<StatusToggle
|
|
label="Water Flow"
|
|
value={waterFlow}
|
|
onChange={setWaterFlow}
|
|
/>
|
|
<StatusToggle
|
|
label="Nutrients Mixed"
|
|
value={nutrientsMixed}
|
|
onChange={setNutrientsMixed}
|
|
/>
|
|
<StatusToggle
|
|
label="Schedule Active"
|
|
value={scheduleActive}
|
|
onChange={setScheduleActive}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Issues / Notes */}
|
|
{!allGood && (
|
|
<div className="card p-6">
|
|
<label className="block text-sm font-medium text-secondary mb-2">Issues / Notes</label>
|
|
<textarea
|
|
value={issues}
|
|
onChange={(e) => setIssues(e.target.value)}
|
|
placeholder="Describe any issues found..."
|
|
className="input w-full resize-none"
|
|
rows={3}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Photo Upload */}
|
|
{(isPhotoRequired || !allGood) && (
|
|
<div className="card p-6">
|
|
<label className="block text-sm font-medium text-secondary mb-2">
|
|
{isPhotoRequired ? 'Photo (Required)' : 'Photo (Recommended)'}
|
|
</label>
|
|
|
|
<input
|
|
type="file"
|
|
id="photo-upload"
|
|
className="hidden"
|
|
accept="image/*"
|
|
capture="environment"
|
|
onChange={(e) => {
|
|
if (e.target.files?.[0]) {
|
|
setPhoto(URL.createObjectURL(e.target.files[0]));
|
|
}
|
|
}}
|
|
/>
|
|
|
|
{photo ? (
|
|
<div className="relative">
|
|
<img src={photo} alt="Preview" className="w-full max-h-48 object-cover rounded-md" />
|
|
<button
|
|
onClick={() => setPhoto(null)}
|
|
className="absolute top-2 right-2 p-1.5 bg-destructive text-white rounded-full shadow-lg"
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<label
|
|
htmlFor="photo-upload"
|
|
className={`flex flex-col items-center justify-center p-8 border-2 border-dashed rounded-md cursor-pointer transition-colors ${isPhotoRequired
|
|
? 'border-destructive/50 bg-destructive-muted hover:border-destructive'
|
|
: 'border-subtle hover:border-default hover:bg-tertiary'
|
|
}`}
|
|
>
|
|
<Camera size={24} className="text-tertiary mb-2" />
|
|
<span className="text-sm font-medium text-primary">Tap to capture</span>
|
|
<span className="text-xs text-tertiary">System status photo</span>
|
|
</label>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Action Buttons */}
|
|
<div className="flex gap-3">
|
|
<button onClick={onBack} className="btn btn-secondary flex-1 h-12 md:h-14">
|
|
Back
|
|
</button>
|
|
<button onClick={handleNext} className="btn btn-primary flex-1 h-12 md:h-14">
|
|
{isLastZone ? 'Complete' : 'Next Zone'}
|
|
{!isLastZone && <ChevronRight size={16} />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Status Toggle Component
|
|
function StatusToggle({
|
|
label,
|
|
value,
|
|
onChange
|
|
}: {
|
|
label: string;
|
|
value: boolean;
|
|
onChange: (val: boolean) => void;
|
|
}) {
|
|
return (
|
|
<button
|
|
onClick={() => onChange(!value)}
|
|
className={`w-full flex items-center justify-between p-4 rounded-md border transition-colors ${value
|
|
? 'bg-success-muted border-success/30 text-success'
|
|
: 'bg-destructive-muted border-destructive/30 text-destructive'
|
|
}`}
|
|
>
|
|
<span className="font-medium text-primary">{label}</span>
|
|
<span className="text-lg">{value ? '✓' : '✗'}</span>
|
|
</button>
|
|
);
|
|
}
|