Compare commits
27 Commits
14636bd58f
...
2d5e5a05a2
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d5e5a05a2 | |||
| 4c279c387c | |||
| 149d8caa06 | |||
| 672ac529e7 | |||
| 174ed9c25d | |||
| 05b2ddc27c | |||
| 32c7781d14 | |||
| d4d104b2c2 | |||
| 0f5ebd25be | |||
| 1b7a8b8f25 | |||
| 1095edffdb | |||
| 8edcf5fd8d | |||
| 1d4708585e | |||
| 18ee6e1fbe | |||
| 4a7ecffbb8 | |||
| b41a0f817c | |||
| de967141ba | |||
| 734c33a12e | |||
| e40351e0be | |||
| f8e64d6a2c | |||
| 80173171b3 | |||
| 8bf281a3fb | |||
| d2f2f6d7d7 | |||
| 107640e561 | |||
| a5c503e1f3 | |||
| 75f18c9eb8 | |||
| faa469e688 |
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
57
backend/app/models/intrant.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
60
backend/app/routers/achats.py
Normal 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()
|
||||
78
backend/app/routers/fabrications.py
Normal 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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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
0
backend/scripts/__init__.py
Normal file
300
backend/scripts/import_graines.py
Normal 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()
|
||||
100
backend/scripts/migrate_plant_varieties.py
Normal 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()
|
||||
@@ -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):
|
||||
|
||||
BIN
data/jardin.db
BIN
data/uploads/01c05aed-27a4-4902-b99d-9a1a27957b91.jpg
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
data/uploads/0cf928a2-4e1e-4f80-b808-aa066ce3f174.jpg
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
data/uploads/119cd18a-50a6-40df-8c72-b5711c6ea7f2.jpg
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
data/uploads/14cb5845-82fb-412e-82e8-16eeed8e920b.jpg
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
data/uploads/185159c5-d396-4d1a-aa5b-9c2a6c6267d6.jpg
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
data/uploads/2826ce60-929c-447b-82cd-d95166d1a364.jpg
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
data/uploads/2e670eec-bf29-4c9e-8634-87dcea54274e.jpg
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
data/uploads/456fc305-c22b-4a69-bcb5-bc418ed7d982.jpg
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
data/uploads/4b00ab47-1577-476b-9e83-cdc3cc96af0c.jpg
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
data/uploads/53ac30ee-108e-42f2-b61b-cd810dcbeeb5.jpg
Normal file
|
After Width: | Height: | Size: 3.8 MiB |
BIN
data/uploads/72bc5fc7-a3eb-49e9-8bb1-4e0f54d5e4c2.jpg
Normal file
|
After Width: | Height: | Size: 2.7 MiB |
BIN
data/uploads/8c108c64-f513-427b-bd16-a7b8fb99245e.jpg
Normal file
|
After Width: | Height: | Size: 3.4 MiB |
BIN
data/uploads/92819523-a2dc-476f-8770-f8615d4a5cfe.jpg
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
data/uploads/945c2aa1-896d-4979-bb3c-2a74571b3050.jpg
Normal file
|
After Width: | Height: | Size: 3.4 MiB |
BIN
data/uploads/a8641f29-f7ab-465c-a739-a1a3367f6628.jpg
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
data/uploads/a9ebb40f-5402-4409-9d0a-5de42eacfba6.jpg
Normal file
|
After Width: | Height: | Size: 4.8 MiB |
BIN
data/uploads/accdf642-36b5-4d9c-b9be-465ea65afb17.jpg
Normal file
|
After Width: | Height: | Size: 3.3 MiB |
BIN
data/uploads/b2b098f7-1c09-410c-a440-e95de836bd5f.jpg
Normal file
|
After Width: | Height: | Size: 3.7 MiB |
BIN
data/uploads/b5320f20-2d59-47e2-9c92-b48878890bb9.jpg
Normal file
|
After Width: | Height: | Size: 3.5 MiB |
BIN
data/uploads/bc3fe54e-b894-4ca8-8be8-1b065afaf918.jpg
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
data/uploads/be350e09-2e7e-4c22-ab3e-c2f248a750cd.jpg
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
BIN
data/uploads/bfb03b30-ef05-4710-ab46-0c4cde2f7366.jpg
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
data/uploads/bfdf7863-16e5-4151-95aa-087d0aa920bd.jpg
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
data/uploads/c1414164-b276-43c5-ab08-229baef76c2b.jpg
Normal file
|
After Width: | Height: | Size: 2.9 MiB |
BIN
data/uploads/c14d8348-815f-4ebf-9ea1-bef89d122324.jpg
Normal file
|
After Width: | Height: | Size: 3.3 MiB |
BIN
data/uploads/c1e27615-b167-4513-910b-e3fa2017fe42.jpg
Normal file
|
After Width: | Height: | Size: 4.3 MiB |
BIN
data/uploads/c2d2c0bc-3994-431a-8e33-2a8e42903040.jpg
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
data/uploads/c52790ff-fe48-45d5-baff-2367b9667916.jpg
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
data/uploads/cef0474e-73f2-4da8-b513-847488d04955.jpg
Normal file
|
After Width: | Height: | Size: 3.8 MiB |
BIN
data/uploads/d45599ca-1ffd-41fb-b5db-a65d3740a145.jpg
Normal file
|
After Width: | Height: | Size: 2.5 MiB |
BIN
data/uploads/dbcb37c5-65fd-412e-9e40-d2667b7f7548.jpg
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
data/uploads/e8e0a754-64a3-4e23-8bbe-c2fbd667bf51.jpg
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
data/uploads/f1d55c5c-ab04-4ab7-8661-d1b23afd6832.jpg
Normal file
|
After Width: | Height: | Size: 3.8 MiB |
BIN
data/uploads/f303b7ce-46c2-48da-b22c-f563970fb75b.jpg
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
data/uploads/f7fba076-625a-431c-a21d-8359cdea9a40.jpg
Normal file
|
After Width: | Height: | Size: 3.7 MiB |
BIN
data/uploads/fa43b43d-80e6-4bea-bf45-8c91f1bdb073.jpg
Normal file
|
After Width: | Height: | Size: 4.3 MiB |
163
docs/plans/2026-03-08-plantes-varietes-design.md
Normal 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 |
|
||||
1170
docs/plans/2026-03-08-plantes-varietes.md
Normal file
|
Before Width: | Height: | Size: 3.4 MiB After Width: | Height: | Size: 3.4 MiB |
|
Before Width: | Height: | Size: 3.7 MiB After Width: | Height: | Size: 3.7 MiB |
@@ -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: '📆' },
|
||||
|
||||
@@ -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: '📆' },
|
||||
|
||||
9
frontend/src/api/achats.js
Normal 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}`),
|
||||
};
|
||||
28
frontend/src/api/achats.ts
Normal 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}`),
|
||||
}
|
||||
10
frontend/src/api/fabrications.js
Normal 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}`),
|
||||
};
|
||||
32
frontend/src/api/fabrications.ts
Normal 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}`),
|
||||
}
|
||||
@@ -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}`),
|
||||
};
|
||||
|
||||
@@ -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}`),
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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') },
|
||||
|
||||
@@ -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') },
|
||||
|
||||
27
frontend/src/stores/achats.js
Normal 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 };
|
||||
});
|
||||
28
frontend/src/stores/achats.ts
Normal 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 }
|
||||
})
|
||||
34
frontend/src/stores/fabrications.js
Normal 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 };
|
||||
});
|
||||
35
frontend/src/stores/fabrications.ts
Normal 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 }
|
||||
})
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
643
frontend/src/views/IntratsView.vue
Normal 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>
|
||||
2141
frontend/src/views/IntratsView.vue.js
Normal 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()
|
||||
|
||||
@@ -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,41 +659,7 @@ 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) {
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
|
||||
...{ class: "space-y-2" },
|
||||
});
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.h3, __VLS_intrinsicElements.h3)({
|
||||
...{ class: "text-[10px] font-black text-text-muted uppercase tracking-widest" },
|
||||
});
|
||||
__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) {
|
||||
__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 font-bold" },
|
||||
});
|
||||
(__VLS_ctx.detailPlant.boutique_nom);
|
||||
}
|
||||
if (__VLS_ctx.detailPlant.prix_achat) {
|
||||
__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-yellow font-bold" },
|
||||
});
|
||||
(__VLS_ctx.detailPlant.prix_achat.toFixed(2));
|
||||
}
|
||||
if (__VLS_ctx.detailPlant.date_achat) {
|
||||
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" },
|
||||
});
|
||||
@@ -594,9 +669,31 @@ if (__VLS_ctx.detailPlant) {
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.span, __VLS_intrinsicElements.span)({
|
||||
...{ class: "text-text text-sm" },
|
||||
});
|
||||
(__VLS_ctx.detailPlant.date_achat);
|
||||
(__VLS_ctx.detailPlantObj.temp_germination);
|
||||
}
|
||||
if (__VLS_ctx.detailPlant.poids) {
|
||||
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" },
|
||||
});
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.h3, __VLS_intrinsicElements.h3)({
|
||||
...{ class: "text-[10px] font-black text-text-muted uppercase tracking-widest" },
|
||||
});
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
|
||||
...{ class: "grid grid-cols-2 sm:grid-cols-3 gap-3" },
|
||||
});
|
||||
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" },
|
||||
});
|
||||
@@ -606,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.poids);
|
||||
(__VLS_ctx.detailPlant.varieties?.[0]?.boutique_nom);
|
||||
}
|
||||
if (__VLS_ctx.detailPlant.dluo) {
|
||||
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" },
|
||||
});
|
||||
@@ -616,12 +713,48 @@ 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-yellow font-bold" },
|
||||
});
|
||||
(__VLS_ctx.detailPlant.dluo);
|
||||
(__VLS_ctx.isDluoExpired(__VLS_ctx.detailPlant.dluo) ? ' ⚠️' : '');
|
||||
(__VLS_ctx.detailPlant.varieties?.[0]?.prix_achat?.toFixed(2));
|
||||
}
|
||||
if (__VLS_ctx.detailPlant.boutique_url) {
|
||||
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" },
|
||||
});
|
||||
__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.detailPlant.varieties?.[0]?.date_achat);
|
||||
}
|
||||
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" },
|
||||
});
|
||||
__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 font-bold" },
|
||||
});
|
||||
(__VLS_ctx.detailPlant.varieties?.[0]?.poids);
|
||||
}
|
||||
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" },
|
||||
});
|
||||
__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-sm font-bold', __VLS_ctx.isDluoExpired(__VLS_ctx.detailPlant.varieties?.[0]?.dluo ?? '') ? 'text-red' : 'text-green']) },
|
||||
});
|
||||
(__VLS_ctx.detailPlant.varieties?.[0]?.dluo);
|
||||
(__VLS_ctx.isDluoExpired(__VLS_ctx.detailPlant.varieties?.[0]?.dluo ?? '') ? ' ⚠️' : '');
|
||||
}
|
||||
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,
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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}`;
|
||||
|
||||