diff --git a/.ai/table-service.md b/.ai/table-service.md new file mode 100644 index 00000000..a1718e13 --- /dev/null +++ b/.ai/table-service.md @@ -0,0 +1,260 @@ +# Table Service Integration + +## Overview + +The Table Service feature enables waiter-operated table ordering in the POS application. +Waiters can take orders at tables, track patrons, manage fulfillment/payment states +independently, and settle tabs. + +--- + +## Database Schema + +### `tables` Table + +| Column | Type | Notes | +|--------------|----------|----------------------------------------| +| id | int (PK) | Auto-increment | +| event_id | int (FK) | References `events.id` | +| table_number | int | Unique within event (non-soft-deleted) | +| name | string | Display name, e.g., "Table 1" | +| created_at | datetime | | +| updated_at | datetime | | +| deleted_at | datetime | Soft delete support | + +**Constraints:** `UNIQUE(event_id, table_number)` + +### `patrons` Table + +| Column | Type | Notes | +|------------|----------|----------------------------| +| id | int (PK) | Auto-increment | +| event_id | int (FK) | References `events.id` | +| name | string | Nullable (anonymous patrons)| +| table_id | int (FK) | Nullable, references `tables.id` | +| created_at | datetime | | +| updated_at | datetime | | + +### `orders` Table (New Columns) + +| Column | Type | Notes | +|----------------|----------|----------------------------------------| +| patron_id | int (FK) | Nullable, references `patrons.id` | +| table_id | int (FK) | Nullable, references `tables.id` | +| payment_status | string | `'unpaid'`, `'paid'`, `'voided'`; default `'paid'` | + +### `events` Table (New Columns) + +| Column | Type | Notes | +|---------------------------|------|-------------------------------------| +| allow_unpaid_table_orders | bool | Default `false`; allows waiters to open tabs | + +--- + +## Models + +### `Table` (`App\Models\Table`) + +- Uses `SoftDeletes` and `HasFactory` traits +- **Relationships:** `event()`, `patrons()`, `orders()` +- **Methods:** + - `getLatestPatron()` — returns the most recently created patron at this table + - `bulkGenerate(Event $event, int $count)` — static; creates `$count` tables starting + from the highest existing `table_number + 1`, named "Table N" + +### `Patron` (`App\Models\Patron`) + +- **Relationships:** `event()`, `table()`, `orders()` +- **Methods:** + - `getOutstandingBalance()` — sum of prices of all unpaid orders + - `hasUnpaidOrders()` — boolean check for any unpaid orders + +### `Order` (Updated) + +New status constants: +```php +Order::STATUS_PREPARED = 'prepared' +Order::STATUS_DELIVERED = 'delivered' + +Order::PAYMENT_STATUS_UNPAID = 'unpaid' +Order::PAYMENT_STATUS_PAID = 'paid' +Order::PAYMENT_STATUS_VOIDED = 'voided' +``` + +New relationships: `patron()`, `table()` + +--- + +## Patron Assignment Algorithm + +`PatronAssignmentService` resolves which patron should own an incoming order: + +### 1. Named Orders (e.g., Quiz App) + +``` +If name is provided: + → Search for existing patron with that name who has orders within last 24 hours + → If found: reuse that patron + → If not found: create a new patron with the name +``` + +### 2. Anonymous Orders (e.g., Table QR Scan) + +``` +If table is provided (no name): + → Get the last patron assigned to this table + → If that patron has unpaid orders: reuse them + → If all orders are paid: create a new patron for the table +``` + +### 3. No Context + +``` +If neither name nor table: return null (no patron assignment) +``` + +### Auto-create Tables + +`findOrCreateTable(Event $event, int $tableNumber)` finds an existing non-soft-deleted table +or creates one. Used when remote orders arrive referencing unknown table numbers. + +--- + +## API Endpoints + +### Table Endpoints + +| Method | Path | Action | Auth | +|--------|-----------------------------------------|------------------|-----------------| +| GET | `/events/{id}/tables` | List tables | Both APIs | +| POST | `/events/{id}/tables` | Create table | Both APIs | +| POST | `/events/{id}/tables/generate` | Bulk generate | Both APIs | +| GET | `/tables/{id}` | View table | Both APIs | +| PUT | `/tables/{id}` | Edit table | Both APIs | +| DELETE | `/tables/{id}` | Soft-delete table | Management only | + +### Patron Endpoints + +| Method | Path | Action | Auth | +|--------|-----------------------------------------|------------------|-----------------| +| GET | `/events/{id}/patrons` | List patrons | Both APIs | +| POST | `/events/{id}/patrons` | Create patron | Both APIs | +| GET | `/patrons/{id}` | View patron | Both APIs | +| PUT | `/patrons/{id}` | Edit patron | Both APIs | + +### Updated Order Fields + +`OrderResourceDefinition` now exposes: +- `payment_status` — filterable, writeable +- `patron_id` — filterable, writeable +- `table_id` — filterable, writeable + +--- + +## Authorization Policies + +### `TablePolicy` + +| Action | User (in org) | Device (in org) | Other/Null | +|----------|:-------------:|:---------------:|:----------:| +| index | ✅ | ✅ | ❌ | +| create | ✅ | ✅ | ❌ | +| view | ✅ | ✅ | ❌ | +| edit | ✅ | ✅ | ❌ | +| destroy | ✅ | ❌ | ❌ | + +### `PatronPolicy` + +Same as `TablePolicy` — devices can CRUD except destroy. + +--- + +## Frontend + +### Services + +- **`TableService`** — extends `AbstractService`, sets `indexUrl = events/{id}/tables`, + `entityUrl = tables`. Has `bulkGenerate(count)` method. +- **`PatronService`** — extends `AbstractService`, sets `indexUrl = events/{id}/patrons`, + `entityUrl = patrons`. +- **`PaymentService`** — has `orders(orders)` batch payment method for settling multiple + unpaid orders in a single payment transaction. Has `payLater()` method and + `allow_pay_later` flag for deferred payment. + +### POS Device Settings + +- **`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. + +### 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 | +|-----------------------|-----------------------------------|----------------------------------------------------------| +| `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/` | 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 + +**Manage app** registers standalone routes: + +| Path | Name | Component | +|---------------------------------|---------|------------------| +| `/events/:id/tables` | tables | Tables | +| `/events/:id/waiter` | waiter | WaiterDashboard | +| `/events/:id/patron/:patronId` | patron | PatronDetail | + +**POS app** integrates table service into the Headquarters component via `TableService.vue` (no standalone routes). + +### Navigation + +- **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 + +### 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 + +| Setting | Type | Default | Description | +|------------------------------|------|---------|------------------------------------------| +| `allow_unpaid_table_orders` | bool | false | When true, waiters can "Pay Later" on orders, leaving payment_status as 'unpaid' while fulfillment continues | + +--- + +## Offline Considerations + +The table service must work offline. Key design decisions: +- `OrderService` extends `AbstractOfflineQueue` which stores orders in IndexedDB +- Table and patron data is cached via the `ApiCacheService` interceptors +- Waiters can create orders offline; they sync when connectivity returns +- Bar preparation status won't update offline, but the waiter can manually mark + orders as delivered diff --git a/CLAUDE.md b/CLAUDE.md index 17a0a9c7..443b46b7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -268,6 +268,7 @@ Call `cardService.initializeKeyManager(uid, id, secret)` and `cardService.loadPu --- ## Common Patterns +- We use tabs, not spaces for indentation - Vue components use Bootstrap-Vue (`b-*` components) - Vue 3 compatibility mode via `@vue/compat` - Shared components live in `resources/shared/js/` diff --git a/app/Http/DeviceApi/V1/Controllers/PatronController.php b/app/Http/DeviceApi/V1/Controllers/PatronController.php new file mode 100644 index 00000000..704c648f --- /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 16d738b9..13a29d4f 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 00000000..f279b793 --- /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 30269068..46479bb9 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 00000000..f966ef06 --- /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 00000000..581a5c4c --- /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 90fc1460..38056e24 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 00000000..c5c10d0c --- /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 00000000..e36bd3d4 --- /dev/null +++ b/app/Http/Shared/V1/ResourceDefinitions/TableResourceDefinition.php @@ -0,0 +1,36 @@ +identifier('id') + ->int(); + + $this->field('table_number') + ->number() + ->required() + ->sortable() + ->visible(true) + ->writeable(true, true); + + $this->field('name') + ->string() + ->required() + ->sortable() + ->visible(true) + ->writeable(true, true); + } +} diff --git a/app/Models/Event.php b/app/Models/Event.php index 4386ce00..4a923702 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 55d68cd5..0c93a189 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 00000000..afb5bc7f --- /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 00000000..8599e141 --- /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 00000000..7e1c2b4f --- /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 00000000..91675f54 --- /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 a4d7e23c..de93ce9f 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 00000000..747de5d1 --- /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 00000000..3f9bcd6d --- /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 00000000..2b6c6fcb --- /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 00000000..f5867186 --- /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 00000000..0ed21f80 --- /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'); + }); + } +}; diff --git a/resources/manage/js/app.js b/resources/manage/js/app.js index c223767f..47409eb5 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 6d55c38f..61165f7e 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 @@ + + + +