8 mars
This commit is contained in:
@@ -13,7 +13,7 @@
|
||||
</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>
|
||||
@@ -29,28 +29,34 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grille de 5 colonnes -->
|
||||
<!-- 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.id"
|
||||
<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(p.categorie || '') }"
|
||||
:style="{ borderLeftColor: getCatColor(groupPrimaryCategory(p.nom_commun||'')) }"
|
||||
@click="openDetails(p)">
|
||||
|
||||
<!-- Badge catégorie en haut à gauche -->
|
||||
|
||||
<!-- 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(p.categorie || '')]">
|
||||
{{ p.categorie }}
|
||||
<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">{{ p.nom_commun }}</h2>
|
||||
<p v-if="p.variete" class="text-text-muted text-[10px] font-black uppercase tracking-widest mt-1 opacity-60">{{ p.variete }}</p>
|
||||
|
||||
<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>
|
||||
@@ -65,22 +71,45 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modale de Détails (Popup) -->
|
||||
<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="detailPlant = null">
|
||||
<!-- 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 de la modale -->
|
||||
<div class="p-6 border-b border-bg-soft flex justify-between items-start" :style="{ borderLeft: `8px solid ${getCatColor(detailPlant.categorie || '')}` }">
|
||||
<div>
|
||||
<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">{{ detailPlant.nom_commun }}</h2>
|
||||
<p v-if="detailPlant.variete" class="text-yellow font-bold uppercase tracking-widest text-sm mt-1">{{ detailPlant.variete }}</p>
|
||||
<!-- 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="detailVarieties.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 }} / {{ detailVarieties.length }}
|
||||
</span>
|
||||
<button @click="nextVariety"
|
||||
:disabled="detailVarietyIdx === detailVarieties.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']">
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="detailPlant.variete" class="text-yellow font-bold uppercase tracking-widest text-sm">{{ detailPlant.variete }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="closeDetail" class="text-text-muted hover:text-red transition-colors text-2xl ml-4">✕</button>
|
||||
</div>
|
||||
<button @click="detailPlant = null" class="text-text-muted hover:text-red transition-colors text-2xl">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Corps de la modale -->
|
||||
<!-- Corps -->
|
||||
<div class="p-6 overflow-y-auto space-y-6">
|
||||
<!-- Caractéristiques -->
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
@@ -107,6 +136,40 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Boutique -->
|
||||
<div v-if="detailPlant.boutique_nom || detailPlant.prix_achat || detailPlant.poids || detailPlant.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">
|
||||
<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>
|
||||
</div>
|
||||
<div v-if="detailPlant.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>
|
||||
</div>
|
||||
<div v-if="detailPlant.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>
|
||||
</div>
|
||||
<div v-if="detailPlant.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>
|
||||
</div>
|
||||
<div v-if="detailPlant.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>
|
||||
</div>
|
||||
<div v-if="detailPlant.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"
|
||||
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>
|
||||
@@ -115,6 +178,31 @@
|
||||
</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>
|
||||
|
||||
<!-- Galerie Photos -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between items-center">
|
||||
@@ -125,7 +213,7 @@
|
||||
<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"
|
||||
<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" />
|
||||
@@ -138,7 +226,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer de la modale -->
|
||||
<!-- Footer -->
|
||||
<div class="p-4 bg-bg-hard border-t border-bg-soft flex gap-3">
|
||||
<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>
|
||||
@@ -155,7 +243,6 @@
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="submitPlant" class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Champs de formulaire identiques -->
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Nom commun *</label>
|
||||
@@ -208,6 +295,84 @@
|
||||
</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">
|
||||
@@ -224,7 +389,78 @@
|
||||
<button class="absolute top-6 right-6 text-white text-4xl hover:text-yellow">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Upload Photo Trigger (Invisible) -->
|
||||
<!-- 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" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -234,7 +470,6 @@ import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { usePlantsStore } from '@/stores/plants'
|
||||
import type { Plant } from '@/api/plants'
|
||||
import { formatPlantLabel } from '@/utils/plants'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const plantsStore = usePlantsStore()
|
||||
@@ -242,7 +477,6 @@ const toast = useToast()
|
||||
const showForm = ref(false)
|
||||
const submitting = ref(false)
|
||||
const editPlant = ref<Plant | null>(null)
|
||||
const detailPlant = ref<Plant | null>(null)
|
||||
const selectedCat = ref('')
|
||||
const searchQuery = ref('')
|
||||
const plantPhotos = ref<Media[]>([])
|
||||
@@ -251,6 +485,17 @@ 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[]>([])
|
||||
const detailVarietyIdx = ref(0)
|
||||
const detailPlant = computed(() => detailVarieties.value[detailVarietyIdx.value] ?? null)
|
||||
|
||||
// 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)
|
||||
?? detailPlant.value
|
||||
)
|
||||
|
||||
interface Media {
|
||||
id: number; entity_type: string; entity_id: number
|
||||
url: string; thumbnail_url?: string; titre?: string
|
||||
@@ -265,25 +510,118 @@ const categories = [
|
||||
{ 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(() => {
|
||||
let source = plantsStore.plants
|
||||
if (selectedCat.value) source = source.filter(p => p.categorie === selectedCat.value)
|
||||
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()
|
||||
source = source.filter(p =>
|
||||
p.nom_commun?.toLowerCase().includes(q) ||
|
||||
p.variete?.toLowerCase().includes(q)
|
||||
)
|
||||
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 [...source].sort((a, b) => (a.nom_commun || '').localeCompare(b.nom_commun || '', 'fr'))
|
||||
|
||||
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',
|
||||
@@ -298,9 +636,32 @@ function catTextClass(cat: string) {
|
||||
} as any)[cat] || 'text-text-muted'
|
||||
}
|
||||
|
||||
function closeDetail() {
|
||||
detailVarieties.value = []
|
||||
detailVarietyIdx.value = 0
|
||||
plantPhotos.value = []
|
||||
}
|
||||
|
||||
async function openDetails(p: Plant) {
|
||||
detailPlant.value = p
|
||||
await fetchPhotos(p.id!)
|
||||
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))
|
||||
detailVarietyIdx.value = 0
|
||||
await fetchPhotos(detailVarieties.value[0].id!)
|
||||
}
|
||||
|
||||
async function prevVariety() {
|
||||
if (detailVarietyIdx.value > 0) {
|
||||
detailVarietyIdx.value--
|
||||
await fetchPhotos(detailPlant.value!.id!)
|
||||
}
|
||||
}
|
||||
|
||||
async function nextVariety() {
|
||||
if (detailVarietyIdx.value < detailVarieties.value.length - 1) {
|
||||
detailVarietyIdx.value++
|
||||
await fetchPhotos(detailPlant.value!.id!)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPhotos(plantId: number) {
|
||||
@@ -316,31 +677,64 @@ async function fetchPhotos(plantId: number) {
|
||||
}
|
||||
|
||||
function startEdit(p: Plant) {
|
||||
detailPlant.value = null
|
||||
// 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
|
||||
Object.assign(form, {
|
||||
nom_commun: p.nom_commun || '', variete: p.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 || '',
|
||||
})
|
||||
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: '',
|
||||
})
|
||||
}
|
||||
|
||||
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}`, { ...form })
|
||||
await axios.put(`/api/plants/${editPlant.value.id}`, payload)
|
||||
|
||||
// Synchroniser les associations à toutes les variétés 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,
|
||||
})
|
||||
}
|
||||
|
||||
await plantsStore.fetchAll()
|
||||
toast.success('Plante modifiée')
|
||||
} else {
|
||||
await plantsStore.create({ ...form })
|
||||
await plantsStore.create(payload)
|
||||
toast.success('Plante créée')
|
||||
}
|
||||
closeForm()
|
||||
@@ -355,7 +749,7 @@ async function removePlant(id: number) {
|
||||
if (!confirm('Supprimer définitivement cette plante ?')) return
|
||||
try {
|
||||
await plantsStore.remove(id)
|
||||
detailPlant.value = null
|
||||
closeDetail()
|
||||
toast.success('Plante supprimée')
|
||||
} catch {
|
||||
// L'intercepteur Axios affiche le message
|
||||
|
||||
Reference in New Issue
Block a user