site-panzai/pages/admin/index.vue

252 lines
16 KiB
Vue
Raw Normal View History

2026-11-13 13:57:04 +01:00
<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>