# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project

**NXT-ePMS** — Web-based Hotel Property Management System for the Philippine market.
Current property: **Parkview Hotel CDO** (Cagayan de Oro). Property name is stored in `settings` and rendered dynamically in the sidebar.

Stack: Laravel 11 · Tailwind CSS v4 · Alpine.js v3 (CDN) · MySQL · Vite 8

## Commands

**Note:** On some machines the system `php` resolves to an old version (e.g. 7.4) and will fail; on the current device it already resolves to 8.3.30, so bare `php` works. To stay consistent and portable, prefer the Laragon 8.3 binary:

```powershell
$PHP = "C:\laragon\bin\php\php-8.3.30-Win32-vs16-x64\php.exe"
```

| Task | Command |
|---|---|
| Dev server | `& $PHP artisan serve --port=8000` |
| Asset build (watch) | `npm run dev` — use cmd.exe or run `Set-ExecutionPolicy RemoteSigned` first in PowerShell |
| Asset build (prod) | `npm run build` |
| Run migrations | `& $PHP artisan migrate` |
| Fresh migrate + seed all | `& $PHP artisan migrate:fresh --seed` |
| Seed one class | `& $PHP artisan db:seed --class=RoomSeeder` |
| Clear route/cache | `& $PHP artisan route:clear; & $PHP artisan cache:clear` |
| Bulk DB updates | Write a temp script bootstrapping Laravel (`require 'vendor/autoload.php'` then `$app->make(Kernel::class)->bootstrap()`), run with `& $PHP script.php`, then delete it — tinker rejects multi-line inline scripts on Windows |

**Route 404 after adding a new route:** `artisan serve` caches routes in-process. Kill all PHP processes and restart: `& $PHP -S 127.0.0.1:8000 -t public`.

## Testing

PHPUnit (`& $PHP artisan test`, or `composer test`). Config in `phpunit.xml`: tests run on **SQLite `:memory:`** (`DB_CONNECTION=sqlite`), `MAIL_MAILER=array`, `QUEUE_CONNECTION=sync`, `BCRYPT_ROUNDS=4` — the real MySQL DB is never touched. All migrations run cleanly on SQLite (verified). Test classes that hit the DB use `RefreshDatabase`.

- **Base `tests/TestCase.php`** provides two helpers: `userWithRole(string $slug, array $attrs = [])` (seeds `RoleSeeder` on first use, returns an active `User` bound to that system role) and `openShift(User $user)` (creates an `open` shift so the `shift.active` middleware lets a non-admin's write requests through — super_admin bypasses it via `isAdmin()`).
- **Factories** (`database/factories/`): `UserFactory` (built-in), plus `RoomFactory`, `ReservationFactory` (has `forRoom(Room)` + `status()` states), `RatePlanFactory` (`percentOff()`/`amountOff()`/`fixed()` states). Their models (`Room`, `Reservation`, `RatePlan`) carry the `HasFactory` trait.
- **Coverage** (120 tests): Unit — `WebsiteContentLocaleTest` (locale fallback), `ReservationTest` (booking-no sequence, `balanceDue`/folio), `RatePlanTest` (`rateFor`), `UserPermissionTest` (per-role RBAC matrix). Feature — `AuthTest`, `RbacTest`, `ReservationLifecycleTest` (check-in/out side-effects, balance-blocked checkout, delete guard), `ReservationCreateTest` (single create, date-conflict rejection, multi-room group booking), `ReservationStatusTest` (confirm/no-show/cancel-frees-room/undo-checkin/undo-checkout/restore transitions), `WebsiteTranslateTest`, `DatabaseBackupTest`, `BookingConfirmationEmailTest`, `OnlineBookingTest` (results availability, session hold, promo apply/reject, min-advance validation, pay-on-arrival creates a pending Online reservation, double-booking blocked at payment), `PosTest` (direct sale vs charge-to-room→folio, line-item totals, shift-gated writes), `OperationsTest` (housekeeping status marks + shift gate + **RBAC denial**, maintenance create/resolve/reopen with room side-effects + RBAC, room transfer swap + 422 guards + RBAC), `FrontDeskTest` (check-in/out/room-rack queues + calendar render), `RbacActionsTest` (write-endpoint permission gates deny the wrong role + permitted-role control), `PublicPagesTest` (all public pages render on an empty content store; room-detail; live vs draft news 404), `ReportsTest` (25 cases — RBAC + correctness for all 15 reports, incl. senior/PWD booking math, VAT-exempt segregation, OR generation, deposit-as-payment).
- **Adversarially reviewed** (multi-agent) for false-greens + weak assertions, then strengthened: the translate test now seeds an English base and asserts the `fil` row is actually written + English is untouched; the balance-blocked checkout asserts the folio redirect target + `error` flash (not just unchanged state); the inactive-login test asserts the exact deactivation message; `nextBookingNo()` uses `now()` (freezable) with `Carbon::setTestNow` in the tests, and the increment test seeds two out-of-order rows so the max-sequence `ORDER BY` is genuinely exercised (mutation-verified: DESC→ASC fails the test).
- **Two latent bugs found by the new tests (fixed):** `PosController::store()` used `$item['product_id'] ?: null`, which fatals on an ad-hoc line item that omits the key (now `?? null`); and the `public.*` View composer was registered inside a boot-time `Schema::hasTable` guard, so it silently didn't register when the app boots before the table exists (e.g. a fresh test DB) — the table check now lives *inside* the composer, so `$bookingEnabled`/`$sitePromo`/`$siteContact` are always shared.
- **Server-side RBAC on write endpoints (now comprehensive):** every state-changing endpoint enforces its permission via `abort_unless(auth()->user()->hasPermission('<key>'), 403)` at the top of the controller action — **not** route `can:` middleware. Why not `can:`: the per-permission Gates are registered at boot inside a `Schema::hasTable('permissions')` guard, so in any process that boots before the table exists (a fresh test DB migrated in `setUp`) they never register and every non-admin `can:`/`@can` returns false; `hasPermission()` reads `role.permissions` directly and is correct everywhere. Mappings: rooms `rooms.create`/`rooms.edit`; reservations `reservations.create` (store), `.edit` (update/confirm/destroy/restoreTrashed/extend/transfer/reminders), `.check_in` (check-in + undo), `.check_out` (check-out + undo), `.cancel` (cancel/no-show/restore), `.payments` (payments + folio charges); POS `pos.transactions` (sale) / `pos.products` (product CRUD); guests `guests.manage`; rate plans `rate_plans.manage`; channels `channel_manager.manage`; settings `settings.edit`; housekeeping `housekeeping.update`; maintenance `maintenance.create`/`maintenance.resolve`; staff/roles/website/reports/backups already gated. The gates align with the sidebar `@can` gating, so no role that lacks a nav item is newly blocked. `RbacActionsTest` verifies denials + a permitted-role control.
- **Gaps / next**: nothing critical — remaining ungated endpoints are read-only (dashboard, calendar, front-desk queues) or intentionally public (newsletter/inquiry submit).

## Database

MySQL. `.env` has `DB_CONNECTION=mysql`, `DB_HOST=127.0.0.1`, `DB_PORT=3306`, `DB_DATABASE=pvh_pms`, `DB_USERNAME=root`, `DB_PASSWORD=` (empty — Laragon default). Laragon's MySQL must be running before `artisan serve`.

### Tables & seeders

| Table | Seeder | Notes |
|---|---|---|
| `rooms` | `RoomSeeder` | 63 rooms across 4 floors; does `DELETE FROM rooms` (sqlite_sequence reset is guarded by `DB::getDriverName() === 'sqlite'`) — safe to re-run |
| `settings` | `SettingSeeder` | 22 key-value pairs; uses `updateOrCreate` — safe to re-run |
| `reservations` | `ReservationSeeder` | 72 reservations (Jun–Sep 2026); truncates then reinserts. Also clears `reservation_payments` at top and seeds payment records at end for all `deposit_paid = true` rows. Syncs room statuses at end: checked_in → occupied, Jun 13–14 checkouts → dirty. Has `deleted_at` (soft delete). |
| `reservation_payments` | _(seeded by ReservationSeeder)_ | One row per payment transaction. Cleared and rebuilt whenever `ReservationSeeder` runs. Has `deleted_at` (soft delete). |
| `maintenance_requests` | `MaintenanceSeeder` | 5 sample work orders; truncates then reinserts (sqlite_sequence reset guarded) |
| `rate_plans` | `RatePlanSeeder` | Sample plans: ARP (10% off), CORP (fixed rates), GRP (15% off), LONG (₱100 off); truncates then reinserts. Has `deleted_at` (soft delete). |
| `rate_plan_rates` | _(seeded by RatePlanSeeder)_ | Per-room-type fixed rates for `fixed`-type plans |
| `channel_configs` | `ChannelConfigSeeder` | 9 channels mapping source → rate plan; truncates then reinserts |
| `website_contents` | `WebsiteContentSeeder` | Key-value content store keyed by `section`+`key`; uses `updateOrCreate` — safe to re-run. Sections: `homepage`, `about`, `contact`, `booking`, `seo` |
| `room_photos` | _(manual upload)_ | `room_no` nullable — NULL = gallery photo, non-null = room-specific. Path relative to `storage/app/public/` |
| `roles` | `RoleSeeder` | 6 system roles; uses `updateOrCreate` on `slug` — safe to re-run |
| `permissions` | _(seeded by RoleSeeder)_ | 42 permission keys grouped by module; uses `updateOrCreate` on `key` |
| `role_permission` | _(seeded by RoleSeeder)_ | Pivot synced per role on each run |
| `users` | `StaffSeeder` | 5 staff members (one per role); uses `updateOrCreate` on `email` — safe to re-run |
| `password_reset_tokens` | _(built-in Laravel)_ | Managed by `Password` broker; tokens expire in 60 min |
| `reservation_groups` | _(no seeder)_ | One row per group booking. Columns: `group_no` (`GRP-YYYY-NNNNNN`), `guest_name`, `guest_phone`, `guest_email`, `check_in`, `check_out`, `notes`. Has `deleted_at` (soft delete). |
| `guest_reminders` | _(no seeder)_ | Per-reservation reminders. Columns: `reservation_id`, `remind_at` (datetime), `type`, `notes`, `status` (`pending`/`done`), `created_by` (user FK nullable), `completed_at` |

`DatabaseSeeder` call order: `RoomSeeder → SettingSeeder → ReservationSeeder → MaintenanceSeeder → GuestSeeder → PosProductSeeder → PosTransactionSeeder → RatePlanSeeder → ChannelConfigSeeder → RoleSeeder → StaffSeeder → WebsiteContentSeeder`.

**Date queries:** Always use `whereDate('col', 'YYYY-MM-DD')` or `whereYear/whereMonth` for date columns — never `whereIn(['2026-06-13'])` on a date/datetime column, it won't match.

## Models

- **`Room`** — `amenities` cast to `array` (JSON). Status helpers use `status*()` prefix (e.g. `statusDirty()`, `statusClean()`) because `isDirty()` is reserved by Eloquent. Statuses: `available`, `clean`, `occupied`, `dirty`, `maintenance`, `out_of_order`.
- **`Reservation`** — uses `SoftDeletes`. Date fields (`check_in`, `check_out`, `deposit_date`) cast to `'date'`; `deleted_at` cast to `'datetime'`. `belongsTo(Room::class, 'room_no', 'no')`. `belongsTo(RatePlan::class)->withTrashed()` (preserves rate plan name on show page even after plan is soft-deleted). `belongsTo(ReservationGroup::class, 'group_id')`. Statuses: `pending`, `confirmed`, `checked_in`, `checked_out`, `cancelled`, `no_show`.
  - **Booking number format:** `YYYY-MM-NNNNNN` (e.g. `2026-06-000012`) — year, booking month, 6-digit zero-padded sequence. Sequence is continuous within the year (month is display-only, not a reset boundary). Resets to `000001` on a new year; overflows at 999,999 back to 1. Generated by `Reservation::nextBookingNo()`.
  - **Payments:** `payments()` → `HasMany(ReservationPayment)` ordered by `payment_date, id`. `totalPaid()` sums `payments()->sum('amount')`. `balanceDue()` = `max(0, amount − totalPaid())`. The old single-deposit fields (`deposit_amount`, `deposit_paid`, etc.) remain on the table as intake fields; the seeder migrates them into `reservation_payments` rows.
  - **Reminders:** `reminders()` → `HasMany(GuestReminder)` ordered by `remind_at`.
- **`ReservationPayment`** — uses `SoftDeletes`. Columns: `reservation_id`, `amount` (decimal:2), `method`, `reference` (nullable), `or_number` (nullable, **unique** — BIR Official Receipt no.), `payment_date` (date), `notes` (nullable). Methods: `Cash`, `GCash`, `Maya`, `Credit Card`, `Debit Card`, `Bank Transfer`, `Check`. `belongsTo(Reservation::class)`. `nextOrNumber()` → `<or_prefix>-00000001` (prefix from Settings, 8-digit sequence from the highest suffix, `withTrashed`); assigned in `ReservationController::addPayments`. Deposits collected at booking are stored on the reservation, not as payments, so they have no OR yet.
- **`Setting`** — key-value store. Read with `Setting::get('key', 'default')`, write with `Setting::set('key', $value)`, load all as array with `Setting::allKeyed()`. Called directly in `layouts/app.blade.php` via `@php` block.
- **`MaintenanceRequest`** — `resolved_at` cast to `datetime`. `belongsTo(Room::class, 'room_no', 'no')` (nullable — common-area requests have no room). Statuses: `open`, `in_progress`, `resolved`. Categories: `electrical`, `plumbing`, `hvac`, `furniture`, `appliances`, `structural`, `other`. Priorities: `urgent`, `high`, `normal`, `low`.
- **`RatePlan`** — uses `SoftDeletes`. Columns: `name`, `code` (unique), `type` (`percent_off` | `amount_off` | `fixed`), `value` (decimal, null for fixed), `min_nights`, `valid_from`/`valid_until` (nullable dates), `active` (boolean). `hasMany(RatePlanRate::class)`. `hasMany(Reservation::class)`. Key method: `rateFor(string $roomType, float $rackRate): float` — computes adjusted nightly rate. `modifierLabel()` returns human-readable modifier string.
- **`RatePlanRate`** — per-room-type fixed rate overrides for `fixed`-type plans. Columns: `rate_plan_id`, `room_type`, `rate`. `belongsTo(RatePlan::class)`.
- **`WebsiteContent`** — key-value content store, **locale-aware** (`en` base + `fil`/`ceb`). `LOCALES` const. `get(section, key, default='', ?locale=null)` and `section(section, ?locale=null)` resolve the given/app locale, **falling back to English** when a localized value is null/blank (so English is unchanged with the default `en` locale). `set(section, key, value, locale='en')` upserts on `(section,key,locale)`. `sectionRaw(section, locale)` returns only that locale's rows (no fallback) — used by the translation editor + to pin the admin CMS to English. Unique key is `(section,key,locale)`. Sections: `homepage`, `about`, `contact`, `booking`, `seo`, `promo`, `payment`, `policies`.
- **`RoomPhoto`** — `room_no` nullable FK to `rooms.no`. NULL = gallery photo, non-null = room photo. `belongsTo(Room)`. Ordered by `sort_order`. Stored on `public` disk; retrieve URL via `Storage::url($path)`.
- **`ChannelConfig`** — maps booking source → rate plan. Columns: `source` (unique string), `rate_plan_id` (nullable FK), `active` (boolean), `notes`. `belongsTo(RatePlan::class)`. `updateOrCreate` pattern on `source` key.
- **`Role`** — columns: `name`, `slug` (unique), `description`, `is_system` (bool). `belongsToMany(Permission::class, 'role_permission')`. `hasMany(User::class)`. `permissionKeys(): array` — plucks `key` from loaded permissions.
- **`Permission`** — columns: `module`, `key` (unique), `label`. `belongsToMany(Role::class, 'role_permission')`. 42 seeded keys grouped across 17 modules (dashboard, rooms, reservations, front_desk, housekeeping, maintenance, rate_plans, channel_manager, calendar, reports, guests, pos, billing, settings, staff, roles).
- **`User`** — extends `Authenticatable`. Added columns: `role_id` (FK nullable), `active` (bool, default true), `last_login_at` (datetime nullable), `phone`, `position`. `belongsTo(Role::class)`. Key methods: `hasPermission(string $key): bool` (loads `role.permissions` if not loaded, returns true for admins), `isAdmin(): bool` (checks `role->slug === 'super_admin'`), `initials(): string` (first+last initials for avatar).
- **`ReservationGroup`** — uses `SoftDeletes`. One row per multi-room booking. Columns: `group_no` (`GRP-YYYY-NNNNNN`), `guest_name`, `guest_phone`, `guest_email`, `check_in`, `check_out`, `notes`. `hasMany(Reservation::class, 'group_id')` ordered by `room_no`. Key methods: `totalAmount()`, `totalPaid()`, `nextGroupNo()`. Group show page at `/reservation-groups/{id}`.
- **`FolioCharge`** — uses `SoftDeletes`. Extra charges attached to a reservation (room service, minibar, etc.). `belongsTo(Reservation::class)`.
- **`GuestReminder`** — per-reservation reminder. Columns: `reservation_id`, `remind_at` (datetime), `type`, `notes`, `status` (`pending`/`done`), `created_by` (nullable user FK), `completed_at`. Types: `Wake-up Call`, `Amenity Delivery`, `Transportation Pickup`, `Checkout Reminder`, `Custom`. `belongsTo(Reservation::class)`. `belongsTo(User::class, 'created_by')`. `isPending(): bool`. Static `types(): array`.

## Authentication & RBAC

### Route protection
All routes are wrapped in `Route::middleware('auth')->group(...)`. Public routes are: `GET/POST /login`, `POST /logout`, `GET/POST /forgot-password`, `GET /reset-password/{token}`, `POST /reset-password`.

The `auth` middleware redirects unauthenticated requests to the named route `login`.

### Gate registration (`AppServiceProvider::boot()`)
```php
Gate::before(fn($user) => $user->isAdmin() ? true : null); // super_admin bypasses everything
// One Gate per permission key, loaded dynamically from permissions table:
Permission::all()->each(fn($p) => Gate::define($p->key, fn($user) => $user->hasPermission($p->key)));
```
Wrapped in `Schema::hasTable('permissions')` guard so it doesn't fail before migrations run.

Use `@can('permission.key')` in Blade, `abort_unless(auth()->user()->hasPermission('key'), 403)` in controllers.

**Website module (finer RBAC):** `website.manage` is an umbrella; finer grants `website.content` / `website.seo` / `website.booking` let a role manage only some areas. Check with `$user->canWebsite('content'|'seo'|'booking')` (true if they hold `website.manage` OR the specific grant) and `canWebsiteAny()`. `WebsiteContentController::update()` derives the area from the posted section; standalone content modules (promotions, testimonials, FAQ, trust badges, attractions, hero slides, inquiries, newsletter) require `website.content`; the maintenance toggle requires `website.booking`. CMS tabs + the sidebar website group hide areas the user can't manage. `front_desk` holds `website.content`.

### System roles (cannot be deleted, `is_system = true`)
| Slug | Key access |
|---|---|
| `super_admin` | All — Gate::before bypass, no permission check needed |
| `manager` | All except `staff.manage`, `roles.manage` |
| `front_desk` | Reservations, front desk, guests, calendar |
| `housekeeping` | Housekeeping + maintenance view/create |
| `maintenance` | Maintenance full + rooms view |
| `accountant` | Reports, payments, POS view, billing |

### Seeded staff accounts
| Email | Password | Role |
|---|---|---|
| `admin@parkviewhotelcdo.com` | `admin123` | Super Admin |
| `manager@parkviewhotelcdo.com` | `manager123` | Manager |
| `frontdesk@parkviewhotelcdo.com` | `frontdesk123` | Front Desk |
| `housekeeping@parkviewhotelcdo.com` | `housekeeping123` | Housekeeping |
| `maintenance@parkviewhotelcdo.com` | `maintenance123` | Maintenance |

### Password reset
Uses Laravel's built-in `Password` broker. `MAIL_MAILER=log` in `.env` — reset links go to `storage/logs/laravel.log` during development (search `reset-password` in the log). Tokens expire in 60 minutes. To switch to real email: set `MAIL_MAILER=smtp` with SMTP credentials — no code changes needed.

## Controllers & Routes

All routes declared individually in `routes/web.php` — no resource routing. All controllers in `app/Http/Controllers/`.

| Controller | Route prefix | Key notes |
|---|---|---|
| `AuthController` | `/login`, `/logout` | `login()` checks `active=true` before `Auth::attempt()`; sets `last_login_at` on success; inactive users get specific error message |
| `PasswordResetController` | `/forgot-password`, `/reset-password` | Uses `Password::sendResetLink()` and `Password::reset()`. Route names must match Laravel broker: `password.request`, `password.email`, `password.reset`, `password.update` |
| `StaffController` | `/staff` | CRUD + `deactivate`/`activate` actions; cannot deactivate own account; `profile()`/`updateProfile()` at `/staff/profile` |
| `RoleController` | `/roles` | CRUD; `destroy()` blocked if `is_system=true` or role has users assigned; `edit()` passes `$assigned` (array of permission IDs) for grid pre-check |
| `DashboardController` | `/dashboard` | Computes live KPIs, 7-day revenue, room status counts, arrivals/departures lists, booking source breakdown |
| `RoomController` | `/rooms` | Full CRUD including `store()` (POST `/rooms`, name `rooms.store`). `show()` queries `Reservation` for current checked-in guest on occupied rooms. |
| `ReservationController` | `/reservations` | Full action set — see table below |
| `FrontDeskController` | `/check-in`, `/check-out`, `/room-rack` | Three views; `roomRack()` maps checked-in reservations and today's arrivals by `room_no`, groups rooms by floor. `checkIn()` also passes `$todayReminders` (pending `GuestReminder` rows due today, with `reservation` eager-loaded) |
| `GuestReminderController` | `/reservations/{id}/reminders` | `store()` creates reminder; `complete()` sets `status=done` + `completed_at`; `destroy()` deletes — all scoped to `reservation_id` |
| `ReservationGroupController` | `/reservation-groups/{id}` | `show()` loads group with `reservations.payments` + `reservations.folioCharges`; renders group summary + per-room table |
| `HousekeepingController` | `/housekeeping` | Splits dirty rooms into `$priorityRooms` (arriving today) and `$normalByFloor`; POST routes for markClean/markDirty/markMaintenance/markOutOfOrder/markAvailable all use `back()` |
| `MaintenanceController` | `/maintenance` | `store()` optionally updates room status on create; `resolve()` optionally releases room back to `clean`/`available`; `reopen()` clears `resolved_at` |
| `PublicController` | `/`, `/about`, `/rooms-overview`, `/rooms-overview/{type}`, `/gallery`, `/contact` | Unauthenticated public pages. Loads `WebsiteContent::section()` data for each view. `rooms()` groups by type with first photo. `roomDetail()` loads per-type rooms + `RoomPhoto` |
| `PublicBookingController` | `/book` | `search()` — shows form; `results()` — queries available room types; `showForm()` — one available room of the type; `store()` — double-checks availability, creates Reservation (status=pending, source=Online); `confirmation()` — loads by booking_no |
| `WebsiteContentController` | `/website` | Per-area RBAC (`canWebsite`). `index()` reads all sections **as English** (`sectionRaw(..,'en')`) + gallery + roomPhotos; if `?locale=fil|ceb` it renders the dedicated **translation editor** (`translateView` → `website-content.translate`). `update()` saves the English base (reads old via `get(..,'en')`, writes `set()` default `en`) + image uploads. `translate()` (POST `website.translate`, gated `website.content`) saves only the curated `TRANSLATABLE` text keys for one locale + records locale-tagged revisions. `restore()` reverts within the revision's `locale`. Gallery/room-photo upload/delete/reorder. Images stored on `public` disk (English-only — settings/toggles/images are never per-locale). |
| `SettingController` | `/settings` | `index()` passes `Setting::allKeyed()` as `$settings`; view uses `$s['key'] ?? ''` |
| `RatePlanController` | `/rate-plans` | Full CRUD. `index()` annotates each plan with reservation count. `store()`/`update()` handle both modifier value and per-room-type rate grids (for `fixed` type). |
| `ChannelManagerController` | `/channel-manager` | `index()` returns source performance stats (DB::raw aggregates) + channel configs. `saveMapping()` does `updateOrCreate` on source key. |
| `CalendarController` | `/calendar` | `index(?month=YYYY-MM)` — queries rooms + reservations overlapping the month, pre-computes bar offset/width (days from period start, clamped to visible window), passes `$bars[$room_no][]` array + `$roomStatuses` (pluck of `status` keyed by `no`) to view. `$roomStatuses` is injected as `window._calRoomStatuses` for the drag-and-drop transfer modal. |
| `ReportsController` | `/reports` | `index()` renders a **permission-gated hub** of report cards (grouped Performance / Financial Control); `/reports` no longer redirects. Sub-pages — see below. Shared `parseDateRange()` (current month default, max 365 days) + `parseAsOf()` (single-date reports). Every sub-page has a **Print** view (`?print=1` → `reports.print.*`, reusing `reports/print/_letterhead.blade.php`). Screen pages share `reports/_filter.blade.php` (range / single-date / none modes). Per-report RBAC: each method does `abort_unless(hasPermission('reports.<key>'), 403)`; nav + hub use `User::canViewAnyReport()` (any `reports.*` key). |
| `DatabaseBackupController` | `/backups` | **Super-admin only** (`abort_unless(isAdmin())` on every method — a DB dump is all guest PII + payment records + password hashes). `store()` runs `mysqldump` via Symfony `Process` (password passed via `MYSQL_PWD` env; **full env inherited via `array_merge(getenv(), …)`** so Windows doesn't strip `SystemRoot` → else `mysqldump error 2004: Can't create TCP/IP socket`), writing `--result-file` to `storage_path('app/backups')` (git-ignored, never web-accessible). `index()` lists files; `download()`/`destroy()` take a `{filename}` param validated by `safePath()` (basename-equality + `/^backup-[A-Za-z0-9._-]+\.sql$/` + realpath-within-dir → blocks traversal); the routes also constrain `{filename}` to `[A-Za-z0-9._-]+`. Binary resolved by `resolveMysqldump()` (env `MYSQLDUMP_PATH` → Laragon glob → `mysqldump` on PATH). No restore-from-UI (restore is a manual `mysql < file.sql` — deliberately, to avoid a footgun). |

### ReservationController — full action map

| Method | Route | Effect |
|---|---|---|
| `index` | GET `/reservations` | Sortable list with stats |
| `create` / `store` | GET/POST `/reservations` | New reservation form; accepts `room_nos[]` array (1 = single, 2+ = group booking). Creates `ReservationGroup` when count > 1, one `Reservation` per room. Accepts `rate_plan_id`, calls `$plan->rateFor()` to compute `rate` and `amount`. Checks all requested rooms for date conflicts before creating anything. Redirects to group show page for groups. |
| `show` | GET `/reservations/{id}` | Detail view; passes `$payments`, `$charges`, `$reminders`, `$activityLogs`, `$availableRooms` (non-empty only when `checked_in`); shows rate plan badge + group context banner if applicable |
| `edit` / `update` | GET/PATCH `/reservations/{id}` | Edit form; recomputes rate/amount on save using the room's current rack rate from `rooms.rate` (not the stored reservation rate — avoids compounding discounts). Checks for date conflicts with other reservations in the same room before saving. |
| `confirm` | POST `…/confirm` | `pending` → `confirmed` |
| `checkIn` | POST `…/check-in` | `confirmed/pending` → `checked_in`; room → `occupied` |
| `checkOut` | POST `…/check-out` | `checked_in` → `checked_out`; room → `dirty` |
| `noShow` | POST `…/no-show` | `confirmed/pending` → `no_show` |
| `cancel` | POST `…/cancel` | any active → `cancelled`; if checked_in, room → `available` |
| `undoCheckIn` | POST `…/undo-checkin` | `checked_in` → `confirmed`; room → `available` |
| `undoCheckOut` | POST `…/undo-checkout` | `checked_out` → `checked_in`; room → `occupied`. **Blocked** if another reservation is currently `checked_in` to the same room — returns `session('error')` |
| `restore` | POST `…/restore` | `cancelled/no_show` → `confirmed`; no room change. Checks for date conflicts first — returns `session('error')` if room is already booked. |
| `addPayments` | POST `…/payments` | Validates `payments[*][method/amount/reference]` array; creates one `ReservationPayment` per row; supports split (multiple methods per transaction) |
| `deletePayment` | DELETE `…/payments/{paymentId}` | Scoped delete: `WHERE reservation_id = {id}` |
| `transferRoom` | POST `…/transfer-room` | Checked-in only; old room → `dirty`, new room → `occupied`; updates `room_no` + `room_type`; logs `reservation.room_transferred`. Aborts 422 if target room status is not `available` or `clean`. |
| `destroy` | DELETE `/reservations/{id}` | Soft-delete; blocked if `checked_in`; logs activity; redirects to index |
| `restoreTrashed` | POST `/reservations/{id}/restore-trashed` | Restores soft-deleted reservation; checks for date conflicts first — returns `session('error')` if room is already booked; logs activity; redirects to show |
| `forceDelete` | DELETE `/reservations/{id}/force-delete` | Admin-only (`isAdmin()`); permanently deletes payments, charges, reminders, then reservation |

### ReportsController — sub-pages

| Method | Route | What it computes |
|---|---|---|
| `occupancy` | GET `/reports/occupancy?from=&to=` | Day-by-day table: occupied rooms, occ%, revenue, ADR, RevPAR. Revenue distributed evenly across nights (`amount / nights`). Summary tiles for avg occ%, total revenue, ADR, RevPAR. |
| `revenue` | GET `/reports/revenue?from=&to=` | Reservations with `check_in` in range. Four breakdowns: by room type (with ADR), by booking source, by rate plan, by payment method. Totals + share bars. |
| `nightAudit` | GET `/reports/night-audit?date=` | Arrivals, departures, in-house for one date. Payments collected that date grouped by method, with transaction list. |
| `arAging` | GET `/reports/ar-aging` | **Live snapshot** (no date param) of every `checked_in`/`checked_out` reservation with `balanceDue() > 0`, aged into in-house / current / 1–30 / 31–60 / 60+ buckets by `check_out`. Eager-loads `payments`+`folioCharges` (no N+1); balance = `amount` + folio sum − payments sum. Also surfaces credit balances (overpayments). Perm `reports.ar_aging`. |
| `cancellations` | GET `/reports/cancellations?from=&to=` | `Reservation::withTrashed()` status `cancelled`/`no_show` with `check_in` in range: lost count (cancelled vs no-show), lost room-nights, lost revenue, forfeited deposits (sum of payments), cancellation rate (÷ all bookings arriving in range), breakdowns by source + room type. Perm `reports.cancellations`. |
| `ancillary` | GET `/reports/ancillary?from=&to=` | `FolioCharge` with `charge_date` in range grouped by category (F&B/minibar/laundry/telephone/damage/other), POS-linked vs manual counts, share %, plus ancillary-vs-room-revenue ratio (room basis is `check_in`). Perm `reports.ancillary`. |
| `advanceDeposits` | GET `/reports/advance-deposits?date=` | Unearned-revenue snapshot: payments (`payment_date` ≤ as-of) on `pending`/`confirmed`/`checked_in` reservations whose `check_out` > as-of; totals by method + by arrival month + per-booking detail. Perm `reports.advance_deposits`. |
| `cashVariance` | GET `/reports/cash-variance?from=&to=` | Shifts started in range: per-shift over/short via `Shift::cashVariance()` (null when opening/closing not both set), cumulative per cashier, and a **flag for shifts closed with no cash count** (`status=closed` + `closing_cash` NULL). Over/short/net totals; material-variance count (≥ ₱100). Perm `reports.cash_variance`. |
| `voids` | GET `/reports/voids?from=&to=` | `activity_logs` filtered to a curated void/override set (`payment_deleted`, `charge_deleted`, `deleted`, `undeleted`, `cancelled`, `no_show`, `restored`, `undo_check_in/out`, `room_transferred`, `room.out_of_order/maintenance`), grouped Voids / Reversals / Overrides. Who + subject + amount (from `properties`) + IP; by-action and by-staff (repeat-offender) tables. Perm `reports.voids`. |
| `housekeepingTurnover` | GET `/reports/housekeeping-turnover?date=` | Due-out (`check_out`=date, checked_in/out), stayover (`check_in`<date<`check_out`, checked_in), due-in (`check_in`=date) rooms joined to `rooms`, grouped by floor. Print-first attendant worksheet (has a tick column). Perm `reports.housekeeping`. |
| `maintenanceBacklog` | GET `/reports/maintenance-backlog?from=&to=` | Live open/in-progress backlog with aging + priority-based overdue flag (urgent 1d / high 3d / normal 7d / low 14d), by priority + category; plus **MTTR** (avg resolve time) for work orders `resolved_at` in range, overall + by category. Perm `reports.maintenance`. |
| `dotStatistics` | GET `/reports/dot-statistics?from=&to=` | Guest arrivals (check_in in range, not cancelled/no_show) joined to `guests.nationality`, grouped by nationality: arrivals, guests (Σpax), room-nights, **guest-nights** (Σ nights×pax), revenue; domestic (nationality=Filipino) vs foreign split, avg LOS. For DOT/LGU tourism. Perm `reports.dot_stats`. |
| `discounts` | GET `/reports/discounts?from=&to=` | Senior/PWD register (RA 9994 / RA 10754): reservations with `discount_type` set (not cancelled/no_show, check_in in range) — guest, OSCA/PWD ID, gross (net+discount), discount, net; total discount = **claimable BIR deduction**, senior/PWD counts, by type. Perm `reports.discounts`. |
| `birVat` | GET `/reports/bir-vat?from=&to=` | **Accrual-basis** sales (room by check_in + folio by charge_date + direct POS by transaction_date, `payment_method` NOT NULL to avoid double-counting room-charged POS). **VAT-exempt senior/PWD shares are pulled OUT of the VATable base before the ÷1.12 split** (the discounted share carries no VAT), so gross → less exempt → VATable gross → net → output VAT is on the vatable remainder only. **Cash-basis** OR register: `reservation_payments` with `or_number`, payment_date in range (now incl. booking deposits). Perm `reports.bir_vat`. |

**Audit-log completeness:** `FolioController::destroyCharge()` now logs `reservation.charge_deleted` (with amount/category in `properties`) so folio-charge voids appear in the Voids report — previously charge removals were unaudited.

**All POST actions use `back()`** so staff return to whichever page (list, show, check-in queue, etc.) triggered the action.

**Flash messages:** All success actions set `session('success')`. `undoCheckOut` sets `session('error')` on room conflict instead of aborting. Views must render both:
```blade
@if(session('success')) ... @endif
@if(session('error'))   ... @endif
```

## Views

`resources/views/layouts/app.blade.php` is the single shell: white sidebar (`w-60`), sticky header (`h-14`), `ml-60` main content. Nav items are wrapped with `@can('permission.key')` guards — staff only see what their role allows. Sidebar bottom has an Alpine dropdown with **My Profile** and **Sign Out** links.

**Sidebar nav structure:** `Dashboard` (ungrouped, top) then 5 grouped sections — **Front Desk** (Reservations, Check-in, Check-out, Room Rack, Guest Profiles, Billing & Folios, My Calendar) · **Operations** (Housekeeping, Maintenance, Point of Sale, Shift Log) · **Revenue** (Rate Plans, Channel Manager, Reports) · **Website & Marketing** (Website CMS, News, Promotions, Testimonials, Trust Badges, Attractions, FAQ, Media, Inquiries, Newsletter — the whole block gated by `canWebsiteAny()`) · **Settings** (Room Management, Staff & Roles, Property Settings, Activity Log). Each section header + its collapsed-state divider is wrapped in an `@if($navXxx)` guard (booleans computed in a `@php` block at the top of `<nav>` from the same permissions as the items) so a role with no items in a section never sees an orphan header. Individual items keep their own `@can`.

Standalone auth pages (`auth/login`, `auth/forgot-password`, `auth/reset-password`) do NOT extend `layouts/app` — they are full standalone HTML with centered card layout, no sidebar.

**Active nav pattern:** layout uses `@yield('nav_xxx', 'text-gray-500 hover:...')`, child views override with `@section('nav_xxx', 'bg-teal-50 text-teal-700 font-semibold')`.

**View modules:**

| Folder | Files |
|---|---|
| `public/layouts/` | `guest.blade.php` — public site shell (sticky nav, mobile hamburger, footer with contact info) |
| `public/` | `home`, `rooms`, `room-detail`, `about`, `gallery`, `contact` |
| `public/booking/` | `search`, `results`, `form`, `confirmation` |
| `website-content/` | `index` — admin CMS with Alpine tabs |
| `auth/` | `login`, `forgot-password`, `reset-password` |
| `staff/` | `index`, `create`, `edit`, `profile` |
| `roles/` | `index`, `create`, `edit`, `_permission_grid` (partial) |
| `rooms/` | `index`, `show`, `create`, `edit` |
| `reservations/` | `index`, `show`, `create` |
| `front-desk/` | `check-in`, `check-out`, `room-rack` |
| `housekeeping/` | `index` |
| `maintenance/` | `index`, `create`, `show` |
| `rate-plans/` | `index`, `create`, `edit` |
| `channel-manager/` | `index` |
| `calendar/` | `index` |
| `reports/` | `occupancy`, `revenue`, `night-audit`, `_toolbar` (shared partial) |
| `settings/` | `index` |
| root | `dashboard` |

## Tailwind v4 & UI Conventions

No `tailwind.config.js` — scanning configured via `@source` directives in `resources/css/app.css`. Alpine.js `[x-cloak]` rule is also in that file.

- Card borders: `border-[0.5px] border-gray-200 rounded-xl` (arbitrary value, valid in v4)
- Form inputs: `border-[0.5px] border-gray-200 rounded-lg` + `focus:ring-2 focus:ring-teal-500/20 focus:border-teal-400`
- Active/primary: `teal-600` / `teal-700`; status badges use `border-[0.5px]` not `ring-1`
- Undo/restore actions: `text-blue-700 bg-blue-50 border-[0.5px] border-blue-200 hover:bg-blue-100` — visually distinct from primary (teal) and destructive (red) actions
- No gradients, no heavy shadows; Alpine.js loaded from CDN only
- Sort links use `request()->fullUrlWithQuery(['sort' => ..., 'dir' => ...])` to preserve other query params
- **Dropdown clipping:** Never put `overflow-hidden` on a card container that has an Alpine dropdown child. Instead, apply `rounded-t-xl` directly on the status band `div` so the card corners are still clipped without hiding the dropdown.
- **Mobile responsiveness** (mobile-first — the app shell is already responsive: off-canvas sidebar + backdrop, `md:ml-*` margins only on desktop, `p-4 md:p-6`; public shell has a hamburger). For **page content**: wrap every wide data `<table>` in `<div class="overflow-x-auto">` (give the table a `min-w-[…]` so columns keep their width and scroll instead of clipping) — **never** rely on a card's `overflow-hidden` to hold a table. Page headers / action-button clusters / filter toolbars must stack + wrap on phones: `flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between` on the header, `flex-wrap` on button/filter rows, and `flex-1 min-w-0` on date inputs. Stat grids use `grid grid-cols-1 sm:grid-cols-*`. In a grid, full-width children need `sm:col-span-2` (an unprefixed `col-span-2` forces the implicit grid back to 2 columns even at `grid-cols-1`). Pasted-embed iframes (maps): add `[&>iframe]:w-full [&>iframe]:h-full` to the wrapper so the fixed-width embed fills the box.

### Show Password Toggle

All password fields use a vanilla JS eye-toggle pattern. Wrap the `<input>` in a `relative` div and add a `.pw-toggle` button:

```html
<div class="relative">
    <input type="password" name="password" class="... pr-10">
    <button type="button" class="pw-toggle absolute inset-y-0 right-0 px-3 ..." tabindex="-1">
        <svg class="eye-off ...">...</svg>   {{-- shown by default --}}
        <svg class="eye-on hidden ...">...</svg>
    </button>
</div>
```

JS (inline `<script>` at bottom of each view):
```js
document.querySelectorAll('.pw-toggle').forEach(function(btn) {
    btn.addEventListener('click', function() {
        var input = btn.previousElementSibling;
        var isText = input.type === 'text';
        input.type = isText ? 'password' : 'text';
        btn.querySelector('.eye-off').classList.toggle('hidden', !isText);
        btn.querySelector('.eye-on').classList.toggle('hidden', isText);
    });
});
```

Used on: login, staff/create, staff/edit, staff/profile, auth/reset-password.

### Permission Grid

`roles/_permission_grid.blade.php` — shared partial for role create/edit. Expects `$permissions` (Collection grouped by module) and `$assigned` (array of permission IDs). Uses vanilla JS `.module-toggle` / `.perm-check` with `data-group` attributes for per-module select-all with indeterminate state. No Alpine dependency.

### Global Confirmation Dialog

All destructive or state-changing form submissions use a global modal defined in `layouts/app.blade.php` (HTML + vanilla JS, no Alpine dependency). Opt in by adding attributes to the `<form>` element — never to the button:

```html
<form ... data-confirm="Confirm this action?">           {{-- normal (teal) --}}
<form ... data-confirm="Delete this?" data-danger="true"> {{-- danger (red) --}}
```

The JS intercepts the `submit` event, shows the dialog, then calls `form.submit()` programmatically (which does NOT re-fire the submit event, so no loop). Pressing Escape or clicking the backdrop cancels. Never use `onsubmit="return confirm(...)"` — all native confirm() calls have been replaced.

**Split payment form caveat:** The payments form uses Alpine `x-for` to render dynamic rows with `:name` bindings. These are real DOM elements by the time the user clicks submit, so `data-confirm` + `form.submit()` works correctly — Alpine's rendered fields are included in the native submit.

### Alpine.js Patterns

**Complex Alpine state → named `<script>` function.** When `x-data` contains `@json()` output or multi-line JS, extract to a named function and reference it:
```html
<script>function myData() { return { ..., items: @json($items) }; }</script>
<div x-data="myData()">...</div>
```
Inline `x-data` with `@json()` inside HTML attributes causes parser failures (JS appears as visible text).

**`$el` in child Alpine directives.** Adding `x-data="{}"` to a child element makes it an Alpine micro-component root, so `$el` in its directives refers to that element (not the outer component root). Used in the calendar to bind per-bar search dimming without PHP-in-JS escaping:
```html
<a x-data="{}" data-name="{{ strtolower($bar['guest_name']) }}"
   :class="{ 'opacity-20': search.length > 1 && !$el.dataset.name.includes(search.toLowerCase()) }">
```
Child micro-components inherit parent scope (can read `search`, `statuses`, etc.) without re-declaring them.

**Pre-compute `@json()` values in the controller.** Never pass a `->map(closure)` result directly to `@json()` inside a Blade directive — the closure serialises as `{}`. Pre-compute the collection in the controller and pass the result as a plain variable.

**SortableJS + Alpine bridge via CustomEvent.** When SortableJS and Alpine need to communicate, dispatch a `CustomEvent` from SortableJS `onEnd` and catch it in Alpine with `@event-name.window`:
```js
// SortableJS onEnd
window.dispatchEvent(new CustomEvent('cal-transfer', { detail: { ... } }));
```
```html
<!-- Alpine modal -->
<div x-data="transferModalData()" @cal-transfer.window="open($event.detail)">
```
Always revert the DOM in `onEnd` (`evt.to.insertBefore(evt.item, ...)`) — SortableJS moves the element in the DOM but bar positions are determined by CSS `left/width`, so the visual is wrong until a page reload. The revert keeps the DOM consistent while the modal handles the actual submit + reload.

**PHP → JS room status injection.** Inject server-side data for client-side validation using a `window.*` global in a `<script>` block (not inside Alpine `x-data`):
```blade
<script>window._calRoomStatuses = @json($roomStatuses);</script>
```
Read it in SortableJS callbacks or Alpine getters: `(window._calRoomStatuses || {})[roomNo]`.

## Philippine-specific

- Currency: ₱ peso — format with `number_format($val, 0)`
- VAT 12% default, configurable in Settings; BIR registered name, TIN, OR prefix also in Settings
- **Senior Citizen / PWD discount (RA 9994 / RA 10754)** — create form has mutually-exclusive senior/PWD checkboxes + an OSCA/PWD ID field (Alpine-revealed). `ReservationController::store()` applies `Reservation::statutoryDiscount($vatInclTotal, $pax, $type)` = 20% off + VAT-exempt on **one guest's share** (`total ÷ pax`: strip 12% VAT, less 20%) to the **first room only** (single qualified guest), *before* the deposit % is computed; persists `discount_type` / `discount_id_number` / `discount_amount`; `amount` holds the discounted total. `update()` re-applies the discount (amount is recomputed from rack rate on edit). Surfaced in the Senior/PWD Discount Register + BIR VAT-exempt memo. **The 1/pax-share interpretation was a deliberate user choice — the register/BIR views tell staff to confirm with their accountant.**
- **Nationality** captured via a select on the reservation create form → **snapshotted onto `reservations.nationality`** (frozen at booking, default Filipino, so a later guest-profile edit can't reclassify a filed DOT period) *and* copied to `guests.nationality`. The DOT report reads the reservation snapshot.
- **Booking deposits are recorded as real `ReservationPayment` rows** (with an OR number), not just the legacy `deposit_*` columns — so `balanceDue()` credits them (checkout isn't wrongly blocked) and they appear in the BIR OR register. The `deposit_*` columns remain as intake metadata.
- The senior/PWD discount **requires an OSCA/PWD ID** (server-validated in `store()`, `:required` on the form) — a VAT-exempt deduction can't be claimed without it.

## Room Types & Rates

All 16 types and their rack rates (stored per-room in `rooms.rate`):

| Type | Rate | Type | Rate |
|---|---|---|---|
| Economy Junior | _(not set)_ | Junior Deluxe | ₱699 |
| Economy Twin | ₱699 | Superior Deluxe | ₱849 |
| Economy Double | ₱699 | Deluxe Twin | ₱899 |
| Ordinary Twin | ₱549 | Deluxe Double | ₱899 |
| Ordinary Double | ₱549 | Deluxe Trio | ₱1,249 |
| Standard Twin | ₱849 | Barkadahan | ₱1,499 |
| Standard Double | ₱849 | Family Room / Family Room 2 | ₱1,649 |
| Executive Suite | ₱1,749 | | |

## Current State

| Module | Status | Notes |
|---|---|---|
| Dashboard | Done | KPIs, arrivals/departures, room status chart, 7-day revenue, booking sources, recent reservations |
| Room Management | Done | Full CRUD, 63 rooms, 4 floors |
| Property Settings | Done | 22 keys, full form |
| Reservations | Done | List (sortable), detail, create, edit; full status lifecycle with reversals; rate plan integration; soft delete with trash view (restore / force-delete for admins) |
| Payments | Done | Split payments per reservation; `reservation_payments` table; summary bar + per-payment list with delete; dynamic Alpine form |
| Group Bookings | Done | Multi-room bookings under one guest; `reservation_groups` table + `ReservationGroup` model; group show page with per-room totals; purple group badge on reservation list, check-in, check-out pages; group context banner on reservation show |
| Room Transfer | Done | Checked-in room swap: old room → dirty, new room → occupied; room picker modal on reservation show; activity log entry |
| Guest Reminders | Done | `guest_reminders` table; reminders card on reservation show sidebar (add/view/mark-done/delete); amber "Today's Reminders" panel on check-in page (only when pending reminders exist for today) |
| Check-in Queue | Done | Daily arrivals with overdue highlighting, inline Check In button, today's pending reminders panel |
| Check-out Queue | Done | In-house guests due out, balance due column, inline Check Out button |
| Room Rack | Done | Visual grid of all 63 rooms, Alpine filter by status, guest/arrival info on cards |
| Housekeeping | Done | Priority queue (arriving today), dirty-by-floor grid, clean & ready section, maintenance section |
| Maintenance | Done | Work order list (filter tabs, priority sort), create form (room or common area), show/resolve/reopen |
| Rate Plans | Done | CRUD for percent_off / amount_off / fixed plans; `RatePlan::rateFor()` computes adjusted nightly rate; wired into reservation create/edit with live Alpine preview |
| Channel Manager | Done | Source performance table (bookings, revenue, avg stay, share); rate mapping config per channel; "+ Add Channel" form |
| My Calendar | Done | Gantt-style timeline: rooms (Y) × days (X); bars colour-coded by status; sticky room labels; today highlight; hover tooltip; month navigation. Filters: status (multi-select pills), hide empty rows, floor filter, guest name search (dims non-matching bars). Drag-and-drop room transfer: checked-in bars are draggable (SortableJS) with left-side grip handle; dropping opens transfer modal (reason required); blocked client-side + server-side when target room is occupied. |
| Reports | Done | **Hub landing page** (`/reports`) with permission-gated cards in 4 groups. **Performance**: Occupancy, Revenue, Night Audit. **Financial Control**: AR Aging, Cancellations & No-Shows, Ancillary Revenue, Advance Deposits. **Audit & Cash Control**: Cash & Shift Variance, Voids & Overrides. **Operations**: Housekeeping Turnover, Maintenance Backlog & MTTR. **Philippine Compliance**: DOT Guest Statistics, Senior/PWD Discount Register, BIR Sales Book & VAT. Every report has a printable letterhead view (`?print=1`). Per-report permission keys (`reports.*`); `User::canViewAnyReport()` gates the nav. Roles: manager/super_admin all; accountant all financial + audit + compliance; front_desk AR aging + DOT; housekeeping turnover; maintenance backlog. All aggregation logic adversarially reviewed (multi-agent). |
| Guest Profiles | Done | (from prior sessions) |
| Point of Sale | Done | (from prior sessions) |
| Login & Auth | Done | Session auth via `Auth::attempt()`; `auth` middleware on all routes; inactive account blocked with clear message; `last_login_at` stamped on login |
| Staff & Roles | Done | Dynamic RBAC: `roles` + `permissions` + `role_permission` pivot; 6 system roles; 43 permission keys; `Gate::before` super-admin bypass; staff CRUD with activate/deactivate; My Profile page (name/email/phone/position/password) |
| Password Reset | Done | Forgot password → email link → reset form; `MAIL_MAILER=log` in dev (link in `storage/logs/laravel.log`); password strength meter on reset form |
| Public Website | Done | 6 public pages (home, rooms, room-detail, about, gallery, contact) at `/`, `/rooms-overview`, etc. Guest layout with sticky nav + footer. SEO meta from CMS. |
| Online Booking | Done | Date search → available rooms → guest form → confirmation. Creates `Reservation` with `source='Online'`, `status='pending'`. Booking number on confirmation page. **Confirmation email**: `processPayment()` sends `App\Mail\BookingConfirmationMail` (Markdown mail, `emails/booking-confirmation`) to the guest — wrapped in try/catch so a mail failure never breaks the booking; skipped when no guest email. Delivers via `MAIL_MAILER=log` today (ready for SMTP, no code change). |
| Website CMS | Done | Admin panel at `/website` (requires `website.manage`). Alpine tabs: Homepage, About, Contact, Social & Chat, Gallery, Room Photos, Booking Settings, Promo Bar, SEO. Image upload to `public` disk. Content edits logged to `activity_logs`; unsaved-changes guard on all forms. See **Website CMS Expansion Roadmap** below. |
| Contact Inquiries | Done (Phase 1) | Public contact form → `inquiries` table + `Inquiry` model; admin inbox at `/inquiries` (`InquiryController`, mirrors newsletter list). Submissions logged to `activity_logs`; new-count badge on sidebar. |
| Promo/Announcement Bar | Done (Phase 1) | `promo` WebsiteContent section; site-wide dismissible bar with Alpine countdown, shared to `public.*` via `View::composer`. |
| Social & Chat CTA | Done (Phase 1) | Social links (FB/IG/TikTok) in footer + floating Messenger/Viber/WhatsApp/Call FAB; scalar keys in `contact` section. |
| Folio & Billing | Done | `folio_charges` table; `FolioCharge` model; extra charge add/delete on reservation show; `billing/index` list with search/filter/balance stats; `/reservations/{id}/folio` printable folio with VAT breakdown + signature block |
| Database Backups | Done | Super-admin `/backups` page (`DatabaseBackupController`): create (`mysqldump`), list, download, delete. Files in git-ignored `storage/app/backups`; nav link under Settings (admin only); security-reviewed. See the controller row above. |

### Reservation Status Lifecycle

```
pending ──confirm──► confirmed ──check-in──► checked_in ──check-out──► checked_out
   │                    │                        │
   └──no-show──►  no_show    ◄──restore──┘       │
   └──cancel──►  cancelled   ◄──restore──┘       │
                                                  │
              confirmed ◄──undo-checkin───────────┘
              checked_in ◄──undo-checkout──────────── checked_out
                              (blocked if room occupied by another guest)
```

**Room status side-effects:**

| Action | Room change |
|---|---|
| `checkIn` | → `occupied` |
| `checkOut` | → `dirty` |
| `cancel` (from checked_in) | → `available` |
| `undoCheckIn` | → `available` |
| `undoCheckOut` | → `occupied` |
| `restore` (cancel/no_show) | no change |

## Website CMS Expansion Roadmap

Phased plan to grow the public site + `/website` CMS. **Architectural rule** (from the codebase): singular scalar copy → a `WebsiteContent` `(section,key)` row saved by the generic `update()` loop; anything list-shaped / orderable / dated / moderated → its own table like `room_photos`/`newsletter_subscribers` (do **not** fake collections as numbered keys — the `slide_1..3` mistake). Log every content mutation to `activity_logs` via `ActivityLogger::log(...)`; gate admin with `website.manage`. A new **section** needs one line in `$allowed` (WebsiteContentController) + a new Alpine tab; a new **image key** needs adding to `$imageKeys` or the upload is silently dropped. Every CMS checkbox needs a hidden `value=0` field before it (unchecked boxes don't submit).

**Phase 1 — Meet the Messenger-era guest & make editing safe (DONE):**
- Social links + floating Messenger/Viber/WhatsApp/Call chat FAB — scalar keys in the `contact` section; footer social row + fixed FAB in `public.layouts.guest`.
- Contact/inquiry form + submissions inbox — `inquiries` table + `Inquiry` + `InquiryController` (public `store`; admin `index`/`updateStatus`/`destroy`); inbox at `/inquiries`.
- Site-wide promo/announcement bar with countdown — new `promo` section; shared to `public.*` via `View::composer`; dismissible via localStorage.
- Content edit audit trail — `WebsiteContentController::update()` diffs old→new and logs `website.updated` (subject_type `website`).
- Unsaved-changes guard + fixed `booking.enabled` one-way toggle — per-tab dirty tracking + `beforeunload`; hidden `value=0` before every CMS checkbox.

**Phase 2 — Get found & convert (SEO + PH-market) (DONE, except image optimization):**
- SEO backbone (all in `public.layouts.guest` head): dynamic `sitemap.xml` + `robots.txt` via `SitemapController` (static `public/robots.txt` removed so the route serves it; both routes sit outside the maintenance group so they stay crawlable); canonical URLs; Open Graph + Twitter Card meta; rendered `meta_keywords`; global `seo.og_image` upload (added to `$imageKeys`) with per-page override via `@section('og_image')`. Head computes values with `$__env->yieldContent(...)`.
- schema.org JSON-LD: `Hotel` sitewide (Settings-driven address/phone), `HotelRoom` + `Offer` + `BreadcrumbList` on room-detail (via `@push('jsonld')` → `@stack('jsonld')`), `FAQPage` on `/faqs`. JSON built with `json_encode(..., JSON_UNESCAPED_UNICODE)` (slashes escaped to prevent `</script>` breakout).
- FAQ manager: `faqs` table + `Faq` model (`scopePublished`); admin CRUD at `/website/faqs` (`FaqController`, own sidebar link — kept off `/faqs` to avoid clashing with the public page); public `/faqs` accordion grouped by category + FAQPage schema. FAQ links in footer + sitemap.
- Promo codes wired into `/book`: `PublicBookingController::store()` resolves a code against active `RatePlan`s (validates `active` + `valid_from`/`valid_until` + `min_nights`), applies `rateFor()`, holds discounted rate + `rack_rate` + `rate_plan_id` in the `pending_booking` session; payment page shows rack→promo breakdown + savings; `processPayment()` persists `rate_plan_id`. Optional `booking.promo_hint` shows above the promo field.
- "How to Pay": new `payment` WebsiteContent section (added to `$allowed`; QR keys `gcash_qr`/`maya_qr`/`bank_qr` added to `$imageKeys`); admin "How to Pay" tab; public `/how-to-pay` page (`PublicBookingController::howToPay`) + link on booking confirmation + footer.
- "Why book direct" perks (`booking.perks`, newline list on the results page) + scarcity badge ("Only N left" when `available_count` ≤ 3).
- **Deferred to Phase 4:** on-upload image optimization + alt text (needs an image library dependency — intervention/image or GD pipeline).

**Phase 3 — Rich repeatable content & PH trust (IN PROGRESS):**
- DONE (3a): **Promotions/Offers** — `promotions` table + `Promotion` model (`scopeLive` = active + within optional date window, SoftDeletes); admin CRUD at `/promotions` (`PromotionController`, single-page inline add/edit, image upload, sidebar link); public `/promos` page + homepage "Special Offers" teaser (shows up to 3 live) + footer/sitemap links; promo cards surface a Rate Plan `promo_code` (ties into the Phase-2 promo engine).
- DONE (3a): **Testimonials + star ratings** — `testimonials` table + `Testimonial` model (`scopeApproved`, `initials()`); admin CRUD at `/testimonials` (`TestimonialController`, inline add/edit, avatar upload, approve/hide status + featured flag, sidebar link); homepage "What Our Guests Say" grid + `AggregateRating` + `Review` JSON-LD (`@push('jsonld')`). Note: `home()` now passes `$propName` (home.blade referenced it unconditionally in the new section — the layout's `$propName` is not in child-view scope).
- DONE (3b): **DOT / trust badges** — `trust_badges` table + `TrustBadge` model + `TrustBadgeController` at `/trust-badges` (sidebar link); active badges render as a strip in the public footer (label/subtitle/number/logo, optional link).
- DONE (3b): **Guest policies** — new `policies` WebsiteContent section (added to `$allowed`) + CMS "Policies" tab; public `/policies` page (Senior/PWD per RA 9994 / RA 10754, cancellation, house rules, check-in/out) + footer link. Seeder ships PH senior/PWD defaults.
- DONE (3b): **CDO attractions guide** — `attractions` table + `Attraction` model + `AttractionController` at `/website/attractions` (sidebar link; kept off `/attractions` to avoid clashing with the public page); public `/attractions` guide + footer/sitemap links.
- DONE (3b): **Newsletter unsubscribe** (Data Privacy Act) — signed public route `newsletter.unsubscribe` (`signed` middleware) + `NewsletterSubscriber::unsubscribeUrl()` helper + public confirmation page; admin "Unsubscribe" action (`newsletter.unsubscribe-admin`) in the subscriber list. Both set `active=false` + `unsubscribed_at` (the previously-orphaned columns).
- DONE (3c): **Hero-slider repeater** — `hero_slides` table (its migration copies the faked `slide_1..3` WebsiteContent values into rows) + `HeroSlide` model + `HeroSlideController` at `/website/hero-slides` (linked from the Homepage CMS tab, which no longer carries the fixed 3-slide form); the homepage hero reads active slides from the DB (gradient fallback when none). The `slide_1..3` WebsiteContent keys are now dead.
- DONE (3c): **Per-section RBAC** — finer permissions `website.content` / `website.seo` / `website.booking` alongside the `website.manage` umbrella; `User::canWebsite($area)` (= has `website.manage` OR `website.$area`) + `canWebsiteAny()`. `WebsiteContentController::update()` maps the posted section → area; standalone content modules + hero-slides require `website.content`; the maintenance toggle requires `website.booking`. CMS tabs + the sidebar website group hide areas the user can't manage. `front_desk` is granted `website.content`. Backward compatible — `website.manage` holders (super_admin, manager) still get everything.

**Phase 3 complete.**

**Phase 4 — Editorial maturity (IN PROGRESS):**
- DONE (4a): **News / Blog** — `posts` table + `Post` model (`scopeLive` = status `published` AND `published_at` ≤ now; `SoftDeletes`; `author()` belongsTo User; `isScheduled()`). Admin CRUD via `PostController` — dedicated create/edit pages at `/posts` (shared `posts/_form` partial), gated `website.content`, sidebar link. **Scheduling** via `published_at`: a future time queues the post; it auto-goes-live on the next request (no cron). Public `/news` (paginated list) + `/news/{slug}` single post with `NewsArticle` JSON-LD + `og:type=article`; homepage "Latest Updates" teaser; footer link + sitemap (one entry per live post). Body rendered as escaped text with `whitespace-pre-line` (WYSIWYG is a later 4b item).
- DONE (4b): **Content-freshness dashboard** — read-only `WebsiteHealthController` at `/website/health` (linked from the CMS header): setup checklist (SEO/contact/map/hero/gallery), content-library counts with deep links, room-types-missing-photos, an attention feed (maintenance mode, booking off, expired-but-active promos, draft posts, new inquiries), and recent website edits from `activity_logs`. **Rich-text (Markdown) for blog posts** — `Post::bodyHtml()` renders Markdown via Laravel's built-in `Str::markdown(['html_input' => 'escape', 'allow_unsafe_links' => false])` (XSS-safe — raw HTML is escaped, so `{!! !!}` is safe), styled by a `.post-body` block in `app.css`; the post editor has a small Markdown toolbar (Alpine, no JS dependency).
- DONE (4c): **CMS content revision history + one-click restore** — `website_content_revisions` table + `WebsiteContentRevision` model (immutable, `created_at` only). `WebsiteContentController::update()` snapshots the **full** previous value of every changed **text** key (image keys are excluded — their old files are deleted on replace, so they aren't rollback-able). `/website/history` (linked from the CMS header, `canWebsiteAny`) lists revisions with before/after previews and a **Restore** button (`website.history.restore`) that reverts the key and records the revert as a new revision, so it too is undoable. Area-gated: restoring a section's revision requires that section's `website.<area>` grant (`areaForSection()`). Adversarially reviewed across correctness / RBAC / data-integrity — clean.
- DONE (4d): **On-upload image optimization** — `App\Services\ImageOptimizer::store(UploadedFile $file, string $dir, int $maxDim = 1920, int $quality = 82)` downscales oversized images (never upscales), preserves format (PNG/WebP alpha kept), applies JPEG EXIF orientation, then re-encodes (JPEG q82 / PNG lvl 6 / WebP q80). Routed through **all 9 upload paths**: WebsiteContentController (image loop + gallery + room photos) and the promotions/testimonials/trust-badges/attractions/hero-slides/posts controllers (each passes a per-context `maxDim`). Safe by design: ≤24-megapixel cap, and GIFs / non-image / any GD failure fall back to the plain `store()` so an upload can never fail. Verified via real uploads: a 4000×3000 / 184 KB JPEG → 1920×1440 / 43 KB; a transparent PNG kept its alpha; a GIF fell back untouched. Adversarially reviewed, then hardened (24 MP cap to avoid uncatchable OOM + output-buffer balancing in the catch).
- DONE (4e): **Bulk image drag-reorder + cover** — `WebsiteContentController::reorderPhotos()` (POST `website.photos.reorder`, gated `website.content`) persists a drag-reordered set of `RoomPhoto` IDs as `sort_order`. The Gallery grid and each Room-type photo grid in the CMS are SortableJS drop zones (CDN `sortablejs@1.15.6`, reused from the calendar); `onEnd` POSTs the new order via `fetch` (X-CSRF-TOKEN header). The **first** photo of each grid is the cover — shown with a JS-managed `COVER` badge (custom `.cover-badge` CSS so it survives Tailwind's build-time scan, which doesn't see class names built only in JS strings). `.js-photo-sortable` / `.photo-drag-ghost` live in `app.css`; grid images carry `pointer-events-none` and the delete control is `filter`-excluded from dragging.
- DONE (4f): **Media Library** — `media` table + `Media` model + `MediaController` at `/media` (gated `website.content`, sidebar link): multi-file upload (each routed through `ImageOptimizer`, capturing width/height/size/mime + a title from the filename), searchable paginated grid, inline alt-text/title editing, copy-URL, delete. A `media.list` JSON endpoint powers an in-editor **picker** in the blog post editor (`posts/_form`, the `postEditor` Alpine component) — pick a library image and it inserts it at the cursor, so images are uploaded once and reused. The existing bespoke image fields (hero/about/promos/etc.) keep their own uploads; wiring the picker into them is a future enhancement.
- DONE (4g): **Multilingual (English base + Filipino + Bisaya)** — `WebsiteContent` gained a `locale` column (existing rows backfilled to `en`; unique key re-indexed to `(section,key,locale)`) and `get()`/`section()` now resolve the app locale **with English fallback**, so English rendering/editing is byte-for-byte unchanged. Public locale lives in the session via `SetLocale` middleware (appended to the `web` group) and switches through `GET /locale/{locale}` (validates against `LOCALES`, then `back()`); the guest layout has a globe language dropdown (desktop + mobile) and emits `<html lang>` + `og:locale`. Admin translation is a **dedicated editor** (`/website?locale=fil|ceb` → `translateView`), listing English reference beside a field per curated `WebsiteContentController::TRANSLATABLE` key (text only — toggles/numbers/images stay English-only), saved by `translate()` (gated `website.content`) which records locale-tagged revisions; `restore()` reverts within the revision's locale. The admin English CMS is pinned to `en` (`sectionRaw(..,'en')` / `get(..,'en')`) so a staffer previewing the site in another language can't accidentally edit against it — likewise the Site-Health dashboard. Verified end-to-end (EN unchanged · switch shows fallback · translate → public shows it · locale-scoped restore). **Adversarially reviewed** (multi-agent), which caught + fixed four issues: (1) `translate()` now gates **per-area** (`canWebsite(areaForSection($section))`) inside the loop and `translateView()` hides unmanageable sections — a content-only editor can no longer write `seo`/`booking`/`promo`/`payment` translations; (2) migration `down()` deletes non-`en` rows before restoring the 2-col unique (was un-rollback-able once translations existed); (3) blanking a field **clears** its translation (matched via `array_key_exists`, since `ConvertEmptyStringsToNull` turns a blank textarea into `null` — `isset` would skip it) and never writes empty rows; (4) Site-Health reads English. **hreflang** left out on purpose — the session-based locale serves one URL per page, so per-locale `hreflang` URLs would need URL-based locales (a future change).
- DONE (4h): **Visual WYSIWYG blog editor** — the post body field (`posts/_form`, `postEditor` Alpine component) mounts a **Toast UI Editor** (v3.2.2, WYSIWYG mode) from CDN. **Storage stays Markdown** — the editor syncs `getMarkdown()` into the hidden `<textarea name="body">`, so `Post::bodyHtml()` (`Str::markdown` GFM + `html_input=escape`) and the whole XSS-safe render pipeline are untouched, and pre-existing Markdown posts still load (`initialValue`). Full GFM toolbar (heading/bold/italic/strike/quote/hr/lists/task/table/link/code); the Media Library picker is wired in (toolbar button + `addImageBlobHook` routes pasted/dropped images through the library instead of base64-embedding). **Progressive enhancement**: the script loads `async` and `init()` polls for `window.toastui` (a slow CDN can't block the page), falling back to the plain textarea on timeout; client `required` moved to server validation (a hidden required field isn't focusable). Verified end-to-end (Markdown round-trip · GFM render · `<script>`/`<b>` escaped). **Adversarially reviewed** (multi-agent), which caught + fixed two issues: (1) a **`dirty` flag** now gates the submit-time sync so a no-op save (e.g. editing only the title) leaves the stored body **byte-for-byte intact** instead of re-serializing it through Toast (which would normalize Markdown / strip literal HTML); (2) the CDN script is `async` + polled so a slow/hung jsdelivr can't stall the editor page. Refuted (no action): missing SRI (consistent with existing Alpine/SortableJS CDN usage, no CSP), missing try/catch, and whitespace-only body (Laravel `required` trims).
- TODO (4i+): URL-based locales + hreflang · picker in the bespoke CMS image fields · multilingual for standalone tables (promotions/posts/etc.).

**Phase 4 — Editorial maturity:** draft→publish + revision history/rollback · reusable media library · WYSIWYG editor · news/blog module · content-freshness dashboard · bulk image drag-reorder · multilingual (EN/Filipino/Bisaya) + hreflang.
