Deploy Ladill POS / deploy (push) Successful in 35s
Builds on Phase 1 with the menu structure restaurants need: - Menu setup page (restaurant mode): categories, kitchen stations, and modifier groups with options (price deltas). - Products gain category, kitchen station, default course, and attached modifier groups (managed on the product form). - Ordering: the ticket groups the catalog by category and, when a product has modifier groups, opens a picker (respects min/max select) for options + notes + course before adding. Effective price = base + modifier deltas (resolved server-side; client prices are never trusted). - Fire-by-course: send the whole tab or just one course to the kitchen. - KDS: filter by station; each item shows its station, course, modifiers and notes. Schema additive (pos_categories, pos_stations, pos_modifier_groups, pos_modifiers, product↔group pivot, pos_sale_line_modifiers; category/station/course columns on products and lines). PosRestaurantTest covers modifiers, course and station routing; suite green (11 passed). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
178 lines
12 KiB
PHP
178 lines
12 KiB
PHP
<x-app-layout title="Ticket">
|
||
<div x-data="posTicket(@js([
|
||
'base' => route('pos.tickets.show', $sale),
|
||
'lines' => $lines,
|
||
'products' => $products,
|
||
'totalMinor' => $sale->total_minor,
|
||
'currency' => $sale->currency,
|
||
'courseLabels' => $courses,
|
||
]))" class="space-y-4">
|
||
|
||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||
<div>
|
||
<a href="{{ route('pos.floor') }}" class="inline-flex items-center gap-1 text-sm text-slate-500 hover:text-slate-700">
|
||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5"/></svg>
|
||
Floor
|
||
</a>
|
||
<h1 class="mt-1 text-lg font-semibold text-slate-900">
|
||
{{ $sale->table?->label ?? ucfirst(str_replace('_', ' ', $sale->order_type)) }}
|
||
</h1>
|
||
<p class="text-sm text-slate-500 capitalize">{{ str_replace('_', ' ', $sale->order_type) }}{{ $sale->covers ? ' · '.$sale->covers.' covers' : '' }} · {{ $sale->reference }}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<p x-show="flash" x-text="flash" x-cloak class="rounded-xl border border-indigo-100 bg-indigo-50 px-4 py-2 text-sm text-indigo-700"></p>
|
||
|
||
<div class="grid gap-4 lg:grid-cols-[1fr_22rem]">
|
||
{{-- Catalog --}}
|
||
<div class="rounded-2xl border border-slate-200 bg-white p-4">
|
||
<input type="search" x-model="search" placeholder="Search items…"
|
||
class="mb-3 w-full rounded-xl border-slate-200 text-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||
|
||
<div x-show="categories.length" class="mb-3 flex flex-wrap gap-1.5">
|
||
<button type="button" @click="activeCategory = ''" :class="activeCategory === '' ? 'bg-indigo-600 text-white' : 'bg-slate-100 text-slate-600'"
|
||
class="rounded-full px-3 py-1 text-xs font-medium">All</button>
|
||
<template x-for="c in categories" :key="c">
|
||
<button type="button" @click="activeCategory = c" :class="activeCategory === c ? 'bg-indigo-600 text-white' : 'bg-slate-100 text-slate-600'"
|
||
class="rounded-full px-3 py-1 text-xs font-medium" x-text="c"></button>
|
||
</template>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||
<template x-for="p in filteredProducts" :key="p.id">
|
||
<button type="button" @click="tap(p)" :disabled="busy"
|
||
class="rounded-xl border border-slate-200 p-3 text-left transition hover:border-indigo-300 hover:bg-indigo-50/50 disabled:opacity-50">
|
||
<span class="block text-sm font-medium text-slate-900" x-text="p.name"></span>
|
||
<span class="mt-1 block text-xs text-slate-500" x-text="money(p.price_minor)"></span>
|
||
<span x-show="p.modifier_groups.length" class="mt-1 inline-block text-[10px] font-medium text-indigo-500">options</span>
|
||
</button>
|
||
</template>
|
||
<template x-if="filteredProducts.length === 0">
|
||
<p class="col-span-full py-6 text-center text-sm text-slate-400">No products. Add some under Products.</p>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
|
||
{{-- Ticket --}}
|
||
<div class="flex flex-col rounded-2xl border border-slate-200 bg-white">
|
||
<div class="flex-1 space-y-2 p-4">
|
||
<template x-if="lines.length === 0">
|
||
<p class="py-8 text-center text-sm text-slate-400">No items yet. Tap a product to add it.</p>
|
||
</template>
|
||
<template x-for="line in lines" :key="line.id">
|
||
<div class="rounded-xl border border-slate-100 p-2.5">
|
||
<div class="flex items-start justify-between gap-2">
|
||
<div class="min-w-0">
|
||
<p class="truncate text-sm font-medium text-slate-900" x-text="line.name"></p>
|
||
<p x-show="line.modifiers.length" x-cloak class="text-xs text-slate-500" x-text="line.modifiers.join(', ')"></p>
|
||
<p x-show="line.notes" x-cloak class="text-xs text-rose-600" x-text="line.notes"></p>
|
||
<div class="mt-1 flex items-center gap-1.5">
|
||
<span x-show="line.course" x-cloak class="rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium text-slate-500" x-text="courseLabel(line.course)"></span>
|
||
<span x-show="line.fired" x-cloak class="rounded-full bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-700">In kitchen</span>
|
||
</div>
|
||
</div>
|
||
<div class="text-right">
|
||
<p class="text-sm font-semibold text-slate-900" x-text="money(line.line_total_minor)"></p>
|
||
<div class="mt-1 flex items-center justify-end gap-1.5">
|
||
<button type="button" @click="dec(line)" :disabled="busy" class="flex h-6 w-6 items-center justify-center rounded-md border border-slate-200 text-slate-600 hover:bg-slate-50">−</button>
|
||
<span class="w-5 text-center text-sm" x-text="line.quantity"></span>
|
||
<button type="button" @click="inc(line)" :disabled="busy" class="flex h-6 w-6 items-center justify-center rounded-md border border-slate-200 text-slate-600 hover:bg-slate-50">+</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<div class="border-t border-slate-100 p-4">
|
||
<div class="mb-3 flex items-center justify-between text-base font-semibold text-slate-900">
|
||
<span>Total</span>
|
||
<span x-text="money(totalMinor)"></span>
|
||
</div>
|
||
|
||
{{-- Fire to kitchen (all, or by course when more than one course is waiting) --}}
|
||
<div x-show="hasNew" x-cloak class="mb-2 space-y-1.5">
|
||
<button type="button" @click="send()" :disabled="busy"
|
||
class="w-full rounded-xl border border-amber-300 bg-amber-50 px-4 py-2.5 text-sm font-semibold text-amber-800 transition hover:bg-amber-100 disabled:opacity-40">
|
||
Send all to kitchen
|
||
</button>
|
||
<template x-if="newCourses.length > 1">
|
||
<div class="flex flex-wrap gap-1.5">
|
||
<template x-for="c in newCourses" :key="c">
|
||
<button type="button" @click="send(c)" :disabled="busy"
|
||
class="rounded-lg border border-amber-200 bg-white px-2.5 py-1 text-xs font-medium text-amber-700 hover:bg-amber-50"
|
||
x-text="'Fire ' + courseLabel(c)"></button>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<form method="post" action="{{ route('pos.tickets.settle', $sale) }}" class="space-y-2">
|
||
@csrf
|
||
<input type="text" name="customer_name" placeholder="Customer name (optional)"
|
||
class="w-full rounded-xl border-slate-200 text-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||
<div class="grid grid-cols-2 gap-2">
|
||
<button name="payment_method" value="cash" :disabled="lines.length === 0"
|
||
class="rounded-xl border border-slate-200 bg-white px-3 py-2.5 text-sm font-semibold text-slate-700 hover:bg-slate-50 disabled:opacity-40">Cash</button>
|
||
<button name="payment_method" value="pay" :disabled="lines.length === 0"
|
||
class="btn-primary disabled:opacity-40">Ladill Pay</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{{-- Modifier picker --}}
|
||
<div x-show="picker.open" x-cloak class="fixed inset-0 z-50 flex items-end justify-center bg-slate-900/40 p-0 sm:items-center sm:p-4" @click.self="closePicker()">
|
||
<div class="w-full max-w-md overflow-hidden rounded-t-2xl bg-white shadow-xl sm:rounded-2xl">
|
||
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-3">
|
||
<p class="text-sm font-semibold text-slate-900" x-text="picker.product?.name"></p>
|
||
<button @click="closePicker()" class="rounded-lg p-1.5 text-slate-400 hover:bg-slate-100">
|
||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/></svg>
|
||
</button>
|
||
</div>
|
||
<div class="max-h-[60vh] space-y-4 overflow-y-auto px-5 py-4">
|
||
<template x-for="g in picker.groups" :key="g.id">
|
||
<div>
|
||
<p class="text-xs font-semibold text-slate-600">
|
||
<span x-text="g.name"></span>
|
||
<span class="font-normal text-slate-400" x-text="'(choose ' + g.min + (g.max ? '–' + g.max : '+') + ')'"></span>
|
||
</p>
|
||
<div class="mt-2 flex flex-wrap gap-2">
|
||
<template x-for="m in g.modifiers" :key="m.id">
|
||
<button type="button" @click="toggleModifier(g, m)"
|
||
:class="isSelected(g, m) ? 'border-indigo-500 bg-indigo-50 text-indigo-700' : 'border-slate-200 text-slate-600'"
|
||
class="rounded-lg border px-3 py-1.5 text-sm font-medium">
|
||
<span x-text="m.name"></span>
|
||
<span x-show="m.price_delta_minor" class="text-xs opacity-70"
|
||
x-text="(m.price_delta_minor > 0 ? ' +' : ' ') + money(m.price_delta_minor).replace(currency + ' ', '')"></span>
|
||
</button>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<div>
|
||
<label class="text-xs font-semibold text-slate-600">Course</label>
|
||
<select x-model="picker.course" class="mt-1 w-full rounded-xl border-slate-200 text-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||
<option value="">No course</option>
|
||
@foreach($courses as $key => $label)
|
||
<option value="{{ $key }}">{{ $label }}</option>
|
||
@endforeach
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label class="text-xs font-semibold text-slate-600">Notes</label>
|
||
<input type="text" x-model="picker.notes" maxlength="200" placeholder="e.g. no onions"
|
||
class="mt-1 w-full rounded-xl border-slate-200 text-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center justify-between gap-3 border-t border-slate-100 px-5 py-3">
|
||
<span class="text-sm font-semibold text-slate-900" x-text="money(pickerPrice)"></span>
|
||
<button type="button" @click="confirmPicker()" :disabled="! pickerValid || busy" class="btn-primary disabled:opacity-40">Add to ticket</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</x-app-layout>
|