Add Frontdesk-style Devices registry for POS hardware.
Deploy Ladill POS / deploy (push) Successful in 42s

Register tills, customer displays, kitchen screens, printers, scanners,
and tablets per branch. Customer displays share the branch display token
and mark online when the public screen polls; stale devices go offline
on a schedule.
This commit is contained in:
isaacclad
2026-07-15 21:49:21 +00:00
parent 46c60259c9
commit c01518b0ee
15 changed files with 877 additions and 3 deletions
+3 -2
View File
@@ -2,12 +2,13 @@
In-store register at **pos.ladill.com** — product grid, cart, cash or Ladill Pay checkout. In-store register at **pos.ladill.com** — product grid, cart, cash or Ladill Pay checkout.
## Features (v1) ## Features
- **Register** — catalog + quick-amount sales - **Register** — catalog + quick-amount sales
- **Products** — local catalog CRUD - **Products** — local catalog CRUD
- **Sales** — history and receipt view - **Sales** — history and receipt view
- **Ladill Pay** — MoMo/card via `source_service: pos`, `fee_tier: sales` - **Devices** — tills, customer displays, kitchen screens, printers (tokenized where needed)
- **Payment gateway** — merchant Paystack / Flutterwave / Hubtel (Pro+)
## Local dev ## Local dev
@@ -0,0 +1,22 @@
<?php
namespace App\Console\Commands;
use App\Services\Pos\DeviceService;
use Illuminate\Console\Command;
class MarkDevicesOfflineCommand extends Command
{
protected $signature = 'pos:devices-offline {--minutes=10 : Consider devices stale after this many minutes}';
protected $description = 'Mark POS devices offline when they stop heartbeating.';
public function handle(DeviceService $devices): int
{
$minutes = max(1, (int) $this->option('minutes'));
$count = $devices->markStaleDevicesOffline($minutes);
$this->info("Marked {$count} device(s) offline.");
return self::SUCCESS;
}
}
@@ -0,0 +1,172 @@
<?php
namespace App\Http\Controllers\Pos;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Pos\Concerns\ScopesToAccount;
use App\Models\PosDevice;
use App\Models\PosLocation;
use App\Services\Pos\DeviceService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
class DeviceController extends Controller
{
use ScopesToAccount;
public function __construct(private DeviceService $devices) {}
public function index(Request $request): View
{
$owner = $this->ownerRef($request);
$query = PosDevice::owned($owner)->with('location')->orderBy('name');
$this->scopeToLocation($request, $query);
$devices = $query->paginate(25);
$statsQuery = PosDevice::owned($owner);
$this->scopeToLocation($request, $statsQuery);
$heroStats = [
'total' => (clone $statsQuery)->count(),
'online' => (clone $statsQuery)
->where('status', PosDevice::STATUS_ONLINE)
->where('last_online_at', '>=', now()->subMinutes(10))
->count(),
'displays' => (clone $statsQuery)
->whereIn('type', ['customer_display', 'kitchen_display'])
->count(),
];
return view('pos.devices.index', [
'devices' => $devices,
'deviceTypes' => config('pos.device_types', []),
'heroStats' => $heroStats,
]);
}
public function create(Request $request): View
{
return view('pos.devices.create', [
'deviceTypes' => config('pos.device_types', []),
'hints' => config('pos.device_registration_hints', []),
'locations' => $this->locations($request),
'defaultLocationId' => $this->location($request)->id,
]);
}
public function store(Request $request): RedirectResponse
{
$types = array_keys(config('pos.device_types', []));
$owner = $this->ownerRef($request);
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'type' => ['required', 'string', Rule::in($types)],
'location_id' => ['nullable', 'integer'],
]);
$locationId = $validated['location_id'] ?? null;
if ($locationId) {
$this->assertLocationOwned($request, (int) $locationId);
}
if (in_array($validated['type'], ['customer_display', 'kitchen_display'], true) && ! $locationId) {
return back()->withInput()->with('error', 'Choose a branch for this display device.');
}
$this->devices->register([
'owner_ref' => $owner,
'name' => $validated['name'],
'type' => $validated['type'],
'location_id' => $locationId,
]);
return redirect()->route('pos.devices.index')->with('success', 'Device registered.');
}
public function edit(Request $request, PosDevice $device): View
{
$this->authorizeOwner($request, $device);
return view('pos.devices.edit', [
'device' => $device,
'deviceTypes' => config('pos.device_types', []),
'hints' => config('pos.device_registration_hints', []),
'locations' => $this->locations($request),
'openUrl' => $this->devices->openUrl($device),
]);
}
public function update(Request $request, PosDevice $device): RedirectResponse
{
$this->authorizeOwner($request, $device);
$types = array_keys(config('pos.device_types', []));
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'type' => ['required', 'string', Rule::in($types)],
'location_id' => ['nullable', 'integer'],
'status' => ['nullable', 'string', Rule::in([
PosDevice::STATUS_ONLINE,
PosDevice::STATUS_OFFLINE,
PosDevice::STATUS_MAINTENANCE,
])],
]);
if (! empty($validated['location_id'])) {
$this->assertLocationOwned($request, (int) $validated['location_id']);
}
$device->update([
'name' => $validated['name'],
'type' => $validated['type'],
'location_id' => $validated['location_id'] ?: null,
'status' => $validated['status'] ?? $device->status,
]);
return redirect()->route('pos.devices.index')->with('success', 'Device updated.');
}
public function destroy(Request $request, PosDevice $device): RedirectResponse
{
$this->authorizeOwner($request, $device);
$device->delete();
return redirect()->route('pos.devices.index')->with('success', 'Device removed.');
}
public function regenerateToken(Request $request, PosDevice $device): RedirectResponse
{
$this->authorizeOwner($request, $device);
$this->devices->regenerateToken($device);
return back()->with('success', 'Device token regenerated. Re-open the device URL if needed.');
}
/** @return \Illuminate\Support\Collection<int, PosLocation> */
protected function locations(Request $request)
{
$query = PosLocation::owned($this->ownerRef($request))->orderBy('name');
$scope = $this->locationScope($request);
if ($scope !== null) {
$query->whereKey($scope);
}
return $query->get();
}
protected function assertLocationOwned(Request $request, int $locationId): void
{
$exists = PosLocation::owned($this->ownerRef($request))->whereKey($locationId)->exists();
abort_unless($exists, 404);
$scope = $this->locationScope($request);
if ($scope !== null) {
abort_unless($locationId === $scope, 404);
}
}
}
@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
use App\Models\PosCustomerDisplay; use App\Models\PosCustomerDisplay;
use App\Models\PosSale; use App\Models\PosSale;
use App\Services\Pos\CustomerDisplayService; use App\Services\Pos\CustomerDisplayService;
use App\Services\Pos\DeviceService;
use App\Services\Pos\ReceiptEmailService; use App\Services\Pos\ReceiptEmailService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -18,11 +19,13 @@ class CustomerDisplayController extends Controller
public function __construct( public function __construct(
private CustomerDisplayService $displays, private CustomerDisplayService $displays,
private ReceiptEmailService $receiptEmail, private ReceiptEmailService $receiptEmail,
private DeviceService $devices,
) {} ) {}
public function show(string $token): View public function show(string $token): View
{ {
$display = $this->find($token); $display = $this->find($token);
$this->devices->touchByToken($token);
return view('pos.customer-display', [ return view('pos.customer-display', [
'display' => $display, 'display' => $display,
@@ -36,6 +39,7 @@ class CustomerDisplayController extends Controller
public function state(string $token): JsonResponse public function state(string $token): JsonResponse
{ {
$display = $this->find($token); $display = $this->find($token);
$this->devices->touchByToken($token);
return response()->json($this->displays->publicState($display)); return response()->json($this->displays->publicState($display));
} }
+62
View File
@@ -0,0 +1,62 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PosDevice extends Model
{
public const STATUS_ONLINE = 'online';
public const STATUS_OFFLINE = 'offline';
public const STATUS_MAINTENANCE = 'maintenance';
protected $fillable = [
'owner_ref',
'location_id',
'name',
'type',
'status',
'device_token',
'config',
'last_online_at',
];
protected function casts(): array
{
return [
'config' => 'array',
'last_online_at' => 'datetime',
];
}
public function scopeOwned(Builder $query, string $ownerRef): Builder
{
return $query->where('owner_ref', $ownerRef);
}
public function location(): BelongsTo
{
return $this->belongsTo(PosLocation::class, 'location_id');
}
public function isOnline(int $staleMinutes = 10): bool
{
return $this->status === self::STATUS_ONLINE
&& $this->last_online_at
&& $this->last_online_at->gte(now()->subMinutes($staleMinutes));
}
public function typeLabel(): string
{
return (string) (config('pos.device_types.'.$this->type) ?? $this->type);
}
public function usesToken(): bool
{
return in_array($this->type, config('pos.device_token_types', []), true);
}
}
+148
View File
@@ -0,0 +1,148 @@
<?php
namespace App\Services\Pos;
use App\Models\PosCustomerDisplay;
use App\Models\PosDevice;
use App\Models\PosLocation;
use Illuminate\Support\Str;
class DeviceService
{
public function __construct(private CustomerDisplayService $displays) {}
public function generateToken(): string
{
return Str::random(48);
}
public function findByToken(string $token): ?PosDevice
{
return PosDevice::query()->where('device_token', $token)->first();
}
public function recordHeartbeat(PosDevice $device): PosDevice
{
$device->update([
'status' => PosDevice::STATUS_ONLINE,
'last_online_at' => now(),
]);
return $device->fresh();
}
/** Mark token-bearing devices online when their public endpoints are hit. */
public function touchByToken(?string $token): void
{
if (! $token) {
return;
}
$device = $this->findByToken($token);
if ($device) {
$this->recordHeartbeat($device);
}
}
public function markStaleDevicesOffline(int $minutes = 10): int
{
return PosDevice::query()
->where('status', PosDevice::STATUS_ONLINE)
->where(function ($q) use ($minutes) {
$q->whereNull('last_online_at')
->orWhere('last_online_at', '<', now()->subMinutes($minutes));
})
->update(['status' => PosDevice::STATUS_OFFLINE]);
}
/** @return array<string, mixed> */
public function defaultConfigForType(string $type): array
{
return match ($type) {
'customer_display' => ['role' => 'customer_facing'],
'kitchen_display' => ['role' => 'kitchen'],
'receipt_printer' => ['paper_mm' => 80, 'driver' => 'browser'],
'register' => ['role' => 'till'],
default => [],
};
}
/**
* Create a device; customer_display types share the branch display token.
*
* @param array{name: string, type: string, location_id?: ?int, owner_ref: string} $data
*/
public function register(array $data): PosDevice
{
$type = $data['type'];
$locationId = isset($data['location_id']) ? (int) $data['location_id'] : null;
$token = null;
if (in_array($type, config('pos.device_token_types', []), true)) {
$token = $this->tokenForNewDevice($type, $locationId, $data['owner_ref']);
}
return PosDevice::create([
'owner_ref' => $data['owner_ref'],
'location_id' => $locationId ?: null,
'name' => $data['name'],
'type' => $type,
'status' => PosDevice::STATUS_OFFLINE,
'device_token' => $token,
'config' => $this->defaultConfigForType($type),
]);
}
public function regenerateToken(PosDevice $device): PosDevice
{
if (! $device->usesToken()) {
return $device;
}
$token = $this->generateToken();
if ($device->type === 'customer_display' && $device->location_id) {
$location = PosLocation::query()->find($device->location_id);
if ($location) {
$display = $this->displays->ensureForLocation($location);
$display->update(['token' => $token]);
}
}
$device->update(['device_token' => $token]);
return $device->fresh();
}
public function openUrl(PosDevice $device): ?string
{
if (! $device->device_token) {
return null;
}
return match ($device->type) {
'customer_display' => route('pos.customer-display.show', ['token' => $device->device_token]),
'kitchen_display' => route('pos.kitchen'),
default => null,
};
}
private function tokenForNewDevice(string $type, ?int $locationId, string $ownerRef): string
{
if ($type === 'customer_display' && $locationId) {
$location = PosLocation::query()
->where('owner_ref', $ownerRef)
->whereKey($locationId)
->first();
if ($location) {
$display = $this->displays->ensureForLocation($location);
// Keep a single active display token per branch; reuse it for the device.
return $display->token;
}
}
return $this->generateToken();
}
}
+30
View File
@@ -49,4 +49,34 @@ return [
'description' => 'Your own payment gateway, unlimited catalog, multi-branch & team — from GHS 790/mo.', 'description' => 'Your own payment gateway, unlimited catalog, multi-branch & team — from GHS 790/mo.',
'route' => 'pos.pro.index', 'route' => 'pos.pro.index',
], ],
/*
|--------------------------------------------------------------------------
| Hardware / screen devices (register, customer display, kitchen, )
|--------------------------------------------------------------------------
*/
'device_types' => [
'register' => 'Register / till computer',
'customer_display' => 'Customer display',
'kitchen_display' => 'Kitchen display',
'receipt_printer' => 'Receipt printer',
'barcode_scanner' => 'Barcode scanner',
'tablet' => 'Tablet',
],
/** Types that get a server-generated token for unattended open URLs / agents. */
'device_token_types' => [
'customer_display',
'kitchen_display',
'receipt_printer',
],
'device_registration_hints' => [
'register' => 'Tracked for your team. Staff sign in on this machine — no device token is issued.',
'customer_display' => 'Creates (or reuses) the branch customer-display token. Open the URL on a second screen facing the customer.',
'kitchen_display' => 'Token for kitchen screen provisioning. Kitchen currently opens the signed-in Kitchen page for this account.',
'receipt_printer' => 'Token for a future print agent. Paper size still comes from branch receipt settings.',
'barcode_scanner' => 'Tracked for inventory. USB scanners work as keyboard wedges on the register today.',
'tablet' => 'Tracked for inventory. Use Customer display or Kitchen display if the tablet should run unattended.',
],
]; ];
@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('pos_devices', function (Blueprint $table) {
$table->id();
$table->string('owner_ref', 64)->index();
$table->foreignId('location_id')->nullable()->constrained('pos_locations')->nullOnDelete();
$table->string('name');
$table->string('type', 40)->index();
$table->string('status', 20)->default('offline')->index();
$table->string('device_token', 64)->nullable()->unique();
$table->json('config')->nullable();
$table->timestamp('last_online_at')->nullable();
$table->timestamps();
$table->index(['owner_ref', 'type']);
});
}
public function down(): void
{
Schema::dropIfExists('pos_devices');
}
};
+8 -1
View File
@@ -38,8 +38,15 @@
</nav> </nav>
<div class="shrink-0 border-t border-slate-100 px-3 py-3"> <div class="shrink-0 border-t border-slate-100 px-3 py-3">
<a href="{{ route('pos.devices.index') }}"
class="group flex items-center gap-3 rounded-lg px-3 py-2 text-[13px] transition {{ request()->routeIs('pos.devices*') ? 'bg-indigo-50 text-indigo-700 font-semibold' : 'text-slate-600 hover:bg-slate-50 hover:text-slate-900' }}">
<svg class="h-[18px] w-[18px] shrink-0 {{ request()->routeIs('pos.devices*') ? 'text-indigo-600' : 'text-slate-400' }}" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 17.25v1.007a3 3 0 0 1-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0 1 15 18.257V17.25m6-12V15a2.25 2.25 0 0 1-2.25 2.25H5.25A2.25 2.25 0 0 1 3 15V5.25A2.25 2.25 0 0 1 5.25 3h13.5A2.25 2.25 0 0 1 21 5.25Z" />
</svg>
<span class="flex-1 truncate">Devices</span>
</a>
<a href="{{ route('pos.settings') }}" <a href="{{ route('pos.settings') }}"
class="group flex items-center gap-3 rounded-lg px-3 py-2 text-[13px] transition {{ request()->routeIs('pos.settings*') ? 'bg-indigo-50 text-indigo-700 font-semibold' : 'text-slate-600 hover:bg-slate-50 hover:text-slate-900' }}"> class="group mt-0.5 flex items-center gap-3 rounded-lg px-3 py-2 text-[13px] transition {{ request()->routeIs('pos.settings*') ? 'bg-indigo-50 text-indigo-700 font-semibold' : 'text-slate-600 hover:bg-slate-50 hover:text-slate-900' }}">
<svg class="h-[18px] w-[18px] shrink-0 {{ request()->routeIs('pos.settings*') ? 'text-indigo-600' : 'text-slate-400' }}" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> <svg class="h-[18px] w-[18px] shrink-0 {{ request()->routeIs('pos.settings*') ? 'text-indigo-600' : 'text-slate-400' }}" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" /> <path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg> </svg>
@@ -0,0 +1,51 @@
<x-app-layout title="Add device">
<div class="mx-auto max-w-lg space-y-6">
<div>
<a href="{{ route('pos.devices.index') }}" class="text-sm font-medium text-slate-500 hover:text-slate-800"> Devices</a>
<h1 class="mt-2 text-2xl font-semibold text-slate-900">Register device</h1>
<p class="mt-1 text-sm text-slate-500">Name the hardware and link it to a branch when needed.</p>
</div>
@if (session('error'))
<div class="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-800">{{ session('error') }}</div>
@endif
<form method="POST" action="{{ route('pos.devices.store') }}"
class="space-y-4 rounded-2xl border border-slate-200 bg-white p-6"
x-data="{ type: @js(old('type', 'register')), hints: @js($hints) }">
@csrf
<div>
<label class="block text-sm font-medium text-slate-700">Name</label>
<input type="text" name="name" value="{{ old('name') }}" required placeholder="Front till · Customer screen"
class="mt-1.5 w-full rounded-xl border-slate-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
@error('name') <p class="mt-1 text-xs text-rose-600">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Type</label>
<select name="type" required x-model="type"
class="mt-1.5 w-full rounded-xl border-slate-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
@foreach ($deviceTypes as $key => $label)
<option value="{{ $key }}">{{ $label }}</option>
@endforeach
</select>
<p class="mt-2 text-xs text-slate-500" x-text="hints[type] || ''"></p>
@error('type') <p class="mt-1 text-xs text-rose-600">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Branch</label>
<select name="location_id"
class="mt-1.5 w-full rounded-xl border-slate-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
<option value=""> Optional / all branches </option>
@foreach ($locations as $location)
<option value="{{ $location->id }}" @selected((string) old('location_id', $defaultLocationId) === (string) $location->id)>
{{ $location->name }}
</option>
@endforeach
</select>
<p class="mt-1 text-xs text-slate-400">Required for customer and kitchen displays.</p>
@error('location_id') <p class="mt-1 text-xs text-rose-600">{{ $message }}</p> @enderror
</div>
<button type="submit" class="btn-primary w-full">Register device</button>
</form>
</div>
</x-app-layout>
@@ -0,0 +1,96 @@
<x-app-layout title="Edit device">
<div class="mx-auto max-w-lg space-y-6">
<div>
<a href="{{ route('pos.devices.index') }}" class="text-sm font-medium text-slate-500 hover:text-slate-800"> Devices</a>
<h1 class="mt-2 text-2xl font-semibold text-slate-900">Edit device</h1>
<p class="mt-1 text-sm text-slate-500">{{ $deviceTypes[$device->type] ?? $device->type }} · {{ $device->location?->name ?? 'No branch' }}</p>
</div>
@if (session('success'))
<div class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">{{ session('success') }}</div>
@endif
<form method="POST" action="{{ route('pos.devices.update', $device) }}"
class="space-y-4 rounded-2xl border border-slate-200 bg-white p-6"
x-data="{ type: @js(old('type', $device->type)), hints: @js($hints) }">
@csrf
@method('PUT')
<div>
<label class="block text-sm font-medium text-slate-700">Name</label>
<input type="text" name="name" value="{{ old('name', $device->name) }}" required
class="mt-1.5 w-full rounded-xl border-slate-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Type</label>
<select name="type" required x-model="type"
class="mt-1.5 w-full rounded-xl border-slate-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
@foreach ($deviceTypes as $key => $label)
<option value="{{ $key }}">{{ $label }}</option>
@endforeach
</select>
<p class="mt-2 text-xs text-slate-500" x-text="hints[type] || ''"></p>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Status</label>
<select name="status"
class="mt-1.5 w-full rounded-xl border-slate-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
@foreach (['online', 'offline', 'maintenance'] as $status)
<option value="{{ $status }}" @selected(old('status', $device->status) === $status)>{{ ucfirst($status) }}</option>
@endforeach
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Branch</label>
<select name="location_id"
class="mt-1.5 w-full rounded-xl border-slate-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
<option value=""> Optional / all branches </option>
@foreach ($locations as $location)
<option value="{{ $location->id }}" @selected((string) old('location_id', $device->location_id) === (string) $location->id)>
{{ $location->name }}
</option>
@endforeach
</select>
</div>
@if ($device->device_token)
<div class="rounded-xl border border-slate-100 bg-slate-50 p-4">
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Device token</p>
<code class="mt-2 block break-all text-xs text-slate-700">{{ $device->device_token }}</code>
@if ($openUrl)
<a href="{{ $openUrl }}" target="_blank" rel="noopener"
class="mt-3 inline-flex text-sm font-semibold text-indigo-600 hover:text-indigo-800">
Open device URL
</a>
@endif
<p class="mt-2 text-xs text-slate-500">Last online: {{ $device->last_online_at?->diffForHumans() ?? 'Never' }}</p>
</div>
@endif
<div class="flex gap-3 pt-1">
<a href="{{ route('pos.devices.index') }}" class="inline-flex flex-1 items-center justify-center rounded-xl border border-slate-200 px-4 py-2.5 text-sm font-medium text-slate-700 hover:bg-slate-50">Back</a>
<button type="submit" class="btn-primary flex-1">Save</button>
</div>
</form>
<div class="space-y-3 rounded-2xl border border-rose-100 bg-white p-5">
<p class="text-sm font-semibold text-slate-900">Device actions</p>
@if ($device->device_token)
<form method="POST" action="{{ route('pos.devices.regenerate-token', $device) }}"
onsubmit="return confirm('Regenerate token? Existing display sessions will stop until reopened.');">
@csrf
<button type="submit" class="w-full rounded-xl border border-amber-200 bg-amber-50 px-4 py-2.5 text-sm font-medium text-amber-900 hover:bg-amber-100">
Regenerate token
</button>
</form>
@endif
<form method="POST" action="{{ route('pos.devices.destroy', $device) }}"
onsubmit="return confirm('Remove this device?');">
@csrf
@method('DELETE')
<button type="submit" class="w-full rounded-xl border border-rose-200 bg-rose-50 px-4 py-2.5 text-sm font-medium text-rose-800 hover:bg-rose-100">
Remove device
</button>
</form>
</div>
</div>
</x-app-layout>
@@ -0,0 +1,82 @@
<x-app-layout title="Devices">
<div class="mx-auto max-w-5xl space-y-6">
<div class="flex flex-wrap items-end justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-wider text-indigo-600">Hardware</p>
<h1 class="mt-1 text-2xl font-semibold text-slate-900">Devices</h1>
<p class="mt-1 text-sm text-slate-500">Register tills, customer screens, kitchen displays, and printers for each branch.</p>
</div>
<a href="{{ route('pos.devices.create') }}" class="btn-primary">Add device</a>
</div>
<div class="grid gap-3 sm:grid-cols-3">
<div class="rounded-2xl border border-slate-200 bg-white px-5 py-4">
<p class="text-2xl font-semibold tabular-nums text-slate-900">{{ number_format($heroStats['total']) }}</p>
<p class="mt-0.5 text-xs font-medium uppercase tracking-wide text-slate-500">Devices</p>
</div>
<div class="rounded-2xl border border-slate-200 bg-white px-5 py-4">
<p class="text-2xl font-semibold tabular-nums text-emerald-600">{{ number_format($heroStats['online']) }}</p>
<p class="mt-0.5 text-xs font-medium uppercase tracking-wide text-slate-500">Online</p>
</div>
<div class="rounded-2xl border border-slate-200 bg-white px-5 py-4">
<p class="text-2xl font-semibold tabular-nums text-slate-900">{{ number_format($heroStats['displays']) }}</p>
<p class="mt-0.5 text-xs font-medium uppercase tracking-wide text-slate-500">Displays</p>
</div>
</div>
@if (session('success'))
<div class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">{{ session('success') }}</div>
@endif
@if (session('error'))
<div class="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-800">{{ session('error') }}</div>
@endif
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white">
<div class="border-b border-slate-100 px-6 py-4">
<h2 class="text-sm font-semibold text-slate-900">All devices</h2>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-slate-100 text-sm">
<thead class="bg-slate-50 text-left text-xs uppercase text-slate-500">
<tr>
<th class="px-4 py-3">Name</th>
<th class="px-4 py-3">Type</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Branch</th>
<th class="px-4 py-3">Last online</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50">
@forelse ($devices as $device)
<tr>
<td class="px-4 py-3 font-medium text-slate-900">{{ $device->name }}</td>
<td class="px-4 py-3 text-slate-600">{{ $deviceTypes[$device->type] ?? $device->type }}</td>
<td class="px-4 py-3">
<span class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium {{ $device->isOnline() ? 'bg-emerald-100 text-emerald-800' : 'bg-slate-100 text-slate-600' }}">
{{ $device->isOnline() ? 'Online' : ucfirst($device->status) }}
</span>
</td>
<td class="px-4 py-3 text-slate-600">{{ $device->location?->name ?? '—' }}</td>
<td class="px-4 py-3 text-slate-500">{{ $device->last_online_at?->diffForHumans() ?? 'Never' }}</td>
<td class="px-4 py-3 text-right">
<a href="{{ route('pos.devices.edit', $device) }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-800">Manage</a>
</td>
</tr>
@empty
<tr>
<td colspan="6" class="px-4 py-10 text-center text-slate-500">
No devices yet.
<a href="{{ route('pos.devices.create') }}" class="font-medium text-indigo-600 hover:text-indigo-800">Add your first device</a>
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@if ($devices->hasPages())
<div class="border-t border-slate-100 px-5 py-3">{{ $devices->links() }}</div>
@endif
</div>
</div>
</x-app-layout>
+1
View File
@@ -9,3 +9,4 @@ Artisan::command('inspire', function () {
})->purpose('Display an inspiring quote'); })->purpose('Display an inspiring quote');
Schedule::command('pos:pro-renew')->dailyAt('02:50')->withoutOverlapping(); Schedule::command('pos:pro-renew')->dailyAt('02:50')->withoutOverlapping();
Schedule::command('pos:devices-offline')->everyFiveMinutes()->withoutOverlapping();
+10
View File
@@ -11,6 +11,7 @@ use App\Http\Controllers\Pos\MenuController;
use App\Http\Controllers\Pos\ProductController; use App\Http\Controllers\Pos\ProductController;
use App\Http\Controllers\Pos\ProController; use App\Http\Controllers\Pos\ProController;
use App\Http\Controllers\Pos\CustomerDisplayController; use App\Http\Controllers\Pos\CustomerDisplayController;
use App\Http\Controllers\Pos\DeviceController;
use App\Http\Controllers\Pos\RegisterController; use App\Http\Controllers\Pos\RegisterController;
use App\Http\Controllers\Pos\SaleController; use App\Http\Controllers\Pos\SaleController;
use App\Http\Controllers\Pos\SettingsController; use App\Http\Controllers\Pos\SettingsController;
@@ -133,6 +134,15 @@ Route::middleware(['auth', 'platform.session'])->group(function () {
Route::get('/settings', [SettingsController::class, 'index'])->name('pos.settings'); Route::get('/settings', [SettingsController::class, 'index'])->name('pos.settings');
Route::put('/settings', [SettingsController::class, 'update'])->name('pos.settings.update'); Route::put('/settings', [SettingsController::class, 'update'])->name('pos.settings.update');
Route::get('/devices', [DeviceController::class, 'index'])->name('pos.devices.index');
Route::get('/devices/create', [DeviceController::class, 'create'])->name('pos.devices.create');
Route::post('/devices', [DeviceController::class, 'store'])->name('pos.devices.store');
Route::get('/devices/{device}/edit', [DeviceController::class, 'edit'])->name('pos.devices.edit');
Route::put('/devices/{device}', [DeviceController::class, 'update'])->name('pos.devices.update');
Route::delete('/devices/{device}', [DeviceController::class, 'destroy'])->name('pos.devices.destroy');
Route::post('/devices/{device}/regenerate-token', [DeviceController::class, 'regenerateToken'])->name('pos.devices.regenerate-token');
Route::get('/settings/branches', [LocationController::class, 'index'])->name('pos.branches.index'); Route::get('/settings/branches', [LocationController::class, 'index'])->name('pos.branches.index');
Route::get('/settings/branches/create', [LocationController::class, 'create'])->name('pos.branches.create'); Route::get('/settings/branches/create', [LocationController::class, 'create'])->name('pos.branches.create');
Route::post('/settings/branches', [LocationController::class, 'store'])->name('pos.branches.store'); Route::post('/settings/branches', [LocationController::class, 'store'])->name('pos.branches.store');
+157
View File
@@ -0,0 +1,157 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\EnsurePlatformSession;
use App\Models\PosCustomerDisplay;
use App\Models\PosDevice;
use App\Models\PosLocation;
use App\Models\User;
use App\Services\Pos\DeviceService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PosDeviceTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->withoutMiddleware(EnsurePlatformSession::class);
$this->withoutVite();
}
private function user(): User
{
return User::create([
'public_id' => 'u-'.uniqid(),
'name' => 'Owner',
'email' => uniqid().'@example.com',
]);
}
private function location(User $user): PosLocation
{
return PosLocation::create([
'owner_ref' => $user->public_id,
'name' => 'Main register',
'currency' => 'GHS',
]);
}
public function test_can_register_register_device_without_token(): void
{
$user = $this->user();
$location = $this->location($user);
$this->actingAs($user)
->post(route('pos.devices.store'), [
'name' => 'Front till',
'type' => 'register',
'location_id' => $location->id,
])
->assertRedirect(route('pos.devices.index'));
$device = PosDevice::query()->where('owner_ref', $user->public_id)->first();
$this->assertNotNull($device);
$this->assertSame('register', $device->type);
$this->assertNull($device->device_token);
}
public function test_customer_display_device_reuses_branch_display_token(): void
{
$user = $this->user();
$location = $this->location($user);
$this->actingAs($user)
->post(route('pos.devices.store'), [
'name' => 'Counter screen',
'type' => 'customer_display',
'location_id' => $location->id,
])
->assertRedirect(route('pos.devices.index'));
$device = PosDevice::query()->where('owner_ref', $user->public_id)->firstOrFail();
$display = PosCustomerDisplay::query()->where('location_id', $location->id)->firstOrFail();
$this->assertNotEmpty($device->device_token);
$this->assertSame($display->token, $device->device_token);
}
public function test_customer_display_device_requires_branch(): void
{
$user = $this->user();
$this->location($user);
$this->actingAs($user)
->from(route('pos.devices.create'))
->post(route('pos.devices.store'), [
'name' => 'Orphan screen',
'type' => 'customer_display',
'location_id' => '',
])
->assertRedirect(route('pos.devices.create'))
->assertSessionHas('error');
}
public function test_devices_index_lists_owned_devices(): void
{
$user = $this->user();
$location = $this->location($user);
PosDevice::create([
'owner_ref' => $user->public_id,
'location_id' => $location->id,
'name' => 'Kitchen pad',
'type' => 'tablet',
'status' => 'offline',
]);
$this->actingAs($user)
->get(route('pos.devices.index'))
->assertOk()
->assertSee('Kitchen pad')
->assertSee('Devices');
}
public function test_regenerate_token_updates_customer_display(): void
{
$user = $this->user();
$location = $this->location($user);
$device = app(DeviceService::class)->register([
'owner_ref' => $user->public_id,
'name' => 'Display',
'type' => 'customer_display',
'location_id' => $location->id,
]);
$old = $device->device_token;
$this->actingAs($user)
->post(route('pos.devices.regenerate-token', $device))
->assertRedirect();
$device->refresh();
$display = PosCustomerDisplay::query()->where('location_id', $location->id)->firstOrFail();
$this->assertNotSame($old, $device->device_token);
$this->assertSame($device->device_token, $display->token);
}
public function test_customer_display_poll_marks_device_online(): void
{
$user = $this->user();
$location = $this->location($user);
$device = app(DeviceService::class)->register([
'owner_ref' => $user->public_id,
'name' => 'Display',
'type' => 'customer_display',
'location_id' => $location->id,
]);
$this->getJson(route('pos.customer-display.state', ['token' => $device->device_token]))
->assertOk();
$device->refresh();
$this->assertSame('online', $device->status);
$this->assertNotNull($device->last_online_at);
}
}