Files
ladill-pos/resources/views/pos/register.blade.php
T
isaaccladandCursor 9a2d196d84
Deploy Ladill POS / deploy (push) Successful in 1m42s
Add barcode scanner, thermal receipt printing, and printer settings.
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>
2026-07-08 05:40:14 +00:00

267 lines
15 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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">&times;</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>