feat(plantes): popup variété + bouton Variété + temp_germination/temps_levee_j

- Ajoute detailPlantObj (ref<Plant>) synchronisé dans openDetails/prevVariety/nextVariety/closeDetail
- Renomme detailVarieties (ref<Plant[]>) en detailPlantGroup pour la navigation par groupe de nom_commun
- Ajoute detailVarieties comme computed<PlantVariety[]> depuis detailPlantObj.value.varieties
- Ajoute refs/fonctions formulaire variété : showFormVariety, editVariety, formVariety, openAddVariety, openEditVariety, closeFormVariety, submitVariety, deleteVariety
- Bouton  Variété dans le footer du popup détail
- Liste des PlantVariety dans le popup détail (avec édition/suppression et alerte DLUO)
- Champs temp_germination et temps_levee_j dans la section caractéristiques
- Popup formulaire variété (z-[70]) avec tous les champs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-08 19:34:39 +01:00
parent 05b2ddc27c
commit 174ed9c25d

View File

@@ -85,7 +85,7 @@
<!-- Navigation variétés -->
<div class="flex items-center gap-3 mt-3">
<div v-if="detailVarieties.length > 1" class="flex items-center gap-2">
<div v-if="detailPlantGroup.length > 1" class="flex items-center gap-2">
<button @click="prevVariety"
:disabled="detailVarietyIdx === 0"
:class="['w-7 h-7 rounded-full border flex items-center justify-center text-sm font-bold transition-all',
@@ -93,12 +93,12 @@
</button>
<span class="text-text-muted text-xs font-bold">
Variété {{ detailVarietyIdx + 1 }} / {{ detailVarieties.length }}
Variété {{ detailVarietyIdx + 1 }} / {{ detailPlantGroup.length }}
</span>
<button @click="nextVariety"
:disabled="detailVarietyIdx === detailVarieties.length - 1"
:disabled="detailVarietyIdx === detailPlantGroup.length - 1"
:class="['w-7 h-7 rounded-full border flex items-center justify-center text-sm font-bold transition-all',
detailVarietyIdx === detailVarieties.length - 1 ? 'border-bg-soft text-text-muted/30 cursor-not-allowed' : 'border-yellow/50 text-yellow hover:bg-yellow/10']">
detailVarietyIdx === detailPlantGroup.length - 1 ? 'border-bg-soft text-text-muted/30 cursor-not-allowed' : 'border-yellow/50 text-yellow hover:bg-yellow/10']">
</button>
</div>
@@ -134,6 +134,14 @@
<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 -->
@@ -203,6 +211,28 @@
</div>
</div>
<!-- Variétés (PlantVariety) -->
<div v-if="detailVarieties.length" class="space-y-2 mt-4">
<h3 class="text-[9px] font-black text-text-muted uppercase tracking-widest">
🌿 Variétés ({{ detailVarieties.length }})
</h3>
<div class="space-y-1">
<div v-for="v in detailVarieties" :key="v.id"
class="flex items-center justify-between bg-bg/30 px-3 py-2 rounded-lg border border-bg-soft">
<div>
<span class="text-text text-sm font-bold">{{ v.variete || '(sans nom)' }}</span>
<span v-if="v.boutique_nom" class="text-text-muted text-xs ml-2">🛒 {{ v.boutique_nom }}</span>
<span v-if="v.prix_achat" class="text-yellow text-xs ml-2">{{ v.prix_achat.toFixed(2) }}€</span>
<span v-if="v.dluo && isDluoExpired(v.dluo)" class="text-red text-xs ml-1">⚠️ DLUO</span>
</div>
<div class="flex gap-1">
<button @click="openEditVariety(v)" class="text-text-muted hover:text-yellow text-xs px-2">✏️</button>
<button @click="deleteVariety(v.id!)" class="text-text-muted hover:text-red text-xs px-2">✕</button>
</div>
</div>
</div>
</div>
<!-- Galerie Photos -->
<div class="space-y-3">
<div class="flex justify-between items-center">
@@ -228,6 +258,10 @@
<!-- Footer -->
<div class="p-4 bg-bg-hard border-t border-bg-soft flex gap-3">
<button @click="openAddVariety"
class="btn-primary !bg-green !text-bg py-2 px-4 font-black uppercase text-xs flex items-center gap-1">
Variété
</button>
<button @click="startEdit(detailPlant)" class="btn-primary !bg-yellow !text-bg flex-1 py-3 font-black uppercase text-xs tracking-widest">Modifier la fiche</button>
<button @click="removePlant(detailPlant.id!)" class="btn-outline !border-red/20 !text-red hover:bg-red/10 px-6 py-3 font-black uppercase text-xs tracking-widest">Supprimer</button>
</div>
@@ -462,6 +496,81 @@
<!-- Upload Photo -->
<input type="file" ref="fileInput" accept="image/*" class="hidden" @change="handleFileUpload" />
<!-- ====== POPUP FORMULAIRE VARIÉTÉ ====== -->
<div v-if="showFormVariety" class="fixed inset-0 bg-black/80 backdrop-blur-sm z-[70] flex items-center justify-center p-4" @click.self="closeFormVariety">
<div class="bg-bg-hard rounded-3xl p-6 w-full max-w-xl border border-bg-soft shadow-2xl max-h-[90vh] overflow-y-auto">
<div class="flex items-center justify-between mb-5 border-b border-bg-soft pb-4">
<h2 class="text-text font-black text-xl uppercase">
{{ editVariety ? 'Modifier la variété' : ' Nouvelle variété' }}
</h2>
<button @click="closeFormVariety" class="text-text-muted hover:text-red text-2xl"></button>
</div>
<p class="text-text-muted text-xs mb-4 italic">
Plante : <span class="text-yellow font-bold">{{ detailPlantObj?.nom_commun }}</span>
</p>
<form @submit.prevent="submitVariety" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="md:col-span-2">
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Nom de la variété *</label>
<input v-model="formVariety.variete" required
class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm focus:border-yellow outline-none"
placeholder="Ex: Nantaise, Cornue des Andes, Moneymaker…" />
</div>
<div class="md:col-span-2">
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Tags</label>
<input v-model="formVariety.tags"
class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm focus:border-yellow outline-none"
placeholder="bio, f1, ancien, résistant…" />
</div>
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Enseigne</label>
<select v-model="formVariety.boutique_nom"
class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm outline-none focus:border-yellow appearance-none">
<option value=""> Non renseigné </option>
<option v-for="b in BOUTIQUES" :key="b" :value="b">{{ b }}</option>
</select>
</div>
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Prix ()</label>
<input v-model.number="formVariety.prix_achat" type="number" step="0.01" min="0"
class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm focus:border-yellow outline-none" />
</div>
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Poids / Qté sachet</label>
<input v-model="formVariety.poids" placeholder="ex: 5g, 100 graines"
class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm focus:border-yellow outline-none" />
</div>
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Date d'achat</label>
<input v-model="formVariety.date_achat" type="date"
class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm focus:border-yellow outline-none" />
</div>
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">DLUO</label>
<input v-model="formVariety.dluo" type="date"
class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm focus:border-yellow outline-none" />
</div>
<div class="md:col-span-2">
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">URL produit</label>
<input v-model="formVariety.boutique_url" type="url" placeholder="https://…"
class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm focus:border-yellow outline-none" />
</div>
<div class="md:col-span-2">
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Notes spécifiques à cette variété</label>
<textarea v-model="formVariety.notes_variete" rows="3"
class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm focus:border-yellow outline-none resize-none" />
</div>
<div class="md:col-span-2 flex justify-between pt-4 border-t border-bg-soft">
<button type="button" @click="closeFormVariety"
class="text-text-muted hover:text-red uppercase text-xs font-bold px-6">Annuler</button>
<button type="submit"
class="btn-primary px-8 py-3 !bg-green !text-bg font-black">
{{ editVariety ? 'Sauvegarder' : 'Ajouter' }}
</button>
</div>
</form>
</div>
</div>
</div>
</template>
@@ -469,7 +578,7 @@
import { computed, onMounted, reactive, ref } from 'vue'
import axios from 'axios'
import { usePlantsStore } from '@/stores/plants'
import type { Plant } from '@/api/plants'
import type { Plant, PlantVariety } from '@/api/plants'
import { useToast } from '@/composables/useToast'
const plantsStore = usePlantsStore()
@@ -485,17 +594,35 @@ const lightbox = ref<Media | null>(null)
const fileInput = ref<HTMLInputElement | null>(null)
const uploadTarget = ref<Plant | null>(null)
// Navigation variétés
const detailVarieties = ref<Plant[]>([])
// Navigation variétés (groupe de Plant par nom_commun)
const detailPlantGroup = ref<Plant[]>([])
const detailVarietyIdx = ref(0)
const detailPlant = computed(() => detailVarieties.value[detailVarietyIdx.value] ?? null)
const detailPlant = computed(() => detailPlantGroup.value[detailVarietyIdx.value] ?? null)
// detailPlantObj : ref sur la plante ouverte en détail (Plant complet)
const detailPlantObj = ref<Plant | null>(null)
// detailVarieties : variétés (PlantVariety) de la plante affichée
const detailVarieties = computed<PlantVariety[]>(() => {
if (!detailPlantObj.value) return []
return detailPlantObj.value.varieties ?? []
})
// Associations au niveau nom_commun (première variété ayant des données)
const detailAssociations = computed(() =>
detailVarieties.value.find(v => v.associations_favorables?.length || v.associations_defavorables?.length)
detailPlantGroup.value.find(v => v.associations_favorables?.length || v.associations_defavorables?.length)
?? detailPlant.value
)
// Refs pour le formulaire variété
const showFormVariety = ref(false)
const editVariety = ref<PlantVariety | null>(null)
const formVariety = reactive<Partial<PlantVariety>>({
variete: '', tags: '', notes_variete: '',
boutique_nom: '', boutique_url: '', prix_achat: undefined,
date_achat: '', poids: '', dluo: '',
})
interface Media {
id: number; entity_type: string; entity_id: number
url: string; thumbnail_url?: string; titre?: string
@@ -637,7 +764,8 @@ function catTextClass(cat: string) {
}
function closeDetail() {
detailVarieties.value = []
detailPlantGroup.value = []
detailPlantObj.value = null
detailVarietyIdx.value = 0
plantPhotos.value = []
}
@@ -645,21 +773,24 @@ function closeDetail() {
async function openDetails(p: Plant) {
const key = (p.nom_commun || '').toLowerCase()
const group = plantGroups.value.get(key) || [p]
detailVarieties.value = [...group].sort((a, b) => (a.id || 0) - (b.id || 0))
detailPlantGroup.value = [...group].sort((a, b) => (a.id || 0) - (b.id || 0))
detailVarietyIdx.value = 0
await fetchPhotos(detailVarieties.value[0].id!)
detailPlantObj.value = detailPlantGroup.value[0]
await fetchPhotos(detailPlantGroup.value[0].id!)
}
async function prevVariety() {
if (detailVarietyIdx.value > 0) {
detailVarietyIdx.value--
detailPlantObj.value = detailPlant.value
await fetchPhotos(detailPlant.value!.id!)
}
}
async function nextVariety() {
if (detailVarietyIdx.value < detailVarieties.value.length - 1) {
if (detailVarietyIdx.value < detailPlantGroup.value.length - 1) {
detailVarietyIdx.value++
detailPlantObj.value = detailPlant.value
await fetchPhotos(detailPlant.value!.id!)
}
}
@@ -712,6 +843,52 @@ function closeForm() {
})
}
function openAddVariety() {
if (!detailPlantObj.value) return
editVariety.value = null
Object.assign(formVariety, {
variete: '', tags: '', notes_variete: '',
boutique_nom: '', boutique_url: '', prix_achat: undefined,
date_achat: '', poids: '', dluo: '',
})
showFormVariety.value = true
}
function openEditVariety(v: PlantVariety) {
editVariety.value = v
Object.assign(formVariety, { ...v })
showFormVariety.value = true
}
function closeFormVariety() {
showFormVariety.value = false
editVariety.value = null
}
async function submitVariety() {
if (!detailPlantObj.value?.id) return
const payload = { ...formVariety, prix_achat: formVariety.prix_achat ?? undefined }
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()
}
async function deleteVariety(vid: number) {
if (!detailPlantObj.value?.id) return
if (!confirm('Supprimer cette variété ?')) return
await plantsStore.removeVariety(detailPlantObj.value.id, vid)
toast.success('Variété supprimée')
}
async function submitPlant() {
if (submitting.value) return
submitting.value = true