site-panzai/pages/admin/index.vue
2026-11-13 13:57:04 +01:00

251 lines
16 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import type { Order } from '~/server/utils/store'
definePageMeta({ layout: 'admin' })
const { data: orders } = useOrders()
const { data: settings } = useAdminSettings()
// Horloge pour les chronos
const now = ref(Date.now())
onMounted(() => {
const t = setInterval(() => { now.value = Date.now() }, 1000)
onBeforeUnmount(() => clearInterval(t))
})
const cols = [
{ id: 'new', title: 'Nouvelles', dot: 'var(--orange-1)' },
{ id: 'prep', title: 'En préparation', dot: 'var(--orange-2)' },
{ id: 'ready', title: 'Prêtes', dot: 'var(--leaf)' },
{ id: 'done', title: 'Terminées', dot: '#6B5A4A' }
] as const
const inCol = (id: string) => {
const list = (orders.value || []).filter(o => o.status === id)
return id === 'done' ? list.slice(0, 6) : [...list].sort((a, b) => a.createdAt - b.createdAt)
}
const today = computed(() => (orders.value || []).filter(o => o.status !== 'refused').length)
const nNew = computed(() => inCol('new').length)
function elapsed(o: Order) {
const s = Math.max(0, Math.floor((now.value - o.createdAt) / 1000))
return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`
}
function timerColor(o: Order) {
if (o.status === 'done') return '#6B5A4A'
const min = (now.value - o.createdAt) / 60000
if (o.readyBy && now.value > o.readyBy && o.status === 'prep') return 'var(--danger)'
return min >= 20 ? 'var(--danger)' : min >= 12 ? 'var(--orange-1)' : 'var(--leaf)'
}
const selectedId = ref<string | null>(null)
const selected = computed(() => (orders.value || []).find(o => o.id === selectedId.value) || null)
const busy = ref<string | null>(null)
async function act(o: Order, action: 'accept' | 'refuse' | 'advance' | 'refund', minutes?: number) {
busy.value = o.id + action
try { await orderAction(o.id, action, minutes) } finally { busy.value = null }
}
const confirmRefund = ref(false)
watch(selectedId, () => { confirmRefund.value = false })
</script>
<template>
<div class="stack" style="gap: 18px">
<div class="top">
<div class="grow">
<h1 class="h1">Commandes en direct</h1>
<p class="small muted">{{ new Date().toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long' }) }}</p>
</div>
<div class="box kpi">
<p class="eyebrow" style="font-size: 11px">Commandes du jour</p>
<p class="kpi-v">{{ today }}</p>
</div>
<label class="box waitsel">
<span class="small" style="font-weight: 700">Attente affichée</span>
<select :value="settings?.wait" aria-label="Temps d'attente affiché aux clients" @change="saveSettings({ wait: Number(($event.target as HTMLSelectElement).value) })">
<option v-for="m in [10, 15, 20, 30, 45, 60]" :key="m" :value="m">~{{ m }} min</option>
</select>
</label>
<button class="openb" :class="{ paused: !settings?.open }" type="button" role="switch" :aria-checked="!!settings?.open" @click="saveSettings({ open: !settings?.open })">
<span class="sw" />
{{ settings?.open ? 'Commandes ouvertes' : 'Commandes en pause' }}
</button>
</div>
<div v-if="nNew" class="alert" role="status">
<Icon name="bell" :size="28" class="bell" />
<p class="strong" style="font-size: 17px">{{ nNew }} nouvelle{{ nNew > 1 ? 's' : '' }} commande{{ nNew > 1 ? 's' : '' }} à accepter</p>
</div>
<div class="board">
<section v-for="c in cols" :key="c.id" class="col" :aria-label="c.title">
<header class="col-h">
<span class="dot" :style="{ background: c.dot, width: '12px', height: '12px' }" />
<h2>{{ c.title }}</h2>
<span class="n">{{ inCol(c.id).length }}</span>
</header>
<p v-if="!inCol(c.id).length" class="empty small muted">{{ c.id === 'new' ? 'En attente de commandes…' : 'Rien pour le moment' }}</p>
<TransitionGroup name="card" tag="div" class="stack" style="gap: 12px">
<article v-for="o in inCol(c.id)" :key="o.id" class="oc" :class="{ fresh: o.status === 'new', dim: o.status === 'done' }">
<div class="row" style="align-items: flex-start; gap: 8px">
<div class="grow">
<p class="num">#{{ o.num }}</p>
<p class="small muted">{{ hhmm(o.createdAt) }} · {{ o.client.name }}</p>
</div>
<span v-if="o.status === 'done'" class="timer done">Terminée</span>
<span v-else class="timer" :style="{ background: timerColor(o) }">{{ elapsed(o) }}</span>
</div>
<div class="row wrap" style="gap: 6px">
<span class="tag" :style="{ background: o.mode === 'livraison' ? 'var(--orange-1)' : 'var(--leaf)' }">{{ o.mode === 'livraison' ? 'Livraison' : `Retrait · ${o.slot}` }}</span>
<span class="tag-o" :class="{ unpaid: !o.paid }">{{ o.refunded ? 'Remboursée' : o.paid ? `Payé · ${payLabel[o.payment]}` : 'À encaisser' }}</span>
</div>
<ul class="items">
<li v-for="(it, i) in o.items" :key="i">
<strong class="q">{{ it.qty }}×</strong>
<span><strong class="strong">{{ it.name.replace(/^(Le|La|Les) /, '') }} {{ it.size }}</strong><br><span class="small muted">{{ [it.meat, it.sauce, ...it.extras].filter(Boolean).join(' · ') }}</span>
<span v-if="it.note" class="inote">« {{ it.note }} »</span></span>
</li>
</ul>
<div class="row" style="justify-content: space-between">
<strong style="font-size: 20px; color: #fff">{{ eur(o.total) }}</strong>
<div class="row" style="gap: 6px">
<button class="btn btn-icon mini" type="button" aria-label="Imprimer le ticket" @click="printTicket(o)"><Icon name="print" /></button>
<button class="btn btn-icon mini" type="button" aria-label="Voir le détail" @click="selectedId = o.id"><Icon name="arrow" /></button>
</div>
</div>
<template v-if="o.status === 'new'">
<p class="eyebrow" style="font-size: 11px">Accepter — temps de prépa</p>
<div class="acc">
<button v-for="m in [10, 20, 30]" :key="m" class="btn btn-green" type="button" :disabled="!!busy" @click="act(o, 'accept', m)">{{ m }} min</button>
</div>
<button class="btn btn-danger" type="button" :disabled="!!busy" @click="act(o, 'refuse')">Refuser</button>
</template>
<button v-else-if="o.status === 'prep'" class="btn btn-primary btn-lg" type="button" :disabled="!!busy" @click="act(o, 'advance')">Marquer prête<template v-if="o.readyBy"> · {{ hhmm(o.readyBy) }}</template></button>
<button v-else-if="o.status === 'ready'" class="btn btn-cream btn-lg" type="button" :disabled="!!busy" @click="act(o, 'advance')">{{ o.mode === 'livraison' ? 'Remise au livreur' : 'Récupérée par le client' }}</button>
</article>
</TransitionGroup>
</section>
</div>
<!-- Panneau détail -->
<Teleport to="body">
<div v-if="selected" class="ov" @click.self="selectedId = null">
<aside class="panel" aria-labelledby="det-h">
<div class="row" style="gap: 12px; align-items: flex-start">
<div class="grow">
<p class="small muted">Reçue à {{ hhmm(selected.createdAt) }} · {{ selected.mode === 'livraison' ? 'Livraison' : 'Retrait' }}</p>
<h2 id="det-h" style="font-size: 30px; font-weight: 800; color: #fff">#{{ selected.num }}</h2>
</div>
<button class="btn btn-icon mini" type="button" aria-label="Fermer" @click="selectedId = null"><Icon name="close" :stroke="2.6" /></button>
</div>
<div class="card client">
<div class="row" style="gap: 12px">
<span class="av">{{ selected.client.name.split(' ').map(s => s[0]).join('').slice(0, 2).toUpperCase() }}</span>
<div><p class="strong">{{ selected.client.name }}</p><p class="small muted">{{ selected.client.phone }}</p></div>
</div>
<div v-if="selected.address" class="row" style="gap: 12px; align-items: flex-start">
<a class="minimap" :href="`https://maps.google.com/?q=${encodeURIComponent(selected.address)}`" target="_blank" rel="noopener" aria-label="Ouvrir l'adresse dans Google Maps"><span class="pin" /></a>
<div><p class="strong" style="font-size: 15px">{{ selected.address }}</p><p class="small muted">Ouvrir l'itinéraire →</p></div>
</div>
<p v-else class="small">Créneau de retrait : <strong>{{ selected.slot }}</strong></p>
</div>
<ul class="items big">
<li v-for="(it, i) in selected.items" :key="i">
<strong class="q">{{ it.qty }}×</strong>
<span class="grow"><strong class="strong">{{ it.name }} {{ it.size }}</strong><br><span class="small muted">{{ [it.meat, it.sauce, ...it.extras].filter(Boolean).join(' · ') }}</span><span v-if="it.note" class="inote">« {{ it.note }} »</span></span>
<strong>{{ eur(it.unit * it.qty) }}</strong>
</li>
</ul>
<dl class="tot">
<dt>Livraison</dt><dd>{{ eur(selected.fee) }}</dd>
<dt class="big">Total</dt><dd class="big">{{ eur(selected.total) }}</dd>
<dt>Paiement</dt><dd :style="{ color: selected.refunded ? 'var(--danger-text)' : selected.paid ? 'var(--leaf)' : 'var(--orange-1)' }">{{ selected.refunded ? 'Remboursé' : selected.paid ? `Payé · ${payLabel[selected.payment]}` : 'À encaisser' }}</dd>
</dl>
<div>
<p class="eyebrow" style="margin-bottom: 10px">Historique</p>
<ol class="hist">
<li v-for="(h, i) in selected.history" :key="i"><strong>{{ hhmm(h.at) }}</strong> {{ h.label }}</li>
</ol>
</div>
<div class="stack" style="gap: 10px; margin-top: auto">
<div class="acts">
<a :href="`tel:${selected.client.phone.replace(/\s/g, '')}`" class="btn btn-ghost"><Icon name="phone" /> Appeler</a>
<button class="btn btn-ghost" type="button" @click="printTicket(selected)"><Icon name="print" /> Ticket</button>
<button class="btn btn-danger" type="button" :disabled="selected.refunded || selected.payment === 'especes'" @click="confirmRefund = true">Rembourser</button>
</div>
<div v-if="confirmRefund" class="confirm" role="alertdialog" aria-label="Confirmer le remboursement">
<p class="strong">Rembourser {{ eur(selected.total) }} à {{ selected.client.name }} ?</p>
<div class="row" style="gap: 10px">
<button class="btn grow" style="background: var(--danger); color: var(--bg)" type="button" @click="act(selected, 'refund'); confirmRefund = false">Confirmer</button>
<button class="btn btn-ghost grow" type="button" @click="confirmRefund = false">Annuler</button>
</div>
</div>
</div>
</aside>
</div>
</Teleport>
</div>
</template>
<style scoped>
.top { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; }
.h1 { font-size: 28px; font-weight: 800; color: #fff; }
.box { padding: 10px 16px; border-radius: 18px; background: var(--surface); }
.kpi { text-align: right; }
.kpi-v { font-size: 30px; font-weight: 800; color: #fff; line-height: 1.1; }
.waitsel { display: flex; align-items: center; gap: 10px; }
.waitsel select { height: 46px; padding: 0 12px; border-radius: 12px; border: 1px solid var(--line-2); background: var(--bg); font-size: 18px; font-weight: 800; }
.openb { display: flex; align-items: center; gap: 12px; min-height: 66px; padding: 0 20px 0 12px; border: 0; border-radius: 18px; background: var(--leaf); color: var(--bg); font-size: 16px; font-weight: 800; cursor: pointer; }
.openb.paused { background: var(--orange-1); }
.sw { position: relative; width: 56px; height: 32px; border-radius: 16px; background: rgba(20,16,13,.35); }
.sw::after { content: ''; position: absolute; top: 4px; left: 28px; width: 24px; height: 24px; border-radius: 50%; background: #fff; transition: left .2s; }
.paused .sw::after { left: 4px; }
.alert { display: flex; align-items: center; gap: 14px; padding: 14px 18px; border-radius: 18px; background: linear-gradient(90deg, rgba(255,180,58,.18), rgba(232,98,42,.06)); border: 1px solid rgba(255,180,58,.45); color: var(--orange-1); }
.bell { animation: bell 1.2s ease-in-out infinite; transform-origin: 50% 10%; }
.board { display: grid; grid-template-columns: repeat(auto-fit, minmax(255px, 1fr)); gap: 14px; align-items: start; }
.col { padding: 12px; border-radius: 22px; background: #19130F; min-height: 70vh; display: flex; flex-direction: column; gap: 12px; }
.col-h { display: flex; align-items: center; gap: 10px; padding: 6px 6px 0; }
.col-h h2 { font-size: 18px; font-weight: 800; color: #fff; }
.col-h .n { margin-left: auto; min-width: 32px; height: 32px; border-radius: 10px; background: var(--surface-2); color: #fff; font-weight: 800; display: flex; align-items: center; justify-content: center; }
.empty { padding: 20px 8px; text-align: center; }
.oc { padding: 14px; border-radius: 18px; background: var(--surface-3); display: flex; flex-direction: column; gap: 10px; }
.oc.fresh { animation: pulse 1.6s ease-in-out infinite; }
.oc.dim { opacity: .6; }
.num { font-size: 22px; font-weight: 800; color: #fff; letter-spacing: -.3px; }
.timer { padding: 6px 10px; border-radius: 10px; color: var(--bg); font-size: 20px; font-weight: 800; font-variant-numeric: tabular-nums; }
.timer.done { background: var(--surface-2); color: var(--cream); font-size: 13px; }
.tag { padding: 5px 10px; border-radius: 8px; color: var(--bg); font-size: 13px; font-weight: 800; }
.tag-o { padding: 5px 10px; border-radius: 8px; border: 1px solid rgba(140,198,63,.6); font-size: 13px; font-weight: 700; }
.tag-o.unpaid { border-color: rgba(255,107,74,.8); }
.items { list-style: none; margin: 0; padding: 10px 0 0; border-top: 1px solid rgba(243,227,195,.08); display: flex; flex-direction: column; gap: 8px; }
.items li { display: flex; gap: 8px; }
.items .q { color: var(--orange-1); font-size: 16px; min-width: 26px; }
.items .strong { font-size: 16px; }
.inote { display: block; margin-top: 4px; padding: 4px 8px; border-radius: 8px; background: rgba(255,180,58,.1); color: var(--orange-1); font-size: 13px; font-weight: 700; }
.mini { background: #2F241C; color: var(--cream); }
.acc { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; }
.acc .btn { min-height: 54px; font-size: 17px; padding: 0; }
.card-enter-active { transition: all .35s ease; }
.card-enter-from { opacity: 0; transform: translateY(-12px) scale(.97); }
.ov { position: fixed; inset: 0; z-index: 70; background: rgba(8,6,5,.6); display: flex; justify-content: flex-end; animation: fade .2s ease both; }
.panel { width: min(540px, 100%); height: 100%; overflow-y: auto; background: var(--surface); border-left: 1px solid var(--line); padding: 24px; display: flex; flex-direction: column; gap: 20px; animation: rise .3s ease both; }
.client { padding: 16px; background: var(--bg); display: flex; flex-direction: column; gap: 14px; }
.av { width: 44px; height: 44px; border-radius: 50%; background: var(--wood-dark); display: flex; align-items: center; justify-content: center; font-weight: 800; color: var(--orange-1); }
.minimap { position: relative; flex: none; width: 140px; height: 96px; border-radius: 14px; background: repeating-linear-gradient(0deg, #2A221B 0 1px, transparent 1px 18px), repeating-linear-gradient(90deg, #2A221B 0 1px, transparent 1px 18px), #1A1511; }
.minimap .pin { position: absolute; left: 58px; top: 28px; width: 24px; height: 24px; border-radius: 50% 50% 50% 0; transform: rotate(-45deg); background: var(--orange-2); }
.items.big .strong { font-size: 17px; }
.tot { margin: 0; display: grid; grid-template-columns: 1fr auto; gap: 6px; font-size: 15px; }
.tot dt { opacity: .7; }
.tot dd { margin: 0; text-align: right; font-weight: 700; }
.tot .big { font-size: 20px; font-weight: 800; color: #fff; opacity: 1; }
.hist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; font-size: 15px; }
.hist strong { display: inline-block; min-width: 56px; color: #fff; font-variant-numeric: tabular-nums; }
.acts { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
.acts .btn { min-height: 54px; padding: 0 8px; }
.confirm { padding: 16px; border-radius: 16px; background: var(--bg); border: 1px solid rgba(255,107,74,.5); display: flex; flex-direction: column; gap: 12px; }
</style>