diff --git a/backend/main.py b/backend/main.py
index 000c432..97eb0b2 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -118,13 +118,16 @@ def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security))
resnet_model.fc = nn.Linear(resnet_model.fc.in_features, NUM_CLASSES)
MODEL_PATH = os.path.join(BASE_DIR, "model", "plant_disease_resnet18.pth")
-if os.path.exists(MODEL_PATH):
- resnet_model.load_state_dict(torch.load(MODEL_PATH, map_location=device))
- resnet_model = resnet_model.to(device)
- resnet_model.eval()
- print(f"[OK] ResNet18 loaded - {NUM_CLASSES} classes on {device}")
+if os.path.exists(MODEL_PATH) and os.path.getsize(MODEL_PATH) > 0:
+ try:
+ resnet_model.load_state_dict(torch.load(MODEL_PATH, map_location=device))
+ resnet_model = resnet_model.to(device)
+ resnet_model.eval()
+ print(f"[OK] ResNet18 loaded -- {NUM_CLASSES} classes on {device}")
+ except Exception as e:
+ print(f"[WARN] Failed to load model weights: {e}. Inference will fail.")
else:
- print(f"[WARN] Model weights not found at {MODEL_PATH}. Inference will fail.")
+ print(f"[WARN] Model weights not found or empty at {MODEL_PATH}. Inference will fail.")
# CLIP model (OOD guard) — lazy load to keep startup fast
clip_model = None
@@ -220,12 +223,23 @@ class YieldCreate(BaseModel):
quality_notes: Optional[str] = None
class FieldCreate(BaseModel):
+ name: str
+ area_acres: float
+ polygon: List[Dict[str, float]]
+ center_latitude: Optional[float] = None
+ center_longitude: Optional[float] = None
+
+
+class FieldResponse(BaseModel):
+ id: str
+ farm_id: str
name: str
area_acres: float
area_hectares: Optional[float] = None
polygon: List[Dict[str, float]]
center_latitude: Optional[float] = None
center_longitude: Optional[float] = None
+ created_at: Optional[str] = None
class SoilEstimationRequest(BaseModel):
farm_id: str
@@ -642,51 +656,87 @@ async def delete_farm(farm_id: str, user=Depends(verify_token)):
# ── Farm Fields (Polygon) ─────────────────────────────────────────────────────
@app.get("/api/farms/{farm_id}/fields")
-async def get_fields(farm_id: str, user=Depends(verify_token)):
- res = supabase.table("farms").select("location").eq("id", farm_id).eq("farmer_id", user.id).limit(1).execute()
- if not res.data:
+async def get_fields(
+ farm_id: str,
+ user: dict = Depends(verify_token),
+) -> dict:
+ # Verify farm ownership before returning fields
+ owned = (
+ supabase.table("farms")
+ .select("id")
+ .eq("id", farm_id)
+ .eq("farmer_id", user.id)
+ .limit(1)
+ .execute()
+ )
+ if not owned.data:
raise HTTPException(status_code=404, detail="Farm not found")
- fields = (res.data.get("location") or {}).get("fields", [])
- return {"success": True, "data": fields}
+
+ res = (
+ supabase.table("farm_fields")
+ .select("*")
+ .eq("farm_id", farm_id)
+ .order("created_at")
+ .execute()
+ )
+ return {"success": True, "data": res.data or []}
+
@app.post("/api/farms/{farm_id}/fields", status_code=201)
-async def add_field(farm_id: str, body: FieldCreate, user=Depends(verify_token)):
- owned = supabase.table("farms").select("location, total_area").eq("id", farm_id).eq("farmer_id", user.id).limit(1).execute()
+async def add_field(
+ farm_id: str,
+ body: FieldCreate,
+ user: dict = Depends(verify_token),
+) -> dict:
+ # Verify farm ownership
+ owned = (
+ supabase.table("farms")
+ .select("id")
+ .eq("id", farm_id)
+ .eq("farmer_id", user.id)
+ .limit(1)
+ .execute()
+ )
if not owned.data:
raise HTTPException(status_code=403, detail="Access denied")
- current_loc = owned.data[0][0].get("location") or {}
- current_fields = current_loc.get("fields", [])
-
- new_field = {
- "id": str(uuid.uuid4()),
- **body.model_dump(),
+ payload: dict = {
+ "farm_id": farm_id,
+ "name": body.name,
+ "area_acres": body.area_acres,
+ "polygon": body.polygon,
+ "center_latitude": body.center_latitude,
+ "center_longitude": body.center_longitude,
}
- updated_fields = current_fields + [new_field]
- total_area = sum(f.get("area_acres", 0) for f in updated_fields)
+ # Strip None values so DB defaults apply
+ payload = {k: v for k, v in payload.items() if v is not None}
+
+ res = supabase.table("farm_fields").insert(payload).execute()
+ if not res.data:
+ raise HTTPException(status_code=500, detail="Failed to save field")
- supabase.table("farms").update({
- "location": {**current_loc, "fields": updated_fields},
- "total_area": total_area,
- }).eq("id", farm_id).execute()
+ return {"success": True, "data": res.data[0]}
- return {"success": True, "data": new_field}
@app.delete("/api/farms/{farm_id}/fields/{field_id}")
-async def delete_field(farm_id: str, field_id: str, user=Depends(verify_token)):
- owned = supabase.table("farms").select("location, total_area").eq("id", farm_id).eq("farmer_id", user.id).limit(1).execute()
+async def delete_field(
+ farm_id: str,
+ field_id: str,
+ user: dict = Depends(verify_token),
+) -> dict:
+ # Verify farm ownership
+ owned = (
+ supabase.table("farms")
+ .select("id")
+ .eq("id", farm_id)
+ .eq("farmer_id", user.id)
+ .limit(1)
+ .execute()
+ )
if not owned.data:
raise HTTPException(status_code=403, detail="Access denied")
- current_loc = owned.data[0][0].get("location") or {}
- current_fields = current_loc.get("fields", [])
- updated_fields = [f for f in current_fields if f.get("id") != field_id]
- total_area = sum(f.get("area_acres", 0) for f in updated_fields) or owned.data[0][0].get("total_area", 0)
-
- supabase.table("farms").update({
- "location": {**current_loc, "fields": updated_fields},
- "total_area": total_area,
- }).eq("id", farm_id).execute()
+ supabase.table("farm_fields").delete().eq("id", field_id).eq("farm_id", farm_id).execute()
return {"success": True, "message": "Field deleted"}
diff --git a/backend/supabase/migrations/20260607000000_farm_fields_table.sql b/backend/supabase/migrations/20260607000000_farm_fields_table.sql
new file mode 100644
index 0000000..3a450bf
--- /dev/null
+++ b/backend/supabase/migrations/20260607000000_farm_fields_table.sql
@@ -0,0 +1,198 @@
+-- =============================================================================
+-- AgroNavis — Migration 6: Dedicated farm_fields table
+-- Applied: 2026-06-07
+--
+-- Replaces the JSONB fields array embedded in farms.location with a proper
+-- relational table. This enables:
+-- • RLS policies scoped to individual fields
+-- • Indexed spatial lookups by farm
+-- • Clean foreign-key cascade deletes
+-- • Accurate total_area derived via DB aggregation
+--
+-- Backwards compatibility: farms.location.polygon (legacy single-polygon)
+-- is left untouched so existing data continues to render in FarmMap.
+-- =============================================================================
+
+-- -----------------------------------------------------------------------------
+-- 1. Create farm_fields table
+-- -----------------------------------------------------------------------------
+
+CREATE TABLE IF NOT EXISTS public.farm_fields (
+ id uuid NOT NULL DEFAULT gen_random_uuid(),
+ farm_id uuid NOT NULL REFERENCES public.farms(id) ON DELETE CASCADE,
+
+ name text NOT NULL,
+ area_acres numeric NOT NULL CHECK (area_acres > 0),
+ area_hectares numeric GENERATED ALWAYS AS (area_acres * 0.404686) STORED,
+
+ -- Polygon stored as JSONB array of {lat, lng} points
+ -- e.g. [{"lat": 22.57, "lng": 88.36}, ...]
+ polygon jsonb NOT NULL,
+
+ center_latitude numeric,
+ center_longitude numeric,
+
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+
+ CONSTRAINT farm_fields_pkey PRIMARY KEY (id)
+);
+
+-- Index: fast lookup of all fields for a given farm
+CREATE INDEX IF NOT EXISTS idx_farm_fields_farm_id
+ ON public.farm_fields (farm_id);
+
+-- Index: spatial-ish lookup by centre coordinates
+CREATE INDEX IF NOT EXISTS idx_farm_fields_center
+ ON public.farm_fields (center_latitude, center_longitude)
+ WHERE center_latitude IS NOT NULL AND center_longitude IS NOT NULL;
+
+
+-- -----------------------------------------------------------------------------
+-- 2. updated_at trigger
+-- -----------------------------------------------------------------------------
+
+CREATE TRIGGER set_farm_fields_updated_at
+ BEFORE UPDATE ON public.farm_fields
+ FOR EACH ROW
+ EXECUTE FUNCTION update_updated_at_column();
+
+
+-- -----------------------------------------------------------------------------
+-- 3. Row Level Security
+-- Farmers can only see and modify fields that belong to their own farms.
+-- -----------------------------------------------------------------------------
+
+ALTER TABLE public.farm_fields ENABLE ROW LEVEL SECURITY;
+
+CREATE POLICY "Farmers can view fields on own farms"
+ ON public.farm_fields FOR SELECT
+ USING (
+ auth.uid() IN (
+ SELECT farmer_id FROM public.farms WHERE id = farm_id
+ )
+ );
+
+CREATE POLICY "Farmers can insert fields on own farms"
+ ON public.farm_fields FOR INSERT
+ WITH CHECK (
+ auth.uid() IN (
+ SELECT farmer_id FROM public.farms WHERE id = farm_id
+ )
+ );
+
+CREATE POLICY "Farmers can update fields on own farms"
+ ON public.farm_fields FOR UPDATE
+ USING (
+ auth.uid() IN (
+ SELECT farmer_id FROM public.farms WHERE id = farm_id
+ )
+ );
+
+CREATE POLICY "Farmers can delete fields on own farms"
+ ON public.farm_fields FOR DELETE
+ USING (
+ auth.uid() IN (
+ SELECT farmer_id FROM public.farms WHERE id = farm_id
+ )
+ );
+
+
+-- -----------------------------------------------------------------------------
+-- 4. Auto-update farms.total_area when fields change
+-- Keeps the farms row in sync without needing application-level bookkeeping.
+-- -----------------------------------------------------------------------------
+
+CREATE OR REPLACE FUNCTION public.sync_farm_total_area()
+RETURNS TRIGGER
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_farm_id uuid;
+ v_total numeric;
+BEGIN
+ -- Determine the affected farm_id from either NEW or OLD row
+ v_farm_id := COALESCE(NEW.farm_id, OLD.farm_id);
+
+ SELECT COALESCE(SUM(area_acres), 0)
+ INTO v_total
+ FROM public.farm_fields
+ WHERE farm_id = v_farm_id;
+
+ UPDATE public.farms
+ SET total_area = v_total
+ WHERE id = v_farm_id;
+
+ RETURN COALESCE(NEW, OLD);
+END;
+$$;
+
+CREATE TRIGGER trg_sync_farm_total_area
+ AFTER INSERT OR UPDATE OR DELETE ON public.farm_fields
+ FOR EACH ROW
+ EXECUTE FUNCTION public.sync_farm_total_area();
+
+
+-- -----------------------------------------------------------------------------
+-- 5. Migrate existing JSONB fields into the new table
+-- Reads farms.location->>'fields' and inserts each entry as a proper row.
+-- Safe to run even if the JSONB array is empty or missing.
+-- -----------------------------------------------------------------------------
+
+DO $$
+DECLARE
+ r RECORD;
+ field_row jsonb;
+BEGIN
+ FOR r IN
+ SELECT id, location
+ FROM public.farms
+ WHERE location IS NOT NULL
+ AND location->'fields' IS NOT NULL
+ AND jsonb_array_length(location->'fields') > 0
+ LOOP
+ FOR field_row IN
+ SELECT jsonb_array_elements(r.location->'fields')
+ LOOP
+ INSERT INTO public.farm_fields (
+ id,
+ farm_id,
+ name,
+ area_acres,
+ polygon,
+ center_latitude,
+ center_longitude
+ ) VALUES (
+ COALESCE((field_row->>'id')::uuid, gen_random_uuid()),
+ r.id,
+ COALESCE(field_row->>'name', 'Unnamed Field'),
+ COALESCE((field_row->>'area_acres')::numeric, 0),
+ COALESCE(field_row->'polygon', '[]'::jsonb),
+ (field_row->>'center_latitude')::numeric,
+ (field_row->>'center_longitude')::numeric
+ )
+ ON CONFLICT (id) DO NOTHING;
+ END LOOP;
+ END LOOP;
+END;
+$$;
+
+
+-- -----------------------------------------------------------------------------
+-- 6. Grant service_role access (used by FastAPI backend)
+-- -----------------------------------------------------------------------------
+
+GRANT SELECT, INSERT, UPDATE, DELETE ON public.farm_fields TO service_role;
+
+
+-- =============================================================================
+-- DONE
+-- Table: farm_fields (id, farm_id, name, area_acres, area_hectares,
+-- polygon, center_latitude, center_longitude,
+-- created_at, updated_at)
+-- Trigger: trg_sync_farm_total_area — keeps farms.total_area accurate
+-- Trigger: set_farm_fields_updated_at — auto-updates updated_at
+-- RLS: SELECT / INSERT / UPDATE / DELETE scoped to farm owner
+-- Index: idx_farm_fields_farm_id, idx_farm_fields_center
+-- Migration: existing JSONB fields copied into new table
+-- =============================================================================
diff --git a/backend/supabase/seed.sql b/backend/supabase/seed.sql
new file mode 100644
index 0000000..b6f2091
--- /dev/null
+++ b/backend/supabase/seed.sql
@@ -0,0 +1,76 @@
+-- AgroNavis local development seed
+-- Creates the dev bypass user referenced in .env.example
+-- Runs automatically on: npx supabase db reset
+--
+-- Credentials: dev@agronavis.local / password123
+-- Matches NEXT_PUBLIC_DEV_EMAIL / NEXT_PUBLIC_DEV_PASSWORD in .env
+-- DO NOT add real credentials or production data here.
+
+DO $$
+DECLARE
+ dev_user_id uuid := gen_random_uuid();
+BEGIN
+ -- Only insert if the dev user doesn't already exist
+ IF NOT EXISTS (
+ SELECT 1 FROM auth.users WHERE email = 'dev@agronavis.local'
+ ) THEN
+ INSERT INTO auth.users (
+ id,
+ instance_id,
+ aud,
+ role,
+ email,
+ encrypted_password,
+ email_confirmed_at,
+ raw_app_meta_data,
+ raw_user_meta_data,
+ created_at,
+ updated_at,
+ confirmation_token,
+ recovery_token,
+ email_change_token_new,
+ email_change
+ ) VALUES (
+ dev_user_id,
+ '00000000-0000-0000-0000-000000000000',
+ 'authenticated',
+ 'authenticated',
+ 'dev@agronavis.local',
+ -- bcrypt hash of 'password123'
+ crypt('password123', gen_salt('bf')),
+ now(),
+ '{"provider":"email","providers":["email"]}',
+ '{"email_verified":true}',
+ now(),
+ now(),
+ '',
+ '',
+ '',
+ ''
+ );
+
+ INSERT INTO auth.identities (
+ id,
+ user_id,
+ identity_data,
+ provider,
+ provider_id,
+ last_sign_in_at,
+ created_at,
+ updated_at
+ ) VALUES (
+ gen_random_uuid(),
+ dev_user_id,
+ json_build_object('sub', dev_user_id::text, 'email', 'dev@agronavis.local'),
+ 'email',
+ dev_user_id::text,
+ now(),
+ now(),
+ now()
+ );
+
+ RAISE NOTICE 'Dev user created: dev@agronavis.local';
+ ELSE
+ RAISE NOTICE 'Dev user already exists, skipping.';
+ END IF;
+END $$;
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 3458faf..71d55ed 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -15,6 +15,7 @@
"@turf/turf": "^7.3.4",
"@types/leaflet": "^1.9.20",
"@types/turf": "^3.5.32",
+ "Agronavis": "file:..",
"axios": "^1.6.2",
"dotenv": "^17.4.2",
"i18next": "^25.6.0",
@@ -52,6 +53,14 @@
"ts-node": "^10.9.2"
}
},
+ "..": {
+ "name": "Agronavis",
+ "version": "2.0.0",
+ "devDependencies": {
+ "concurrently": "^8.2.2",
+ "supabase": "^2.105.0"
+ }
+ },
"node_modules/@adobe/css-tools": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz",
@@ -6928,6 +6937,10 @@
"node": ">= 14"
}
},
+ "node_modules/Agronavis": {
+ "resolved": "..",
+ "link": true
+ },
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index fe58917..d471401 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -17,6 +17,7 @@
"@turf/turf": "^7.3.4",
"@types/leaflet": "^1.9.20",
"@types/turf": "^3.5.32",
+ "Agronavis": "file:..",
"axios": "^1.6.2",
"dotenv": "^17.4.2",
"i18next": "^25.6.0",
diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx
index e162565..a5165b7 100644
--- a/frontend/src/components/Dashboard.tsx
+++ b/frontend/src/components/Dashboard.tsx
@@ -7,6 +7,7 @@ import s from '../styles/Dashboard.module.css';
import { farmApi, cropApi } from '../utils/farmApi';
import { soilService } from '../utils/soilService';
import { useReverseGeocode } from '../hooks/useReverseGeocode';
+import { FIELD_COLORS } from '../utils/mapUtils';
import type { LatLng } from '../utils/geoUtils';
import ProfileTab from './ProfileTab';
import AnalyticsDashboard from './AnalyticsDashboard';
@@ -20,6 +21,16 @@ const PolygonMapper = dynamic(() => import('./map/PolygonMapper'), {
loading: () =>
,
});
+interface Field {
+ id: string;
+ name: string;
+ area_acres: number;
+ area_hectares?: number;
+ polygon: Array<{ lat: number; lng: number }>;
+ center_latitude?: number;
+ center_longitude?: number;
+}
+
interface Farm {
id: string;
name: string;
@@ -87,13 +98,18 @@ const FertDot: React.FC<{ color: string }> = ({ color }) => (
}} />
);
+// Palette for field colour swatches — imported from shared mapUtils
+// (FIELD_COLORS is used by both Dashboard and FarmMap to keep colours in sync)
+
const Dashboard: React.FC = ({ activeTab, setActiveTab }) => {
const router = useRouter();
const { t } = useTranslation();
const [farms, setFarms] = useState([]);
const [crops, setCrops] = useState([]);
const [selectedFarmId, setSelectedFarmId] = useState('');
- const [polygonData, setPolygonData] = useState<{
+ const [fields, setFields] = useState([]);
+ const [pendingField, setPendingField] = useState<{
+ fieldName: string;
coordinates: LatLng[];
areaAcres: number;
areaHectares: number;
@@ -101,6 +117,7 @@ const Dashboard: React.FC = ({ activeTab, setActiveTab }) => {
centerLng: number;
} | null>(null);
const [saving, setSaving] = useState(false);
+ const [deletingFieldId, setDeletingFieldId] = useState(null);
const [saveMessage, setSaveMessage] = useState<{ type: 'success' | 'error' | 'info'; text: string } | null>(null);
const [loading, setLoading] = useState(true);
@@ -134,6 +151,14 @@ const Dashboard: React.FC = ({ activeTab, setActiveTab }) => {
useEffect(() => { loadData(); }, [loadData]);
+ // Reload fields from the dedicated table whenever the selected farm changes
+ useEffect(() => {
+ if (!selectedFarmId) return;
+ farmApi.getFarmFields(selectedFarmId)
+ .then((data: Field[]) => setFields(data || []))
+ .catch(() => setFields([]));
+ }, [selectedFarmId]);
+
// Handle ?mode=draw from onboarding redirect
useEffect(() => {
const mode = router.query.mode as string;
@@ -146,41 +171,60 @@ const Dashboard: React.FC = ({ activeTab, setActiveTab }) => {
}, [router.query, farms]);
// Map handlers
- const handleSavePolygon = async () => {
- if (!polygonData || !selectedFarmId) return;
+ const handleAddField = async () => {
+ if (!pendingField || !selectedFarmId) return;
setSaving(true);
setSaveMessage(null);
try {
- await farmApi.updateFarm(selectedFarmId, {
- total_area: polygonData.areaAcres,
- latitude: polygonData.centerLat,
- longitude: polygonData.centerLng,
- location: {
- ...selectedFarm?.location,
- polygon: polygonData.coordinates.map(p => ({ lat: p.lat, lng: p.lng })),
- center_latitude: polygonData.centerLat,
- center_longitude: polygonData.centerLng,
- area_acres: polygonData.areaAcres,
- area_hectares: polygonData.areaHectares,
- },
+ await farmApi.addFarmField(selectedFarmId, {
+ name: pendingField.fieldName,
+ area_acres: pendingField.areaAcres,
+ area_hectares: pendingField.areaHectares,
+ polygon: pendingField.coordinates.map(p => ({ lat: p.lat, lng: p.lng })),
+ center_latitude: pendingField.centerLat,
+ center_longitude: pendingField.centerLng,
});
- setSaveMessage({ type: 'success', text: `Boundary saved — ${polygonData.areaAcres.toFixed(1)} acres` });
+ setSaveMessage({ type: 'success', text: `"${pendingField.fieldName}" saved — ${pendingField.areaAcres.toFixed(1)} acres` });
try {
- const geo = await geocode(polygonData.centerLat, polygonData.centerLng);
+ const geo = await geocode(pendingField.centerLat, pendingField.centerLng);
if (geo?.state && geo?.district) {
await soilService.estimateSoilHealth(selectedFarmId, geo.state, geo.district);
- setSaveMessage({ type: 'success', text: `Boundary saved & soil analysed for ${geo.district}` });
}
- } catch { /* silent */ }
+ } catch { /* silent — soil estimation is best-effort */ }
+ // Refresh both farms (for total_area) and the fields list
await loadData();
- setTimeout(() => { setPolygonData(null); setSaveMessage(null); }, 2500);
- } catch (err: any) {
- setSaveMessage({ type: 'error', text: err.message || 'Failed to save boundary' });
+ const refreshed = await farmApi.getFarmFields(selectedFarmId);
+ setFields(refreshed || []);
+ setPendingField(null);
+ setTimeout(() => setSaveMessage(null), 3000);
+ } catch (err: unknown) {
+ const msg = err instanceof Error ? err.message : 'Failed to save field';
+ setSaveMessage({ type: 'error', text: msg });
} finally {
setSaving(false);
}
};
+ const handleDeleteField = async (fieldId: string, fieldName: string) => {
+ if (!selectedFarmId) return;
+ setDeletingFieldId(fieldId);
+ setSaveMessage(null);
+ try {
+ await farmApi.deleteFarmField(selectedFarmId, fieldId);
+ setSaveMessage({ type: 'info', text: `"${fieldName}" removed.` });
+ // Refresh both farms (total_area updated by DB trigger) and fields list
+ await loadData();
+ const refreshed = await farmApi.getFarmFields(selectedFarmId);
+ setFields(refreshed || []);
+ setTimeout(() => setSaveMessage(null), 2500);
+ } catch (err: unknown) {
+ const msg = err instanceof Error ? err.message : 'Failed to delete field';
+ setSaveMessage({ type: 'error', text: msg });
+ } finally {
+ setDeletingFieldId(null);
+ }
+ };
+
// Fertilizer calc — all values derived from farm area + selected crop
const fertFarm = farms.find(f => f.id === fertFarmId) || farms[0];
const soilKey = fertFarm?.soil_type || 'default';
@@ -418,6 +462,11 @@ const Dashboard: React.FC = ({ activeTab, setActiveTab }) => {
{farm.total_area ? `${farm.total_area.toFixed(1)} acres` : 'Area not mapped'} — {farm.irrigation_type || 'Irrigation type not set'}
+ {farm.id === selectedFarmId && fields.length > 0 && (
+
+ · {fields.length} field{fields.length !== 1 ? 's' : ''} mapped
+
+ )}
@@ -464,16 +513,20 @@ const Dashboard: React.FC = ({ activeTab, setActiveTab }) => {
<>
-
Boundary Mapper
+
Field Manager
- Click at least 3 corners on the map to draw your field boundary. Area is calculated automatically.
+ Draw and name each field boundary. You can add multiple fields per farm.
{hasFarms && (
{ setSelectedFarmId(e.target.value); setPolygonData(null); setSaveMessage(null); }}
+ onChange={e => {
+ setSelectedFarmId(e.target.value);
+ setPendingField(null);
+ setSaveMessage(null);
+ }}
>
{farms.map(f => {f.name} )}
@@ -489,13 +542,13 @@ const Dashboard: React.FC
= ({ activeTab, setActiveTab }) => {
{!hasFarms ? (
No farm to map
-
Create a farm first, then draw its boundary here.
+
Create a farm first, then draw its field boundaries here.
router.push('/onboarding/farm')}>Create Farm
) : (
<>
- {/* PolygonMapper: do NOT constrain height here — let it render fully */}
-
+ {/* ── Polygon drawing tool ── */}
+
= ({ activeTab, setActiveTab }) => {
? { lat: selectedFarm.location.latitude, lng: selectedFarm.location.longitude }
: { lat: 22.5726, lng: 88.3639 }
}
- onPolygonComplete={data => setPolygonData(data)}
+ onPolygonComplete={data => setPendingField(data)}
/>
- {/* Confirm area panel — shown when polygon data available */}
- {polygonData && (
+ {/* ── Pending field confirm panel ── */}
+ {pendingField && (
= ({ activeTab, setActiveTab }) => {
boxShadow: '0 2px 12px rgba(16,185,129,0.1)',
}}>
-
Field Area
-
- {polygonData.areaAcres.toFixed(2)}
-
Acres
+
+ Ready to save
-
- {polygonData.areaHectares.toFixed(2)} Hectares
+
+ {pendingField.fieldName}
+
+
+ {pendingField.areaAcres.toFixed(2)} acres · {pendingField.areaHectares.toFixed(2)} ha
setPolygonData(null)}
+ onClick={() => setPendingField(null)}
style={{
padding: '10px 20px',
border: '1.5px solid var(--color-border)',
- borderRadius: 10,
- background: 'white',
- fontSize: 13,
- fontWeight: 600,
+ borderRadius: 10, background: 'white',
+ fontSize: 13, fontWeight: 600,
color: 'var(--color-text-secondary)',
- cursor: 'pointer',
- fontFamily: 'inherit',
+ cursor: 'pointer', fontFamily: 'inherit',
}}
>
- Redraw
+ Discard
- {saving ? 'Saving...' : 'Save Boundary'}
+ {saving ? 'Saving...' : 'Save Field'}
)}
+
+ {/* ── Saved fields list ── */}
+ {fields.length > 0 ? (
+
+
+
+ Mapped Fields
+
+
+ {fields.length} field{fields.length !== 1 ? 's' : ''} · {selectedFarm?.total_area?.toFixed(1) ?? '0'} acres total
+
+
+ {fields.map((field, idx) => (
+
+
+
+
+ {field.name}
+
+
+ {field.area_acres.toFixed(2)} acres
+ {field.area_hectares ? ` · ${field.area_hectares.toFixed(2)} ha` : ''}
+
+
+
handleDeleteField(field.id, field.name)}
+ disabled={deletingFieldId === field.id}
+ aria-label={`Delete field ${field.name}`}
+ style={{
+ padding: '6px 14px',
+ border: '1px solid #fca5a5',
+ borderRadius: 8, background: '#fef2f2',
+ fontSize: 12, fontWeight: 600, color: '#dc2626',
+ cursor: deletingFieldId === field.id ? 'not-allowed' : 'pointer',
+ fontFamily: 'inherit', flexShrink: 0,
+ opacity: deletingFieldId === field.id ? 0.6 : 1,
+ }}
+ >
+ {deletingFieldId === field.id ? 'Removing...' : 'Remove'}
+
+
+ ))}
+
+ ) : (
+
+ No fields mapped yet. Draw a boundary above and give it a name to add your first field.
+
+ )}
>
)}
>
diff --git a/frontend/src/components/FarmMap.tsx b/frontend/src/components/FarmMap.tsx
index 1ccfe45..333dd77 100644
--- a/frontend/src/components/FarmMap.tsx
+++ b/frontend/src/components/FarmMap.tsx
@@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
import { MapContainer, TileLayer, Marker, Popup, Polygon } from 'react-leaflet';
import L from 'leaflet';
import { farmApi } from '../utils/farmApi';
+import { FIELD_COLORS } from '../utils/mapUtils';
import styles from '../styles/Map.module.css';
// Fix for Leaflet marker icons in Next.js
@@ -11,11 +12,20 @@ const DefaultIcon = L.icon({
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
- shadowSize: [41, 41]
+ shadowSize: [41, 41],
});
-
L.Marker.prototype.options.icon = DefaultIcon;
+interface Field {
+ id: string;
+ name: string;
+ area_acres: number;
+ area_hectares?: number;
+ polygon: Array<{ lat: number; lng: number }>;
+ center_latitude?: number;
+ center_longitude?: number;
+}
+
interface Farm {
id: string;
name: string;
@@ -25,17 +35,8 @@ interface Farm {
state?: string;
district?: string;
village?: string;
- polygon?: Array<{lat: number, lng: number}>;
- fields?: Array<{
- id: string;
- name: string;
- area_acres: number;
- area_hectares?: number;
- polygon: Array<{lat: number, lng: number}>;
- center_latitude?: number;
- center_longitude?: number;
- created_at?: string;
- }>;
+ /** Legacy single-polygon stored directly on the farm */
+ polygon?: Array<{ lat: number; lng: number }>;
};
total_area: number;
soil_type?: string;
@@ -48,20 +49,20 @@ interface FarmMapProps {
centerLng?: number;
zoom?: number;
showAllFarms?: boolean;
- showAllFields?: boolean;
height?: string;
}
-const FarmMap: React.FC
= ({
- farmId,
- centerLat = 20.5937,
+const FarmMap: React.FC = ({
+ farmId,
+ centerLat = 20.5937,
centerLng = 78.9629,
zoom = 5,
showAllFarms = false,
- showAllFields = false,
- height = '400px'
+ height = '400px',
}) => {
const [farms, setFarms] = useState([]);
+ // Map from farm ID → its fields fetched from farm_fields table
+ const [fieldsByFarm, setFieldsByFarm] = useState>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [mapCenter, setMapCenter] = useState<[number, number]>([centerLat, centerLng]);
@@ -71,50 +72,66 @@ const FarmMap: React.FC = ({
const loadFarmData = async () => {
try {
setLoading(true);
-
+
if (farmId) {
const farm = await farmApi.getFarm(farmId);
- if (farm) {
- setFarms([farm]);
- if (farm.location?.latitude && farm.location?.longitude) {
- setMapCenter([farm.location.latitude, farm.location.longitude]);
- setMapZoom(16);
- } else if (farm.location?.polygon && farm.location.polygon.length >= 3) {
- const polygon = farm.location.polygon;
- const centerLat = polygon.reduce((sum: number, p: {lat: number}) => sum + p.lat, 0) / polygon.length;
- const centerLng = polygon.reduce((sum: number, p: {lng: number}) => sum + p.lng, 0) / polygon.length;
- setMapCenter([centerLat, centerLng]);
- setMapZoom(16);
- }
- } else {
- setFarms([]);
- setError('Farm not found');
+ if (!farm) { setError('Farm not found'); return; }
+
+ setFarms([farm]);
+
+ // Load fields from the dedicated table
+ const farmFields: Field[] = await farmApi.getFarmFields(farmId).catch(() => []);
+ setFieldsByFarm({ [farmId]: farmFields });
+
+ // Set map centre from first field, then legacy polygon centroid, then lat/lng
+ const firstField = farmFields[0];
+ if (firstField?.center_latitude && firstField?.center_longitude) {
+ setMapCenter([firstField.center_latitude, firstField.center_longitude]);
+ setMapZoom(16);
+ } else if (farm.location?.polygon && farm.location.polygon.length >= 3) {
+ const poly = farm.location.polygon;
+ setMapCenter([
+ poly.reduce((s: number, p: { lat: number }) => s + p.lat, 0) / poly.length,
+ poly.reduce((s: number, p: { lng: number }) => s + p.lng, 0) / poly.length,
+ ]);
+ setMapZoom(16);
+ } else if (farm.location?.latitude && farm.location?.longitude) {
+ setMapCenter([farm.location.latitude, farm.location.longitude]);
+ setMapZoom(16);
}
} else if (showAllFarms) {
- const allFarms = await farmApi.getFarms();
- const farmsWithCoords = allFarms.filter(
- (farm: Farm) => (farm.location?.latitude && farm.location?.longitude) ||
- (farm.location?.polygon && farm.location.polygon.length >= 3)
+ const allFarms: Farm[] = await farmApi.getFarms();
+ const farmsWithData = allFarms.filter(
+ f =>
+ (f.location?.latitude && f.location?.longitude) ||
+ (f.location?.polygon?.length ?? 0) >= 3
);
-
- setFarms(farmsWithCoords);
-
- if (farmsWithCoords.length > 0) {
- const firstFarm = farmsWithCoords[0];
- if (firstFarm.location?.latitude && firstFarm.location?.longitude) {
- setMapCenter([firstFarm.location.latitude, firstFarm.location.longitude]);
- } else if (firstFarm.location?.polygon && firstFarm.location.polygon.length >= 3) {
- const polygon = firstFarm.location.polygon;
- const centerLat = polygon.reduce((sum: number, p: {lat: number}) => sum + p.lat, 0) / polygon.length;
- const centerLng = polygon.reduce((sum: number, p: {lng: number}) => sum + p.lng, 0) / polygon.length;
- setMapCenter([centerLat, centerLng]);
+ setFarms(farmsWithData);
+
+ // Load fields for each farm in parallel
+ const entries = await Promise.all(
+ farmsWithData.map(async f => {
+ const ff: Field[] = await farmApi.getFarmFields(f.id).catch(() => []);
+ return [f.id, ff] as [string, Field[]];
+ })
+ );
+ setFieldsByFarm(Object.fromEntries(entries));
+
+ if (farmsWithData.length > 0) {
+ const first = farmsWithData[0];
+ const firstField = entries[0]?.[1]?.[0];
+ if (firstField?.center_latitude && firstField?.center_longitude) {
+ setMapCenter([firstField.center_latitude, firstField.center_longitude]);
+ } else if (first.location?.latitude && first.location?.longitude) {
+ setMapCenter([first.location.latitude, first.location.longitude]);
}
setMapZoom(12);
}
}
- } catch (err: any) {
- console.error('Error loading farm data:', err);
- setError(err.message || 'Failed to load farm data');
+ } catch (err: unknown) {
+ const msg = err instanceof Error ? err.message : 'Failed to load farm data';
+ console.error('FarmMap load error:', err);
+ setError(msg);
} finally {
setLoading(false);
}
@@ -134,17 +151,17 @@ const FarmMap: React.FC = ({
if (farms.length === 0) {
return (
-
- No farms with location data available.
- Use "Draw Field" to add field boundaries.
+
+ No farms with location data available.
+ Use "Field Manager" to add field boundaries.
);
}
- const heightClass =
- height === '100%' ? styles.mapHeightFull :
- height === '250px' ? styles.mapHeightSmall :
+ const heightClass =
+ height === '100%' ? styles.mapHeightFull :
+ height === '250px' ? styles.mapHeightSmall :
styles.mapHeight;
return (
@@ -158,70 +175,98 @@ const FarmMap: React.FC = ({
attribution='© OpenStreetMap '
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
+
{farms.map(farm => {
- const hasPolygon = farm.location?.polygon && farm.location.polygon.length >= 3;
- const hasFields = farm.location?.fields && farm.location.fields.length > 0;
- const lat = farm.location?.latitude;
- const lng = farm.location?.longitude;
-
- // Derive center from polygon centroid if GPS lat/lng not set
- const polyCenter = hasPolygon ? {
- lat: farm.location!.polygon!.reduce((s: number, p: {lat: number}) => s + p.lat, 0) / farm.location!.polygon!.length,
- lng: farm.location!.polygon!.reduce((s: number, p: {lng: number}) => s + p.lng, 0) / farm.location!.polygon!.length,
- } : null;
-
- const centerLat = lat ?? polyCenter?.lat ?? farm.location?.fields?.[0]?.center_latitude;
- const centerLng = lng ?? polyCenter?.lng ?? farm.location?.fields?.[0]?.center_longitude;
-
-
- if (!centerLat || !centerLng) return null;
-
+ const fields: Field[] = fieldsByFarm[farm.id] ?? [];
+ const hasFields = fields.length > 0;
+ const hasLegacyPolygon =
+ !hasFields && (farm.location?.polygon?.length ?? 0) >= 3;
+
+ // Marker position: first field centre → legacy polygon centroid → lat/lng
+ const markerLat =
+ fields[0]?.center_latitude ??
+ farm.location?.latitude ??
+ (hasLegacyPolygon
+ ? farm.location!.polygon!.reduce((s: number, p: { lat: number }) => s + p.lat, 0) / farm.location!.polygon!.length
+ : undefined);
+ const markerLng =
+ fields[0]?.center_longitude ??
+ farm.location?.longitude ??
+ (hasLegacyPolygon
+ ? farm.location!.polygon!.reduce((s: number, p: { lng: number }) => s + p.lng, 0) / farm.location!.polygon!.length
+ : undefined);
+
return (
- {showAllFields && hasFields ? (
- farm.location.fields!.map((field, idx) => (
- field.polygon && field.polygon.length >= 3 && (
- [p.lat, p.lng])}
- pathOptions={{ color: '#8b5cf6', fillColor: '#8b5cf6', fillOpacity: 0.3, weight: 3 }}
+ {/* Named fields from farm_fields table — each gets its own colour */}
+ {hasFields &&
+ fields.map((field, idx) => {
+ if (!field.polygon || field.polygon.length < 3) return null;
+ const color = FIELD_COLORS[idx % FIELD_COLORS.length];
+ return (
+ [p.lat, p.lng] as [number, number])}
+ pathOptions={{ color, fillColor: color, fillOpacity: 0.25, weight: 2.5 }}
>
-
-
{field.name}
-
Area: {field.area_acres} acres
- {field.area_hectares &&
Area: {field.area_hectares} ha
}
+
+
+ {field.name}
+
+
+ {field.area_acres.toFixed(2)} acres
+ {field.area_hectares ? ` · ${field.area_hectares.toFixed(2)} ha` : ''}
+
+
+ {farm.name}
+
- )
- ))
- ) : hasPolygon && (
-
[p.lat, p.lng])}
- pathOptions={{ color: '#16a34a', fillColor: '#16a34a', fillOpacity: 0.3, weight: 3 }}
- />
+ );
+ })}
+
+ {/* Legacy single polygon — only shown when no named fields exist */}
+ {hasLegacyPolygon && (
+ [p.lat, p.lng] as [number, number])}
+ pathOptions={{ color: '#16a34a', fillColor: '#16a34a', fillOpacity: 0.25, weight: 2.5 }}
+ >
+
+ {farm.name}
+
+
+ )}
+
+ {/* Farm marker */}
+ {markerLat !== undefined && markerLng !== undefined && (
+
+
+
+
{farm.name}
+
+ {farm.total_area.toFixed(1)} acres total
+
+ {hasFields && (
+
+ {fields.length} field{fields.length !== 1 ? 's' : ''} mapped
+
+ )}
+ {farm.soil_type && (
+
Soil: {farm.soil_type}
+ )}
+ {(farm.location?.village || farm.location?.district || farm.location?.state) && (
+
+ {[farm.location?.village, farm.location?.district, farm.location?.state]
+ .filter(Boolean)
+ .join(', ')}
+
+ )}
+
+
+
)}
-
-
-
-
{farm.name}
-
Area: {farm.total_area} acres
- {farm.soil_type &&
Soil: {farm.soil_type}
}
- {farm.irrigation_type &&
Irri: {farm.irrigation_type}
}
- {hasPolygon && (
-
- ✓ Field boundary mapped ({farm.location.polygon!.length} points)
-
- )}
- {(farm.location?.village || farm.location?.district || farm.location?.state) && (
-
- {[farm.location?.village, farm.location?.district, farm.location?.state].filter(Boolean).join(', ')}
-
- )}
-
-
-
);
})}
@@ -230,4 +275,4 @@ const FarmMap: React.FC = ({
);
};
-export default FarmMap;
\ No newline at end of file
+export default FarmMap;
diff --git a/frontend/src/components/map/PolygonMapper.tsx b/frontend/src/components/map/PolygonMapper.tsx
index 8285a2d..0f11ea7 100644
--- a/frontend/src/components/map/PolygonMapper.tsx
+++ b/frontend/src/components/map/PolygonMapper.tsx
@@ -1,13 +1,12 @@
-'use client'; // if you are using Next.js App Router
+'use client';
-import React, { useEffect, useRef, useState } from 'react';
+import React, { useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import { usePolygonArea } from '../../hooks/usePolygonArea';
import SearchBox from './SearchBox';
import type { LatLng } from '../../utils/geoUtils';
import WeatherStatusBadge from './WeatherStatusBadge';
// Leaflet must be loaded client-side only (it accesses window)
-// So we lazy-load the inner map component
const MapInner = dynamic(() => import('./MapInner'), {
ssr: false,
loading: () => (
@@ -21,39 +20,38 @@ const MapInner = dynamic(() => import('./MapInner'), {
),
});
+interface PolygonCompleteData {
+ fieldName: string;
+ coordinates: LatLng[];
+ areaAcres: number;
+ areaHectares: number;
+ centerLat: number;
+ centerLng: number;
+}
+
interface PolygonMapperProps {
- onPolygonComplete: (data: {
- coordinates: LatLng[];
- areaAcres: number;
- areaHectares: number;
- centerLat: number;
- centerLng: number;
- }) => void;
+ onPolygonComplete: (data: PolygonCompleteData) => void;
initialCenter?: LatLng;
- showInstructions?: boolean;
}
-export default function PolygonMapper({ onPolygonComplete, initialCenter, showInstructions = true }: PolygonMapperProps) {
+export default function PolygonMapper({
+ onPolygonComplete,
+ initialCenter,
+}: PolygonMapperProps) {
const { points, area, center, isComplete, addPoint, removeLastPoint, resetPolygon } = usePolygonArea();
const [mapCenter, setMapCenter] = useState(initialCenter || { lat: 22.5726, lng: 88.3639 });
const [mapZoom, setMapZoom] = useState(13);
- const [locationLoading, setLocationLoading] = useState(false);
+ const [fieldName, setFieldName] = useState('');
+ const [nameError, setNameError] = useState('');
useEffect(() => {
if (!initialCenter && navigator.geolocation) {
- setLocationLoading(true);
navigator.geolocation.getCurrentPosition(
(position) => {
- setMapCenter({
- lat: position.coords.latitude,
- lng: position.coords.longitude
- });
+ setMapCenter({ lat: position.coords.latitude, lng: position.coords.longitude });
setMapZoom(15);
- setLocationLoading(false);
},
- () => {
- setLocationLoading(false);
- }
+ () => { /* geolocation denied — keep default centre */ }
);
} else if (initialCenter) {
setMapCenter(initialCenter);
@@ -68,13 +66,23 @@ export default function PolygonMapper({ onPolygonComplete, initialCenter, showIn
const handleConfirm = () => {
if (!isComplete) return;
+ const trimmed = fieldName.trim();
+ if (!trimmed) {
+ setNameError('Give this field a name before saving.');
+ return;
+ }
+ setNameError('');
onPolygonComplete({
+ fieldName: trimmed,
coordinates: points,
areaAcres: area.acres,
areaHectares: area.hectares,
centerLat: center.lat,
centerLng: center.lng,
});
+ // Reset for the next field
+ resetPolygon();
+ setFieldName('');
};
return (
@@ -83,14 +91,50 @@ export default function PolygonMapper({ onPolygonComplete, initialCenter, showIn
{/* Header */}
- Draw your field boundary
+ Draw a field boundary
- Click 4 corners to mark your field boundary.
+ Name the field, then click 4 corners on the map to draw its boundary.
- {/* Search bar (floats above the map visually) */}
+ {/* Field name input */}
+
+
+ Field Name
+
+
{ setFieldName(e.target.value); if (nameError) setNameError(''); }}
+ placeholder="e.g. Wheat Field, North Paddy, Block A"
+ maxLength={60}
+ style={{
+ width: '100%',
+ padding: '10px 14px',
+ border: `1.5px solid ${nameError ? '#fca5a5' : 'var(--color-border)'}`,
+ borderRadius: '10px',
+ fontSize: '14px',
+ color: 'var(--color-text-primary)',
+ background: 'white',
+ fontFamily: 'inherit',
+ boxSizing: 'border-box',
+ outline: 'none',
+ transition: 'border-color 0.15s',
+ }}
+ onFocus={e => { e.currentTarget.style.borderColor = '#10b981'; }}
+ onBlur={e => { e.currentTarget.style.borderColor = nameError ? '#fca5a5' : 'var(--color-border)'; }}
+ />
+ {nameError && (
+
{nameError}
+ )}
+
+
+ {/* Search bar */}
{/* Map */}
@@ -105,19 +149,19 @@ export default function PolygonMapper({ onPolygonComplete, initialCenter, showIn
onMapClick={addPoint}
/>
- {/* Instructions overlay (top-left of map) */}
+ {/* How-to overlay */}
- How to use:
- 1. Search your village
- 2. Click corners of your field
- 3. Drop 3+ pins to form a polygon
+ How to use:
+ 1. Name your field above
+ 2. Search your village
+ 3. Click 4 corners to mark boundary
@@ -127,7 +171,7 @@ export default function PolygonMapper({ onPolygonComplete, initialCenter, showIn
background: isComplete ? '#f0fdf4' : '#f9fafb',
border: `1px solid ${isComplete ? '#86efac' : '#e5e7eb'}`,
borderRadius: '10px', padding: '12px 16px',
- transition: 'all 0.3s ease'
+ transition: 'all 0.3s ease',
}}>
{/* Pin count */}
@@ -135,27 +179,22 @@ export default function PolygonMapper({ onPolygonComplete, initialCenter, showIn
background: '#16a34a', color: 'white',
borderRadius: '50%', width: '20px', height: '20px',
fontSize: '11px', display: 'flex', alignItems: 'center', justifyContent: 'center',
- fontWeight: 600
+ fontWeight: 600,
}}>{points.length}
{points.length === 1 ? 'pin dropped' : 'pins dropped'}
- {/* Area display */}
{isComplete && (
<>
-
+
-
- {area.acres}
-
+ {area.acres}
acres
-
- {area.hectares}
-
+ {area.hectares}
hectares
@@ -168,7 +207,7 @@ export default function PolygonMapper({ onPolygonComplete, initialCenter, showIn
{!isComplete && points.length > 0 && (
- Drop {4 - points.length} more {4 - points.length === 1 ? 'pin' : 'pins'} to complete boundary
+ Drop {Math.max(0, 4 - points.length)} more {(4 - points.length) === 1 ? 'pin' : 'pins'} to complete boundary
)}
@@ -178,8 +217,7 @@ export default function PolygonMapper({ onPolygonComplete, initialCenter, showIn
)}
- {/* Spacer */}
-
+
{/* Action buttons */}
@@ -189,7 +227,7 @@ export default function PolygonMapper({ onPolygonComplete, initialCenter, showIn
style={{
padding: '6px 12px', border: '1px solid #d1d5db',
borderRadius: '6px', background: 'white',
- fontSize: '12px', cursor: 'pointer', color: '#374151'
+ fontSize: '12px', cursor: 'pointer', color: '#374151',
}}
>
Undo last pin
@@ -201,7 +239,7 @@ export default function PolygonMapper({ onPolygonComplete, initialCenter, showIn
style={{
padding: '6px 12px', border: '1px solid #fca5a5',
borderRadius: '6px', background: '#fef2f2',
- fontSize: '12px', cursor: 'pointer', color: '#dc2626'
+ fontSize: '12px', cursor: 'pointer', color: '#dc2626',
}}
>
Reset
@@ -218,14 +256,14 @@ export default function PolygonMapper({ onPolygonComplete, initialCenter, showIn
fontSize: '13px',
cursor: 'pointer',
color: 'white',
- fontWeight: 500
+ fontWeight: 500,
}}
>
- Confirm field boundary →
+ Add field →
)}
);
-}
\ No newline at end of file
+}
diff --git a/frontend/src/utils/mapUtils.ts b/frontend/src/utils/mapUtils.ts
new file mode 100644
index 0000000..a46fa09
--- /dev/null
+++ b/frontend/src/utils/mapUtils.ts
@@ -0,0 +1,21 @@
+/**
+ * Shared map utilities
+ *
+ * Constants and helpers used by both the Dashboard (field list swatches)
+ * and FarmMap (polygon rendering) so the colours stay in sync.
+ */
+
+/**
+ * Colour palette for named field polygons and swatches.
+ * Index cycles when a farm has more fields than palette entries.
+ */
+export const FIELD_COLORS: readonly string[] = [
+ '#10b981', // emerald
+ '#f97316', // orange
+ '#6366f1', // indigo
+ '#eab308', // yellow
+ '#ec4899', // pink
+ '#06b6d4', // cyan
+ '#84cc16', // lime
+ '#a855f7', // purple
+] as const;
diff --git a/package-lock.json b/package-lock.json
index aed6693..4eaed6f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,6 +8,8 @@
"name": "Agronavis",
"version": "2.0.0",
"devDependencies": {
+ "concurrently": "^8.2.2",
+ "supabase": "^2.105.0"
"@commitlint/cli": "^21.0.2",
"@commitlint/config-conventional": "^21.0.2",
"concurrently": "^8.2.2",
@@ -49,6 +51,117 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@supabase/cli-darwin-arm64": {
+ "version": "2.105.0",
+ "resolved": "https://registry.npmjs.org/@supabase/cli-darwin-arm64/-/cli-darwin-arm64-2.105.0.tgz",
+ "integrity": "sha512-ptlLrggNCq7dndvY7ce0MIvCZRnAYXeyKC0H7c4DqQmCbOPZgSwD5a4E3RPrZS6TLeJPG7XhpuJarAU5PTf/9w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@supabase/cli-darwin-x64": {
+ "version": "2.105.0",
+ "resolved": "https://registry.npmjs.org/@supabase/cli-darwin-x64/-/cli-darwin-x64-2.105.0.tgz",
+ "integrity": "sha512-kJDYyy3UkXd4hDzo+duOzUk8yDqLEit8d/clBCiXNQFSJf96QCnG2iyrzT4dCDUk8UB3+g9xL1mH2EEuPHj7oA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@supabase/cli-linux-arm64": {
+ "version": "2.105.0",
+ "resolved": "https://registry.npmjs.org/@supabase/cli-linux-arm64/-/cli-linux-arm64-2.105.0.tgz",
+ "integrity": "sha512-cdHgfIFElYkAjrp0aJPlQPTqQ9doSxB4qRswlGNsSYaLlK5Ns78rXsB7G71RRnyrzb41C183osRhVENWVrnK/w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@supabase/cli-linux-arm64-musl": {
+ "version": "2.105.0",
+ "resolved": "https://registry.npmjs.org/@supabase/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.105.0.tgz",
+ "integrity": "sha512-45vxbXRwe/JUAjyvQ8oKO2o44IK3GunlVqeIWGzcDgaeMg5XO4x0i9+NGPvOvvhyMB4bgJ3cMxh/ovl1AM0GZA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@supabase/cli-linux-x64": {
+ "version": "2.105.0",
+ "resolved": "https://registry.npmjs.org/@supabase/cli-linux-x64/-/cli-linux-x64-2.105.0.tgz",
+ "integrity": "sha512-rj1iU0h4EJaanx72eJtipiXczAY3gug9qxo62MYUZg2X1aaegFtyMS83fmjC7YlduW2Cu+zvgWoyTlA6R5Ndzg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@supabase/cli-linux-x64-musl": {
+ "version": "2.105.0",
+ "resolved": "https://registry.npmjs.org/@supabase/cli-linux-x64-musl/-/cli-linux-x64-musl-2.105.0.tgz",
+ "integrity": "sha512-VnHn1NPn9Ov81BXFOx6FpHxbBT5e3ObaUxO7DR+LDGjh2wfMUj4E6Y863w+r0HJk0hi0mRH7u0U0dOrbBp4E+w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@supabase/cli-windows-arm64": {
+ "version": "2.105.0",
+ "resolved": "https://registry.npmjs.org/@supabase/cli-windows-arm64/-/cli-windows-arm64-2.105.0.tgz",
+ "integrity": "sha512-b5xQ5dVkARZTBiSwLpPbwffRHvVDcO3ZvAtzz54J44KQdLD0TSrgoXZJBrI4Y3ggmG70EiITKPL8+rT6ptjXsQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@supabase/cli-windows-x64": {
+ "version": "2.105.0",
+ "resolved": "https://registry.npmjs.org/@supabase/cli-windows-x64/-/cli-windows-x64-2.105.0.tgz",
+ "integrity": "sha512-QmfqDQJ58ba/9+vzM8HJQsa/rY6OdtaaaAmPKwCMd8JUQ7758p8sydYe1vXTb4CCPdvk9qW2d/Dh54ucH4SPwg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
"node_modules/@commitlint/cli": {
"version": "21.0.2",
"resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-21.0.2.tgz",
@@ -1234,6 +1347,26 @@
"node": ">=8"
}
},
+ "node_modules/supabase": {
+ "version": "2.105.0",
+ "resolved": "https://registry.npmjs.org/supabase/-/supabase-2.105.0.tgz",
+ "integrity": "sha512-UB2aFLYAVujTQsZ9l+aCbDfLNaZApZucByRNP/1j0L1pXXzFhSgEyZSrvHSUO5LIvOb09AGHWishL/usVTuHTg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "supabase": "dist/supabase.js"
+ },
+ "optionalDependencies": {
+ "@supabase/cli-darwin-arm64": "2.105.0",
+ "@supabase/cli-darwin-x64": "2.105.0",
+ "@supabase/cli-linux-arm64": "2.105.0",
+ "@supabase/cli-linux-arm64-musl": "2.105.0",
+ "@supabase/cli-linux-x64": "2.105.0",
+ "@supabase/cli-linux-x64-musl": "2.105.0",
+ "@supabase/cli-windows-arm64": "2.105.0",
+ "@supabase/cli-windows-x64": "2.105.0"
+ }
+ },
"node_modules/supports-color": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
diff --git a/package.json b/package.json
index 656edad..9b276de 100644
--- a/package.json
+++ b/package.json
@@ -4,7 +4,7 @@
"private": true,
"scripts": {
"dev": "concurrently --names \"API,WEB\" --prefix-colors \"green,cyan\" \"npm run dev:api\" \"npm run dev:web\"",
- "dev:api": "cd backend && uvicorn main:app --reload --port 8000",
+ "dev:api": "backend\\venv\\Scripts\\uvicorn.exe main:app --reload --port 8000 --app-dir backend",
"dev:web": "cd frontend && npm run dev",
"install:frontend": "npm install --prefix frontend",
"build": "npm run build --prefix frontend",
@@ -12,6 +12,7 @@
"prepare": "husky"
},
"devDependencies": {
+ "supabase": "^2.105.0",
"@commitlint/cli": "^21.0.2",
"@commitlint/config-conventional": "^21.0.2",
"concurrently": "^8.2.2",