This commit is contained in:
2026-03-08 10:04:14 +01:00
parent 7967f63fea
commit 14636bd58f
74 changed files with 14180 additions and 4453 deletions

View File

@@ -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