251 lines
16 KiB
Vue
251 lines
16 KiB
Vue
<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>
|