Compare commits

..

27 Commits

Author SHA1 Message Date
2d5e5a05a2 claude 5 2026-03-09 18:19:38 +01:00
4c279c387c fix(plantes): submitPlant — créer/modifier PlantVariety lors de la soumission du formulaire plante
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 19:49:05 +01:00
149d8caa06 fix(plantes): test plant_variety + seed PlantVariety + formatPlantLabel + migrate.py nettoyage
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 19:45:52 +01:00
672ac529e7 fix(plantes): deleteVariety/submitVariety — try/catch + refresh detailPlantObj
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 19:38:06 +01:00
174ed9c25d feat(plantes): popup variété + bouton Variété + temp_germination/temps_levee_j
- Ajoute detailPlantObj (ref<Plant>) synchronisé dans openDetails/prevVariety/nextVariety/closeDetail
- Renomme detailVarieties (ref<Plant[]>) en detailPlantGroup pour la navigation par groupe de nom_commun
- Ajoute detailVarieties comme computed<PlantVariety[]> depuis detailPlantObj.value.varieties
- Ajoute refs/fonctions formulaire variété : showFormVariety, editVariety, formVariety, openAddVariety, openEditVariety, closeFormVariety, submitVariety, deleteVariety
- Bouton  Variété dans le footer du popup détail
- Liste des PlantVariety dans le popup détail (avec édition/suppression et alerte DLUO)
- Champs temp_germination et temps_levee_j dans la section caractéristiques
- Popup formulaire variété (z-[70]) avec tous les champs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 19:34:39 +01:00
05b2ddc27c feat(plantes): store plants — actions variety CRUD 2026-03-08 19:29:58 +01:00
32c7781d14 feat(plantes): API plants.ts — Plant + PlantVariety + endpoints varieties
Remplace Plant (variete/boutique/tags inline) par Plant + PlantVariety séparés.
Ajoute temp_germination, temps_levee_j, varieties[]. Ajoute CRUD variétés dans plantsApi.
Corrige PlantesView et TachesView pour lire boutique/variete via varieties?.[0].

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 19:28:19 +01:00
d4d104b2c2 fix(plantes): import_graines — idempotence plant_variety + media + import unicodedata
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 19:08:32 +01:00
0f5ebd25be feat(plantes): script import graines + arbustre (JSON → plant_variety)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 19:04:56 +01:00
1b7a8b8f25 fix(db): activer PRAGMA foreign_keys=ON pour SQLite (ON DELETE CASCADE effectif)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 18:58:09 +01:00
1095edffdb feat(plantes): router plants — GET retourne varieties + CRUD /varieties 2026-03-08 18:54:30 +01:00
8edcf5fd8d feat(plantes): migrate.py — sections plant_variety + temp_germination/temps_levee_j 2026-03-08 18:52:48 +01:00
1d4708585e fix(plantes): script migration — try/except rollback + DB_PATH absolu + commentaires IDs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 18:51:50 +01:00
18ee6e1fbe feat(plantes): script migration one-shot plant_variety + fusion haricot grimpant 2026-03-08 17:36:11 +01:00
4a7ecffbb8 fix(plantes): PlantImage __tablename__ explicite + varieties Field(default_factory=list)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 17:34:55 +01:00
b41a0f817c fix(plantes): PlantWithVarieties — ajouter created_at manquant 2026-03-08 14:11:35 +01:00
de967141ba feat(plantes): modèle Plant épuré + PlantVariety + PlantWithVarieties 2026-03-08 14:10:12 +01:00
734c33a12e docs: plan implémentation plantes/variétés — 8 tâches 2026-03-08 14:06:21 +01:00
e40351e0be docs: design plantes/variétés — Option B 2 tables + import graines 2026-03-08 14:01:42 +01:00
f8e64d6a2c feat(intrants): IntratsView with Achats + Fabrications tabs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 13:24:26 +01:00
80173171b3 feat(intrants): add /intrants route + sidebar nav 2026-03-08 13:19:11 +01:00
8bf281a3fb feat(intrants): frontend API clients + Pinia stores 2026-03-08 10:10:10 +01:00
d2f2f6d7d7 feat(intrants): register achats + fabrications routers 2026-03-08 10:09:23 +01:00
107640e561 feat(intrants): CRUD + statut router for fabrications 2026-03-08 10:08:27 +01:00
a5c503e1f3 feat(intrants): CRUD router for achats 2026-03-08 10:08:14 +01:00
75f18c9eb8 feat(intrants): add migration for achat_intrant + fabrication tables
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 10:07:28 +01:00
faa469e688 feat(intrants): add AchatIntrant + Fabrication SQLModel
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 10:05:53 +01:00
86 changed files with 6225 additions and 139 deletions

View File

@@ -12,6 +12,9 @@ plante :
- [x] plante du potager, fleur, arbre ou arbuste
- [x] liste de plantes courantes seedée : carotte, tomate, ail, oignon, haricot, petits pois, poireaux, pomme de terre, salade, fraise, framboise, persil, échalote, courgette, chou-fleur, chou boule, ...
- [x] association des plantes (favorables / défavorables) : tags noms communs, validation croisée, édition depuis popup plante
- [ ] ajouter un bouton "ajouter varieté" a gauche de modifier ce qui affiche un popup speciifque variété ou je peut saisir les champ specifique a une varieté et/ou modifier le contenu de champs de la plante "nom commun" ne supprime pas les champs et contenu de nom commun, mais se substitue. possibilite d'inserer les capture d'image du sachet de graine ( 2 photo avant et arriere) optimisiation de la taille de l'image
- [ ] analyse le dossier doc/graine et arbustre ( json et image) et ingrer une seule foisdans la bdd les élement, attention il y aura necessité de créer de nouvelle varité en fonction du nom commun. fait une selection intelligente des champ json utile dans ma base de donnée et qui concerne les caracteristiique de la varité . il y aura certainement la nencessité de rajoter des champ. verifie que les champ date de semis, repiquage, resolte soit bien present ( date => mois a cocher), verifie ensoleillement, arrosage, conseils, t° de germination, maladies, distance de semis, temps de levée. ces champs doivent aussi apparaire dans le poptup plante " nom commun" . brainstorming general pour la gestion des plantes pour une structure de donnée coherente, evolutive et efficace
- [ ] dans la base de donnée actuelle des plantes y a t il dans plan qui peuvent etre fusionner en créeant des vatiété ( ex haricot et haricot grimpant ?) analyse et propose une modiifcation de la bdd qui créer ainsi les nouvelles varietés
taches:
- [x] liste des tâches courantes au jardin pré-remplie (seed)
@@ -25,6 +28,7 @@ outils:
planning:
- [x] PlanningView : calendrier 4 semaines, tâches et plantations par jour
- [] une vue calendrier et une vue gantt 2 bouton dans le hedader pour selectionner ou calendrier ou gantt
calendrier:
- [x] renommer le header lunaire en calendrier (Météo + Lunaire + Dictons + navigation)

View File

@@ -1,9 +1,17 @@
from sqlmodel import SQLModel, create_engine, Session
from sqlalchemy import event
from app.config import DATABASE_URL
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
@event.listens_for(engine, "connect")
def set_sqlite_pragma(dbapi_connection, connection_record):
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
def get_session():
with Session(engine) as session:
yield session

View File

@@ -63,6 +63,8 @@ from app.routers import ( # noqa
lunar,
meteo,
identify,
achats,
fabrications,
)
app.include_router(gardens.router, prefix="/api")
@@ -79,6 +81,8 @@ app.include_router(recoltes.router, prefix="/api")
app.include_router(lunar.router, prefix="/api")
app.include_router(meteo.router, prefix="/api")
app.include_router(identify.router, prefix="/api")
app.include_router(achats.router, prefix="/api")
app.include_router(fabrications.router, prefix="/api")
@app.get("/api/health")

View File

@@ -15,6 +15,13 @@ EXPECTED_COLUMNS: dict[str, list[tuple[str, str, str | None]]] = {
("url_reference", "TEXT", None),
("associations_favorables", "TEXT", None), # JSON list[str]
("associations_defavorables", "TEXT", None), # JSON list[str]
("temp_germination", "TEXT", None),
("temps_levee_j", "TEXT", None),
],
"plant_variety": [
("variete", "TEXT", None),
("tags", "TEXT", None),
("notes_variete", "TEXT", None),
("boutique_nom", "TEXT", None),
("boutique_url", "TEXT", None),
("prix_achat", "REAL", None),
@@ -77,6 +84,34 @@ EXPECTED_COLUMNS: dict[str, list[tuple[str, str, str | None]]] = {
("photos", "TEXT", None),
("videos", "TEXT", None),
],
"achat_intrant": [
("categorie", "TEXT", None),
("nom", "TEXT", None),
("marque", "TEXT", None),
("boutique_nom", "TEXT", None),
("boutique_url", "TEXT", None),
("prix", "REAL", None),
("poids", "TEXT", None),
("date_achat", "TEXT", None),
("dluo", "TEXT", None),
("notes", "TEXT", None),
("jardin_id", "INTEGER", None),
("plantation_id", "INTEGER", None),
("tache_id", "INTEGER", None),
],
"fabrication": [
("type", "TEXT", None),
("nom", "TEXT", None),
("ingredients", "TEXT", None),
("date_debut", "TEXT", None),
("date_fin_prevue", "TEXT", None),
("statut", "TEXT", "'en_cours'"),
("quantite_produite", "TEXT", None),
("notes", "TEXT", None),
("jardin_id", "INTEGER", None),
("plantation_id", "INTEGER", None),
("tache_id", "INTEGER", None),
],
}

View File

@@ -1,5 +1,5 @@
from app.models.garden import Garden, GardenCell, GardenImage, Measurement # noqa
from app.models.plant import Plant, PlantImage # noqa
from app.models.plant import Plant, PlantImage, PlantVariety, PlantWithVarieties # noqa
from app.models.planting import Planting, PlantingEvent # noqa
from app.models.task import Task # noqa
from app.models.settings import UserSettings, LunarCalendarEntry # noqa
@@ -10,3 +10,4 @@ from app.models.astuce import Astuce # noqa
from app.models.recolte import Recolte, Observation # noqa
from app.models.meteo import MeteoStation, MeteoOpenMeteo # noqa
from app.models.saint import SaintDuJour # noqa
from app.models.intrant import AchatIntrant, Fabrication # noqa

View File

@@ -0,0 +1,57 @@
# backend/app/models/intrant.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 AchatIntrant(SQLModel, table=True):
__tablename__ = "achat_intrant"
id: Optional[int] = Field(default=None, primary_key=True)
categorie: str # terreau | engrais | traitement | autre
nom: str
marque: Optional[str] = None
boutique_nom: Optional[str] = None
boutique_url: Optional[str] = None
prix: Optional[float] = None
poids: Optional[str] = None # "20L", "1kg", "500ml"
date_achat: Optional[str] = None # ISO date
dluo: Optional[str] = None # ISO date
notes: Optional[str] = None
jardin_id: Optional[int] = Field(default=None, foreign_key="garden.id")
plantation_id: Optional[int] = Field(default=None, foreign_key="planting.id")
tache_id: Optional[int] = Field(default=None, foreign_key="task.id")
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class Ingredient(SQLModel):
"""Modèle pour un ingrédient de fabrication (non persisté seul)."""
nom: str
quantite: str # "1kg", "10L"
class Fabrication(SQLModel, table=True):
__tablename__ = "fabrication"
id: Optional[int] = Field(default=None, primary_key=True)
type: str # compost | decoction | purin | autre
nom: str
ingredients: Optional[List[dict]] = Field(
default=None,
sa_column=Column("ingredients", SA_JSON, nullable=True),
)
date_debut: Optional[str] = None # ISO date
date_fin_prevue: Optional[str] = None # ISO date
statut: str = "en_cours" # en_cours | pret | utilise | echec
quantite_produite: Optional[str] = None # "8L", "50kg"
notes: Optional[str] = None
jardin_id: Optional[int] = Field(default=None, foreign_key="garden.id")
plantation_id: Optional[int] = Field(default=None, foreign_key="planting.id")
tache_id: Optional[int] = Field(default=None, foreign_key="task.id")
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class FabricationStatutUpdate(SQLModel):
statut: str

View File

@@ -1,3 +1,4 @@
# backend/app/models/plant.py
from datetime import datetime, timezone
from typing import List, Optional
from sqlalchemy import Column
@@ -11,20 +12,20 @@ class Plant(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
nom_commun: str
nom_botanique: Optional[str] = None
variete: Optional[str] = None
famille: Optional[str] = None
tags: Optional[str] = None # CSV
type_plante: Optional[str] = None # legume | fruit | aromatique | fleur
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 # ex: "2,3"
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
@@ -41,17 +42,62 @@ class Plant(SQLModel, table=True):
default=None,
sa_column=Column("associations_defavorables", SA_JSON, nullable=True),
)
# Boutique / approvisionnement (par variété)
boutique_nom: Optional[str] = None # ex: "Gamm Vert", "Lidl", "Amazon"
boutique_url: Optional[str] = None # URL fiche produit
prix_achat: Optional[float] = None
date_achat: Optional[str] = None # ISO date
poids: Optional[str] = None # ex: "5g", "100g", "50 graines"
dluo: Optional[str] = None # date limite utilisation optimale
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
created_at: Optional[datetime] = None
associations_favorables: Optional[List[str]] = None
associations_defavorables: Optional[List[str]] = None
varieties: List[PlantVariety] = Field(default_factory=list)
class PlantImage(SQLModel, table=True):
__tablename__ = "plant_image"
id: Optional[int] = Field(default=None, primary_key=True)
plant_id: int = Field(foreign_key="plant.id", index=True)
filename: str

View File

@@ -0,0 +1,60 @@
# backend/app/routers/achats.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.intrant import AchatIntrant
router = APIRouter(tags=["intrants"])
@router.get("/achats", response_model=List[AchatIntrant])
def list_achats(
categorie: Optional[str] = Query(None),
jardin_id: Optional[int] = Query(None),
session: Session = Depends(get_session),
):
q = select(AchatIntrant)
if categorie:
q = q.where(AchatIntrant.categorie == categorie)
if jardin_id:
q = q.where(AchatIntrant.jardin_id == jardin_id)
return session.exec(q.order_by(AchatIntrant.created_at.desc())).all()
@router.post("/achats", response_model=AchatIntrant, status_code=status.HTTP_201_CREATED)
def create_achat(a: AchatIntrant, session: Session = Depends(get_session)):
session.add(a)
session.commit()
session.refresh(a)
return a
@router.get("/achats/{id}", response_model=AchatIntrant)
def get_achat(id: int, session: Session = Depends(get_session)):
a = session.get(AchatIntrant, id)
if not a:
raise HTTPException(404, "Achat introuvable")
return a
@router.put("/achats/{id}", response_model=AchatIntrant)
def update_achat(id: int, data: AchatIntrant, session: Session = Depends(get_session)):
a = session.get(AchatIntrant, id)
if not a:
raise HTTPException(404, "Achat introuvable")
for k, v in data.model_dump(exclude_unset=True, exclude={"id", "created_at"}).items():
setattr(a, k, v)
session.add(a)
session.commit()
session.refresh(a)
return a
@router.delete("/achats/{id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_achat(id: int, session: Session = Depends(get_session)):
a = session.get(AchatIntrant, id)
if not a:
raise HTTPException(404, "Achat introuvable")
session.delete(a)
session.commit()

View File

@@ -0,0 +1,78 @@
# backend/app/routers/fabrications.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.intrant import Fabrication, FabricationStatutUpdate
router = APIRouter(tags=["intrants"])
@router.get("/fabrications", response_model=List[Fabrication])
def list_fabrications(
type: Optional[str] = Query(None),
statut: Optional[str] = Query(None),
jardin_id: Optional[int] = Query(None),
session: Session = Depends(get_session),
):
q = select(Fabrication)
if type:
q = q.where(Fabrication.type == type)
if statut:
q = q.where(Fabrication.statut == statut)
if jardin_id:
q = q.where(Fabrication.jardin_id == jardin_id)
return session.exec(q.order_by(Fabrication.created_at.desc())).all()
@router.post("/fabrications", response_model=Fabrication, status_code=status.HTTP_201_CREATED)
def create_fabrication(f: Fabrication, session: Session = Depends(get_session)):
session.add(f)
session.commit()
session.refresh(f)
return f
@router.get("/fabrications/{id}", response_model=Fabrication)
def get_fabrication(id: int, session: Session = Depends(get_session)):
f = session.get(Fabrication, id)
if not f:
raise HTTPException(404, "Fabrication introuvable")
return f
@router.put("/fabrications/{id}", response_model=Fabrication)
def update_fabrication(id: int, data: Fabrication, session: Session = Depends(get_session)):
f = session.get(Fabrication, id)
if not f:
raise HTTPException(404, "Fabrication introuvable")
for k, v in data.model_dump(exclude_unset=True, exclude={"id", "created_at"}).items():
setattr(f, k, v)
session.add(f)
session.commit()
session.refresh(f)
return f
@router.patch("/fabrications/{id}/statut", response_model=Fabrication)
def update_statut(id: int, data: FabricationStatutUpdate, session: Session = Depends(get_session)):
f = session.get(Fabrication, id)
if not f:
raise HTTPException(404, "Fabrication introuvable")
valid = {"en_cours", "pret", "utilise", "echec"}
if data.statut not in valid:
raise HTTPException(400, f"Statut invalide. Valeurs: {valid}")
f.statut = data.statut
session.add(f)
session.commit()
session.refresh(f)
return f
@router.delete("/fabrications/{id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_fabrication(id: int, session: Session = Depends(get_session)):
f = session.get(Fabrication, id)
if not f:
raise HTTPException(404, "Fabrication introuvable")
session.delete(f)
session.commit()

View File

@@ -1,40 +1,50 @@
# 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
from app.models.plant import Plant, PlantVariety, PlantWithVarieties
router = APIRouter(tags=["plantes"])
@router.get("/plants", response_model=List[Plant])
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.variete, Plant.id)
q = select(Plant).order_by(Plant.nom_commun, Plant.id)
if categorie:
q = q.where(Plant.categorie == categorie)
return session.exec(q).all()
return [_with_varieties(p, session) for p in session.exec(q).all()]
@router.post("/plants", response_model=Plant, status_code=status.HTTP_201_CREATED)
@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 p
return _with_varieties(p, session)
@router.get("/plants/{id}", response_model=Plant)
@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 p
return _with_varieties(p, session)
@router.put("/plants/{id}", response_model=Plant)
@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:
@@ -44,7 +54,7 @@ def update_plant(id: int, data: Plant, session: Session = Depends(get_session)):
session.add(p)
session.commit()
session.refresh(p)
return p
return _with_varieties(p, session)
@router.delete("/plants/{id}", status_code=status.HTTP_204_NO_CONTENT)
@@ -52,5 +62,49 @@ 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()

View File

@@ -6,7 +6,7 @@ import app.models # noqa
def run_seed():
from app.models.garden import Garden, GardenCell, Measurement
from app.models.plant import Plant
from app.models.plant import Plant, PlantVariety
from app.models.planting import Planting, PlantingEvent
from app.models.task import Task
from app.models.tool import Tool
@@ -131,11 +131,24 @@ def run_seed():
plantes = []
for data in plantes_data:
variete = data.pop('variete', None)
p = Plant(**data)
session.add(p)
plantes.append(p)
session.flush()
# Créer les variétés pour les plantes qui en avaient une
plantes_varietes = [
("Andine Cornue", 0), # Tomate
("Verte", 1), # Courgette
("Batavia", 3), # Laitue
("Nain", 6), # Haricot
("Mange-tout", 7), # Pois
("Milan", 15), # Chou
]
for variete_nom, idx in plantes_varietes:
session.add(PlantVariety(plant_id=plantes[idx].id, variete=variete_nom))
tomate = plantes[0]
courgette = plantes[1]

0
backend/data/jardin.db Normal file
View File

Binary file not shown.

View File

View File

@@ -0,0 +1,300 @@
#!/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
import unicodedata
import uuid
from datetime import datetime, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent.parent
DB_PATH = ROOT / "data" / "jardin.db"
UPLOADS_DIR = ROOT / "data" / "uploads"
GRAINE_DIR = ROOT / "docs" / "graine"
ARBUSTRE_DIR = ROOT / "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", "Echalote"),
("courge", "Courge"),
("chou pomme", "Chou"),
("chou-fleur", "Chou-fleur"),
]
def roman_to_csv(s: str) -> str:
if not s:
return ""
s = s.strip()
# Handle "(selon sachet)" or other parenthetical notes
if "(" in s:
s = s.split("(")[0].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:
if not s:
return None
try:
# Handle "2-3 cm" → take first number
first = s.split()[0].split("-")[0].replace(",", ".")
return float(first)
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" WARNING image absente: {src}")
return
# Vérifier si cette image existe déjà dans media pour cette variété
existing_m = conn.execute(
"SELECT id FROM media WHERE entity_type = 'plant_variety' AND entity_id = ? AND url LIKE ?",
(variety_id, f"%{src.stem}%")
).fetchone()
if existing_m:
return
UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
# Use UUID-based filename like the rest of the app
dest_name = f"{uuid.uuid4()}.jpg"
shutil.copy2(src, UPLOADS_DIR / dest_name)
url = f"/uploads/{dest_name}"
conn.execute("""
INSERT INTO media (entity_type, entity_id, url, created_at)
VALUES ('plant_variety', ?, ?, ?)
""", (variety_id, url, datetime.now(timezone.utc).isoformat()))
def normalize(s: str) -> str:
"""Normalise string: minuscules, supprime accents simples."""
return ''.join(c for c in unicodedata.normalize('NFD', s.lower()) if unicodedata.category(c) != 'Mn')
def resolve_nom(full_name: str) -> tuple[str, str]:
"""Retourne (nom_commun, variete) depuis le nom complet du sachet."""
norm = normalize(full_name)
for key, val in NOM_MAP:
norm_key = normalize(key)
if norm.startswith(norm_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"WARNING fichier absent: {path}")
return
data = json.loads(path.read_text(encoding="utf-8"))
key = "plantes" if "plantes" in data else list(data.keys())[0]
entries = data[key]
for entry in entries:
full_name = entry.get("plante", "")
if not full_name:
continue
nom_commun, variete_name = resolve_nom(full_name)
carac = entry.get("caracteristiques_plantation", {})
detail = entry.get("detail", {})
texte = detail.get("texte_integral_visible", {}) if isinstance(detail, dict) else {}
plant_id = find_or_create_plant(conn, nom_commun)
# Enrichir plant (ne pas écraser si déjà rempli)
updates: dict = {}
semis = roman_to_csv(carac.get("periode_semis", ""))
recolte = roman_to_csv(carac.get("periode_recolte", ""))
profondeur = extract_float(carac.get("profondeur") or "")
espacement_raw = carac.get("espacement") or ""
espacement = extract_float(espacement_raw)
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 isinstance(texte, dict) and texte.get("arriere"):
updates["astuces_culture"] = texte["arriere"][:1000]
elif isinstance(texte, str) and texte:
updates["astuces_culture"] = texte[: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),
)
# Vérifier si cette variété existe déjà pour cette plante
existing_v = conn.execute(
"SELECT id FROM plant_variety WHERE plant_id = ? AND LOWER(variete) = LOWER(?)",
(plant_id, variete_name)
).fetchone()
if existing_v:
print(f"{nom_commun}{variete_name} (déjà importé)")
continue
# 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" OK {nom_commun} - {variete_name}")
def import_arbustre(conn: sqlite3.Connection) -> None:
path = ARBUSTRE_DIR / "caracteristiques_arbustre.json"
if not path.exists():
print(f"WARNING fichier absent: {path}")
return
data = json.loads(path.read_text(encoding="utf-8"))
key = "plantes" if "plantes" in data else list(data.keys())[0]
entries = data[key]
for entry in entries:
full_name = entry.get("plante", "")
if not full_name:
continue
nom_latin = entry.get("nom_latin", "") or ""
# Determine nom_commun from the plante field
if "Vitis" in full_name or "Vitis" in nom_latin:
nom_commun = "Vigne"
elif "Ribes nigrum" in full_name or "Ribes nigrum" in nom_latin:
nom_commun = "Cassissier"
elif "Rubus idaeus" in full_name or "Rubus idaeus" in nom_latin:
nom_commun = "Framboisier"
elif "'" in full_name:
nom_commun = full_name.split("'")[0].strip().title()
elif nom_latin:
parts = nom_latin.split()
nom_commun = (parts[0] + " " + parts[1]).title() if len(parts) > 1 else nom_latin.title()
else:
nom_commun = full_name.split()[0].title()
# variete_name: content inside quotes
if "'" in full_name:
variete_name = full_name.split("'")[1].strip()
else:
variete_name = full_name
plant_id = find_or_create_plant(conn, nom_commun, "arbuste")
carac = entry.get("caracteristiques_plantation", {})
arrosage = carac.get("arrosage")
exposition = carac.get("exposition")
if arrosage:
conn.execute("UPDATE plant SET besoin_eau = ? WHERE id = ?", (arrosage, plant_id))
if exposition:
conn.execute("UPDATE plant SET besoin_soleil = ? WHERE id = ?", (exposition, plant_id))
# Vérifier si cette variété existe déjà pour cette plante
existing_v = conn.execute(
"SELECT id FROM plant_variety WHERE plant_id = ? AND LOWER(variete) = LOWER(?)",
(plant_id, variete_name)
).fetchone()
if existing_v:
print(f"{nom_commun}{variete_name} (déjà importé)")
continue
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" OK {nom_commun} - {variete_name}")
def run() -> None:
if not DB_PATH.exists():
print(f"ERREUR : base de données introuvable : {DB_PATH}")
return
UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
try:
tables = [r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()]
if "plant_variety" not in tables:
print("WARNING Exécutez d'abord migrate_plant_varieties.py")
return
print("=== Import graines ===")
import_graines(conn)
print("\n=== Import arbustre ===")
import_arbustre(conn)
conn.commit()
print("\nImport terminé.")
except Exception as e:
conn.rollback()
print(f"ERREUR - rollback : {e}")
raise
finally:
conn.close()
if __name__ == "__main__":
run()

View File

@@ -0,0 +1,100 @@
#!/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(__file__).resolve().parent.parent.parent / "data" / "jardin.db"
def run():
if not DB_PATH.exists():
print(f"ERREUR : base de données introuvable : {DB_PATH}")
return
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
try:
# 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.")
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)
# IDs stables dans le seed de production : Haricot=7, haricot grimpant=21
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()
print("\nMigration terminée avec succès.")
except Exception as e:
conn.rollback()
print(f"ERREUR — rollback effectué : {e}")
raise
finally:
conn.close()
if __name__ == "__main__":
run()

View File

@@ -12,14 +12,30 @@ def test_list_plants(client):
assert len(r.json()) == 2
def test_allow_same_common_name_with_different_varieties(client):
client.post("/api/plants", json={"nom_commun": "Tomate", "variete": "Roma"})
client.post("/api/plants", json={"nom_commun": "Tomate", "variete": "Andine Cornue"})
r = client.get("/api/plants")
assert r.status_code == 200
tomates = [p for p in r.json() if p["nom_commun"] == "Tomate"]
assert len(tomates) == 2
assert {p.get("variete") for p in tomates} == {"Roma", "Andine Cornue"}
def test_plant_variety_crud(client):
# Créer une plante
r = client.post("/api/plants", json={"nom_commun": "Tomate"})
assert r.status_code == 201
plant_id = r.json()["id"]
# Créer deux variétés
r1 = client.post(f"/api/plants/{plant_id}/varieties", json={"variete": "Roma"})
assert r1.status_code == 201
vid1 = r1.json()["id"]
r2 = client.post(f"/api/plants/{plant_id}/varieties", json={"variete": "Andine Cornue"})
assert r2.status_code == 201
# GET /plants/{id} doit retourner les 2 variétés
r = client.get(f"/api/plants/{plant_id}")
varieties = r.json().get("varieties", [])
assert len(varieties) == 2
assert {v["variete"] for v in varieties} == {"Roma", "Andine Cornue"}
# Supprimer une variété
client.delete(f"/api/plants/{plant_id}/varieties/{vid1}")
r = client.get(f"/api/plants/{plant_id}")
assert len(r.json()["varieties"]) == 1
def test_get_plant(client):

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

View File

@@ -0,0 +1,163 @@
# Plantes & Variétés — Design
> **Pour Claude :** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Restructurer la gestion des plantes en séparant niveau espèce (`plant`) et niveau variété (`plant_variety`), importer les données JSON des sachets de graines et arbustes, et ajouter le bouton "Ajouter variété" dans l'UI.
**Architecture:** Option B — deux tables distinctes (`plant` + `plant_variety`), migration des 21 plantes existantes, import one-shot des 14 graines + 4 arbustes, nouveaux endpoints CRUD variétés.
**Tech Stack:** FastAPI + SQLModel + SQLite (backend), Vue 3 + Pinia + Tailwind Gruvbox (frontend)
---
## Modèle de données
### Table `plant` (niveau espèce / nom commun)
Conserve toutes les colonnes actuelles **sauf** : `variete`, `boutique_nom`, `boutique_url`, `prix_achat`, `date_achat`, `poids`, `dluo`, `tags`
Nouvelles colonnes ajoutées :
| Champ | Type | Notes |
|---|---|---|
| temp_germination | TEXT | Ex: "8-10°C" |
| temps_levee_j | TEXT | Ex: "15-20 jours" |
### Table `plant_variety` (niveau variété)
| Champ | Type | Notes |
|---|---|---|
| id | INTEGER PK | |
| plant_id | INTEGER FK → plant | |
| variete | TEXT | "Nain", "Grimpant", "Stockarda"… |
| tags | TEXT | Tags spécifiques à cette variété |
| notes_variete | TEXT | Observations propres à la variété |
| boutique_nom | TEXT | Enseigne d'achat |
| boutique_url | TEXT | URL fiche produit |
| prix_achat | REAL | En € |
| date_achat | TEXT | ISO date |
| poids | TEXT | Ex: "500 graines", "1g" |
| dluo | TEXT | ISO date limite d'utilisation |
| created_at | TEXT | ISO datetime |
### Photos sachet de graines
→ Table `media` existante avec `entity_type='plant_variety'` et `entity_id=plant_variety.id`
→ Deux photos par variété max (recto + verso sachet), type `photo_sachet`
---
## API REST
### Endpoints `plant` modifiés
```
GET /api/plants → liste plant + varieties[] (jointure)
GET /api/plants/{id} → détail plant + varieties[]
POST /api/plants → créer une plante (sans variété)
PUT /api/plants/{id} → modifier les champs espèce
DEL /api/plants/{id} → supprimer (cascade varieties)
```
### Nouveaux endpoints `plant_variety`
```
GET /api/plants/{id}/varieties → liste des variétés d'une plante
POST /api/plants/{id}/varieties → créer une variété
PUT /api/plants/{id}/varieties/{vid} → modifier une variété
DELETE /api/plants/{id}/varieties/{vid} → supprimer une variété
```
### Import one-shot
```
POST /api/plants/import-graines → importe docs/graine/ + docs/arbustre/
```
---
## Migration BDD (script Python one-shot)
1. Créer table `plant_variety` avec toutes ses colonnes
2. Pour chaque ligne `plant` actuelle → créer une `plant_variety` (copier variete, boutique_*, tags)
3. Recréer `plant` sans les colonnes migrées + ajouter temp_germination + temps_levee_j
4. Fusionner `haricot grimpant` (id=21) sous `Haricot` (id=7) comme variété, supprimer id=21
---
## Import JSON
### `docs/graine/caracteristiques_plantation.json` (14 entrées)
Mapping automatique `nom → plant.nom_commun` :
| JSON | nom_commun BDD | Action |
|---|---|---|
| Oignon Stockarda | Oignon (nouveau) | créer plant + variety |
| Laitue Attraction | Laitue (id=4) | nouvelle variety |
| Persil frise Moskrul 2 | Persil (id=13) | enrichir plant + nouvelle variety |
| Courgette de Nice | Courgette (id=2) | nouvelle variety |
| Pois à ecosser Merveille | Pois (id=8) | enrichir + nouvelle variety |
| Tomates Moneymaker | Tomate (id=1) | nouvelle variety |
| Poireau Bleu de Solaise | Poireau (id=9) | enrichir variety existante |
| Echalion Zebrune | Échalote (id=14) | nouvelle variety |
| Courge Musquée Sucrine | Courge (nouveau) | créer plant + variety |
| Laitue Grosse Blonde | Laitue (id=4) | nouvelle variety |
| Chou Pomme Brunswick | Chou (id=16) | enrichir + nouvelle variety |
| Chou-fleur Nautilus HF1 | Chou-fleur (id=15) | enrichir variety existante |
| Tomate Cornue Cornabel | Tomate (id=1) | nouvelle variety |
| Pois mangetout Carouby | Pois (id=8) | nouvelle variety |
Champs JSON → colonnes `plant` :
- `periode_semis` (ex: "III-IV") → `semis_exterieur_mois` (converti en liste mois "3,4")
- `periode_recolte``recolte_mois`
- `temperature``temp_germination`
- `profondeur``profondeur_semis_cm` (extrait la valeur numérique)
- `espacement``espacement_cm` (premier chiffre)
- `exposition``besoin_soleil`
- texte `arriere``astuces_culture`
Photos `IMG_*.jpg` → copiées dans `/data/uploads/` + entrées `media` (entity_type='plant_variety')
### `docs/arbustre/caracteristiques_arbustre.json` (4 entrées)
4 nouvelles plantes de catégorie `arbuste` avec leurs variétés.
---
## Frontend
### `PlantesView.vue` — modifications
- **GET /api/plants** retourne `plant + varieties[]` → groupage déjà géré
- **Popup détail** : bouton **"+ Variété"** à gauche du bouton "Modifier"
- **Popup variété** : formulaire pré-rempli depuis `plant` (nom commun) + champs variété-spécifiques :
- Nom de variété (requis)
- Tags, notes variété
- Boutique (nom, URL, prix, date, poids, DLUO)
- Section photos sachet : 2 uploads (recto + verso)
- **Popup nom commun** : affiche `temp_germination` + `temps_levee_j` dans la section culture
### `frontend/src/api/plants.ts` — modifications
- Interface `Plant` : supprimer boutique/tags/variete, ajouter `temp_germination`, `temps_levee_j`, `varieties: PlantVariety[]`
- Nouvelle interface `PlantVariety` : tous les champs variété
- Nouveaux appels API : `createVariety`, `updateVariety`, `deleteVariety`
### `frontend/src/stores/plants.ts` — modifications
- Actions : `createVariety`, `updateVariety`, `deleteVariety`
- `fetchAll` charge désormais les varieties intégrées
---
## Fichiers à créer / modifier
| Fichier | Action |
|---|---|
| `backend/app/models/plant.py` | Modifier : supprimer champs migrés, ajouter temp_germination + temps_levee_j + PlantVariety |
| `backend/app/routers/plants.py` | Modifier : GET retourne varieties, + endpoints varieties CRUD |
| `backend/app/migrate.py` | Modifier : ajouter section plant_variety |
| `backend/scripts/migrate_plant_varieties.py` | Créer : script migration one-shot |
| `backend/scripts/import_graines.py` | Créer : script import JSON graines + arbustre |
| `frontend/src/api/plants.ts` | Modifier : Plant + PlantVariety interfaces |
| `frontend/src/stores/plants.ts` | Modifier : actions variety |
| `frontend/src/views/PlantesView.vue` | Modifier : bouton + variété + popup variété + champs nouveaux |

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 MiB

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 MiB

After

Width:  |  Height:  |  Size: 3.7 MiB

View File

@@ -192,6 +192,7 @@ const links = [
{ to: '/plantes', label: 'Plantes', icon: '🌱' },
{ to: '/bibliotheque', label: 'Bibliothèque', icon: '📷' },
{ to: '/outils', label: 'Outils', icon: '🔧' },
{ to: '/intrants', label: 'Intrants', icon: '🧪' },
{ to: '/plantations', label: 'Plantations', icon: '🥕' },
{ to: '/taches', label: 'Tâches', icon: '✅' },
{ to: '/planning', label: 'Planning', icon: '📆' },

View File

@@ -130,6 +130,7 @@ const links = [
{ to: '/plantes', label: 'Plantes', icon: '🌱' },
{ to: '/bibliotheque', label: 'Bibliothèque', icon: '📷' },
{ to: '/outils', label: 'Outils', icon: '🔧' },
{ to: '/intrants', label: 'Intrants', icon: '🧪' },
{ to: '/plantations', label: 'Plantations', icon: '🥕' },
{ to: '/taches', label: 'Tâches', icon: '✅' },
{ to: '/planning', label: 'Planning', icon: '📆' },

View File

@@ -0,0 +1,9 @@
// frontend/src/api/achats.ts
import client from './client';
export const achatsApi = {
list: (params) => client.get('/api/achats', { params }).then(r => r.data),
get: (id) => client.get(`/api/achats/${id}`).then(r => r.data),
create: (a) => client.post('/api/achats', a).then(r => r.data),
update: (id, a) => client.put(`/api/achats/${id}`, a).then(r => r.data),
delete: (id) => client.delete(`/api/achats/${id}`),
};

View File

@@ -0,0 +1,28 @@
// frontend/src/api/achats.ts
import client from './client'
export interface AchatIntrant {
id?: number
categorie: string // terreau | engrais | traitement | autre
nom: string
marque?: string
boutique_nom?: string
boutique_url?: string
prix?: number
poids?: string
date_achat?: string
dluo?: string
notes?: string
jardin_id?: number
plantation_id?: number
tache_id?: number
}
export const achatsApi = {
list: (params?: { categorie?: string; jardin_id?: number }) =>
client.get<AchatIntrant[]>('/api/achats', { params }).then(r => r.data),
get: (id: number) => client.get<AchatIntrant>(`/api/achats/${id}`).then(r => r.data),
create: (a: Partial<AchatIntrant>) => client.post<AchatIntrant>('/api/achats', a).then(r => r.data),
update: (id: number, a: Partial<AchatIntrant>) => client.put<AchatIntrant>(`/api/achats/${id}`, a).then(r => r.data),
delete: (id: number) => client.delete(`/api/achats/${id}`),
}

View File

@@ -0,0 +1,10 @@
// frontend/src/api/fabrications.ts
import client from './client';
export const fabricationsApi = {
list: (params) => client.get('/api/fabrications', { params }).then(r => r.data),
get: (id) => client.get(`/api/fabrications/${id}`).then(r => r.data),
create: (f) => client.post('/api/fabrications', f).then(r => r.data),
update: (id, f) => client.put(`/api/fabrications/${id}`, f).then(r => r.data),
updateStatut: (id, statut) => client.patch(`/api/fabrications/${id}/statut`, { statut }).then(r => r.data),
delete: (id) => client.delete(`/api/fabrications/${id}`),
};

View File

@@ -0,0 +1,32 @@
// frontend/src/api/fabrications.ts
import client from './client'
export interface Ingredient {
nom: string
quantite: string
}
export interface Fabrication {
id?: number
type: string // compost | decoction | purin | autre
nom: string
ingredients?: Ingredient[]
date_debut?: string
date_fin_prevue?: string
statut?: string // en_cours | pret | utilise | echec
quantite_produite?: string
notes?: string
jardin_id?: number
plantation_id?: number
tache_id?: number
}
export const fabricationsApi = {
list: (params?: { type?: string; statut?: string; jardin_id?: number }) =>
client.get<Fabrication[]>('/api/fabrications', { params }).then(r => r.data),
get: (id: number) => client.get<Fabrication>(`/api/fabrications/${id}`).then(r => r.data),
create: (f: Partial<Fabrication>) => client.post<Fabrication>('/api/fabrications', f).then(r => r.data),
update: (id: number, f: Partial<Fabrication>) => client.put<Fabrication>(`/api/fabrications/${id}`, f).then(r => r.data),
updateStatut: (id: number, statut: string) => client.patch<Fabrication>(`/api/fabrications/${id}/statut`, { statut }).then(r => r.data),
delete: (id: number) => client.delete(`/api/fabrications/${id}`),
}

View File

@@ -1,3 +1,4 @@
// frontend/src/api/plants.ts
import client from './client';
export const plantsApi = {
list: (categorie) => client.get('/api/plants', { params: categorie ? { categorie } : {} }).then(r => r.data),
@@ -5,4 +6,8 @@ export const plantsApi = {
create: (p) => client.post('/api/plants', p).then(r => r.data),
update: (id, p) => client.put(`/api/plants/${id}`, p).then(r => r.data),
delete: (id) => client.delete(`/api/plants/${id}`),
// Variétés
createVariety: (plantId, v) => client.post(`/api/plants/${plantId}/varieties`, v).then(r => r.data),
updateVariety: (plantId, vid, v) => client.put(`/api/plants/${plantId}/varieties/${vid}`, v).then(r => r.data),
deleteVariety: (plantId, vid) => client.delete(`/api/plants/${plantId}/varieties/${vid}`),
};

View File

@@ -1,29 +1,12 @@
// frontend/src/api/plants.ts
import client from './client'
export interface Plant {
export interface PlantVariety {
id?: number
nom_commun: string
nom_botanique?: string
plant_id?: number
variete?: string
famille?: string
categorie?: string // potager|fleur|arbre|arbuste
tags?: string
type_plante?: string
besoin_eau?: string
besoin_soleil?: string
espacement_cm?: number
temp_min_c?: number
hauteur_cm?: number
plantation_mois?: string
recolte_mois?: string
semis_interieur_mois?: string
semis_exterieur_mois?: string
maladies_courantes?: string
astuces_culture?: string
url_reference?: string
notes?: string
associations_favorables?: string[]
associations_defavorables?: string[]
notes_variete?: string
boutique_nom?: string
boutique_url?: string
prix_achat?: number
@@ -32,10 +15,49 @@ export interface Plant {
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),
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}`),
}

View File

@@ -24,6 +24,7 @@ const links = [
{ to: '/plantes', label: 'Plantes' },
{ to: '/bibliotheque', label: '📷 Bibliothèque' },
{ to: '/outils', label: 'Outils' },
{ to: '/intrants', label: '🧪 Intrants' },
{ to: '/plantations', label: 'Plantations' },
{ to: '/taches', label: 'Tâches' },
{ to: '/planning', label: 'Planning' },

View File

@@ -7,6 +7,7 @@ const links = [
{ to: '/plantes', label: 'Plantes' },
{ to: '/bibliotheque', label: '📷 Bibliothèque' },
{ to: '/outils', label: 'Outils' },
{ to: '/intrants', label: '🧪 Intrants' },
{ to: '/plantations', label: 'Plantations' },
{ to: '/taches', label: 'Tâches' },
{ to: '/planning', label: 'Planning' },

View File

@@ -8,6 +8,7 @@ export default createRouter({
{ path: '/plantes', component: () => import('@/views/PlantesView.vue') },
{ path: '/bibliotheque', component: () => import('@/views/BibliothequeView.vue') },
{ path: '/outils', component: () => import('@/views/OutilsView.vue') },
{ path: '/intrants', component: () => import('@/views/IntratsView.vue') },
{ path: '/plantations', component: () => import('@/views/PlantationsView.vue') },
{ path: '/planning', component: () => import('@/views/PlanningView.vue') },
{ path: '/taches', component: () => import('@/views/TachesView.vue') },

View File

@@ -9,6 +9,7 @@ export default createRouter({
{ path: '/plantes', component: () => import('@/views/PlantesView.vue') },
{ path: '/bibliotheque', component: () => import('@/views/BibliothequeView.vue') },
{ path: '/outils', component: () => import('@/views/OutilsView.vue') },
{ path: '/intrants', component: () => import('@/views/IntratsView.vue') },
{ path: '/plantations', component: () => import('@/views/PlantationsView.vue') },
{ path: '/planning', component: () => import('@/views/PlanningView.vue') },
{ path: '/taches', component: () => import('@/views/TachesView.vue') },

View File

@@ -0,0 +1,27 @@
// frontend/src/stores/achats.ts
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { achatsApi } from '@/api/achats';
export const useAchatsStore = defineStore('achats', () => {
const achats = ref([]);
const loading = ref(false);
async function fetchAll(params) {
loading.value = true;
try {
achats.value = await achatsApi.list(params);
}
finally {
loading.value = false;
}
}
async function create(a) {
const created = await achatsApi.create(a);
achats.value.unshift(created);
return created;
}
async function remove(id) {
await achatsApi.delete(id);
achats.value = achats.value.filter(a => a.id !== id);
}
return { achats, loading, fetchAll, create, remove };
});

View File

@@ -0,0 +1,28 @@
// frontend/src/stores/achats.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { achatsApi, type AchatIntrant } from '@/api/achats'
export const useAchatsStore = defineStore('achats', () => {
const achats = ref<AchatIntrant[]>([])
const loading = ref(false)
async function fetchAll(params?: { categorie?: string }) {
loading.value = true
try { achats.value = await achatsApi.list(params) }
finally { loading.value = false }
}
async function create(a: Partial<AchatIntrant>) {
const created = await achatsApi.create(a)
achats.value.unshift(created)
return created
}
async function remove(id: number) {
await achatsApi.delete(id)
achats.value = achats.value.filter(a => a.id !== id)
}
return { achats, loading, fetchAll, create, remove }
})

View File

@@ -0,0 +1,34 @@
// frontend/src/stores/fabrications.ts
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { fabricationsApi } from '@/api/fabrications';
export const useFabricationsStore = defineStore('fabrications', () => {
const fabrications = ref([]);
const loading = ref(false);
async function fetchAll(params) {
loading.value = true;
try {
fabrications.value = await fabricationsApi.list(params);
}
finally {
loading.value = false;
}
}
async function create(f) {
const created = await fabricationsApi.create(f);
fabrications.value.unshift(created);
return created;
}
async function updateStatut(id, statut) {
const updated = await fabricationsApi.updateStatut(id, statut);
const idx = fabrications.value.findIndex(f => f.id === id);
if (idx !== -1)
fabrications.value[idx] = updated;
return updated;
}
async function remove(id) {
await fabricationsApi.delete(id);
fabrications.value = fabrications.value.filter(f => f.id !== id);
}
return { fabrications, loading, fetchAll, create, updateStatut, remove };
});

View File

@@ -0,0 +1,35 @@
// frontend/src/stores/fabrications.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { fabricationsApi, type Fabrication } from '@/api/fabrications'
export const useFabricationsStore = defineStore('fabrications', () => {
const fabrications = ref<Fabrication[]>([])
const loading = ref(false)
async function fetchAll(params?: { type?: string; statut?: string }) {
loading.value = true
try { fabrications.value = await fabricationsApi.list(params) }
finally { loading.value = false }
}
async function create(f: Partial<Fabrication>) {
const created = await fabricationsApi.create(f)
fabrications.value.unshift(created)
return created
}
async function updateStatut(id: number, statut: string) {
const updated = await fabricationsApi.updateStatut(id, statut)
const idx = fabrications.value.findIndex(f => f.id === id)
if (idx !== -1) fabrications.value[idx] = updated
return updated
}
async function remove(id: number) {
await fabricationsApi.delete(id)
fabrications.value = fabrications.value.filter(f => f.id !== id)
}
return { fabrications, loading, fetchAll, create, updateStatut, remove }
})

View File

@@ -1,3 +1,4 @@
// frontend/src/stores/plants.ts
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { plantsApi } from '@/api/plants';
@@ -18,9 +19,43 @@ export const usePlantsStore = defineStore('plants', () => {
plants.value.push(created);
return created;
}
async function update(id, p) {
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) {
await plantsApi.delete(id);
plants.value = plants.value.filter(p => p.id !== id);
}
return { plants, loading, fetchAll, create, remove };
async function createVariety(plantId, v) {
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, vid, v) {
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, vid) {
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 };
});

View File

@@ -1,6 +1,7 @@
// frontend/src/stores/plants.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { plantsApi, type Plant } from '@/api/plants'
import { plantsApi, type Plant, type PlantVariety } from '@/api/plants'
export const usePlantsStore = defineStore('plants', () => {
const plants = ref<Plant[]>([])
@@ -18,10 +19,45 @@ export const usePlantsStore = defineStore('plants', () => {
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)
}
return { plants, loading, fetchAll, create, remove }
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 }
})

View File

@@ -1,6 +1,7 @@
export function formatPlantLabel(plant) {
if (plant.variete && plant.variete.trim()) {
return `${plant.nom_commun}${plant.variete.trim()}`;
const variete = plant.variete ?? plant.varieties?.[0]?.variete;
if (variete && variete.trim()) {
return `${plant.nom_commun}${variete.trim()}`;
}
return plant.nom_commun;
}

View File

@@ -1,12 +1,18 @@
export interface PlantVarietyData {
variete?: string | null
}
export interface PlantLabelData {
id?: number
nom_commun: string
variete?: string | null
varieties?: PlantVarietyData[] | null
}
export function formatPlantLabel(plant: PlantLabelData): string {
if (plant.variete && plant.variete.trim()) {
return `${plant.nom_commun}${plant.variete.trim()}`
const variete = plant.variete ?? plant.varieties?.[0]?.variete
if (variete && variete.trim()) {
return `${plant.nom_commun}${variete.trim()}`
}
return plant.nom_commun
}

View File

@@ -0,0 +1,643 @@
<!-- frontend/src/views/IntratsView.vue -->
<template>
<div class="p-4 max-w-[1800px] mx-auto space-y-6">
<!-- En-tête -->
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
<h1 class="text-3xl font-bold text-yellow tracking-tight">🧪 Intrants</h1>
<!-- Onglets -->
<div class="flex items-center gap-2 bg-bg-hard rounded-xl p-1 border border-bg-soft">
<button v-for="tab in tabs" :key="tab.key"
@click="activeTab = tab.key"
:class="['px-4 py-2 rounded-lg text-sm font-bold transition-all',
activeTab === tab.key ? 'bg-yellow text-bg' : 'text-text-muted hover:text-text']">
{{ tab.label }}
</button>
</div>
<button @click="openCreateForm"
class="btn-primary !bg-yellow !text-bg flex items-center gap-2 rounded-lg py-2 px-4 shadow-lg hover:scale-105 transition-all font-bold">
<span class="text-lg">+</span> {{ activeTab === 'achats' ? 'Ajouter un achat' : 'Nouvelle fabrication' }}
</button>
</div>
<!-- ====== ONGLET ACHATS ====== -->
<div v-if="activeTab === 'achats'">
<!-- Filtres -->
<div class="flex flex-wrap gap-2 mb-4">
<button v-for="cat in categoriesAchat" :key="cat.val"
@click="filterCat = cat.val"
:class="['px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest transition-all border',
filterCat === cat.val ? 'bg-yellow text-bg border-yellow' : 'bg-bg-hard text-text-muted border-bg-soft hover:text-text']">
{{ cat.label }}
</button>
</div>
<!-- Grille achats -->
<div v-if="achatsStore.loading" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<div v-for="i in 6" :key="i" class="card-jardin h-32 animate-pulse opacity-20"></div>
</div>
<div v-else-if="filteredAchats.length" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<div v-for="a in filteredAchats" :key="a.id"
class="card-jardin !p-0 overflow-hidden flex flex-col hover:border-yellow/40 transition-all border-l-[6px] cursor-pointer"
:style="{ borderLeftColor: catAchatColor(a.categorie) }"
@click="openDetailAchat(a)">
<div class="p-4 flex-1">
<div class="flex items-start justify-between gap-2 mb-2">
<div>
<span :class="['text-[8px] font-black uppercase tracking-widest px-1.5 py-0.5 rounded', catAchatTextClass(a.categorie)]">
{{ a.categorie }}
</span>
<h3 class="text-text font-bold text-lg mt-1 leading-tight">{{ a.nom }}</h3>
<p v-if="a.marque" class="text-text-muted text-xs">{{ a.marque }}</p>
</div>
<span v-if="a.prix" class="text-yellow font-black text-lg shrink-0">{{ a.prix.toFixed(2) }}</span>
</div>
<div class="flex flex-wrap gap-2 mt-3">
<span v-if="a.boutique_nom" class="text-[10px] bg-bg/40 px-2 py-0.5 rounded border border-bg-soft text-text-muted">
🛒 {{ a.boutique_nom }}
</span>
<span v-if="a.poids" class="text-[10px] bg-bg/40 px-2 py-0.5 rounded border border-bg-soft text-text-muted">
{{ a.poids }}
</span>
<span v-if="a.dluo" :class="['text-[10px] px-2 py-0.5 rounded border',
isDluoExpired(a.dluo) ? 'bg-red/10 border-red/40 text-red' : 'bg-bg/40 border-bg-soft text-text-muted']">
📅 DLUO: {{ a.dluo }}{{ isDluoExpired(a.dluo) ? ' ⚠️' : '' }}
</span>
</div>
</div>
</div>
</div>
<div v-else class="text-center py-16 text-text-muted/40 italic">
Aucun achat enregistré. Commencez par ajouter un terreau, engrais ou traitement.
</div>
</div>
<!-- ====== ONGLET FABRICATIONS ====== -->
<div v-if="activeTab === 'fabrications'">
<!-- Filtres -->
<div class="flex flex-wrap gap-2 mb-4">
<button v-for="t in typesFabrication" :key="t.val"
@click="filterType = t.val"
:class="['px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest transition-all border',
filterType === t.val ? 'bg-yellow text-bg border-yellow' : 'bg-bg-hard text-text-muted border-bg-soft hover:text-text']">
{{ t.label }}
</button>
<div class="w-px bg-bg-soft mx-1"></div>
<button v-for="s in statutsFabrication" :key="s.val"
@click="filterStatut = s.val"
:class="['px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest transition-all border',
filterStatut === s.val ? `${s.bgClass} text-bg border-transparent` : 'bg-bg-hard text-text-muted border-bg-soft hover:text-text']">
{{ s.label }}
</button>
</div>
<!-- Grille fabrications -->
<div v-if="fabricationsStore.loading" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<div v-for="i in 4" :key="i" class="card-jardin h-40 animate-pulse opacity-20"></div>
</div>
<div v-else-if="filteredFabricatons.length" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<div v-for="f in filteredFabricatons" :key="f.id"
class="card-jardin !p-0 overflow-hidden flex flex-col hover:border-yellow/40 transition-all border-l-[6px] cursor-pointer"
:style="{ borderLeftColor: statutColor(f.statut || 'en_cours') }"
@click="openDetailFabrication(f)">
<div class="p-4 flex-1">
<div class="flex items-start justify-between gap-2 mb-2">
<div class="flex-1 min-w-0">
<span :class="['text-[8px] font-black uppercase tracking-widest px-1.5 py-0.5 rounded', typeFabTextClass(f.type)]">
{{ f.type }}
</span>
<h3 class="text-text font-bold text-lg mt-1 leading-tight">{{ f.nom }}</h3>
</div>
<span :class="['text-[9px] font-black px-2 py-0.5 rounded-full border shrink-0', statutBadgeClass(f.statut || 'en_cours')]">
{{ statutLabel(f.statut || 'en_cours') }}
</span>
</div>
<!-- Ingrédients résumé -->
<p v-if="f.ingredients?.length" class="text-text-muted text-[10px] mb-2 truncate">
🌿 {{ f.ingredients.map(i => i.nom).join(', ') }}
</p>
<div class="flex flex-wrap gap-2">
<span v-if="f.date_fin_prevue" class="text-[10px] bg-bg/40 px-2 py-0.5 rounded border border-bg-soft text-text-muted">
📅 Prêt le {{ f.date_fin_prevue }}
</span>
<span v-if="f.quantite_produite" class="text-[10px] bg-bg/40 px-2 py-0.5 rounded border border-bg-soft text-text-muted">
{{ f.quantite_produite }}
</span>
</div>
</div>
<!-- Boutons rapides statut -->
<div v-if="f.statut === 'en_cours'" class="flex border-t border-bg-soft">
<button @click.stop="quickStatut(f, 'pret')"
class="flex-1 py-2 text-[10px] font-black text-green hover:bg-green/10 transition-colors">
Prêt
</button>
<div class="w-px bg-bg-soft"></div>
<button @click.stop="quickStatut(f, 'echec')"
class="flex-1 py-2 text-[10px] font-black text-red hover:bg-red/10 transition-colors">
Échec
</button>
</div>
</div>
</div>
<div v-else class="text-center py-16 text-text-muted/40 italic">
Aucune fabrication. Commencez par créer un compost ou une décoction.
</div>
</div>
<!-- ====== POPUP DÉTAIL ACHAT ====== -->
<div v-if="detailAchat" class="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4" @click.self="detailAchat = null">
<div class="bg-bg-hard rounded-3xl w-full max-w-lg border border-bg-soft shadow-2xl overflow-hidden flex flex-col max-h-[85vh]">
<div class="p-5 border-b border-bg-soft flex justify-between items-start" :style="{ borderLeft: `8px solid ${catAchatColor(detailAchat.categorie)}` }">
<div>
<span :class="['text-[9px] font-black uppercase tracking-widest px-2 py-0.5 rounded bg-bg/50', catAchatTextClass(detailAchat.categorie)]">
{{ detailAchat.categorie }}
</span>
<h2 class="text-text font-black text-2xl mt-1">{{ detailAchat.nom }}</h2>
<p v-if="detailAchat.marque" class="text-text-muted text-sm">{{ detailAchat.marque }}</p>
</div>
<button @click="detailAchat = null" class="text-text-muted hover:text-red text-2xl"></button>
</div>
<div class="p-5 overflow-y-auto space-y-4">
<div class="grid grid-cols-2 gap-3">
<div v-if="detailAchat.prix" 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">Prix</span>
<span class="text-yellow font-black text-lg">{{ detailAchat.prix.toFixed(2) }} </span>
</div>
<div v-if="detailAchat.poids" 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">Poids / Qté</span>
<span class="text-text font-bold">{{ detailAchat.poids }}</span>
</div>
<div v-if="detailAchat.boutique_nom" 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">Enseigne</span>
<span class="text-text font-bold">{{ detailAchat.boutique_nom }}</span>
</div>
<div v-if="detailAchat.date_achat" 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">Date d'achat</span>
<span class="text-text text-sm">{{ detailAchat.date_achat }}</span>
</div>
<div v-if="detailAchat.dluo" class="bg-bg/30 p-3 rounded-xl border border-bg-soft col-span-2">
<span class="text-[9px] font-black text-text-muted uppercase block mb-0.5">DLUO</span>
<span :class="['font-bold', isDluoExpired(detailAchat.dluo) ? 'text-red' : 'text-green']">
{{ detailAchat.dluo }}{{ isDluoExpired(detailAchat.dluo) ? ' Dépassée' : ' Valide' }}
</span>
</div>
</div>
<div v-if="detailAchat.boutique_url">
<a :href="detailAchat.boutique_url" target="_blank" rel="noopener"
class="text-blue text-sm hover:underline">🔗 Voir le produit en ligne</a>
</div>
<div v-if="detailAchat.notes" class="bg-bg/40 p-4 rounded-2xl border-l-4 border-yellow/30 text-text/90 text-sm italic whitespace-pre-line">
{{ detailAchat.notes }}
</div>
</div>
<div class="p-4 border-t border-bg-soft flex gap-3">
<button @click="startEditAchat(detailAchat)" class="btn-primary !bg-yellow !text-bg flex-1 py-2 font-black uppercase text-xs">Modifier</button>
<button @click="deleteAchat(detailAchat.id!)" class="btn-outline !border-red/20 !text-red hover:bg-red/10 px-4 py-2 font-black uppercase text-xs">Supprimer</button>
</div>
</div>
</div>
<!-- ====== POPUP DÉTAIL FABRICATION ====== -->
<div v-if="detailFabrication" class="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4" @click.self="detailFabrication = null">
<div class="bg-bg-hard rounded-3xl w-full max-w-lg border border-bg-soft shadow-2xl overflow-hidden flex flex-col max-h-[85vh]">
<div class="p-5 border-b border-bg-soft flex justify-between items-start" :style="{ borderLeft: `8px solid ${statutColor(detailFabrication.statut || 'en_cours')}` }">
<div>
<div class="flex items-center gap-2 mb-1">
<span :class="['text-[9px] font-black uppercase px-1.5 py-0.5 rounded', typeFabTextClass(detailFabrication.type)]">
{{ detailFabrication.type }}
</span>
<span :class="['text-[9px] font-black px-2 py-0.5 rounded-full border', statutBadgeClass(detailFabrication.statut || 'en_cours')]">
{{ statutLabel(detailFabrication.statut || 'en_cours') }}
</span>
</div>
<h2 class="text-text font-black text-2xl">{{ detailFabrication.nom }}</h2>
</div>
<button @click="detailFabrication = null" class="text-text-muted hover:text-red text-2xl">✕</button>
</div>
<div class="p-5 overflow-y-auto space-y-4">
<!-- Dates -->
<div class="grid grid-cols-2 gap-3">
<div v-if="detailFabrication.date_debut" 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">Début</span>
<span class="text-text text-sm">{{ detailFabrication.date_debut }}</span>
</div>
<div v-if="detailFabrication.date_fin_prevue" 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">Prêt le</span>
<span class="text-text text-sm">{{ detailFabrication.date_fin_prevue }}</span>
</div>
<div v-if="detailFabrication.quantite_produite" class="bg-bg/30 p-3 rounded-xl border border-bg-soft col-span-2">
<span class="text-[9px] font-black text-text-muted uppercase block mb-0.5">Quantité produite</span>
<span class="text-text font-bold">{{ detailFabrication.quantite_produite }}</span>
</div>
</div>
<!-- Ingrédients -->
<div v-if="detailFabrication.ingredients?.length" class="space-y-2">
<h3 class="text-[9px] font-black text-text-muted uppercase tracking-widest">🌿 Ingrédients</h3>
<div class="space-y-1">
<div v-for="(ing, i) in detailFabrication.ingredients" :key="i"
class="flex justify-between items-center bg-bg/30 px-3 py-2 rounded-lg border border-bg-soft">
<span class="text-text text-sm">{{ ing.nom }}</span>
<span class="text-yellow font-bold text-sm">{{ ing.quantite }}</span>
</div>
</div>
</div>
<!-- Notes -->
<div v-if="detailFabrication.notes" class="bg-bg/40 p-4 rounded-2xl border-l-4 border-yellow/30 text-text/90 text-sm italic whitespace-pre-line">
{{ detailFabrication.notes }}
</div>
<!-- Changement de statut -->
<div class="space-y-2">
<h3 class="text-[9px] font-black text-text-muted uppercase tracking-widest">Changer le statut</h3>
<div class="flex gap-2 flex-wrap">
<button v-for="s in statutsFabrication.slice(1)" :key="s.val"
@click="changeStatut(detailFabrication, s.val)"
:disabled="detailFabrication.statut === s.val"
:class="['px-3 py-1 rounded-lg text-[10px] font-black uppercase border transition-all',
detailFabrication.statut === s.val
? `${s.bgClass} text-bg border-transparent cursor-default`
: 'border-bg-soft text-text-muted hover:text-text']">
{{ s.label }}
</button>
</div>
</div>
</div>
<div class="p-4 border-t border-bg-soft flex gap-3">
<button @click="startEditFabrication(detailFabrication)" class="btn-primary !bg-yellow !text-bg flex-1 py-2 font-black uppercase text-xs">Modifier</button>
<button @click="deleteFabrication(detailFabrication.id!)" class="btn-outline !border-red/20 !text-red hover:bg-red/10 px-4 py-2 font-black uppercase text-xs">Supprimer</button>
</div>
</div>
</div>
<!-- ====== FORMULAIRE ACHAT ====== -->
<div v-if="showFormAchat" class="fixed inset-0 bg-black/80 backdrop-blur-sm z-[60] flex items-center justify-center p-4" @click.self="closeFormAchat">
<div class="bg-bg-hard rounded-3xl p-6 w-full max-w-2xl border border-bg-soft shadow-2xl max-h-[90vh] overflow-y-auto">
<div class="flex items-center justify-between mb-6 border-b border-bg-soft pb-4">
<h2 class="text-text font-black text-xl uppercase">{{ editAchat ? 'Modifier l\'achat' : 'Nouvel achat' }}</h2>
<button @click="closeFormAchat" class="text-text-muted hover:text-red text-2xl"></button>
</div>
<form @submit.prevent="submitAchat" 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">Catégorie *</label>
<div class="flex gap-2 flex-wrap">
<button v-for="cat in categoriesAchat.slice(1)" :key="cat.val" type="button"
@click="formAchat.categorie = cat.val"
:class="['px-3 py-1 rounded-full text-[10px] font-black uppercase border transition-all',
formAchat.categorie === cat.val ? 'border-transparent text-bg' : 'border-bg-soft text-text-muted hover:text-text']"
:style="formAchat.categorie === cat.val ? { background: catAchatColor(cat.val) } : {}">
{{ cat.label }}
</button>
</div>
</div>
<div class="md:col-span-2">
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Nom *</label>
<input v-model="formAchat.nom" 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: Terreau universel Floragard" />
</div>
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Marque</label>
<input v-model="formAchat.marque" 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">Enseigne</label>
<select v-model="formAchat.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="formAchat.prix" type="number" step="0.01" min="0" placeholder="0.00" 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 / Quantité</label>
<input v-model="formAchat.poids" placeholder="ex: 20L, 1kg, 500ml" 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="formAchat.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="formAchat.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="formAchat.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</label>
<textarea v-model="formAchat.notes" rows="2" 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="closeFormAchat" class="text-text-muted hover:text-red uppercase text-xs font-bold px-6">Annuler</button>
<button type="submit" :disabled="submitting" class="btn-primary px-8 py-3 !bg-yellow !text-bg font-black">
{{ editAchat ? 'Sauvegarder' : 'Enregistrer' }}
</button>
</div>
</form>
</div>
</div>
<!-- ====== FORMULAIRE FABRICATION ====== -->
<div v-if="showFormFab" class="fixed inset-0 bg-black/80 backdrop-blur-sm z-[60] flex items-center justify-center p-4" @click.self="closeFormFab">
<div class="bg-bg-hard rounded-3xl p-6 w-full max-w-2xl border border-bg-soft shadow-2xl max-h-[90vh] overflow-y-auto">
<div class="flex items-center justify-between mb-6 border-b border-bg-soft pb-4">
<h2 class="text-text font-black text-xl uppercase">{{ editFab ? 'Modifier' : 'Nouvelle fabrication' }}</h2>
<button @click="closeFormFab" class="text-text-muted hover:text-red text-2xl">✕</button>
</div>
<form @submit.prevent="submitFab" 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">Type *</label>
<div class="flex gap-2 flex-wrap">
<button v-for="t in typesFabrication.slice(1)" :key="t.val" type="button"
@click="formFab.type = t.val"
:class="['px-3 py-1 rounded-full text-[10px] font-black uppercase border transition-all',
formFab.type === t.val ? `${t.bgClass} text-bg border-transparent` : 'border-bg-soft text-text-muted hover:text-text']">
{{ t.label }}
</button>
</div>
</div>
<div class="md:col-span-2">
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Nom *</label>
<input v-model="formFab.nom" 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: Purin d'ortie mai 2026" />
</div>
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Date de début</label>
<input v-model="formFab.date_debut" 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">Date prévue prête</label>
<input v-model="formFab.date_fin_prevue" 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">Statut</label>
<select v-model="formFab.statut" 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 v-for="s in statutsFabrication.slice(1)" :key="s.val" :value="s.val">{{ s.label }}</option>
</select>
</div>
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Quantité produite</label>
<input v-model="formFab.quantite_produite" placeholder="ex: 8L, 50kg" 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>
<!-- Ingrédients -->
<div class="md:col-span-2 bg-bg/40 border border-bg-soft rounded-2xl p-4">
<div class="flex items-center justify-between mb-3">
<span class="text-[10px] font-black text-text-muted uppercase tracking-widest">🌿 Ingrédients</span>
<button type="button" @click="addIngredient"
class="px-2 py-0.5 rounded-full text-[10px] font-bold border border-green/40 text-green hover:bg-green/10 transition-all">
+ Ajouter
</button>
</div>
<div class="space-y-2">
<div v-for="(ing, i) in formFab.ingredients" :key="i" class="flex gap-2 items-center">
<input v-model="ing.nom" placeholder="Ingrédient" class="flex-1 bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm focus:border-yellow outline-none" />
<input v-model="ing.quantite" placeholder="Qté" class="w-24 bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm focus:border-yellow outline-none" />
<button type="button" @click="removeIngredient(i)" class="text-red/60 hover:text-red text-lg leading-none">✕</button>
</div>
<p v-if="!formFab.ingredients.length" class="text-text-muted/40 text-xs italic">Aucun ingrédient ajouté</p>
</div>
</div>
<div class="md:col-span-2">
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Notes / Recette</label>
<textarea v-model="formFab.notes" 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" placeholder="Instructions, observations, recette..." />
</div>
<div class="md:col-span-2 flex justify-between pt-4 border-t border-bg-soft">
<button type="button" @click="closeFormFab" class="text-text-muted hover:text-red uppercase text-xs font-bold px-6">Annuler</button>
<button type="submit" :disabled="submitting" class="btn-primary px-8 py-3 !bg-yellow !text-bg font-black">
{{ editFab ? 'Sauvegarder' : 'Créer' }}
</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import axios from 'axios'
import { useAchatsStore } from '@/stores/achats'
import { useFabricationsStore } from '@/stores/fabrications'
import type { AchatIntrant } from '@/api/achats'
import type { Fabrication } from '@/api/fabrications'
import { useToast } from '@/composables/useToast'
const achatsStore = useAchatsStore()
const fabricationsStore = useFabricationsStore()
const toast = useToast()
const activeTab = ref<'achats' | 'fabrications'>('achats')
const filterCat = ref('')
const filterType = ref('')
const filterStatut = ref('')
const submitting = ref(false)
// Détails
const detailAchat = ref<AchatIntrant | null>(null)
const detailFabrication = ref<Fabrication | null>(null)
// Formulaire achat
const showFormAchat = ref(false)
const editAchat = ref<AchatIntrant | null>(null)
const formAchat = reactive<Partial<AchatIntrant>>({
categorie: 'terreau', nom: '', marque: '', boutique_nom: '',
boutique_url: '', prix: undefined, poids: '', date_achat: '', dluo: '', notes: '',
})
// Formulaire fabrication
const showFormFab = ref(false)
const editFab = ref<Fabrication | null>(null)
const formFab = reactive({
type: 'purin', nom: '', date_debut: '', date_fin_prevue: '',
statut: 'en_cours', quantite_produite: '', notes: '',
ingredients: [] as { nom: string; quantite: string }[],
})
const tabs: { key: 'achats' | 'fabrications'; label: string }[] = [
{ key: 'achats', label: '🛒 Achats' },
{ key: 'fabrications', label: '🌿 Fabrications' },
]
const BOUTIQUES = [
'Gamm Vert', 'Lidl', 'Super U', 'Intermarché', 'Truffaut', 'Botanic',
'Amazon', 'Graines Baumaux', 'Vilmorin', 'Germinance', 'Direct producteur',
'Marché local', 'Autre',
]
const categoriesAchat = [
{ val: '', label: 'Tous' },
{ val: 'terreau', label: '🪨 Terreau' },
{ val: 'engrais', label: '🌿 Engrais' },
{ val: 'traitement', label: '💊 Traitement' },
{ val: 'autre', label: 'Autre' },
]
const typesFabrication = [
{ val: '', label: 'Tous', bgClass: 'bg-yellow' },
{ val: 'compost', label: '♻️ Compost', bgClass: 'bg-orange' },
{ val: 'decoction', label: '🫖 Décoction', bgClass: 'bg-blue' },
{ val: 'purin', label: '🌱 Purin', bgClass: 'bg-green' },
{ val: 'autre', label: 'Autre', bgClass: 'bg-text-muted' },
]
const statutsFabrication = [
{ val: '', label: 'Tous', bgClass: 'bg-yellow' },
{ val: 'en_cours', label: '⏳ En cours', bgClass: 'bg-orange' },
{ val: 'pret', label: '✓ Prêt', bgClass: 'bg-green' },
{ val: 'utilise', label: '✓ Utilisé', bgClass: 'bg-bg-soft' },
{ val: 'echec', label: '✗ Échec', bgClass: 'bg-red' },
]
const filteredAchats = computed(() => {
let list = achatsStore.achats
if (filterCat.value) list = list.filter(a => a.categorie === filterCat.value)
return list
})
const filteredFabricatons = computed(() => {
let list = fabricationsStore.fabrications
if (filterType.value) list = list.filter(f => f.type === filterType.value)
if (filterStatut.value) list = list.filter(f => f.statut === filterStatut.value)
return list
})
function isDluoExpired(dluo: string) {
return !!dluo && new Date(dluo) < new Date()
}
function catAchatColor(cat: string) {
return ({ terreau: '#fe8019', engrais: '#b8bb26', traitement: '#83a598', autre: '#928374' } as any)[cat] || '#928374'
}
function catAchatTextClass(cat: string) {
return ({ terreau: 'text-orange', engrais: 'text-green', traitement: 'text-blue', autre: 'text-text-muted' } as any)[cat] || 'text-text-muted'
}
function statutColor(statut: string) {
return ({ en_cours: '#fe8019', pret: '#b8bb26', utilise: '#928374', echec: '#fb4934' } as any)[statut] || '#928374'
}
function statutBadgeClass(statut: string) {
return ({
en_cours: 'bg-orange/10 border-orange/40 text-orange',
pret: 'bg-green/10 border-green/40 text-green',
utilise: 'bg-bg-soft border-bg-soft text-text-muted',
echec: 'bg-red/10 border-red/40 text-red',
} as any)[statut] || ''
}
function statutLabel(statut: string) {
return ({ en_cours: '⏳ En cours', pret: '✓ Prêt', utilise: '✓ Utilisé', echec: '✗ Échec' } as any)[statut] || statut
}
function typeFabTextClass(type: string) {
return ({ compost: 'text-orange', decoction: 'text-blue', purin: 'text-green', autre: 'text-text-muted' } as any)[type] || 'text-text-muted'
}
// ---- Achats ----
function openCreateForm() {
if (activeTab.value === 'achats') {
editAchat.value = null
Object.assign(formAchat, { categorie: 'terreau', nom: '', marque: '', boutique_nom: '', boutique_url: '', prix: undefined, poids: '', date_achat: '', dluo: '', notes: '' })
showFormAchat.value = true
} else {
editFab.value = null
Object.assign(formFab, { type: 'purin', nom: '', date_debut: '', date_fin_prevue: '', statut: 'en_cours', quantite_produite: '', notes: '', ingredients: [] })
showFormFab.value = true
}
}
function openDetailAchat(a: AchatIntrant) { detailAchat.value = a }
function startEditAchat(a: AchatIntrant) {
detailAchat.value = null
editAchat.value = a
Object.assign(formAchat, { ...a })
showFormAchat.value = true
}
function closeFormAchat() { showFormAchat.value = false; editAchat.value = null }
async function submitAchat() {
if (submitting.value) return
submitting.value = true
try {
const payload = { ...formAchat, prix: formAchat.prix ?? undefined }
if (editAchat.value) {
await axios.put(`/api/achats/${editAchat.value.id}`, payload)
await achatsStore.fetchAll()
toast.success('Achat modifié')
} else {
await achatsStore.create(payload)
toast.success('Achat enregistré')
}
closeFormAchat()
} catch { /* intercepteur */ } finally { submitting.value = false }
}
async function deleteAchat(id: number) {
if (!confirm('Supprimer cet achat ?')) return
await achatsStore.remove(id)
detailAchat.value = null
toast.success('Achat supprimé')
}
// ---- Fabrications ----
function openDetailFabrication(f: Fabrication) { detailFabrication.value = f }
function startEditFabrication(f: Fabrication) {
detailFabrication.value = null
editFab.value = f
Object.assign(formFab, {
...f,
ingredients: f.ingredients ? [...f.ingredients.map(i => ({ ...i }))] : [],
})
showFormFab.value = true
}
function closeFormFab() { showFormFab.value = false; editFab.value = null }
function addIngredient() { formFab.ingredients.push({ nom: '', quantite: '' }) }
function removeIngredient(i: number) { formFab.ingredients.splice(i, 1) }
async function submitFab() {
if (submitting.value) return
submitting.value = true
try {
const payload = { ...formFab, ingredients: formFab.ingredients.filter(i => i.nom) }
if (editFab.value) {
await axios.put(`/api/fabrications/${editFab.value.id}`, payload)
await fabricationsStore.fetchAll()
toast.success('Fabrication modifiée')
} else {
await fabricationsStore.create(payload)
toast.success('Fabrication créée')
}
closeFormFab()
} catch { /* intercepteur */ } finally { submitting.value = false }
}
async function quickStatut(f: Fabrication, statut: string) {
await fabricationsStore.updateStatut(f.id!, statut)
toast.success(`Statut → ${statutLabel(statut)}`)
}
async function changeStatut(f: Fabrication, statut: string) {
await fabricationsStore.updateStatut(f.id!, statut)
detailFabrication.value = fabricationsStore.fabrications.find(x => x.id === f.id) ?? detailFabrication.value
toast.success(`Statut → ${statutLabel(statut)}`)
}
async function deleteFabrication(id: number) {
if (!confirm('Supprimer cette fabrication ?')) return
await fabricationsStore.remove(id)
detailFabrication.value = null
toast.success('Fabrication supprimée')
}
onMounted(async () => {
await Promise.all([achatsStore.fetchAll(), fabricationsStore.fetchAll()])
})
</script>

File diff suppressed because it is too large Load Diff

View File

@@ -85,7 +85,7 @@
<!-- Navigation variétés -->
<div class="flex items-center gap-3 mt-3">
<div v-if="detailVarieties.length > 1" class="flex items-center gap-2">
<div v-if="detailPlantGroup.length > 1" class="flex items-center gap-2">
<button @click="prevVariety"
:disabled="detailVarietyIdx === 0"
:class="['w-7 h-7 rounded-full border flex items-center justify-center text-sm font-bold transition-all',
@@ -93,16 +93,16 @@
</button>
<span class="text-text-muted text-xs font-bold">
Variété {{ detailVarietyIdx + 1 }} / {{ detailVarieties.length }}
Variété {{ detailVarietyIdx + 1 }} / {{ detailPlantGroup.length }}
</span>
<button @click="nextVariety"
:disabled="detailVarietyIdx === detailVarieties.length - 1"
:disabled="detailVarietyIdx === detailPlantGroup.length - 1"
:class="['w-7 h-7 rounded-full border flex items-center justify-center text-sm font-bold transition-all',
detailVarietyIdx === detailVarieties.length - 1 ? 'border-bg-soft text-text-muted/30 cursor-not-allowed' : 'border-yellow/50 text-yellow hover:bg-yellow/10']">
detailVarietyIdx === detailPlantGroup.length - 1 ? 'border-bg-soft text-text-muted/30 cursor-not-allowed' : 'border-yellow/50 text-yellow hover:bg-yellow/10']">
</button>
</div>
<p v-if="detailPlant.variete" class="text-yellow font-bold uppercase tracking-widest text-sm">{{ detailPlant.variete }}</p>
<p v-if="detailPlant.varieties?.[0]?.variete" class="text-yellow font-bold uppercase tracking-widest text-sm">{{ detailPlant.varieties?.[0]?.variete }}</p>
</div>
</div>
<button @click="closeDetail" class="text-text-muted hover:text-red transition-colors text-2xl ml-4"></button>
@@ -134,37 +134,45 @@
<span class="font-bold">Mois: {{ detailPlant.plantation_mois }}</span>
</div>
</div>
<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>
</div>
<!-- Boutique -->
<div v-if="detailPlant.boutique_nom || detailPlant.prix_achat || detailPlant.poids || detailPlant.dluo" class="space-y-2">
<div v-if="detailPlant.varieties?.[0]?.boutique_nom || detailPlant.varieties?.[0]?.prix_achat || detailPlant.varieties?.[0]?.poids || detailPlant.varieties?.[0]?.dluo" class="space-y-2">
<h3 class="text-[10px] font-black text-text-muted uppercase tracking-widest">🛒 Approvisionnement</h3>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
<div v-if="detailPlant.boutique_nom" class="bg-bg/30 p-3 rounded-xl border border-bg-soft">
<div v-if="detailPlant.varieties?.[0]?.boutique_nom" 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">Enseigne</span>
<span class="text-text text-sm font-bold">{{ detailPlant.boutique_nom }}</span>
<span class="text-text text-sm font-bold">{{ detailPlant.varieties?.[0]?.boutique_nom }}</span>
</div>
<div v-if="detailPlant.prix_achat" class="bg-bg/30 p-3 rounded-xl border border-bg-soft">
<div v-if="detailPlant.varieties?.[0]?.prix_achat" 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">Prix</span>
<span class="text-yellow font-bold">{{ detailPlant.prix_achat.toFixed(2) }} </span>
<span class="text-yellow font-bold">{{ detailPlant.varieties?.[0]?.prix_achat?.toFixed(2) }} </span>
</div>
<div v-if="detailPlant.date_achat" class="bg-bg/30 p-3 rounded-xl border border-bg-soft">
<div v-if="detailPlant.varieties?.[0]?.date_achat" 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">Date d'achat</span>
<span class="text-text text-sm">{{ detailPlant.date_achat }}</span>
<span class="text-text text-sm">{{ detailPlant.varieties?.[0]?.date_achat }}</span>
</div>
<div v-if="detailPlant.poids" class="bg-bg/30 p-3 rounded-xl border border-bg-soft">
<div v-if="detailPlant.varieties?.[0]?.poids" 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">Poids / Qté</span>
<span class="text-text text-sm font-bold">{{ detailPlant.poids }}</span>
<span class="text-text text-sm font-bold">{{ detailPlant.varieties?.[0]?.poids }}</span>
</div>
<div v-if="detailPlant.dluo" class="bg-bg/30 p-3 rounded-xl border border-bg-soft">
<div v-if="detailPlant.varieties?.[0]?.dluo" 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">DLUO</span>
<span :class="['text-sm font-bold', isDluoExpired(detailPlant.dluo) ? 'text-red' : 'text-green']">
{{ detailPlant.dluo }}{{ isDluoExpired(detailPlant.dluo) ? ' ' : '' }}
<span :class="['text-sm font-bold', isDluoExpired(detailPlant.varieties?.[0]?.dluo ?? '') ? 'text-red' : 'text-green']">
{{ detailPlant.varieties?.[0]?.dluo }}{{ isDluoExpired(detailPlant.varieties?.[0]?.dluo ?? '') ? ' ' : '' }}
</span>
</div>
<div v-if="detailPlant.boutique_url" class="bg-bg/30 p-3 rounded-xl border border-bg-soft">
<div v-if="detailPlant.varieties?.[0]?.boutique_url" 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">Lien</span>
<a :href="detailPlant.boutique_url" target="_blank" rel="noopener"
<a :href="detailPlant.varieties?.[0]?.boutique_url" target="_blank" rel="noopener"
class="text-blue text-xs hover:underline truncate block">🔗 Voir le produit</a>
</div>
</div>
@@ -203,6 +211,28 @@
</div>
</div>
<!-- Variétés (PlantVariety) -->
<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>
<!-- Galerie Photos -->
<div class="space-y-3">
<div class="flex justify-between items-center">
@@ -228,6 +258,10 @@
<!-- Footer -->
<div class="p-4 bg-bg-hard border-t border-bg-soft flex gap-3">
<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>
<button @click="startEdit(detailPlant)" class="btn-primary !bg-yellow !text-bg flex-1 py-3 font-black uppercase text-xs tracking-widest">Modifier la fiche</button>
<button @click="removePlant(detailPlant.id!)" class="btn-outline !border-red/20 !text-red hover:bg-red/10 px-6 py-3 font-black uppercase text-xs tracking-widest">Supprimer</button>
</div>
@@ -462,6 +496,81 @@
<!-- Upload Photo -->
<input type="file" ref="fileInput" accept="image/*" class="hidden" @change="handleFileUpload" />
<!-- ====== 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>
</div>
</template>
@@ -469,7 +578,7 @@
import { computed, onMounted, reactive, ref } from 'vue'
import axios from 'axios'
import { usePlantsStore } from '@/stores/plants'
import type { Plant } from '@/api/plants'
import type { Plant, PlantVariety } from '@/api/plants'
import { useToast } from '@/composables/useToast'
const plantsStore = usePlantsStore()
@@ -485,17 +594,35 @@ const lightbox = ref<Media | null>(null)
const fileInput = ref<HTMLInputElement | null>(null)
const uploadTarget = ref<Plant | null>(null)
// Navigation variétés
const detailVarieties = ref<Plant[]>([])
// Navigation variétés (groupe de Plant par nom_commun)
const detailPlantGroup = ref<Plant[]>([])
const detailVarietyIdx = ref(0)
const detailPlant = computed(() => detailVarieties.value[detailVarietyIdx.value] ?? null)
const detailPlant = computed(() => detailPlantGroup.value[detailVarietyIdx.value] ?? null)
// detailPlantObj : ref sur la plante ouverte en détail (Plant complet)
const detailPlantObj = ref<Plant | null>(null)
// detailVarieties : variétés (PlantVariety) de la plante affichée
const detailVarieties = computed<PlantVariety[]>(() => {
if (!detailPlantObj.value) return []
return detailPlantObj.value.varieties ?? []
})
// Associations au niveau nom_commun (première variété ayant des données)
const detailAssociations = computed(() =>
detailVarieties.value.find(v => v.associations_favorables?.length || v.associations_defavorables?.length)
detailPlantGroup.value.find(v => v.associations_favorables?.length || v.associations_defavorables?.length)
?? detailPlant.value
)
// Refs pour le formulaire variété
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: '',
})
interface Media {
id: number; entity_type: string; entity_id: number
url: string; thumbnail_url?: string; titre?: string
@@ -572,7 +699,7 @@ const filteredPlants = computed(() => {
result = result.filter(p => {
const group = plantGroups.value.get((p.nom_commun || '').toLowerCase()) || []
return group.some(v =>
v.nom_commun?.toLowerCase().includes(q) || v.variete?.toLowerCase().includes(q)
v.nom_commun?.toLowerCase().includes(q) || v.varieties?.[0]?.variete?.toLowerCase().includes(q)
)
})
}
@@ -637,7 +764,8 @@ function catTextClass(cat: string) {
}
function closeDetail() {
detailVarieties.value = []
detailPlantGroup.value = []
detailPlantObj.value = null
detailVarietyIdx.value = 0
plantPhotos.value = []
}
@@ -645,21 +773,24 @@ function closeDetail() {
async function openDetails(p: Plant) {
const key = (p.nom_commun || '').toLowerCase()
const group = plantGroups.value.get(key) || [p]
detailVarieties.value = [...group].sort((a, b) => (a.id || 0) - (b.id || 0))
detailPlantGroup.value = [...group].sort((a, b) => (a.id || 0) - (b.id || 0))
detailVarietyIdx.value = 0
await fetchPhotos(detailVarieties.value[0].id!)
detailPlantObj.value = detailPlantGroup.value[0]
await fetchPhotos(detailPlantGroup.value[0].id!)
}
async function prevVariety() {
if (detailVarietyIdx.value > 0) {
detailVarietyIdx.value--
detailPlantObj.value = detailPlant.value
await fetchPhotos(detailPlant.value!.id!)
}
}
async function nextVariety() {
if (detailVarietyIdx.value < detailVarieties.value.length - 1) {
if (detailVarietyIdx.value < detailPlantGroup.value.length - 1) {
detailVarietyIdx.value++
detailPlantObj.value = detailPlant.value
await fetchPhotos(detailPlant.value!.id!)
}
}
@@ -684,15 +815,16 @@ function startEdit(p: Plant) {
closeDetail()
editPlant.value = p
const v0 = p.varieties?.[0]
Object.assign(form, {
nom_commun: p.nom_commun || '', variete: p.variete || '', famille: p.famille || '',
nom_commun: p.nom_commun || '', variete: v0?.variete || '', famille: p.famille || '',
categorie: p.categorie || 'potager', besoin_eau: p.besoin_eau || 'moyen', besoin_soleil: p.besoin_soleil || 'plein soleil',
plantation_mois: p.plantation_mois || '', notes: p.notes || '',
associations_favorables: [...(withAssoc.associations_favorables ?? [])],
associations_defavorables: [...(withAssoc.associations_defavorables ?? [])],
boutique_nom: p.boutique_nom || '', boutique_url: p.boutique_url || '',
prix_achat: p.prix_achat ?? null, date_achat: p.date_achat || '',
poids: p.poids || '', dluo: p.dluo || '',
boutique_nom: v0?.boutique_nom || '', boutique_url: v0?.boutique_url || '',
prix_achat: v0?.prix_achat ?? null, date_achat: v0?.date_achat || '',
poids: v0?.poids || '', dluo: v0?.dluo || '',
})
assocFilter.value = ''
showAssocModal.value = false
@@ -711,15 +843,85 @@ function closeForm() {
})
}
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 }
try {
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')
}
// Refresh plant data so detailPlantObj reflects updated varieties
await plantsStore.fetchAll()
const updatedPlant = plantsStore.plants.find(p => p.id === detailPlantObj.value?.id)
if (updatedPlant) detailPlantObj.value = updatedPlant
closeFormVariety()
} catch {
// L'intercepteur Axios affiche le message d'erreur
}
}
async function deleteVariety(vid: number) {
if (!detailPlantObj.value?.id) return
if (!confirm('Supprimer cette variété ?')) return
try {
await plantsStore.removeVariety(detailPlantObj.value.id, vid)
await plantsStore.fetchAll()
const updatedPlant = plantsStore.plants.find(p => p.id === detailPlantObj.value?.id)
if (updatedPlant) detailPlantObj.value = updatedPlant
toast.success('Variété supprimée')
} catch {
// L'intercepteur Axios affiche le message d'erreur
}
}
async function submitPlant() {
if (submitting.value) return
submitting.value = true
try {
const payload = { ...form, prix_achat: form.prix_achat ?? undefined }
if (editPlant.value) {
await axios.put(`/api/plants/${editPlant.value.id}`, payload)
// Extraire les champs variété du form (non envoyés dans Plant)
const varietyPayload: Partial<import('@/api/plants').PlantVariety> = {
variete: form.variete || undefined,
boutique_nom: form.boutique_nom || undefined,
boutique_url: form.boutique_url || undefined,
prix_achat: form.prix_achat ?? undefined,
date_achat: form.date_achat || undefined,
poids: form.poids || undefined,
dluo: form.dluo || undefined,
}
// Payload Plant pur (sans champs variété)
const { variete: _v, boutique_nom: _bn, boutique_url: _bu, prix_achat: _pa,
date_achat: _da, poids: _po, dluo: _d, ...plantPayload } = { ...form }
// Synchroniser les associations à toutes les variétés du même nom commun
if (editPlant.value) {
await axios.put(`/api/plants/${editPlant.value.id}`, plantPayload)
// Synchroniser associations aux plantes du même nom commun
const nomKey = form.nom_commun.toLowerCase()
const siblings = plantsStore.plants.filter(
p => p.id !== editPlant.value!.id && (p.nom_commun || '').toLowerCase() === nomKey
@@ -731,10 +933,26 @@ async function submitPlant() {
})
}
// Mettre à jour ou créer la variété
const existingVariety = editPlant.value.varieties?.[0]
const hasVarietyData = Object.values(varietyPayload).some(v => v !== undefined)
const plantId = editPlant.value.id!
if (existingVariety?.id) {
await plantsStore.updateVariety(plantId, existingVariety.id!, varietyPayload)
} else if (hasVarietyData) {
await plantsStore.createVariety(plantId, varietyPayload)
}
await plantsStore.fetchAll()
toast.success('Plante modifiée')
} else {
await plantsStore.create(payload)
const created = await plantsStore.create(plantPayload)
// Créer la variété si des données variété sont présentes
const hasVarietyData = Object.values(varietyPayload).some(v => v !== undefined)
if (created.id && hasVarietyData) {
await plantsStore.createVariety(created.id, varietyPayload)
}
await plantsStore.fetchAll()
toast.success('Plante créée')
}
closeForm()

View File

@@ -15,13 +15,29 @@ const loadingPhotos = ref(false);
const lightbox = ref(null);
const fileInput = ref(null);
const uploadTarget = ref(null);
// Navigation variétés
const detailVarieties = ref([]);
// Navigation variétés (groupe de Plant par nom_commun)
const detailPlantGroup = ref([]);
const detailVarietyIdx = ref(0);
const detailPlant = computed(() => detailVarieties.value[detailVarietyIdx.value] ?? null);
const detailPlant = computed(() => detailPlantGroup.value[detailVarietyIdx.value] ?? null);
// detailPlantObj : ref sur la plante ouverte en détail (Plant complet)
const detailPlantObj = ref(null);
// detailVarieties : variétés (PlantVariety) de la plante affichée
const detailVarieties = computed(() => {
if (!detailPlantObj.value)
return [];
return detailPlantObj.value.varieties ?? [];
});
// Associations au niveau nom_commun (première variété ayant des données)
const detailAssociations = computed(() => detailVarieties.value.find(v => v.associations_favorables?.length || v.associations_defavorables?.length)
const detailAssociations = computed(() => detailPlantGroup.value.find(v => v.associations_favorables?.length || v.associations_defavorables?.length)
?? detailPlant.value);
// Refs pour le formulaire variété
const showFormVariety = ref(false);
const editVariety = ref(null);
const formVariety = reactive({
variete: '', tags: '', notes_variete: '',
boutique_nom: '', boutique_url: '', prix_achat: undefined,
date_achat: '', poids: '', dluo: '',
});
const categories = [
{ val: '', label: 'TOUTES' },
{ val: 'potager', label: '🥕 POTAGER' },
@@ -84,7 +100,7 @@ const filteredPlants = computed(() => {
const q = searchQuery.value.toLowerCase();
result = result.filter(p => {
const group = plantGroups.value.get((p.nom_commun || '').toLowerCase()) || [];
return group.some(v => v.nom_commun?.toLowerCase().includes(q) || v.variete?.toLowerCase().includes(q));
return group.some(v => v.nom_commun?.toLowerCase().includes(q) || v.varieties?.[0]?.variete?.toLowerCase().includes(q));
});
}
return result.sort((a, b) => (a.nom_commun || '').localeCompare(b.nom_commun || '', 'fr'));
@@ -144,26 +160,30 @@ function catTextClass(cat) {
}[cat] || 'text-text-muted';
}
function closeDetail() {
detailVarieties.value = [];
detailPlantGroup.value = [];
detailPlantObj.value = null;
detailVarietyIdx.value = 0;
plantPhotos.value = [];
}
async function openDetails(p) {
const key = (p.nom_commun || '').toLowerCase();
const group = plantGroups.value.get(key) || [p];
detailVarieties.value = [...group].sort((a, b) => (a.id || 0) - (b.id || 0));
detailPlantGroup.value = [...group].sort((a, b) => (a.id || 0) - (b.id || 0));
detailVarietyIdx.value = 0;
await fetchPhotos(detailVarieties.value[0].id);
detailPlantObj.value = detailPlantGroup.value[0];
await fetchPhotos(detailPlantGroup.value[0].id);
}
async function prevVariety() {
if (detailVarietyIdx.value > 0) {
detailVarietyIdx.value--;
detailPlantObj.value = detailPlant.value;
await fetchPhotos(detailPlant.value.id);
}
}
async function nextVariety() {
if (detailVarietyIdx.value < detailVarieties.value.length - 1) {
if (detailVarietyIdx.value < detailPlantGroup.value.length - 1) {
detailVarietyIdx.value++;
detailPlantObj.value = detailPlant.value;
await fetchPhotos(detailPlant.value.id);
}
}
@@ -186,15 +206,16 @@ function startEdit(p) {
const withAssoc = group.find(v => v.associations_favorables?.length || v.associations_defavorables?.length) ?? p;
closeDetail();
editPlant.value = p;
const v0 = p.varieties?.[0];
Object.assign(form, {
nom_commun: p.nom_commun || '', variete: p.variete || '', famille: p.famille || '',
nom_commun: p.nom_commun || '', variete: v0?.variete || '', famille: p.famille || '',
categorie: p.categorie || 'potager', besoin_eau: p.besoin_eau || 'moyen', besoin_soleil: p.besoin_soleil || 'plein soleil',
plantation_mois: p.plantation_mois || '', notes: p.notes || '',
associations_favorables: [...(withAssoc.associations_favorables ?? [])],
associations_defavorables: [...(withAssoc.associations_defavorables ?? [])],
boutique_nom: p.boutique_nom || '', boutique_url: p.boutique_url || '',
prix_achat: p.prix_achat ?? null, date_achat: p.date_achat || '',
poids: p.poids || '', dluo: p.dluo || '',
boutique_nom: v0?.boutique_nom || '', boutique_url: v0?.boutique_url || '',
prix_achat: v0?.prix_achat ?? null, date_achat: v0?.date_achat || '',
poids: v0?.poids || '', dluo: v0?.dluo || '',
});
assocFilter.value = '';
showAssocModal.value = false;
@@ -211,15 +232,87 @@ function closeForm() {
date_achat: '', poids: '', dluo: '',
});
}
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) {
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 };
try {
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');
}
// Refresh plant data so detailPlantObj reflects updated varieties
await plantsStore.fetchAll();
const updatedPlant = plantsStore.plants.find(p => p.id === detailPlantObj.value?.id);
if (updatedPlant)
detailPlantObj.value = updatedPlant;
closeFormVariety();
}
catch {
// L'intercepteur Axios affiche le message d'erreur
}
}
async function deleteVariety(vid) {
if (!detailPlantObj.value?.id)
return;
if (!confirm('Supprimer cette variété ?'))
return;
try {
await plantsStore.removeVariety(detailPlantObj.value.id, vid);
await plantsStore.fetchAll();
const updatedPlant = plantsStore.plants.find(p => p.id === detailPlantObj.value?.id);
if (updatedPlant)
detailPlantObj.value = updatedPlant;
toast.success('Variété supprimée');
}
catch {
// L'intercepteur Axios affiche le message d'erreur
}
}
async function submitPlant() {
if (submitting.value)
return;
submitting.value = true;
try {
const payload = { ...form, prix_achat: form.prix_achat ?? undefined };
// Extraire les champs variété du form (non envoyés dans Plant)
const varietyPayload = {
variete: form.variete || undefined,
boutique_nom: form.boutique_nom || undefined,
boutique_url: form.boutique_url || undefined,
prix_achat: form.prix_achat ?? undefined,
date_achat: form.date_achat || undefined,
poids: form.poids || undefined,
dluo: form.dluo || undefined,
};
// Payload Plant pur (sans champs variété)
const { variete: _v, boutique_nom: _bn, boutique_url: _bu, prix_achat: _pa, date_achat: _da, poids: _po, dluo: _d, ...plantPayload } = { ...form };
if (editPlant.value) {
await axios.put(`/api/plants/${editPlant.value.id}`, payload);
// Synchroniser les associations à toutes les variétés du même nom commun
await axios.put(`/api/plants/${editPlant.value.id}`, plantPayload);
// Synchroniser associations aux plantes du même nom commun
const nomKey = form.nom_commun.toLowerCase();
const siblings = plantsStore.plants.filter(p => p.id !== editPlant.value.id && (p.nom_commun || '').toLowerCase() === nomKey);
for (const sibling of siblings) {
@@ -228,11 +321,27 @@ async function submitPlant() {
associations_defavorables: form.associations_defavorables,
});
}
// Mettre à jour ou créer la variété
const existingVariety = editPlant.value.varieties?.[0];
const hasVarietyData = Object.values(varietyPayload).some(v => v !== undefined);
const plantId = editPlant.value.id;
if (existingVariety?.id) {
await plantsStore.updateVariety(plantId, existingVariety.id, varietyPayload);
}
else if (hasVarietyData) {
await plantsStore.createVariety(plantId, varietyPayload);
}
await plantsStore.fetchAll();
toast.success('Plante modifiée');
}
else {
await plantsStore.create(payload);
const created = await plantsStore.create(plantPayload);
// Créer la variété si des données variété sont présentes
const hasVarietyData = Object.values(varietyPayload).some(v => v !== undefined);
if (created.id && hasVarietyData) {
await plantsStore.createVariety(created.id, varietyPayload);
}
await plantsStore.fetchAll();
toast.success('Plante créée');
}
closeForm();
@@ -470,7 +579,7 @@ if (__VLS_ctx.detailPlant) {
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
...{ class: "flex items-center gap-3 mt-3" },
});
if (__VLS_ctx.detailVarieties.length > 1) {
if (__VLS_ctx.detailPlantGroup.length > 1) {
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
...{ class: "flex items-center gap-2" },
});
@@ -484,19 +593,19 @@ if (__VLS_ctx.detailPlant) {
...{ class: "text-text-muted text-xs font-bold" },
});
(__VLS_ctx.detailVarietyIdx + 1);
(__VLS_ctx.detailVarieties.length);
(__VLS_ctx.detailPlantGroup.length);
__VLS_asFunctionalElement(__VLS_intrinsicElements.button, __VLS_intrinsicElements.button)({
...{ onClick: (__VLS_ctx.nextVariety) },
disabled: (__VLS_ctx.detailVarietyIdx === __VLS_ctx.detailVarieties.length - 1),
disabled: (__VLS_ctx.detailVarietyIdx === __VLS_ctx.detailPlantGroup.length - 1),
...{ class: (['w-7 h-7 rounded-full border flex items-center justify-center text-sm font-bold transition-all',
__VLS_ctx.detailVarietyIdx === __VLS_ctx.detailVarieties.length - 1 ? 'border-bg-soft text-text-muted/30 cursor-not-allowed' : 'border-yellow/50 text-yellow hover:bg-yellow/10']) },
__VLS_ctx.detailVarietyIdx === __VLS_ctx.detailPlantGroup.length - 1 ? 'border-bg-soft text-text-muted/30 cursor-not-allowed' : 'border-yellow/50 text-yellow hover:bg-yellow/10']) },
});
}
if (__VLS_ctx.detailPlant.variete) {
if (__VLS_ctx.detailPlant.varieties?.[0]?.variete) {
__VLS_asFunctionalElement(__VLS_intrinsicElements.p, __VLS_intrinsicElements.p)({
...{ class: "text-yellow font-bold uppercase tracking-widest text-sm" },
});
(__VLS_ctx.detailPlant.variete);
(__VLS_ctx.detailPlant.varieties?.[0]?.variete);
}
__VLS_asFunctionalElement(__VLS_intrinsicElements.button, __VLS_intrinsicElements.button)({
...{ onClick: (__VLS_ctx.closeDetail) },
@@ -550,7 +659,31 @@ if (__VLS_ctx.detailPlant) {
...{ class: "font-bold" },
});
(__VLS_ctx.detailPlant.plantation_mois);
if (__VLS_ctx.detailPlant.boutique_nom || __VLS_ctx.detailPlant.prix_achat || __VLS_ctx.detailPlant.poids || __VLS_ctx.detailPlant.dluo) {
if (__VLS_ctx.detailPlantObj?.temp_germination) {
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
...{ class: "bg-bg/30 p-3 rounded-xl border border-bg-soft" },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.span, __VLS_intrinsicElements.span)({
...{ class: "text-[9px] font-black text-text-muted uppercase block mb-0.5" },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.span, __VLS_intrinsicElements.span)({
...{ class: "text-text text-sm" },
});
(__VLS_ctx.detailPlantObj.temp_germination);
}
if (__VLS_ctx.detailPlantObj?.temps_levee_j) {
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
...{ class: "bg-bg/30 p-3 rounded-xl border border-bg-soft" },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.span, __VLS_intrinsicElements.span)({
...{ class: "text-[9px] font-black text-text-muted uppercase block mb-0.5" },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.span, __VLS_intrinsicElements.span)({
...{ class: "text-text text-sm" },
});
(__VLS_ctx.detailPlantObj.temps_levee_j);
}
if (__VLS_ctx.detailPlant.varieties?.[0]?.boutique_nom || __VLS_ctx.detailPlant.varieties?.[0]?.prix_achat || __VLS_ctx.detailPlant.varieties?.[0]?.poids || __VLS_ctx.detailPlant.varieties?.[0]?.dluo) {
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
...{ class: "space-y-2" },
});
@@ -560,7 +693,7 @@ if (__VLS_ctx.detailPlant) {
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
...{ class: "grid grid-cols-2 sm:grid-cols-3 gap-3" },
});
if (__VLS_ctx.detailPlant.boutique_nom) {
if (__VLS_ctx.detailPlant.varieties?.[0]?.boutique_nom) {
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
...{ class: "bg-bg/30 p-3 rounded-xl border border-bg-soft" },
});
@@ -570,9 +703,9 @@ if (__VLS_ctx.detailPlant) {
__VLS_asFunctionalElement(__VLS_intrinsicElements.span, __VLS_intrinsicElements.span)({
...{ class: "text-text text-sm font-bold" },
});
(__VLS_ctx.detailPlant.boutique_nom);
(__VLS_ctx.detailPlant.varieties?.[0]?.boutique_nom);
}
if (__VLS_ctx.detailPlant.prix_achat) {
if (__VLS_ctx.detailPlant.varieties?.[0]?.prix_achat) {
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
...{ class: "bg-bg/30 p-3 rounded-xl border border-bg-soft" },
});
@@ -582,9 +715,9 @@ if (__VLS_ctx.detailPlant) {
__VLS_asFunctionalElement(__VLS_intrinsicElements.span, __VLS_intrinsicElements.span)({
...{ class: "text-yellow font-bold" },
});
(__VLS_ctx.detailPlant.prix_achat.toFixed(2));
(__VLS_ctx.detailPlant.varieties?.[0]?.prix_achat?.toFixed(2));
}
if (__VLS_ctx.detailPlant.date_achat) {
if (__VLS_ctx.detailPlant.varieties?.[0]?.date_achat) {
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
...{ class: "bg-bg/30 p-3 rounded-xl border border-bg-soft" },
});
@@ -594,9 +727,9 @@ if (__VLS_ctx.detailPlant) {
__VLS_asFunctionalElement(__VLS_intrinsicElements.span, __VLS_intrinsicElements.span)({
...{ class: "text-text text-sm" },
});
(__VLS_ctx.detailPlant.date_achat);
(__VLS_ctx.detailPlant.varieties?.[0]?.date_achat);
}
if (__VLS_ctx.detailPlant.poids) {
if (__VLS_ctx.detailPlant.varieties?.[0]?.poids) {
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
...{ class: "bg-bg/30 p-3 rounded-xl border border-bg-soft" },
});
@@ -606,9 +739,9 @@ if (__VLS_ctx.detailPlant) {
__VLS_asFunctionalElement(__VLS_intrinsicElements.span, __VLS_intrinsicElements.span)({
...{ class: "text-text text-sm font-bold" },
});
(__VLS_ctx.detailPlant.poids);
(__VLS_ctx.detailPlant.varieties?.[0]?.poids);
}
if (__VLS_ctx.detailPlant.dluo) {
if (__VLS_ctx.detailPlant.varieties?.[0]?.dluo) {
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
...{ class: "bg-bg/30 p-3 rounded-xl border border-bg-soft" },
});
@@ -616,12 +749,12 @@ if (__VLS_ctx.detailPlant) {
...{ class: "text-[9px] font-black text-text-muted uppercase block mb-0.5" },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.span, __VLS_intrinsicElements.span)({
...{ class: (['text-sm font-bold', __VLS_ctx.isDluoExpired(__VLS_ctx.detailPlant.dluo) ? 'text-red' : 'text-green']) },
...{ class: (['text-sm font-bold', __VLS_ctx.isDluoExpired(__VLS_ctx.detailPlant.varieties?.[0]?.dluo ?? '') ? 'text-red' : 'text-green']) },
});
(__VLS_ctx.detailPlant.dluo);
(__VLS_ctx.isDluoExpired(__VLS_ctx.detailPlant.dluo) ? ' ⚠️' : '');
(__VLS_ctx.detailPlant.varieties?.[0]?.dluo);
(__VLS_ctx.isDluoExpired(__VLS_ctx.detailPlant.varieties?.[0]?.dluo ?? '') ? ' ⚠️' : '');
}
if (__VLS_ctx.detailPlant.boutique_url) {
if (__VLS_ctx.detailPlant.varieties?.[0]?.boutique_url) {
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
...{ class: "bg-bg/30 p-3 rounded-xl border border-bg-soft" },
});
@@ -629,7 +762,7 @@ if (__VLS_ctx.detailPlant) {
...{ class: "text-[9px] font-black text-text-muted uppercase block mb-0.5" },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.a, __VLS_intrinsicElements.a)({
href: (__VLS_ctx.detailPlant.boutique_url),
href: (__VLS_ctx.detailPlant.varieties?.[0]?.boutique_url),
target: "_blank",
rel: "noopener",
...{ class: "text-blue text-xs hover:underline truncate block" },
@@ -691,6 +824,69 @@ if (__VLS_ctx.detailPlant) {
}
}
}
if (__VLS_ctx.detailVarieties.length) {
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
...{ class: "space-y-2 mt-4" },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.h3, __VLS_intrinsicElements.h3)({
...{ class: "text-[9px] font-black text-text-muted uppercase tracking-widest" },
});
(__VLS_ctx.detailVarieties.length);
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
...{ class: "space-y-1" },
});
for (const [v] of __VLS_getVForSourceType((__VLS_ctx.detailVarieties))) {
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
key: (v.id),
...{ class: "flex items-center justify-between bg-bg/30 px-3 py-2 rounded-lg border border-bg-soft" },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({});
__VLS_asFunctionalElement(__VLS_intrinsicElements.span, __VLS_intrinsicElements.span)({
...{ class: "text-text text-sm font-bold" },
});
(v.variete || '(sans nom)');
if (v.boutique_nom) {
__VLS_asFunctionalElement(__VLS_intrinsicElements.span, __VLS_intrinsicElements.span)({
...{ class: "text-text-muted text-xs ml-2" },
});
(v.boutique_nom);
}
if (v.prix_achat) {
__VLS_asFunctionalElement(__VLS_intrinsicElements.span, __VLS_intrinsicElements.span)({
...{ class: "text-yellow text-xs ml-2" },
});
(v.prix_achat.toFixed(2));
}
if (v.dluo && __VLS_ctx.isDluoExpired(v.dluo)) {
__VLS_asFunctionalElement(__VLS_intrinsicElements.span, __VLS_intrinsicElements.span)({
...{ class: "text-red text-xs ml-1" },
});
}
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
...{ class: "flex gap-1" },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.button, __VLS_intrinsicElements.button)({
...{ onClick: (...[$event]) => {
if (!(__VLS_ctx.detailPlant))
return;
if (!(__VLS_ctx.detailVarieties.length))
return;
__VLS_ctx.openEditVariety(v);
} },
...{ class: "text-text-muted hover:text-yellow text-xs px-2" },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.button, __VLS_intrinsicElements.button)({
...{ onClick: (...[$event]) => {
if (!(__VLS_ctx.detailPlant))
return;
if (!(__VLS_ctx.detailVarieties.length))
return;
__VLS_ctx.deleteVariety(v.id);
} },
...{ class: "text-text-muted hover:text-red text-xs px-2" },
});
}
}
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
...{ class: "space-y-3" },
});
@@ -766,6 +962,10 @@ if (__VLS_ctx.detailPlant) {
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
...{ class: "p-4 bg-bg-hard border-t border-bg-soft flex gap-3" },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.button, __VLS_intrinsicElements.button)({
...{ onClick: (__VLS_ctx.openAddVariety) },
...{ class: "btn-primary !bg-green !text-bg py-2 px-4 font-black uppercase text-xs flex items-center gap-1" },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.button, __VLS_intrinsicElements.button)({
...{ onClick: (...[$event]) => {
if (!(__VLS_ctx.detailPlant))
@@ -1249,6 +1449,152 @@ __VLS_asFunctionalElement(__VLS_intrinsicElements.input)({
...{ class: "hidden" },
});
/** @type {typeof __VLS_ctx.fileInput} */ ;
if (__VLS_ctx.showFormVariety) {
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
...{ onClick: (__VLS_ctx.closeFormVariety) },
...{ class: "fixed inset-0 bg-black/80 backdrop-blur-sm z-[70] flex items-center justify-center p-4" },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.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" },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
...{ class: "flex items-center justify-between mb-5 border-b border-bg-soft pb-4" },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.h2, __VLS_intrinsicElements.h2)({
...{ class: "text-text font-black text-xl uppercase" },
});
(__VLS_ctx.editVariety ? 'Modifier la variété' : ' Nouvelle variété');
__VLS_asFunctionalElement(__VLS_intrinsicElements.button, __VLS_intrinsicElements.button)({
...{ onClick: (__VLS_ctx.closeFormVariety) },
...{ class: "text-text-muted hover:text-red text-2xl" },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.p, __VLS_intrinsicElements.p)({
...{ class: "text-text-muted text-xs mb-4 italic" },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.span, __VLS_intrinsicElements.span)({
...{ class: "text-yellow font-bold" },
});
(__VLS_ctx.detailPlantObj?.nom_commun);
__VLS_asFunctionalElement(__VLS_intrinsicElements.form, __VLS_intrinsicElements.form)({
...{ onSubmit: (__VLS_ctx.submitVariety) },
...{ class: "grid grid-cols-1 md:grid-cols-2 gap-4" },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
...{ class: "md:col-span-2" },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.label, __VLS_intrinsicElements.label)({
...{ class: "text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1" },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.input)({
required: true,
...{ 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…",
});
(__VLS_ctx.formVariety.variete);
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
...{ class: "md:col-span-2" },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.label, __VLS_intrinsicElements.label)({
...{ class: "text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1" },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.input)({
...{ 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…",
});
(__VLS_ctx.formVariety.tags);
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({});
__VLS_asFunctionalElement(__VLS_intrinsicElements.label, __VLS_intrinsicElements.label)({
...{ class: "text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1" },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.select, __VLS_intrinsicElements.select)({
value: (__VLS_ctx.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" },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.option, __VLS_intrinsicElements.option)({
value: "",
});
for (const [b] of __VLS_getVForSourceType((__VLS_ctx.BOUTIQUES))) {
__VLS_asFunctionalElement(__VLS_intrinsicElements.option, __VLS_intrinsicElements.option)({
key: (b),
value: (b),
});
(b);
}
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({});
__VLS_asFunctionalElement(__VLS_intrinsicElements.label, __VLS_intrinsicElements.label)({
...{ class: "text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1" },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.input)({
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" },
});
(__VLS_ctx.formVariety.prix_achat);
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({});
__VLS_asFunctionalElement(__VLS_intrinsicElements.label, __VLS_intrinsicElements.label)({
...{ class: "text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1" },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.input)({
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" },
});
(__VLS_ctx.formVariety.poids);
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({});
__VLS_asFunctionalElement(__VLS_intrinsicElements.label, __VLS_intrinsicElements.label)({
...{ class: "text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1" },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.input)({
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" },
});
(__VLS_ctx.formVariety.date_achat);
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({});
__VLS_asFunctionalElement(__VLS_intrinsicElements.label, __VLS_intrinsicElements.label)({
...{ class: "text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1" },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.input)({
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" },
});
(__VLS_ctx.formVariety.dluo);
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
...{ class: "md:col-span-2" },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.label, __VLS_intrinsicElements.label)({
...{ class: "text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1" },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.input)({
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" },
});
(__VLS_ctx.formVariety.boutique_url);
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
...{ class: "md:col-span-2" },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.label, __VLS_intrinsicElements.label)({
...{ class: "text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1" },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.textarea)({
value: (__VLS_ctx.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" },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
...{ class: "md:col-span-2 flex justify-between pt-4 border-t border-bg-soft" },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.button, __VLS_intrinsicElements.button)({
...{ onClick: (__VLS_ctx.closeFormVariety) },
type: "button",
...{ class: "text-text-muted hover:text-red uppercase text-xs font-bold px-6" },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.button, __VLS_intrinsicElements.button)({
type: "submit",
...{ class: "btn-primary px-8 py-3 !bg-green !text-bg font-black" },
});
(__VLS_ctx.editVariety ? 'Sauvegarder' : 'Ajouter');
}
/** @type {__VLS_StyleScopedClasses['p-4']} */ ;
/** @type {__VLS_StyleScopedClasses['max-w-[1800px]']} */ ;
/** @type {__VLS_StyleScopedClasses['mx-auto']} */ ;
@@ -1503,6 +1849,32 @@ __VLS_asFunctionalElement(__VLS_intrinsicElements.input)({
/** @type {__VLS_StyleScopedClasses['gap-2']} */ ;
/** @type {__VLS_StyleScopedClasses['text-green']} */ ;
/** @type {__VLS_StyleScopedClasses['font-bold']} */ ;
/** @type {__VLS_StyleScopedClasses['bg-bg/30']} */ ;
/** @type {__VLS_StyleScopedClasses['p-3']} */ ;
/** @type {__VLS_StyleScopedClasses['rounded-xl']} */ ;
/** @type {__VLS_StyleScopedClasses['border']} */ ;
/** @type {__VLS_StyleScopedClasses['border-bg-soft']} */ ;
/** @type {__VLS_StyleScopedClasses['text-[9px]']} */ ;
/** @type {__VLS_StyleScopedClasses['font-black']} */ ;
/** @type {__VLS_StyleScopedClasses['text-text-muted']} */ ;
/** @type {__VLS_StyleScopedClasses['uppercase']} */ ;
/** @type {__VLS_StyleScopedClasses['block']} */ ;
/** @type {__VLS_StyleScopedClasses['mb-0.5']} */ ;
/** @type {__VLS_StyleScopedClasses['text-text']} */ ;
/** @type {__VLS_StyleScopedClasses['text-sm']} */ ;
/** @type {__VLS_StyleScopedClasses['bg-bg/30']} */ ;
/** @type {__VLS_StyleScopedClasses['p-3']} */ ;
/** @type {__VLS_StyleScopedClasses['rounded-xl']} */ ;
/** @type {__VLS_StyleScopedClasses['border']} */ ;
/** @type {__VLS_StyleScopedClasses['border-bg-soft']} */ ;
/** @type {__VLS_StyleScopedClasses['text-[9px]']} */ ;
/** @type {__VLS_StyleScopedClasses['font-black']} */ ;
/** @type {__VLS_StyleScopedClasses['text-text-muted']} */ ;
/** @type {__VLS_StyleScopedClasses['uppercase']} */ ;
/** @type {__VLS_StyleScopedClasses['block']} */ ;
/** @type {__VLS_StyleScopedClasses['mb-0.5']} */ ;
/** @type {__VLS_StyleScopedClasses['text-text']} */ ;
/** @type {__VLS_StyleScopedClasses['text-sm']} */ ;
/** @type {__VLS_StyleScopedClasses['space-y-2']} */ ;
/** @type {__VLS_StyleScopedClasses['text-[10px]']} */ ;
/** @type {__VLS_StyleScopedClasses['font-black']} */ ;
@@ -1653,6 +2025,45 @@ __VLS_asFunctionalElement(__VLS_intrinsicElements.input)({
/** @type {__VLS_StyleScopedClasses['py-0.5']} */ ;
/** @type {__VLS_StyleScopedClasses['rounded-full']} */ ;
/** @type {__VLS_StyleScopedClasses['capitalize']} */ ;
/** @type {__VLS_StyleScopedClasses['space-y-2']} */ ;
/** @type {__VLS_StyleScopedClasses['mt-4']} */ ;
/** @type {__VLS_StyleScopedClasses['text-[9px]']} */ ;
/** @type {__VLS_StyleScopedClasses['font-black']} */ ;
/** @type {__VLS_StyleScopedClasses['text-text-muted']} */ ;
/** @type {__VLS_StyleScopedClasses['uppercase']} */ ;
/** @type {__VLS_StyleScopedClasses['tracking-widest']} */ ;
/** @type {__VLS_StyleScopedClasses['space-y-1']} */ ;
/** @type {__VLS_StyleScopedClasses['flex']} */ ;
/** @type {__VLS_StyleScopedClasses['items-center']} */ ;
/** @type {__VLS_StyleScopedClasses['justify-between']} */ ;
/** @type {__VLS_StyleScopedClasses['bg-bg/30']} */ ;
/** @type {__VLS_StyleScopedClasses['px-3']} */ ;
/** @type {__VLS_StyleScopedClasses['py-2']} */ ;
/** @type {__VLS_StyleScopedClasses['rounded-lg']} */ ;
/** @type {__VLS_StyleScopedClasses['border']} */ ;
/** @type {__VLS_StyleScopedClasses['border-bg-soft']} */ ;
/** @type {__VLS_StyleScopedClasses['text-text']} */ ;
/** @type {__VLS_StyleScopedClasses['text-sm']} */ ;
/** @type {__VLS_StyleScopedClasses['font-bold']} */ ;
/** @type {__VLS_StyleScopedClasses['text-text-muted']} */ ;
/** @type {__VLS_StyleScopedClasses['text-xs']} */ ;
/** @type {__VLS_StyleScopedClasses['ml-2']} */ ;
/** @type {__VLS_StyleScopedClasses['text-yellow']} */ ;
/** @type {__VLS_StyleScopedClasses['text-xs']} */ ;
/** @type {__VLS_StyleScopedClasses['ml-2']} */ ;
/** @type {__VLS_StyleScopedClasses['text-red']} */ ;
/** @type {__VLS_StyleScopedClasses['text-xs']} */ ;
/** @type {__VLS_StyleScopedClasses['ml-1']} */ ;
/** @type {__VLS_StyleScopedClasses['flex']} */ ;
/** @type {__VLS_StyleScopedClasses['gap-1']} */ ;
/** @type {__VLS_StyleScopedClasses['text-text-muted']} */ ;
/** @type {__VLS_StyleScopedClasses['hover:text-yellow']} */ ;
/** @type {__VLS_StyleScopedClasses['text-xs']} */ ;
/** @type {__VLS_StyleScopedClasses['px-2']} */ ;
/** @type {__VLS_StyleScopedClasses['text-text-muted']} */ ;
/** @type {__VLS_StyleScopedClasses['hover:text-red']} */ ;
/** @type {__VLS_StyleScopedClasses['text-xs']} */ ;
/** @type {__VLS_StyleScopedClasses['px-2']} */ ;
/** @type {__VLS_StyleScopedClasses['space-y-3']} */ ;
/** @type {__VLS_StyleScopedClasses['flex']} */ ;
/** @type {__VLS_StyleScopedClasses['justify-between']} */ ;
@@ -1722,6 +2133,17 @@ __VLS_asFunctionalElement(__VLS_intrinsicElements.input)({
/** @type {__VLS_StyleScopedClasses['flex']} */ ;
/** @type {__VLS_StyleScopedClasses['gap-3']} */ ;
/** @type {__VLS_StyleScopedClasses['btn-primary']} */ ;
/** @type {__VLS_StyleScopedClasses['!bg-green']} */ ;
/** @type {__VLS_StyleScopedClasses['!text-bg']} */ ;
/** @type {__VLS_StyleScopedClasses['py-2']} */ ;
/** @type {__VLS_StyleScopedClasses['px-4']} */ ;
/** @type {__VLS_StyleScopedClasses['font-black']} */ ;
/** @type {__VLS_StyleScopedClasses['uppercase']} */ ;
/** @type {__VLS_StyleScopedClasses['text-xs']} */ ;
/** @type {__VLS_StyleScopedClasses['flex']} */ ;
/** @type {__VLS_StyleScopedClasses['items-center']} */ ;
/** @type {__VLS_StyleScopedClasses['gap-1']} */ ;
/** @type {__VLS_StyleScopedClasses['btn-primary']} */ ;
/** @type {__VLS_StyleScopedClasses['!bg-yellow']} */ ;
/** @type {__VLS_StyleScopedClasses['!text-bg']} */ ;
/** @type {__VLS_StyleScopedClasses['flex-1']} */ ;
@@ -2333,6 +2755,235 @@ __VLS_asFunctionalElement(__VLS_intrinsicElements.input)({
/** @type {__VLS_StyleScopedClasses['hover:opacity-90']} */ ;
/** @type {__VLS_StyleScopedClasses['transition-opacity']} */ ;
/** @type {__VLS_StyleScopedClasses['hidden']} */ ;
/** @type {__VLS_StyleScopedClasses['fixed']} */ ;
/** @type {__VLS_StyleScopedClasses['inset-0']} */ ;
/** @type {__VLS_StyleScopedClasses['bg-black/80']} */ ;
/** @type {__VLS_StyleScopedClasses['backdrop-blur-sm']} */ ;
/** @type {__VLS_StyleScopedClasses['z-[70]']} */ ;
/** @type {__VLS_StyleScopedClasses['flex']} */ ;
/** @type {__VLS_StyleScopedClasses['items-center']} */ ;
/** @type {__VLS_StyleScopedClasses['justify-center']} */ ;
/** @type {__VLS_StyleScopedClasses['p-4']} */ ;
/** @type {__VLS_StyleScopedClasses['bg-bg-hard']} */ ;
/** @type {__VLS_StyleScopedClasses['rounded-3xl']} */ ;
/** @type {__VLS_StyleScopedClasses['p-6']} */ ;
/** @type {__VLS_StyleScopedClasses['w-full']} */ ;
/** @type {__VLS_StyleScopedClasses['max-w-xl']} */ ;
/** @type {__VLS_StyleScopedClasses['border']} */ ;
/** @type {__VLS_StyleScopedClasses['border-bg-soft']} */ ;
/** @type {__VLS_StyleScopedClasses['shadow-2xl']} */ ;
/** @type {__VLS_StyleScopedClasses['max-h-[90vh]']} */ ;
/** @type {__VLS_StyleScopedClasses['overflow-y-auto']} */ ;
/** @type {__VLS_StyleScopedClasses['flex']} */ ;
/** @type {__VLS_StyleScopedClasses['items-center']} */ ;
/** @type {__VLS_StyleScopedClasses['justify-between']} */ ;
/** @type {__VLS_StyleScopedClasses['mb-5']} */ ;
/** @type {__VLS_StyleScopedClasses['border-b']} */ ;
/** @type {__VLS_StyleScopedClasses['border-bg-soft']} */ ;
/** @type {__VLS_StyleScopedClasses['pb-4']} */ ;
/** @type {__VLS_StyleScopedClasses['text-text']} */ ;
/** @type {__VLS_StyleScopedClasses['font-black']} */ ;
/** @type {__VLS_StyleScopedClasses['text-xl']} */ ;
/** @type {__VLS_StyleScopedClasses['uppercase']} */ ;
/** @type {__VLS_StyleScopedClasses['text-text-muted']} */ ;
/** @type {__VLS_StyleScopedClasses['hover:text-red']} */ ;
/** @type {__VLS_StyleScopedClasses['text-2xl']} */ ;
/** @type {__VLS_StyleScopedClasses['text-text-muted']} */ ;
/** @type {__VLS_StyleScopedClasses['text-xs']} */ ;
/** @type {__VLS_StyleScopedClasses['mb-4']} */ ;
/** @type {__VLS_StyleScopedClasses['italic']} */ ;
/** @type {__VLS_StyleScopedClasses['text-yellow']} */ ;
/** @type {__VLS_StyleScopedClasses['font-bold']} */ ;
/** @type {__VLS_StyleScopedClasses['grid']} */ ;
/** @type {__VLS_StyleScopedClasses['grid-cols-1']} */ ;
/** @type {__VLS_StyleScopedClasses['md:grid-cols-2']} */ ;
/** @type {__VLS_StyleScopedClasses['gap-4']} */ ;
/** @type {__VLS_StyleScopedClasses['md:col-span-2']} */ ;
/** @type {__VLS_StyleScopedClasses['text-text-muted']} */ ;
/** @type {__VLS_StyleScopedClasses['text-[10px]']} */ ;
/** @type {__VLS_StyleScopedClasses['font-black']} */ ;
/** @type {__VLS_StyleScopedClasses['uppercase']} */ ;
/** @type {__VLS_StyleScopedClasses['tracking-widest']} */ ;
/** @type {__VLS_StyleScopedClasses['block']} */ ;
/** @type {__VLS_StyleScopedClasses['mb-1']} */ ;
/** @type {__VLS_StyleScopedClasses['w-full']} */ ;
/** @type {__VLS_StyleScopedClasses['bg-bg']} */ ;
/** @type {__VLS_StyleScopedClasses['border']} */ ;
/** @type {__VLS_StyleScopedClasses['border-bg-soft']} */ ;
/** @type {__VLS_StyleScopedClasses['rounded-xl']} */ ;
/** @type {__VLS_StyleScopedClasses['px-4']} */ ;
/** @type {__VLS_StyleScopedClasses['py-3']} */ ;
/** @type {__VLS_StyleScopedClasses['text-text']} */ ;
/** @type {__VLS_StyleScopedClasses['text-sm']} */ ;
/** @type {__VLS_StyleScopedClasses['focus:border-yellow']} */ ;
/** @type {__VLS_StyleScopedClasses['outline-none']} */ ;
/** @type {__VLS_StyleScopedClasses['md:col-span-2']} */ ;
/** @type {__VLS_StyleScopedClasses['text-text-muted']} */ ;
/** @type {__VLS_StyleScopedClasses['text-[10px]']} */ ;
/** @type {__VLS_StyleScopedClasses['font-black']} */ ;
/** @type {__VLS_StyleScopedClasses['uppercase']} */ ;
/** @type {__VLS_StyleScopedClasses['tracking-widest']} */ ;
/** @type {__VLS_StyleScopedClasses['block']} */ ;
/** @type {__VLS_StyleScopedClasses['mb-1']} */ ;
/** @type {__VLS_StyleScopedClasses['w-full']} */ ;
/** @type {__VLS_StyleScopedClasses['bg-bg']} */ ;
/** @type {__VLS_StyleScopedClasses['border']} */ ;
/** @type {__VLS_StyleScopedClasses['border-bg-soft']} */ ;
/** @type {__VLS_StyleScopedClasses['rounded-xl']} */ ;
/** @type {__VLS_StyleScopedClasses['px-4']} */ ;
/** @type {__VLS_StyleScopedClasses['py-3']} */ ;
/** @type {__VLS_StyleScopedClasses['text-text']} */ ;
/** @type {__VLS_StyleScopedClasses['text-sm']} */ ;
/** @type {__VLS_StyleScopedClasses['focus:border-yellow']} */ ;
/** @type {__VLS_StyleScopedClasses['outline-none']} */ ;
/** @type {__VLS_StyleScopedClasses['text-text-muted']} */ ;
/** @type {__VLS_StyleScopedClasses['text-[10px]']} */ ;
/** @type {__VLS_StyleScopedClasses['font-black']} */ ;
/** @type {__VLS_StyleScopedClasses['uppercase']} */ ;
/** @type {__VLS_StyleScopedClasses['tracking-widest']} */ ;
/** @type {__VLS_StyleScopedClasses['block']} */ ;
/** @type {__VLS_StyleScopedClasses['mb-1']} */ ;
/** @type {__VLS_StyleScopedClasses['w-full']} */ ;
/** @type {__VLS_StyleScopedClasses['bg-bg']} */ ;
/** @type {__VLS_StyleScopedClasses['border']} */ ;
/** @type {__VLS_StyleScopedClasses['border-bg-soft']} */ ;
/** @type {__VLS_StyleScopedClasses['rounded-xl']} */ ;
/** @type {__VLS_StyleScopedClasses['px-4']} */ ;
/** @type {__VLS_StyleScopedClasses['py-3']} */ ;
/** @type {__VLS_StyleScopedClasses['text-text']} */ ;
/** @type {__VLS_StyleScopedClasses['text-sm']} */ ;
/** @type {__VLS_StyleScopedClasses['outline-none']} */ ;
/** @type {__VLS_StyleScopedClasses['focus:border-yellow']} */ ;
/** @type {__VLS_StyleScopedClasses['appearance-none']} */ ;
/** @type {__VLS_StyleScopedClasses['text-text-muted']} */ ;
/** @type {__VLS_StyleScopedClasses['text-[10px]']} */ ;
/** @type {__VLS_StyleScopedClasses['font-black']} */ ;
/** @type {__VLS_StyleScopedClasses['uppercase']} */ ;
/** @type {__VLS_StyleScopedClasses['tracking-widest']} */ ;
/** @type {__VLS_StyleScopedClasses['block']} */ ;
/** @type {__VLS_StyleScopedClasses['mb-1']} */ ;
/** @type {__VLS_StyleScopedClasses['w-full']} */ ;
/** @type {__VLS_StyleScopedClasses['bg-bg']} */ ;
/** @type {__VLS_StyleScopedClasses['border']} */ ;
/** @type {__VLS_StyleScopedClasses['border-bg-soft']} */ ;
/** @type {__VLS_StyleScopedClasses['rounded-xl']} */ ;
/** @type {__VLS_StyleScopedClasses['px-4']} */ ;
/** @type {__VLS_StyleScopedClasses['py-3']} */ ;
/** @type {__VLS_StyleScopedClasses['text-text']} */ ;
/** @type {__VLS_StyleScopedClasses['text-sm']} */ ;
/** @type {__VLS_StyleScopedClasses['focus:border-yellow']} */ ;
/** @type {__VLS_StyleScopedClasses['outline-none']} */ ;
/** @type {__VLS_StyleScopedClasses['text-text-muted']} */ ;
/** @type {__VLS_StyleScopedClasses['text-[10px]']} */ ;
/** @type {__VLS_StyleScopedClasses['font-black']} */ ;
/** @type {__VLS_StyleScopedClasses['uppercase']} */ ;
/** @type {__VLS_StyleScopedClasses['tracking-widest']} */ ;
/** @type {__VLS_StyleScopedClasses['block']} */ ;
/** @type {__VLS_StyleScopedClasses['mb-1']} */ ;
/** @type {__VLS_StyleScopedClasses['w-full']} */ ;
/** @type {__VLS_StyleScopedClasses['bg-bg']} */ ;
/** @type {__VLS_StyleScopedClasses['border']} */ ;
/** @type {__VLS_StyleScopedClasses['border-bg-soft']} */ ;
/** @type {__VLS_StyleScopedClasses['rounded-xl']} */ ;
/** @type {__VLS_StyleScopedClasses['px-4']} */ ;
/** @type {__VLS_StyleScopedClasses['py-3']} */ ;
/** @type {__VLS_StyleScopedClasses['text-text']} */ ;
/** @type {__VLS_StyleScopedClasses['text-sm']} */ ;
/** @type {__VLS_StyleScopedClasses['focus:border-yellow']} */ ;
/** @type {__VLS_StyleScopedClasses['outline-none']} */ ;
/** @type {__VLS_StyleScopedClasses['text-text-muted']} */ ;
/** @type {__VLS_StyleScopedClasses['text-[10px]']} */ ;
/** @type {__VLS_StyleScopedClasses['font-black']} */ ;
/** @type {__VLS_StyleScopedClasses['uppercase']} */ ;
/** @type {__VLS_StyleScopedClasses['tracking-widest']} */ ;
/** @type {__VLS_StyleScopedClasses['block']} */ ;
/** @type {__VLS_StyleScopedClasses['mb-1']} */ ;
/** @type {__VLS_StyleScopedClasses['w-full']} */ ;
/** @type {__VLS_StyleScopedClasses['bg-bg']} */ ;
/** @type {__VLS_StyleScopedClasses['border']} */ ;
/** @type {__VLS_StyleScopedClasses['border-bg-soft']} */ ;
/** @type {__VLS_StyleScopedClasses['rounded-xl']} */ ;
/** @type {__VLS_StyleScopedClasses['px-4']} */ ;
/** @type {__VLS_StyleScopedClasses['py-3']} */ ;
/** @type {__VLS_StyleScopedClasses['text-text']} */ ;
/** @type {__VLS_StyleScopedClasses['text-sm']} */ ;
/** @type {__VLS_StyleScopedClasses['focus:border-yellow']} */ ;
/** @type {__VLS_StyleScopedClasses['outline-none']} */ ;
/** @type {__VLS_StyleScopedClasses['text-text-muted']} */ ;
/** @type {__VLS_StyleScopedClasses['text-[10px]']} */ ;
/** @type {__VLS_StyleScopedClasses['font-black']} */ ;
/** @type {__VLS_StyleScopedClasses['uppercase']} */ ;
/** @type {__VLS_StyleScopedClasses['tracking-widest']} */ ;
/** @type {__VLS_StyleScopedClasses['block']} */ ;
/** @type {__VLS_StyleScopedClasses['mb-1']} */ ;
/** @type {__VLS_StyleScopedClasses['w-full']} */ ;
/** @type {__VLS_StyleScopedClasses['bg-bg']} */ ;
/** @type {__VLS_StyleScopedClasses['border']} */ ;
/** @type {__VLS_StyleScopedClasses['border-bg-soft']} */ ;
/** @type {__VLS_StyleScopedClasses['rounded-xl']} */ ;
/** @type {__VLS_StyleScopedClasses['px-4']} */ ;
/** @type {__VLS_StyleScopedClasses['py-3']} */ ;
/** @type {__VLS_StyleScopedClasses['text-text']} */ ;
/** @type {__VLS_StyleScopedClasses['text-sm']} */ ;
/** @type {__VLS_StyleScopedClasses['focus:border-yellow']} */ ;
/** @type {__VLS_StyleScopedClasses['outline-none']} */ ;
/** @type {__VLS_StyleScopedClasses['md:col-span-2']} */ ;
/** @type {__VLS_StyleScopedClasses['text-text-muted']} */ ;
/** @type {__VLS_StyleScopedClasses['text-[10px]']} */ ;
/** @type {__VLS_StyleScopedClasses['font-black']} */ ;
/** @type {__VLS_StyleScopedClasses['uppercase']} */ ;
/** @type {__VLS_StyleScopedClasses['tracking-widest']} */ ;
/** @type {__VLS_StyleScopedClasses['block']} */ ;
/** @type {__VLS_StyleScopedClasses['mb-1']} */ ;
/** @type {__VLS_StyleScopedClasses['w-full']} */ ;
/** @type {__VLS_StyleScopedClasses['bg-bg']} */ ;
/** @type {__VLS_StyleScopedClasses['border']} */ ;
/** @type {__VLS_StyleScopedClasses['border-bg-soft']} */ ;
/** @type {__VLS_StyleScopedClasses['rounded-xl']} */ ;
/** @type {__VLS_StyleScopedClasses['px-4']} */ ;
/** @type {__VLS_StyleScopedClasses['py-3']} */ ;
/** @type {__VLS_StyleScopedClasses['text-text']} */ ;
/** @type {__VLS_StyleScopedClasses['text-sm']} */ ;
/** @type {__VLS_StyleScopedClasses['focus:border-yellow']} */ ;
/** @type {__VLS_StyleScopedClasses['outline-none']} */ ;
/** @type {__VLS_StyleScopedClasses['md:col-span-2']} */ ;
/** @type {__VLS_StyleScopedClasses['text-text-muted']} */ ;
/** @type {__VLS_StyleScopedClasses['text-[10px]']} */ ;
/** @type {__VLS_StyleScopedClasses['font-black']} */ ;
/** @type {__VLS_StyleScopedClasses['uppercase']} */ ;
/** @type {__VLS_StyleScopedClasses['tracking-widest']} */ ;
/** @type {__VLS_StyleScopedClasses['block']} */ ;
/** @type {__VLS_StyleScopedClasses['mb-1']} */ ;
/** @type {__VLS_StyleScopedClasses['w-full']} */ ;
/** @type {__VLS_StyleScopedClasses['bg-bg']} */ ;
/** @type {__VLS_StyleScopedClasses['border']} */ ;
/** @type {__VLS_StyleScopedClasses['border-bg-soft']} */ ;
/** @type {__VLS_StyleScopedClasses['rounded-xl']} */ ;
/** @type {__VLS_StyleScopedClasses['px-4']} */ ;
/** @type {__VLS_StyleScopedClasses['py-3']} */ ;
/** @type {__VLS_StyleScopedClasses['text-text']} */ ;
/** @type {__VLS_StyleScopedClasses['text-sm']} */ ;
/** @type {__VLS_StyleScopedClasses['focus:border-yellow']} */ ;
/** @type {__VLS_StyleScopedClasses['outline-none']} */ ;
/** @type {__VLS_StyleScopedClasses['resize-none']} */ ;
/** @type {__VLS_StyleScopedClasses['md:col-span-2']} */ ;
/** @type {__VLS_StyleScopedClasses['flex']} */ ;
/** @type {__VLS_StyleScopedClasses['justify-between']} */ ;
/** @type {__VLS_StyleScopedClasses['pt-4']} */ ;
/** @type {__VLS_StyleScopedClasses['border-t']} */ ;
/** @type {__VLS_StyleScopedClasses['border-bg-soft']} */ ;
/** @type {__VLS_StyleScopedClasses['text-text-muted']} */ ;
/** @type {__VLS_StyleScopedClasses['hover:text-red']} */ ;
/** @type {__VLS_StyleScopedClasses['uppercase']} */ ;
/** @type {__VLS_StyleScopedClasses['text-xs']} */ ;
/** @type {__VLS_StyleScopedClasses['font-bold']} */ ;
/** @type {__VLS_StyleScopedClasses['px-6']} */ ;
/** @type {__VLS_StyleScopedClasses['btn-primary']} */ ;
/** @type {__VLS_StyleScopedClasses['px-8']} */ ;
/** @type {__VLS_StyleScopedClasses['py-3']} */ ;
/** @type {__VLS_StyleScopedClasses['!bg-green']} */ ;
/** @type {__VLS_StyleScopedClasses['!text-bg']} */ ;
/** @type {__VLS_StyleScopedClasses['font-black']} */ ;
var __VLS_dollars;
const __VLS_self = (await import('vue')).defineComponent({
setup() {
@@ -2346,10 +2997,15 @@ const __VLS_self = (await import('vue')).defineComponent({
loadingPhotos: loadingPhotos,
lightbox: lightbox,
fileInput: fileInput,
detailVarieties: detailVarieties,
detailPlantGroup: detailPlantGroup,
detailVarietyIdx: detailVarietyIdx,
detailPlant: detailPlant,
detailPlantObj: detailPlantObj,
detailVarieties: detailVarieties,
detailAssociations: detailAssociations,
showFormVariety: showFormVariety,
editVariety: editVariety,
formVariety: formVariety,
categories: categories,
BOUTIQUES: BOUTIQUES,
form: form,
@@ -2373,6 +3029,11 @@ const __VLS_self = (await import('vue')).defineComponent({
nextVariety: nextVariety,
startEdit: startEdit,
closeForm: closeForm,
openAddVariety: openAddVariety,
openEditVariety: openEditVariety,
closeFormVariety: closeFormVariety,
submitVariety: submitVariety,
deleteVariety: deleteVariety,
submitPlant: submitPlant,
removePlant: removePlant,
openUpload: openUpload,

View File

@@ -416,7 +416,7 @@ const plantingsByGarden = computed(() => {
function plantingLabel(p: Planting): string {
const plant = plantsStore.plants.find(pl => pl.id === p.variety_id)
const nom = plant
? [plant.nom_commun, plant.variete].filter(Boolean).join(' ')
? [plant.nom_commun, plant.varieties?.[0]?.variete].filter(Boolean).join(' ')
: `Variété #${p.variety_id}`
const date = p.date_plantation ? ` (${fmtDate(p.date_plantation)})` : ''
return `${nom}${date}`

View File

@@ -99,7 +99,7 @@ const plantingsByGarden = computed(() => {
function plantingLabel(p) {
const plant = plantsStore.plants.find(pl => pl.id === p.variety_id);
const nom = plant
? [plant.nom_commun, plant.variete].filter(Boolean).join(' — ')
? [plant.nom_commun, plant.varieties?.[0]?.variete].filter(Boolean).join(' — ')
: `Variété #${p.variety_id}`;
const date = p.date_plantation ? ` (${fmtDate(p.date_plantation)})` : '';
return `${nom}${date}`;