Deploy Ladill POS / deploy (push) Successful in 1m42s
Register supports USB keyboard-wedge scanners with SKU lookup (local catalog and CRM in retail mode). Sales get a thermal receipt print template (58/80mm) with configurable header/footer, plus optional auto-print after cash sales. Co-authored-by: Cursor <cursoragent@cursor.com>
267 lines
15 KiB
PHP
267 lines
15 KiB
PHP
<x-app-layout title="Register">
|
||
<div class="mx-auto max-w-6xl" x-data="posRegister(@js([
|
||
'products' => $products,
|
||
'lookupUrl' => $barcodeLookupUrl,
|
||
'csrf' => csrf_token(),
|
||
]))">
|
||
<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 class="rounded-2xl border border-slate-200 bg-white p-4">
|
||
<label for="barcode-scan" class="text-sm font-medium text-slate-700">Barcode scanner</label>
|
||
<p class="mt-0.5 text-xs text-slate-400">Scan with a USB scanner or type a SKU/barcode and press Enter.</p>
|
||
<div class="mt-2 flex gap-2">
|
||
<input type="text" id="barcode-scan" x-ref="scanInput" x-model="scanCode"
|
||
@keydown.enter.prevent="submitScan()"
|
||
placeholder="Scan barcode…" autocomplete="off"
|
||
class="block w-full rounded-xl border-slate-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||
<button type="button" @click="submitScan()"
|
||
class="shrink-0 rounded-xl bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-800">Add</button>
|
||
</div>
|
||
<p x-show="scanError" x-cloak class="mt-2 text-xs text-red-600" x-text="scanError"></p>
|
||
<p x-show="scanOk" x-cloak class="mt-2 text-xs text-green-700" x-text="scanOk"></p>
|
||
</div>
|
||
|
||
<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-0.5 text-xs text-slate-400" x-show="product.sku" x-text="product.sku"></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 || [],
|
||
lookupUrl: config.lookupUrl || '',
|
||
csrf: config.csrf || '',
|
||
cart: [],
|
||
quickAmount: '',
|
||
quickLabel: 'Sale',
|
||
scanCode: '',
|
||
scanError: '',
|
||
scanOk: '',
|
||
scanBuffer: '',
|
||
scanTimer: null,
|
||
init() {
|
||
document.addEventListener('keydown', (e) => this.onGlobalKeydown(e));
|
||
},
|
||
onGlobalKeydown(e) {
|
||
if (this.shouldIgnoreScan(e)) return;
|
||
if (e.key === 'Enter') {
|
||
const code = (this.scanBuffer || '').trim();
|
||
this.scanBuffer = '';
|
||
if (code.length >= 3) {
|
||
e.preventDefault();
|
||
this.scanCode = code;
|
||
this.submitScan();
|
||
}
|
||
return;
|
||
}
|
||
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||
this.scanBuffer += e.key;
|
||
clearTimeout(this.scanTimer);
|
||
this.scanTimer = setTimeout(() => { this.scanBuffer = ''; }, 120);
|
||
}
|
||
},
|
||
shouldIgnoreScan(e) {
|
||
const tag = (e.target?.tagName || '').toLowerCase();
|
||
if (tag === 'textarea') return true;
|
||
if (tag === 'input' && e.target?.id !== 'barcode-scan') return true;
|
||
if (e.target?.isContentEditable) return true;
|
||
return false;
|
||
},
|
||
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,
|
||
});
|
||
}
|
||
this.scanError = '';
|
||
this.scanOk = product.name + ' added';
|
||
setTimeout(() => { this.scanOk = ''; }, 1500);
|
||
},
|
||
async submitScan() {
|
||
const code = (this.scanCode || '').trim();
|
||
this.scanError = '';
|
||
this.scanOk = '';
|
||
if (!code) return;
|
||
|
||
const local = this.products.find(p => p.sku && String(p.sku).toLowerCase() === code.toLowerCase());
|
||
if (local) {
|
||
this.addProduct(local);
|
||
this.scanCode = '';
|
||
return;
|
||
}
|
||
|
||
if (!this.lookupUrl) {
|
||
this.scanError = 'No product found for that barcode.';
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const res = await fetch(this.lookupUrl + '?' + new URLSearchParams({ code }), {
|
||
headers: {
|
||
Accept: 'application/json',
|
||
'X-Requested-With': 'XMLHttpRequest',
|
||
'X-CSRF-TOKEN': this.csrf,
|
||
},
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) {
|
||
this.scanError = data.message || 'No product found for that barcode.';
|
||
return;
|
||
}
|
||
this.addProduct(data.product);
|
||
this.scanCode = '';
|
||
} catch (err) {
|
||
this.scanError = 'Barcode lookup failed. Try again.';
|
||
}
|
||
},
|
||
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>
|