# 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

**Critical:** The system `php` resolves to 7.4 and will fail. Always use 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`.

## 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), `payment_date` (date), `notes` (nullable). Methods: `Cash`, `GCash`, `Maya`, `Credit Card`, `Debit Card`, `Bank Transfer`, `Check`. `belongsTo(Reservation::class)`.
- **`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. `get(section, key, default)`, `set(section, key, value)`, `section(section): array` (returns pluck of value by key). Sections: `homepage`, `about`, `contact`, `booking`, `seo`.
- **`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.

### 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` | Requires `website.manage`. `index()` passes all sections + gallery + roomPhotos grouped. `update()` saves text fields and image uploads. Gallery and room photo upload/delete actions. Images stored on `public` disk. |
| `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` | Three sub-pages — see below. Shared `parseDateRange()` helper (defaults to current month, max 365 days). All reservation queries use `Reservation::withTrashed()` so soft-deleted reservations are preserved in historical data. |

### 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. |

**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.

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.

### 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 fields on reservation create form

## 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 | Three tabs: Occupancy (day-by-day occ%, ADR, RevPAR with colour-coded bars + totals), Revenue (by room type, source, rate plan, payment method), Night Audit (arrivals/departures/in-house lists + payments collected). Shared date-range picker with quick presets. |
| 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. |
| Website CMS | Done | Admin panel at `/website` (requires `website.manage`). Alpine tabs: Homepage, About, Contact, Gallery, Room Photos, Booking Settings, SEO. Image upload to `public` disk. |
| 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 |

### 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 |
