Deploy Ladill POS / deploy (push) Successful in 28s
Products are now mode-aware:
- Retail: the catalog lives in Ladill CRM. The Products page proxies CRUD to the
CRM products API (CrmClient gains product/create/update/delete), and the
register reads CRM products live; a sold line is stored as a name/price
snapshot (product_id null, since CRM products aren't local rows). Resilient —
the register shows an empty catalog if CRM is unreachable. CRM import is hidden
in Settings (not needed when reading live).
- Restaurant: unchanged — the local pos_products catalog keeps its POS-only depth
(category, kitchen station, course, modifier groups).
ProductController routes take a raw {product} id (CRM id in retail, local id in
restaurant). Suite green (17), covering both modes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
174 lines
10 KiB
PHP
174 lines
10 KiB
PHP
<x-app-layout title="Register">
|
||
<div class="mx-auto max-w-6xl" x-data="posRegister(@js([
|
||
'products' => $products,
|
||
]))">
|
||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||
<div>
|
||
<h1 class="text-lg font-semibold text-slate-900">Register</h1>
|
||
<p class="mt-1 text-sm text-slate-500">{{ $location->name }} · {{ $location->currency }}</p>
|
||
<form method="post" action="{{ route('pos.mode.set') }}" class="mt-2 inline-flex rounded-xl bg-slate-100 p-0.5">
|
||
@csrf
|
||
<button name="style" value="retail"
|
||
@class([
|
||
'rounded-lg px-3 py-1.5 text-xs font-semibold transition',
|
||
'bg-white text-slate-900 shadow-sm' => ! $location->isRestaurant(),
|
||
'text-slate-500 hover:text-slate-700' => $location->isRestaurant(),
|
||
])>Retail</button>
|
||
<button name="style" value="restaurant"
|
||
@class([
|
||
'rounded-lg px-3 py-1.5 text-xs font-semibold transition',
|
||
'bg-white text-slate-900 shadow-sm' => $location->isRestaurant(),
|
||
'text-slate-500 hover:text-slate-700' => ! $location->isRestaurant(),
|
||
])>Restaurant</button>
|
||
</form>
|
||
</div>
|
||
<div class="flex gap-2">
|
||
<button type="button" @click="tab = 'catalog'" :class="tab === 'catalog' ? 'bg-indigo-600 text-white' : 'bg-white text-slate-700 ring-1 ring-slate-200'"
|
||
class="rounded-xl px-4 py-2 text-sm font-medium">Catalog</button>
|
||
<button type="button" @click="tab = 'quick'" :class="tab === 'quick' ? 'bg-indigo-600 text-white' : 'bg-white text-slate-700 ring-1 ring-slate-200'"
|
||
class="rounded-xl px-4 py-2 text-sm font-medium">Quick amount</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mt-6 grid gap-6 lg:grid-cols-5">
|
||
<div class="lg:col-span-3 space-y-4">
|
||
<div x-show="tab === 'catalog'" class="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||
<template x-for="product in products" :key="product.id">
|
||
<button type="button" @click="addProduct(product)"
|
||
class="rounded-2xl border border-slate-200 bg-white p-4 text-left hover:border-indigo-200 hover:shadow-sm">
|
||
<p class="font-medium text-slate-900" x-text="product.name"></p>
|
||
<p class="mt-1 text-sm text-indigo-600" x-text="formatMoney(product.price_minor)"></p>
|
||
</button>
|
||
</template>
|
||
@if (empty($products))
|
||
<p class="col-span-full text-sm text-slate-500">No products yet. <a href="{{ route('pos.products.create') }}" class="text-indigo-600">Add products</a> or use Quick amount.</p>
|
||
@endif
|
||
</div>
|
||
|
||
<div x-show="tab === 'quick'" x-cloak class="rounded-2xl border border-slate-200 bg-white p-6">
|
||
<label class="text-sm font-medium text-slate-700">Amount ({{ $location->currency }})</label>
|
||
<input type="number" x-model="quickAmount" min="0.01" step="0.01" placeholder="0.00"
|
||
class="mt-2 block w-full rounded-xl border-slate-300 text-lg shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||
<label class="mt-4 block text-sm font-medium text-slate-700">Description</label>
|
||
<input type="text" x-model="quickLabel" placeholder="Sale"
|
||
class="mt-2 block w-full rounded-xl border-slate-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||
<button type="button" @click="addQuick()" class="mt-4 rounded-xl bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-800">Add to cart</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="lg:col-span-2">
|
||
<form method="POST" action="{{ route('pos.register.charge') }}" @submit="prepareSubmit" class="rounded-2xl border border-slate-200 bg-white p-5">
|
||
@csrf
|
||
<h2 class="font-semibold text-slate-900">Cart</h2>
|
||
<ul class="mt-4 space-y-2 max-h-64 overflow-y-auto">
|
||
<template x-for="(line, index) in cart" :key="index">
|
||
<li class="flex items-start justify-between gap-2 rounded-xl bg-slate-50 px-3 py-2 text-sm">
|
||
<div>
|
||
<p class="font-medium text-slate-800" x-text="line.name"></p>
|
||
<p class="text-xs text-slate-500"><span x-text="line.quantity"></span> × <span x-text="formatMoney(line.unit_price_minor)"></span></p>
|
||
</div>
|
||
<button type="button" @click="removeLine(index)" class="text-slate-400 hover:text-red-600">×</button>
|
||
</li>
|
||
</template>
|
||
<li x-show="cart.length === 0" class="text-sm text-slate-500">Cart is empty.</li>
|
||
</ul>
|
||
|
||
<div class="mt-4 border-t border-slate-100 pt-4">
|
||
<div class="flex justify-between text-sm">
|
||
<span class="text-slate-500">Total</span>
|
||
<span class="font-semibold text-slate-900" x-text="formatMoney(cartTotal())"></span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mt-4 space-y-3">
|
||
@include('pos.partials.customer-picker', ['customers' => $crmCustomers])
|
||
<input type="text" id="customer_name" name="customer_name" placeholder="Customer name (optional)"
|
||
class="block w-full rounded-xl border-slate-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||
<input type="email" id="customer_email" name="customer_email" placeholder="Email (optional)"
|
||
class="block w-full rounded-xl border-slate-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||
<input type="tel" id="customer_phone" name="customer_phone" placeholder="Phone (optional)"
|
||
class="block w-full rounded-xl border-slate-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||
</div>
|
||
|
||
<div id="line-fields"></div>
|
||
|
||
<div class="mt-5 flex flex-col gap-2">
|
||
<button type="submit" name="payment_method" value="pay" :disabled="cart.length === 0"
|
||
class="btn-primary w-full disabled:opacity-50">Charge MoMo / card</button>
|
||
<button type="submit" name="payment_method" value="cash" :disabled="cart.length === 0"
|
||
class="w-full rounded-xl border border-slate-200 px-4 py-2.5 text-sm font-medium text-slate-700 hover:bg-slate-50 disabled:opacity-50">Record cash</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
function posRegister(config) {
|
||
return {
|
||
tab: 'catalog',
|
||
products: config.products || [],
|
||
cart: [],
|
||
quickAmount: '',
|
||
quickLabel: 'Sale',
|
||
addProduct(product) {
|
||
const existing = this.cart.find(l => l.product_id === product.id);
|
||
if (existing) {
|
||
existing.quantity += 1;
|
||
} else {
|
||
this.cart.push({
|
||
product_id: product.id,
|
||
name: product.name,
|
||
unit_price_minor: product.price_minor,
|
||
quantity: 1,
|
||
});
|
||
}
|
||
},
|
||
addQuick() {
|
||
const amount = Math.round(parseFloat(this.quickAmount || 0) * 100);
|
||
if (amount <= 0) return;
|
||
this.cart.push({
|
||
product_id: null,
|
||
name: this.quickLabel || 'Sale',
|
||
unit_price_minor: amount,
|
||
quantity: 1,
|
||
});
|
||
this.quickAmount = '';
|
||
},
|
||
removeLine(index) {
|
||
this.cart.splice(index, 1);
|
||
},
|
||
cartTotal() {
|
||
return this.cart.reduce((sum, l) => sum + l.unit_price_minor * l.quantity, 0);
|
||
},
|
||
formatMoney(minor) {
|
||
return '{{ $location->currency }} ' + (minor / 100).toFixed(2);
|
||
},
|
||
prepareSubmit(e) {
|
||
if (this.cart.length === 0) {
|
||
e.preventDefault();
|
||
return;
|
||
}
|
||
const container = document.getElementById('line-fields');
|
||
container.innerHTML = '';
|
||
this.cart.forEach((line, i) => {
|
||
const fields = [
|
||
['product_id', line.product_id ?? ''],
|
||
['name', line.name],
|
||
['unit_price_minor', line.unit_price_minor],
|
||
['quantity', line.quantity],
|
||
];
|
||
fields.forEach(([key, val]) => {
|
||
const input = document.createElement('input');
|
||
input.type = 'hidden';
|
||
input.name = `lines[${i}][${key}]`;
|
||
input.value = val;
|
||
container.appendChild(input);
|
||
});
|
||
});
|
||
},
|
||
};
|
||
}
|
||
</script>
|
||
</x-app-layout>
|