1030 lines
51 KiB
Vue
1030 lines
51 KiB
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">
|
||
<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>
|