# v12 Projects Realignment 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:** Realign the Projects module to wireframe v12: twin standalone create/edit blades on spatie/laravel-html forms, 4 secure file uploads, CSV-driven Unit Inventory that creates unassigned units, and global promoter/label/sidebar alignment.

**Architecture:** Spec at `docs/superpowers/specs/2026-08-24-v12-projects-spatie-design.md` (rev 4, approved after 4 review rounds — READ IT FIRST, it is the contract; §ref below means that spec). Markup source of truth: `docs/wireframes-v12/add-project.html`, `edit-project.html`, `view-project.html`. House conventions: `docs/adding-a-module.md`, existing components in `resources/views/components/form/`.

**Tech Stack:** Laravel 13 · PHP 8.3 · MySQL 8 · spatie/laravel-html · yajra datatables · proengsoft/jsvalidation (remote disabled) · Pest · Larastan lvl 6 · Pint.

**Rules for every task:** TDD (failing test first). Gates before every commit: `composer test`, `composer analyse`, `composer lint`. ONE test-suite runner at a time (shared `nrems_html_test` DB). Working dir `~/nrems-app-spatie-html`, branch `feat/v12-projects`. Never touch `~/nrems-app`.

---

## Chunk A: Schema + request foundations

### Task A1: The migration (§3)

**Files:**
- Create: `database/migrations/2026_08_24_100000_realign_projects_and_units_to_v12.php`
- Modify (models/data): `app/Models/Project.php` (fillable, casts, @property), `app/Models/Unit.php` (@property → `?int` for both FKs), `database/factories/ProjectFactory.php`, `database/seeders/DemoProjectSeeder.php`
- Modify (mechanical rename sweep — A1's gates cannot pass without ALL of these): `app/DataTables/ProjectsDataTable.php` (WHITELISTED_COLUMNS ~:73 + select ~:135), `resources/views/projects/show.blade.php`, `resources/views/projects/partials/form.blade.php` (still shared at this point), `lang/en/projects.php`, `app/Http/Requests/Projects/StoreProjectRequest.php`, `tests/Feature/Projects/ProjectCrudTest.php`, `tests/Feature/Projects/ProjectsDataTableTest.php`, `tests/Feature/Models/InventorySchemaTest.php`
- Modify (deliberate behaviour reversals — SAME commit as the index swap, they go red the moment it lands): `tests/Feature/Models/InventorySchemaTest.php` "allows the same unit number under a different wing" → assert rejection; `tests/Feature/Units/UnitCrudTest.php` "accepts the same unit number under a different wing" → assert rejection
- Test: `tests/Feature/Models/V12SchemaTest.php` (schema shape) + `tests/Migration/V12MigrationConversionTest.php` (conversion; NEW testsuite)
- Modify: `phpunit.xml` (third `<testsuite>` for `tests/Migration`), `tests/Pest.php` (bind `tests/Migration` to `Tests\TestCase` + `DatabaseMigrations` — NOT RefreshDatabase; also fix the stale `nrems_app_test` comment while in there)

- [ ] Write failing tests: schema shape in V12SchemaTest (new columns, nullability, `(project_id, unit_number)` unique blocks cross-wing duplicates); **migration data-conversion test (§9.1) in `tests/Migration/V12MigrationConversionTest.php` — NOT under tests/Feature:** `RefreshDatabase` wraps Feature tests in a transaction that DDL implicitly commits, so rollback/replay there poisons the shared test DB. This suite uses `DatabaseMigrations` (re-migrates per test, self-healing on mid-test failure). Test body: `artisan migrate:rollback --step=1` inside try/finally that ALWAYS re-runs `migrate` → `DB::table('projects')->insert([...old column names..., 'developer_name' => 'X', 'expected_possession' => d, 'total_plot_area' => 900])` → `artisan migrate` → assert promoter_name === 'X', expected_completion_date === d, total_land_area === 100.00.
- [ ] Migration up(), in THIS order:
  1. Precondition (§3): abort listing any cross-wing duplicate `(project_id, unit_number)` pairs.
  2. projects: `renameColumn('developer_name','promoter_name')`, `renameColumn('expected_possession','expected_completion_date')`, `renameColumn('total_plot_area','total_land_area')` then `DB::statement("UPDATE projects SET total_land_area = ROUND(total_land_area / 9, 2) WHERE total_land_area IS NOT NULL")` (sq ft → sq yd).
  3. projects: add new columns per §3 list (description TEXT; promoter_contact string(20); survey_number; google_maps_link/website_url/youtube_link string(512); rera_valid_until/construction_start_date DATE; environmental_clearance BOOLEAN nullable; na_permission_number, building_permission_number, commencement_certificate_number strings; 4× `{slot}_path` + 4× `{slot}_original_name` string(255)) — all nullable.
  4. units — **errno-1553 sequence, do not reorder (§3):** `index('project_wing_id')` → `dropUnique(['project_wing_id','unit_number'])` → `unique(['project_id','unit_number'])` → both FKs `->nullable()->change()`.
- [ ] down(): abort with clear message if `whereNull('project_wing_id')` units exist; reverse in mirror order (re-add composite unique BEFORE dropping the plain index); ×9 conversion back; rename-backs; drop new columns. Docblock documents partial irreversibility.
- [ ] Model updates: Project fillable+casts (§3 — environmental_clearance bool, 3 date casts), @property block; Unit @property `?int`.
- [ ] Factory/seeder: rename fields; supply floors_per_tower always; express land area in sq.yards; promoter names per v12 sample data.
- [ ] `php artisan migrate:fresh --seed` clean on dev DB; run gates; commit `feat(schema): v12 projects columns + project-scoped unit uniqueness + nullable wing/floor`.

### Task A2: Request/validation realignment (§3)

**Files:**
- Modify: `app/Http/Requests/Projects/StoreProjectRequest.php`, `UpdateProjectRequest.php`, `app/Http/Requests/Units/StoreUnitRequest.php`, `UpdateUnitRequest.php`
- Test: extend `tests/Feature/Projects/ProjectCrudTest.php` (or equivalent existing file)

- [ ] Failing tests: required set per §3 (incl. floors_per_tower); URL rules; promoter_contact regex; date ordering; environmental_clearance `nullable|in:1,0` with `''`→null prepareForValidation; project-scoped unit uniqueness in Store/UpdateUnitRequest incl. soft-deleted collision message.
- [ ] Update stale docblock prose (per-wing uniqueness) in `StoreUnitRequest`, `StoreWingRequest`, `StoreUnitTypeRequest`, `UnitController::destroy`, `UnitFactory`, units migration note; update StoreProjectRequest city comment (§9.5 list).
- [ ] Gates; commit `feat(validation): v12 project rules + project-scoped unit uniqueness`.

### Task A3: `x-form.open`/`x-form.close` via html()->form() (§2)

**Files:**
- Create: `resources/views/components/form/open.blade.php`, `close.blade.php`
- Modify: every data-entry `<form ...>` in `resources/views/{projects,units,unit_types,auth}/` and modal/wings partials (POST forms only; GET filter forms stay plain — documented boundary in component docblock)
- Test: `tests/Feature/Components/FormOpenTest.php`

- [ ] Failing tests: renders `<form>` with id/action/novalidate, hidden CSRF, method spoof for PUT/DELETE, `enctype` when `:multipart="true"`; close renders `</form>`.
- [ ] Implement via `html()->form($method, $action)` → `->open()` / `html()->form()->close()`; spatie handles spoofing+token.
- [ ] Convert existing POST forms (login, forgot/reset, logout, unit_types form, units form, projects (still shared partial at this point), wings/floors modals, delete actions). Existing feature tests are the regression net.
- [ ] Gates; commit `feat(components): x-form.open/close via spatie html()->form(), convert POST forms`.

### Task A4: `x-form.file` component (§8)

**Files:**
- Create: `resources/views/components/form/file.blade.php`
- Test: `tests/Feature/Components/FormComponentsTest.php` (extend)

- [ ] Failing tests: hidden `<input type="file">` inside clickable Theme A upload-zone label; accept attr; hint line renders types+max; error state (is-invalid, aria); current-file block (escaped original name + download href + replace copy) only when `:current-name`/`:current-url` given; name/id conventions match existing components.
- [ ] Implement via `html()->file()` + §8 requirements (spatie attribute-bag translation pattern copied from input.blade.php).
- [ ] Gates; commit `feat(components): x-form.file upload-zone component`.

### Task A5: `x-form.radio` component (§8)

**Files:** Create `resources/views/components/form/radio.blade.php`; test in `FormComponentsTest.php`.

- [ ] Failing tests: group renders one input per option, strict-string checked resolution via form_old, inline/stacked variants, error wiring, 0-value option selectable.
- [ ] Implement via `html()->radio()`; gates; commit `feat(components): x-form.radio group component`.

## Chunk B: Uploads + projects twin views

### Task B1: Upload storage + download route (§5)

**Files:**
- Create: `app/Services/Projects/ProjectFileService.php`, `app/Http/Controllers/ProjectFileController.php`
- Modify: `routes/modules/projects.php` (download route with closed slot enum via `whereIn`), `config/filesystems.php` only if needed (local disk exists)
- Test: `tests/Feature/Projects/ProjectFileTest.php`

- [ ] B1 tests ONLY what B1 owns (service + download route, no form path exists yet): service store/replace-deletes-old/naming/disk placement exercised directly; download needs `projects.view`, returns attachment + explicit stored Content-Type + nosniff; guest → login; invalid slot → 404; empty slot → 404. **The upload-acceptance tests through the real form path (valid per slot, oversize per slot, wrong-MIME-right-extension crafted file, php-in-png polyglot) belong to B2 (store) and B3 (replace on update) — B2/B3 own the FormRequests and controller wiring.**
- [ ] DECIDE + implement deliberately (A1 carry-forward): upload columns are excluded from $fillable (path-tamper safety), so `HasAudit`'s logFillable() will NOT log uploads — add explicit activity() log entries for store/replace (a replaced brochure is exactly what an audit trail wants), or document why not.
- [ ] Service API: `store(Project $p, string $slot, UploadedFile $f): void` (validated upstream; stores as `{slot}-{random40}.{ext}` on local disk under `projects/{id}/`, deletes prior file, fills `{slot}_path`+`{slot}_original_name`), `download(Project $p, string $slot): Response` via `response()->download($abs, $sanitisedName, ['X-Content-Type-Options'=>'nosniff','Content-Type'=>$storedType])` — framework `HeaderUtils::makeDisposition` path (§5); belt-and-braces control-char/path-separator strip.
- [ ] Rule strings (added to the FormRequests in B2/B3, recorded here as the single source: rera_certificate `file|mimes:pdf,jpg,jpeg,png|mimetypes:application/pdf,image/jpeg,image/png|max:5120`; brochure pdf 10240; logo png,jpg,jpeg 2048; site_plan pdf,png,jpg,jpeg 5120).
- [ ] Gates; commit `feat(projects): secure file slots + authorized download`.

### Task B2: `projects/create.blade.php` — full v12, standalone (§4)

**A2-approved prose fixes to fold in (reviewer-prescribed, exact):** (1) StoreProjectRequest ~:81-85 backstop paragraph — without the middleware the result is a spurious in:1,0 error on the '' placeholder (strict in_array), NOT silently-stored false; point back at the placeholder-rejection consequence. (2) jsvalidation drop reason (StoreProjectRequest ~:50-54 + ProjectCrudTest ~:196-198): Url is on jsvalidation's $serverRules BY NAME (RuleListTrait), parameters irrelevant; a bare url renders nothing client-side either, so switching gains zero and loses the scheme check. (3) StoreProjectRequest ~:132-136 city comment currently describes the post-B2 state in present tense — accurate once B2 makes city free-text; update alongside. Also: promoter_contact help text (if rendered) must reuse projects.validation.promoter_contact, never a drifting hints.* twin.

**Files:**
- Rewrite: `resources/views/projects/create.blade.php` (all fields inline; twin-rule docblock)
- Modify: `app/Http/Controllers/ProjectController.php` (store: new fields + uploads via ProjectFileService)
- Test: extend ProjectCrudTest + a jsvalidation test

- [ ] Failing tests: create page shows every §4 section/field (assert on stable lang-key labels + input names); store persists new fields + uploads; **upload acceptance through the form (from B1's deferred list): valid upload per slot, oversize per slot, wrong-MIME-right-extension crafted file, php-in-png polyglot rejected;** jsvalidation config on the page is parseable, has no `laravelValidationRemote`, and excludes file-slot + `units.*` keys (§8). A2 discovery: jsvalidation SILENTLY DROPS the parameterised `url:http,https` rule (fields render Nullable+Max only) — client-side laxness only, server authoritative; do NOT 'fix' with bare `url` (reintroduces the javascript: hole); assert the known exclusion set explicitly in the §8 test.
- [ ] B1 carry-forwards: (a) call ProjectFileService::store() AFTER the persistence closure returns — NEVER inside the RetryOnUniqueCollision/DB::transaction closure (a retry would write a second file and orphan the first); (b) build the `mimetypes:` half of upload rules from the SAME enum as `mimes:` (extend ProjectFileSlot with a mimeTypes() method) OR add a test asserting every extensions() entry has a mimetypes: counterpart — the two lists must not drift.
- [ ] Lang labels to v12 wording exactly (A1 carry-forward list): fields.promoter_name → "Developing Entity Name"; floors_per_tower → "Number of Floors"; towers_count → "Number of Towers/Blocks"; rera_no → "RERA Registration Number"; built_up_area → "Built-up Area (sq.ft)"; open_space_pct → "Open Space %" (columns.promoter stays "Promoter").
- [ ] **`<x-form.open :multipart="true">` on BOTH project twins (A3 carry-forward, cannot be caught by tests: Laravel's test client sends multipart regardless of enctype — only the D3 headless flow or a human would see files silently not arriving).**
- [ ] Build the view from `docs/wireframes-v12/add-project.html` translated to Theme A grid + x-form.* components: Basic Information (incl. description textarea), Location (city free-text input), Structure (amenity checkbox grid nested inside, §4), Compliance & Legal (4× x-form.file), Marketing & Media. NO Unit Inventory section yet (Chunk C). Footer Cancel + Save Project.
- [ ] Boolean select: environmental_clearance Yes/No select — value expression VERBATIM: `:value="$project->environmental_clearance === null ? null : (int) $project->environmental_clearance"`; round-trip test lands in B3 where edit exists.
- [ ] Gates; commit `feat(projects): standalone v12 create view + store`.

### Task B3: `projects/edit.blade.php` — standalone twin (§4)

**Files:** Rewrite `resources/views/projects/edit.blade.php`; modify ProjectController@update; delete `resources/views/projects/partials/form.blade.php`; tests as B2 for edit/update + **saved-"No" environmental_clearance round-trip (§9.1)** + upload replace flow (replace deletes old file — B1's deferred test, via the real update path).
- [ ] B2 carry-forwards: mirror :multipart, the verbatim :value expression, the :except line (UpdateProjectRequest inherits clientRuleExclusions()), the four x-form.file blocks with :current-name/:current-url, wire storeUploads() into update(); mirror the section-split placement test for edit (RERA in Compliance & Legal, other three opening Marketing & Media); DELETE partials/form.blade.php + fix create.blade.php docblock's past-tense 'replaced'; finish the CITY_OPTIONS comment truth (edit path select2 gone).

- [ ] Same sections as create + tab strip ("Project Details" single tab per v12) + each file slot shows current file (escaped original name, download link, replace control). Twin-rule header docblock in this file too (§2 — both twins carry it).
- [ ] Delete the shared partial; grep `projects.partials.form` → empty.
- [ ] Gates; commit `feat(projects): standalone v12 edit view + update; drop shared form partial`.

### Task B4: Twin-parity test (§2)

**Files:** Create `tests/Feature/Projects/TwinViewParityTest.php`.

- [ ] Implement per §2: authenticated GETs of create+edit; extract `name=` sets and required-attribute sets; assert equal after excluding `_token`, `_method`, select2/checkbox-group helper hiddens. Include a comment stating the §2 limitation (labels/order/rules not covered).
- [ ] Gates; commit `test(projects): twin create/edit parity guard`.

### Task B5: show + index v12 (§4)

**Files:** Modify `resources/views/projects/show.blade.php`, `app/Http/Controllers/ProjectController.php@show`, `app/DataTables/ProjectsDataTable.php`, `lang/en/projects.php`, `lang/en/units.php`.

- [ ] Failing tests: show renders Promoter labels + new fields; Download Brochure button present only when slot filled, targets download route; unassigned units render under `units.unassigned` group header LAST (orderByRaw `project_wing_id IS NULL` in the units eager-load closure — §4 pre-cleared); index column header Promoter.
- [ ] `units.unassigned` key adopted on the three §4 surfaces (wing cell only; floor stays em-dash).
- [ ] Remove `parking_slots` and `configurations` from show.blade.php (§3 — columns stay in DB, UI drops them).
- [ ] A1 carry-forward: `summary.possession` label on show → "Expected Completion Date".
- [ ] Gates; commit `feat(projects): v12 show/index — promoter labels, brochure download, unassigned group`.

## Chunk C: Unit Inventory import

### Task C1: Server-side unit creation (§6)

**Files:** Modify `StoreProjectRequest`/`UpdateProjectRequest` (units array rules), `ProjectController@store/@update`; test `tests/Feature/Projects/ProjectUnitImportTest.php`.

- [ ] Failing tests: `units => sometimes|array|max:500` (501 rows → 422, no queries against rows); integer-key guard; row rules (`unit_number` regex ≤20, `carpet_area` 1–999999.99); payload-duplicate row-keyed errors; existing + soft-deleted collision row-keyed errors; transactional create (project + units all-or-nothing); created units: wing/floor null, status available, zero-default prices, `total_price` unpostable; **UniqueConstraintViolationException from unit inserts rethrown as ValidationException keyed `units.N.unit_number` INSIDE the RetryOnUniqueCollision wrapper — assert no code burn (§6: nextCode unchanged after the failure)**; edit = additions only. (No UI exists yet — direct POSTs; correct order: server first so the form is never live without handling.)
- [ ] Gates; commit `feat(projects): CSV/manual unit rows create unassigned units (server)`.

### Task C2: Client module `nrems-unit-import.js` + form wiring (§6)

**Files:**
- Create: `public/assets/js/nrems-unit-import.js` (ES5, self-contained, mirrors nrems-unit-price.js style)
- Modify: both `projects/create.blade.php` + `edit.blade.php` (Unit Inventory section per v12 markup: header Upload File label+hidden input accept=".csv", info line, `#unitInventoryRows`, one editable manual row + add-row button, old('units') re-render with from_file marker per §6)
- Test: `tests/Feature/Projects/ProjectUnitImportViewTest.php` (server-rendered aspects: section present, old-input re-render read-only rows + row errors + summary alert; **edit form shows existing-units count + link to the Units module, §6**) — JS behaviour itself is covered headlessly in D3

- [ ] Parser requirements (§2/§6): FileReader, reject >1MB pre-read, BOM/CRLF/quoted fields, header auto-skip (row 1 second cell non-numeric), take first two columns, ignore extras, 500-row cap, per-line error list alert, read-only rows via `.value`/`createTextNode` ONLY (never innerHTML), from-file badge + per-row remove + "N units from {file}" + clear-all, re-upload replaces parsed rows and keeps manual rows, submit-time required/numeric mirror.
- [ ] Gates; commit `feat(projects): unit inventory section + CSV client module`.
- [ ] Manual e2e sanity on dev server before commit: create a project with 2 manual rows + fixture CSV; verify units exist unassigned.

### Task C3: units + unit_types twins (§ scope S3)

**Files:** Inline `units/partials/form.blade.php` into `units/create.blade.php` + `edit.blade.php`; same for `unit_types`; delete both partials; extend TwinViewParityTest to cover both modules; carry the twin-rule docblock.

- [ ] Existing CRUD tests are the net; parity tests the guard. Grep `partials.form` → empty repo-wide.
- [ ] Gates; commit `refactor(units,unit_types): standalone twin create/edit views`.

## Chunk D: Global alignment + QA

### Task D1: Sidebar + labels (§7)

**Files:** `resources/views/layouts/partials/sidebar.blade.php` (remove Resale/Transfer; confirm section order vs v12; keep documented Unit Inventory + Unit Types additions), `lang/en/nav.php`, remaining label sweeps.

- [ ] Failing test: sidebar has no resale item; sections per v12.
- [ ] C3 carry-forward (spec-review prescribed): 5-line test asserting the two unit twins' inline cascade <script> blocks are byte-identical (200 duplicated lines currently unguarded; extraction to nrems-unit-cascade.js recorded as a future follow-up, not now). §9.5 greps (`developer_name|total_plot_area|expected_possession|unit_no` + per-wing prose) end empty outside migrations. Gates; commit.

### Task D2: Docs (§7, §9.6)

**Files:** `docs/adding-a-module.md` (twin-file rule replaces shared-partial step; x-form.open/file/radio; v12 source of truth; CSV pattern), `README.md` (deploy: storage dir, upload_max_filesize/post_max_size ≥ 12M, post_max_size body-drop note).

- [ ] Commit `docs: v12 conventions — twin forms, new components, upload limits`.
- [ ] Orchestrator (not subagent): update out-of-repo memory (nrems-boilerplate.md + MEMORY.md + claude-mem) — v12 canonical, this repo only active one, Theme A pages stale for future modules, plus final state of this build (§7/S8).

### Task D3: Browser evidence + headless CSV flow (§9.3)

**Files:** extend `tools/responsive-smoke.mjs`; evidence to `docs/superpowers/evidence/responsive-v12/`.

- [ ] BUILD ITEM pulled into D3 (D2 discovery — spec §5 assumed it, never built): client-side file.size guard on the four upload slots. Small JS (house ES5 style or inline via x-form.file enhancement) checking file.size against a data-max-kb attribute on choose, inline message via the existing {field}_hint / error markup, blocking nothing server-side (server authoritative). The brochure 10MB case is the one that otherwise risks the empty-body/419. Verify it live in the headless flow. DECLARED SCOPE ADDITION to D3 (spec-review conditions): rendered-attribute assertion + browser evidence; same commit must remove README's gap paragraph and transfer the 'unlikely oversized pick' credit from the hint to the guard.
- [ ] Visual-parity check (A4 spec-review note): the upload zones render a generic forms.upload.prompt where v12 shows per-slot <h5> titles (slot name carried by the field label instead — deliberate); confirm acceptable in screenshots, don't discover late.
- [ ] 390/768/1280 shots: create (all sections, upload zones, inventory rows), edit (current-file slots), show (Promoter, Download Brochure, Unassigned), units index with unassigned unit. Headless flow: fixture CSV → read-only rows → re-upload replaces → submit → units exist → failed submit re-renders rows + errors.
- [ ] Commit evidence + tool changes.

### Task D4: Final acceptance + security review

- [ ] Full gates on clean DB (`migrate:fresh --seed`, `composer setup` from scratch per §9.5).
- [ ] Independent security review agent over: upload pipeline, download headers, CSV surface (mass-assignment, XSS via cells/filenames), §9.4 list. Include B1 spec-review note: Laravel 11 registers unauthenticated signed-URL storage/{path} GET/PUT routes for the local disk ('serve' => true default) — nothing mints signed URLs today, but consider 'serve' => false since the app never uses it (removes a policy-bypassing second path to project files). Fix anything found (loop).
- [ ] Final spec-§9 acceptance check; update PROGRESS notes; merge `feat/v12-projects` → main.

## CR-5 tasks (spec §15) — branch feat/cr5-client-feedback
- [x] CR5-A: Demolition (spec §15.1) + units slim + created_by (§15.2.1–2). Migration 1.
- [x] CR5-B: project_types JSON multi (§15.2.3, §15.3.1) + optional trio (§15.2.4, §15.3.2) + error-below-dropdown (§15.3.3). Migration 2.
- [x] CR5-C: Unit Inventory CSV/XLS/XLSX via SheetJS (§15.3.5) + replace-on-new-file UX (§15.3.6) + server replace semantics (§15.4) + show tab columns (§15.5) + remove Manage-units link (§15.3.4).
- [x] CR5-D: combined review + docs (README troubleshooting §15.6, plan log, fidelity registry) + browser evidence.
- [ ] CR5-E: handoff package rebuild (§15.7's last line). NOT done — the package
      lives outside the repo and is rebuilt from a merged branch.
Process: NO full suite run (client instruction) — targeted Pest files + analyse + lint per commit; full run deferred, recorded in §15.7.

### CR-5 task log

| Commit | What landed |
|---|---|
| `732d10a` | **CR5-A.** Wings, floor plans, unit types and the standalone units module removed — controllers, requests, models, policies, enums, DataTables, routes, views, sidebar entries, permissions, factories and the tests that existed to cover them. `units` slimmed to four columns plus `created_by`; the three tables dropped. |
| `c26561f` | **CR5-B.** `project_type` → `project_types` JSON array with an AsEnumCollection cast and a multi-select; tower count, floor count and RERA number made optional in both the schema and the rules; jQuery Validation's `errorPlacement` overridden so a client-side message lands under the control like the server-rendered one, select2 included. |
| `f7ae27e` | **CR5-C.** `.xls`/`.xlsx` accepted alongside `.csv`, all three parsed through vendored SheetJS on one code path; a chosen file hides the saved rows and warns that saving replaces them; on update a non-empty `units` array hard-deletes the project's inventory and inserts the file's rows in the same transaction, logging both counts; the show page's Inventory tab cut to three columns. |
| — | **CR5-D1**, adversarial review of the three above. Seven findings: one blocker, one should-fix, five smaller. |
| `a6ffd88` | **CR5-D2**, the fixes. The blocker: a rejected UPDATE re-rendered the file's rows beside the saved units it was about to replace, with no warning and a hidden clear-all button — the module never runs on a re-render, because no `change` event fires there — so correcting the rejected field and saving again deleted every saved unit silently. The view now renders that state itself. Also: the slim-units migration made genuinely re-runnable (its docblock had claimed it already was); `v12-evidence.mjs` and `v12-fixtures.php` pruned of the removed units index and its "Unassigned" assertions; `d-block` on client-side validation messages; two docblocks corrected. Finding 7 (`form_error_message` missing `project_types.0`) proved **not reproducible** and pinned with unit tests instead of "fixed". |

Two things the review turned up that were nobody's finding: `v12-fidelity.mjs`
still walked the three removed unit pairs, and both browser tools deleted their
evidence directory's hand-written README on startup. Both fixed in `a6ffd88`.

**The full suite has still not been run** (client instruction, spec §15.7).
Every commit above is gated on targeted Pest files, `composer analyse` and
`composer lint`, and both browser tools are green. Run `composer test` before
any further feature work — that is the debt CR-5 knowingly took on.
