1171 lines
40 KiB
Markdown
1171 lines
40 KiB
Markdown
# 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<Plant[]>('/api/plants', { params: categorie ? { categorie } : {} }).then(r => r.data),
|
||
get: (id: number) => client.get<Plant>(`/api/plants/${id}`).then(r => r.data),
|
||
create: (p: Partial<Plant>) => client.post<Plant>('/api/plants', p).then(r => r.data),
|
||
update: (id: number, p: Partial<Plant>) => client.put<Plant>(`/api/plants/${id}`, p).then(r => r.data),
|
||
delete: (id: number) => client.delete(`/api/plants/${id}`),
|
||
// Variétés
|
||
createVariety: (plantId: number, v: Partial<PlantVariety>) =>
|
||
client.post<PlantVariety>(`/api/plants/${plantId}/varieties`, v).then(r => r.data),
|
||
updateVariety: (plantId: number, vid: number, v: Partial<PlantVariety>) =>
|
||
client.put<PlantVariety>(`/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<Plant[]>([])
|
||
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<Plant>) {
|
||
const created = await plantsApi.create(p)
|
||
plants.value.push(created)
|
||
return created
|
||
}
|
||
|
||
async function update(id: number, p: Partial<Plant>) {
|
||
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<PlantVariety>) {
|
||
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<PlantVariety>) {
|
||
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 `<script setup lang="ts">`, apporter ces changements :
|
||
|
||
**a) Mettre à jour l'import API** — remplacer `import type { Plant } from '@/api/plants'` par :
|
||
```typescript
|
||
import type { Plant, PlantVariety } from '@/api/plants'
|
||
```
|
||
|
||
**b) Les `detailVarieties` viennent maintenant de `plant.varieties[]`**
|
||
|
||
Remplacer la computed `detailVarieties` (qui groupait par nom_commun) par :
|
||
```typescript
|
||
// detailVarieties : charger depuis plant.varieties de la plante affichée
|
||
const detailVarieties = computed<PlantVariety[]>(() => {
|
||
if (!detailPlantObj.value) return []
|
||
return detailPlantObj.value.varieties ?? []
|
||
})
|
||
```
|
||
|
||
Et ajouter `detailPlantObj` comme ref pointant sur la plante `Plant` affichée :
|
||
```typescript
|
||
const detailPlantObj = ref<Plant | null>(null)
|
||
```
|
||
|
||
Dans `openDetail(plant)` : assigner `detailPlantObj.value = plant`
|
||
|
||
**c) Ajouter les refs pour le popup variété**
|
||
```typescript
|
||
const showFormVariety = ref(false)
|
||
const editVariety = ref<PlantVariety | null>(null)
|
||
const formVariety = reactive<Partial<PlantVariety>>({
|
||
variete: '', tags: '', notes_variete: '',
|
||
boutique_nom: '', boutique_url: '', prix_achat: undefined,
|
||
date_achat: '', poids: '', dluo: '',
|
||
})
|
||
```
|
||
|
||
**d) Ajouter les fonctions variété**
|
||
```typescript
|
||
function openAddVariety() {
|
||
if (!detailPlantObj.value) return
|
||
editVariety.value = null
|
||
Object.assign(formVariety, {
|
||
variete: '', tags: '', notes_variete: '',
|
||
boutique_nom: '', boutique_url: '', prix_achat: undefined,
|
||
date_achat: '', poids: '', dluo: '',
|
||
})
|
||
showFormVariety.value = true
|
||
}
|
||
|
||
function openEditVariety(v: PlantVariety) {
|
||
editVariety.value = v
|
||
Object.assign(formVariety, { ...v })
|
||
showFormVariety.value = true
|
||
}
|
||
|
||
function closeFormVariety() {
|
||
showFormVariety.value = false
|
||
editVariety.value = null
|
||
}
|
||
|
||
async function submitVariety() {
|
||
if (!detailPlantObj.value?.id) return
|
||
const payload = { ...formVariety, prix_achat: formVariety.prix_achat ?? undefined }
|
||
if (editVariety.value?.id) {
|
||
await plantsStore.updateVariety(detailPlantObj.value.id, editVariety.value.id, payload)
|
||
toast.success('Variété modifiée')
|
||
} else {
|
||
await plantsStore.createVariety(detailPlantObj.value.id, payload)
|
||
toast.success('Variété ajoutée')
|
||
}
|
||
closeFormVariety()
|
||
}
|
||
|
||
async function deleteVariety(vid: number) {
|
||
if (!detailPlantObj.value?.id) return
|
||
if (!confirm('Supprimer cette variété ?')) return
|
||
await plantsStore.removeVariety(detailPlantObj.value.id, vid)
|
||
toast.success('Variété supprimée')
|
||
}
|
||
```
|
||
|
||
**Step 3: Adapter le template — popup détail**
|
||
|
||
Dans la popup de détail de la plante (header du popup), ajouter le bouton **"➕ Variété"** à gauche du bouton "Modifier" existant :
|
||
|
||
```html
|
||
<!-- Dans les boutons du footer de la popup détail -->
|
||
<button @click="openAddVariety"
|
||
class="btn-primary !bg-green !text-bg py-2 px-4 font-black uppercase text-xs flex items-center gap-1">
|
||
➕ Variété
|
||
</button>
|
||
```
|
||
|
||
**Step 4: Ajouter l'affichage des champs temp_germination + temps_levee_j**
|
||
|
||
Dans la section "Caractéristiques culture" du popup détail, après les mois de semis, ajouter :
|
||
|
||
```html
|
||
<div v-if="detailPlantObj?.temp_germination" class="bg-bg/30 p-3 rounded-xl border border-bg-soft">
|
||
<span class="text-[9px] font-black text-text-muted uppercase block mb-0.5">T° germination</span>
|
||
<span class="text-text text-sm">{{ detailPlantObj.temp_germination }}</span>
|
||
</div>
|
||
<div v-if="detailPlantObj?.temps_levee_j" class="bg-bg/30 p-3 rounded-xl border border-bg-soft">
|
||
<span class="text-[9px] font-black text-text-muted uppercase block mb-0.5">Temps de levée</span>
|
||
<span class="text-text text-sm">{{ detailPlantObj.temps_levee_j }}</span>
|
||
</div>
|
||
```
|
||
|
||
**Step 5: Ajouter la liste des variétés dans le popup détail**
|
||
|
||
Après la section associations, ajouter un bloc variétés :
|
||
|
||
```html
|
||
<!-- Variétés -->
|
||
<div v-if="detailVarieties.length" class="space-y-2 mt-4">
|
||
<h3 class="text-[9px] font-black text-text-muted uppercase tracking-widest">
|
||
🌿 Variétés ({{ detailVarieties.length }})
|
||
</h3>
|
||
<div class="space-y-1">
|
||
<div v-for="v in detailVarieties" :key="v.id"
|
||
class="flex items-center justify-between bg-bg/30 px-3 py-2 rounded-lg border border-bg-soft">
|
||
<div>
|
||
<span class="text-text text-sm font-bold">{{ v.variete || '(sans nom)' }}</span>
|
||
<span v-if="v.boutique_nom" class="text-text-muted text-xs ml-2">🛒 {{ v.boutique_nom }}</span>
|
||
<span v-if="v.prix_achat" class="text-yellow text-xs ml-2">{{ v.prix_achat.toFixed(2) }}€</span>
|
||
<span v-if="v.dluo && isDluoExpired(v.dluo)" class="text-red text-xs ml-1">⚠️ DLUO</span>
|
||
</div>
|
||
<div class="flex gap-1">
|
||
<button @click="openEditVariety(v)" class="text-text-muted hover:text-yellow text-xs px-2">✏️</button>
|
||
<button @click="deleteVariety(v.id!)" class="text-text-muted hover:text-red text-xs px-2">✕</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
```
|
||
|
||
**Step 6: Ajouter le popup formulaire variété**
|
||
|
||
Ajouter après le popup formulaire plante (avant la fermeture `</div>` du root) :
|
||
|
||
```html
|
||
<!-- ====== POPUP FORMULAIRE VARIÉTÉ ====== -->
|
||
<div v-if="showFormVariety" class="fixed inset-0 bg-black/80 backdrop-blur-sm z-[70] flex items-center justify-center p-4" @click.self="closeFormVariety">
|
||
<div class="bg-bg-hard rounded-3xl p-6 w-full max-w-xl border border-bg-soft shadow-2xl max-h-[90vh] overflow-y-auto">
|
||
<div class="flex items-center justify-between mb-5 border-b border-bg-soft pb-4">
|
||
<h2 class="text-text font-black text-xl uppercase">
|
||
{{ editVariety ? 'Modifier la variété' : '➕ Nouvelle variété' }}
|
||
</h2>
|
||
<button @click="closeFormVariety" class="text-text-muted hover:text-red text-2xl">✕</button>
|
||
</div>
|
||
<p class="text-text-muted text-xs mb-4 italic">
|
||
Plante : <span class="text-yellow font-bold">{{ detailPlantObj?.nom_commun }}</span>
|
||
</p>
|
||
<form @submit.prevent="submitVariety" class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<div class="md:col-span-2">
|
||
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Nom de la variété *</label>
|
||
<input v-model="formVariety.variete" required
|
||
class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm focus:border-yellow outline-none"
|
||
placeholder="Ex: Nantaise, Cornue des Andes, Moneymaker…" />
|
||
</div>
|
||
<div class="md:col-span-2">
|
||
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Tags</label>
|
||
<input v-model="formVariety.tags"
|
||
class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm focus:border-yellow outline-none"
|
||
placeholder="bio, f1, ancien, résistant…" />
|
||
</div>
|
||
<div>
|
||
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Enseigne</label>
|
||
<select v-model="formVariety.boutique_nom"
|
||
class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm outline-none focus:border-yellow appearance-none">
|
||
<option value="">— Non renseigné —</option>
|
||
<option v-for="b in BOUTIQUES" :key="b" :value="b">{{ b }}</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Prix (€)</label>
|
||
<input v-model.number="formVariety.prix_achat" type="number" step="0.01" min="0"
|
||
class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm focus:border-yellow outline-none" />
|
||
</div>
|
||
<div>
|
||
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Poids / Qté sachet</label>
|
||
<input v-model="formVariety.poids" placeholder="ex: 5g, 100 graines"
|
||
class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm focus:border-yellow outline-none" />
|
||
</div>
|
||
<div>
|
||
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Date d'achat</label>
|
||
<input v-model="formVariety.date_achat" type="date"
|
||
class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm focus:border-yellow outline-none" />
|
||
</div>
|
||
<div>
|
||
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">DLUO</label>
|
||
<input v-model="formVariety.dluo" type="date"
|
||
class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm focus:border-yellow outline-none" />
|
||
</div>
|
||
<div class="md:col-span-2">
|
||
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">URL produit</label>
|
||
<input v-model="formVariety.boutique_url" type="url" placeholder="https://…"
|
||
class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm focus:border-yellow outline-none" />
|
||
</div>
|
||
<div class="md:col-span-2">
|
||
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Notes spécifiques à cette variété</label>
|
||
<textarea v-model="formVariety.notes_variete" rows="3"
|
||
class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm focus:border-yellow outline-none resize-none" />
|
||
</div>
|
||
<div class="md:col-span-2 flex justify-between pt-4 border-t border-bg-soft">
|
||
<button type="button" @click="closeFormVariety"
|
||
class="text-text-muted hover:text-red uppercase text-xs font-bold px-6">Annuler</button>
|
||
<button type="submit"
|
||
class="btn-primary px-8 py-3 !bg-green !text-bg font-black">
|
||
{{ editVariety ? 'Sauvegarder' : 'Ajouter' }}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
```
|
||
|
||
**Step 7: Vérifier le build TypeScript**
|
||
|
||
```bash
|
||
cd frontend && npm run build 2>&1 | grep -E "error|warn|built"
|
||
```
|
||
Expected: `✓ built in X.XXs` sans erreur TypeScript.
|
||
|
||
**Step 8: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/views/PlantesView.vue
|
||
git commit -m "feat(plantes): popup variété + bouton ➕ Variété + temp_germination/temps_levee_j"
|
||
```
|
||
|
||
---
|
||
|
||
## Test manuel final
|
||
|
||
1. Lancer : `docker compose up --build`
|
||
2. Aller sur `/plantes`
|
||
3. Cliquer sur une plante → popup → bouton **"➕ Variété"** → formulaire pré-rempli
|
||
4. Créer une variété "Nantaise Bio" pour Carotte → apparaît dans la liste variétés
|
||
5. Vérifier que `temp_germination` s'affiche si renseigné (après import graines)
|
||
6. Exécuter les scripts de migration et d'import, vérifier les nouvelles plantes/variétés
|