site-panzai/pages/admin/menu.vue

115 lines
7.1 KiB
Vue
Raw Permalink Normal View History

2026-11-13 13:57:04 +01:00
<script setup lang="ts">
import type { Category, Dish, Size } from '~/server/utils/store'
definePageMeta({ layout: 'admin' })
const { data, refresh } = useFetch<{ dishes: Dish[] }>('/api/menu', { key: 'menu' })
const dishes = computed(() => data.value?.dishes || [])
const cats: { id: Category, label: string }[] = [
{ id: 'wok', label: 'Plats au wok' }, { id: 'baguette', label: 'Baguette' },
{ id: 'boissons', label: 'Boissons' }, { id: 'desserts', label: 'Desserts' }
]
const filter = ref<Category | 'all'>('all')
const shown = computed(() => filter.value === 'all' ? dishes.value : dishes.value.filter(d => d.cat === filter.value))
const toast = useToast()
async function patch(d: Dish, body: Record<string, unknown>, msg?: string) {
await $fetch(`/api/menu/${d.id}`, { method: 'PATCH', body })
await refresh()
if (msg) toast.show(msg)
}
const toCents = (v: string) => {
const n = Number(v.replace(',', '.').replace(/[^\d.]/g, ''))
return v.trim() === '' || Number.isNaN(n) ? null : Math.round(n * 100)
}
const fmt = (c: number | null | undefined) => c == null ? '' : (c / 100).toFixed(2).replace('.', ',')
function savePrice(d: Dish, s: Size, e: Event) {
const v = toCents((e.target as HTMLInputElement).value)
if (v === d.prices[s]) return
patch(d, { prices: { [s]: v } }, `Prix ${s} de ${d.name} mis à jour`)
}
// Ajout d'un plat
const adding = ref(false)
const form = reactive({ name: '', desc: '', cat: 'wok' as Category, priceL: '', priceXL: '', meats: true })
async function addDish() {
if (!form.name.trim()) return
await $fetch('/api/menu', { method: 'POST', body: {
name: form.name, desc: form.desc, cat: form.cat,
priceL: toCents(form.priceL), priceXL: toCents(form.priceXL),
meats: form.meats && (form.cat === 'wok' || form.cat === 'baguette') ? ['Bœuf', 'Crevettes', 'Poulet'] : []
} })
Object.assign(form, { name: '', desc: '', priceL: '', priceXL: '' })
adding.value = false
await refresh()
toast.show('Plat ajouté — il est déjà visible sur le site')
}
const { msg } = useToast()
</script>
<template>
<div class="stack" style="gap: 20px">
<div class="row wrap" style="gap: 14px">
<div class="grow">
<h1 style="font-size: 28px; font-weight: 800; color: #fff">Gestion du menu</h1>
<p class="small muted">Chaque changement est en ligne instantanément sur le site.</p>
</div>
<button class="btn btn-primary btn-lg" type="button" @click="adding = !adding">{{ adding ? 'Fermer' : '+ Ajouter un plat' }}</button>
</div>
<form v-if="adding" class="card addf" @submit.prevent="addDish">
<div class="field"><label class="label" for="f-n">Nom</label><input id="f-n" v-model="form.name" class="input" required placeholder="Ex. : Thé glacé maison"></div>
<div class="field"><label class="label" for="f-c">Catégorie</label>
<select id="f-c" v-model="form.cat" class="input"><option v-for="c in cats" :key="c.id" :value="c.id">{{ c.label }}</option></select></div>
<div class="field" style="grid-column: 1 / -1"><label class="label" for="f-d">Description</label><input id="f-d" v-model="form.desc" class="input" placeholder="Ingrédients, accompagnement…"></div>
<div class="field"><label class="label" for="f-l">Prix L (€)</label><input id="f-l" v-model="form.priceL" class="input" inputmode="decimal" placeholder="optionnel"></div>
<div class="field"><label class="label" for="f-x">Prix XL / unique (€)</label><input id="f-x" v-model="form.priceXL" class="input" inputmode="decimal" placeholder="Ex. : 3,50"></div>
<label class="row" style="gap: 10px; font-weight: 700"><input v-model="form.meats" type="checkbox" style="width: 20px; height: 20px"> Viande au choix (bœuf / crevettes / poulet)</label>
<button class="btn btn-primary btn-lg" type="submit">Ajouter au menu</button>
</form>
<div class="row wrap" style="gap: 8px">
<button class="chip f" type="button" :aria-pressed="filter === 'all'" @click="filter = 'all'">Tout · {{ dishes.length }}</button>
<button v-for="c in cats" :key="c.id" class="chip f" type="button" :aria-pressed="filter === c.id" @click="filter = c.id">{{ c.label }} · {{ dishes.filter(d => d.cat === c.id).length }}</button>
</div>
<div class="stack" style="gap: 10px">
<article v-for="d in shown" :key="d.id" class="card dish" :class="{ off: !d.available }">
<img v-if="d.img" :src="d.img" alt="" class="thumb">
<span v-else class="thumb ph" title="Photo à ajouter" />
<div class="info">
<p class="row" style="gap: 8px; flex-wrap: wrap"><strong class="strong" style="font-size: 17px">{{ d.name }}</strong><span v-if="d.badge" class="badge" :class="`badge-${d.badge.tone}`">{{ d.badge.label }}</span></p>
<p class="small muted">{{ d.desc || '—' }}</p>
<p class="small muted">{{ d.meats.length ? d.meats.join(' / ') : 'Sans choix de viande' }}<template v-if="d.sauces"> · {{ d.sauces.length }} sauces</template></p>
</div>
<label v-for="s in (['L', 'XL'] as const).filter(s => s in d.prices)" :key="s" class="pricef">
<span>{{ s }}</span>
<input class="input" :value="fmt(d.prices[s])" inputmode="decimal" placeholder="à définir" :aria-label="`Prix ${s} ${d.name}`" @change="savePrice(d, s, $event)">
</label>
<button class="avail" type="button" role="switch" :aria-checked="d.available" @click="patch(d, { available: !d.available }, d.available ? `${d.name} en rupture` : `${d.name} de nouveau disponible`)">
<span class="switch" :aria-checked="d.available" />
{{ d.available ? 'Disponible' : 'Rupture' }}
</button>
</article>
</div>
<div v-if="msg" class="toast" role="status">{{ msg }}</div>
</div>
</template>
<style scoped>
.addf { padding: 20px; display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 14px; align-items: end; }
.addf select { appearance: auto; }
.f { border: 0; color: var(--cream); cursor: pointer; }
.f[aria-pressed="true"] { background: var(--cream); color: var(--bg); font-weight: 800; }
.dish { display: flex; flex-wrap: wrap; align-items: center; gap: 16px; padding: 14px 16px; border: 1px solid transparent; }
.dish.off { border-color: rgba(255,107,74,.45); }
.dish.off .thumb { filter: grayscale(.9) brightness(.7); }
.thumb { width: 68px; height: 68px; object-fit: contain; border-radius: 14px; background: var(--surface-2); padding: 4px; flex: none; }
.thumb.ph { display: block; border: 2px dashed rgba(243,227,195,.25); }
.info { flex: 1 1 220px; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
.pricef { display: flex; flex-direction: column; gap: 4px; font-size: 11px; font-weight: 800; letter-spacing: .8px; }
.pricef .input { width: 100px; min-height: 46px; background: var(--bg); color: var(--orange-1); font-size: 17px; font-weight: 800; }
.avail { display: flex; align-items: center; gap: 10px; min-width: 160px; min-height: 52px; padding: 0 14px 0 10px; border: 0; border-radius: 14px; background: var(--bg); color: var(--cream); font-weight: 800; font-size: 15px; cursor: pointer; }
.avail[aria-checked="false"] { color: var(--danger-text); }
.avail .switch { pointer-events: none; }
</style>