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 @@ + + + + @@ -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} paymentData + */ + async orders(orders, acceptCurrentCard = true) { + + // Do we have a payment method configured? If not, just leave orders unpaid. + if (!this.hasPaymentMethod()) { + orders.forEach(o => { o.paid = false; }); + return {}; + } + + // Calculate total price across all orders + const totalPrice = orders.reduce((sum, o) => sum + (o.price || 0), 0); + + // Create a synthetic order for the payment flow + const combinedOrder = { + uid: orders[0] ? orders[0].uid || orders[0].id : null, + price: totalPrice, + order: { items: [] } + }; + + // handle the actual payment + let paymentData = await this.handleOrder(combinedOrder, acceptCurrentCard); + + // mark all orders as paid + orders.forEach(o => { o.paid = true; }); + + return paymentData; + } + /** * Helper method to keep the order code more clear. * @param order diff --git a/resources/shared/js/services/SettingService.js b/resources/shared/js/services/SettingService.js index 46d62cd..ca69cc8 100644 --- a/resources/shared/js/services/SettingService.js +++ b/resources/shared/js/services/SettingService.js @@ -27,6 +27,7 @@ export class SettingService { allowLiveOrders = true; allowRemoteOrders = true; + allowTableService = false; constructor() { @@ -57,6 +58,10 @@ export class SettingService { this.allowRemoteOrders = false; } + if (typeof(settings.allowTableService) !== 'undefined') { + this.allowTableService = settings.allowTableService ? true : false; + } + resolve(); }.bind(this)); @@ -73,7 +78,8 @@ export class SettingService { nfcPassword: this.nfcPassword, allowLiveOrders: this.allowLiveOrders, - allowRemoteOrders: this.allowRemoteOrders + allowRemoteOrders: this.allowRemoteOrders, + allowTableService: this.allowTableService }, function() { resolve(); }); diff --git a/resources/tests/table-service-routes.test.js b/resources/tests/table-service-routes.test.js index e3a42d6..f8f1fdd 100644 --- a/resources/tests/table-service-routes.test.js +++ b/resources/tests/table-service-routes.test.js @@ -1,119 +1,108 @@ /** - * Tests to verify Table Service routes are correctly configured in POS and Manage apps. + * Tests to verify Table Service routes are correctly configured. * - * 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. + * - POS: Table service is integrated into Headquarters (not separate routes). + * POS MUST NOT have standalone waiter/patron/tables routes. + * - Manage app: MUST have tables, waiter, and patron routes for admin access. + * - 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'); +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"); - }); +describe('POS app table service integration', () => { +const source = readAppFile('pos'); - it('imports PatronDetail component', () => { - expect(source).toContain("import PatronDetail from"); - }); +it('does NOT import standalone Tables component', () => { +expect(source).not.toContain("import Tables from"); +}); - it('has tables route', () => { - expect(source).toContain("name: 'tables'"); - }); +it('does NOT import standalone WaiterDashboard component', () => { +expect(source).not.toContain("import WaiterDashboard from"); +}); - it('has waiter route', () => { - expect(source).toContain("name: 'waiter'"); - }); +it('does NOT import standalone PatronDetail component', () => { +expect(source).not.toContain("import PatronDetail from"); +}); - it('has patron route', () => { - expect(source).toContain("name: 'patron'"); - }); +it('does NOT have standalone tables route', () => { +expect(source).not.toContain("name: 'tables'"); +}); - it('has tables path', () => { - expect(source).toContain("/events/:id/tables"); - }); +it('does NOT have standalone waiter route', () => { +expect(source).not.toContain("name: 'waiter'"); +}); - it('has waiter path', () => { - expect(source).toContain("/events/:id/waiter"); - }); +it('does NOT have standalone patron route', () => { +expect(source).not.toContain("name: 'patron'"); +}); - it('has patron path', () => { - expect(source).toContain("/events/:id/patron/:patronId"); - }); +it('still has headquarters route where table service is integrated', () => { +expect(source).toContain("name: 'hq'"); +}); }); describe('Manage app table service routing', () => { - const source = readAppFile('manage'); +const source = readAppFile('manage'); - it('imports Tables component', () => { - expect(source).toContain("import Tables from"); - }); +it('imports Tables component', () => { +expect(source).toContain("import Tables from"); +}); - it('imports WaiterDashboard component', () => { - expect(source).toContain("import WaiterDashboard from"); - }); +it('imports WaiterDashboard component', () => { +expect(source).toContain("import WaiterDashboard from"); +}); - it('imports PatronDetail component', () => { - expect(source).toContain("import PatronDetail from"); - }); +it('imports PatronDetail component', () => { +expect(source).toContain("import PatronDetail from"); +}); - it('has tables route', () => { - expect(source).toContain("name: 'tables'"); - }); +it('has tables route', () => { +expect(source).toContain("name: 'tables'"); +}); - it('has waiter route', () => { - expect(source).toContain("name: 'waiter'"); - }); +it('has waiter route', () => { +expect(source).toContain("name: 'waiter'"); +}); - it('has patron route', () => { - expect(source).toContain("name: 'patron'"); - }); +it('has patron route', () => { +expect(source).toContain("name: 'patron'"); +}); - it('has tables path', () => { - expect(source).toContain("/events/:id/tables"); - }); +it('has tables path', () => { +expect(source).toContain("/events/:id/tables"); +}); - it('has waiter path', () => { - expect(source).toContain("/events/:id/waiter"); - }); +it('has waiter path', () => { +expect(source).toContain("/events/:id/waiter"); +}); - it('has patron path', () => { - expect(source).toContain("/events/:id/patron/:patronId"); - }); +it('has patron path', () => { +expect(source).toContain("/events/:id/patron/:patronId"); +}); }); describe('Client app does not reference table service', () => { - const source = readAppFile('clients'); +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 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 WaiterDashboard', () => { +expect(source).not.toContain("WaiterDashboard"); +}); - it('does not reference PatronDetail', () => { - expect(source).not.toContain("PatronDetail"); - }); +it('does not reference PatronDetail', () => { +expect(source).not.toContain("PatronDetail"); +}); - it('does not have waiter route', () => { - expect(source).not.toContain("name: 'waiter'"); - }); +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 index 2aebf59..6a21348 100644 --- a/resources/tests/table-service-services.test.js +++ b/resources/tests/table-service-services.test.js @@ -3,6 +3,8 @@ * * Verifies that: * - TableService and PatronService extend AbstractService correctly + * - PaymentService has batch orders() method + * - SettingService supports allowTableService setting * - Shared views (Tables, WaiterDashboard, PatronDetail) exist and have expected structure */ import { describe, it, expect } from 'vitest'; @@ -12,201 +14,239 @@ import { resolve } from 'path'; const sharedPath = resolve(__dirname, '..', 'shared', 'js'); function readFile(path) { - return readFileSync(path, 'utf-8'); +return readFileSync(path, 'utf-8'); } describe('TableService', () => { - const servicePath = resolve(sharedPath, 'services', 'TableService.js'); +const servicePath = resolve(sharedPath, 'services', 'TableService.js'); - it('file exists', () => { - expect(existsSync(servicePath)).toBe(true); - }); +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('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 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('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('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'"); - }); +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'); +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('PaymentService batch orders method', () => { +const servicePath = resolve(sharedPath, 'services', 'PaymentService.js'); + +it('file exists', () => { +expect(existsSync(servicePath)).toBe(true); +}); + +it('has orders() method for batch payment', () => { +const content = readFile(servicePath); +expect(content).toContain('async orders('); +}); + +it('orders() method calculates total price', () => { +const content = readFile(servicePath); +expect(content).toContain('totalPrice'); +expect(content).toContain('reduce'); +}); - it('file exists', () => { - expect(existsSync(servicePath)).toBe(true); - }); +it('orders() method creates combined order', () => { +const content = readFile(servicePath); +expect(content).toContain('combinedOrder'); +}); - it('extends AbstractService', () => { - const content = readFile(servicePath); - expect(content).toContain("import {AbstractService} from './AbstractService'"); - expect(content).toContain('extends AbstractService'); - }); +it('orders() handles no payment method case', () => { +const content = readFile(servicePath); +expect(content).toContain('hasPaymentMethod'); +}); +}); - it('sets indexUrl for patrons', () => { - const content = readFile(servicePath); - expect(content).toContain("'/patrons'"); - }); +describe('SettingService table service support', () => { +const servicePath = resolve(sharedPath, 'services', 'SettingService.js'); - it('sets entityUrl for patrons', () => { - const content = readFile(servicePath); - expect(content).toContain("this.entityUrl = 'patrons'"); - }); +it('has allowTableService default', () => { +const content = readFile(servicePath); +expect(content).toContain('allowTableService = false'); +}); - it('does not import unused jQuery', () => { - const content = readFile(servicePath); - expect(content).not.toContain("import $ from 'jquery'"); - }); +it('loads allowTableService from settings', () => { +const content = readFile(servicePath); +expect(content).toContain("settings.allowTableService"); +}); + +it('saves allowTableService to settings', () => { +const content = readFile(servicePath); +expect(content).toContain('allowTableService: this.allowTableService'); +}); }); 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()'); - }); +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 (still exists for manage app)', () => { +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'); +}); +}); + +describe('PatronDetail.vue shared view (still exists for manage app)', () => { +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('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 index 3c692a7..39876f5 100644 --- a/resources/tests/table-service-views.test.js +++ b/resources/tests/table-service-views.test.js @@ -1,80 +1,138 @@ /** - * Tests to verify Table Service links are correctly placed in Events views. + * Tests to verify Table Service links and settings 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 + * - POS Events: MUST NOT have standalone "Waiter dashboard" or "Manage tables" links + * (table service is now integrated in Headquarters) + * - Manage Events: MUST have "Manage tables" and "Waiter dashboard" links */ 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' - ); +return readFileSync( +resolve(__dirname, '..', appName, 'js', 'views', fileName), +'utf-8' +); } function readVueTemplate(appName) { - const content = readVueFile(appName, 'Events.vue'); - const startIdx = content.indexOf(''); - return (startIdx >= 0 && endIdx > startIdx) ? content.substring(startIdx, endIdx + ''.length) : ''; +const content = readVueFile(appName, 'Events.vue'); +const startIdx = content.indexOf(''); +return (startIdx >= 0 && endIdx > startIdx) ? content.substring(startIdx, endIdx + ''.length) : ''; } -describe('POS Events.vue table service links', () => { - const template = readVueTemplate('pos'); +describe('POS Events.vue table service links (removed - integrated in HQ)', () => { +const template = readVueTemplate('pos'); - it('has Waiter dashboard action link', () => { - expect(template).toContain('Waiter dashboard'); - }); +it('does NOT have Waiter dashboard link (integrated in HQ instead)', () => { +expect(template).not.toContain('Waiter dashboard'); +}); + +it('does NOT have Manage tables link (manage app only)', () => { +expect(template).not.toContain('Manage tables'); +}); + +it('does NOT link to waiter route', () => { +expect(template).not.toContain("name: 'waiter'"); +}); + +it('does NOT link to tables route', () => { +expect(template).not.toContain("name: 'tables'"); +}); +}); + +describe('POS Headquarters.vue table service integration', () => { +const content = readVueFile('pos', 'Headquarters.vue'); + +it('has table service mode flag', () => { +expect(content).toContain('showTableService'); +}); + +it('imports TableService', () => { +expect(content).toContain('TableService'); +}); + +it('imports PatronService', () => { +expect(content).toContain('PatronService'); +}); - it('has Manage tables action link', () => { - expect(template).toContain('Manage tables'); - }); +it('imports OrderService', () => { +expect(content).toContain('OrderService'); +}); - it('links to waiter route', () => { - expect(template).toContain("name: 'waiter'"); - }); +it('imports PaymentPopup component', () => { +expect(content).toContain('PaymentPopup'); +}); - it('links to tables route', () => { - expect(template).toContain("name: 'tables'"); - }); +it('has patron modal', () => { +expect(content).toContain('patronModal'); +}); + +it('has settle balance button', () => { +expect(content).toContain('settleBalance'); +}); + +it('has new order form in patron modal', () => { +expect(content).toContain('submitPatronOrder'); +}); + +it('has order queue with status actions', () => { +expect(content).toContain('markPrepared'); +expect(content).toContain('markDelivered'); +expect(content).toContain('markVoided'); +}); }); describe('Manage Events.vue table service links', () => { - const template = readVueTemplate('manage'); +const template = readVueTemplate('manage'); - it('has Manage tables action link', () => { - expect(template).toContain('Manage tables'); - }); +it('has Manage tables action link', () => { +expect(template).toContain('Manage tables'); +}); - it('has Waiter dashboard action link', () => { - expect(template).toContain('Waiter dashboard'); - }); +it('has Waiter dashboard action link', () => { +expect(template).toContain('Waiter dashboard'); +}); - it('links to tables route', () => { - expect(template).toContain("name: 'tables'"); - }); +it('links to tables route', () => { +expect(template).toContain("name: 'tables'"); +}); - it('links to waiter route', () => { - expect(template).toContain("name: 'waiter'"); - }); +it('links to waiter route', () => { +expect(template).toContain("name: 'waiter'"); +}); - it('has Table Service section header', () => { - expect(template).toContain('Table Service'); - }); +it('has Table Service section header', () => { +expect(template).toContain('Table Service'); +}); }); describe('Manage Events.vue table service settings', () => { - const content = readVueFile('manage', 'Events.vue'); +const content = readVueFile('manage', 'Events.vue'); - it('has allow_unpaid_table_orders checkbox', () => { - expect(content).toContain('allow_unpaid_table_orders'); - }); +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'); - }); +it('has label text for unpaid table orders setting', () => { +expect(content).toContain('Allow unpaid table orders'); +}); +}); + +describe('POS Settings.vue table service toggle', () => { +const content = readVueFile('pos', 'Settings.vue'); + +it('has allowTableService data property', () => { +expect(content).toContain('allowTableService'); +}); + +it('has table service checkbox', () => { +expect(content).toContain('Allow table service at this terminal'); +}); + +it('disables live orders when table service is active', () => { +expect(content).toContain(':disabled="allowTableService"'); +}); }); From b89e0b866b28bae67d5957e025829665a8317d9c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Mar 2026 16:38:33 +0000 Subject: [PATCH 10/14] Fix indentation: use tabs consistently per project conventions Co-authored-by: daedeloth <1168599+daedeloth@users.noreply.github.com> --- resources/pos/js/views/Settings.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/pos/js/views/Settings.vue b/resources/pos/js/views/Settings.vue index 4cbf3ed..ecef9ba 100644 --- a/resources/pos/js/views/Settings.vue +++ b/resources/pos/js/views/Settings.vue @@ -435,7 +435,7 @@ this.nfcPassword = this.settingService.nfcPassword; this.allowLiveOrders = this.settingService.allowLiveOrders; this.allowRemoteOrders = this.settingService.allowRemoteOrders; -this.allowTableService = this.settingService.allowTableService; + this.allowTableService = this.settingService.allowTableService; } } From 827de7621bc977e39b0afe98f084ac5555235740 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Mar 2026 16:44:32 +0000 Subject: [PATCH 11/14] Fix indentation to use tabs consistently, update documentation All files now use tab indentation per project conventions (CLAUDE.md). Updated .ai/table-service.md to reflect the new integrated architecture. Co-authored-by: daedeloth <1168599+daedeloth@users.noreply.github.com> --- .ai/table-service.md | 35 +- public/mix-manifest.json | 6 +- resources/pos/js/views/Headquarters.vue | 1268 ++++++++--------- resources/tests/table-service-routes.test.js | 144 +- .../tests/table-service-services.test.js | 394 ++--- resources/tests/table-service-views.test.js | 180 +-- 6 files changed, 1020 insertions(+), 1007 deletions(-) diff --git a/.ai/table-service.md b/.ai/table-service.md index 19c67d1..3caa663 100644 --- a/.ai/table-service.md +++ b/.ai/table-service.md @@ -177,18 +177,28 @@ Same as `TablePolicy` — devices can CRUD except destroy. `entityUrl = tables`. Has `bulkGenerate(count)` method. - **`PatronService`** — extends `AbstractService`, sets `indexUrl = events/{id}/patrons`, `entityUrl = patrons`. +- **`PaymentService`** — has `orders(orders)` batch payment method for settling multiple + unpaid orders in a single payment transaction. -### Views (all in `resources/shared/js/views/`) +### POS Device Settings -| 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| +- **`allowTableService`** — stored in `SettingService`, persisted in localStorage. + Mutually exclusive with `allowLiveOrders` and `allowRemoteOrders`. + When enabled, the POS Headquarters shows the waiter dashboard instead of the bar + live/remote orders interface. + +### Views + +| View | Location | Purpose | +|-----------------------|-----------------------------------|----------------------------------------------------------| +| `Tables.vue` | `shared/js/views/` | Table management: bulk generate, inline rename, delete (manage app only) | +| `WaiterDashboard.vue` | `shared/js/views/` | Standalone waiter dashboard (used by manage app) | +| `PatronDetail.vue` | `shared/js/views/` | Standalone patron detail (used by manage app) | +| `Headquarters.vue` | `pos/js/views/` | Integrated POS: bar mode OR waiter dashboard with patron modals | ### Routes -Both POS and Manage apps register these routes: +**Manage app** registers standalone routes: | Path | Name | Component | |---------------------------------|---------|------------------| @@ -196,10 +206,17 @@ Both POS and Manage apps register these routes: | `/events/:id/waiter` | waiter | WaiterDashboard | | `/events/:id/patron/:patronId` | patron | PatronDetail | +**POS app** integrates table service into the Headquarters component (no standalone routes). +When `allowTableService` is enabled in device settings, Headquarters shows: +- Table grid with patron list (click patron opens modal) +- Order queue with status/device filters +- Patron detail modal with order history, settle balance, and new order form + ### 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" +- **POS Events.vue**: Standard actions only (sales overview, order history, check-in). + Table service access is via Headquarters when enabled in settings. +- **Manage Events.vue**: "Manage tables" and "Waiter dashboard" links in "Table Service" dropdown group; `allow_unpaid_table_orders` checkbox in event edit modal --- diff --git a/public/mix-manifest.json b/public/mix-manifest.json index 8319280..3b7b3b9 100644 --- a/public/mix-manifest.json +++ b/public/mix-manifest.json @@ -8,9 +8,5 @@ "/css/app.css": "/css/app.css", "/res/clients/css/app.css": "/res/clients/css/app.css", "/res/pos.css": "/res/pos.css", - "/res/manage.css": "/res/manage.css", - "/res/clients/js/app.js.LICENSE.txt": "/res/clients/js/app.js.LICENSE.txt", - "/res/manage.js.LICENSE.txt": "/res/manage.js.LICENSE.txt", - "/res/pos.js.LICENSE.txt": "/res/pos.js.LICENSE.txt", - "/res/sales/js/qrGenerator.js.LICENSE.txt": "/res/sales/js/qrGenerator.js.LICENSE.txt" + "/res/manage.css": "/res/manage.css" } diff --git a/resources/pos/js/views/Headquarters.vue b/resources/pos/js/views/Headquarters.vue index 58eafe2..ade22a5 100644 --- a/resources/pos/js/views/Headquarters.vue +++ b/resources/pos/js/views/Headquarters.vue @@ -21,643 +21,643 @@ diff --git a/resources/tests/table-service-routes.test.js b/resources/tests/table-service-routes.test.js index f8f1fdd..ca88d40 100644 --- a/resources/tests/table-service-routes.test.js +++ b/resources/tests/table-service-routes.test.js @@ -1,108 +1,108 @@ /** - * Tests to verify Table Service routes are correctly configured. - * - * - POS: Table service is integrated into Headquarters (not separate routes). - * POS MUST NOT have standalone waiter/patron/tables routes. - * - Manage app: MUST have tables, waiter, and patron routes for admin access. - * - Client app: MUST NOT reference any table service routes. - */ +* Tests to verify Table Service routes are correctly configured. +* +* - POS: Table service is integrated into Headquarters (not separate routes). +* POS MUST NOT have standalone waiter/patron/tables routes. +* - Manage app: MUST have tables, waiter, and patron routes for admin access. +* - 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'); + return readFileSync(resolve(__dirname, '..', appName, 'js', 'app.js'), 'utf-8'); } describe('POS app table service integration', () => { -const source = readAppFile('pos'); + const source = readAppFile('pos'); -it('does NOT import standalone Tables component', () => { -expect(source).not.toContain("import Tables from"); -}); + it('does NOT import standalone Tables component', () => { + expect(source).not.toContain("import Tables from"); + }); -it('does NOT import standalone WaiterDashboard component', () => { -expect(source).not.toContain("import WaiterDashboard from"); -}); + it('does NOT import standalone WaiterDashboard component', () => { + expect(source).not.toContain("import WaiterDashboard from"); + }); -it('does NOT import standalone PatronDetail component', () => { -expect(source).not.toContain("import PatronDetail from"); -}); + it('does NOT import standalone PatronDetail component', () => { + expect(source).not.toContain("import PatronDetail from"); + }); -it('does NOT have standalone tables route', () => { -expect(source).not.toContain("name: 'tables'"); -}); + it('does NOT have standalone tables route', () => { + expect(source).not.toContain("name: 'tables'"); + }); -it('does NOT have standalone waiter route', () => { -expect(source).not.toContain("name: 'waiter'"); -}); + it('does NOT have standalone waiter route', () => { + expect(source).not.toContain("name: 'waiter'"); + }); -it('does NOT have standalone patron route', () => { -expect(source).not.toContain("name: 'patron'"); -}); + it('does NOT have standalone patron route', () => { + expect(source).not.toContain("name: 'patron'"); + }); -it('still has headquarters route where table service is integrated', () => { -expect(source).toContain("name: 'hq'"); -}); + it('still has headquarters route where table service is integrated', () => { + expect(source).toContain("name: 'hq'"); + }); }); describe('Manage app table service routing', () => { -const source = readAppFile('manage'); + const source = readAppFile('manage'); -it('imports Tables component', () => { -expect(source).toContain("import Tables from"); -}); + it('imports Tables component', () => { + expect(source).toContain("import Tables from"); + }); -it('imports WaiterDashboard component', () => { -expect(source).toContain("import WaiterDashboard from"); -}); + it('imports WaiterDashboard component', () => { + expect(source).toContain("import WaiterDashboard from"); + }); -it('imports PatronDetail component', () => { -expect(source).toContain("import PatronDetail from"); -}); + it('imports PatronDetail component', () => { + expect(source).toContain("import PatronDetail from"); + }); -it('has tables route', () => { -expect(source).toContain("name: 'tables'"); -}); + it('has tables route', () => { + expect(source).toContain("name: 'tables'"); + }); -it('has waiter route', () => { -expect(source).toContain("name: 'waiter'"); -}); + it('has waiter route', () => { + expect(source).toContain("name: 'waiter'"); + }); -it('has patron route', () => { -expect(source).toContain("name: 'patron'"); -}); + it('has patron route', () => { + expect(source).toContain("name: 'patron'"); + }); -it('has tables path', () => { -expect(source).toContain("/events/:id/tables"); -}); + it('has tables path', () => { + expect(source).toContain("/events/:id/tables"); + }); -it('has waiter path', () => { -expect(source).toContain("/events/:id/waiter"); -}); + it('has waiter path', () => { + expect(source).toContain("/events/:id/waiter"); + }); -it('has patron path', () => { -expect(source).toContain("/events/:id/patron/:patronId"); -}); + it('has patron path', () => { + expect(source).toContain("/events/:id/patron/:patronId"); + }); }); describe('Client app does not reference table service', () => { -const source = readAppFile('clients'); + 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 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 WaiterDashboard', () => { + expect(source).not.toContain("WaiterDashboard"); + }); -it('does not reference PatronDetail', () => { -expect(source).not.toContain("PatronDetail"); -}); + it('does not reference PatronDetail', () => { + expect(source).not.toContain("PatronDetail"); + }); -it('does not have waiter route', () => { -expect(source).not.toContain("name: 'waiter'"); -}); + 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 index 6a21348..194f54e 100644 --- a/resources/tests/table-service-services.test.js +++ b/resources/tests/table-service-services.test.js @@ -1,12 +1,12 @@ /** - * Tests for Table Service frontend services and shared views. - * - * Verifies that: - * - TableService and PatronService extend AbstractService correctly - * - PaymentService has batch orders() method - * - SettingService supports allowTableService setting - * - Shared views (Tables, WaiterDashboard, PatronDetail) exist and have expected structure - */ +* Tests for Table Service frontend services and shared views. +* +* Verifies that: +* - TableService and PatronService extend AbstractService correctly +* - PaymentService has batch orders() method +* - SettingService supports allowTableService setting +* - 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'; @@ -14,239 +14,239 @@ import { resolve } from 'path'; const sharedPath = resolve(__dirname, '..', 'shared', 'js'); function readFile(path) { -return readFileSync(path, 'utf-8'); + return readFileSync(path, 'utf-8'); } describe('TableService', () => { -const servicePath = resolve(sharedPath, 'services', 'TableService.js'); + const servicePath = resolve(sharedPath, 'services', 'TableService.js'); -it('file exists', () => { -expect(existsSync(servicePath)).toBe(true); -}); + 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('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 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('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('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'"); -}); + 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'); + const servicePath = resolve(sharedPath, 'services', 'PatronService.js'); -it('file exists', () => { -expect(existsSync(servicePath)).toBe(true); -}); + 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('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 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('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'"); -}); + it('does not import unused jQuery', () => { + const content = readFile(servicePath); + expect(content).not.toContain("import $ from 'jquery'"); + }); }); describe('PaymentService batch orders method', () => { -const servicePath = resolve(sharedPath, 'services', 'PaymentService.js'); + const servicePath = resolve(sharedPath, 'services', 'PaymentService.js'); -it('file exists', () => { -expect(existsSync(servicePath)).toBe(true); -}); + it('file exists', () => { + expect(existsSync(servicePath)).toBe(true); + }); -it('has orders() method for batch payment', () => { -const content = readFile(servicePath); -expect(content).toContain('async orders('); -}); + it('has orders() method for batch payment', () => { + const content = readFile(servicePath); + expect(content).toContain('async orders('); + }); -it('orders() method calculates total price', () => { -const content = readFile(servicePath); -expect(content).toContain('totalPrice'); -expect(content).toContain('reduce'); -}); + it('orders() method calculates total price', () => { + const content = readFile(servicePath); + expect(content).toContain('totalPrice'); + expect(content).toContain('reduce'); + }); -it('orders() method creates combined order', () => { -const content = readFile(servicePath); -expect(content).toContain('combinedOrder'); -}); + it('orders() method creates combined order', () => { + const content = readFile(servicePath); + expect(content).toContain('combinedOrder'); + }); -it('orders() handles no payment method case', () => { -const content = readFile(servicePath); -expect(content).toContain('hasPaymentMethod'); -}); + it('orders() handles no payment method case', () => { + const content = readFile(servicePath); + expect(content).toContain('hasPaymentMethod'); + }); }); describe('SettingService table service support', () => { -const servicePath = resolve(sharedPath, 'services', 'SettingService.js'); + const servicePath = resolve(sharedPath, 'services', 'SettingService.js'); -it('has allowTableService default', () => { -const content = readFile(servicePath); -expect(content).toContain('allowTableService = false'); -}); + it('has allowTableService default', () => { + const content = readFile(servicePath); + expect(content).toContain('allowTableService = false'); + }); -it('loads allowTableService from settings', () => { -const content = readFile(servicePath); -expect(content).toContain("settings.allowTableService"); -}); + it('loads allowTableService from settings', () => { + const content = readFile(servicePath); + expect(content).toContain("settings.allowTableService"); + }); -it('saves allowTableService to settings', () => { -const content = readFile(servicePath); -expect(content).toContain('allowTableService: this.allowTableService'); -}); + it('saves allowTableService to settings', () => { + const content = readFile(servicePath); + expect(content).toContain('allowTableService: this.allowTableService'); + }); }); describe('Tables.vue shared view', () => { -const viewPath = resolve(sharedPath, 'views', 'Tables.vue'); + const viewPath = resolve(sharedPath, 'views', 'Tables.vue'); -it('file exists', () => { -expect(existsSync(viewPath)).toBe(true); -}); + it('file exists', () => { + expect(existsSync(viewPath)).toBe(true); + }); -it('imports TableService', () => { -const content = readFile(viewPath); -expect(content).toContain('TableService'); -}); + 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 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 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('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'); -}); + it('displays table_number field', () => { + const content = readFile(viewPath); + expect(content).toContain('table_number'); + }); }); describe('WaiterDashboard.vue shared view (still exists for manage app)', () => { -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'); -}); + 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'); + }); }); describe('PatronDetail.vue shared view (still exists for manage app)', () => { -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('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()'); -}); + 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('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 index 39876f5..9acee88 100644 --- a/resources/tests/table-service-views.test.js +++ b/resources/tests/table-service-views.test.js @@ -1,138 +1,138 @@ /** - * Tests to verify Table Service links and settings in Events views. - * - * - POS Events: MUST NOT have standalone "Waiter dashboard" or "Manage tables" links - * (table service is now integrated in Headquarters) - * - Manage Events: MUST have "Manage tables" and "Waiter dashboard" links - */ +* Tests to verify Table Service links and settings in Events views. +* +* - POS Events: MUST NOT have standalone "Waiter dashboard" or "Manage tables" links +* (table service is now integrated in Headquarters) +* - Manage Events: MUST have "Manage tables" and "Waiter dashboard" links +*/ 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' -); + return readFileSync( + resolve(__dirname, '..', appName, 'js', 'views', fileName), + 'utf-8' + ); } function readVueTemplate(appName) { -const content = readVueFile(appName, 'Events.vue'); -const startIdx = content.indexOf(''); -return (startIdx >= 0 && endIdx > startIdx) ? content.substring(startIdx, endIdx + ''.length) : ''; + const content = readVueFile(appName, 'Events.vue'); + const startIdx = content.indexOf(''); + return (startIdx >= 0 && endIdx > startIdx) ? content.substring(startIdx, endIdx + ''.length) : ''; } describe('POS Events.vue table service links (removed - integrated in HQ)', () => { -const template = readVueTemplate('pos'); + const template = readVueTemplate('pos'); -it('does NOT have Waiter dashboard link (integrated in HQ instead)', () => { -expect(template).not.toContain('Waiter dashboard'); -}); + it('does NOT have Waiter dashboard link (integrated in HQ instead)', () => { + expect(template).not.toContain('Waiter dashboard'); + }); -it('does NOT have Manage tables link (manage app only)', () => { -expect(template).not.toContain('Manage tables'); -}); + it('does NOT have Manage tables link (manage app only)', () => { + expect(template).not.toContain('Manage tables'); + }); -it('does NOT link to waiter route', () => { -expect(template).not.toContain("name: 'waiter'"); -}); + it('does NOT link to waiter route', () => { + expect(template).not.toContain("name: 'waiter'"); + }); -it('does NOT link to tables route', () => { -expect(template).not.toContain("name: 'tables'"); -}); + it('does NOT link to tables route', () => { + expect(template).not.toContain("name: 'tables'"); + }); }); describe('POS Headquarters.vue table service integration', () => { -const content = readVueFile('pos', 'Headquarters.vue'); + const content = readVueFile('pos', 'Headquarters.vue'); -it('has table service mode flag', () => { -expect(content).toContain('showTableService'); -}); + it('has table service mode flag', () => { + expect(content).toContain('showTableService'); + }); -it('imports TableService', () => { -expect(content).toContain('TableService'); -}); + it('imports TableService', () => { + expect(content).toContain('TableService'); + }); -it('imports PatronService', () => { -expect(content).toContain('PatronService'); -}); + it('imports PatronService', () => { + expect(content).toContain('PatronService'); + }); -it('imports OrderService', () => { -expect(content).toContain('OrderService'); -}); + it('imports OrderService', () => { + expect(content).toContain('OrderService'); + }); -it('imports PaymentPopup component', () => { -expect(content).toContain('PaymentPopup'); -}); + it('imports PaymentPopup component', () => { + expect(content).toContain('PaymentPopup'); + }); -it('has patron modal', () => { -expect(content).toContain('patronModal'); -}); + it('has patron modal', () => { + expect(content).toContain('patronModal'); + }); -it('has settle balance button', () => { -expect(content).toContain('settleBalance'); -}); + it('has settle balance button', () => { + expect(content).toContain('settleBalance'); + }); -it('has new order form in patron modal', () => { -expect(content).toContain('submitPatronOrder'); -}); + it('has new order form in patron modal', () => { + expect(content).toContain('submitPatronOrder'); + }); -it('has order queue with status actions', () => { -expect(content).toContain('markPrepared'); -expect(content).toContain('markDelivered'); -expect(content).toContain('markVoided'); -}); + it('has order queue with status actions', () => { + expect(content).toContain('markPrepared'); + expect(content).toContain('markDelivered'); + expect(content).toContain('markVoided'); + }); }); describe('Manage Events.vue table service links', () => { -const template = readVueTemplate('manage'); + const template = readVueTemplate('manage'); -it('has Manage tables action link', () => { -expect(template).toContain('Manage tables'); -}); + it('has Manage tables action link', () => { + expect(template).toContain('Manage tables'); + }); -it('has Waiter dashboard action link', () => { -expect(template).toContain('Waiter dashboard'); -}); + it('has Waiter dashboard action link', () => { + expect(template).toContain('Waiter dashboard'); + }); -it('links to tables route', () => { -expect(template).toContain("name: 'tables'"); -}); + it('links to tables route', () => { + expect(template).toContain("name: 'tables'"); + }); -it('links to waiter route', () => { -expect(template).toContain("name: 'waiter'"); -}); + it('links to waiter route', () => { + expect(template).toContain("name: 'waiter'"); + }); -it('has Table Service section header', () => { -expect(template).toContain('Table Service'); -}); + it('has Table Service section header', () => { + expect(template).toContain('Table Service'); + }); }); describe('Manage Events.vue table service settings', () => { -const content = readVueFile('manage', 'Events.vue'); + const content = readVueFile('manage', 'Events.vue'); -it('has allow_unpaid_table_orders checkbox', () => { -expect(content).toContain('allow_unpaid_table_orders'); -}); + 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'); -}); + it('has label text for unpaid table orders setting', () => { + expect(content).toContain('Allow unpaid table orders'); + }); }); describe('POS Settings.vue table service toggle', () => { -const content = readVueFile('pos', 'Settings.vue'); + const content = readVueFile('pos', 'Settings.vue'); -it('has allowTableService data property', () => { -expect(content).toContain('allowTableService'); -}); + it('has allowTableService data property', () => { + expect(content).toContain('allowTableService'); + }); -it('has table service checkbox', () => { -expect(content).toContain('Allow table service at this terminal'); -}); + it('has table service checkbox', () => { + expect(content).toContain('Allow table service at this terminal'); + }); -it('disables live orders when table service is active', () => { -expect(content).toContain(':disabled="allowTableService"'); -}); + it('disables live orders when table service is active', () => { + expect(content).toContain(':disabled="allowTableService"'); + }); }); From f43ea42f21c14a3d7d081fe40465fb452cd7cb87 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Mar 2026 16:48:24 +0000 Subject: [PATCH 12/14] Revert unrelated mix-manifest.json changes Co-authored-by: daedeloth <1168599+daedeloth@users.noreply.github.com> --- public/mix-manifest.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/public/mix-manifest.json b/public/mix-manifest.json index 3b7b3b9..8319280 100644 --- a/public/mix-manifest.json +++ b/public/mix-manifest.json @@ -8,5 +8,9 @@ "/css/app.css": "/css/app.css", "/res/clients/css/app.css": "/res/clients/css/app.css", "/res/pos.css": "/res/pos.css", - "/res/manage.css": "/res/manage.css" + "/res/manage.css": "/res/manage.css", + "/res/clients/js/app.js.LICENSE.txt": "/res/clients/js/app.js.LICENSE.txt", + "/res/manage.js.LICENSE.txt": "/res/manage.js.LICENSE.txt", + "/res/pos.js.LICENSE.txt": "/res/pos.js.LICENSE.txt", + "/res/sales/js/qrGenerator.js.LICENSE.txt": "/res/sales/js/qrGenerator.js.LICENSE.txt" } From 8bcbf382adeb69c20f80439da650a664f7f84ab8 Mon Sep 17 00:00:00 2001 From: Thijs Van der Schaeghe Date: Fri, 13 Mar 2026 20:25:12 +0100 Subject: [PATCH 13/14] Fix a few small issues related to settings. --- resources/pos/js/views/Settings.vue | 31 ++++++++++++++++------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/resources/pos/js/views/Settings.vue b/resources/pos/js/views/Settings.vue index ecef9ba..8ade19b 100644 --- a/resources/pos/js/views/Settings.vue +++ b/resources/pos/js/views/Settings.vue @@ -34,7 +34,7 @@ - +
{{ $t('General settings') }} @@ -67,7 +67,7 @@ :description="$t('This terminal can process orders from tables')" > @@ -77,16 +77,16 @@ :description="$t('This terminal is used by a waiter for table service. Cannot be combined with live or remote orders.')" > - +

- +
{{ $t('Remote NFC reader') }}

{{ $t('Requires') }} {{ $t('an additional service') }}. @@ -118,7 +118,7 @@ > - +

{{ $t('Save') }} {{ $t('Reset') }} @@ -126,7 +126,7 @@
- +
{{ $t('License') }}
@@ -149,11 +149,11 @@ {{ $t('Visit the management portal to buy and activate a license for this device.') }}

- +

- +
{{ $t('Sync status') }}
@@ -194,18 +194,18 @@ {{ $t('Synchronization complete.') }} - +

- +
{{ $t('Device') }}

{{ $t('Disconnect this device from the server. You will need to re-pair it to use it again.') }}

🚪 {{ $t('Logout') }} - +
@@ -280,6 +280,7 @@ allowLiveOrders: false, allowRemoteOrders: false, + allowTableService: false, licenseStatus: null, @@ -408,6 +409,7 @@ this.settingService.allowLiveOrders = this.allowLiveOrders; this.settingService.allowRemoteOrders = this.allowRemoteOrders; + this.settingService.allowTableService = this.allowTableService; // Sync order settings to the server const posDeviceService = new PosDeviceService(); @@ -415,7 +417,8 @@ this.settingService.save(), posDeviceService.updateCurrentDevice({ allow_remote_orders: this.allowRemoteOrders, - allow_live_orders: this.allowLiveOrders + allow_live_orders: this.allowLiveOrders, + allow_table_service: this.allowTableService }) ]).then(function() { window.location.reload(); From 1da6174e762428728bae79e97160f09295ee2a02 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Mar 2026 22:27:44 +0000 Subject: [PATCH 14/14] Extract TableService component, reuse LiveSales, add Pay Later button 1. Patron selection & details in same modal: table click opens modal at patron list step, selecting patron transitions to details view, "Back to patron list" returns to selection. 2. Table service extracted to pos/js/components/TableService.vue. Headquarters delegates via component tag. 3. New order form reuses LiveSales component with patronId, tableId, and allowPayLater props. No more duplicated menu/order code. 4. Pay Later button added to PaymentPopup when allow_pay_later is set on PaymentService. PaymentService.payLater() resolves with paymentType 'pay-later'. LiveSales enables/resets the flag per order. Co-authored-by: daedeloth <1168599+daedeloth@users.noreply.github.com> --- .ai/table-service.md | 35 +- resources/pos/js/components/TableService.vue | 491 ++++++++++++++++ resources/pos/js/views/Headquarters.vue | 552 +----------------- resources/shared/js/components/LiveSales.vue | 62 +- .../shared/js/components/PaymentPopup.vue | 9 + .../shared/js/services/PaymentService.js | 19 + .../tests/table-service-services.test.js | 24 + resources/tests/table-service-views.test.js | 120 +++- 8 files changed, 729 insertions(+), 583 deletions(-) create mode 100644 resources/pos/js/components/TableService.vue diff --git a/.ai/table-service.md b/.ai/table-service.md index 3caa663..a1718e1 100644 --- a/.ai/table-service.md +++ b/.ai/table-service.md @@ -178,7 +178,8 @@ Same as `TablePolicy` — devices can CRUD except destroy. - **`PatronService`** — extends `AbstractService`, sets `indexUrl = events/{id}/patrons`, `entityUrl = patrons`. - **`PaymentService`** — has `orders(orders)` batch payment method for settling multiple - unpaid orders in a single payment transaction. + unpaid orders in a single payment transaction. Has `payLater()` method and + `allow_pay_later` flag for deferred payment. ### POS Device Settings @@ -187,6 +188,14 @@ Same as `TablePolicy` — devices can CRUD except destroy. When enabled, the POS Headquarters shows the waiter dashboard instead of the bar live/remote orders interface. +### Component Architecture + +| Component | Location | Purpose | +|-----------------------|-----------------------------------|----------------------------------------------------------| +| `TableService.vue` | `pos/js/components/` | Isolated table service component: table grid, patron modal (selection + details), order queue | +| `LiveSales.vue` | `shared/js/components/` | Menu + order form. Accepts optional `patronId`, `tableId`, `allowPayLater` props for table service context | +| `PaymentPopup.vue` | `shared/js/components/` | Payment modal. Shows "Pay later" button when `allow_pay_later` is set on PaymentService | + ### Views | View | Location | Purpose | @@ -194,7 +203,14 @@ Same as `TablePolicy` — devices can CRUD except destroy. | `Tables.vue` | `shared/js/views/` | Table management: bulk generate, inline rename, delete (manage app only) | | `WaiterDashboard.vue` | `shared/js/views/` | Standalone waiter dashboard (used by manage app) | | `PatronDetail.vue` | `shared/js/views/` | Standalone patron detail (used by manage app) | -| `Headquarters.vue` | `pos/js/views/` | Integrated POS: bar mode OR waiter dashboard with patron modals | +| `Headquarters.vue` | `pos/js/views/` | Thin orchestrator: bar mode OR `` component | + +### Modal Flow (POS) + +1. Click table card → modal opens at patron selection step +2. Select patron or click "New Patron" → modal transitions to patron details +3. Patron details show: outstanding balance, order history, settle button, and LiveSales new order form +4. "Back to patron list" button returns to step 2 ### Routes @@ -206,11 +222,7 @@ Same as `TablePolicy` — devices can CRUD except destroy. | `/events/:id/waiter` | waiter | WaiterDashboard | | `/events/:id/patron/:patronId` | patron | PatronDetail | -**POS app** integrates table service into the Headquarters component (no standalone routes). -When `allowTableService` is enabled in device settings, Headquarters shows: -- Table grid with patron list (click patron opens modal) -- Order queue with status/device filters -- Patron detail modal with order history, settle balance, and new order form +**POS app** integrates table service into the Headquarters component via `TableService.vue` (no standalone routes). ### Navigation @@ -219,6 +231,15 @@ When `allowTableService` is enabled in device settings, Headquarters shows: - **Manage Events.vue**: "Manage tables" and "Waiter dashboard" links in "Table Service" dropdown group; `allow_unpaid_table_orders` checkbox in event edit modal +### Pay Later Flow + +When `event.allow_unpaid_table_orders` is true: +1. LiveSales sets `allow_pay_later = true` on PaymentService before triggering payment +2. PaymentPopup shows a "Pay later" button alongside cash/card/voucher options +3. Clicking "Pay later" resolves with `paymentType: 'pay-later'` +4. LiveSales sets `payment_status: 'unpaid'` on the order +5. `allow_pay_later` is reset to `false` after each order + --- ## Event Settings diff --git a/resources/pos/js/components/TableService.vue b/resources/pos/js/components/TableService.vue new file mode 100644 index 0000000..1bc9eb5 --- /dev/null +++ b/resources/pos/js/components/TableService.vue @@ -0,0 +1,491 @@ + + + + + diff --git a/resources/pos/js/views/Headquarters.vue b/resources/pos/js/views/Headquarters.vue index ade22a5..f9d9dff 100644 --- a/resources/pos/js/views/Headquarters.vue +++ b/resources/pos/js/views/Headquarters.vue @@ -46,234 +46,8 @@ - -
- - - - -
- -
- -
- - - - -
{{ $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') }} - - - -
- -
- - - - - - - - -
-
-
-
- - - -
- - - -
{{ $t('Outstanding Balance') }}
- €{{ selectedPatron.outstanding_balance.toFixed(2) }} -
- - - {{ $t('Pay Outstanding Balance') }} - - -
-
- -

{{ $t('Orders') }}

- - - - - - - - - - - - {{ $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.') }}

-
+ + @@ -285,16 +59,11 @@