feat: global breadcrumbs + walkthrough photo upload
Breadcrumbs: - Added Breadcrumbs to main Layout (appears on ALL pages) - Dynamic route support (/batches/:id, /rooms/:id) - Proper navigation hierarchy Daily Walkthrough: - Enhanced layout with progress bar - Photo capture from camera or file upload - Notes fields for each check - Improved touch targets and mobile UX Removed inline breadcrumbs from individual pages since they now come from the global Layout.
This commit is contained in:
parent
9897e73de1
commit
15b50a74c6
3 changed files with 385 additions and 165 deletions
|
|
@ -12,6 +12,7 @@ import { SessionTimeoutWarning } from './ui/SessionTimeoutWarning';
|
||||||
import { PageTitleUpdater } from '../hooks/usePageTitle';
|
import { PageTitleUpdater } from '../hooks/usePageTitle';
|
||||||
import AnnouncementBanner from './AnnouncementBanner';
|
import AnnouncementBanner from './AnnouncementBanner';
|
||||||
import { DevTools } from './dev/DevTools';
|
import { DevTools } from './dev/DevTools';
|
||||||
|
import { Breadcrumbs } from './ui/Breadcrumbs';
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
@ -136,6 +137,8 @@ export default function Layout() {
|
||||||
<PageTitleUpdater />
|
<PageTitleUpdater />
|
||||||
<AnnouncementBanner />
|
<AnnouncementBanner />
|
||||||
<div className="p-4 md:p-6 lg:p-8">
|
<div className="p-4 md:p-6 lg:p-8">
|
||||||
|
{/* Global Breadcrumbs - appears on all pages */}
|
||||||
|
<Breadcrumbs />
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -33,14 +33,34 @@ const ROUTE_CONFIG: Record<string, { label: string; parent?: string }> = {
|
||||||
'/settings/walkthrough': { label: 'Walkthrough Settings', parent: '/settings' },
|
'/settings/walkthrough': { label: 'Walkthrough Settings', parent: '/settings' },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Dynamic route patterns
|
||||||
|
const DYNAMIC_ROUTES: { pattern: RegExp; getLabel: (match: RegExpMatchArray) => string; parent: string }[] = [
|
||||||
|
{ pattern: /^\/batches\/(.+)$/, getLabel: () => 'Batch Details', parent: '/batches' },
|
||||||
|
{ pattern: /^\/rooms\/(.+)$/, getLabel: () => 'Room Details', parent: '/rooms' },
|
||||||
|
{ pattern: /^\/tasks\/(.+)$/, getLabel: () => 'Task Details', parent: '/tasks' },
|
||||||
|
];
|
||||||
|
|
||||||
function getBreadcrumbs(pathname: string): BreadcrumbItem[] {
|
function getBreadcrumbs(pathname: string): BreadcrumbItem[] {
|
||||||
const crumbs: BreadcrumbItem[] = [];
|
const crumbs: BreadcrumbItem[] = [];
|
||||||
let currentPath = pathname;
|
let currentPath = pathname;
|
||||||
|
|
||||||
// Build breadcrumb chain
|
// Check for dynamic routes first
|
||||||
|
for (const route of DYNAMIC_ROUTES) {
|
||||||
|
const match = pathname.match(route.pattern);
|
||||||
|
if (match) {
|
||||||
|
crumbs.unshift({ label: route.getLabel(match), path: pathname });
|
||||||
|
currentPath = route.parent;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build breadcrumb chain from static routes
|
||||||
while (currentPath && ROUTE_CONFIG[currentPath]) {
|
while (currentPath && ROUTE_CONFIG[currentPath]) {
|
||||||
const config = ROUTE_CONFIG[currentPath];
|
const config = ROUTE_CONFIG[currentPath];
|
||||||
crumbs.unshift({ label: config.label, path: currentPath });
|
// Don't add if already in crumbs (from dynamic route)
|
||||||
|
if (!crumbs.find(c => c.path === currentPath)) {
|
||||||
|
crumbs.unshift({ label: config.label, path: currentPath });
|
||||||
|
}
|
||||||
currentPath = config.parent || '';
|
currentPath = config.parent || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -58,11 +78,11 @@ export function Breadcrumbs() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav aria-label="Breadcrumb" className="mb-4">
|
<nav aria-label="Breadcrumb" className="mb-4">
|
||||||
<ol className="flex items-center gap-1 text-sm">
|
<ol className="flex items-center gap-1 text-sm flex-wrap">
|
||||||
<li>
|
<li>
|
||||||
<Link
|
<Link
|
||||||
to="/"
|
to="/"
|
||||||
className="p-1 rounded hover:bg-slate-100 dark:hover:bg-slate-800 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 transition-colors"
|
className="p-1.5 rounded-md hover:bg-tertiary text-tertiary hover:text-primary transition-colors flex items-center"
|
||||||
aria-label="Home"
|
aria-label="Home"
|
||||||
>
|
>
|
||||||
<Home size={16} />
|
<Home size={16} />
|
||||||
|
|
@ -77,15 +97,15 @@ export function Breadcrumbs() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li key={crumb.path} className="flex items-center gap-1">
|
<li key={crumb.path} className="flex items-center gap-1">
|
||||||
<ChevronRight size={14} className="text-slate-300 dark:text-slate-600" />
|
<ChevronRight size={14} className="text-tertiary flex-shrink-0" />
|
||||||
{isLast ? (
|
{isLast ? (
|
||||||
<span className="px-2 py-1 font-medium text-slate-900 dark:text-white">
|
<span className="px-2 py-1 font-medium text-primary">
|
||||||
{crumb.label}
|
{crumb.label}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<Link
|
<Link
|
||||||
to={crumb.path}
|
to={crumb.path}
|
||||||
className="px-2 py-1 rounded text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
|
className="px-2 py-1 rounded-md text-tertiary hover:text-primary hover:bg-tertiary transition-colors"
|
||||||
>
|
>
|
||||||
{crumb.label}
|
{crumb.label}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -100,9 +120,18 @@ export function Breadcrumbs() {
|
||||||
|
|
||||||
// Page title helper
|
// Page title helper
|
||||||
export function getPageTitle(pathname: string): string {
|
export function getPageTitle(pathname: string): string {
|
||||||
|
// Check dynamic routes first
|
||||||
|
for (const route of DYNAMIC_ROUTES) {
|
||||||
|
const match = pathname.match(route.pattern);
|
||||||
|
if (match) {
|
||||||
|
return `${route.getLabel(match)} | 777 Wolfpack`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const config = ROUTE_CONFIG[pathname];
|
const config = ROUTE_CONFIG[pathname];
|
||||||
if (config) {
|
if (config) {
|
||||||
return `${config.label} | 777 Wolfpack`;
|
return `${config.label} | 777 Wolfpack`;
|
||||||
}
|
}
|
||||||
return '777 Wolfpack - Grow Ops Manager';
|
return '777 Wolfpack - Grow Ops Manager';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
import { settingsApi, WalkthroughSettings } from '../lib/settingsApi';
|
import { settingsApi, WalkthroughSettings } from '../lib/settingsApi';
|
||||||
import { walkthroughApi, ReservoirCheckData, IrrigationCheckData, PlantHealthCheckData } from '../lib/walkthroughApi';
|
import { walkthroughApi, ReservoirCheckData, IrrigationCheckData, PlantHealthCheckData } from '../lib/walkthroughApi';
|
||||||
import {
|
import {
|
||||||
ArrowLeft, Check, Loader2, AlertCircle, Droplets, Sprout, Bug,
|
Check, Loader2, Droplets, Sprout, Bug,
|
||||||
Camera, X, Minus, Plus, ChevronDown, ChevronUp
|
Camera, X, Minus, Plus, ChevronDown, ChevronUp, Upload
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useToast } from '../context/ToastContext';
|
import { useToast } from '../context/ToastContext';
|
||||||
|
|
||||||
|
|
@ -92,12 +92,16 @@ export default function DailyWalkthroughPage() {
|
||||||
const totalChecks = Object.keys(reservoirChecks).length + Object.keys(irrigationChecks).length + Object.keys(plantHealthChecks).length;
|
const totalChecks = Object.keys(reservoirChecks).length + Object.keys(irrigationChecks).length + Object.keys(plantHealthChecks).length;
|
||||||
const requiredChecks = TANKS.length + ZONES.length + HEALTH_ZONES.length;
|
const requiredChecks = TANKS.length + ZONES.length + HEALTH_ZONES.length;
|
||||||
const isComplete = totalChecks === requiredChecks;
|
const isComplete = totalChecks === requiredChecks;
|
||||||
|
const progressPercent = Math.round((totalChecks / requiredChecks) * 100);
|
||||||
|
|
||||||
// Pre-start view
|
// Pre-start view
|
||||||
if (!walkthroughId) {
|
if (!walkthroughId) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-primary flex items-center justify-center p-4">
|
<div className="min-h-screen bg-primary flex items-center justify-center p-4">
|
||||||
<div className="w-full max-w-sm text-center">
|
<div className="w-full max-w-sm text-center">
|
||||||
|
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-accent-muted flex items-center justify-center">
|
||||||
|
<Sprout size={36} className="text-accent" />
|
||||||
|
</div>
|
||||||
<h1 className="text-xl font-semibold text-primary mb-2">Daily Walkthrough</h1>
|
<h1 className="text-xl font-semibold text-primary mb-2">Daily Walkthrough</h1>
|
||||||
<p className="text-sm text-tertiary mb-6">
|
<p className="text-sm text-tertiary mb-6">
|
||||||
{new Date().toLocaleDateString('en-US', { weekday: 'long', month: 'short', day: 'numeric' })}
|
{new Date().toLocaleDateString('en-US', { weekday: 'long', month: 'short', day: 'numeric' })}
|
||||||
|
|
@ -105,109 +109,128 @@ export default function DailyWalkthroughPage() {
|
||||||
<button
|
<button
|
||||||
onClick={handleStart}
|
onClick={handleStart}
|
||||||
disabled={isStarting}
|
disabled={isStarting}
|
||||||
className="btn btn-primary w-full"
|
className="btn btn-primary w-full text-base py-3"
|
||||||
>
|
>
|
||||||
{isStarting ? <Loader2 size={14} className="animate-spin" /> : 'Begin Walkthrough'}
|
{isStarting ? <Loader2 size={18} className="animate-spin" /> : 'Begin Walkthrough'}
|
||||||
</button>
|
</button>
|
||||||
|
<Link to="/" className="block mt-4 text-sm text-tertiary hover:text-secondary">
|
||||||
|
← Back to Dashboard
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl mx-auto pb-24 space-y-4 animate-in">
|
<div className="max-w-2xl mx-auto pb-28 animate-in">
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center gap-3 mb-6">
|
{/* Header with Progress */}
|
||||||
<button onClick={() => navigate('/')} className="p-2 -ml-2 hover:bg-tertiary rounded-md">
|
<div className="card p-4 mb-4">
|
||||||
<ArrowLeft size={16} className="text-secondary" />
|
<div className="flex items-center justify-between mb-3">
|
||||||
</button>
|
<div>
|
||||||
<div>
|
<h1 className="text-lg font-semibold text-primary">Daily Walkthrough</h1>
|
||||||
<h1 className="text-lg font-semibold text-primary">Daily Walkthrough</h1>
|
<p className="text-xs text-tertiary">
|
||||||
<p className="text-xs text-tertiary">{totalChecks}/{requiredChecks} checks completed</p>
|
{new Date().toLocaleDateString('en-US', { weekday: 'long', month: 'short', day: 'numeric' })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<span className="text-2xl font-bold text-accent">{progressPercent}%</span>
|
||||||
|
<p className="text-xs text-tertiary">{totalChecks}/{requiredChecks} checks</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div className="h-2 bg-subtle rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-accent transition-all duration-500 ease-out"
|
||||||
|
style={{ width: `${progressPercent}%` }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Reservoirs Section */}
|
{/* Sections */}
|
||||||
{settings?.enableReservoirs !== false && (
|
<div className="space-y-3">
|
||||||
<CollapsibleSection
|
{/* Reservoirs Section */}
|
||||||
title="Reservoirs"
|
{settings?.enableReservoirs !== false && (
|
||||||
icon={Droplets}
|
<CollapsibleSection
|
||||||
count={Object.keys(reservoirChecks).length}
|
title="Reservoirs"
|
||||||
total={TANKS.length}
|
icon={Droplets}
|
||||||
expanded={expandedSections.reservoirs}
|
count={Object.keys(reservoirChecks).length}
|
||||||
onToggle={() => toggleSection('reservoirs')}
|
total={TANKS.length}
|
||||||
>
|
expanded={expandedSections.reservoirs}
|
||||||
<div className="space-y-2">
|
onToggle={() => toggleSection('reservoirs')}
|
||||||
{TANKS.map(tank => (
|
>
|
||||||
<ReservoirRow
|
<div className="space-y-2">
|
||||||
key={tank.name}
|
{TANKS.map(tank => (
|
||||||
tank={tank}
|
<ReservoirRow
|
||||||
data={reservoirChecks[tank.name]}
|
key={tank.name}
|
||||||
onChange={(data) => setReservoirChecks(prev => ({ ...prev, [tank.name]: data }))}
|
tank={tank}
|
||||||
/>
|
data={reservoirChecks[tank.name]}
|
||||||
))}
|
onChange={(data) => setReservoirChecks(prev => ({ ...prev, [tank.name]: data }))}
|
||||||
</div>
|
/>
|
||||||
</CollapsibleSection>
|
))}
|
||||||
)}
|
</div>
|
||||||
|
</CollapsibleSection>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Irrigation Section */}
|
{/* Irrigation Section */}
|
||||||
{settings?.enableIrrigation !== false && (
|
{settings?.enableIrrigation !== false && (
|
||||||
<CollapsibleSection
|
<CollapsibleSection
|
||||||
title="Irrigation"
|
title="Irrigation"
|
||||||
icon={Sprout}
|
icon={Sprout}
|
||||||
count={Object.keys(irrigationChecks).length}
|
count={Object.keys(irrigationChecks).length}
|
||||||
total={ZONES.length}
|
total={ZONES.length}
|
||||||
expanded={expandedSections.irrigation}
|
expanded={expandedSections.irrigation}
|
||||||
onToggle={() => toggleSection('irrigation')}
|
onToggle={() => toggleSection('irrigation')}
|
||||||
>
|
>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{ZONES.map(zone => (
|
{ZONES.map(zone => (
|
||||||
<IrrigationRow
|
<IrrigationRow
|
||||||
key={zone.name}
|
key={zone.name}
|
||||||
zone={zone}
|
zone={zone}
|
||||||
data={irrigationChecks[zone.name]}
|
data={irrigationChecks[zone.name]}
|
||||||
onChange={(data) => setIrrigationChecks(prev => ({ ...prev, [zone.name]: data }))}
|
onChange={(data) => setIrrigationChecks(prev => ({ ...prev, [zone.name]: data }))}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Plant Health Section */}
|
{/* Plant Health Section */}
|
||||||
{settings?.enablePlantHealth !== false && (
|
{settings?.enablePlantHealth !== false && (
|
||||||
<CollapsibleSection
|
<CollapsibleSection
|
||||||
title="Plant Health"
|
title="Plant Health"
|
||||||
icon={Bug}
|
icon={Bug}
|
||||||
count={Object.keys(plantHealthChecks).length}
|
count={Object.keys(plantHealthChecks).length}
|
||||||
total={HEALTH_ZONES.length}
|
total={HEALTH_ZONES.length}
|
||||||
expanded={expandedSections.plantHealth}
|
expanded={expandedSections.plantHealth}
|
||||||
onToggle={() => toggleSection('plantHealth')}
|
onToggle={() => toggleSection('plantHealth')}
|
||||||
>
|
>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{HEALTH_ZONES.map(zone => (
|
{HEALTH_ZONES.map(zone => (
|
||||||
<PlantHealthRow
|
<PlantHealthRow
|
||||||
key={zone}
|
key={zone}
|
||||||
zoneName={zone}
|
zoneName={zone}
|
||||||
data={plantHealthChecks[zone]}
|
data={plantHealthChecks[zone]}
|
||||||
onChange={(data) => setPlantHealthChecks(prev => ({ ...prev, [zone]: data }))}
|
onChange={(data) => setPlantHealthChecks(prev => ({ ...prev, [zone]: data }))}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Fixed Submit Button */}
|
{/* Fixed Submit Button */}
|
||||||
<div className="fixed bottom-0 left-0 right-0 p-4 bg-primary border-t border-default">
|
<div className="fixed bottom-0 left-0 right-0 p-4 bg-primary/95 backdrop-blur border-t border-subtle">
|
||||||
<div className="max-w-2xl mx-auto">
|
<div className="max-w-2xl mx-auto">
|
||||||
<button
|
<button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={!isComplete || isSubmitting}
|
disabled={!isComplete || isSubmitting}
|
||||||
className="btn btn-primary w-full"
|
className={`btn w-full py-3 text-base ${isComplete ? 'btn-primary' : 'bg-subtle text-tertiary cursor-not-allowed'}`}
|
||||||
>
|
>
|
||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
<><Loader2 size={14} className="animate-spin" /> Submitting...</>
|
<><Loader2 size={18} className="animate-spin" /> Submitting...</>
|
||||||
) : isComplete ? (
|
) : isComplete ? (
|
||||||
<><Check size={14} /> Submit Walkthrough</>
|
<><Check size={18} /> Submit Walkthrough</>
|
||||||
) : (
|
) : (
|
||||||
`Complete all checks (${totalChecks}/${requiredChecks})`
|
`Complete all checks (${totalChecks}/${requiredChecks})`
|
||||||
)}
|
)}
|
||||||
|
|
@ -238,23 +261,86 @@ function CollapsibleSection({
|
||||||
className="w-full flex items-center justify-between p-4 hover:bg-tertiary transition-colors"
|
className="w-full flex items-center justify-between p-4 hover:bg-tertiary transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className={`w-8 h-8 rounded-md flex items-center justify-center ${isComplete ? 'bg-success-muted text-success' : 'bg-accent-muted text-accent'
|
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${isComplete ? 'bg-success-muted text-success' : 'bg-accent-muted text-accent'
|
||||||
}`}>
|
}`}>
|
||||||
{isComplete ? <Check size={14} /> : <Icon size={14} />}
|
{isComplete ? <Check size={18} /> : <Icon size={18} />}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<h3 className="text-sm font-medium text-primary">{title}</h3>
|
<h3 className="text-sm font-medium text-primary">{title}</h3>
|
||||||
<p className="text-xs text-tertiary">{count}/{total} complete</p>
|
<p className="text-xs text-tertiary">{count}/{total} complete</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{expanded ? <ChevronUp size={16} className="text-tertiary" /> : <ChevronDown size={16} className="text-tertiary" />}
|
<div className="flex items-center gap-2">
|
||||||
|
{isComplete && <span className="text-xs text-success font-medium">✓ Done</span>}
|
||||||
|
{expanded ? <ChevronUp size={18} className="text-tertiary" /> : <ChevronDown size={18} className="text-tertiary" />}
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
{expanded && <div className="p-4 pt-0 border-t border-subtle">{children}</div>}
|
{expanded && <div className="p-4 pt-0 border-t border-subtle">{children}</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reservoir Row - Compact inline
|
// Inline Photo Capture Component
|
||||||
|
function PhotoCapture({
|
||||||
|
onCapture,
|
||||||
|
photoUrl
|
||||||
|
}: {
|
||||||
|
onCapture: (url: string | null) => void;
|
||||||
|
photoUrl?: string | null;
|
||||||
|
}) {
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const cameraInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
// For now, create a local preview URL
|
||||||
|
// In production, this would upload to server
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
onCapture(url);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (photoUrl) {
|
||||||
|
return (
|
||||||
|
<div className="relative mt-2">
|
||||||
|
<img src={photoUrl} alt="Captured" className="w-full h-24 object-cover rounded-lg" />
|
||||||
|
<button
|
||||||
|
onClick={() => onCapture(null)}
|
||||||
|
className="absolute top-1 right-1 w-6 h-6 bg-destructive text-white rounded-full flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2 mt-2">
|
||||||
|
<input ref={fileInputRef} type="file" accept="image/*" onChange={handleFile} className="hidden" />
|
||||||
|
<input ref={cameraInputRef} type="file" accept="image/*" capture="environment" onChange={handleFile} className="hidden" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => cameraInputRef.current?.click()}
|
||||||
|
className="flex-1 flex items-center justify-center gap-1.5 py-2 bg-accent text-white rounded-lg text-xs font-medium hover:bg-accent/90 transition-colors"
|
||||||
|
>
|
||||||
|
<Camera size={14} />
|
||||||
|
Camera
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="flex-1 flex items-center justify-center gap-1.5 py-2 bg-subtle text-secondary rounded-lg text-xs font-medium hover:bg-secondary transition-colors"
|
||||||
|
>
|
||||||
|
<Upload size={14} />
|
||||||
|
Upload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reservoir Row - Enhanced with photo
|
||||||
function ReservoirRow({
|
function ReservoirRow({
|
||||||
tank, data, onChange
|
tank, data, onChange
|
||||||
}: {
|
}: {
|
||||||
|
|
@ -263,6 +349,8 @@ function ReservoirRow({
|
||||||
onChange: (data: ReservoirCheckData) => void;
|
onChange: (data: ReservoirCheckData) => void;
|
||||||
}) {
|
}) {
|
||||||
const [level, setLevel] = useState(data?.levelPercent ?? 100);
|
const [level, setLevel] = useState(data?.levelPercent ?? 100);
|
||||||
|
const [photo, setPhoto] = useState<string | null>(data?.photoUrl || null);
|
||||||
|
const [notes, setNotes] = useState(data?.notes || '');
|
||||||
const [editing, setEditing] = useState(!data);
|
const [editing, setEditing] = useState(!data);
|
||||||
|
|
||||||
const getStatus = (l: number) => l >= 70 ? 'OK' : l >= 30 ? 'LOW' : 'CRITICAL';
|
const getStatus = (l: number) => l >= 70 ? 'OK' : l >= 30 ? 'LOW' : 'CRITICAL';
|
||||||
|
|
@ -273,56 +361,84 @@ function ReservoirRow({
|
||||||
tankType: tank.type,
|
tankType: tank.type,
|
||||||
levelPercent: level,
|
levelPercent: level,
|
||||||
status: getStatus(level),
|
status: getStatus(level),
|
||||||
|
photoUrl: photo || undefined,
|
||||||
|
notes: notes || undefined,
|
||||||
});
|
});
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!editing && data) {
|
if (!editing && data) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between p-3 bg-tertiary rounded-md">
|
<div className="flex items-center justify-between p-3 bg-tertiary rounded-lg">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className={`w-2 h-8 rounded-full ${data.status === 'OK' ? 'bg-success' : data.status === 'LOW' ? 'bg-warning' : 'bg-destructive'
|
<div className={`w-2 h-10 rounded-full ${data.status === 'OK' ? 'bg-success' : data.status === 'LOW' ? 'bg-warning' : 'bg-destructive'
|
||||||
}`} />
|
}`} />
|
||||||
<div>
|
<div>
|
||||||
<span className="text-sm text-primary">{tank.name}</span>
|
<span className="text-sm font-medium text-primary">{tank.name}</span>
|
||||||
<span className="text-xs text-tertiary ml-2">{data.levelPercent}%</span>
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-tertiary">{data.levelPercent}%</span>
|
||||||
|
{data.photoUrl && <Camera size={10} className="text-accent" />}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setEditing(true)} className="text-xs text-accent">Edit</button>
|
<button onClick={() => setEditing(true)} className="text-xs text-accent font-medium">Edit</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-3 bg-tertiary rounded-md">
|
<div className="p-4 bg-tertiary rounded-lg space-y-3">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm font-medium text-primary">{tank.name}</span>
|
<span className="text-sm font-medium text-primary">{tank.name}</span>
|
||||||
<span className="text-xs text-tertiary">{tank.type}</span>
|
<span className={`text-xs px-2 py-0.5 rounded ${tank.type === 'VEG' ? 'bg-success-muted text-success' : 'bg-accent-muted text-accent'}`}>
|
||||||
|
{tank.type}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Level Slider */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className={`w-3 h-12 rounded-full overflow-hidden bg-subtle relative`}>
|
<div className={`w-4 h-14 rounded-full overflow-hidden bg-subtle relative`}>
|
||||||
<div
|
<div
|
||||||
className={`absolute bottom-0 left-0 right-0 transition-all ${getStatus(level) === 'OK' ? 'bg-success' : getStatus(level) === 'LOW' ? 'bg-warning' : 'bg-destructive'
|
className={`absolute bottom-0 left-0 right-0 transition-all ${getStatus(level) === 'OK' ? 'bg-success' : getStatus(level) === 'LOW' ? 'bg-warning' : 'bg-destructive'}`}
|
||||||
}`}
|
|
||||||
style={{ height: `${level}%` }}
|
style={{ height: `${level}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<div className="flex-1">
|
||||||
type="range"
|
<input
|
||||||
min="0"
|
type="range"
|
||||||
max="100"
|
min="0"
|
||||||
value={level}
|
max="100"
|
||||||
onChange={(e) => setLevel(parseInt(e.target.value))}
|
value={level}
|
||||||
className="flex-1 h-1.5 bg-subtle rounded-full appearance-none cursor-pointer accent-accent"
|
onChange={(e) => setLevel(parseInt(e.target.value))}
|
||||||
/>
|
className="w-full h-2 bg-subtle rounded-full appearance-none cursor-pointer"
|
||||||
<span className="text-sm font-medium text-primary w-12 text-right">{level}%</span>
|
/>
|
||||||
<button onClick={handleSave} className="btn btn-primary h-8 px-3 text-xs">Save</button>
|
</div>
|
||||||
|
<span className={`text-lg font-bold w-14 text-right ${getStatus(level) === 'OK' ? 'text-success' : getStatus(level) === 'LOW' ? 'text-warning' : 'text-destructive'}`}>
|
||||||
|
{level}%
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
placeholder="Notes (optional)"
|
||||||
|
className="w-full px-3 py-2 bg-primary border border-subtle rounded-lg text-sm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Photo Capture */}
|
||||||
|
<PhotoCapture photoUrl={photo} onCapture={setPhoto} />
|
||||||
|
|
||||||
|
{/* Save Button */}
|
||||||
|
<button onClick={handleSave} className="btn btn-primary w-full">
|
||||||
|
Save Check
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Irrigation Row - Compact inline
|
// Irrigation Row - Enhanced with photo
|
||||||
function IrrigationRow({
|
function IrrigationRow({
|
||||||
zone, data, onChange
|
zone, data, onChange
|
||||||
}: {
|
}: {
|
||||||
|
|
@ -333,6 +449,7 @@ function IrrigationRow({
|
||||||
const [working, setWorking] = useState(data?.drippersWorking ?? zone.drippers);
|
const [working, setWorking] = useState(data?.drippersWorking ?? zone.drippers);
|
||||||
const [waterFlow, setWaterFlow] = useState(data?.waterFlow ?? true);
|
const [waterFlow, setWaterFlow] = useState(data?.waterFlow ?? true);
|
||||||
const [nutrients, setNutrients] = useState(data?.nutrientsMixed ?? true);
|
const [nutrients, setNutrients] = useState(data?.nutrientsMixed ?? true);
|
||||||
|
const [photo, setPhoto] = useState<string | null>(null);
|
||||||
const [editing, setEditing] = useState(!data);
|
const [editing, setEditing] = useState(!data);
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
|
|
@ -344,6 +461,7 @@ function IrrigationRow({
|
||||||
waterFlow,
|
waterFlow,
|
||||||
nutrientsMixed: nutrients,
|
nutrientsMixed: nutrients,
|
||||||
scheduleActive: true,
|
scheduleActive: true,
|
||||||
|
photoUrl: photo || undefined,
|
||||||
});
|
});
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
};
|
};
|
||||||
|
|
@ -351,50 +469,82 @@ function IrrigationRow({
|
||||||
if (!editing && data) {
|
if (!editing && data) {
|
||||||
const issues = !data.waterFlow || !data.nutrientsMixed || data.drippersWorking < data.drippersTotal;
|
const issues = !data.waterFlow || !data.nutrientsMixed || data.drippersWorking < data.drippersTotal;
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between p-3 bg-tertiary rounded-md">
|
<div className="flex items-center justify-between p-3 bg-tertiary rounded-lg">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className={`w-2 h-8 rounded-full ${issues ? 'bg-warning' : 'bg-success'}`} />
|
<div className={`w-2 h-10 rounded-full ${issues ? 'bg-warning' : 'bg-success'}`} />
|
||||||
<div>
|
<div>
|
||||||
<span className="text-sm text-primary">{zone.name}</span>
|
<span className="text-sm font-medium text-primary">{zone.name}</span>
|
||||||
<span className="text-xs text-tertiary ml-2">{data.drippersWorking}/{data.drippersTotal}</span>
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-tertiary">{data.drippersWorking}/{data.drippersTotal} drippers</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setEditing(true)} className="text-xs text-accent">Edit</button>
|
<button onClick={() => setEditing(true)} className="text-xs text-accent font-medium">Edit</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-3 bg-tertiary rounded-md space-y-3">
|
<div className="p-4 bg-tertiary rounded-lg space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm font-medium text-primary">{zone.name}</span>
|
<span className="text-sm font-medium text-primary">{zone.name}</span>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button onClick={() => setWorking(Math.max(0, working - 1))} className="w-6 h-6 bg-subtle rounded flex items-center justify-center">
|
|
||||||
<Minus size={12} />
|
|
||||||
</button>
|
|
||||||
<span className="text-sm w-16 text-center">{working}/{zone.drippers}</span>
|
|
||||||
<button onClick={() => setWorking(Math.min(zone.drippers, working + 1))} className="w-6 h-6 bg-subtle rounded flex items-center justify-center">
|
|
||||||
<Plus size={12} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<label className="flex items-center gap-2 text-xs">
|
{/* Dripper Count */}
|
||||||
<input type="checkbox" checked={waterFlow} onChange={() => setWaterFlow(!waterFlow)} className="w-4 h-4 rounded" />
|
<div className="flex items-center justify-center gap-4">
|
||||||
Water
|
<button
|
||||||
|
onClick={() => setWorking(Math.max(0, working - 1))}
|
||||||
|
className="w-10 h-10 bg-subtle rounded-lg flex items-center justify-center hover:bg-secondary transition-colors"
|
||||||
|
>
|
||||||
|
<Minus size={16} />
|
||||||
|
</button>
|
||||||
|
<div className="text-center">
|
||||||
|
<span className="text-2xl font-bold text-primary">{working}</span>
|
||||||
|
<span className="text-sm text-tertiary">/{zone.drippers}</span>
|
||||||
|
<p className="text-xs text-tertiary">drippers working</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setWorking(Math.min(zone.drippers, working + 1))}
|
||||||
|
className="w-10 h-10 bg-subtle rounded-lg flex items-center justify-center hover:bg-secondary transition-colors"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Checkboxes */}
|
||||||
|
<div className="flex items-center justify-center gap-6">
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={waterFlow}
|
||||||
|
onChange={() => setWaterFlow(!waterFlow)}
|
||||||
|
className="w-5 h-5 rounded accent-accent"
|
||||||
|
/>
|
||||||
|
Water Flow
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-2 text-xs">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<input type="checkbox" checked={nutrients} onChange={() => setNutrients(!nutrients)} className="w-4 h-4 rounded" />
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={nutrients}
|
||||||
|
onChange={() => setNutrients(!nutrients)}
|
||||||
|
className="w-5 h-5 rounded accent-accent"
|
||||||
|
/>
|
||||||
Nutrients
|
Nutrients
|
||||||
</label>
|
</label>
|
||||||
<div className="flex-1" />
|
|
||||||
<button onClick={handleSave} className="btn btn-primary h-8 px-3 text-xs">Save</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Photo Capture */}
|
||||||
|
<PhotoCapture photoUrl={photo} onCapture={setPhoto} />
|
||||||
|
|
||||||
|
{/* Save Button */}
|
||||||
|
<button onClick={handleSave} className="btn btn-primary w-full">
|
||||||
|
Save Check
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plant Health Row - Compact inline
|
// Plant Health Row - Enhanced with photo
|
||||||
function PlantHealthRow({
|
function PlantHealthRow({
|
||||||
zoneName, data, onChange
|
zoneName, data, onChange
|
||||||
}: {
|
}: {
|
||||||
|
|
@ -404,6 +554,8 @@ function PlantHealthRow({
|
||||||
}) {
|
}) {
|
||||||
const [health, setHealth] = useState<'GOOD' | 'FAIR' | 'NEEDS_ATTENTION'>(data?.healthStatus ?? 'GOOD');
|
const [health, setHealth] = useState<'GOOD' | 'FAIR' | 'NEEDS_ATTENTION'>(data?.healthStatus ?? 'GOOD');
|
||||||
const [pests, setPests] = useState(data?.pestsObserved ?? false);
|
const [pests, setPests] = useState(data?.pestsObserved ?? false);
|
||||||
|
const [photo, setPhoto] = useState<string | null>(null);
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
const [editing, setEditing] = useState(!data);
|
const [editing, setEditing] = useState(!data);
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
|
|
@ -414,57 +566,93 @@ function PlantHealthRow({
|
||||||
waterAccess: 'OK',
|
waterAccess: 'OK',
|
||||||
foodAccess: 'OK',
|
foodAccess: 'OK',
|
||||||
flaggedForAttention: health !== 'GOOD' || pests,
|
flaggedForAttention: health !== 'GOOD' || pests,
|
||||||
|
issuePhotoUrl: photo || undefined,
|
||||||
|
notes: notes || undefined,
|
||||||
});
|
});
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!editing && data) {
|
if (!editing && data) {
|
||||||
const issues = data.healthStatus !== 'GOOD' || data.pestsObserved;
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between p-3 bg-tertiary rounded-md">
|
<div className="flex items-center justify-between p-3 bg-tertiary rounded-lg">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className={`w-2 h-8 rounded-full ${data.healthStatus === 'GOOD' && !data.pestsObserved ? 'bg-success' :
|
<div className={`w-2 h-10 rounded-full ${data.healthStatus === 'GOOD' && !data.pestsObserved ? 'bg-success' :
|
||||||
data.healthStatus === 'FAIR' ? 'bg-warning' : 'bg-destructive'
|
data.healthStatus === 'FAIR' ? 'bg-warning' : 'bg-destructive'
|
||||||
}`} />
|
}`} />
|
||||||
<div>
|
<div>
|
||||||
<span className="text-sm text-primary">{zoneName}</span>
|
<span className="text-sm font-medium text-primary">{zoneName}</span>
|
||||||
<span className="text-xs text-tertiary ml-2">{data.healthStatus}</span>
|
<div className="flex items-center gap-2">
|
||||||
{data.pestsObserved && <span className="text-xs text-destructive ml-2">🐛 Pests</span>}
|
<span className={`text-xs ${data.healthStatus === 'GOOD' ? 'text-success' : data.healthStatus === 'FAIR' ? 'text-warning' : 'text-destructive'}`}>
|
||||||
|
{data.healthStatus}
|
||||||
|
</span>
|
||||||
|
{data.pestsObserved && <span className="text-xs text-destructive">🐛</span>}
|
||||||
|
{data.issuePhotoUrl && <Camera size={10} className="text-accent" />}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setEditing(true)} className="text-xs text-accent">Edit</button>
|
<button onClick={() => setEditing(true)} className="text-xs text-accent font-medium">Edit</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-3 bg-tertiary rounded-md space-y-3">
|
<div className="p-4 bg-tertiary rounded-lg space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm font-medium text-primary">{zoneName}</span>
|
<span className="text-sm font-medium text-primary">{zoneName}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
|
{/* Health Status Buttons */}
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
{(['GOOD', 'FAIR', 'NEEDS_ATTENTION'] as const).map(s => (
|
{(['GOOD', 'FAIR', 'NEEDS_ATTENTION'] as const).map(s => (
|
||||||
<button
|
<button
|
||||||
key={s}
|
key={s}
|
||||||
onClick={() => setHealth(s)}
|
onClick={() => setHealth(s)}
|
||||||
className={`flex-1 py-1.5 rounded text-xs font-medium transition-colors ${health === s
|
className={`py-3 rounded-lg text-sm font-medium transition-all ${health === s
|
||||||
? s === 'GOOD' ? 'bg-success text-white'
|
? s === 'GOOD' ? 'bg-success text-white shadow-lg scale-105'
|
||||||
: s === 'FAIR' ? 'bg-warning text-white'
|
: s === 'FAIR' ? 'bg-warning text-white shadow-lg scale-105'
|
||||||
: 'bg-destructive text-white'
|
: 'bg-destructive text-white shadow-lg scale-105'
|
||||||
: 'bg-subtle text-secondary hover:bg-secondary'
|
: 'bg-subtle text-secondary hover:bg-secondary'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{s === 'NEEDS_ATTENTION' ? 'Attention' : s}
|
{s === 'GOOD' ? '✓ Good' : s === 'FAIR' ? '⚠️ Fair' : '❌ Attention'}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<label className="flex items-center gap-2 text-xs">
|
{/* Pests Checkbox */}
|
||||||
<input type="checkbox" checked={pests} onChange={() => setPests(!pests)} className="w-4 h-4 rounded" />
|
<label className="flex items-center justify-center gap-2 p-3 bg-destructive-muted rounded-lg cursor-pointer">
|
||||||
Pests observed
|
<input
|
||||||
</label>
|
type="checkbox"
|
||||||
<button onClick={handleSave} className="btn btn-primary h-8 px-3 text-xs">Save</button>
|
checked={pests}
|
||||||
</div>
|
onChange={() => setPests(!pests)}
|
||||||
|
className="w-5 h-5 rounded accent-destructive"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-destructive font-medium">🐛 Pests Observed</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
{(health !== 'GOOD' || pests) && (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
placeholder="Describe the issue..."
|
||||||
|
className="w-full px-3 py-2 bg-primary border border-subtle rounded-lg text-sm"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Photo Capture - shown for issues */}
|
||||||
|
{(health !== 'GOOD' || pests) && (
|
||||||
|
<>
|
||||||
|
<p className="text-xs text-tertiary text-center">📷 Photo recommended for issues</p>
|
||||||
|
<PhotoCapture photoUrl={photo} onCapture={setPhoto} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Save Button */}
|
||||||
|
<button onClick={handleSave} className="btn btn-primary w-full">
|
||||||
|
Save Check
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue