Files
jardin/frontend/src/views/PlantesView.vue

1030 lines
51 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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">
<div class="flex items-center gap-4">
<h1 class="text-3xl font-bold text-yellow tracking-tight">Bibliothèque des Plantes</h1>
<div class="flex gap-2">
<button v-for="cat in categories" :key="cat.val"
@click="selectedCat = cat.val"
:class="['px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest transition-all border',
selectedCat === cat.val ? 'bg-yellow text-bg border-yellow' : 'bg-bg-hard text-text-muted border-bg-soft hover:text-text']">
{{ cat.label }}
</button>
</div>
</div>
<div class="flex items-center gap-3">
<div class="relative min-w-[300px]">
<span class="absolute left-3 top-1/2 -translate-y-1/2 opacity-40">🔍</span>
<input
v-model="searchQuery"
placeholder="Rechercher une plante, variété..."
class="w-full bg-bg-hard border border-bg-soft rounded-full pl-9 pr-4 py-2 text-text text-sm focus:border-yellow transition-all outline-none shadow-inner"
/>
</div>
<button @click="showForm = true" 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> Ajouter une plante
</button>
</div>
</div>
<!-- Grille -->
<div v-if="plantsStore.loading" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
<div v-for="i in 10" :key="i" class="card-jardin h-40 animate-pulse opacity-20"></div>
</div>
<div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
<div v-for="p in filteredPlants" :key="(p.nom_commun||'').toLowerCase()"
class="card-jardin !p-0 group overflow-hidden flex flex-col hover:border-yellow/40 transition-all border-l-[6px] relative min-h-[160px] cursor-pointer"
:style="{ borderLeftColor: getCatColor(groupPrimaryCategory(p.nom_commun||'')) }"
@click="openDetails(p)">
<!-- Badge catégorie -->
<div class="absolute top-2 left-2">
<span :class="['text-[7px] font-black uppercase tracking-[0.2em] px-2 py-0.5 rounded bg-bg/60 backdrop-blur-sm', catTextClass(groupPrimaryCategory(p.nom_commun||''))]">
{{ groupPrimaryCategory(p.nom_commun||'') }}
</span>
</div>
<!-- Badge nombre de variétés -->
<div v-if="groupSize(p.nom_commun||'') > 1" class="absolute top-2 right-2">
<span class="text-[9px] font-black px-2 py-0.5 rounded-full bg-yellow/20 text-yellow border border-yellow/30">
{{ groupSize(p.nom_commun||'') }} variétés
</span>
</div>
<div class="p-5 flex-1 flex flex-col justify-center">
<h2 class="text-text font-bold text-2xl leading-tight group-hover:text-yellow transition-colors capitalize">{{ p.nom_commun }}</h2>
<div class="mt-4 flex flex-wrap gap-2">
<div v-if="p.plantation_mois" class="flex items-center gap-2 bg-bg/40 px-2 py-1 rounded border border-bg-soft">
<span class="text-[10px]">📅</span>
<span class="text-[10px] font-black text-text-muted">P: {{ p.plantation_mois }}</span>
</div>
<div v-if="p.besoin_eau" class="flex items-center gap-2 bg-bg/40 px-2 py-1 rounded border border-bg-soft">
<span class="text-[10px] text-blue">💧</span>
<span class="text-[10px] font-black text-text-muted">{{ p.besoin_eau }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- Modale Détails -->
<div v-if="detailPlant" class="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4" @click.self="closeDetail">
<div class="bg-bg-hard rounded-3xl w-full max-w-2xl border border-bg-soft shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
<!-- Header -->
<div class="p-6 border-b border-bg-soft" :style="{ borderLeft: `8px solid ${getCatColor(detailPlant.categorie || '')}` }">
<div class="flex justify-between items-start">
<div class="flex-1 min-w-0">
<span :class="['text-[10px] font-black uppercase tracking-widest px-2 py-1 rounded mb-2 inline-block bg-bg/50', catTextClass(detailPlant.categorie || '')]">
{{ detailPlant.categorie }}
</span>
<h2 class="text-text font-black text-4xl leading-none capitalize">{{ detailPlant.nom_commun }}</h2>
<!-- Navigation variétés -->
<div class="flex items-center gap-3 mt-3">
<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',
detailVarietyIdx === 0 ? 'border-bg-soft text-text-muted/30 cursor-not-allowed' : 'border-yellow/50 text-yellow hover:bg-yellow/10']">
</button>
<span class="text-text-muted text-xs font-bold">
Variété {{ detailVarietyIdx + 1 }} / {{ detailPlantGroup.length }}
</span>
<button @click="nextVariety"
: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 === 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.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>
</div>
</div>
<!-- Corps -->
<div class="p-6 overflow-y-auto space-y-6">
<!-- Caractéristiques -->
<div class="grid grid-cols-3 gap-4">
<div class="bg-bg/30 p-3 rounded-xl border border-bg-soft">
<span class="text-[10px] font-black text-text-muted uppercase block mb-1">Besoin en eau</span>
<div class="flex items-center gap-2 text-blue">
<span>💧</span>
<span class="font-bold capitalize">{{ detailPlant.besoin_eau }}</span>
</div>
</div>
<div class="bg-bg/30 p-3 rounded-xl border border-bg-soft">
<span class="text-[10px] font-black text-text-muted uppercase block mb-1">Exposition</span>
<div class="flex items-center gap-2 text-yellow">
<span></span>
<span class="font-bold capitalize">{{ detailPlant.besoin_soleil }}</span>
</div>
</div>
<div class="bg-bg/30 p-3 rounded-xl border border-bg-soft">
<span class="text-[10px] font-black text-text-muted uppercase block mb-1">Plantation</span>
<div class="flex items-center gap-2 text-green">
<span>📅</span>
<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.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.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.varieties?.[0]?.boutique_nom }}</span>
</div>
<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.varieties?.[0]?.prix_achat?.toFixed(2) }} </span>
</div>
<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.varieties?.[0]?.date_achat }}</span>
</div>
<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.varieties?.[0]?.poids }}</span>
</div>
<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.varieties?.[0]?.dluo ?? '') ? 'text-red' : 'text-green']">
{{ detailPlant.varieties?.[0]?.dluo }}{{ isDluoExpired(detailPlant.varieties?.[0]?.dluo ?? '') ? ' ' : '' }}
</span>
</div>
<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.varieties?.[0]?.boutique_url" target="_blank" rel="noopener"
class="text-blue text-xs hover:underline truncate block">🔗 Voir le produit</a>
</div>
</div>
</div>
<!-- Notes -->
<div v-if="detailPlant.notes" class="space-y-2">
<h3 class="text-[10px] font-black text-text-muted uppercase tracking-widest">Conseils & Notes</h3>
<div class="bg-bg/40 p-4 rounded-2xl border-l-4 border-yellow/30 text-text/90 leading-relaxed italic text-sm whitespace-pre-line">
{{ detailPlant.notes }}
</div>
</div>
<!-- Associations (niveau nom commun) -->
<div v-if="detailAssociations?.associations_favorables?.length || detailAssociations?.associations_defavorables?.length" class="space-y-3">
<h3 class="text-[10px] font-black text-text-muted uppercase tracking-widest">Associations</h3>
<div class="grid grid-cols-2 gap-4">
<div v-if="detailAssociations?.associations_favorables?.length">
<div class="text-[10px] font-black text-green uppercase mb-1.5">🤝 Favorables</div>
<div class="flex flex-wrap gap-1.5">
<span v-for="n in detailAssociations.associations_favorables" :key="n"
class="bg-green/10 border border-green/40 text-green text-[11px] px-2 py-0.5 rounded-full capitalize">
{{ n }}
</span>
</div>
</div>
<div v-if="detailAssociations?.associations_defavorables?.length">
<div class="text-[10px] font-black text-red uppercase mb-1.5">⚡ À éviter</div>
<div class="flex flex-wrap gap-1.5">
<span v-for="n in detailAssociations.associations_defavorables" :key="n"
class="bg-red/10 border border-red/40 text-red text-[11px] px-2 py-0.5 rounded-full capitalize">
{{ n }}
</span>
</div>
</div>
</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">
<h3 class="text-[10px] font-black text-text-muted uppercase tracking-widest">Photos & Médias</h3>
<button @click="openUpload(detailPlant)" class="text-[10px] font-black text-yellow hover:underline uppercase">+ Ajouter</button>
</div>
<div v-if="loadingPhotos" class="grid grid-cols-4 gap-2 animate-pulse">
<div v-for="i in 4" :key="i" class="aspect-square bg-bg-soft rounded-lg"></div>
</div>
<div v-else-if="plantPhotos.length" class="grid grid-cols-4 gap-2">
<div v-for="m in plantPhotos" :key="m.id"
class="aspect-square rounded-lg overflow-hidden bg-bg relative group cursor-pointer border border-bg-soft"
@click="lightbox = m">
<img :src="m.thumbnail_url || m.url" class="w-full h-full object-cover group-hover:scale-110 transition-transform" />
<button @click.stop="deletePhoto(m)" class="absolute top-1 right-1 w-6 h-6 bg-red/80 text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">✕</button>
</div>
</div>
<div v-else class="text-center py-8 bg-bg/20 rounded-2xl border border-dashed border-bg-soft opacity-40">
<span class="text-xs italic">Aucune photo pour le moment</span>
</div>
</div>
</div>
<!-- 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>
</div>
</div>
<!-- Modale Formulaire (Ajout/Edition) -->
<div v-if="showForm || editPlant" class="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4" @click.self="closeForm">
<div class="bg-bg-hard rounded-3xl p-8 w-full max-w-4xl border border-bg-soft shadow-2xl max-h-[90vh] overflow-y-auto">
<div class="flex items-center justify-between mb-8 border-b border-bg-soft pb-4">
<h2 class="text-text font-black text-2xl uppercase tracking-tighter">{{ editPlant ? 'Modifier la plante' : 'Nouvelle plante' }}</h2>
<button @click="closeForm" class="text-text-muted hover:text-red transition-colors text-2xl">✕</button>
</div>
<form @submit.prevent="submitPlant" class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-4">
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Nom commun *</label>
<input v-model="form.nom_commun" 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" />
</div>
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Variété</label>
<input v-model="form.variete" 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="grid grid-cols-2 gap-4">
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Catégorie</label>
<select v-model="form.categorie" 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="c in categories.slice(1)" :key="c.val" :value="c.val">{{ c.label.split(' ')[1] }}</option>
</select>
</div>
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Famille</label>
<input v-model="form.famille" 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>
</div>
<div class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Eau</label>
<select v-model="form.besoin_eau" 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="faible">Faible</option>
<option value="moyen">Moyen</option>
<option value="élevé">Élevé</option>
</select>
</div>
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Soleil</label>
<select v-model="form.besoin_soleil" 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="ombre">Ombre</option>
<option value="mi-ombre">Mi-ombre</option>
<option value="plein soleil">Plein soleil</option>
</select>
</div>
</div>
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Mois Plantation (ex: 3,4,5)</label>
<input v-model="form.plantation_mois" 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">Notes & Conseils</label>
<textarea v-model="form.notes" rows="1" @input="autoResize" 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 overflow-hidden" />
</div>
</div>
<!-- Boutique / Approvisionnement (par variété) -->
<div class="md:col-span-2 bg-bg/40 border border-bg-soft rounded-2xl p-4">
<div class="flex items-center gap-2 mb-4">
<span class="text-[10px] font-black text-text-muted uppercase tracking-widest">🛒 Boutique / Approvisionnement</span>
<span class="text-[9px] text-text-muted/50 italic">spécifique à cette variété</span>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Enseigne</label>
<select v-model="form.boutique_nom" class="w-full bg-bg border border-bg-soft rounded-xl px-3 py-2 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="form.prix_achat" type="number" step="0.01" min="0" placeholder="0.00"
class="w-full bg-bg border border-bg-soft rounded-xl px-3 py-2 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="form.date_achat" type="date"
class="w-full bg-bg border border-bg-soft rounded-xl px-3 py-2 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="form.poids" placeholder="ex: 5g, 50 graines, 1kg"
class="w-full bg-bg border border-bg-soft rounded-xl px-3 py-2 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="form.dluo" type="date"
class="w-full bg-bg border border-bg-soft rounded-xl px-3 py-2 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">URL produit</label>
<input v-model="form.boutique_url" type="url" placeholder="https://..."
class="w-full bg-bg border border-bg-soft rounded-xl px-3 py-2 text-text text-sm focus:border-yellow outline-none" />
</div>
</div>
</div>
<!-- Associations (niveau nom commun s'applique à toutes les variétés) -->
<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">
<div>
<span class="text-[10px] font-black text-text-muted uppercase tracking-widest">Associations</span>
<span class="text-[9px] text-text-muted/50 ml-2 italic">s'applique à toutes les variétés</span>
</div>
<button type="button" @click="showAssocModal = true"
class="px-3 py-1 rounded-full text-xs font-bold border border-bg-soft text-text-muted hover:border-yellow hover:text-yellow transition-all">
Modifier
</button>
</div>
<div class="mb-2">
<div class="text-[9px] font-black text-green uppercase tracking-widest mb-1">🤝 Favorables</div>
<div class="flex flex-wrap gap-1.5 min-h-[22px]">
<span v-if="!form.associations_favorables.length" class="text-[10px] text-text-muted/40 italic">aucune</span>
<span v-for="n in form.associations_favorables" :key="n"
class="flex items-center gap-1 bg-green/10 border border-green/40 text-green text-[11px] px-2 py-0.5 rounded-full capitalize">
{{ n }}
<button type="button" @click="askRemoveAssoc('fav', n)" class="hover:text-red leading-none ml-0.5"></button>
</span>
</div>
</div>
<div>
<div class="text-[9px] font-black text-red uppercase tracking-widest mb-1"> À éviter</div>
<div class="flex flex-wrap gap-1.5 min-h-[22px]">
<span v-if="!form.associations_defavorables.length" class="text-[10px] text-text-muted/40 italic">aucune</span>
<span v-for="n in form.associations_defavorables" :key="n"
class="flex items-center gap-1 bg-red/10 border border-red/40 text-red text-[11px] px-2 py-0.5 rounded-full capitalize">
{{ n }}
<button type="button" @click="askRemoveAssoc('def', n)" class="hover:text-red/60 leading-none ml-0.5"></button>
</span>
</div>
</div>
</div>
<div class="md:col-span-2 flex justify-between items-center pt-6 border-t border-bg-soft mt-4">
<button type="button" @click="closeForm" class="btn-outline border-transparent text-text-muted hover:text-red uppercase text-xs font-bold px-6">Annuler</button>
<button type="submit" class="btn-primary px-12 py-4 text-base shadow-xl !bg-yellow !text-bg">
{{ editPlant ? 'Sauvegarder' : 'Enregistrer' }}
</button>
</div>
</form>
</div>
</div>
<!-- Lightbox Photo -->
<div v-if="lightbox" class="fixed inset-0 bg-black/95 z-[100] flex items-center justify-center p-4" @click="lightbox = null">
<img :src="lightbox.url" class="max-w-full max-h-full object-contain rounded-lg shadow-2xl animate-fade-in" />
<button class="absolute top-6 right-6 text-white text-4xl hover:text-yellow"></button>
</div>
<!-- Popup Gestion Associations -->
<div v-if="showAssocModal" class="fixed inset-0 bg-black/80 backdrop-blur-sm z-[60] flex items-center justify-center p-4" @click.self="showAssocModal = false">
<div class="bg-bg-hard rounded-2xl w-full max-w-lg border border-bg-soft shadow-2xl flex flex-col max-h-[80vh]">
<div class="flex items-center justify-between p-4 border-b border-bg-soft">
<h3 class="text-text font-black uppercase tracking-tight">
Associations {{ form.nom_commun || 'plante' }}
</h3>
<button @click="showAssocModal = false" class="text-text-muted hover:text-red text-xl transition-colors"></button>
</div>
<div class="px-4 pt-3 pb-2">
<input v-model="assocFilter" placeholder="🔍 Filtrer les plantes..."
class="w-full bg-bg border border-bg-soft rounded-xl px-3 py-2 text-text text-sm focus:border-yellow outline-none" />
</div>
<div class="overflow-y-auto flex-1 px-4 pb-4 space-y-1">
<div v-for="name in filteredAssocPlants" :key="name"
class="flex items-center justify-between py-2 px-3 rounded-xl hover:bg-bg/40 transition-colors">
<span class="text-text text-sm font-medium flex-1 capitalize">{{ name }}</span>
<div class="flex gap-1.5">
<button type="button"
:class="['px-2 py-1 rounded-lg text-[10px] font-black uppercase transition-all border',
getAssocState(name) === 'fav'
? 'bg-green text-bg border-green'
: 'border-green/30 text-green/60 hover:border-green hover:text-green']"
@click="setAssocState(name, 'fav')">
🤝 Ami
</button>
<button type="button"
:class="['px-2 py-1 rounded-lg text-[10px] font-black uppercase transition-all border',
getAssocState(name) === 'def'
? 'bg-red text-bg border-red'
: 'border-red/30 text-red/60 hover:border-red hover:text-red']"
@click="setAssocState(name, 'def')">
Ennemi
</button>
<button type="button"
:class="['px-2 py-1 rounded-lg text-[10px] font-black uppercase transition-all border',
getAssocState(name) === null
? 'bg-bg-soft text-text-muted border-bg-soft'
: 'border-bg-soft/40 text-text-muted/40 hover:border-bg-soft hover:text-text-muted']"
@click="setAssocState(name, null)">
· Neutre
</button>
</div>
</div>
<div v-if="!filteredAssocPlants.length" class="text-center py-8 text-text-muted/40 text-xs italic">
Aucune plante trouvée
</div>
</div>
<div class="p-4 border-t border-bg-soft text-center">
<button @click="showAssocModal = false"
class="px-6 py-2 rounded-xl bg-yellow text-bg font-black text-xs uppercase tracking-widest hover:opacity-90 transition-opacity">
Valider
</button>
</div>
</div>
</div>
<!-- Confirmation suppression tag -->
<div v-if="confirmRemove" class="fixed inset-0 bg-black/60 z-[70] flex items-center justify-center p-4">
<div class="bg-bg-hard rounded-2xl p-6 w-full max-w-sm border border-bg-soft shadow-2xl text-center">
<p class="text-text mb-1 font-bold">Retirer <span class="text-yellow capitalize">{{ confirmRemove.name }}</span> ?</p>
<p class="text-text-muted text-xs mb-5">
{{ confirmRemove.type === 'fav' ? 'Retirer des associations favorables' : 'Retirer des associations défavorables' }}
</p>
<div class="flex gap-3 justify-center">
<button @click="confirmRemove = null" class="px-4 py-2 rounded-xl border border-bg-soft text-text-muted text-xs font-bold hover:text-text transition-colors">Annuler</button>
<button @click="confirmRemoveAssoc" class="px-4 py-2 rounded-xl bg-red text-bg text-xs font-black uppercase hover:opacity-90 transition-opacity">Retirer</button>
</div>
</div>
</div>
<!-- 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>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import axios from 'axios'
import { usePlantsStore } from '@/stores/plants'
import type { Plant, PlantVariety } from '@/api/plants'
import { useToast } from '@/composables/useToast'
const plantsStore = usePlantsStore()
const toast = useToast()
const showForm = ref(false)
const submitting = ref(false)
const editPlant = ref<Plant | null>(null)
const selectedCat = ref('')
const searchQuery = ref('')
const plantPhotos = ref<Media[]>([])
const loadingPhotos = ref(false)
const lightbox = ref<Media | null>(null)
const fileInput = ref<HTMLInputElement | null>(null)
const uploadTarget = ref<Plant | null>(null)
// Navigation variétés (groupe de Plant par nom_commun)
const detailPlantGroup = ref<Plant[]>([])
const detailVarietyIdx = ref(0)
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(() =>
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
}
const categories = [
{ val: '', label: 'TOUTES' },
{ val: 'potager', label: '🥕 POTAGER' },
{ val: 'fleur', label: '🌸 FLEUR' },
{ val: 'arbre', label: '🌳 ARBRE' },
{ val: 'arbuste', label: '🌿 ARBUSTE' },
{ val: 'adventice', label: '🌾 ADVENTICES' },
]
const BOUTIQUES = [
'Gamm Vert', 'Lidl', 'Super U', 'Intermarché', 'Truffaut', 'Botanic',
'Amazon', 'Graines Baumaux', 'Vilmorin', 'Germinance', 'Direct producteur',
'Marché local', 'Autre',
]
const form = reactive({
nom_commun: '', variete: '', famille: '',
categorie: 'potager', besoin_eau: 'moyen', besoin_soleil: 'plein soleil',
plantation_mois: '', notes: '',
associations_favorables: [] as string[],
associations_defavorables: [] as string[],
boutique_nom: '', boutique_url: '', prix_achat: null as number | null,
date_achat: '', poids: '', dluo: '',
})
const showAssocModal = ref(false)
const assocFilter = ref('')
const confirmRemove = ref<{ type: 'fav' | 'def'; name: string } | null>(null)
// Grouper toutes les plantes par nom_commun (insensible à la casse)
const plantGroups = computed(() => {
const groups = new Map<string, Plant[]>()
for (const p of plantsStore.plants) {
const key = (p.nom_commun || '').toLowerCase()
if (!groups.has(key)) groups.set(key, [])
groups.get(key)!.push(p)
}
return groups
})
function groupSize(nom: string): number {
return plantGroups.value.get(nom.toLowerCase())?.length ?? 1
}
function groupPrimaryCategory(nom: string): string {
const group = plantGroups.value.get(nom.toLowerCase()) || []
return group[0]?.categorie || ''
}
// Une carte par nom_commun unique dans la liste
const filteredPlants = computed(() => {
const uniqueByName = new Map<string, Plant>()
for (const p of [...plantsStore.plants].sort((a, b) => (a.id || 0) - (b.id || 0))) {
const key = (p.nom_commun || '').toLowerCase()
if (!uniqueByName.has(key)) uniqueByName.set(key, p)
}
let result = [...uniqueByName.values()]
if (selectedCat.value) {
result = result.filter(p => {
const group = plantGroups.value.get((p.nom_commun || '').toLowerCase()) || []
return group.some(v => v.categorie === selectedCat.value)
})
}
if (searchQuery.value) {
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.varieties?.[0]?.variete?.toLowerCase().includes(q)
)
})
}
return result.sort((a, b) => (a.nom_commun || '').localeCompare(b.nom_commun || '', 'fr'))
})
// Plantes disponibles pour les associations (nom_commun uniques, hors plante courante)
const filteredAssocPlants = computed(() => {
const q = assocFilter.value.toLowerCase()
const uniqueNames = [...plantGroups.value.keys()]
return uniqueNames
.filter(n => n !== form.nom_commun.toLowerCase() && (!q || n.includes(q)))
.sort()
})
function getAssocState(name: string): 'fav' | 'def' | null {
const lower = name.toLowerCase()
if (form.associations_favorables.some(n => n.toLowerCase() === lower)) return 'fav'
if (form.associations_defavorables.some(n => n.toLowerCase() === lower)) return 'def'
return null
}
function setAssocState(name: string, state: 'fav' | 'def' | null) {
const lower = name.toLowerCase()
form.associations_favorables = form.associations_favorables.filter(n => n.toLowerCase() !== lower)
form.associations_defavorables = form.associations_defavorables.filter(n => n.toLowerCase() !== lower)
if (state === 'fav') form.associations_favorables.push(lower)
else if (state === 'def') form.associations_defavorables.push(lower)
}
function askRemoveAssoc(type: 'fav' | 'def', name: string) {
confirmRemove.value = { type, name }
}
function confirmRemoveAssoc() {
if (!confirmRemove.value) return
const { type, name } = confirmRemove.value
const list = type === 'fav' ? form.associations_favorables : form.associations_defavorables
const lower = name.toLowerCase()
const idx = list.findIndex(n => n.toLowerCase() === lower)
if (idx !== -1) list.splice(idx, 1)
confirmRemove.value = null
}
function isDluoExpired(dluo: string): boolean {
return !!dluo && new Date(dluo) < new Date()
}
function getCatColor(cat: string) {
return ({
potager: '#b8bb26', fleur: '#fabd2f', arbre: '#83a598',
arbuste: '#d3869b', adventice: '#fb4934',
} as any)[cat] || '#928374'
}
function catTextClass(cat: string) {
return ({
potager: 'text-green', fleur: 'text-yellow', arbre: 'text-blue',
arbuste: 'text-purple', adventice: 'text-red',
} as any)[cat] || 'text-text-muted'
}
function closeDetail() {
detailPlantGroup.value = []
detailPlantObj.value = null
detailVarietyIdx.value = 0
plantPhotos.value = []
}
async function openDetails(p: Plant) {
const key = (p.nom_commun || '').toLowerCase()
const group = plantGroups.value.get(key) || [p]
detailPlantGroup.value = [...group].sort((a, b) => (a.id || 0) - (b.id || 0))
detailVarietyIdx.value = 0
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 < detailPlantGroup.value.length - 1) {
detailVarietyIdx.value++
detailPlantObj.value = detailPlant.value
await fetchPhotos(detailPlant.value!.id!)
}
}
async function fetchPhotos(plantId: number) {
loadingPhotos.value = true
try {
const { data } = await axios.get<Media[]>('/api/media', {
params: { entity_type: 'plante', entity_id: plantId }
})
plantPhotos.value = data
} finally {
loadingPhotos.value = false
}
}
function startEdit(p: Plant) {
// Chercher les associations dans le groupe (première variété qui en a)
const key = (p.nom_commun || '').toLowerCase()
const group = plantGroups.value.get(key) || [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: 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: 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
showForm.value = true
}
function closeForm() {
showForm.value = false
editPlant.value = null
showAssocModal.value = false
assocFilter.value = ''
confirmRemove.value = null
Object.assign(form, {
boutique_nom: '', boutique_url: '', prix_achat: null,
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: 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 {
// 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 }
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
)
for (const sibling of siblings) {
await axios.put(`/api/plants/${sibling.id}`, {
associations_favorables: form.associations_favorables,
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 {
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()
} catch {
// L'intercepteur Axios affiche le message
} finally {
submitting.value = false
}
}
async function removePlant(id: number) {
if (!confirm('Supprimer définitivement cette plante ?')) return
try {
await plantsStore.remove(id)
closeDetail()
toast.success('Plante supprimée')
} catch {
// L'intercepteur Axios affiche le message
}
}
function openUpload(p: Plant) {
uploadTarget.value = p
fileInput.value?.click()
}
async function handleFileUpload(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file || !uploadTarget.value) return
const fd = new FormData()
fd.append('file', file)
try {
const { data: uploaded } = await axios.post('/api/upload', fd)
await axios.post('/api/media', {
entity_type: 'plante', entity_id: uploadTarget.value.id,
url: uploaded.url, thumbnail_url: uploaded.thumbnail_url,
})
await fetchPhotos(uploadTarget.value.id!)
toast.success('Photo ajoutée')
} catch {
// L'intercepteur Axios affiche le message
} finally {
uploadTarget.value = null
if (fileInput.value) fileInput.value.value = ''
}
}
async function deletePhoto(m: Media) {
if (!confirm('Supprimer cette photo ?')) return
try {
await axios.delete(`/api/media/${m.id}`)
if (detailPlant.value) await fetchPhotos(detailPlant.value.id!)
toast.success('Photo supprimée')
} catch {
// L'intercepteur Axios affiche le message
}
}
function autoResize(event: Event) {
const el = event.target as HTMLTextAreaElement
el.style.height = 'auto'
el.style.height = el.scrollHeight + 'px'
}
onMounted(async () => {
try {
await plantsStore.fetchAll()
} catch {
toast.error('Impossible de charger les plantes')
}
})
</script>