# Plantes & Variétés — Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Restructurer la gestion des plantes en 2 tables (`plant` + `plant_variety`), migrer les données existantes, importer les JSON graines/arbustre, et ajouter le bouton "➕ Variété" dans l'UI. **Architecture:** Migration SQLite one-shot (plant_variety créée, données migrées, haricot grimpant fusionné), nouveaux endpoints CRUD variétés, PlantesView mise à jour avec popup variété + photos sachet. **Tech Stack:** FastAPI + SQLModel + SQLite · Vue 3 + Pinia + Tailwind CSS Gruvbox Dark --- ### Task 1 : Modèles SQLModel — Plant + PlantVariety **Files:** - Modify: `backend/app/models/plant.py` - Modify: `backend/app/models/__init__.py` **Step 1: Remplacer le contenu de `backend/app/models/plant.py`** ```python # backend/app/models/plant.py from datetime import datetime, timezone from typing import List, Optional from sqlalchemy import Column from sqlalchemy import JSON as SA_JSON from sqlmodel import Field, SQLModel class Plant(SQLModel, table=True): __tablename__ = "plant" id: Optional[int] = Field(default=None, primary_key=True) nom_commun: str nom_botanique: Optional[str] = None famille: Optional[str] = None type_plante: Optional[str] = None categorie: Optional[str] = None # potager|fleur|arbre|arbuste besoin_eau: Optional[str] = None # faible | moyen | fort besoin_soleil: Optional[str] = None espacement_cm: Optional[int] = None hauteur_cm: Optional[int] = None temp_min_c: Optional[float] = None temp_germination: Optional[str] = None # ex: "8-10°C" temps_levee_j: Optional[str] = None # ex: "15-20 jours" duree_culture_j: Optional[int] = None profondeur_semis_cm: Optional[float] = None sol_conseille: Optional[str] = None semis_interieur_mois: Optional[str] = None # CSV ex: "2,3" semis_exterieur_mois: Optional[str] = None repiquage_mois: Optional[str] = None plantation_mois: Optional[str] = None recolte_mois: Optional[str] = None maladies_courantes: Optional[str] = None astuces_culture: Optional[str] = None url_reference: Optional[str] = None notes: Optional[str] = None associations_favorables: Optional[List[str]] = Field( default=None, sa_column=Column("associations_favorables", SA_JSON, nullable=True), ) associations_defavorables: Optional[List[str]] = Field( default=None, sa_column=Column("associations_defavorables", SA_JSON, nullable=True), ) created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) class PlantVariety(SQLModel, table=True): __tablename__ = "plant_variety" id: Optional[int] = Field(default=None, primary_key=True) plant_id: int = Field(foreign_key="plant.id", index=True) variete: Optional[str] = None tags: Optional[str] = None notes_variete: Optional[str] = None boutique_nom: Optional[str] = None boutique_url: Optional[str] = None prix_achat: Optional[float] = None date_achat: Optional[str] = None poids: Optional[str] = None dluo: Optional[str] = None created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) class PlantWithVarieties(SQLModel): """Schéma de réponse API : plant + ses variétés (non persisté).""" id: Optional[int] = None nom_commun: str nom_botanique: Optional[str] = None famille: Optional[str] = None type_plante: Optional[str] = None categorie: Optional[str] = None besoin_eau: Optional[str] = None besoin_soleil: Optional[str] = None espacement_cm: Optional[int] = None hauteur_cm: Optional[int] = None temp_min_c: Optional[float] = None temp_germination: Optional[str] = None temps_levee_j: Optional[str] = None duree_culture_j: Optional[int] = None profondeur_semis_cm: Optional[float] = None sol_conseille: Optional[str] = None semis_interieur_mois: Optional[str] = None semis_exterieur_mois: Optional[str] = None repiquage_mois: Optional[str] = None plantation_mois: Optional[str] = None recolte_mois: Optional[str] = None maladies_courantes: Optional[str] = None astuces_culture: Optional[str] = None url_reference: Optional[str] = None notes: Optional[str] = None associations_favorables: Optional[List[str]] = None associations_defavorables: Optional[List[str]] = None varieties: List[PlantVariety] = [] class PlantImage(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) plant_id: int = Field(foreign_key="plant.id", index=True) filename: str caption: Optional[str] = None created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) ``` **Step 2: Ajouter dans `backend/app/models/__init__.py`** Lire le fichier, ajouter à la fin : ```python from app.models.plant import PlantVariety, PlantWithVarieties # noqa ``` **Step 3: Vérifier l'import** ```bash cd backend && python3 -c "from app.models.plant import Plant, PlantVariety, PlantWithVarieties; print('OK')" ``` Expected: `OK` **Step 4: Commit** ```bash git add backend/app/models/plant.py backend/app/models/__init__.py git commit -m "feat(plantes): modèle Plant épuré + PlantVariety + PlantWithVarieties" ``` --- ### Task 2 : Script migration BDD one-shot **Files:** - Create: `backend/scripts/migrate_plant_varieties.py` **Step 1: Créer le script** ```python #!/usr/bin/env python3 """ Migration one-shot : crée plant_variety, migre données existantes, fusionne haricot grimpant. À exécuter UNE SEULE FOIS depuis la racine du projet. Usage: cd /chemin/projet && python3 backend/scripts/migrate_plant_varieties.py """ import sqlite3 from datetime import datetime, timezone from pathlib import Path DB_PATH = Path("data/jardin.db") def run(): conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row # 1. Créer plant_variety conn.execute(""" CREATE TABLE IF NOT EXISTS plant_variety ( id INTEGER PRIMARY KEY AUTOINCREMENT, plant_id INTEGER NOT NULL REFERENCES plant(id) ON DELETE CASCADE, variete TEXT, tags TEXT, notes_variete TEXT, boutique_nom TEXT, boutique_url TEXT, prix_achat REAL, date_achat TEXT, poids TEXT, dluo TEXT, created_at TEXT DEFAULT (datetime('now')) ) """) print("✓ Table plant_variety créée") # 2. Ajouter colonnes manquantes à plant existing = [r[1] for r in conn.execute("PRAGMA table_info(plant)").fetchall()] for col, typ in [("temp_germination", "TEXT"), ("temps_levee_j", "TEXT")]: if col not in existing: conn.execute(f"ALTER TABLE plant ADD COLUMN {col} {typ}") print(f"✓ Colonne {col} ajoutée à plant") # 3. Vérifier si déjà migré count = conn.execute("SELECT COUNT(*) FROM plant_variety").fetchone()[0] if count > 0: print(f"⚠️ Migration déjà effectuée ({count} variétés). Abandon.") conn.close() return # 4. Migrer chaque plante → plant_variety plants = conn.execute( "SELECT id, nom_commun, variete, tags, boutique_nom, boutique_url, " "prix_achat, date_achat, poids, dluo FROM plant" ).fetchall() for p in plants: conn.execute(""" INSERT INTO plant_variety (plant_id, variete, tags, boutique_nom, boutique_url, prix_achat, date_achat, poids, dluo, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( p["id"], p["variete"], p["tags"], p["boutique_nom"], p["boutique_url"], p["prix_achat"], p["date_achat"], p["poids"], p["dluo"], datetime.now(timezone.utc).isoformat(), )) print(f" → plant id={p['id']} {p['nom_commun']} : variété '{p['variete']}'") # 5. Fusionner haricot grimpant (id=21) sous Haricot (id=7) hg = conn.execute("SELECT * FROM plant WHERE id = 21").fetchone() if hg: # Supprimer la plant_variety créée pour id=21 (on va la recréer sous id=7) conn.execute("DELETE FROM plant_variety WHERE plant_id = 21") # Créer variété sous Haricot (id=7) conn.execute(""" INSERT INTO plant_variety (plant_id, variete, notes_variete, created_at) VALUES (7, 'Grimpant Neckarkönigin', 'Fusionné depuis haricot grimpant', ?) """, (datetime.now(timezone.utc).isoformat(),)) new_vid = conn.execute("SELECT last_insert_rowid()").fetchone()[0] print(f" → haricot grimpant fusionné sous Haricot (plant_variety id={new_vid})") # Supprimer le plant haricot grimpant conn.execute("DELETE FROM plant WHERE id = 21") print(" → plant id=21 (haricot grimpant) supprimé") conn.commit() conn.close() print("\nMigration terminée avec succès.") if __name__ == "__main__": run() ``` **Step 2: Créer le dossier scripts si absent** ```bash mkdir -p backend/scripts && touch backend/scripts/__init__.py ``` **Step 3: Exécuter depuis la racine du projet** ```bash python3 backend/scripts/migrate_plant_varieties.py ``` Expected: lignes `→ plant id=X ...` pour chaque plante + `Migration terminée avec succès.` **Step 4: Vérifier en BDD** ```bash python3 -c " import sqlite3 conn = sqlite3.connect('data/jardin.db') n = conn.execute('SELECT COUNT(*) FROM plant_variety').fetchone()[0] print(f'plant_variety: {n} entrées') # Haricot doit avoir 2 variétés rows = conn.execute('SELECT v.variete FROM plant_variety v JOIN plant p ON v.plant_id=p.id WHERE p.nom_commun=\"Haricot\"').fetchall() print('Haricot variétés:', [r[0] for r in rows]) conn.close() " ``` Expected: `plant_variety: 21 entrées` + `Haricot variétés: ['Nain', 'Grimpant Neckarkönigin']` **Step 5: Commit** ```bash git add backend/scripts/ git commit -m "feat(plantes): script migration one-shot plant_variety + fusion haricot grimpant" ``` --- ### Task 3 : Mettre à jour migrate.py **Files:** - Modify: `backend/app/migrate.py` **Step 1: Ajouter dans la section `"plant"` de `EXPECTED_COLUMNS`** Après `("dluo", "TEXT", None),`, ajouter : ```python ("temp_germination", "TEXT", None), ("temps_levee_j", "TEXT", None), ``` **Step 2: Ajouter la section `"plant_variety"` après `"plant"`** ```python "plant_variety": [ ("variete", "TEXT", None), ("tags", "TEXT", None), ("notes_variete", "TEXT", None), ("boutique_nom", "TEXT", None), ("boutique_url", "TEXT", None), ("prix_achat", "REAL", None), ("date_achat", "TEXT", None), ("poids", "TEXT", None), ("dluo", "TEXT", None), ], ``` **Step 3: Commit** ```bash git add backend/app/migrate.py git commit -m "feat(plantes): migrate.py — sections plant_variety + temp_germination" ``` --- ### Task 4 : Router plants.py — GET avec varieties + CRUD variétés **Files:** - Modify: `backend/app/routers/plants.py` **Step 1: Remplacer le contenu** ```python # backend/app/routers/plants.py from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlmodel import Session, select from app.database import get_session from app.models.plant import Plant, PlantVariety, PlantWithVarieties router = APIRouter(tags=["plantes"]) def _with_varieties(p: Plant, session: Session) -> PlantWithVarieties: varieties = session.exec( select(PlantVariety).where(PlantVariety.plant_id == p.id) ).all() data = p.model_dump() data["varieties"] = [v.model_dump() for v in varieties] return PlantWithVarieties(**data) @router.get("/plants", response_model=List[PlantWithVarieties]) def list_plants( categorie: Optional[str] = Query(None), session: Session = Depends(get_session), ): q = select(Plant).order_by(Plant.nom_commun, Plant.id) if categorie: q = q.where(Plant.categorie == categorie) return [_with_varieties(p, session) for p in session.exec(q).all()] @router.post("/plants", response_model=PlantWithVarieties, status_code=status.HTTP_201_CREATED) def create_plant(p: Plant, session: Session = Depends(get_session)): session.add(p) session.commit() session.refresh(p) return _with_varieties(p, session) @router.get("/plants/{id}", response_model=PlantWithVarieties) def get_plant(id: int, session: Session = Depends(get_session)): p = session.get(Plant, id) if not p: raise HTTPException(404, "Plante introuvable") return _with_varieties(p, session) @router.put("/plants/{id}", response_model=PlantWithVarieties) def update_plant(id: int, data: Plant, session: Session = Depends(get_session)): p = session.get(Plant, id) if not p: raise HTTPException(404, "Plante introuvable") for k, v in data.model_dump(exclude_unset=True, exclude={"id", "created_at"}).items(): setattr(p, k, v) session.add(p) session.commit() session.refresh(p) return _with_varieties(p, session) @router.delete("/plants/{id}", status_code=status.HTTP_204_NO_CONTENT) def delete_plant(id: int, session: Session = Depends(get_session)): p = session.get(Plant, id) if not p: raise HTTPException(404, "Plante introuvable") for v in session.exec(select(PlantVariety).where(PlantVariety.plant_id == id)).all(): session.delete(v) session.delete(p) session.commit() # ---- CRUD Variétés ---- @router.get("/plants/{id}/varieties", response_model=List[PlantVariety]) def list_varieties(id: int, session: Session = Depends(get_session)): if not session.get(Plant, id): raise HTTPException(404, "Plante introuvable") return session.exec(select(PlantVariety).where(PlantVariety.plant_id == id)).all() @router.post("/plants/{id}/varieties", response_model=PlantVariety, status_code=status.HTTP_201_CREATED) def create_variety(id: int, v: PlantVariety, session: Session = Depends(get_session)): if not session.get(Plant, id): raise HTTPException(404, "Plante introuvable") v.plant_id = id session.add(v) session.commit() session.refresh(v) return v @router.put("/plants/{id}/varieties/{vid}", response_model=PlantVariety) def update_variety(id: int, vid: int, data: PlantVariety, session: Session = Depends(get_session)): v = session.get(PlantVariety, vid) if not v or v.plant_id != id: raise HTTPException(404, "Variété introuvable") for k, val in data.model_dump(exclude_unset=True, exclude={"id", "plant_id", "created_at"}).items(): setattr(v, k, val) session.add(v) session.commit() session.refresh(v) return v @router.delete("/plants/{id}/varieties/{vid}", status_code=status.HTTP_204_NO_CONTENT) def delete_variety(id: int, vid: int, session: Session = Depends(get_session)): v = session.get(PlantVariety, vid) if not v or v.plant_id != id: raise HTTPException(404, "Variété introuvable") session.delete(v) session.commit() ``` **Step 2: Vérifier l'import du router** ```bash cd backend && python3 -c "from app.routers.plants import router; print('OK')" ``` Expected: `OK` **Step 3: Test rapide API (backend lancé)** ```bash cd backend && uvicorn app.main:app --reload --port 8060 & sleep 2 curl -s http://localhost:8060/api/plants | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'{len(d)} plants, premier: {d[0][\"nom_commun\"]} ({len(d[0][\"varieties\"])} variétés)')" # Expected: "X plants, premier: Ail (1 variétés)" kill %1 ``` **Step 4: Commit** ```bash git add backend/app/routers/plants.py git commit -m "feat(plantes): router plants — GET retourne varieties + CRUD /varieties" ``` --- ### Task 5 : Script import graines + arbustre **Files:** - Create: `backend/scripts/import_graines.py` **Step 1: Créer le script** ```python #!/usr/bin/env python3 """ Import one-shot : docs/graine/caracteristiques_plantation.json + docs/arbustre/caracteristiques_arbustre.json Usage: cd /chemin/projet && python3 backend/scripts/import_graines.py """ import json import shutil import sqlite3 from datetime import datetime, timezone from pathlib import Path DB_PATH = Path("data/jardin.db") UPLOADS_DIR = Path("data/uploads") GRAINE_DIR = Path("docs/graine") ARBUSTRE_DIR = Path("docs/arbustre") ROMAN = {"I": 1, "II": 2, "III": 3, "IV": 4, "V": 5, "VI": 6, "VII": 7, "VIII": 8, "IX": 9, "X": 10, "XI": 11, "XII": 12} # Mapping : mot-clé lowercase → nom_commun BDD NOM_MAP = [ ("oignon", "Oignon"), ("laitue pommee grosse", "Laitue"), ("laitue attraction", "Laitue"), ("laitue", "Laitue"), ("persil", "Persil"), ("courgette", "Courgette"), ("pois mangetout", "Pois"), ("pois a ecosser", "Pois"), ("pois", "Pois"), ("tomate cornue", "Tomate"), ("tomates moneymaker", "Tomate"), ("tomate", "Tomate"), ("poireau", "Poireau"), ("echalion", "Échalote"), ("courge", "Courge"), ("chou pomme", "Chou"), ("chou-fleur", "Chou-fleur"), ] def roman_to_csv(s: str) -> str: if not s: return "" s = s.strip() parts = s.split("-") if len(parts) == 2: a = ROMAN.get(parts[0].strip(), 0) b = ROMAN.get(parts[1].strip(), 0) if a and b: return ",".join(str(m) for m in range(a, b + 1)) single = ROMAN.get(s, 0) return str(single) if single else "" def extract_float(s: str) -> float | None: try: return float(s.split()[0].replace(",", ".")) except Exception: return None def find_or_create_plant(conn: sqlite3.Connection, nom_commun: str, categorie: str = "potager") -> int: row = conn.execute( "SELECT id FROM plant WHERE LOWER(nom_commun) = LOWER(?)", (nom_commun,) ).fetchone() if row: return row[0] conn.execute( "INSERT INTO plant (nom_commun, categorie, created_at) VALUES (?, ?, ?)", (nom_commun, categorie, datetime.now(timezone.utc).isoformat()), ) return conn.execute("SELECT last_insert_rowid()").fetchone()[0] def copy_image(src: Path, variety_id: int, conn: sqlite3.Connection) -> None: if not src.exists(): print(f" ⚠️ Image absente: {src}") return UPLOADS_DIR.mkdir(parents=True, exist_ok=True) dest_name = f"pv_{variety_id}_{src.name}" shutil.copy2(src, UPLOADS_DIR / dest_name) conn.execute(""" INSERT INTO media (entity_type, entity_id, filename, original_filename, mime_type, created_at) VALUES ('plant_variety', ?, ?, ?, 'image/jpeg', ?) """, (variety_id, dest_name, src.name, datetime.now(timezone.utc).isoformat())) def resolve_nom(full_name: str) -> tuple[str, str]: """Retourne (nom_commun, variete) depuis le nom complet du sachet.""" lower = full_name.lower() for key, val in NOM_MAP: if lower.startswith(key): variete = full_name[len(key):].strip().strip("'\"").title() return val, variete or full_name # Fallback : premier mot = nom_commun parts = full_name.split() return parts[0].title(), " ".join(parts[1:]).strip() or full_name def import_graines(conn: sqlite3.Connection) -> None: path = GRAINE_DIR / "caracteristiques_plantation.json" if not path.exists(): print(f"⚠️ Fichier absent: {path}") return data = json.loads(path.read_text(encoding="utf-8")) for entry in data["plantes"]: full_name = entry["plante"] nom_commun, variete_name = resolve_nom(full_name) carac = entry.get("caracteristiques_plantation", {}) detail = entry.get("detail", {}).get("texte_integral_visible", {}) plant_id = find_or_create_plant(conn, nom_commun) # Enrichir plant (ne pas écraser si déjà rempli) updates: dict[str, object] = {} semis = roman_to_csv(carac.get("periode_semis", "")) recolte = roman_to_csv(carac.get("periode_recolte", "")) profondeur = extract_float(carac.get("profondeur", "")) espacement = extract_float(carac.get("espacement", "")) if semis: updates["semis_exterieur_mois"] = semis if recolte: updates["recolte_mois"] = recolte if profondeur: updates["profondeur_semis_cm"] = profondeur if espacement: updates["espacement_cm"] = int(espacement) if carac.get("exposition"): updates["besoin_soleil"] = carac["exposition"] if carac.get("temperature"): updates["temp_germination"] = carac["temperature"] if detail.get("arriere"): updates["astuces_culture"] = detail["arriere"][:1000] if updates: set_clause = ", ".join(f"{k} = ?" for k in updates) conn.execute( f"UPDATE plant SET {set_clause} WHERE id = ?", (*updates.values(), plant_id), ) # Créer plant_variety conn.execute( "INSERT INTO plant_variety (plant_id, variete, created_at) VALUES (?, ?, ?)", (plant_id, variete_name, datetime.now(timezone.utc).isoformat()), ) vid = conn.execute("SELECT last_insert_rowid()").fetchone()[0] for img in entry.get("images", []): copy_image(GRAINE_DIR / img, vid, conn) print(f" ✓ {nom_commun} — {variete_name}") def import_arbustre(conn: sqlite3.Connection) -> None: path = ARBUSTRE_DIR / "caracteristiques_arbustre.json" if not path.exists(): print(f"⚠️ Fichier absent: {path}") return data = json.loads(path.read_text(encoding="utf-8")) for entry in data["plantes"]: full_name = entry["plante"] # Extraire nom commun depuis nom_latin ou nom plante nom_latin = entry.get("nom_latin", "") if "Vitis" in full_name: nom_commun = "Vigne" elif nom_latin: nom_commun = nom_latin.split()[0].title() + " " + nom_latin.split()[1].title() if len(nom_latin.split()) > 1 else nom_latin.title() else: nom_commun = full_name.split("'")[0].strip().title() variete_name = full_name.split("'")[1].strip() if "'" in full_name else full_name plant_id = find_or_create_plant(conn, nom_commun, "arbuste") carac = entry.get("caracteristiques_plantation", {}) if carac.get("arrosage"): conn.execute("UPDATE plant SET besoin_eau = ? WHERE id = ?", (carac["arrosage"], plant_id)) if carac.get("exposition"): conn.execute("UPDATE plant SET besoin_soleil = ? WHERE id = ?", (carac["exposition"], plant_id)) conn.execute( "INSERT INTO plant_variety (plant_id, variete, created_at) VALUES (?, ?, ?)", (plant_id, variete_name, datetime.now(timezone.utc).isoformat()), ) vid = conn.execute("SELECT last_insert_rowid()").fetchone()[0] for img in entry.get("images", []): copy_image(ARBUSTRE_DIR / img, vid, conn) print(f" ✓ {nom_commun} — {variete_name}") def run() -> None: UPLOADS_DIR.mkdir(parents=True, exist_ok=True) conn = sqlite3.connect(DB_PATH) tables = [r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()] if "plant_variety" not in tables: print("⚠️ Exécutez d'abord migrate_plant_varieties.py") conn.close() return # Vérifier colonne media media_cols = [r[1] for r in conn.execute("PRAGMA table_info(media)").fetchall()] if "original_filename" not in media_cols: conn.execute("ALTER TABLE media ADD COLUMN original_filename TEXT") print("=== Import graines ===") import_graines(conn) print("\n=== Import arbustre ===") import_arbustre(conn) conn.commit() conn.close() print("\nImport terminé.") if __name__ == "__main__": run() ``` **Step 2: Exécuter depuis la racine du projet** ```bash python3 backend/scripts/import_graines.py ``` Expected: liste de `✓ NomCommun — Variete` + `Import terminé.` **Step 3: Vérifier** ```bash python3 -c " import sqlite3 conn = sqlite3.connect('data/jardin.db') n = conn.execute('SELECT COUNT(*) FROM plant_variety').fetchone()[0] np = conn.execute('SELECT COUNT(*) FROM plant').fetchone()[0] print(f'plant: {np}, plant_variety: {n}') conn.close() " ``` Expected: `plant: ~25, plant_variety: ~35+` **Step 4: Commit** ```bash git add backend/scripts/import_graines.py git commit -m "feat(plantes): script import graines + arbustre (JSON → plant_variety)" ``` --- ### Task 6 : Frontend API plants.ts **Files:** - Modify: `frontend/src/api/plants.ts` **Step 1: Remplacer le contenu** ```typescript // frontend/src/api/plants.ts import client from './client' export interface PlantVariety { id?: number plant_id?: number variete?: string tags?: string notes_variete?: string boutique_nom?: string boutique_url?: string prix_achat?: number date_achat?: string poids?: string dluo?: string } export interface Plant { id?: number nom_commun: string nom_botanique?: string famille?: string categorie?: string // potager|fleur|arbre|arbuste type_plante?: string besoin_eau?: string besoin_soleil?: string espacement_cm?: number temp_min_c?: number hauteur_cm?: number temp_germination?: string // ex: "8-10°C" temps_levee_j?: string // ex: "15-20 jours" plantation_mois?: string recolte_mois?: string semis_interieur_mois?: string semis_exterieur_mois?: string repiquage_mois?: string profondeur_semis_cm?: number duree_culture_j?: number sol_conseille?: string maladies_courantes?: string astuces_culture?: string url_reference?: string notes?: string associations_favorables?: string[] associations_defavorables?: string[] varieties?: PlantVariety[] } export const plantsApi = { list: (categorie?: string) => client.get('/api/plants', { params: categorie ? { categorie } : {} }).then(r => r.data), get: (id: number) => client.get(`/api/plants/${id}`).then(r => r.data), create: (p: Partial) => client.post('/api/plants', p).then(r => r.data), update: (id: number, p: Partial) => client.put(`/api/plants/${id}`, p).then(r => r.data), delete: (id: number) => client.delete(`/api/plants/${id}`), // Variétés createVariety: (plantId: number, v: Partial) => client.post(`/api/plants/${plantId}/varieties`, v).then(r => r.data), updateVariety: (plantId: number, vid: number, v: Partial) => client.put(`/api/plants/${plantId}/varieties/${vid}`, v).then(r => r.data), deleteVariety: (plantId: number, vid: number) => client.delete(`/api/plants/${plantId}/varieties/${vid}`), } ``` **Step 2: Vérifier build TypeScript** ```bash cd frontend && npm run build 2>&1 | tail -5 ``` Expected: `✓ built in X.XXs` **Step 3: Commit** ```bash git add frontend/src/api/plants.ts git commit -m "feat(plantes): API plants.ts — Plant + PlantVariety + endpoints varieties" ``` --- ### Task 7 : Store Pinia plants.ts **Files:** - Modify: `frontend/src/stores/plants.ts` **Step 1: Remplacer le contenu** ```typescript // frontend/src/stores/plants.ts import { defineStore } from 'pinia' import { ref } from 'vue' import { plantsApi, type Plant, type PlantVariety } from '@/api/plants' export const usePlantsStore = defineStore('plants', () => { const plants = ref([]) const loading = ref(false) async function fetchAll(categorie?: string) { loading.value = true try { plants.value = await plantsApi.list(categorie) } finally { loading.value = false } } async function create(p: Partial) { const created = await plantsApi.create(p) plants.value.push(created) return created } async function update(id: number, p: Partial) { const updated = await plantsApi.update(id, p) const idx = plants.value.findIndex(x => x.id === id) if (idx !== -1) plants.value[idx] = updated return updated } async function remove(id: number) { await plantsApi.delete(id) plants.value = plants.value.filter(p => p.id !== id) } async function createVariety(plantId: number, v: Partial) { const created = await plantsApi.createVariety(plantId, v) const plant = plants.value.find(p => p.id === plantId) if (plant) { if (!plant.varieties) plant.varieties = [] plant.varieties.push(created) } return created } async function updateVariety(plantId: number, vid: number, v: Partial) { const updated = await plantsApi.updateVariety(plantId, vid, v) const plant = plants.value.find(p => p.id === plantId) if (plant?.varieties) { const idx = plant.varieties.findIndex(x => x.id === vid) if (idx !== -1) plant.varieties[idx] = updated } return updated } async function removeVariety(plantId: number, vid: number) { await plantsApi.deleteVariety(plantId, vid) const plant = plants.value.find(p => p.id === plantId) if (plant?.varieties) { plant.varieties = plant.varieties.filter(v => v.id !== vid) } } return { plants, loading, fetchAll, create, update, remove, createVariety, updateVariety, removeVariety } }) ``` **Step 2: Vérifier build** ```bash cd frontend && npm run build 2>&1 | tail -5 ``` Expected: `✓ built in X.XXs` **Step 3: Commit** ```bash git add frontend/src/stores/plants.ts git commit -m "feat(plantes): store plants — actions variety CRUD" ``` --- ### Task 8 : PlantesView.vue — bouton variété + popup + nouveaux champs **Files:** - Modify: `frontend/src/views/PlantesView.vue` Cette tâche modifie `PlantesView.vue` en plusieurs points précis. Lire le fichier entier d'abord. **Step 1: Lire le fichier** ```bash wc -l frontend/src/views/PlantesView.vue ``` **Step 2: Adapter le script setup — imports et refs** Dans le `