# NREMS Laravel Boilerplate Implementation Plan

> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Build the reusable NREMS Laravel 13 boilerplate: Theme A shell, Fortify auth (login/forgot/reset), Blade form-component library, and the Projects & Units module (5-table inventory), fully tested — then clone to a spatie/laravel-html variant repo.

**Architecture:** Standard Laravel 13 skeleton (no module packages). Thin controllers → FormRequests → Eloquent/services; yajra DataTable classes for every list; spatie permission + policies with `can:` middleware (defense in depth); spatie activitylog on all models; Theme A static assets served from `public/assets` (no Vite build needed). Server is authoritative for all calculations; JS mirrors for UX only.

**Tech Stack:** PHP 8.3 (WSL) · Laravel 13 (v13.26.x) · MySQL 8 (WSL, running) · laravel/fortify · spatie/laravel-permission · spatie/laravel-activitylog · yajra/laravel-datatables ^13 · proengsoft/laravel-jsvalidation · Pest · Pint · Larastan (level 6) · Metronic-based Theme A (Bootstrap 5, jQuery, select2 included in `plugins.bundle`).

**Binding spec:** `docs/superpowers/specs/2026-08-23-nrems-boilerplate-design.md` — read it fully before any task. On any conflict, the spec wins; for view markup, Theme A HTML at `/home/vivek/nrems/nrems-themes/theme-a-wireframe/` wins.

**Non-negotiable conventions (every task):**
- TDD: failing test → minimal implementation → green → commit. Definition of done per task: `composer test` green AND `composer analyse` clean AND `composer lint` clean.
- No raw SQL without bindings. `{{ }}` only in Blade (`{!! !!}` needs reviewer sign-off; the only pre-approved uses are `{!! JsValidator::formRequest(...) !!}` and yajra `{!! $dataTable->table() !!}` / `scripts()`).
- Explicit `$fillable`, `casts()`, `SoftDeletes` + `LogsActivity` on all domain models.
- All user-visible strings via `__('...')` with keys in `lang/en/*.php`.
- Commit messages: conventional (`feat:`, `test:`, `chore:`…), each ending with `Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>`.

---

## Chunk 1: Skeleton, packages, theme shell

### Task 1: Create the Laravel 13 project inside the existing repo

**Files:** whole Laravel skeleton in `/home/vivek/nrems-app` (repo already contains `.git` + `docs/`).

- [ ] **Step 1:** `composer create-project` refuses non-empty dirs — create in a sibling and merge:
```bash
cd /home/vivek
composer create-project laravel/laravel nrems-app-tmp "^13.0" --prefer-dist --no-interaction
rsync -a --exclude .git nrems-app-tmp/ nrems-app/
rm -rf nrems-app-tmp
cd nrems-app
```
- [ ] **Step 2:** Create DB + dedicated user (idempotent):
```bash
sudo mysql -e "CREATE DATABASE IF NOT EXISTS nrems_app CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER IF NOT EXISTS 'nrems'@'localhost' IDENTIFIED BY 'nrems_local_dev';
GRANT ALL PRIVILEGES ON nrems_app.* TO 'nrems'@'localhost'; FLUSH PRIVILEGES;"
```
(Also create `nrems_app_test` the same way for the test suite; grant `nrems` on it too.)
- [ ] **Step 3:** Configure `.env` and `.env.example` (example gets the same keys with placeholder secrets): `APP_NAME=NREMS`, `APP_URL=http://localhost:8000`, `DB_CONNECTION=mysql`, `DB_DATABASE=nrems_app`, `DB_USERNAME=nrems`, `DB_PASSWORD=nrems_local_dev`, `SESSION_DRIVER=database`, `MAIL_MAILER=log`, `SEED_ADMIN_EMAIL=admin@nrems.local`, `SEED_ADMIN_PASSWORD=` (blank in example). Point `phpunit.xml` at `nrems_app_test` (env `DB_DATABASE`); tests use `RefreshDatabase`. Do NOT use `:memory:` sqlite — we test against MySQL 8 like production.
- [ ] **Step 4:** Verify: `php artisan migrate && php artisan about` → shows Laravel 13.x, MySQL. `php artisan serve` responds 200 on `/`.
- [ ] **Step 5:** Commit: `chore: scaffold Laravel 13 skeleton with MySQL + session/database config`

### Task 2: Install packages + QA toolchain

**Files:** `composer.json`, published configs under `config/`, `phpstan.neon`, `pint.json`, migrations from vendors.

- [ ] **Step 1:** Production packages:
```bash
composer require laravel/fortify spatie/laravel-permission spatie/laravel-activitylog "yajra/laravel-datatables:^13" proengsoft/laravel-jsvalidation
```
- [ ] **Step 2:** Dev packages + Pest init (create-project ships PHPUnit; convert):
```bash
composer require --dev pestphp/pest pestphp/pest-plugin-laravel larastan/larastan laravel/pint --with-all-dependencies
./vendor/bin/pest --init   # if it complains tests exist, follow its prompt; keep tests/Pest.php with RefreshDatabase bound to Feature tests
```
- [ ] **Step 3:** Vendor publishes + fortify install:
```bash
php artisan fortify:install
php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider"
php artisan vendor:publish --provider="Spatie\Activitylog\ActivitylogServiceProvider" --tag="activitylog-migrations"
php artisan vendor:publish --provider="Yajra\DataTables\DataTablesServiceProvider"
php artisan vendor:publish --provider="Proengsoft\JsValidation\JsValidationServiceProvider"
php artisan migrate
```
- [ ] **Step 4:** `phpstan.neon`: larastan extension, `level: 6`, `paths: [app]`. `pint.json`: `{"preset": "laravel"}`. Composer scripts:
```json
"scripts": {
  "test": "@php artisan test",
  "analyse": "phpstan analyse --memory-limit=1G",
  "lint": "pint --test",
  "lint:fix": "pint"
}
```
- [ ] **Step 5:** Verify all three commands pass on the fresh skeleton. Commit: `chore: install auth/permission/audit/datatables/jsvalidation + Pest/Larastan/Pint toolchain`

### Task 3: Theme A assets + app layout shell + error pages

**Files:**
- Copy: `/home/vivek/nrems/nrems-themes/theme-a-wireframe/assets/` → `public/assets/` (verbatim: `css/`, `js/`, `media/`, `plugins/`)
- Create: `resources/views/layouts/app.blade.php`, `resources/views/layouts/guest.blade.php`, `resources/views/layouts/partials/sidebar.blade.php`, `topbar.blade.php`, `footer.blade.php`
- Create: `resources/views/errors/403.blade.php`, `404.blade.php`, `500.blade.php`, `resources/views/layouts/error.blade.php`
- Modify: `public/assets/css/theme-a.css` (append global responsive tab-nav rule)

**Markup source:** `theme-a-wireframe/index.html` — its `<head>`, body attributes (`id="kt_app_body"`, `data-*` attrs), early-init inline script, sidebar block (`kt_app_sidebar`), header (`kt_app_header`), and closing script tags (`plugins.bundle.js`, `scripts.bundle.js`, theme custom JS) must be transplanted **exactly**, replacing static hrefs with `{{ asset('assets/...') }}`, page content with `@yield('content')` / `{{ $slot }}`, page title with `@yield('title')`, and active-menu state with a `request()->routeIs()` helper.

- [ ] **Step 1 (failing test):** `tests/Feature/Layout/AppShellTest.php` (Pest): an authenticated user GET `/dashboard` sees `id="kt_app_sidebar"`, `NREMS`, and `assets/css/theme-a.css`; a guest is redirected to `/login`. Also: GET a nonexistent URL as guest → 404 page contains `theme-a.css` (error layout). Expect failures (routes/views missing).
- [ ] **Step 2:** Copy assets; build the four layout files + partials. Sidebar entries = the v12 sections (Main/Inventory/Sales/Operations/Document Uploads/Administration) but **only Dashboard is a live route now**; all others render with `href="#"` and a `disabled`-style class, each wrapped in `@can` checks where a permission exists (`projects.view`, `units.view`, `unit_types.view`) — unreachable modules stay visible-but-inert only for Super Admin (`hasRole('Super Admin')`), hidden otherwise. Note: `@can` on not-yet-seeded permissions is safe — spatie's gate returns false, entries just hide; the role check is likewise false for everyone until Task 9 seeds roles (the Step 1 test deliberately avoids asserting on gated entries). Guest layout = minimal centered card (used by auth pages, Task 8). Error layout = standalone (inline-link theme CSS only, **no session/auth calls**), and 403/404/500 views extend it. No registration needed — Laravel auto-discovers `resources/views/errors/`.
- [ ] **Step 3:** Append to `theme-a.css` the global tab fix:
```css
/* Global: tab navs never clip on small screens (spec §2) */
.nav-tabs, .nav-line-tabs { flex-wrap: nowrap; overflow-x: auto; overflow-y: hidden; scrollbar-width: thin; }
.nav-tabs .nav-item, .nav-line-tabs .nav-item { white-space: nowrap; }
```
Also create `routes/dev.php`, loaded from `web.php` only when `app()->environment('local', 'testing')`: one route `GET /dev/tab-demo` (auth) rendering a page with a 7-item `nav-line-tabs` strip inside a card — the verification vehicle for this fix (no in-scope page has tabs; spec §2 requires the fix be *verified* this round). Task 22's responsive smoke screenshots this page at 390px and asserts no clipping.
- [ ] **Step 4:** Temporary `/dashboard` route (real one in Task 6) rendering `layouts/app` with empty content so the test can pass. Run tests → green. `composer analyse && composer lint`.
- [ ] **Step 5:** Commit: `feat: Theme A layout shell, guest/error layouts, responsive tab fix`

### Task 4: Base conventions — HasAudit trait, helpers, config, security headers

**Files:**
- Create: `app/Models/Concerns/HasAudit.php`, `app/Support/helpers.php`, `config/nrems.php`, `app/Http/Middleware/SecurityHeaders.php`
- Modify: `composer.json` (autoload files), `bootstrap/app.php` (append middleware)
- Test: `tests/Unit/Support/FormatInrTest.php`, `tests/Feature/Http/SecurityHeadersTest.php`

- [ ] **Step 1 (failing tests):** `format_inr(12345678.5)` → `₹1,23,45,678.50`; `format_inr(0)` → `₹0.00`; negative → `-₹1,000.00`. Security headers: any response carries `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`.
- [ ] **Step 2:** Implement:
```php
// app/Support/helpers.php
if (! function_exists('format_inr')) {
    function format_inr(float|int|string $amount, int $decimals = 2): string
    {
        $amount = round((float) $amount, $decimals);
        $negative = $amount < 0;
        [$int, $frac] = array_pad(explode('.', number_format(abs($amount), $decimals, '.', '')), 2, '');
        if (strlen($int) > 3) {
            $last3 = substr($int, -3);
            $rest = substr($int, 0, -3);
            $int = preg_replace('/\B(?=(\d{2})+(?!\d))/', ',', $rest).','.$last3;
        }
        return ($negative ? '-' : '').'₹'.$int.($decimals > 0 ? '.'.$frac : '');
    }
}
```
`HasAudit` trait: `use SoftDeletes, LogsActivity;` + `getActivitylogOptions(): LogOptions` → `LogOptions::defaults()->logFillable()->logOnlyDirty()->dontSubmitEmptyLogs()`. `config/nrems.php`: `['date_format' => 'd-m-Y', 'per_page' => 25]`. Middleware sets the three headers; append globally in `bootstrap/app.php` `->withMiddleware(fn ($m) => $m->append(SecurityHeaders::class))`.
- [ ] **Step 3:** Green + analyse + lint. Commit: `feat: audit trait, INR formatter, security headers, nrems config`

### Task 5: Reusable UI components — breadcrumbs, status badge, card

**Files:**
- Create: `resources/views/components/breadcrumbs.blade.php`, `components/status-badge.blade.php`, `components/card.blade.php`, `app/View/Components/StatusBadge.php`
- Test: `tests/Feature/Components/UiComponentsTest.php`

Markup: Theme A uses its own badge scheme — `<span class="nrems-badge available">` etc., styled at `theme-a.css:~1597` (NOT Bootstrap `badge badge-light-*`). **Decision: adopt `nrems-badge <status>` (HTML wins).** `theme-a.css` currently styles only `available|booked|sold|blocked` + payment/doc statuses; append styles for the missing enum statuses `held|registered|possessed|management_quota` (colors: held=amber, registered=teal, possessed=dark gray, management_quota=purple — consistent with the existing palette), plus a neutral `.nrems-badge.unknown`. `StatusBadge` maps status string → that class; unknown status → `unknown` (never throws). No `sold` mapping (spec: enum only). Breadcrumb markup: Theme A uses a plain `d-flex` div marked `<!--begin::Breadcrumb-->` (see `project-management.html` ~line 384) — copy that, not Bootstrap `.breadcrumb`.

- [ ] **Step 1 (failing test):** `Blade::render('<x-status-badge status="available" />')` contains `nrems-badge available` and `Available`; unknown status renders `nrems-badge unknown`. Breadcrumbs component renders home link + items. Card renders title slot + body.
- [ ] **Step 2:** Implement, labels via `__('nrems.status.available')` etc. in `lang/en/nrems.php`.
- [ ] **Step 3:** Green + analyse + lint. Commit: `feat: breadcrumbs, status-badge, card components`

### Task 6: Dashboard (static shell) + named route

**Files:** `resources/views/dashboard.blade.php`, `app/Http/Controllers/DashboardController.php` (invokable), `routes/web.php`, test `tests/Feature/DashboardTest.php`.

- [ ] **Step 1 (failing test):** guest → redirect `/login`; authenticated user → 200, sees `Dashboard` heading and stat-card markup.
- [ ] **Step 2:** Port `index.html` content region into `dashboard.blade.php` (static placeholder stats per spec §7 — keep the wireframe's dummy numbers, mark section with `{{-- placeholder: real widgets ship with later modules --}}`). Route: `Route::middleware('auth')->get('/dashboard', DashboardController::class)->name('dashboard');` `/` redirects to `/dashboard`. Remove Task 3's temporary route.
- [ ] **Step 3:** Green + analyse + lint. Commit: `feat: dashboard shell page`

---

## Chunk 2: Auth module

### Task 7: Fortify backend — login/logout/reset flows

**Files:**
- Modify: `config/fortify.php` (features: only `Features::resetPasswords()`; `views => true`; `home => '/dashboard'`), `app/Providers/FortifyServiceProvider.php` (created by fortify:install), `app/Providers/AppServiceProvider.php`
- Test: `tests/Feature/Auth/LoginTest.php`, `tests/Feature/Auth/PasswordResetTest.php`

- [ ] **Step 1 (failing tests, write ALL first):**
  - login ok → redirect `/dashboard`, session regenerated (assert `Auth::check()`)
  - wrong password → back with generic error, no user enumeration (same message for unknown email — assert identical error string for both cases)
  - throttle: 6th rapid attempt for same email+IP → 429 or throttle error message
  - logout → session invalidated, guest again
  - forgot-password with known email → reset link "sent" (Notification::fake, assert `ResetPassword` notification)
  - forgot-password with unknown email → **same generic success response** (no enumeration)
  - reset with valid token → password updated (assert new password logs in), old sessions still valid is NOT required
  - reset with invalid/expired token → error, password unchanged
  - weak password (7 chars) rejected on reset
- [ ] **Step 2:** Configure. In `FortifyServiceProvider::boot`: view bindings (`Fortify::loginView`, `requestPasswordResetLinkView`, `resetPasswordView` → the Task 8 views), keep Fortify's default `login` RateLimiter (5/min by email+IP). In `AppServiceProvider::boot`, gate the HaveIBeenPwned check to production so the test suite never touches the network:
```php
Password::defaults(function () {
    $rule = Password::min(8)->letters()->mixedCase()->numbers();
    return app()->isProduction() ? $rule->uncompromised() : $rule;
});
```
Fortify's reset rules use `Password::defaults()` by default in current versions; verify.
- [ ] **Step 3:** Green + analyse + lint. Commit: `feat: Fortify auth backend (login, logout, password reset) with throttle + strong passwords`

### Task 8: Auth pages — Metronic stock restyled to Theme A

**Files:** `resources/views/auth/login.blade.php`, `auth/forgot-password.blade.php`, `auth/reset-password.blade.php` (extend `layouts/guest`).

Theme A has NO auth HTML (spec §6). Build a single centered-card auth layout: white card on `bg-light`, NREMS logo (`assets/media/logos/nrems-icon.svg`), Inter font, primary `#2563EB` buttons (`btn-primary` already themed by `theme-a.css`). Fields use plain Bootstrap markup for now (form components arrive in Chunk 3 — do NOT block auth on them; Task 13 retrofits).

- [ ] **Step 1 (failing test, in `tests/Feature/Auth/AuthPagesTest.php` — Task 13 extends this same file):** GET `/login`, `/forgot-password`, and a reset-password URL (valid token) each return 200 and contain the form actions + CSRF token. (View-render smoke; flows already covered by Task 7.)
- [ ] **Step 2:** Build the three views: email/password inputs, "Forgot password?" link, session status flash (`session('status')`) on forgot page, validation errors inline (`@error`). Escape everything.
- [ ] **Step 3:** Green + analyse + lint. Manual: `php artisan serve`, visually confirm at 390/768/1280 px. Commit: `feat: themed auth pages (login, forgot, reset)`

### Task 9: Roles, permissions, seeded Super Admin

**Files:**
- Create: `database/seeders/RolePermissionSeeder.php`, `database/seeders/AdminUserSeeder.php`; Modify: `DatabaseSeeder.php`, `AppServiceProvider.php` (Gate::before)
- Test: `tests/Feature/Auth/RolePermissionSeedTest.php`

Matrix (spec §5): `projects|units|unit_types` × `view|create|edit|delete` = 12 permissions. Roles: `Super Admin` (no direct perms; `Gate::before` returns true for it), `Admin` (all 12), `Sales` (`projects.view`, `units.view`), `Operations` (`projects.view`, `units.*`, `unit_types.view`).

- [ ] **Step 1 (failing test):** after seeding — 12 permissions exist; role→permission counts as above; Super Admin user exists with `SEED_ADMIN_EMAIL`; `$superAdmin->can('projects.delete')` true via Gate::before; a Sales user `->can('projects.delete')` false.
- [ ] **Step 2:** Implement seeders (idempotent: `firstOrCreate` everywhere). AdminUserSeeder: read via config, not `env()` (cached config in production makes `env()` return null): add `'seed_admin_email' => env('SEED_ADMIN_EMAIL', 'admin@nrems.local'), 'seed_admin_password' => env('SEED_ADMIN_PASSWORD')` to `config/nrems.php` and read `config('nrems.seed_admin_password')`, falling back to `'password'` ONLY when `app()->environment('local', 'testing')`, otherwise `throw new RuntimeException('SEED_ADMIN_PASSWORD required')`. `Gate::before(fn ($user) => $user->hasRole('Super Admin') ? true : null);` User model gets `HasRoles`.
- [ ] **Step 3:** Green + analyse + lint. Commit: `feat: role/permission matrix + seeded Super Admin`

### Task 10: Auth activity log

**Files:** `app/Listeners/LogAuthenticationActivity.php`, wire in `AppServiceProvider` (or `EventServiceProvider` pattern for L13: `Event::listen`). Test: `tests/Feature/Auth/AuthActivityLogTest.php`.

- [ ] **Step 1 (failing test):** successful login writes `activity_log` row (`log_name=auth`, description `login`, causer = user); logout writes `logout`; failed attempt writes `login_failed` with attempted email in properties (NOT the password).
- [ ] **Step 2:** Listen to `Illuminate\Auth\Events\{Login,Logout,Failed}`; use `activity('auth')->...->log(...)`. Assert no password ever stored (test greps properties json).
- [ ] **Step 3:** Green + analyse + lint. Commit: `feat: audit trail for auth events`

---

## Chunk 3: Form component library + jsvalidation

### Task 11: Blade form components

**Files:**
- Create: `resources/views/components/form/input.blade.php`, `form/textarea.blade.php`, `form/select.blade.php`, `form/select2.blade.php`, `form/date.blade.php`, `form/checkbox-group.blade.php`, `form/submit.blade.php`, `form/group.blade.php` (label + error wrapper used by all)
- Test: `tests/Feature/Components/FormComponentsTest.php`

API contract (documented in `docs/adding-a-module.md`, Task 22): every field component accepts `name`, `label`, `:value` (old-input aware via `old($name, $value)`), `required` (renders the Theme A red asterisk `<span class="text-danger">*</span>`), arbitrary attrs pass through `{{ $attributes }}`. Error state: `@error($name)` adds `is-invalid` + `<div class="invalid-feedback d-block">`. `select`/`select2` accept `:options` (assoc array value=>label) + `placeholder`; `select2` adds `data-control="select2"` + `data-placeholder` (Metronic auto-inits select2 on that attribute — verify in `plugins.bundle.js`; if not auto-init, create `resources/views/layouts/partials/scripts.blade.php` NOW with one delegated init and include it from the app layout — Task 12 otherwise creates that file; whichever task needs it first creates it). `date` renders `type="date"` storing `Y-m-d`, displaying native picker (wireframe uses native date inputs). Markup classes copied from `add-project.html` field blocks (`form-label`, `form-control form-control-solid`, `form-select form-select-solid`).

- [ ] **Step 1 (failing tests):** render each component via `Blade::render()`; assert: label + asterisk when required; `name`/`id` attributes; old input wins over `:value` (simulate `session()->flashInput()`); error class + message when errors bag has the key (including dot-to-bracket conversion: name `amenities[]` / `wings.0.name` error keys resolve); select marks `selected` correctly incl. `0`-value options; checkbox-group renders checked states from array old input.
- [ ] **Step 2:** Implement. Keep components dumb — no DB, no auth. `form/group.blade.php` is the shared shell; field components compose it.
- [ ] **Step 3:** Green + analyse + lint. Commit: `feat: themed Blade form component library`

### Task 12: DataTable wrapper + modal component

**Files:** `resources/views/components/datatable.blade.php`, `components/modal.blade.php`; test `tests/Feature/Components/DatatableModalTest.php`.

`x-datatable` wraps yajra's html-builder output region: renders `{!! $dataTable->table(['class' => 'table align-middle table-row-dashed fs-6 gy-4'], true) !!}` inside the Theme A card + toolbar slot (search box/filters slot + "Add" button slot gated by `@can`). `x-modal` copies the modal pattern from `inquiry.html` (spec: only modal reference) — props: `id`, `title`, footer slot.

- [ ] **Step 1 (failing test):** modal renders `modal fade`, id, title, slot content. Datatable component renders card wrapper + slots (pass a fake table string).
- [ ] **Step 2:** Implement. Also create `resources/views/layouts/partials/scripts.blade.php` **if Task 11 didn't already** — its final content is the `@stack('scripts')` include plus any select2 delegated init from Task 11; add `@stack('styles')` in the layout head — layouts include both.
- [ ] **Step 3:** Green + analyse + lint. Commit: `feat: datatable card wrapper + modal component`

### Task 13: jsvalidation integration + retrofit auth forms

**Files:** Modify `resources/views/auth/*.blade.php`, create `resources/views/components/form/jsvalidation.blade.php` helper include; test additions in `tests/Feature/Auth/AuthPagesTest.php`.

- [ ] **Step 1 (failing test):** `/login` page HTML includes `jsvalidation.js` script and a generated validation script block.
- [ ] **Step 2:** Publish check: `public/vendor/jsvalidation/js/jsvalidation.js` exists (from Task 2 publish). Create `app/Http/Requests/Auth/LoginPageRequest.php` mirroring Fortify's login rules (email required|email, password required) — used ONLY to drive jsvalidation on the page: `{!! JsValidator::formRequest(App\Http\Requests\Auth\LoginPageRequest::class, '#kt_login_form') !!}`. Same for forgot/reset pages. Rebuild the three auth forms with `x-form.*` components (Chunk 3 now exists).
- [ ] **Step 3:** Green + analyse + lint. Manual browser check: submitting empty login shows client-side errors without a request. Commit: `feat: client-side validation via jsvalidation + auth forms on component library`

---

## Chunk 4: Projects & Units module, seeds, docs, variant repo

### Task 14: Inventory schema — migrations, models, factories

**Files:**
- Create migrations (one file each, this order): `create_projects_table`, `create_project_wings_table`, `create_floor_plans_table`, `create_unit_types_table`, `create_units_table`
- Create: `app/Models/{Project,ProjectWing,FloorPlan,UnitType,Unit}.php`, enums `app/Enums/{ProjectStatus,ProjectType,WingType,UnitCategory,FloorType,UnitStatus,Facing}.php` (string-backed), factories for all five
- Test: `tests/Feature/Models/InventorySchemaTest.php`

Schema (columns per spec §7 + architecture doc; all tables `id`, timestamps, softDeletes unless noted):
- `projects`: name, code unique, project_type (string ← `ProjectType`), status (string ← `ProjectStatus` — **enum cases must be generated from the exact `<option>` values in `add-project.html`**; read the file), rera_no nullable, developer_name, launch_date date nullable, expected_possession date nullable, address_line1, address_line2 nullable, landmark nullable, city, state, pincode, latitude/longitude decimal(10,7) nullable, towers_count unsignedSmallInteger default 0, floors_per_tower unsignedSmallInteger nullable, total_units unsignedInteger default 0, total_plot_area decimal(12,2) nullable, built_up_area decimal(12,2) nullable, open_space_pct decimal(5,2) nullable, parking_slots unsignedInteger nullable, configurations string nullable, amenities json nullable. Index: status, project_type.
- `project_wings`: project_id FK cascadeOnDelete, name, wing_type (WingType: tower|block|phase|row|standalone), unit_category (UnitCategory: residential|commercial|retail|office|plot|villa), floors_count unsignedSmallInteger default 0, description nullable, sort_order unsignedSmallInteger default 0. Unique (project_id, name).
- `floor_plans`: project_wing_id FK cascadeOnDelete, floor_number smallInteger, floor_label string, floor_type (FloorType: parking|podium|residential|commercial|terrace|mixed), floor_rise_amount decimal(10,2) default 0, units_count unsignedSmallInteger default 0. Unique (project_wing_id, floor_number).
- `unit_types`: project_id FK cascadeOnDelete, name, category (flat|shop|office|plot|villa|penthouse|duplex|studio), carpet_area/built_up_area/super_built_up_area decimal(10,2) nullable, base_rate decimal(10,2) nullable. Unique (project_id, name).
- `units`: project_id FK (denormalized, cascadeOnDelete), project_wing_id FK, floor_plan_id FK, unit_type_id FK nullable nullOnDelete, unit_number string (note: the unique index below includes soft-deleted rows, so a trashed unit permanently reserves its number within the wing — accepted for now; revisit when the Cancellation module lands), status (UnitStatus: available|held|booked|registered|possessed|blocked|management_quota) default available, facing (Facing: north|south|east|west|north_east|north_west|south_east|south_west) nullable, super_built_up_area decimal(10,2) NOT NULL default 0 (pricing needs it); carpet_area/built_up_area/balcony_area/terrace_area decimal(10,2) nullable; loading_pct decimal(5,2) nullable, base_rate decimal(10,2) default 0, floor_rise decimal(10,2) default 0, corner_premium_pct/facing_premium_pct/discount_pct decimal(5,2) default 0, total_price decimal(14,2) default 0, held_until dateTime nullable, notes text nullable. Unique (project_wing_id, unit_number). Index: status, project_id, (project_id, status).

Models: relations both directions (`Project hasMany wings/unitTypes/units`, `ProjectWing hasMany floorPlans/units`, `FloorPlan hasMany units`, `Unit belongsTo` all four); `HasAudit` on all; enum casts; `amenities => 'array'`; date casts.

- [ ] **Step 1 (failing test):** factory-create a full tree (project → wing → floor → unit w/ type); assert relations resolve both ways, enum casts return enum instances, unique constraints throw on duplicates (wing name per project, unit_number per wing), soft delete of project cascades… (note: SoftDeletes does NOT cascade DB-level — assert instead that unit still exists and `Unit::whereHas('project')` behavior is documented; deleting is policy-level "projects.delete" and the controller must soft-delete children explicitly — cover in Task 17).
- [ ] **Step 2:** Implement migrations/enums/models/factories; `php artisan migrate:fresh`.
- [ ] **Step 3:** Green + analyse + lint. Commit: `feat: 5-table inventory schema with enums, models, factories`

### Task 15: Policies + route permission wiring

**Files:** `app/Policies/{ProjectPolicy,UnitPolicy,UnitTypePolicy,ProjectWingPolicy,FloorPlanPolicy}.php`; test `tests/Feature/Policies/InventoryPolicyTest.php`.

Project/Unit/UnitType policies: `viewAny/view` → `<module>.view`, `create` → `.create`, `update` → `.edit`, `delete` → `.delete`. ProjectWingPolicy + FloorPlanPolicy: ALL abilities delegate to `projects.edit` (spec §5).

- [ ] **Step 1 (failing test):** matrix test with role-seeded users: Admin passes everything; Sales can view projects/units but not create/edit/delete anything; Operations can manage units + view unit_types but not edit projects; wing/floor abilities track `projects.edit` exactly; Super Admin passes all via Gate::before.
- [ ] **Step 2:** Implement policies (auto-discovery by name convention; verify with `Gate::inspect`).
- [ ] **Step 3:** Green + analyse + lint. Commit: `feat: inventory policies mapped to permission matrix`

### Task 16: UnitPriceCalculator (spec §7 formula — server-authoritative)

**Files:** `app/Services/Inventory/UnitPriceCalculator.php`; test `tests/Unit/Services/UnitPriceCalculatorTest.php`.

- [ ] **Step 1 (failing tests, complete list):** base only (1000 sqft × ₹5000 = ₹50,00,000); floor rise adds to rate; corner 5% alone; facing 3% alone; both premiums additive on base_amount (NOT compounding — 5%+3% on base, not 3% on top of 5%); discount 10% applied last on gross; rounding to nearest rupee (case ending .5); zero area → 0; 100% discount → 0; negative inputs → `InvalidArgumentException`; percent > 100 → `InvalidArgumentException`.
- [ ] **Step 2:** Implementation (complete — this is the single site for the formula):
```php
namespace App\Services\Inventory;

use InvalidArgumentException;

class UnitPriceCalculator
{
    /**
     * Spec §7 formula (confirm with client before production):
     * total = round((rate+rise)*area * (1 + (corner+facing)/100 applied additively) * (1 - discount/100))
     */
    public function total(
        float $superBuiltUpArea,
        float $baseRate,
        float $floorRise = 0.0,
        float $cornerPremiumPct = 0.0,
        float $facingPremiumPct = 0.0,
        float $discountPct = 0.0,
    ): float {
        foreach (compact('superBuiltUpArea', 'baseRate', 'floorRise') as $name => $value) {
            if ($value < 0) {
                throw new InvalidArgumentException("{$name} must be >= 0");
            }
        }
        foreach (compact('cornerPremiumPct', 'facingPremiumPct', 'discountPct') as $name => $value) {
            if ($value < 0 || $value > 100) {
                throw new InvalidArgumentException("{$name} must be between 0 and 100");
            }
        }

        $baseAmount = ($baseRate + $floorRise) * $superBuiltUpArea;
        $premiums = $baseAmount * ($cornerPremiumPct + $facingPremiumPct) / 100;
        $gross = $baseAmount + $premiums;

        return round($gross * (1 - $discountPct / 100), 0);
    }
}
```
- [ ] **Step 3:** Green + analyse + lint. Commit: `feat: authoritative unit price calculator`

### Task 17: Projects CRUD

**Files:**
- Create: `app/Http/Controllers/ProjectController.php`, `app/Http/Requests/Projects/{StoreProjectRequest,UpdateProjectRequest}.php`, `app/DataTables/ProjectsDataTable.php`, `routes/modules/projects.php` (required from `web.php` inside `Route::middleware('auth')` group)
- Create views: `resources/views/projects/{index,create,edit,show}.blade.php` + `projects/partials/form.blade.php`
- Test: `tests/Feature/Projects/ProjectCrudTest.php`, `tests/Feature/Projects/ProjectsDataTableTest.php`

View sources: `project-management.html` (index — columns EXACTLY as ordered there: Project ID, Project Name, Type, Location, Developer, Towers, Floors, Total Units, RERA No., Status, Actions), `add-project.html`/`edit-project.html` (form sections: Basic, Location, Structure, Amenities checkbox-group), `view-project.html` (show: summary cards + wings/units grid region — wings/units content lands in Tasks 18/20; leave clearly-marked include points).

**Project code is auto-generated, not user input** (HTML renders it `readonly placeholder="Auto-generated"`): on store, the controller/model generates the next `PRJ-NNN` (zero-padded, max existing numeric suffix + 1, inside the store transaction); code is NOT in the FormRequest rules and any submitted value is ignored; edit form shows it readonly. The HTML's "CC / OC Status" select is **dropped this round** (no column; note in view as a comment). Validation (StoreProjectRequest — `authorize()` = `$this->user()->can('create', Project::class)`): name required|string|max:255; project_type/status required + `Rule::enum`; rera_no nullable|string|max:50; launch_date nullable|date (schema wins over the HTML's `required` attr — remove the asterisk) + expected_possession nullable|date|after_or_equal:launch_date; address_line1 required|string|max:255; address_line2/landmark nullable; city required (select — values from the HTML options) and state required|string; pincode nullable|digits:6; lat/lng nullable numeric ranges; counts nullable integers min:0; areas/percent nullable numeric min:0 (open_space_pct max:100); amenities nullable|array + each in the wireframe amenity list; configurations nullable|string|max:255. Index "Floors" column = `floors_per_tower`.

DataTable class (`php artisan datatables:make ProjectsDataTable`): explicit `select` of needed columns, `withCount(['wings', 'units'])` via subqueries for Towers/Total Units, **only whitelisted columns orderable/searchable**, actions column renders edit/view/delete buttons each behind `@can`.

- [ ] **Step 1 (failing tests):** guest → login redirect on every route; Sales (view-only) → 200 on index/show, 403 on create/store/edit/update/delete; Admin: full happy path — store persists w/ amenities json + auto-generated sequential `PRJ-NNN` code (assert two stores get distinct consecutive codes, and a posted `code` field is ignored) + activity log row exists; validation errors (pincode 5 digits, possession before launch, missing address_line1) return errors; update edits; delete soft-deletes project AND its wings/floor_plans/units (controller does `DB::transaction` explicit cascade); DataTable endpoint returns JSON with seeded rows, search by name filters, order by an un-whitelisted column is ignored (no 500, no SQL error).
- [ ] **Step 2:** Implement. Controller stays thin; jsvalidation on create/edit (`JsValidator::formRequest`); flash success via session + Theme A alert partial; index uses `x-datatable` + `ProjectsDataTable` builder; sidebar Projects entry becomes a live route (`@can('projects.view')`).
- [ ] **Step 3:** Green + analyse + lint. Commit: `feat: Projects CRUD with server-side datatable`

### Task 18: Wings & floor plans (nested under project)

**Files:**
- Create: `app/Http/Controllers/{ProjectWingController,FloorPlanController}.php` (nested resource: store/update/destroy only — no standalone pages), `app/Http/Requests/Projects/{StoreWingRequest,UpdateWingRequest,StoreFloorPlanRequest,UpdateFloorPlanRequest}.php`
- Modify: `resources/views/projects/show.blade.php` (+ new partials `projects/partials/wings.blade.php` with `x-modal` add/edit forms per wing + floor plan rows)
- Modify: `routes/modules/projects.php`
- Test: `tests/Feature/Projects/WingFloorCrudTest.php`

Validation: wing name required + unique per project (Rule::unique scoped, ignore self on update); wing_type/unit_category `Rule::enum`; floors_count int 0..250; floor_number required int −5..250 unique per wing; floor_label required; floor_type enum; floor_rise_amount numeric ≥ 0.

- [ ] **Step 1 (failing tests):** user WITHOUT `projects.edit` → 403 on all six write routes (this is the wing/floor policy delegation proof); with it → wing added/updated/deleted (delete blocked with validation error if wing has units); floor plan CRUD same incl. duplicate floor_number rejection.
- [ ] **Step 2:** Implement; forms live in modals on project show; after write redirect back to `projects.show` anchor `#wings`. Delete rules explicit for BOTH levels: a wing with units OR floor plans-with-units cannot be deleted; a floor plan with units cannot be deleted (validation error naming the blocker count). This is deliberately asymmetric with Task 17's project delete (which cascades): deleting a whole project is a rare, explicit teardown, while wing/floor deletes are routine structure edits where silently destroying units would be data loss — do not "fix" the asymmetry.
- [ ] **Step 3:** Green + analyse + lint. Commit: `feat: wings and floor plans managed within project`

### Task 19: Unit types CRUD

**Files:** `app/Http/Controllers/UnitTypeController.php`, `app/Http/Requests/UnitTypes/{StoreUnitTypeRequest,UpdateUnitTypeRequest}.php`, `app/DataTables/UnitTypesDataTable.php`, views `resources/views/unit_types/{index,create,edit}.blade.php`, route file `routes/modules/unit_types.php`, test `tests/Feature/UnitTypes/UnitTypeCrudTest.php`.

Settings-style page (spec §7): simple DataTable + form pages using the component library. Validation: name required unique per project; category enum; areas/base_rate numeric ≥ 0; project_id required exists. Sidebar: under Administration section, `@can('unit_types.view')`.

- [ ] **Step 1 (failing tests):** permission matrix (Operations = view only → 403 on writes; Admin full); CRUD happy path; duplicate name per project rejected; deleting a type used by units: controller soft-deletes the type; units RETAIN their `unit_type_id` (pointing at the trashed row — `nullOnDelete` fires only on force-delete, which we never do) and keep their own stored area/rate snapshot values. Assert exactly that: type trashed, `unit_type_id` unchanged, unit pricing fields unchanged.
- [ ] **Step 2:** Implement.
- [ ] **Step 3:** Green + analyse + lint. Commit: `feat: unit types CRUD`

### Task 20: Units CRUD + cascading selects + live price

**Files:**
- Create: `app/Http/Controllers/UnitController.php`, `app/Http/Controllers/Api/CascadeController.php`, `app/Http/Requests/Units/{StoreUnitRequest,UpdateUnitRequest}.php`, views `resources/views/units/{create,edit,show}.blade.php` + `units/partials/form.blade.php`, `public/assets/js/nrems-unit-price.js`, route file `routes/modules/units.php`
- Test: `tests/Feature/Units/UnitCrudTest.php`, `tests/Feature/Units/CascadeEndpointsTest.php`

View sources: `new-unit.html`/`edit-unit.html` (sections: selection cascade, areas, pricing with readonly Total, facing/status), `view-unit.html` (show page). The HTML's "Corner Unit" Yes/No select is kept as pure UI: choosing "No" disables and zeroes the Corner Premium input; the schema stores only `corner_premium_pct` (the boolean is derivable as pct > 0 and is not a column, not submitted).

Cascade endpoints (auth + `units.view`): `GET /api/projects/{project}/wings` → `[{id,name}]`; `GET /api/wings/{wing}/floor-plans` → `[{id,floor_label}]`; `GET /api/unit-types/for-project/{project}` → id/name/defaults (areas+base_rate) so choosing a type prefills fields client-side. JSON only, no pagination (bounded lists).

Store/Update validation: project/wing/floor ids required + **cross-checked for consistency** (wing belongs to project, floor belongs to wing — `Rule::exists` with where + explicit after() check returning a single clear error); unit_number required, unique per wing (ignore self on update); enums via `Rule::enum`; areas numeric ≥ 0 (super_built_up_area required); pct fields 0..100; rates ≥ 0. **Controller ignores any submitted total_price and recomputes via `UnitPriceCalculator`** (server-authoritative, spec §7).

`nrems-unit-price.js`: mirrors the formula for live display on input events; marked with a header comment `// Mirror of App\Services\Inventory\UnitPriceCalculator — server value is authoritative`.

- [ ] **Step 1 (failing tests):** permission matrix (Sales view-only; Operations full on units); happy create/update — assert stored `total_price` equals calculator output EVEN when the request posts a bogus total_price; mismatched wing/project rejected with validation error; duplicate unit_number in wing rejected; cascade endpoints: correct children only, guest → 401/redirect, wrong-permission user → 403; unit show renders; activity log row on update showing changed attributes.
- [ ] **Step 2:** Implement (form via `x-form.select2` cascades + jsvalidation; disable child selects until parent chosen).
- [ ] **Step 3:** Green + analyse + lint. Manual browser: cascade + live price. Commit: `feat: units CRUD with cascading selects and authoritative pricing`

### Task 21: Unit inventory page (standalone DataTable)

**Files:** `app/DataTables/UnitsDataTable.php`, `app/Http/Controllers/UnitInventoryController.php` (index only), view `resources/views/units/index.blade.php`, route in `routes/modules/units.php`, test `tests/Feature/Units/UnitInventoryTest.php`.

Source: `unit-inventory.html`. The HTML actually carries five filters (Project, Type, Floor, Status, Facing) + a unit-no search box. **Scope decision (overrides both the "HTML wins" rule and spec §7's filter list): implement Project, Wing (spec addition), Type, Status, Facing filters + the unit-number search; the Floor filter is deliberately deferred** (meaningless without wing context; revisit with the Bookings module). All as select2s above the table, wired as DataTable ajax params; status badges via `x-status-badge` (spec: no "sold" — enum only). Eager: `with(['project:id,name','wing:id,name','unitType:id,name'])` + explicit select; whitelisted order/search columns only.

- [ ] **Step 1 (failing tests):** permission gate (`units.view`); JSON endpoint filters by each of the five params singly and combined; search by unit_number; no N+1 (assert query count with `expectsDatabaseQueryCount` on a fixed seed — pick the number after implementing, then pin it).
- [ ] **Step 2:** Implement; sidebar Units entry live.
- [ ] **Step 3:** Green + analyse + lint. Commit: `feat: unit inventory listing with filters`

### Task 22: Demo seeds, responsive smoke, docs

**Files:**
- Create: `database/seeders/DemoProjectSeeder.php` (registered in DatabaseSeeder AFTER roles/admin), `docs/adding-a-module.md`, `README.md` (replace stock), smoke script `tools/responsive-smoke.mjs`
- Test: `tests/Feature/Seeds/DemoSeedTest.php`

Seed data (spec §7): exactly 2 projects — **Navratna Gulmohar Park, code PRJ-009** and **Kalhaar Sky Residency, code PRJ-010** (spec codes win over HTML dummy codes). Pull names/locations/RERA/tower counts/status text from `project-management.html` rows + `view-project.html`; invent consistent wings/floors/units (per project: ≥2 wings, ≥3 floors each, ≥12 units across statuses available/held/booked/registered — NO "sold"), 3–4 unit types each. All units' total_price computed through `UnitPriceCalculator` in the seeder (never hand-typed).

- [ ] **Step 1 (failing test):** `php artisan db:seed` then: exactly 2 projects with those codes; every unit's total_price === calculator recomputation; counts (`total_units`) match actual unit rows.
- [ ] **Step 2:** Implement seeder.
- [ ] **Step 3:** `tools/responsive-smoke.mjs` (`cd tools && npm init -y && npm i puppeteer` — `tools/` gets its own package.json + node_modules, both git-ignored): logs in as seeded admin, screenshots dashboard, projects index, project show, unit edit, unit inventory, login, **and `/dev/tab-demo`** at widths 390/768/1280 into `docs/superpowers/evidence/responsive/`; fails if horizontal scrollbar detected on `<body>` (`document.body.scrollWidth > window.innerWidth + 1`); on `/dev/tab-demo` at 390px additionally asserts the tab strip scrolls rather than clips (`nav.scrollWidth > nav.clientWidth` allowed, but every `.nav-item` fully visible when scrolled — i.e., no zero-width items, and body has no horizontal overflow). This is the spec §2 tab-fix verification. Run it; fix any overflow found (theme CSS level).
- [ ] **Step 4:** Write `docs/adding-a-module.md`: the exact replication recipe (migration+enum → model w/ HasAudit → factory → policy+permissions seeder entry → FormRequests → DataTable class → controller → routes/modules file → views with x-form components → jsvalidation → tests checklist → sidebar entry), referencing real Projects-module files as the canonical example. README: setup (clone → composer install → env → migrate --seed), toolchain commands, security notes (`APP_DEBUG=false`, SEED_ADMIN_PASSWORD, deploy checklist), theme asset provenance + Metronic license note.
- [ ] **Step 5:** Full suite: `composer test && composer analyse && composer lint`. Commit: `feat: demo seeds, responsive evidence, module guide + README`

### Task 23: Variant repo — nrems-app-html (spatie/laravel-html)

**Files:** new repo `/home/vivek/nrems-app-html` (full clone), form layer swapped.

- [ ] **Step 1:** `git clone /home/vivek/nrems-app /home/vivek/nrems-app-html && cd /home/vivek/nrems-app-html && composer install`. Then, explicitly (clone copies only tracked files): `cp .env.example .env`, `php artisan key:generate`, set `DB_DATABASE=nrems_html` in `.env`; create DBs `nrems_html` + `nrems_html_test` (same `nrems` user grant); **edit `phpunit.xml` to `DB_DATABASE=nrems_html_test`** — it is tracked and still points at `nrems_app_test`; skipping this makes the variant's suite wipe the primary repo's test DB. `php artisan migrate --seed` to verify boot. Then `composer require spatie/laravel-html`.
- [ ] **Step 2:** Replace the INTERNALS of each `resources/views/components/form/*.blade.php` to build elements via `html()` (spatie) while keeping the exact same component API and rendered classes — controllers/requests/pages untouched. This proves the swap is contained to the form layer (the point of the comparison). Delete nothing else.
- [ ] **Step 3:** Full suite green (`composer test && composer analyse && composer lint`) — the Task 11 component tests are the contract; they must pass unmodified except assertions on internal markup ordering if spatie output differs benignly (document any such diff in the repo's README).
- [ ] **Step 4:** README note at top: "Variant of nrems-app using spatie/laravel-html for form rendering; see nrems-app for the Blade-component original."
- [ ] **Step 5:** Commit in the new repo: `feat: spatie/laravel-html form layer variant`

---

## Execution notes for the orchestrator (Fable)

- Tasks run **sequentially** (each builds on the last) via subagent-driven development; implementer model: Opus. Fable reviews every task's diff before the next starts.
- Any deviation from spec discovered mid-task → stop, surface, decide; don't improvise schema or formula changes.
- Laravel 13 API drift risk: if any command/config named here differs in v13 (e.g. `fortify:install`, Pest init, policy discovery), the implementer verifies against installed vendor docs and adapts, noting the delta in the task report.
