From 4dcc2bf0c91290b21dc9144343f100f8fcc228c0 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 13 Mar 2026 12:23:33 +0000
Subject: [PATCH 01/14] Initial plan
From e27e8b54f2e4f009aa7a5d9050c8cdbe70e29f76 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 13 Mar 2026 12:40:07 +0000
Subject: [PATCH 02/14] Add Table and Patron models, migrations, controllers,
policies, and routes
- Create tables and patrons database migrations
- Add payment_status, patron_id, table_id to orders table
- Add allow_unpaid_table_orders to events table
- Create Table and Patron Eloquent models with relationships
- Update Order model with new status constants and relationships
- Update Event model with new fillable fields and relationships
- Create TableResourceDefinition and PatronResourceDefinition
- Update OrderResourceDefinition with payment_status, patron_id, table_id
- Update EventResourceDefinition in both APIs with allow_unpaid_table_orders
- Create shared TableController and PatronController with thin API stubs
- Create TablePolicy and PatronPolicy
- Register policies in AuthServiceProvider
- Register routes in both ManagementApi and DeviceApi
- Create PatronAssignmentService for patron lookup/creation algorithm
Co-authored-by: daedeloth <1168599+daedeloth@users.noreply.github.com>
---
.../V1/Controllers/PatronController.php | 14 ++
.../V1/Controllers/TableController.php | 14 ++
.../EventResourceDefinition.php | 2 +-
app/Http/DeviceApi/V1/routes.php | 3 +
.../V1/Controllers/PatronController.php | 14 ++
.../V1/Controllers/TableController.php | 14 ++
.../EventResourceDefinition.php | 2 +-
app/Http/ManagementApi/V1/routes.php | 2 +
.../V1/Controllers/PatronController.php | 94 +++++++++++++
.../Shared/V1/Controllers/TableController.php | 124 ++++++++++++++++
.../OrderResourceDefinition.php | 18 +++
.../PatronResourceDefinition.php | 59 ++++++++
.../TableResourceDefinition.php | 34 +++++
app/Models/Event.php | 17 +++
app/Models/Order.php | 22 +++
app/Models/Patron.php | 67 +++++++++
app/Models/Table.php | 86 ++++++++++++
app/Policies/PatronPolicy.php | 64 +++++++++
app/Policies/TablePolicy.php | 64 +++++++++
app/Providers/AuthServiceProvider.php | 2 +
app/Services/PatronAssignmentService.php | 132 ++++++++++++++++++
.../2026_03_13_120000_create_tables_table.php | 41 ++++++
...2026_03_13_120001_create_patrons_table.php | 40 ++++++
...002_add_table_service_fields_to_orders.php | 44 ++++++
...dd_allow_unpaid_table_orders_to_events.php | 32 +++++
25 files changed, 1003 insertions(+), 2 deletions(-)
create mode 100644 app/Http/DeviceApi/V1/Controllers/PatronController.php
create mode 100644 app/Http/DeviceApi/V1/Controllers/TableController.php
create mode 100644 app/Http/ManagementApi/V1/Controllers/PatronController.php
create mode 100644 app/Http/ManagementApi/V1/Controllers/TableController.php
create mode 100644 app/Http/Shared/V1/Controllers/PatronController.php
create mode 100644 app/Http/Shared/V1/Controllers/TableController.php
create mode 100644 app/Http/Shared/V1/ResourceDefinitions/PatronResourceDefinition.php
create mode 100644 app/Http/Shared/V1/ResourceDefinitions/TableResourceDefinition.php
create mode 100644 app/Models/Patron.php
create mode 100644 app/Models/Table.php
create mode 100644 app/Policies/PatronPolicy.php
create mode 100644 app/Policies/TablePolicy.php
create mode 100644 app/Services/PatronAssignmentService.php
create mode 100644 database/migrations/2026_03_13_120000_create_tables_table.php
create mode 100644 database/migrations/2026_03_13_120001_create_patrons_table.php
create mode 100644 database/migrations/2026_03_13_120002_add_table_service_fields_to_orders.php
create mode 100644 database/migrations/2026_03_13_120003_add_allow_unpaid_table_orders_to_events.php
diff --git a/app/Http/DeviceApi/V1/Controllers/PatronController.php b/app/Http/DeviceApi/V1/Controllers/PatronController.php
new file mode 100644
index 0000000..704c648
--- /dev/null
+++ b/app/Http/DeviceApi/V1/Controllers/PatronController.php
@@ -0,0 +1,14 @@
+string()
->visible(true);
- $this->field([ 'payment_cash', 'payment_vouchers', 'payment_cards', 'allow_unpaid_online_orders', 'split_orders_by_categories' ])
+ $this->field([ 'payment_cash', 'payment_vouchers', 'payment_cards', 'allow_unpaid_online_orders', 'allow_unpaid_table_orders', 'split_orders_by_categories' ])
->bool()
->visible(true);
diff --git a/app/Http/DeviceApi/V1/routes.php b/app/Http/DeviceApi/V1/routes.php
index 16d738b..13a29d4 100644
--- a/app/Http/DeviceApi/V1/routes.php
+++ b/app/Http/DeviceApi/V1/routes.php
@@ -74,6 +74,9 @@ function(RouteCollection $routes)
\App\Http\DeviceApi\V1\Controllers\OrderController::setRoutes($routes);
\App\Http\DeviceApi\V1\Controllers\CategoryController::setRoutes($routes);
+ \App\Http\DeviceApi\V1\Controllers\TableController::setRoutes($routes);
+ \App\Http\DeviceApi\V1\Controllers\PatronController::setRoutes($routes);
+
\App\Http\DeviceApi\V1\Controllers\OrderSummaryController::setRoutes($routes);
\App\Http\DeviceApi\V1\Controllers\AttendeeController::setRoutes($routes);
diff --git a/app/Http/ManagementApi/V1/Controllers/PatronController.php b/app/Http/ManagementApi/V1/Controllers/PatronController.php
new file mode 100644
index 0000000..f279b79
--- /dev/null
+++ b/app/Http/ManagementApi/V1/Controllers/PatronController.php
@@ -0,0 +1,14 @@
+string()
->visible(true);
- $this->field([ 'payment_cash', 'payment_vouchers', 'payment_cards', 'allow_unpaid_online_orders', 'split_orders_by_categories' ])
+ $this->field([ 'payment_cash', 'payment_vouchers', 'payment_cards', 'allow_unpaid_online_orders', 'allow_unpaid_table_orders', 'split_orders_by_categories' ])
->bool()
->visible(true)
->writeable();
diff --git a/app/Http/ManagementApi/V1/routes.php b/app/Http/ManagementApi/V1/routes.php
index 3026906..46479bb 100644
--- a/app/Http/ManagementApi/V1/routes.php
+++ b/app/Http/ManagementApi/V1/routes.php
@@ -90,6 +90,8 @@ function(RouteCollection $routes)
\App\Http\ManagementApi\V1\Controllers\AttendeeController::setRoutes($routes);
\App\Http\ManagementApi\V1\Controllers\DeviceController::setRoutes($routes);
\App\Http\ManagementApi\V1\Controllers\CategoryController::setRoutes($routes);
+ \App\Http\ManagementApi\V1\Controllers\TableController::setRoutes($routes);
+ \App\Http\ManagementApi\V1\Controllers\PatronController::setRoutes($routes);
\App\Http\ManagementApi\V1\Controllers\PaymentGatewayController::setRoutes($routes);
\App\Http\ManagementApi\V1\Controllers\CardController::setSharedRoutes($routes);
\App\Http\ManagementApi\V1\Controllers\TransactionController::setRoutes($routes);
diff --git a/app/Http/Shared/V1/Controllers/PatronController.php b/app/Http/Shared/V1/Controllers/PatronController.php
new file mode 100644
index 0000000..f966ef0
--- /dev/null
+++ b/app/Http/Shared/V1/Controllers/PatronController.php
@@ -0,0 +1,94 @@
+childResource(
+ static::RESOURCE_DEFINITION,
+ 'events/{parentId}/patrons',
+ 'patrons',
+ 'PatronController',
+ [
+ 'id' => self::RESOURCE_ID,
+ 'only' => $only,
+ ]
+ );
+
+ $childResource->tag('patrons');
+
+ return $childResource;
+ }
+
+ /**
+ * @param Request $request
+ * @return Relation
+ */
+ public function getRelationship(Request $request): Relation
+ {
+ /** @var Event $event */
+ $event = $this->getParent($request);
+ return $event->patrons();
+ }
+
+ /**
+ * @param Request $request
+ * @return Model
+ */
+ public function getParent(Request $request): Model
+ {
+ $eventId = $request->route('parentId');
+ return Event::findOrFail($eventId);
+ }
+
+ /**
+ * @return string
+ */
+ public function getRelationshipKey(): string
+ {
+ return self::PARENT_RESOURCE_ID;
+ }
+
+ /**
+ * Called before saveEntity
+ * @param Request $request
+ * @param \Illuminate\Database\Eloquent\Model $entity
+ * @param $isNew
+ * @return Model
+ */
+ protected function beforeSaveEntity(Request $request, \Illuminate\Database\Eloquent\Model $entity, $isNew)
+ {
+ $this->traitBeforeSaveEntity($request, $entity, $isNew);
+ return $entity;
+ }
+}
diff --git a/app/Http/Shared/V1/Controllers/TableController.php b/app/Http/Shared/V1/Controllers/TableController.php
new file mode 100644
index 0000000..581a5c4
--- /dev/null
+++ b/app/Http/Shared/V1/Controllers/TableController.php
@@ -0,0 +1,124 @@
+childResource(
+ static::RESOURCE_DEFINITION,
+ 'events/{parentId}/tables',
+ 'tables',
+ 'TableController',
+ [
+ 'id' => self::RESOURCE_ID,
+ 'only' => $only,
+ ]
+ );
+
+ $childResource->tag('tables');
+
+ // Bulk generate endpoint
+ $childResource->post('events/{parentId}/tables/generate', 'TableController@bulkGenerate')
+ ->summary('Bulk generate tables')
+ ->parameters()->path('parentId')->string()->required()
+ ->returns()->statusCode(200)->many(static::RESOURCE_DEFINITION);
+
+ return $childResource;
+ }
+
+ /**
+ * @param Request $request
+ * @return Relation
+ */
+ public function getRelationship(Request $request): Relation
+ {
+ /** @var Event $event */
+ $event = $this->getParent($request);
+ return $event->tables();
+ }
+
+ /**
+ * @param Request $request
+ * @return Model
+ */
+ public function getParent(Request $request): Model
+ {
+ $eventId = $request->route('parentId');
+ return Event::findOrFail($eventId);
+ }
+
+ /**
+ * @return string
+ */
+ public function getRelationshipKey(): string
+ {
+ return self::PARENT_RESOURCE_ID;
+ }
+
+ /**
+ * Bulk generate tables for an event.
+ * @param Request $request
+ * @return \Symfony\Component\HttpFoundation\Response
+ */
+ public function bulkGenerate(Request $request)
+ {
+ $event = $this->getParent($request);
+ $this->authorizeCreate($request);
+
+ $count = max(1, min(100, intval($request->input('count', 1))));
+ $tables = Table::bulkGenerate($event, $count);
+
+ $readContext = $this->getContext(Action::INDEX);
+ $resources = $this->getResourceTransformer()->getResourceFactory()->createResourceCollection();
+ foreach ($tables as $table) {
+ $resources[] = $this->toResource($table, $readContext);
+ }
+
+ return $this->getResourceResponse($resources, $readContext);
+ }
+
+ /**
+ * Called before saveEntity
+ * @param Request $request
+ * @param \Illuminate\Database\Eloquent\Model $entity
+ * @param $isNew
+ * @return Model
+ */
+ protected function beforeSaveEntity(Request $request, \Illuminate\Database\Eloquent\Model $entity, $isNew)
+ {
+ $this->traitBeforeSaveEntity($request, $entity, $isNew);
+ return $entity;
+ }
+}
diff --git a/app/Http/Shared/V1/ResourceDefinitions/OrderResourceDefinition.php b/app/Http/Shared/V1/ResourceDefinitions/OrderResourceDefinition.php
index 90fc146..38056e2 100644
--- a/app/Http/Shared/V1/ResourceDefinitions/OrderResourceDefinition.php
+++ b/app/Http/Shared/V1/ResourceDefinitions/OrderResourceDefinition.php
@@ -107,6 +107,24 @@ public function __construct()
->filterable()
->writeable(false);
+ $this->field('patron_id')
+ ->number()
+ ->visible(true)
+ ->filterable()
+ ->writeable(true, true);
+
+ $this->field('table_id')
+ ->number()
+ ->visible(true)
+ ->filterable()
+ ->writeable(true, true);
+
+ $this->field('payment_status')
+ ->string()
+ ->visible(true)
+ ->filterable()
+ ->writeable(true, true);
+
$this->field('created_at')
->display('date')
->datetime(DateTimeTransformer::class)
diff --git a/app/Http/Shared/V1/ResourceDefinitions/PatronResourceDefinition.php b/app/Http/Shared/V1/ResourceDefinitions/PatronResourceDefinition.php
new file mode 100644
index 0000000..c5c10d0
--- /dev/null
+++ b/app/Http/Shared/V1/ResourceDefinitions/PatronResourceDefinition.php
@@ -0,0 +1,59 @@
+identifier('id')
+ ->int();
+
+ $this->field('name')
+ ->string()
+ ->visible(true)
+ ->writeable(true, true);
+
+ $this->field('table_id')
+ ->number()
+ ->visible(true)
+ ->writeable(true, true)
+ ->filterable();
+
+ $this->field('outstandingBalance')
+ ->display('outstanding_balance')
+ ->number()
+ ->visible(true);
+
+ $this->field('hasUnpaidOrders')
+ ->display('has_unpaid_orders')
+ ->bool()
+ ->visible(true);
+
+ $this->relationship('orders', OrderResourceDefinition::class)
+ ->many()
+ ->expandable()
+ ->visible(true);
+
+ $this->relationship('table', TableResourceDefinition::class)
+ ->one()
+ ->expandable()
+ ->visible(true);
+
+ $this->field('created_at')
+ ->display('date')
+ ->datetime(DateTimeTransformer::class)
+ ->visible(true);
+ }
+}
diff --git a/app/Http/Shared/V1/ResourceDefinitions/TableResourceDefinition.php b/app/Http/Shared/V1/ResourceDefinitions/TableResourceDefinition.php
new file mode 100644
index 0000000..a8874b3
--- /dev/null
+++ b/app/Http/Shared/V1/ResourceDefinitions/TableResourceDefinition.php
@@ -0,0 +1,34 @@
+identifier('id')
+ ->int();
+
+ $this->field('table_number')
+ ->number()
+ ->required()
+ ->visible(true)
+ ->writeable(true, true);
+
+ $this->field('name')
+ ->string()
+ ->required()
+ ->visible(true)
+ ->writeable(true, true);
+ }
+}
diff --git a/app/Models/Event.php b/app/Models/Event.php
index 4386ce0..4a92370 100644
--- a/app/Models/Event.php
+++ b/app/Models/Event.php
@@ -49,6 +49,7 @@ class Event extends Model
'payment_voucher_value',
'payment_cards',
'split_orders_by_categories',
+ 'allow_unpaid_table_orders',
];
/**
@@ -102,6 +103,22 @@ public function categories()
return $this->hasMany(Category::class);
}
+ /**
+ * @return \Illuminate\Database\Eloquent\Relations\HasMany
+ */
+ public function tables()
+ {
+ return $this->hasMany(Table::class);
+ }
+
+ /**
+ * @return \Illuminate\Database\Eloquent\Relations\HasMany
+ */
+ public function patrons()
+ {
+ return $this->hasMany(Patron::class);
+ }
+
/**
* @return string
*/
diff --git a/app/Models/Order.php b/app/Models/Order.php
index 55d68cd..0c93a18 100644
--- a/app/Models/Order.php
+++ b/app/Models/Order.php
@@ -86,6 +86,12 @@ public static function boot()
const STATUS_DECLINED = 'declined';
const STATUS_PENDING = 'pending';
const STATUS_PROCESSED = 'processed';
+ const STATUS_PREPARED = 'prepared';
+ const STATUS_DELIVERED = 'delivered';
+
+ const PAYMENT_STATUS_UNPAID = 'unpaid';
+ const PAYMENT_STATUS_PAID = 'paid';
+ const PAYMENT_STATUS_VOIDED = 'voided';
/**
* @var string
@@ -121,6 +127,22 @@ public function assignedDevice()
return $this->belongsTo(Device::class, 'assigned_device_id');
}
+ /**
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
+ */
+ public function patron()
+ {
+ return $this->belongsTo(Patron::class);
+ }
+
+ /**
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
+ */
+ public function table()
+ {
+ return $this->belongsTo(Table::class);
+ }
+
/**
* @param $token
* @return Order
diff --git a/app/Models/Patron.php b/app/Models/Patron.php
new file mode 100644
index 0000000..afb5bc7
--- /dev/null
+++ b/app/Models/Patron.php
@@ -0,0 +1,67 @@
+belongsTo(Event::class);
+ }
+
+ /**
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
+ */
+ public function table()
+ {
+ return $this->belongsTo(Table::class);
+ }
+
+ /**
+ * @return \Illuminate\Database\Eloquent\Relations\HasMany
+ */
+ public function orders()
+ {
+ return $this->hasMany(Order::class);
+ }
+
+ /**
+ * Get the total outstanding (unpaid) balance.
+ * @return float
+ */
+ public function getOutstandingBalance()
+ {
+ $total = 0;
+ foreach ($this->orders()->where('payment_status', Order::PAYMENT_STATUS_UNPAID)->get() as $order) {
+ $total += $order->getPrice();
+ }
+ return $total;
+ }
+
+ /**
+ * Check if this patron has any unpaid orders.
+ * @return bool
+ */
+ public function hasUnpaidOrders()
+ {
+ return $this->orders()->where('payment_status', Order::PAYMENT_STATUS_UNPAID)->exists();
+ }
+}
diff --git a/app/Models/Table.php b/app/Models/Table.php
new file mode 100644
index 0000000..8599e14
--- /dev/null
+++ b/app/Models/Table.php
@@ -0,0 +1,86 @@
+belongsTo(Event::class);
+ }
+
+ /**
+ * @return \Illuminate\Database\Eloquent\Relations\HasMany
+ */
+ public function patrons()
+ {
+ return $this->hasMany(Patron::class);
+ }
+
+ /**
+ * @return \Illuminate\Database\Eloquent\Relations\HasMany
+ */
+ public function orders()
+ {
+ return $this->hasMany(Order::class);
+ }
+
+ /**
+ * Get the latest patron assigned to this table.
+ * @return Patron|null
+ */
+ public function getLatestPatron()
+ {
+ return $this->patrons()->latest()->first();
+ }
+
+ /**
+ * Bulk-generate tables for an event.
+ * Queries the highest current active (non-soft-deleted) table_number and increments.
+ *
+ * @param Event $event
+ * @param int $count
+ * @return Table[]
+ */
+ public static function bulkGenerate(Event $event, int $count): array
+ {
+ $highestNumber = $event->tables()
+ ->withoutTrashed()
+ ->max('table_number') ?? 0;
+
+ $tables = [];
+ for ($i = 1; $i <= $count; $i++) {
+ $number = $highestNumber + $i;
+
+ $table = new self();
+ $table->table_number = $number;
+ $table->name = 'Table ' . $number;
+ $table->event()->associate($event);
+ $table->save();
+
+ $tables[] = $table;
+ }
+
+ return $tables;
+ }
+}
diff --git a/app/Policies/PatronPolicy.php b/app/Policies/PatronPolicy.php
new file mode 100644
index 0000000..7e1c2b4
--- /dev/null
+++ b/app/Policies/PatronPolicy.php
@@ -0,0 +1,64 @@
+isMyEvent($user, $event, true);
+ }
+
+ /**
+ * @param Authorizable|null $user
+ * @param Event $event
+ * @return bool
+ */
+ public function create(?Authorizable $user, Event $event)
+ {
+ return $this->isMyEvent($user, $event, true);
+ }
+
+ /**
+ * @param Authorizable|null $user
+ * @param Patron $patron
+ * @return bool
+ */
+ public function view(?Authorizable $user, Patron $patron)
+ {
+ return $this->isMyEvent($user, $patron->event, true);
+ }
+
+ /**
+ * @param Authorizable|null $user
+ * @param Patron $patron
+ * @return bool
+ */
+ public function edit(?Authorizable $user, Patron $patron)
+ {
+ return $this->isMyEvent($user, $patron->event, true);
+ }
+
+ /**
+ * @param Authorizable|null $user
+ * @param Patron $patron
+ * @return bool
+ */
+ public function destroy(?Authorizable $user, Patron $patron)
+ {
+ return $this->isMyEvent($user, $patron->event);
+ }
+}
diff --git a/app/Policies/TablePolicy.php b/app/Policies/TablePolicy.php
new file mode 100644
index 0000000..91675f5
--- /dev/null
+++ b/app/Policies/TablePolicy.php
@@ -0,0 +1,64 @@
+isMyEvent($user, $event, true);
+ }
+
+ /**
+ * @param Authorizable|null $user
+ * @param Event $event
+ * @return bool
+ */
+ public function create(?Authorizable $user, Event $event)
+ {
+ return $this->isMyEvent($user, $event, true);
+ }
+
+ /**
+ * @param Authorizable|null $user
+ * @param Table $table
+ * @return bool
+ */
+ public function view(?Authorizable $user, Table $table)
+ {
+ return $this->isMyEvent($user, $table->event, true);
+ }
+
+ /**
+ * @param Authorizable|null $user
+ * @param Table $table
+ * @return bool
+ */
+ public function edit(?Authorizable $user, Table $table)
+ {
+ return $this->isMyEvent($user, $table->event, true);
+ }
+
+ /**
+ * @param Authorizable|null $user
+ * @param Table $table
+ * @return bool
+ */
+ public function destroy(?Authorizable $user, Table $table)
+ {
+ return $this->isMyEvent($user, $table->event);
+ }
+}
diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php
index a4d7e23..de93ce9 100644
--- a/app/Providers/AuthServiceProvider.php
+++ b/app/Providers/AuthServiceProvider.php
@@ -46,6 +46,8 @@ class AuthServiceProvider extends ServiceProvider
\App\Models\Attendee::class => \App\Policies\AttendeePolicy::class,
\App\Models\OrganisationPaymentGateway::class => \App\Policies\OrganisationPaymentGatewayPolicy::class,
\App\Models\Device::class => \App\Policies\DevicePolicy::class,
+ \App\Models\Table::class => \App\Policies\TablePolicy::class,
+ \App\Models\Patron::class => \App\Policies\PatronPolicy::class,
];
/**
diff --git a/app/Services/PatronAssignmentService.php b/app/Services/PatronAssignmentService.php
new file mode 100644
index 0000000..747de5d
--- /dev/null
+++ b/app/Services/PatronAssignmentService.php
@@ -0,0 +1,132 @@
+resolveNamedPatron($event, trim($name));
+ }
+
+ // 2. Anonymous Orders (Table QR Scan)
+ if ($table) {
+ return $this->resolveTablePatron($event, $table);
+ }
+
+ return null;
+ }
+
+ /**
+ * Resolve patron by name. If a patron with the same name has placed an order
+ * within the configured time window, reuse them.
+ *
+ * @param Event $event
+ * @param string $name
+ * @return Patron
+ */
+ protected function resolveNamedPatron(Event $event, string $name): Patron
+ {
+ $cutoff = now()->subHours(self::PATRON_MATCH_HOURS);
+
+ // Look for an existing patron with this name who has orders recently
+ $patron = $event->patrons()
+ ->where('name', $name)
+ ->whereHas('orders', function ($query) use ($cutoff) {
+ $query->where('created_at', '>=', $cutoff);
+ })
+ ->latest()
+ ->first();
+
+ if ($patron) {
+ return $patron;
+ }
+
+ // Create a new patron
+ $patron = new Patron();
+ $patron->name = $name;
+ $patron->event()->associate($event);
+ $patron->save();
+
+ return $patron;
+ }
+
+ /**
+ * Resolve patron for anonymous table orders.
+ * If the last patron at this table has unpaid orders, reuse them.
+ * Otherwise, create a new patron.
+ *
+ * @param Event $event
+ * @param Table $table
+ * @return Patron
+ */
+ protected function resolveTablePatron(Event $event, Table $table): Patron
+ {
+ // Check the last patron assigned to this table
+ $lastPatron = $table->getLatestPatron();
+
+ if ($lastPatron && $lastPatron->hasUnpaidOrders()) {
+ return $lastPatron;
+ }
+
+ // Create a new patron for this table
+ $patron = new Patron();
+ $patron->event()->associate($event);
+ $patron->table()->associate($table);
+ $patron->save();
+
+ return $patron;
+ }
+
+ /**
+ * Find or create a table by table_number for an event.
+ * Used when remote orders arrive with an unknown table number.
+ *
+ * @param Event $event
+ * @param int $tableNumber
+ * @return Table
+ */
+ public function findOrCreateTable(Event $event, int $tableNumber): Table
+ {
+ $table = $event->tables()
+ ->withoutTrashed()
+ ->where('table_number', $tableNumber)
+ ->first();
+
+ if ($table) {
+ return $table;
+ }
+
+ // Create a new table
+ $table = new Table();
+ $table->table_number = $tableNumber;
+ $table->name = 'Table ' . $tableNumber;
+ $table->event()->associate($event);
+ $table->save();
+
+ return $table;
+ }
+}
diff --git a/database/migrations/2026_03_13_120000_create_tables_table.php b/database/migrations/2026_03_13_120000_create_tables_table.php
new file mode 100644
index 0000000..3f9bcd6
--- /dev/null
+++ b/database/migrations/2026_03_13_120000_create_tables_table.php
@@ -0,0 +1,41 @@
+increments('id');
+
+ $table->integer('event_id')->unsigned();
+ $table->foreign('event_id')->references('id')->on('events');
+
+ $table->integer('table_number')->unsigned();
+ $table->string('name');
+
+ $table->timestamps();
+ $table->softDeletes();
+
+ $table->unique(['event_id', 'table_number']);
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::dropIfExists('tables');
+ }
+};
diff --git a/database/migrations/2026_03_13_120001_create_patrons_table.php b/database/migrations/2026_03_13_120001_create_patrons_table.php
new file mode 100644
index 0000000..2b6c6fc
--- /dev/null
+++ b/database/migrations/2026_03_13_120001_create_patrons_table.php
@@ -0,0 +1,40 @@
+increments('id');
+
+ $table->integer('event_id')->unsigned();
+ $table->foreign('event_id')->references('id')->on('events');
+
+ $table->string('name')->nullable();
+
+ $table->integer('table_id')->unsigned()->nullable();
+ $table->foreign('table_id')->references('id')->on('tables');
+
+ $table->timestamps();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::dropIfExists('patrons');
+ }
+};
diff --git a/database/migrations/2026_03_13_120002_add_table_service_fields_to_orders.php b/database/migrations/2026_03_13_120002_add_table_service_fields_to_orders.php
new file mode 100644
index 0000000..f586718
--- /dev/null
+++ b/database/migrations/2026_03_13_120002_add_table_service_fields_to_orders.php
@@ -0,0 +1,44 @@
+integer('patron_id')->unsigned()->nullable()->after('event_id');
+ $table->foreign('patron_id')->references('id')->on('patrons');
+
+ $table->integer('table_id')->unsigned()->nullable()->after('patron_id');
+ $table->foreign('table_id')->references('id')->on('tables');
+
+ $table->string('payment_status', 32)->default('paid')->after('paid');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::table('orders', function (Blueprint $table) {
+ $table->dropForeign(['patron_id']);
+ $table->dropColumn('patron_id');
+
+ $table->dropForeign(['table_id']);
+ $table->dropColumn('table_id');
+
+ $table->dropColumn('payment_status');
+ });
+ }
+};
diff --git a/database/migrations/2026_03_13_120003_add_allow_unpaid_table_orders_to_events.php b/database/migrations/2026_03_13_120003_add_allow_unpaid_table_orders_to_events.php
new file mode 100644
index 0000000..0ed21f8
--- /dev/null
+++ b/database/migrations/2026_03_13_120003_add_allow_unpaid_table_orders_to_events.php
@@ -0,0 +1,32 @@
+boolean('allow_unpaid_table_orders')->default(false)->after('allow_unpaid_online_orders');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::table('events', function (Blueprint $table) {
+ $table->dropColumn('allow_unpaid_table_orders');
+ });
+ }
+};
From e7ad64b29dc484dff4d65d04241e04cbadfa362e Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 13 Mar 2026 12:44:53 +0000
Subject: [PATCH 03/14] Add frontend services, views, and routes for table
service
- Create TableService.js and PatronService.js frontend services
- Create Tables.vue view for table management (shared between POS and Manage)
- Create WaiterDashboard.vue view for waiter POS mode (tables, patrons, order queue)
- Create PatronDetail.vue view for patron detail and balance settlement
- Register new routes in POS and Manage app.js
- Add table service navigation links in Events.vue for both apps
- Add allow_unpaid_table_orders checkbox in Manage Events edit modal
Co-authored-by: daedeloth <1168599+daedeloth@users.noreply.github.com>
---
resources/manage/js/app.js | 21 ++
resources/manage/js/views/Events.vue | 23 ++
resources/pos/js/app.js | 21 ++
resources/pos/js/views/Events.vue | 14 +
resources/shared/js/services/PatronService.js | 16 +
resources/shared/js/services/TableService.js | 29 ++
resources/shared/js/views/PatronDetail.vue | 135 ++++++++
resources/shared/js/views/Tables.vue | 155 +++++++++
resources/shared/js/views/WaiterDashboard.vue | 313 ++++++++++++++++++
9 files changed, 727 insertions(+)
create mode 100644 resources/shared/js/services/PatronService.js
create mode 100644 resources/shared/js/services/TableService.js
create mode 100644 resources/shared/js/views/PatronDetail.vue
create mode 100644 resources/shared/js/views/Tables.vue
create mode 100644 resources/shared/js/views/WaiterDashboard.vue
diff --git a/resources/manage/js/app.js b/resources/manage/js/app.js
index c223767..47409eb 100644
--- a/resources/manage/js/app.js
+++ b/resources/manage/js/app.js
@@ -47,6 +47,9 @@ import SalesSummaryNames from "../../shared/js/views/SalesSummaryNames";
import Menu from "./views/Menu.vue";
import Devices from "./views/Devices";
import PublicKeys from "./views/PublicKeys";
+import Tables from "../../shared/js/views/Tables";
+import WaiterDashboard from "../../shared/js/views/WaiterDashboard";
+import PatronDetail from "../../shared/js/views/PatronDetail";
function launch() {
@@ -153,6 +156,24 @@ function launch() {
name: 'publicKeys',
component: PublicKeys
},
+
+ {
+ path: '/events/:id/tables',
+ name: 'tables',
+ component: Tables
+ },
+
+ {
+ path: '/events/:id/waiter',
+ name: 'waiter',
+ component: WaiterDashboard
+ },
+
+ {
+ path: '/events/:id/patron/:patronId',
+ name: 'patron',
+ component: PatronDetail
+ },
],
});
diff --git a/resources/manage/js/views/Events.vue b/resources/manage/js/views/Events.vue
index 6d55c38..61165f7 100644
--- a/resources/manage/js/views/Events.vue
+++ b/resources/manage/js/views/Events.vue
@@ -105,6 +105,22 @@
+
+
+
+
+ 🪑
+ {{ $t('Manage tables') }}
+
+
+
+ 🍽️
+ {{ $t('Waiter dashboard') }}
+
+
+
@@ -180,6 +196,13 @@
+
+
+
+
+
+
+
+
diff --git a/resources/shared/js/views/Tables.vue b/resources/shared/js/views/Tables.vue
new file mode 100644
index 0000000..1f4696e
--- /dev/null
+++ b/resources/shared/js/views/Tables.vue
@@ -0,0 +1,155 @@
+
+
+
+ {{ $t('Tables') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('Generate') }}
+
+
+
+
+
+
+ {{ row.item.name }}
+
+
+
+
+ {{ row.item.table_number }}
+
+
+
+
+
+ ✏️
+
+
+ 🗑️
+
+
+
+
+ ✓
+
+
+ ✕
+
+
+
+
+
+
+ {{ $t('No tables configured for this event. Use the generate button above to create tables.') }}
+
+
+
+
+
+
+
diff --git a/resources/shared/js/views/WaiterDashboard.vue b/resources/shared/js/views/WaiterDashboard.vue
new file mode 100644
index 0000000..1689b8b
--- /dev/null
+++ b/resources/shared/js/views/WaiterDashboard.vue
@@ -0,0 +1,313 @@
+
+
+
+ {{ $t('Table Service') }}
+ {{ event.name }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('No Table') }}
+ {{ $t('Unlinked patrons') }}
+
+
+
+
+
+
+ {{ table.name }}
+ #{{ table.table_number }}
+
+
+
+
+
+
+
+ {{ selectedTableId === 'none' ? $t('Unlinked Patrons') : $t('Patrons at {table}', { table: selectedTableName }) }}
+
+ + {{ $t('New Patron') }}
+
+
+
+
+
+
+
+
+
+
+ {{ patron.name || ($t('Patron') + ' #' + patron.id) }}
+
+ {{ $t('Unpaid') }}
+
+
+
+
+ €{{ (patron.outstanding_balance).toFixed(2) }}
+
+
+
+
+
+ {{ $t('No patrons at this table.') }}
+
+
+
+
+
+
+
+
+
+
+ {{ $t('My orders only') }}
+
+
+ {{ $t('Prepared only') }}
+
+
+
+
+
+
+
+
+
+
+ {{ row.item.status }}
+
+
+
+
+
+ {{ row.item.payment_status }}
+
+
+
+
+
+ {{ $t('Prepared') }}
+
+
+ {{ $t('Delivered') }}
+
+
+ {{ $t('Void') }}
+
+
+
+
+
+
+
+
+
+
+
From 5389d6247415735d88461bd0740f0786fda57f76 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 13 Mar 2026 12:47:18 +0000
Subject: [PATCH 04/14] Address code review: remove unused imports, use
Promise.all for settle balance
Co-authored-by: daedeloth <1168599+daedeloth@users.noreply.github.com>
---
resources/shared/js/services/PatronService.js | 1 -
resources/shared/js/services/TableService.js | 1 -
resources/shared/js/views/PatronDetail.vue | 13 +++++++------
3 files changed, 7 insertions(+), 8 deletions(-)
diff --git a/resources/shared/js/services/PatronService.js b/resources/shared/js/services/PatronService.js
index 30d739b..75aa9cc 100644
--- a/resources/shared/js/services/PatronService.js
+++ b/resources/shared/js/services/PatronService.js
@@ -1,4 +1,3 @@
-import $ from 'jquery';
import {AbstractService} from './AbstractService';
export class PatronService extends AbstractService {
diff --git a/resources/shared/js/services/TableService.js b/resources/shared/js/services/TableService.js
index 2a7d00c..28f5868 100644
--- a/resources/shared/js/services/TableService.js
+++ b/resources/shared/js/services/TableService.js
@@ -1,4 +1,3 @@
-import $ from 'jquery';
import {AbstractService} from './AbstractService';
export class TableService extends AbstractService {
diff --git a/resources/shared/js/views/PatronDetail.vue b/resources/shared/js/views/PatronDetail.vue
index 602ef3d..75b9216 100644
--- a/resources/shared/js/views/PatronDetail.vue
+++ b/resources/shared/js/views/PatronDetail.vue
@@ -102,12 +102,13 @@ export default {
async settleBalance() {
if (confirm(this.$t('Pay all outstanding orders for this patron?'))) {
- // Mark all unpaid orders as paid
- for (const order of this.orders) {
- if (order.payment_status === 'unpaid') {
- await this.orderService.update(order.id, { payment_status: 'paid' });
- }
- }
+ // Mark all unpaid orders as paid in parallel
+ const unpaidOrders = this.orders.filter(o => o.payment_status === 'unpaid');
+ await Promise.all(
+ unpaidOrders.map(order =>
+ this.orderService.update(order.id, { payment_status: 'paid' })
+ )
+ );
await this.refresh();
}
},
From de64a43c1563835f83703611fa2d44539bace2da Mon Sep 17 00:00:00 2001
From: Thijs Van der Schaeghe
Date: Fri, 13 Mar 2026 13:53:54 +0100
Subject: [PATCH 05/14] i18n: Implement actual translations.
---
resources/shared/js/i18n/de.js | 9 +-
resources/shared/js/i18n/en.js | 372 +++++++++++++++++++++++++++++-
resources/shared/js/i18n/es.js | 9 +-
resources/shared/js/i18n/fr.js | 8 +-
resources/shared/js/i18n/index.js | 1 -
resources/shared/js/i18n/nl.js | 9 +-
webpack.mix.js | 34 +--
7 files changed, 413 insertions(+), 29 deletions(-)
diff --git a/resources/shared/js/i18n/de.js b/resources/shared/js/i18n/de.js
index 901defe..0c9ee5d 100644
--- a/resources/shared/js/i18n/de.js
+++ b/resources/shared/js/i18n/de.js
@@ -33,7 +33,6 @@ export default {
'Confirm topup': 'Aufladung bestätigen',
'Are you sure you want to topup for €{amount}?': 'Sind Sie sicher, dass Sie für €{amount} aufladen möchten?',
'Please wait': 'Bitte warten',
- 'Topup successful.': 'Aufladung erfolgreich.',
'Topup failed.': 'Aufladung fehlgeschlagen.',
'Card reset successful.': 'Karte erfolgreich zurückgesetzt.',
'Card reset failed.': 'Karte zurücksetzen fehlgeschlagen.',
@@ -56,7 +55,6 @@ export default {
// Live Sales
'Menu items': 'Menüpunkte',
'Total: {amount} items = €{price}': 'Gesamt: {amount} Artikel = €{price}',
- 'Confirm order': 'Bestellung bestätigen',
'Payment successful': 'Zahlung erfolgreich',
'Order registered successfully.': 'Bestellung erfolgreich registriert.',
'Payment failed': 'Zahlung fehlgeschlagen',
@@ -335,6 +333,13 @@ export default {
'Pair Device': 'Gerät koppeln',
'Edit device': 'Gerät bearbeiten',
'A descriptive name to identify this device': 'Ein beschreibender Name zur Identifizierung dieses Geräts',
+ 'Approve the public key for device "{name}"? This will allow other terminals to verify cards signed by this device.': 'Den öffentlichen Schlüssel für Gerät "{name}" genehmigen? Dies ermöglicht anderen Terminals, Karten zu verifizieren, die von diesem Gerät signiert wurden.',
+ 'Approve the public key for device "{name}"?': 'Den öffentlichen Schlüssel für Gerät "{name}" genehmigen?',
+ 'Revoke the public key for device "{name}"?': 'Den öffentlichen Schlüssel für Gerät "{name}" widerrufen?',
+ 'Are you sure you want to delete device "{name}"?\n\nThis will revoke the device\'s access token and it will no longer be able to connect. The device will need to be re-paired to use it again.': 'Sind Sie sicher, dass Sie Gerät "{name}" löschen möchten?\n\nDies widerruft das Zugriffstoken des Geräts und es wird nicht mehr verbinden können. Das Gerät muss erneut gekoppelt werden, um es wieder zu verwenden.',
+ 'Cards signed by {name}:': 'Von {name} signierte Karten:',
+ 'WARNING: Revoking this key will invalidate {count} cards that were last signed by this device. Are you sure?': 'WARNUNG: Das Widerrufen dieses Schlüssels macht {count} Karten ungültig, die zuletzt von diesem Gerät signiert wurden. Sind Sie sicher?',
+ '⚠️ WARNING: This is a destructive action!\n\nRevoking this key will invalidate {count} cards that were last signed by this device.\n\nAre you absolutely sure?': '⚠️ WARNUNG: Dies ist eine destruktive Aktion!\n\nDas Widerrufen dieses Schlüssels macht {count} Karten ungültig, die zuletzt von diesem Gerät signiert wurden.\n\nSind Sie absolut sicher?',
'Enter license for {name}': 'Lizenz eingeben für {name}',
'License Key': 'Lizenzschlüssel',
'Paste the base64-encoded license text block here': 'Fügen Sie hier den base64-codierten Lizenztext ein',
diff --git a/resources/shared/js/i18n/en.js b/resources/shared/js/i18n/en.js
index 647b64a..10bf358 100644
--- a/resources/shared/js/i18n/en.js
+++ b/resources/shared/js/i18n/en.js
@@ -1,4 +1,153 @@
export default {
+ // Shared - General
+ 'Loading data': 'Loading data',
+ 'Save': 'Save',
+ 'Reset': 'Reset',
+ 'Cancel': 'Cancel',
+ 'Saving': 'Saving',
+ 'Saved': 'Saved',
+ 'Confirm': 'Confirm',
+ 'Add': 'Add',
+ 'Delete': 'Delete',
+ 'Edit': 'Edit',
+ 'Actions': 'Actions',
+ 'Name': 'Name',
+ 'Status': 'Status',
+ 'Menu': 'Menu',
+
+ // Cards
+ 'Card Management': 'Card Management',
+ 'Transactions': 'Transactions',
+ 'Scan to start': 'Scan to start',
+ 'No NFC card service found. Please check the settings.': 'No NFC card service found. Please check the settings.',
+ 'Card #{uid}': 'Card #{uid}',
+ 'Topup': 'Topup',
+ 'Custom amount': 'Custom amount',
+ 'Aliases': 'Aliases',
+ '(expire 24h after creation)': '(expire 24h after creation)',
+ 'Discount': 'Discount',
+ 'Card gives {percentage}% at all sales.': 'Card gives {percentage}% at all sales.',
+ 'ID': 'ID',
+ 'Balance': 'Balance',
+ 'Last transaction': 'Last transaction',
+ 'Confirm topup': 'Confirm topup',
+ 'Are you sure you want to topup for €{amount}?': 'Are you sure you want to topup for €{amount}?',
+ 'Please wait': 'Please wait',
+ 'Topup failed.': 'Topup failed.',
+ 'Card reset successful.': 'Card reset successful.',
+ 'Card reset failed.': 'Card reset failed.',
+ 'This card is corrupted, it might belong to a different organisation. If you are sure the card should be correct, you can try to rebuild the card data from the available online data. Note that if you have bars that are operating online, you might lose transactions and the balance might not be correct after rebuilding.': 'This card is corrupted, it might belong to a different organisation. If you are sure the card should be correct, you can try to rebuild the card data from the available online data. Note that if you have bars that are operating online, you might lose transactions and the balance might not be correct after rebuilding.',
+ 'Rebuild': 'Rebuild',
+ 'Danger! Rebuilding will only keep all transactions that are available online. Are you sure you want to do that?': 'Danger! Rebuilding will only keep all transactions that are available online. Are you sure you want to do that?',
+ 'Card is rebuilt from online data.': 'Card is rebuilt from online data.',
+ 'Rebuild error: ': 'Rebuild error: ',
+ 'Are you sure you want to set the card value to 0?': 'Are you sure you want to set the card value to 0?',
+
+ // NFC Card Balance
+ 'NFC': 'NFC',
+ 'API Offline': 'API Offline',
+ 'Balance: {balance}': 'Balance: {balance}',
+ 'Corrupt card, contact support': 'Corrupt card, contact support',
+
+ // Card Details
+ 'Topping up': 'Topping up',
+
+ // Live Sales
+ 'Menu items': 'Menu items',
+ 'Total: {amount} items = €{price}': 'Total: {amount} items = €{price}',
+ 'Confirm order': 'Confirm order',
+ 'Payment successful': 'Payment successful',
+ 'Order registered successfully.': 'Order registered successfully.',
+ 'Payment failed': 'Payment failed',
+ 'Payment has failed. Please re-enter the order.': 'Payment has failed. Please re-enter the order.',
+ 'Order saved': 'Order saved',
+
+ // Sales History
+ 'Orders': 'Orders',
+ 'Summary': 'Summary',
+ 'There don\'t seem to be any orders, sir...': 'There don\'t seem to be any orders, sir...',
+
+ // Sales Summary
+ 'Sales summary': 'Sales summary',
+ 'Sold items': 'Sold items',
+ 'Orderer names': 'Orderer names',
+ 'Rank': 'Rank',
+ 'Item': 'Item',
+ 'Amount': 'Amount',
+ 'Price': 'Price',
+ 'VAT %': 'VAT %',
+ 'Netto': 'Netto',
+ 'VAT': 'VAT',
+ 'Total': 'Total',
+ 'Unknown / manual': 'Unknown / manual',
+ 'Order details': 'Order details',
+
+ // Order Details
+ 'Date': 'Date',
+ 'Table': 'Table',
+ 'Client': 'Client',
+ 'Payment type': 'Payment type',
+ 'Payment': 'Payment',
+ 'Paid': 'Paid',
+ 'by card': 'by card',
+ 'in cash': 'in cash',
+ 'Not paid': 'Not paid',
+
+ // Remote Orders
+ 'Remote orders': 'Remote orders',
+ 'Show all orders': 'Show all orders',
+ 'Only show "{name}" orders': 'Only show "{name}" orders',
+ 'Completed': 'Completed',
+ 'Not accepted': 'Not accepted',
+ 'Order accepted': 'Order accepted',
+ 'Deliver order at table {location}.': 'Deliver order at table {location}.',
+ 'TABLE {location}': 'TABLE {location}',
+ 'Order declined': 'Order declined',
+ 'Order declined. Notify table {location} and ask to enter order again.': 'Order declined. Notify table {location} and ask to enter order again.',
+ 'Confirm unpaid order': 'Confirm unpaid order',
+ 'Confirm declined order': 'Confirm declined order',
+ 'Are you sure you want to decline order #{id}?': 'Are you sure you want to decline order #{id}?',
+ 'The paid amount will be refunded.': 'The paid amount will be refunded.',
+ 'The client will not be notified, so go over to them and let them know why their order was declined.': 'The client will not be notified, so go over to them and let them know why their order was declined.',
+ 'Decline order': 'Decline order',
+ 'Keep order': 'Keep order',
+ 'Closed': 'Closed',
+ 'Open': 'Open',
+
+ // Remote Order Description
+ 'Order #{id}': 'Order #{id}',
+ 'Table: {location}': 'Table: {location}',
+ 'Client: {requester}': 'Client: {requester}',
+ 'Total: {total}': 'Total: {total}',
+ 'Not paid yet': 'Not paid yet',
+
+ // Payment Popup
+ '{amount} vouchers': '{amount} vouchers',
+ 'Scan card': 'Scan card',
+ 'Collect {amount}': 'Collect {amount}',
+ 'Collect {amount} vouchers': 'Collect {amount} vouchers',
+ 'No payment methods have been enabled for this event.': 'No payment methods have been enabled for this event.',
+ 'Please edit the event and enable payment methods.': 'Please edit the event and enable payment methods.',
+
+ // Topup
+ 'Topup successful.': 'Topup successful.',
+ 'Amount: {amount}': 'Amount: {amount}',
+ 'Reason: {reason}': 'Reason: {reason}',
+ 'Topup amount': 'Topup amount',
+ 'Reason': 'Reason',
+ 'Specify a reason for the topup.': 'Specify a reason for the topup.',
+
+ // Transactions Table
+ 'We have not recorded any transactions yet.': 'We have not recorded any transactions yet.',
+ 'Card details': 'Card details',
+
+ // Financial Overview
+ 'Total credit': 'Total credit',
+ 'Topups last 24 hours': 'Topups last 24 hours',
+
+ // Attendees
+ 'Check-In': 'Check-In',
+ 'Set attendees {name}': 'Set attendees {name}',
'Attendees: {name}': 'Attendees: {name}',
'About attendees:': 'About attendees:',
'Attendees link card aliases (secret tokens) to personal information such as name and email.': 'Attendees link card aliases (secret tokens) to personal information such as name and email.',
@@ -10,6 +159,9 @@ export default {
'Aliases can be generated by external tools such as': 'Aliases can be generated by external tools such as',
'You can paste tab-separated or colon-separated data directly into the spreadsheet below.': 'You can paste tab-separated or colon-separated data directly into the spreadsheet below.',
'NFC Card Alias': 'NFC Card Alias',
+ 'This will remove ALL existing attendees and replace them with new ones.': 'This will remove ALL existing attendees and replace them with new ones.',
+ 'Replace attendees': 'Replace attendees',
+ 'All transactions': 'All transactions',
'Delete row': 'Delete row',
'Undo delete': 'Undo delete',
'Save changes': 'Save changes',
@@ -27,17 +179,226 @@ export default {
'Preview': 'Preview',
'{rows} rows': '{rows} rows',
'Are you sure you want to delete ALL rows? This cannot be undone after saving.': 'Are you sure you want to delete ALL rows? This cannot be undone after saving.',
+
+ // Check-In
+ 'Please import attendees and aliases before using this module.': 'Please import attendees and aliases before using this module.',
+ 'Select attendee': 'Select attendee',
+ 'Always checkin all attendees. If the attendee already has a card, please use that card instead of a new one.': 'Always checkin all attendees. If the attendee already has a card, please use that card instead of a new one.',
+ 'No attendees have been set. Please import attendees before using this module.': 'No attendees have been set. Please import attendees before using this module.',
+ 'Confirm check-in': 'Confirm check-in',
+ 'Alias: {alias}': 'Alias: {alias}',
+
+ // POS - App
+ 'CatLab Drinks': 'CatLab Drinks',
+ 'Events': 'Events',
+ 'Cards': 'Cards',
+ 'Settings': 'Settings',
+ 'No active license.': 'No active license.',
+ '{remaining} of {max} card scans remaining.': '{remaining} of {max} card scans remaining.',
+ 'Please purchase a license in the management portal to remove this limit.': 'Please purchase a license in the management portal to remove this limit.',
+ 'License Required': 'License Required',
+ 'Card limit exceeded. Please activate a license to continue scanning cards.': 'Card limit exceeded. Please activate a license to continue scanning cards.',
+ 'You can purchase and activate a license from the management portal under Devices.': 'You can purchase and activate a license from the management portal under Devices.',
+
+ // POS - Authenticate
+ 'Connect Device': 'Connect Device',
+ 'Pair this device with your CatLab Drinks instance': 'Pair this device with your CatLab Drinks instance',
+ 'Connecting...': 'Connecting...',
+ 'Connecting to server…': 'Connecting to server…',
+ 'Enter this code in the management panel to pair this device:': 'Enter this code in the management panel to pair this device:',
+ 'Waiting for confirmation…': 'Waiting for confirmation…',
+ 'Scan QR Code': 'Scan QR Code',
+ 'Enter Token Manually': 'Enter Token Manually',
+ 'Point your camera at the QR code': 'Point your camera at the QR code',
+ '← Back': '← Back',
+ 'Connection URL or Token': 'Connection URL or Token',
+ 'Paste connection URL or token': 'Paste connection URL or token',
+ '→ Authenticate': '→ Authenticate',
+ 'Invalid token. Paste the connection URL or the base64 connect token.': 'Invalid token. Paste the connection URL or the base64 connect token.',
+ 'Invalid connection data: missing api or token.': 'Invalid connection data: missing api or token.',
+ 'Invalid API URL.': 'Invalid API URL.',
+ 'QR code does not contain connection data.': 'QR code does not contain connection data.',
+ 'Could not parse QR code data.': 'Could not parse QR code data.',
+ 'Camera access was denied. Please allow camera access in your browser settings to scan QR codes.': 'Camera access was denied. Please allow camera access in your browser settings to scan QR codes.',
+ 'No camera found on this device. Please use a device with a camera or enter the token manually.': 'No camera found on this device. Please use a device with a camera or enter the token manually.',
+ 'Camera is already in use by another application.': 'Camera is already in use by another application.',
+ 'Could not access camera: ': 'Could not access camera: ',
+
+ // POS - Events
+ 'Event': 'Event',
+ 'Order token': 'Order token',
+ 'Sales overview': 'Sales overview',
+ 'Order history': 'Order history',
+ 'Editing events can only be done from the management console.': 'Editing events can only be done from the management console.',
+ 'You can only toggle remote orders from here.': 'You can only toggle remote orders from here.',
+
+ // POS - Headquarters
+ 'You have disabled both live and remote orders.': 'You have disabled both live and remote orders.',
+ 'This terminal will not be able to process any orders.': 'This terminal will not be able to process any orders.',
+ 'Please enable either live or remote orders in the settings.': 'Please enable either live or remote orders in the settings.',
+
+ // POS - Menu
+ 'Bar HQ': 'Bar HQ',
+ 'Editing the menu can only be done from the management console.': 'Editing the menu can only be done from the management console.',
+ 'Here you can only change the availability of products.': 'Here you can only change the availability of products.',
+ 'Product name': 'Product name',
+ 'Category': 'Category',
+ 'Not selling': 'Not selling',
+ 'Selling': 'Selling',
+
+ // POS - Settings
+ 'Point of sale settings': 'Point of sale settings',
+ 'General settings': 'General settings',
+ 'Device name': 'Device name',
+ 'Name of this device as configured on the server.': 'Name of this device as configured on the server.',
+ 'Allow live orders at this terminal': 'Allow live orders at this terminal',
+ 'This terminal can process orders at the bar': 'This terminal can process orders at the bar',
+ 'Allow remote orders at this terminal': 'Allow remote orders at this terminal',
+ 'This terminal can process orders from tables': 'This terminal can process orders from tables',
+ 'Remote NFC reader': 'Remote NFC reader',
+ 'Requires an additional service': 'Requires an additional service',
+ 'NFC webserver url': 'NFC webserver url',
+ 'NFC Server url': 'NFC Server url',
+ 'NFC webserver password': 'NFC webserver password',
+ 'NFC Server password': 'NFC Server password',
+ 'License': 'License',
+ 'License is active.': 'License is active.',
+ 'Expires: {date}': 'Expires: {date}',
+ 'Cards scanned: {scanned} / {max}': 'Cards scanned: {scanned} / {max}',
+ 'Remaining: {remaining}': 'Remaining: {remaining}',
+ 'Please purchase a license to remove the card scan limit. Visit the management portal to buy and activate a license for this device.': 'Please purchase a license to remove the card scan limit. Visit the management portal to buy and activate a license for this device.',
+ 'Device': 'Device',
+ 'Disconnect this device from the server. You will need to re-pair it to use it again.': 'Disconnect this device from the server. You will need to re-pair it to use it again.',
+ 'Logout': 'Logout',
+ 'Are you sure you want to logout? This device will need to be re-paired to connect again.': 'Are you sure you want to logout? This device will need to be re-paired to connect again.',
+
+ // Manage - App
+ 'Points of sale': 'Points of sale',
+
+ // Manage - Events
+ 'Create new event': 'Create new event',
+ 'Edit menu': 'Edit menu',
+ 'Client order form': 'Client order form',
+ 'Register attendees': 'Register attendees',
+ 'Check-In attendees': 'Check-In attendees',
+ 'POS Device Pairing': 'POS Device Pairing',
+ 'POS (Point of Sale) devices authenticate separately from your management account. You can pair and manage POS devices from the Devices page.': 'POS (Point of Sale) devices authenticate separately from your management account. You can pair and manage POS devices from the Devices page.',
+ 'Manage & Pair Devices': 'Manage & Pair Devices',
+ 'Edit event ID#{id}': 'Edit event ID#{id}',
+ 'New event': 'New event',
+ 'Allow payment with NFC topup cards': 'Allow payment with NFC topup cards',
+ 'Allow payment in cash': 'Allow payment in cash',
+ 'Allow payment with vouchers': 'Allow payment with vouchers',
+ 'Allow unpaid online orders (without providing card alias)': 'Allow unpaid online orders (without providing card alias)',
+ 'Split orders by product categories (e.g drinks for the bar, food for the kitchen)': 'Split orders by product categories (e.g drinks for the bar, food for the kitchen)',
+ 'Voucher value': 'Voucher value',
+ 'Checkin URL (callback)': 'Checkin URL (callback)',
+ 'Are you sure you want to remove this event?': 'Are you sure you want to remove this event?',
+
+ // Manage - Menu
+ 'Create new menu item': 'Create new menu item',
+ 'New item': 'New item',
+ 'Edit item ID#{id}': 'Edit item ID#{id}',
+ 'Description': 'Description',
+ 'Price (all taxes included)': 'Price (all taxes included)',
+ 'VAT / Tax percentage (optional)': 'VAT / Tax percentage (optional)',
+ 'Product category': 'Product category',
+ 'By defining multiple product categories, you can split your orders between different locations.': 'By defining multiple product categories, you can split your orders between different locations.',
+ 'Create new category': 'Create new category',
+ 'Please enter a name for the product.': 'Please enter a name for the product.',
+ 'Please enter a price for the product.': 'Please enter a price for the product.',
+ 'Are you sure you want to remove this menu item?': 'Are you sure you want to remove this menu item?',
+ 'Enter the name of the new category:': 'Enter the name of the new category:',
+
+ // Manage - Devices
+ 'Point of sale devices': 'Point of sale devices',
+ 'Link or authenticate a device': 'Link or authenticate a device',
+ 'Licensed': 'Licensed',
+ 'No license': 'No license',
+ 'Buy License': 'Buy License',
+ 'Enter License': 'Enter License',
+ 'Connect & authenticate a device': 'Connect & authenticate a device',
+ 'Creating connect token': 'Creating connect token',
+ 'Scan this QR code with the POS device to connect:': 'Scan this QR code with the POS device to connect:',
+ 'Connection URL': 'Connection URL',
+ 'You can also copy this URL and paste it in the POS device\'s manual token entry': 'You can also copy this URL and paste it in the POS device\'s manual token entry',
+ '- or -': '- or -',
+ 'Open POS on this device': 'Open POS on this device',
+ 'This is a new device and needs to be paired. Enter the pairing code displayed on the POS device below.': 'This is a new device and needs to be paired. Enter the pairing code displayed on the POS device below.',
+ 'Pairing Code': 'Pairing Code',
+ 'The code shown on the POS device screen': 'The code shown on the POS device screen',
+ 'Enter pairing code': 'Enter pairing code',
+ 'Device Name': 'Device Name',
+ 'A descriptive name to identify this device (e.g. \'Bar Terminal 1\')': 'A descriptive name to identify this device (e.g. \'Bar Terminal 1\')',
+ 'Enter device name': 'Enter device name',
+ 'Pair Device': 'Pair Device',
+ 'Edit device': 'Edit device',
+ 'A descriptive name to identify this device': 'A descriptive name to identify this device',
'Approve the public key for device "{name}"? This will allow other terminals to verify cards signed by this device.': 'Approve the public key for device "{name}"? This will allow other terminals to verify cards signed by this device.',
'Approve the public key for device "{name}"?': 'Approve the public key for device "{name}"?',
'Revoke the public key for device "{name}"?': 'Revoke the public key for device "{name}"?',
'Are you sure you want to delete device "{name}"?\n\nThis will revoke the device\'s access token and it will no longer be able to connect. The device will need to be re-paired to use it again.': 'Are you sure you want to delete device "{name}"?\n\nThis will revoke the device\'s access token and it will no longer be able to connect. The device will need to be re-paired to use it again.',
'Cards signed by {name}:': 'Cards signed by {name}:',
- 'Enter license for {name}': 'Enter license for {name}',
'WARNING: Revoking this key will invalidate {count} cards that were last signed by this device. Are you sure?': 'WARNING: Revoking this key will invalidate {count} cards that were last signed by this device. Are you sure?',
'⚠️ WARNING: This is a destructive action!\n\nRevoking this key will invalidate {count} cards that were last signed by this device.\n\nAre you absolutely sure?': '⚠️ WARNING: This is a destructive action!\n\nRevoking this key will invalidate {count} cards that were last signed by this device.\n\nAre you absolutely sure?',
- 'Balance: {balance}': 'Balance: {balance}',
- '{remaining} of {max} card scans remaining.': '{remaining} of {max} card scans remaining.',
+ 'Enter license for {name}': 'Enter license for {name}',
+ 'License Key': 'License Key',
+ 'Paste the base64-encoded license text block here': 'Paste the base64-encoded license text block here',
+ 'Paste license key here': 'Paste license key here',
+ 'Apply License': 'Apply License',
+ 'Are you sure you want to delete device "{name}"?': 'Are you sure you want to delete device "{name}"?',
+ 'This will revoke the device\'s access token and it will no longer be able to connect. The device will need to be re-paired to use it again.': 'This will revoke the device\'s access token and it will no longer be able to connect. The device will need to be re-paired to use it again.',
+ 'Invalid license key: not valid base64.': 'Invalid license key: not valid base64.',
+ 'Invalid license key: invalid JSON structure.': 'Invalid license key: invalid JSON structure.',
+ 'Invalid license key: missing license data.': 'Invalid license key: missing license data.',
+ 'Invalid license key: missing signature.': 'Invalid license key: missing signature.',
+ 'Invalid license key: missing device_uid in license data.': 'Invalid license key: missing device_uid in license data.',
+ 'Invalid license key: this license is for a different device.': 'Invalid license key: this license is for a different device.',
+ 'Invalid license key: invalid expiration date format.': 'Invalid license key: invalid expiration date format.',
+ 'Invalid license key: license has expired.': 'Invalid license key: license has expired.',
+ 'Failed to save license. Please try again.': 'Failed to save license. Please try again.',
+
+ // Manage - Organisation Settings
+ 'Organisation Settings': 'Organisation Settings',
+ 'Payment Gateways': 'Payment Gateways',
+ 'Configure payment gateways for online top-ups. Credentials are stored encrypted and are never exposed via the API.': 'Configure payment gateways for online top-ups. Credentials are stored encrypted and are never exposed via the API.',
+ 'Gateway': 'Gateway',
+ 'Credentials': 'Credentials',
+ 'Mode': 'Mode',
+ 'Valid': 'Valid',
+ 'Incomplete': 'Incomplete',
+ 'Test': 'Test',
+ 'Live': 'Live',
+ 'Active': 'Active',
+ 'Inactive': 'Inactive',
+ 'No payment gateways configured. Add one to enable online top-ups.': 'No payment gateways configured. Add one to enable online top-ups.',
+ 'Add Payment Gateway': 'Add Payment Gateway',
+ 'Edit Payment Gateway': 'Edit Payment Gateway',
+ 'API Token': 'API Token',
+ 'Your Pay.nl token code': 'Your Pay.nl token code',
+ '(unchanged)': '(unchanged)',
+ 'API Secret': 'API Secret',
+ 'Your Pay.nl API token/secret': 'Your Pay.nl API token/secret',
+ 'Service ID': 'Service ID',
+ 'Your Pay.nl service ID (e.g. SL-xxxx-xxxx)': 'Your Pay.nl service ID (e.g. SL-xxxx-xxxx)',
+ 'Test mode': 'Test mode',
+ 'Are you sure you want to remove the {gateway} payment gateway?': 'Are you sure you want to remove the {gateway} payment gateway?',
+
+ // Client - Order
+ 'Order': 'Order',
+ 'Product': 'Product',
+ 'Table number': 'Table number',
+ 'Place order': 'Place order',
+ 'Oops': 'Oops',
+ 'We\'re on our way!': 'We\'re on our way!',
+ 'New order': 'New order',
+ 'We have received your order ({orderIds}). Your order is in our queue, we will be there as soon as possible.': 'We have received your order ({orderIds}). Your order is in our queue, we will be there as soon as possible.',
+ 'Please enter a table number.': 'Please enter a table number.',
+ 'Please order at least 1 item.': 'Please order at least 1 item.',
+ 'Network connection error. Please check network connection.': 'Network connection error. Please check network connection.',
'Your table number: {tableNumber}': 'Your table number: {tableNumber}',
+
+ // Offline
'Offline': 'Offline',
'Device is offline. Remote orders cannot be processed until the connection is restored.': 'Device is offline. Remote orders cannot be processed until the connection is restored.',
'Sync status': 'Sync status',
@@ -48,5 +409,8 @@ export default {
'Pending queue items:': 'Pending queue items:',
'Sync now': 'Sync now',
'Synchronization complete.': 'Synchronization complete.',
- 'There are still {count} pending items that have not been uploaded. Logging out may cause data loss. Are you sure you want to logout?': 'There are still {count} pending items that have not been uploaded. Logging out may cause data loss. Are you sure you want to logout?'
+ 'There are still {count} pending items that have not been uploaded. Logging out may cause data loss. Are you sure you want to logout?': 'There are still {count} pending items that have not been uploaded. Logging out may cause data loss. Are you sure you want to logout?',
+
+ // Language toggle
+ 'Language': 'Language',
};
diff --git a/resources/shared/js/i18n/es.js b/resources/shared/js/i18n/es.js
index 8f87f0a..5d6c4e6 100644
--- a/resources/shared/js/i18n/es.js
+++ b/resources/shared/js/i18n/es.js
@@ -33,7 +33,6 @@ export default {
'Confirm topup': 'Confirmar recarga',
'Are you sure you want to topup for €{amount}?': '¿Está seguro de que desea recargar por €{amount}?',
'Please wait': 'Por favor espere',
- 'Topup successful.': 'Recarga exitosa.',
'Topup failed.': 'Recarga fallida.',
'Card reset successful.': 'Reinicio de tarjeta exitoso.',
'Card reset failed.': 'Reinicio de tarjeta fallido.',
@@ -56,7 +55,6 @@ export default {
// Live Sales
'Menu items': 'Artículos del menú',
'Total: {amount} items = €{price}': 'Total: {amount} artículos = €{price}',
- 'Confirm order': 'Confirmar pedido',
'Payment successful': 'Pago exitoso',
'Order registered successfully.': 'Pedido registrado exitosamente.',
'Payment failed': 'Pago fallido',
@@ -335,6 +333,13 @@ export default {
'Pair Device': 'Vincular dispositivo',
'Edit device': 'Editar dispositivo',
'A descriptive name to identify this device': 'Un nombre descriptivo para identificar este dispositivo',
+ 'Approve the public key for device "{name}"? This will allow other terminals to verify cards signed by this device.': '¿Aprobar la clave pública del dispositivo "{name}"? Esto permitirá a otros terminales verificar tarjetas firmadas por este dispositivo.',
+ 'Approve the public key for device "{name}"?': '¿Aprobar la clave pública del dispositivo "{name}"?',
+ 'Revoke the public key for device "{name}"?': '¿Revocar la clave pública del dispositivo "{name}"?',
+ 'Are you sure you want to delete device "{name}"?\n\nThis will revoke the device\'s access token and it will no longer be able to connect. The device will need to be re-paired to use it again.': '¿Está seguro de que desea eliminar el dispositivo "{name}"?\n\nEsto revocará el token de acceso del dispositivo y ya no podrá conectarse. El dispositivo necesitará ser vinculado de nuevo para usarlo.',
+ 'Cards signed by {name}:': 'Tarjetas firmadas por {name}:',
+ 'WARNING: Revoking this key will invalidate {count} cards that were last signed by this device. Are you sure?': 'ADVERTENCIA: Revocar esta clave invalidará {count} tarjetas que fueron firmadas por última vez por este dispositivo. ¿Está seguro?',
+ '⚠️ WARNING: This is a destructive action!\n\nRevoking this key will invalidate {count} cards that were last signed by this device.\n\nAre you absolutely sure?': '⚠️ ADVERTENCIA: ¡Esta es una acción destructiva!\n\nRevocar esta clave invalidará {count} tarjetas que fueron firmadas por última vez por este dispositivo.\n\n¿Está absolutamente seguro?',
'Enter license for {name}': 'Ingresar licencia para {name}',
'License Key': 'Clave de licencia',
'Paste the base64-encoded license text block here': 'Pegue aquí el bloque de texto de licencia codificado en base64',
diff --git a/resources/shared/js/i18n/fr.js b/resources/shared/js/i18n/fr.js
index 36d5652..7e1709d 100644
--- a/resources/shared/js/i18n/fr.js
+++ b/resources/shared/js/i18n/fr.js
@@ -33,7 +33,6 @@ export default {
'Confirm topup': 'Confirmer le rechargement',
'Are you sure you want to topup for €{amount}?': 'Êtes-vous sûr de vouloir recharger pour €{amount} ?',
'Please wait': 'Veuillez patienter',
- 'Topup successful.': 'Rechargement réussi.',
'Topup failed.': 'Rechargement échoué.',
'Card reset successful.': 'Réinitialisation de la carte réussie.',
'Card reset failed.': 'Réinitialisation de la carte échouée.',
@@ -335,6 +334,13 @@ export default {
'Pair Device': 'Associer l\'appareil',
'Edit device': 'Modifier l\'appareil',
'A descriptive name to identify this device': 'Un nom descriptif pour identifier cet appareil',
+ 'Approve the public key for device "{name}"? This will allow other terminals to verify cards signed by this device.': 'Approuver la clé publique de l\'appareil "{name}" ? Cela permettra à d\'autres terminaux de vérifier les cartes signées par cet appareil.',
+ 'Approve the public key for device "{name}"?': 'Approuver la clé publique de l\'appareil "{name}" ?',
+ 'Revoke the public key for device "{name}"?': 'Révoquer la clé publique de l\'appareil "{name}" ?',
+ 'Are you sure you want to delete device "{name}"?\n\nThis will revoke the device\'s access token and it will no longer be able to connect. The device will need to be re-paired to use it again.': 'Êtes-vous sûr de vouloir supprimer l\'appareil "{name}" ?\n\nCela révoquera le jeton d\'accès de l\'appareil et il ne pourra plus se connecter. L\'appareil devra être ré-associé pour être réutilisé.',
+ 'Cards signed by {name}:': 'Cartes signées par {name} :',
+ 'WARNING: Revoking this key will invalidate {count} cards that were last signed by this device. Are you sure?': 'AVERTISSEMENT : La révocation de cette clé invalidera {count} cartes qui ont été signées en dernier par cet appareil. Êtes-vous sûr ?',
+ '⚠️ WARNING: This is a destructive action!\n\nRevoking this key will invalidate {count} cards that were last signed by this device.\n\nAre you absolutely sure?': '⚠️ AVERTISSEMENT : Cette action est destructrice !\n\nLa révocation de cette clé invalidera {count} cartes qui ont été signées en dernier par cet appareil.\n\nÊtes-vous absolument sûr ?',
'Enter license for {name}': 'Entrer la licence pour {name}',
'License Key': 'Clé de licence',
'Paste the base64-encoded license text block here': 'Collez le bloc de texte de licence encodé en base64 ici',
diff --git a/resources/shared/js/i18n/index.js b/resources/shared/js/i18n/index.js
index 59d0758..52a490c 100644
--- a/resources/shared/js/i18n/index.js
+++ b/resources/shared/js/i18n/index.js
@@ -64,7 +64,6 @@ const i18n = createI18n({
fallbackLocale: 'en',
missingWarn: false,
fallbackWarn: false,
- formatFallbackMessages: true,
messages: {
en,
nl,
diff --git a/resources/shared/js/i18n/nl.js b/resources/shared/js/i18n/nl.js
index a23716f..f950e19 100644
--- a/resources/shared/js/i18n/nl.js
+++ b/resources/shared/js/i18n/nl.js
@@ -33,7 +33,6 @@ export default {
'Confirm topup': 'Opwaardering bevestigen',
'Are you sure you want to topup for €{amount}?': 'Weet je zeker dat je wilt opwaarderen voor €{amount}?',
'Please wait': 'Even wachten',
- 'Topup successful.': 'Opwaardering geslaagd.',
'Topup failed.': 'Opwaardering mislukt.',
'Card reset successful.': 'Kaart reset geslaagd.',
'Card reset failed.': 'Kaart reset mislukt.',
@@ -56,7 +55,6 @@ export default {
// Live Sales
'Menu items': 'Menu-items',
'Total: {amount} items = €{price}': 'Totaal: {amount} items = €{price}',
- 'Confirm order': 'Bestelling bevestigen',
'Payment successful': 'Betaling geslaagd',
'Order registered successfully.': 'Bestelling succesvol geregistreerd.',
'Payment failed': 'Betaling mislukt',
@@ -335,6 +333,13 @@ export default {
'Pair Device': 'Apparaat koppelen',
'Edit device': 'Apparaat bewerken',
'A descriptive name to identify this device': 'Een beschrijvende naam om dit apparaat te identificeren',
+ 'Approve the public key for device "{name}"? This will allow other terminals to verify cards signed by this device.': 'Openbare sleutel voor apparaat "{name}" goedkeuren? Hierdoor kunnen andere terminals kaarten verifiëren die door dit apparaat zijn ondertekend.',
+ 'Approve the public key for device "{name}"?': 'Openbare sleutel voor apparaat "{name}" goedkeuren?',
+ 'Revoke the public key for device "{name}"?': 'Openbare sleutel voor apparaat "{name}" intrekken?',
+ 'Are you sure you want to delete device "{name}"?\n\nThis will revoke the device\'s access token and it will no longer be able to connect. The device will need to be re-paired to use it again.': 'Weet je zeker dat je apparaat "{name}" wilt verwijderen?\n\nDit trekt het toegangstoken van het apparaat in en het zal niet meer kunnen verbinden. Het apparaat moet opnieuw worden gekoppeld om het weer te gebruiken.',
+ 'Cards signed by {name}:': 'Kaarten ondertekend door {name}:',
+ 'WARNING: Revoking this key will invalidate {count} cards that were last signed by this device. Are you sure?': 'WAARSCHUWING: Het intrekken van deze sleutel maakt {count} kaarten ongeldig die voor het laatst door dit apparaat zijn ondertekend. Weet je het zeker?',
+ '⚠️ WARNING: This is a destructive action!\n\nRevoking this key will invalidate {count} cards that were last signed by this device.\n\nAre you absolutely sure?': '⚠️ WAARSCHUWING: Dit is een destructieve actie!\n\nHet intrekken van deze sleutel maakt {count} kaarten ongeldig die voor het laatst door dit apparaat zijn ondertekend.\n\nWeet je het absoluut zeker?',
'Enter license for {name}': 'Licentie invoeren voor {name}',
'License Key': 'Licentiesleutel',
'Paste the base64-encoded license text block here': 'Plak hier het base64-gecodeerde licentietekstblok',
diff --git a/webpack.mix.js b/webpack.mix.js
index 405ddf9..4a61fd3 100644
--- a/webpack.mix.js
+++ b/webpack.mix.js
@@ -11,32 +11,32 @@ const mix = require('laravel-mix');
|
*/
- // Tell Mix to use Vue 3 and the appropriate compat options when compiling .vue files
+// Tell Mix to use Vue 3 and the appropriate compat options when compiling .vue files
mix.vue({
version: 3,
options: {
- compilerOptions: {
- compatConfig: {
- MODE: 3,
- WATCH_ARRAY: false,
+ compilerOptions: {
+ compatConfig: {
+ MODE: 3,
+ WATCH_ARRAY: false,
+ },
},
- },
},
- })
+})
// Alias Vue and (optionally) the composition API
mix.webpackConfig(() => {
-return {
- resolve: {
- alias: {
- vue: "@vue/compat",
- "@vue/composition-api": "@vue/compat",
- },
- fallback: {
- "stream": require.resolve("stream-browserify")
+ return {
+ resolve: {
+ alias: {
+ vue: "@vue/compat",
+ "@vue/composition-api": "@vue/compat",
+ },
+ fallback: {
+ "stream": require.resolve("stream-browserify")
+ }
+ },
}
- },
-}
})
mix.setPublicPath('public');
From 1a404f4f1bf5ab2ae7eb17818d03c9f1526e231e Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 13 Mar 2026 13:44:38 +0000
Subject: [PATCH 06/14] Add thorough unit/integration tests and documentation
for table service
PHP Unit Tests (6 new test files, ~70 new tests):
- TableModelTest: model attributes, fillable, relationships, soft deletes
- PatronModelTest: model attributes, fillable, relationships, methods
- OrderStatusConstantsTest: new fulfillment/payment status constants
- TablePolicyTest: authorization for user, device, and null (20 tests)
- PatronPolicyTest: authorization for user, device, and null (20 tests)
- PatronAssignmentServiceTest: service instantiation, constants, edge cases
JavaScript/Vitest Tests (3 new test files, 66 new tests):
- table-service-routes.test.js: route config in POS/Manage/Client apps
- table-service-views.test.js: template content verification for Events.vue
- table-service-services.test.js: service structure and shared view integrity
Documentation:
- .ai/table-service.md: comprehensive architecture docs
Co-authored-by: daedeloth <1168599+daedeloth@users.noreply.github.com>
---
.ai/table-service.md | 222 ++++++++++++
resources/tests/table-service-routes.test.js | 119 +++++++
.../tests/table-service-services.test.js | 212 +++++++++++
resources/tests/table-service-views.test.js | 80 +++++
tests/Unit/OrderStatusConstantsTest.php | 118 +++++++
tests/Unit/PatronAssignmentServiceTest.php | 110 ++++++
tests/Unit/PatronModelTest.php | 149 ++++++++
tests/Unit/PatronPolicyTest.php | 331 ++++++++++++++++++
tests/Unit/TableModelTest.php | 137 ++++++++
tests/Unit/TablePolicyTest.php | 331 ++++++++++++++++++
10 files changed, 1809 insertions(+)
create mode 100644 .ai/table-service.md
create mode 100644 resources/tests/table-service-routes.test.js
create mode 100644 resources/tests/table-service-services.test.js
create mode 100644 resources/tests/table-service-views.test.js
create mode 100644 tests/Unit/OrderStatusConstantsTest.php
create mode 100644 tests/Unit/PatronAssignmentServiceTest.php
create mode 100644 tests/Unit/PatronModelTest.php
create mode 100644 tests/Unit/PatronPolicyTest.php
create mode 100644 tests/Unit/TableModelTest.php
create mode 100644 tests/Unit/TablePolicyTest.php
diff --git a/.ai/table-service.md b/.ai/table-service.md
new file mode 100644
index 0000000..19c67d1
--- /dev/null
+++ b/.ai/table-service.md
@@ -0,0 +1,222 @@
+# Table Service Integration
+
+## Overview
+
+The Table Service feature enables waiter-operated table ordering in the POS application.
+Waiters can take orders at tables, track patrons, manage fulfillment/payment states
+independently, and settle tabs.
+
+---
+
+## Database Schema
+
+### `tables` Table
+
+| Column | Type | Notes |
+|--------------|----------|----------------------------------------|
+| id | int (PK) | Auto-increment |
+| event_id | int (FK) | References `events.id` |
+| table_number | int | Unique within event (non-soft-deleted) |
+| name | string | Display name, e.g., "Table 1" |
+| created_at | datetime | |
+| updated_at | datetime | |
+| deleted_at | datetime | Soft delete support |
+
+**Constraints:** `UNIQUE(event_id, table_number)`
+
+### `patrons` Table
+
+| Column | Type | Notes |
+|------------|----------|----------------------------|
+| id | int (PK) | Auto-increment |
+| event_id | int (FK) | References `events.id` |
+| name | string | Nullable (anonymous patrons)|
+| table_id | int (FK) | Nullable, references `tables.id` |
+| created_at | datetime | |
+| updated_at | datetime | |
+
+### `orders` Table (New Columns)
+
+| Column | Type | Notes |
+|----------------|----------|----------------------------------------|
+| patron_id | int (FK) | Nullable, references `patrons.id` |
+| table_id | int (FK) | Nullable, references `tables.id` |
+| payment_status | string | `'unpaid'`, `'paid'`, `'voided'`; default `'paid'` |
+
+### `events` Table (New Columns)
+
+| Column | Type | Notes |
+|---------------------------|------|-------------------------------------|
+| allow_unpaid_table_orders | bool | Default `false`; allows waiters to open tabs |
+
+---
+
+## Models
+
+### `Table` (`App\Models\Table`)
+
+- Uses `SoftDeletes` and `HasFactory` traits
+- **Relationships:** `event()`, `patrons()`, `orders()`
+- **Methods:**
+ - `getLatestPatron()` — returns the most recently created patron at this table
+ - `bulkGenerate(Event $event, int $count)` — static; creates `$count` tables starting
+ from the highest existing `table_number + 1`, named "Table N"
+
+### `Patron` (`App\Models\Patron`)
+
+- **Relationships:** `event()`, `table()`, `orders()`
+- **Methods:**
+ - `getOutstandingBalance()` — sum of prices of all unpaid orders
+ - `hasUnpaidOrders()` — boolean check for any unpaid orders
+
+### `Order` (Updated)
+
+New status constants:
+```php
+Order::STATUS_PREPARED = 'prepared'
+Order::STATUS_DELIVERED = 'delivered'
+
+Order::PAYMENT_STATUS_UNPAID = 'unpaid'
+Order::PAYMENT_STATUS_PAID = 'paid'
+Order::PAYMENT_STATUS_VOIDED = 'voided'
+```
+
+New relationships: `patron()`, `table()`
+
+---
+
+## Patron Assignment Algorithm
+
+`PatronAssignmentService` resolves which patron should own an incoming order:
+
+### 1. Named Orders (e.g., Quiz App)
+
+```
+If name is provided:
+ → Search for existing patron with that name who has orders within last 24 hours
+ → If found: reuse that patron
+ → If not found: create a new patron with the name
+```
+
+### 2. Anonymous Orders (e.g., Table QR Scan)
+
+```
+If table is provided (no name):
+ → Get the last patron assigned to this table
+ → If that patron has unpaid orders: reuse them
+ → If all orders are paid: create a new patron for the table
+```
+
+### 3. No Context
+
+```
+If neither name nor table: return null (no patron assignment)
+```
+
+### Auto-create Tables
+
+`findOrCreateTable(Event $event, int $tableNumber)` finds an existing non-soft-deleted table
+or creates one. Used when remote orders arrive referencing unknown table numbers.
+
+---
+
+## API Endpoints
+
+### Table Endpoints
+
+| Method | Path | Action | Auth |
+|--------|-----------------------------------------|------------------|-----------------|
+| GET | `/events/{id}/tables` | List tables | Both APIs |
+| POST | `/events/{id}/tables` | Create table | Both APIs |
+| POST | `/events/{id}/tables/generate` | Bulk generate | Both APIs |
+| GET | `/tables/{id}` | View table | Both APIs |
+| PUT | `/tables/{id}` | Edit table | Both APIs |
+| DELETE | `/tables/{id}` | Soft-delete table | Management only |
+
+### Patron Endpoints
+
+| Method | Path | Action | Auth |
+|--------|-----------------------------------------|------------------|-----------------|
+| GET | `/events/{id}/patrons` | List patrons | Both APIs |
+| POST | `/events/{id}/patrons` | Create patron | Both APIs |
+| GET | `/patrons/{id}` | View patron | Both APIs |
+| PUT | `/patrons/{id}` | Edit patron | Both APIs |
+
+### Updated Order Fields
+
+`OrderResourceDefinition` now exposes:
+- `payment_status` — filterable, writeable
+- `patron_id` — filterable, writeable
+- `table_id` — filterable, writeable
+
+---
+
+## Authorization Policies
+
+### `TablePolicy`
+
+| Action | User (in org) | Device (in org) | Other/Null |
+|----------|:-------------:|:---------------:|:----------:|
+| index | ✅ | ✅ | ❌ |
+| create | ✅ | ✅ | ❌ |
+| view | ✅ | ✅ | ❌ |
+| edit | ✅ | ✅ | ❌ |
+| destroy | ✅ | ❌ | ❌ |
+
+### `PatronPolicy`
+
+Same as `TablePolicy` — devices can CRUD except destroy.
+
+---
+
+## Frontend
+
+### Services
+
+- **`TableService`** — extends `AbstractService`, sets `indexUrl = events/{id}/tables`,
+ `entityUrl = tables`. Has `bulkGenerate(count)` method.
+- **`PatronService`** — extends `AbstractService`, sets `indexUrl = events/{id}/patrons`,
+ `entityUrl = patrons`.
+
+### Views (all in `resources/shared/js/views/`)
+
+| View | Purpose |
+|-----------------------|----------------------------------------------------------|
+| `Tables.vue` | Table management: bulk generate, inline rename, delete |
+| `WaiterDashboard.vue` | Waiter POS: table grid, patron list, order queue |
+| `PatronDetail.vue` | Patron detail: order history, outstanding balance, settle|
+
+### Routes
+
+Both POS and Manage apps register these routes:
+
+| Path | Name | Component |
+|---------------------------------|---------|------------------|
+| `/events/:id/tables` | tables | Tables |
+| `/events/:id/waiter` | waiter | WaiterDashboard |
+| `/events/:id/patron/:patronId` | patron | PatronDetail |
+
+### Navigation
+
+- **POS Events.vue**: "Waiter dashboard" and "Manage tables" links in Actions dropdown
+- **Manage Events.vue**: "Manage tables" and "Waiter dashboard" links in new "Table Service"
+ dropdown group; `allow_unpaid_table_orders` checkbox in event edit modal
+
+---
+
+## Event Settings
+
+| Setting | Type | Default | Description |
+|------------------------------|------|---------|------------------------------------------|
+| `allow_unpaid_table_orders` | bool | false | When true, waiters can "Pay Later" on orders, leaving payment_status as 'unpaid' while fulfillment continues |
+
+---
+
+## Offline Considerations
+
+The table service must work offline. Key design decisions:
+- `OrderService` extends `AbstractOfflineQueue` which stores orders in IndexedDB
+- Table and patron data is cached via the `ApiCacheService` interceptors
+- Waiters can create orders offline; they sync when connectivity returns
+- Bar preparation status won't update offline, but the waiter can manually mark
+ orders as delivered
diff --git a/resources/tests/table-service-routes.test.js b/resources/tests/table-service-routes.test.js
new file mode 100644
index 0000000..e3a42d6
--- /dev/null
+++ b/resources/tests/table-service-routes.test.js
@@ -0,0 +1,119 @@
+/**
+ * Tests to verify Table Service routes are correctly configured in POS and Manage apps.
+ *
+ * Both POS and Manage apps MUST have:
+ * - tables route
+ * - waiter dashboard route
+ * - patron detail route
+ * - Required component imports
+ *
+ * Client app MUST NOT reference any table service routes.
+ */
+import { describe, it, expect } from 'vitest';
+import { readFileSync } from 'fs';
+import { resolve } from 'path';
+
+function readAppFile(appName) {
+ return readFileSync(resolve(__dirname, '..', appName, 'js', 'app.js'), 'utf-8');
+}
+
+describe('POS app table service routing', () => {
+ const source = readAppFile('pos');
+
+ it('imports Tables component', () => {
+ expect(source).toContain("import Tables from");
+ });
+
+ it('imports WaiterDashboard component', () => {
+ expect(source).toContain("import WaiterDashboard from");
+ });
+
+ it('imports PatronDetail component', () => {
+ expect(source).toContain("import PatronDetail from");
+ });
+
+ it('has tables route', () => {
+ expect(source).toContain("name: 'tables'");
+ });
+
+ it('has waiter route', () => {
+ expect(source).toContain("name: 'waiter'");
+ });
+
+ it('has patron route', () => {
+ expect(source).toContain("name: 'patron'");
+ });
+
+ it('has tables path', () => {
+ expect(source).toContain("/events/:id/tables");
+ });
+
+ it('has waiter path', () => {
+ expect(source).toContain("/events/:id/waiter");
+ });
+
+ it('has patron path', () => {
+ expect(source).toContain("/events/:id/patron/:patronId");
+ });
+});
+
+describe('Manage app table service routing', () => {
+ const source = readAppFile('manage');
+
+ it('imports Tables component', () => {
+ expect(source).toContain("import Tables from");
+ });
+
+ it('imports WaiterDashboard component', () => {
+ expect(source).toContain("import WaiterDashboard from");
+ });
+
+ it('imports PatronDetail component', () => {
+ expect(source).toContain("import PatronDetail from");
+ });
+
+ it('has tables route', () => {
+ expect(source).toContain("name: 'tables'");
+ });
+
+ it('has waiter route', () => {
+ expect(source).toContain("name: 'waiter'");
+ });
+
+ it('has patron route', () => {
+ expect(source).toContain("name: 'patron'");
+ });
+
+ it('has tables path', () => {
+ expect(source).toContain("/events/:id/tables");
+ });
+
+ it('has waiter path', () => {
+ expect(source).toContain("/events/:id/waiter");
+ });
+
+ it('has patron path', () => {
+ expect(source).toContain("/events/:id/patron/:patronId");
+ });
+});
+
+describe('Client app does not reference table service', () => {
+ const source = readAppFile('clients');
+
+ it('does not reference Tables', () => {
+ expect(source).not.toContain("import Tables from");
+ expect(source).not.toContain("name: 'tables'");
+ });
+
+ it('does not reference WaiterDashboard', () => {
+ expect(source).not.toContain("WaiterDashboard");
+ });
+
+ it('does not reference PatronDetail', () => {
+ expect(source).not.toContain("PatronDetail");
+ });
+
+ it('does not have waiter route', () => {
+ expect(source).not.toContain("name: 'waiter'");
+ });
+});
diff --git a/resources/tests/table-service-services.test.js b/resources/tests/table-service-services.test.js
new file mode 100644
index 0000000..2aebf59
--- /dev/null
+++ b/resources/tests/table-service-services.test.js
@@ -0,0 +1,212 @@
+/**
+ * Tests for Table Service frontend services and shared views.
+ *
+ * Verifies that:
+ * - TableService and PatronService extend AbstractService correctly
+ * - Shared views (Tables, WaiterDashboard, PatronDetail) exist and have expected structure
+ */
+import { describe, it, expect } from 'vitest';
+import { readFileSync, existsSync } from 'fs';
+import { resolve } from 'path';
+
+const sharedPath = resolve(__dirname, '..', 'shared', 'js');
+
+function readFile(path) {
+ return readFileSync(path, 'utf-8');
+}
+
+describe('TableService', () => {
+ const servicePath = resolve(sharedPath, 'services', 'TableService.js');
+
+ it('file exists', () => {
+ expect(existsSync(servicePath)).toBe(true);
+ });
+
+ it('extends AbstractService', () => {
+ const content = readFile(servicePath);
+ expect(content).toContain("import {AbstractService} from './AbstractService'");
+ expect(content).toContain('extends AbstractService');
+ });
+
+ it('sets indexUrl for tables', () => {
+ const content = readFile(servicePath);
+ expect(content).toContain("'/tables'");
+ });
+
+ it('sets entityUrl for tables', () => {
+ const content = readFile(servicePath);
+ expect(content).toContain("this.entityUrl = 'tables'");
+ });
+
+ it('has bulkGenerate method', () => {
+ const content = readFile(servicePath);
+ expect(content).toContain('bulkGenerate');
+ });
+
+ it('does not import unused jQuery', () => {
+ const content = readFile(servicePath);
+ expect(content).not.toContain("import $ from 'jquery'");
+ });
+});
+
+describe('PatronService', () => {
+ const servicePath = resolve(sharedPath, 'services', 'PatronService.js');
+
+ it('file exists', () => {
+ expect(existsSync(servicePath)).toBe(true);
+ });
+
+ it('extends AbstractService', () => {
+ const content = readFile(servicePath);
+ expect(content).toContain("import {AbstractService} from './AbstractService'");
+ expect(content).toContain('extends AbstractService');
+ });
+
+ it('sets indexUrl for patrons', () => {
+ const content = readFile(servicePath);
+ expect(content).toContain("'/patrons'");
+ });
+
+ it('sets entityUrl for patrons', () => {
+ const content = readFile(servicePath);
+ expect(content).toContain("this.entityUrl = 'patrons'");
+ });
+
+ it('does not import unused jQuery', () => {
+ const content = readFile(servicePath);
+ expect(content).not.toContain("import $ from 'jquery'");
+ });
+});
+
+describe('Tables.vue shared view', () => {
+ const viewPath = resolve(sharedPath, 'views', 'Tables.vue');
+
+ it('file exists', () => {
+ expect(existsSync(viewPath)).toBe(true);
+ });
+
+ it('imports TableService', () => {
+ const content = readFile(viewPath);
+ expect(content).toContain('TableService');
+ });
+
+ it('has generate tables form', () => {
+ const content = readFile(viewPath);
+ expect(content).toContain('Generate tables');
+ expect(content).toContain('generateTables');
+ });
+
+ it('has inline edit support', () => {
+ const content = readFile(viewPath);
+ expect(content).toContain('startEdit');
+ expect(content).toContain('saveEdit');
+ expect(content).toContain('cancelEdit');
+ });
+
+ it('has delete support', () => {
+ const content = readFile(viewPath);
+ expect(content).toContain('remove');
+ });
+
+ it('displays table_number field', () => {
+ const content = readFile(viewPath);
+ expect(content).toContain('table_number');
+ });
+});
+
+describe('WaiterDashboard.vue shared view', () => {
+ const viewPath = resolve(sharedPath, 'views', 'WaiterDashboard.vue');
+
+ it('file exists', () => {
+ expect(existsSync(viewPath)).toBe(true);
+ });
+
+ it('imports required services', () => {
+ const content = readFile(viewPath);
+ expect(content).toContain('TableService');
+ expect(content).toContain('PatronService');
+ expect(content).toContain('OrderService');
+ expect(content).toContain('EventService');
+ });
+
+ it('has Tables tab', () => {
+ const content = readFile(viewPath);
+ expect(content).toContain('Tables');
+ });
+
+ it('has Order Queue tab', () => {
+ const content = readFile(viewPath);
+ expect(content).toContain('Order Queue');
+ });
+
+ it('has No Table option', () => {
+ const content = readFile(viewPath);
+ expect(content).toContain('No Table');
+ });
+
+ it('has New Patron button', () => {
+ const content = readFile(viewPath);
+ expect(content).toContain('New Patron');
+ expect(content).toContain('createPatron');
+ });
+
+ it('supports order status updates', () => {
+ const content = readFile(viewPath);
+ expect(content).toContain('markPrepared');
+ expect(content).toContain('markDelivered');
+ expect(content).toContain('markVoided');
+ });
+
+ it('has filter controls for order queue', () => {
+ const content = readFile(viewPath);
+ expect(content).toContain('filterMyOrders');
+ expect(content).toContain('filterPreparedOnly');
+ });
+
+ it('uses patron route for patron links', () => {
+ const content = readFile(viewPath);
+ expect(content).toContain("name: 'patron'");
+ });
+});
+
+describe('PatronDetail.vue shared view', () => {
+ const viewPath = resolve(sharedPath, 'views', 'PatronDetail.vue');
+
+ it('file exists', () => {
+ expect(existsSync(viewPath)).toBe(true);
+ });
+
+ it('imports required services', () => {
+ const content = readFile(viewPath);
+ expect(content).toContain('PatronService');
+ expect(content).toContain('OrderService');
+ });
+
+ it('shows outstanding balance', () => {
+ const content = readFile(viewPath);
+ expect(content).toContain('Outstanding Balance');
+ expect(content).toContain('outstanding_balance');
+ });
+
+ it('has settle balance functionality', () => {
+ const content = readFile(viewPath);
+ expect(content).toContain('settleBalance');
+ expect(content).toContain('Pay Outstanding Balance');
+ });
+
+ it('uses Promise.all for parallel settlement', () => {
+ const content = readFile(viewPath);
+ expect(content).toContain('Promise.all');
+ });
+
+ it('shows payment status badges', () => {
+ const content = readFile(viewPath);
+ expect(content).toContain('payment_status');
+ expect(content).toContain('paymentStatusVariant');
+ });
+
+ it('has back button', () => {
+ const content = readFile(viewPath);
+ expect(content).toContain('$router.back()');
+ });
+});
diff --git a/resources/tests/table-service-views.test.js b/resources/tests/table-service-views.test.js
new file mode 100644
index 0000000..3c692a7
--- /dev/null
+++ b/resources/tests/table-service-views.test.js
@@ -0,0 +1,80 @@
+/**
+ * Tests to verify Table Service links are correctly placed in Events views.
+ *
+ * - POS Events.vue: MUST have "Waiter dashboard" link and "Manage tables" link
+ * - Manage Events.vue: MUST have "Manage tables" link, "Waiter dashboard" link,
+ * and "allow_unpaid_table_orders" checkbox
+ */
+import { describe, it, expect } from 'vitest';
+import { readFileSync } from 'fs';
+import { resolve } from 'path';
+
+function readVueFile(appName, fileName) {
+ return readFileSync(
+ resolve(__dirname, '..', appName, 'js', 'views', fileName),
+ 'utf-8'
+ );
+}
+
+function readVueTemplate(appName) {
+ const content = readVueFile(appName, 'Events.vue');
+ const startIdx = content.indexOf('');
+ const endIdx = content.lastIndexOf('');
+ return (startIdx >= 0 && endIdx > startIdx) ? content.substring(startIdx, endIdx + ''.length) : '';
+}
+
+describe('POS Events.vue table service links', () => {
+ const template = readVueTemplate('pos');
+
+ it('has Waiter dashboard action link', () => {
+ expect(template).toContain('Waiter dashboard');
+ });
+
+ it('has Manage tables action link', () => {
+ expect(template).toContain('Manage tables');
+ });
+
+ it('links to waiter route', () => {
+ expect(template).toContain("name: 'waiter'");
+ });
+
+ it('links to tables route', () => {
+ expect(template).toContain("name: 'tables'");
+ });
+});
+
+describe('Manage Events.vue table service links', () => {
+ const template = readVueTemplate('manage');
+
+ it('has Manage tables action link', () => {
+ expect(template).toContain('Manage tables');
+ });
+
+ it('has Waiter dashboard action link', () => {
+ expect(template).toContain('Waiter dashboard');
+ });
+
+ it('links to tables route', () => {
+ expect(template).toContain("name: 'tables'");
+ });
+
+ it('links to waiter route', () => {
+ expect(template).toContain("name: 'waiter'");
+ });
+
+ it('has Table Service section header', () => {
+ expect(template).toContain('Table Service');
+ });
+});
+
+describe('Manage Events.vue table service settings', () => {
+ const content = readVueFile('manage', 'Events.vue');
+
+ it('has allow_unpaid_table_orders checkbox', () => {
+ expect(content).toContain('allow_unpaid_table_orders');
+ });
+
+ it('has label text for unpaid table orders setting', () => {
+ expect(content).toContain('Allow unpaid table orders');
+ });
+});
diff --git a/tests/Unit/OrderStatusConstantsTest.php b/tests/Unit/OrderStatusConstantsTest.php
new file mode 100644
index 0000000..76956d0
--- /dev/null
+++ b/tests/Unit/OrderStatusConstantsTest.php
@@ -0,0 +1,118 @@
+assertEquals('pending', Order::STATUS_PENDING);
+ }
+
+ /**
+ * Test that STATUS_PROCESSED constant exists and has correct value.
+ */
+ public function testStatusProcessed(): void
+ {
+ $this->assertEquals('processed', Order::STATUS_PROCESSED);
+ }
+
+ /**
+ * Test that STATUS_DECLINED constant exists and has correct value.
+ */
+ public function testStatusDeclined(): void
+ {
+ $this->assertEquals('declined', Order::STATUS_DECLINED);
+ }
+
+ /**
+ * Test that STATUS_PREPARED constant exists (new for table service).
+ */
+ public function testStatusPrepared(): void
+ {
+ $this->assertEquals('prepared', Order::STATUS_PREPARED);
+ }
+
+ /**
+ * Test that STATUS_DELIVERED constant exists (new for table service).
+ */
+ public function testStatusDelivered(): void
+ {
+ $this->assertEquals('delivered', Order::STATUS_DELIVERED);
+ }
+
+ // --- Payment status constants ---
+
+ /**
+ * Test that PAYMENT_STATUS_UNPAID constant exists.
+ */
+ public function testPaymentStatusUnpaid(): void
+ {
+ $this->assertEquals('unpaid', Order::PAYMENT_STATUS_UNPAID);
+ }
+
+ /**
+ * Test that PAYMENT_STATUS_PAID constant exists.
+ */
+ public function testPaymentStatusPaid(): void
+ {
+ $this->assertEquals('paid', Order::PAYMENT_STATUS_PAID);
+ }
+
+ /**
+ * Test that PAYMENT_STATUS_VOIDED constant exists.
+ */
+ public function testPaymentStatusVoided(): void
+ {
+ $this->assertEquals('voided', Order::PAYMENT_STATUS_VOIDED);
+ }
+
+ // --- Relationship existence checks ---
+
+ /**
+ * Test that Order defines patron relationship (new for table service).
+ */
+ public function testDefinesPatronRelationship(): void
+ {
+ $order = new Order();
+ $this->assertTrue(
+ method_exists($order, 'patron'),
+ 'Order model should define patron() relationship'
+ );
+ }
+
+ /**
+ * Test that Order defines table relationship (new for table service).
+ */
+ public function testDefinesTableRelationship(): void
+ {
+ $order = new Order();
+ $this->assertTrue(
+ method_exists($order, 'table'),
+ 'Order model should define table() relationship'
+ );
+ }
+
+ /**
+ * Test that Order still defines existing relationships.
+ */
+ public function testDefinesExistingRelationships(): void
+ {
+ $order = new Order();
+ $this->assertTrue(method_exists($order, 'event'), 'Order should still have event() relationship');
+ $this->assertTrue(method_exists($order, 'assignedDevice'), 'Order should still have assignedDevice() relationship');
+ $this->assertTrue(method_exists($order, 'order'), 'Order should still have order() relationship');
+ $this->assertTrue(method_exists($order, 'cardTransactions'), 'Order should still have cardTransactions() relationship');
+ }
+}
diff --git a/tests/Unit/PatronAssignmentServiceTest.php b/tests/Unit/PatronAssignmentServiceTest.php
new file mode 100644
index 0000000..9d63e47
--- /dev/null
+++ b/tests/Unit/PatronAssignmentServiceTest.php
@@ -0,0 +1,110 @@
+assertEquals(24, PatronAssignmentService::PATRON_MATCH_HOURS);
+ }
+
+ /**
+ * Test that resolvePatron method exists with correct signature.
+ */
+ public function testResolvePatronMethodExists(): void
+ {
+ $service = new PatronAssignmentService();
+ $this->assertTrue(
+ method_exists($service, 'resolvePatron'),
+ 'PatronAssignmentService should have resolvePatron() method'
+ );
+ }
+
+ /**
+ * Test that findOrCreateTable method exists.
+ */
+ public function testFindOrCreateTableMethodExists(): void
+ {
+ $service = new PatronAssignmentService();
+ $this->assertTrue(
+ method_exists($service, 'findOrCreateTable'),
+ 'PatronAssignmentService should have findOrCreateTable() method'
+ );
+ }
+
+ /**
+ * Test that resolvePatron returns null when neither name nor table is provided.
+ */
+ public function testResolvePatronReturnsNullWithNoArguments(): void
+ {
+ $service = new PatronAssignmentService();
+
+ $event = $this->createMock(\App\Models\Event::class);
+
+ $result = $service->resolvePatron($event);
+ $this->assertNull($result);
+ }
+
+ /**
+ * Test that resolvePatron returns null when name is empty string.
+ */
+ public function testResolvePatronReturnsNullWithEmptyName(): void
+ {
+ $service = new PatronAssignmentService();
+
+ $event = $this->createMock(\App\Models\Event::class);
+
+ $result = $service->resolvePatron($event, '');
+ $this->assertNull($result);
+ }
+
+ /**
+ * Test that resolvePatron returns null when name is whitespace only.
+ */
+ public function testResolvePatronReturnsNullWithWhitespaceName(): void
+ {
+ $service = new PatronAssignmentService();
+
+ $event = $this->createMock(\App\Models\Event::class);
+
+ $result = $service->resolvePatron($event, ' ');
+ $this->assertNull($result);
+ }
+
+ /**
+ * Test that the service can be instantiated without dependencies.
+ */
+ public function testServiceInstantiation(): void
+ {
+ $service = new PatronAssignmentService();
+ $this->assertInstanceOf(PatronAssignmentService::class, $service);
+ }
+
+ /**
+ * Test that resolvePatron accepts the expected parameter types.
+ */
+ public function testResolvePatronAcceptsNullParameters(): void
+ {
+ $service = new PatronAssignmentService();
+
+ $event = $this->createMock(\App\Models\Event::class);
+
+ // Should not throw an exception
+ $result = $service->resolvePatron($event, null, null);
+ $this->assertNull($result);
+ }
+}
diff --git a/tests/Unit/PatronModelTest.php b/tests/Unit/PatronModelTest.php
new file mode 100644
index 0000000..7aa88c2
--- /dev/null
+++ b/tests/Unit/PatronModelTest.php
@@ -0,0 +1,149 @@
+assertEquals('patrons', $patron->getTable());
+ }
+
+ /**
+ * Test that fillable attributes include name.
+ */
+ public function testFillableAttributes(): void
+ {
+ $patron = new Patron();
+ $fillable = $patron->getFillable();
+
+ $this->assertContains('name', $fillable);
+ }
+
+ /**
+ * Test that event_id is NOT fillable (set via relationship).
+ */
+ public function testEventIdNotFillable(): void
+ {
+ $patron = new Patron();
+ $fillable = $patron->getFillable();
+
+ $this->assertNotContains('event_id', $fillable);
+ }
+
+ /**
+ * Test that table_id is NOT fillable (set via relationship).
+ */
+ public function testTableIdNotFillable(): void
+ {
+ $patron = new Patron();
+ $fillable = $patron->getFillable();
+
+ $this->assertNotContains('table_id', $fillable);
+ }
+
+ /**
+ * Test that name can be set directly.
+ */
+ public function testNameAttribute(): void
+ {
+ $patron = new Patron();
+ $patron->name = 'Alice';
+
+ $this->assertEquals('Alice', $patron->name);
+ }
+
+ /**
+ * Test that name can be null (anonymous patrons).
+ */
+ public function testNameCanBeNull(): void
+ {
+ $patron = new Patron();
+ $patron->name = null;
+
+ $this->assertNull($patron->name);
+ }
+
+ /**
+ * Test that the model defines the expected relationships.
+ */
+ public function testDefinesEventRelationship(): void
+ {
+ $patron = new Patron();
+ $this->assertTrue(
+ method_exists($patron, 'event'),
+ 'Patron model should define event() relationship'
+ );
+ }
+
+ /**
+ * Test that the model defines table relationship.
+ */
+ public function testDefinesTableRelationship(): void
+ {
+ $patron = new Patron();
+ $this->assertTrue(
+ method_exists($patron, 'table'),
+ 'Patron model should define table() relationship'
+ );
+ }
+
+ /**
+ * Test that the model defines orders relationship.
+ */
+ public function testDefinesOrdersRelationship(): void
+ {
+ $patron = new Patron();
+ $this->assertTrue(
+ method_exists($patron, 'orders'),
+ 'Patron model should define orders() relationship'
+ );
+ }
+
+ /**
+ * Test that getOutstandingBalance method exists.
+ */
+ public function testHasGetOutstandingBalanceMethod(): void
+ {
+ $patron = new Patron();
+ $this->assertTrue(
+ method_exists($patron, 'getOutstandingBalance'),
+ 'Patron model should define getOutstandingBalance() method'
+ );
+ }
+
+ /**
+ * Test that hasUnpaidOrders method exists.
+ */
+ public function testHasHasUnpaidOrdersMethod(): void
+ {
+ $patron = new Patron();
+ $this->assertTrue(
+ method_exists($patron, 'hasUnpaidOrders'),
+ 'Patron model should define hasUnpaidOrders() method'
+ );
+ }
+
+ /**
+ * Test that the model does NOT use SoftDeletes (patrons are not soft-deleted).
+ */
+ public function testDoesNotUseSoftDeletes(): void
+ {
+ $patron = new Patron();
+ $this->assertFalse(
+ in_array(\Illuminate\Database\Eloquent\SoftDeletes::class, class_uses_recursive($patron)),
+ 'Patron model should NOT use SoftDeletes trait'
+ );
+ }
+}
diff --git a/tests/Unit/PatronPolicyTest.php b/tests/Unit/PatronPolicyTest.php
new file mode 100644
index 0000000..b0a32e8
--- /dev/null
+++ b/tests/Unit/PatronPolicyTest.php
@@ -0,0 +1,331 @@
+policy = new PatronPolicy();
+ }
+
+ /**
+ * Helper to create a mock Event that belongs to an Organisation.
+ */
+ private function createMockEvent(Organisation $organisation): Event
+ {
+ $event = $this->createMock(Event::class);
+ $event->method('__get')
+ ->willReturnMap([
+ ['organisation', $organisation],
+ ]);
+ return $event;
+ }
+
+ /**
+ * Helper to create a mock Patron that belongs to an Event.
+ */
+ private function createMockPatron(Event $event): Patron
+ {
+ $patron = $this->createMock(Patron::class);
+ $patron->method('__get')
+ ->willReturnMap([
+ ['event', $event],
+ ]);
+ return $patron;
+ }
+
+ /**
+ * Helper to create a mock User in the given organisation.
+ */
+ private function createMockUser(Organisation $organisation): User
+ {
+ $user = $this->createMock(User::class);
+ $collection = new Collection([$organisation]);
+ $user->method('__get')
+ ->with('organisations')
+ ->willReturn($collection);
+ return $user;
+ }
+
+ /**
+ * Helper to create a mock Device in the given organisation.
+ */
+ private function createMockDevice(Organisation $organisation): Device
+ {
+ $device = $this->createMock(Device::class);
+ $device->method('__get')
+ ->willReturnMap([
+ ['organisation_id', $organisation->id],
+ ]);
+ return $device;
+ }
+
+ // --- index tests ---
+
+ public function testIndexAllowedForOrganisationUser(): void
+ {
+ $org = new Organisation();
+ $org->id = 1;
+
+ $event = $this->createMockEvent($org);
+ $user = $this->createMockUser($org);
+
+ $this->assertTrue($this->policy->index($user, $event));
+ }
+
+ public function testIndexAllowedForDeviceInOrganisation(): void
+ {
+ $org = new Organisation();
+ $org->id = 1;
+
+ $event = $this->createMockEvent($org);
+ $device = $this->createMockDevice($org);
+
+ $this->assertTrue($this->policy->index($device, $event));
+ }
+
+ public function testIndexDeniedForDeviceInDifferentOrganisation(): void
+ {
+ $org1 = new Organisation();
+ $org1->id = 1;
+ $org2 = new Organisation();
+ $org2->id = 2;
+
+ $event = $this->createMockEvent($org1);
+ $device = $this->createMockDevice($org2);
+
+ $this->assertFalse($this->policy->index($device, $event));
+ }
+
+ public function testIndexDeniedForNonMemberUser(): void
+ {
+ $org1 = new Organisation();
+ $org1->id = 1;
+ $org2 = new Organisation();
+ $org2->id = 2;
+
+ $event = $this->createMockEvent($org2);
+ $user = $this->createMockUser($org1);
+
+ $this->assertFalse($this->policy->index($user, $event));
+ }
+
+ public function testIndexDeniedForNullUser(): void
+ {
+ $org = new Organisation();
+ $org->id = 1;
+
+ $event = $this->createMockEvent($org);
+
+ $this->assertFalse($this->policy->index(null, $event));
+ }
+
+ // --- create tests ---
+
+ public function testCreateAllowedForOrganisationUser(): void
+ {
+ $org = new Organisation();
+ $org->id = 1;
+
+ $event = $this->createMockEvent($org);
+ $user = $this->createMockUser($org);
+
+ $this->assertTrue($this->policy->create($user, $event));
+ }
+
+ public function testCreateAllowedForDeviceInOrganisation(): void
+ {
+ $org = new Organisation();
+ $org->id = 1;
+
+ $event = $this->createMockEvent($org);
+ $device = $this->createMockDevice($org);
+
+ $this->assertTrue($this->policy->create($device, $event));
+ }
+
+ public function testCreateDeniedForNullUser(): void
+ {
+ $org = new Organisation();
+ $org->id = 1;
+
+ $event = $this->createMockEvent($org);
+
+ $this->assertFalse($this->policy->create(null, $event));
+ }
+
+ // --- view tests ---
+
+ public function testViewAllowedForOrganisationUser(): void
+ {
+ $org = new Organisation();
+ $org->id = 1;
+
+ $event = $this->createMockEvent($org);
+ $patron = $this->createMockPatron($event);
+ $user = $this->createMockUser($org);
+
+ $this->assertTrue($this->policy->view($user, $patron));
+ }
+
+ public function testViewAllowedForDeviceInOrganisation(): void
+ {
+ $org = new Organisation();
+ $org->id = 1;
+
+ $event = $this->createMockEvent($org);
+ $patron = $this->createMockPatron($event);
+ $device = $this->createMockDevice($org);
+
+ $this->assertTrue($this->policy->view($device, $patron));
+ }
+
+ public function testViewDeniedForNonMemberUser(): void
+ {
+ $org1 = new Organisation();
+ $org1->id = 1;
+ $org2 = new Organisation();
+ $org2->id = 2;
+
+ $event = $this->createMockEvent($org2);
+ $patron = $this->createMockPatron($event);
+ $user = $this->createMockUser($org1);
+
+ $this->assertFalse($this->policy->view($user, $patron));
+ }
+
+ public function testViewDeniedForNullUser(): void
+ {
+ $org = new Organisation();
+ $org->id = 1;
+
+ $event = $this->createMockEvent($org);
+ $patron = $this->createMockPatron($event);
+
+ $this->assertFalse($this->policy->view(null, $patron));
+ }
+
+ // --- edit tests ---
+
+ public function testEditAllowedForOrganisationUser(): void
+ {
+ $org = new Organisation();
+ $org->id = 1;
+
+ $event = $this->createMockEvent($org);
+ $patron = $this->createMockPatron($event);
+ $user = $this->createMockUser($org);
+
+ $this->assertTrue($this->policy->edit($user, $patron));
+ }
+
+ public function testEditAllowedForDeviceInOrganisation(): void
+ {
+ $org = new Organisation();
+ $org->id = 1;
+
+ $event = $this->createMockEvent($org);
+ $patron = $this->createMockPatron($event);
+ $device = $this->createMockDevice($org);
+
+ $this->assertTrue($this->policy->edit($device, $patron));
+ }
+
+ public function testEditDeniedForNonMemberUser(): void
+ {
+ $org1 = new Organisation();
+ $org1->id = 1;
+ $org2 = new Organisation();
+ $org2->id = 2;
+
+ $event = $this->createMockEvent($org2);
+ $patron = $this->createMockPatron($event);
+ $user = $this->createMockUser($org1);
+
+ $this->assertFalse($this->policy->edit($user, $patron));
+ }
+
+ public function testEditDeniedForNullUser(): void
+ {
+ $org = new Organisation();
+ $org->id = 1;
+
+ $event = $this->createMockEvent($org);
+ $patron = $this->createMockPatron($event);
+
+ $this->assertFalse($this->policy->edit(null, $patron));
+ }
+
+ // --- destroy tests ---
+
+ public function testDestroyAllowedForOrganisationUser(): void
+ {
+ $org = new Organisation();
+ $org->id = 1;
+
+ $event = $this->createMockEvent($org);
+ $patron = $this->createMockPatron($event);
+ $user = $this->createMockUser($org);
+
+ $this->assertTrue($this->policy->destroy($user, $patron));
+ }
+
+ /**
+ * Destroy is restricted: no device access allowed.
+ */
+ public function testDestroyDeniedForDevice(): void
+ {
+ $org = new Organisation();
+ $org->id = 1;
+
+ $event = $this->createMockEvent($org);
+ $patron = $this->createMockPatron($event);
+ $device = $this->createMockDevice($org);
+
+ $this->assertFalse($this->policy->destroy($device, $patron));
+ }
+
+ public function testDestroyDeniedForNonMemberUser(): void
+ {
+ $org1 = new Organisation();
+ $org1->id = 1;
+ $org2 = new Organisation();
+ $org2->id = 2;
+
+ $event = $this->createMockEvent($org2);
+ $patron = $this->createMockPatron($event);
+ $user = $this->createMockUser($org1);
+
+ $this->assertFalse($this->policy->destroy($user, $patron));
+ }
+
+ public function testDestroyDeniedForNullUser(): void
+ {
+ $org = new Organisation();
+ $org->id = 1;
+
+ $event = $this->createMockEvent($org);
+ $patron = $this->createMockPatron($event);
+
+ $this->assertFalse($this->policy->destroy(null, $patron));
+ }
+}
diff --git a/tests/Unit/TableModelTest.php b/tests/Unit/TableModelTest.php
new file mode 100644
index 0000000..7fecc6c
--- /dev/null
+++ b/tests/Unit/TableModelTest.php
@@ -0,0 +1,137 @@
+assertEquals('tables', $table->getTable());
+ }
+
+ /**
+ * Test that fillable attributes include table_number and name.
+ */
+ public function testFillableAttributes(): void
+ {
+ $table = new Table();
+ $fillable = $table->getFillable();
+
+ $this->assertContains('table_number', $fillable);
+ $this->assertContains('name', $fillable);
+ }
+
+ /**
+ * Test that fillable does NOT include event_id (set via relationship).
+ */
+ public function testEventIdNotFillable(): void
+ {
+ $table = new Table();
+ $fillable = $table->getFillable();
+
+ $this->assertNotContains('event_id', $fillable);
+ }
+
+ /**
+ * Test that table_number can be set directly.
+ */
+ public function testTableNumberAttribute(): void
+ {
+ $table = new Table();
+ $table->table_number = 5;
+
+ $this->assertEquals(5, $table->table_number);
+ }
+
+ /**
+ * Test that name can be set directly.
+ */
+ public function testNameAttribute(): void
+ {
+ $table = new Table();
+ $table->name = 'VIP Table';
+
+ $this->assertEquals('VIP Table', $table->name);
+ }
+
+ /**
+ * Test that the model uses SoftDeletes trait.
+ */
+ public function testUsesSoftDeletes(): void
+ {
+ $table = new Table();
+ $this->assertTrue(
+ method_exists($table, 'trashed'),
+ 'Table model should use SoftDeletes trait'
+ );
+ }
+
+ /**
+ * Test that the model defines the expected relationships.
+ */
+ public function testDefinesEventRelationship(): void
+ {
+ $table = new Table();
+ $this->assertTrue(
+ method_exists($table, 'event'),
+ 'Table model should define event() relationship'
+ );
+ }
+
+ /**
+ * Test that the model defines patrons relationship.
+ */
+ public function testDefinesPatronsRelationship(): void
+ {
+ $table = new Table();
+ $this->assertTrue(
+ method_exists($table, 'patrons'),
+ 'Table model should define patrons() relationship'
+ );
+ }
+
+ /**
+ * Test that the model defines orders relationship.
+ */
+ public function testDefinesOrdersRelationship(): void
+ {
+ $table = new Table();
+ $this->assertTrue(
+ method_exists($table, 'orders'),
+ 'Table model should define orders() relationship'
+ );
+ }
+
+ /**
+ * Test that getLatestPatron method exists.
+ */
+ public function testHasGetLatestPatronMethod(): void
+ {
+ $table = new Table();
+ $this->assertTrue(
+ method_exists($table, 'getLatestPatron'),
+ 'Table model should define getLatestPatron() method'
+ );
+ }
+
+ /**
+ * Test that bulkGenerate static method exists.
+ */
+ public function testHasBulkGenerateStaticMethod(): void
+ {
+ $this->assertTrue(
+ method_exists(Table::class, 'bulkGenerate'),
+ 'Table model should define static bulkGenerate() method'
+ );
+ }
+}
diff --git a/tests/Unit/TablePolicyTest.php b/tests/Unit/TablePolicyTest.php
new file mode 100644
index 0000000..5b7287a
--- /dev/null
+++ b/tests/Unit/TablePolicyTest.php
@@ -0,0 +1,331 @@
+policy = new TablePolicy();
+ }
+
+ /**
+ * Helper to create a mock Event that belongs to an Organisation.
+ */
+ private function createMockEvent(Organisation $organisation): Event
+ {
+ $event = $this->createMock(Event::class);
+ $event->method('__get')
+ ->willReturnMap([
+ ['organisation', $organisation],
+ ]);
+ return $event;
+ }
+
+ /**
+ * Helper to create a mock Table that belongs to an Event.
+ */
+ private function createMockTable(Event $event): Table
+ {
+ $table = $this->createMock(Table::class);
+ $table->method('__get')
+ ->willReturnMap([
+ ['event', $event],
+ ]);
+ return $table;
+ }
+
+ /**
+ * Helper to create a mock User in the given organisation.
+ */
+ private function createMockUser(Organisation $organisation): User
+ {
+ $user = $this->createMock(User::class);
+ $collection = new Collection([$organisation]);
+ $user->method('__get')
+ ->with('organisations')
+ ->willReturn($collection);
+ return $user;
+ }
+
+ /**
+ * Helper to create a mock Device in the given organisation.
+ */
+ private function createMockDevice(Organisation $organisation): Device
+ {
+ $device = $this->createMock(Device::class);
+ $device->method('__get')
+ ->willReturnMap([
+ ['organisation_id', $organisation->id],
+ ]);
+ return $device;
+ }
+
+ // --- index tests ---
+
+ public function testIndexAllowedForOrganisationUser(): void
+ {
+ $org = new Organisation();
+ $org->id = 1;
+
+ $event = $this->createMockEvent($org);
+ $user = $this->createMockUser($org);
+
+ $this->assertTrue($this->policy->index($user, $event));
+ }
+
+ public function testIndexAllowedForDeviceInOrganisation(): void
+ {
+ $org = new Organisation();
+ $org->id = 1;
+
+ $event = $this->createMockEvent($org);
+ $device = $this->createMockDevice($org);
+
+ $this->assertTrue($this->policy->index($device, $event));
+ }
+
+ public function testIndexDeniedForDeviceInDifferentOrganisation(): void
+ {
+ $org1 = new Organisation();
+ $org1->id = 1;
+ $org2 = new Organisation();
+ $org2->id = 2;
+
+ $event = $this->createMockEvent($org1);
+ $device = $this->createMockDevice($org2);
+
+ $this->assertFalse($this->policy->index($device, $event));
+ }
+
+ public function testIndexDeniedForNonMemberUser(): void
+ {
+ $org1 = new Organisation();
+ $org1->id = 1;
+ $org2 = new Organisation();
+ $org2->id = 2;
+
+ $event = $this->createMockEvent($org2);
+ $user = $this->createMockUser($org1);
+
+ $this->assertFalse($this->policy->index($user, $event));
+ }
+
+ public function testIndexDeniedForNullUser(): void
+ {
+ $org = new Organisation();
+ $org->id = 1;
+
+ $event = $this->createMockEvent($org);
+
+ $this->assertFalse($this->policy->index(null, $event));
+ }
+
+ // --- create tests ---
+
+ public function testCreateAllowedForOrganisationUser(): void
+ {
+ $org = new Organisation();
+ $org->id = 1;
+
+ $event = $this->createMockEvent($org);
+ $user = $this->createMockUser($org);
+
+ $this->assertTrue($this->policy->create($user, $event));
+ }
+
+ public function testCreateAllowedForDeviceInOrganisation(): void
+ {
+ $org = new Organisation();
+ $org->id = 1;
+
+ $event = $this->createMockEvent($org);
+ $device = $this->createMockDevice($org);
+
+ $this->assertTrue($this->policy->create($device, $event));
+ }
+
+ public function testCreateDeniedForNullUser(): void
+ {
+ $org = new Organisation();
+ $org->id = 1;
+
+ $event = $this->createMockEvent($org);
+
+ $this->assertFalse($this->policy->create(null, $event));
+ }
+
+ // --- view tests ---
+
+ public function testViewAllowedForOrganisationUser(): void
+ {
+ $org = new Organisation();
+ $org->id = 1;
+
+ $event = $this->createMockEvent($org);
+ $table = $this->createMockTable($event);
+ $user = $this->createMockUser($org);
+
+ $this->assertTrue($this->policy->view($user, $table));
+ }
+
+ public function testViewAllowedForDeviceInOrganisation(): void
+ {
+ $org = new Organisation();
+ $org->id = 1;
+
+ $event = $this->createMockEvent($org);
+ $table = $this->createMockTable($event);
+ $device = $this->createMockDevice($org);
+
+ $this->assertTrue($this->policy->view($device, $table));
+ }
+
+ public function testViewDeniedForNonMemberUser(): void
+ {
+ $org1 = new Organisation();
+ $org1->id = 1;
+ $org2 = new Organisation();
+ $org2->id = 2;
+
+ $event = $this->createMockEvent($org2);
+ $table = $this->createMockTable($event);
+ $user = $this->createMockUser($org1);
+
+ $this->assertFalse($this->policy->view($user, $table));
+ }
+
+ public function testViewDeniedForNullUser(): void
+ {
+ $org = new Organisation();
+ $org->id = 1;
+
+ $event = $this->createMockEvent($org);
+ $table = $this->createMockTable($event);
+
+ $this->assertFalse($this->policy->view(null, $table));
+ }
+
+ // --- edit tests ---
+
+ public function testEditAllowedForOrganisationUser(): void
+ {
+ $org = new Organisation();
+ $org->id = 1;
+
+ $event = $this->createMockEvent($org);
+ $table = $this->createMockTable($event);
+ $user = $this->createMockUser($org);
+
+ $this->assertTrue($this->policy->edit($user, $table));
+ }
+
+ public function testEditAllowedForDeviceInOrganisation(): void
+ {
+ $org = new Organisation();
+ $org->id = 1;
+
+ $event = $this->createMockEvent($org);
+ $table = $this->createMockTable($event);
+ $device = $this->createMockDevice($org);
+
+ $this->assertTrue($this->policy->edit($device, $table));
+ }
+
+ public function testEditDeniedForNonMemberUser(): void
+ {
+ $org1 = new Organisation();
+ $org1->id = 1;
+ $org2 = new Organisation();
+ $org2->id = 2;
+
+ $event = $this->createMockEvent($org2);
+ $table = $this->createMockTable($event);
+ $user = $this->createMockUser($org1);
+
+ $this->assertFalse($this->policy->edit($user, $table));
+ }
+
+ public function testEditDeniedForNullUser(): void
+ {
+ $org = new Organisation();
+ $org->id = 1;
+
+ $event = $this->createMockEvent($org);
+ $table = $this->createMockTable($event);
+
+ $this->assertFalse($this->policy->edit(null, $table));
+ }
+
+ // --- destroy tests ---
+
+ public function testDestroyAllowedForOrganisationUser(): void
+ {
+ $org = new Organisation();
+ $org->id = 1;
+
+ $event = $this->createMockEvent($org);
+ $table = $this->createMockTable($event);
+ $user = $this->createMockUser($org);
+
+ $this->assertTrue($this->policy->destroy($user, $table));
+ }
+
+ /**
+ * Destroy is restricted: no device access allowed.
+ */
+ public function testDestroyDeniedForDevice(): void
+ {
+ $org = new Organisation();
+ $org->id = 1;
+
+ $event = $this->createMockEvent($org);
+ $table = $this->createMockTable($event);
+ $device = $this->createMockDevice($org);
+
+ $this->assertFalse($this->policy->destroy($device, $table));
+ }
+
+ public function testDestroyDeniedForNonMemberUser(): void
+ {
+ $org1 = new Organisation();
+ $org1->id = 1;
+ $org2 = new Organisation();
+ $org2->id = 2;
+
+ $event = $this->createMockEvent($org2);
+ $table = $this->createMockTable($event);
+ $user = $this->createMockUser($org1);
+
+ $this->assertFalse($this->policy->destroy($user, $table));
+ }
+
+ public function testDestroyDeniedForNullUser(): void
+ {
+ $org = new Organisation();
+ $org->id = 1;
+
+ $event = $this->createMockEvent($org);
+ $table = $this->createMockTable($event);
+
+ $this->assertFalse($this->policy->destroy(null, $table));
+ }
+}
From c0e4c1f64f0d59027570d79d6a0b0135694c954c Mon Sep 17 00:00:00 2001
From: Thijs Van der Schaeghe
Date: Fri, 13 Mar 2026 17:06:09 +0100
Subject: [PATCH 07/14] Make tables sortable.
---
.../Shared/V1/ResourceDefinitions/TableResourceDefinition.php | 2 ++
1 file changed, 2 insertions(+)
diff --git a/app/Http/Shared/V1/ResourceDefinitions/TableResourceDefinition.php b/app/Http/Shared/V1/ResourceDefinitions/TableResourceDefinition.php
index a8874b3..e36bd3d 100644
--- a/app/Http/Shared/V1/ResourceDefinitions/TableResourceDefinition.php
+++ b/app/Http/Shared/V1/ResourceDefinitions/TableResourceDefinition.php
@@ -22,12 +22,14 @@ public function __construct()
$this->field('table_number')
->number()
->required()
+ ->sortable()
->visible(true)
->writeable(true, true);
$this->field('name')
->string()
->required()
+ ->sortable()
->visible(true)
->writeable(true, true);
}
From 7ba082b2cf91c91bb4e478407dc6bab31d97c49d Mon Sep 17 00:00:00 2001
From: Thijs Van der Schaeghe
Date: Fri, 13 Mar 2026 17:25:59 +0100
Subject: [PATCH 08/14] Make tables sortable.
---
CLAUDE.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/CLAUDE.md b/CLAUDE.md
index 17a0a9c..443b46b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -268,6 +268,7 @@ Call `cardService.initializeKeyManager(uid, id, secret)` and `cardService.loadPu
---
## Common Patterns
+- We use tabs, not spaces for indentation
- Vue components use Bootstrap-Vue (`b-*` components)
- Vue 3 compatibility mode via `@vue/compat`
- Shared components live in `resources/shared/js/`
From 17d156e487660dcbb3373dc46cf8007131e33822 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 13 Mar 2026 16:37:04 +0000
Subject: [PATCH 09/14] Address PR feedback: integrate table service into HQ,
add payment flow, settings toggle
1. Tables only manageable in manage app - removed standalone routes from POS
2. Settlement uses PaymentService flow - added $paymentService.orders() for batch payment
3. POS Settings: 'Allow table service' toggle, mutually exclusive with live/remote
4. Waiter dashboard integrated into Headquarters component
5. Patron detail shown as modal within Headquarters
6. New order form in patron modal for creating orders
Updated all tests to match new architecture.
Co-authored-by: daedeloth <1168599+daedeloth@users.noreply.github.com>
---
resources/pos/js/app.js | 21 -
resources/pos/js/views/Events.vue | 14 -
resources/pos/js/views/Headquarters.vue | 673 ++++++++++++++++--
resources/pos/js/views/Settings.vue | 22 +-
.../shared/js/services/PaymentService.js | 34 +
.../shared/js/services/SettingService.js | 8 +-
resources/tests/table-service-routes.test.js | 153 ++--
.../tests/table-service-services.test.js | 394 +++++-----
resources/tests/table-service-views.test.js | 156 ++--
9 files changed, 1068 insertions(+), 407 deletions(-)
diff --git a/resources/pos/js/app.js b/resources/pos/js/app.js
index 27db105..2706fba 100644
--- a/resources/pos/js/app.js
+++ b/resources/pos/js/app.js
@@ -52,9 +52,6 @@ import CheckIn from "../../shared/js/views/CheckIn";
import SalesSummaryNames from "../../shared/js/views/SalesSummaryNames";
import Menu from "./views/Menu.vue";
import Relax from "../../shared/js/components/Relax";
-import Tables from "../../shared/js/views/Tables";
-import WaiterDashboard from "../../shared/js/views/WaiterDashboard";
-import PatronDetail from "../../shared/js/views/PatronDetail";
import Authenticate from "./views/Authenticate";
@@ -179,24 +176,6 @@ async function launch() {
component: CheckIn
},
- {
- path: '/events/:id/tables',
- name: 'tables',
- component: Tables
- },
-
- {
- path: '/events/:id/waiter',
- name: 'waiter',
- component: WaiterDashboard
- },
-
- {
- path: '/events/:id/patron/:patronId',
- name: 'patron',
- component: PatronDetail
- },
-
{
path: '/cards',
name: 'cards',
diff --git a/resources/pos/js/views/Events.vue b/resources/pos/js/views/Events.vue
index 30f95e5..13c07fc 100644
--- a/resources/pos/js/views/Events.vue
+++ b/resources/pos/js/views/Events.vue
@@ -64,20 +64,6 @@
-
- 🍽️
- {{ $t('Waiter dashboard') }}
-
-
-
- 🪑
- {{ $t('Manage tables') }}
-
-
-
-
🛂
diff --git a/resources/pos/js/views/Headquarters.vue b/resources/pos/js/views/Headquarters.vue
index 079c396..58eafe2 100644
--- a/resources/pos/js/views/Headquarters.vue
+++ b/resources/pos/js/views/Headquarters.vue
@@ -21,90 +21,643 @@
-
+
-
-
- {{ $t('You have disabled both live and remote orders.') }}
- {{ $t('This terminal will not be able to process any orders.') }}
- {{ $t('Please enable either live or remote orders in the settings.') }}
-
-
+
+
+{{ $t('You have disabled both live and remote orders.') }}
+{{ $t('This terminal will not be able to process any orders.') }}
+{{ $t('Please enable either live or remote orders in the settings.') }}
+
+
-
-
+
+
+
-
+
-
+
-
+
-
+
-
-
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{{ $t('No Table') }}
+{{ $t('Unlinked patrons') }}
+
+
+
+
+
+
+{{ table.name }}
+#{{ table.table_number }}
+
+
+
+
+
+
+
+{{ selectedTableId === 'none' ? $t('Unlinked Patrons') : $t('Patrons at {table}', { table: selectedTableName }) }}
+
++ {{ $t('New Patron') }}
+
+
+
+
+
+
+
+
+
+
+{{ patron.name || ($t('Patron') + ' #' + patron.id) }}
+
+{{ $t('Unpaid') }}
+
+
+
+
+€{{ (patron.outstanding_balance).toFixed(2) }}
+
+
+
+
+
+{{ $t('No patrons at this table.') }}
+
+
+
+
+
+
+
+
+
+
+
+{{ $t('My orders only') }}
+
+
+{{ $t('Prepared only') }}
+
+
+
+
+
+
+
+
+
+
+{{ row.item.status }}
+
+
+
+
+
+{{ row.item.payment_status }}
+
+
+
+
+
+{{ $t('Prepared') }}
+
+
+{{ $t('Delivered') }}
+
+
+{{ $t('Void') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{{ $t('Outstanding Balance') }}
+€{{ selectedPatron.outstanding_balance.toFixed(2) }}
+
+
+
+{{ $t('Pay Outstanding Balance') }}
+
+
+
+
+
+
{{ $t('Orders') }}
+
+
+
+{{ item.amount }}× {{ item.name }},
+
+
+
+
+{{ row.item.status }}
+
+
+
+{{ row.item.payment_status }}
+
+
+
+€{{ row.item.price ? row.item.price.toFixed(2) : '0.00' }}
+
+
+
+
+{{ $t('No orders for this patron.') }}
+
+
+
+
+
+
{{ $t('New Order') }}
+
+
+
+
+
+
+
+{{item.name}}
+
+
+
+{{item.amount}}
+
+
+
+
+
{{ $t('Total: {amount} items = €{price}', { amount: patronOrderTotals.amount, price: patronOrderTotals.price.toFixed(2) }) }}
+
+
+
+
+{{ $t('Place Order') }}
+
+
+
+{{ patronOrderWarning }}
+
+
+
+
+
+
+
+👍
+{{ $t('Payment processed successfully.') }}
+
+
+
+
+
+
diff --git a/resources/pos/js/views/Settings.vue b/resources/pos/js/views/Settings.vue
index 2fd22ff..4cbf3ed 100644
--- a/resources/pos/js/views/Settings.vue
+++ b/resources/pos/js/views/Settings.vue
@@ -57,7 +57,7 @@
:description="$t('This terminal can process orders at the bar')"
>
@@ -67,11 +67,21 @@
:description="$t('This terminal can process orders from tables')"
>
+
+
+
+
@@ -286,7 +296,12 @@
},
watch: {
-
+ allowTableService(newVal) {
+ if (newVal) {
+ this.allowLiveOrders = false;
+ this.allowRemoteOrders = false;
+ }
+ }
},
methods: {
@@ -420,6 +435,7 @@
this.nfcPassword = this.settingService.nfcPassword;
this.allowLiveOrders = this.settingService.allowLiveOrders;
this.allowRemoteOrders = this.settingService.allowRemoteOrders;
+this.allowTableService = this.settingService.allowTableService;
}
}
diff --git a/resources/shared/js/services/PaymentService.js b/resources/shared/js/services/PaymentService.js
index 13fea98..6bf8cfc 100644
--- a/resources/shared/js/services/PaymentService.js
+++ b/resources/shared/js/services/PaymentService.js
@@ -140,6 +140,40 @@ export class PaymentService extends Eventable {
return paymentData;
}
+ /**
+ * Handle payment for multiple orders at once (batch settlement).
+ * Sums the total price of all orders and triggers a single payment transaction.
+ * @param {Array} orders - Array of order objects with price fields
+ * @param {boolean} acceptCurrentCard
+ * @returns {Promise