# NREMS v12 Realignment — Projects Module, spatie/laravel-html Forms, CSV Unit Inventory

Date: 2026-08-24 · Status: approved by user (chat), rev 4 after spec review · Repo: `~/nrems-app-spatie-html` (renamed from `nrems-app-html`)

## 0. Context and decisions already made

The boilerplate (spec 2026-08-23) was built from Theme A HTML, which derives from wireframe **v10**. The client-canonical wireframe is now **v12**, vendored at `docs/wireframes-v12/` (identical to https://nrems-wireframes.virtualcarry.com/v12/). User decisions, final:

1. **This repo is the only active repo.** `~/nrems-app` (Blade components) is frozen as reference. This repo was renamed `nrems-app-spatie-html`.
2. **Unit Inventory rows on Add/Edit Project create real `units` rows** with wing/floor unassigned (nullable FKs), placed later via the Units module.
3. **The 4 project file uploads are fully implemented** (validated storage + authorized download), not UI-only.
4. Forms use **spatie/laravel-html** for form/input/select/checkbox/file/radio tags. House style for all future modules.
5. **Separate create and edit blade files per module, all fields inline — no shared form partial.** Applies to projects, units, unit_types and future modules.

Standing constraints from the boilerplate spec remain: security-first, gates (Pest green, Larastan level 6, Pint), Theme A markup/classes, server-authoritative data, policies + FormRequests on every write, `HasAudit`, DataTable whitelist rules, `Js::from()` rule, jsvalidation with remote validation disabled, and the soft-delete-uniqueness rule (validation must never be laxer than DB unique indexes, which include soft-deleted rows).

## 1. Scope

In scope: S1 repo bookkeeping (done; docs reflect it) · S2 `x-form.file`/`x-form.radio` + `html()->form()` wrapper for data-entry forms · S3 twin create/edit blades for projects, units, unit_types (shared partials deleted) · S4 projects schema+UI realignment to v12 (§3–4) · S5 secure uploads (§5) · S6 Unit Inventory manual+CSV rows → real units (§6) · S7 global v12 label/nav alignment (§7) · S8 responsive evidence, QA gates, docs/memory updates.

Out of scope: xlsx parsing (CSV only; UI copy must say "CSV" even though v12's `accept` lists .xlsx), Inquiries/Bookings, regenerating Theme A pages for other modules, "Save as Draft" (no draft schema), the Legal Files & Agreements material on edit-project (Documents module), cleanup of files belonging to soft-deleted projects (files persist; no force-delete route exists — deferred).

## 2. Architecture choices

- **Twin-file forms.** `create.blade.php` and `edit.blade.php` each contain the full field markup. Guardrails: (a) header docblock in both stating the twin rule; (b) a Pest parity test that renders both views via authenticated GETs and asserts equal sets of (i) field `name`s and (ii) names carrying the `required` attribute. Exclusions: `_token`, `_method`, select2/checkbox-group helper hidden inputs. Stated limitation: label wording, field order, and validation-rule drift are NOT covered by this test.
- **`html()->form()` wrapper.** Component pair `x-form.open`/`x-form.close` (implementer may use a single slotted wrapper; must be consistent) rendering via spatie `html()->form()`: always emits CSRF, method spoofing, `novalidate`, `id`, optional `enctype="multipart/form-data"`. GET filter forms stay plain HTML — documented boundary in the component docblock.
- **Uploads.** Four single-file slots as nullable columns on `projects`: `{slot}_path` + `{slot}_original_name` (string 255) for slots `rera_certificate`, `brochure`, `logo`, `site_plan`. Replace-on-upload deletes the old file. Storage on the non-public `local` disk under `projects/{id}/`. Download route `GET /projects/{project}/files/{slot}` — slot validated against a closed enum in the route definition (`whereIn`), gated by `ProjectPolicy@view`.
- **CSV parse is client-side, validation server-side.** ES5 module `public/assets/js/nrems-unit-import.js`: FileReader on the chosen `.csv` (reject files > 1 MB before reading), parse (quoted fields, CRLF, BOM), header row skipped when row 1's second cell is non-numeric, render read-only row pairs into `#unitInventoryRows` replacing previously-parsed rows (manual rows preserved). Rows submit as `units[N][unit_number]` / `units[N][carpet_area]` (payload keys = DB column names). Cell content is inserted via `.value`/`createTextNode` only — never innerHTML. Server re-validates everything; `readonly` is cosmetic.

## 3. Projects & units schema changes (one migration; down() aborts with a clear message if unassigned units exist — documented as partially irreversible)

**projects — renames (data carried across):**
- `developer_name` → `promoter_name`
- `expected_possession` → `expected_completion_date` (same concept; v12 has no separate possession field)
- `total_plot_area` (sq ft) → `total_land_area` decimal(12,2) **sq.yards — up() converts value/9 rounded 2dp; down() converts back ×9**

**projects — new columns (nullable unless noted):** `description` TEXT · `promoter_contact` string(20) · `survey_number` string · `google_maps_link` string(512) · `rera_valid_until` date · `na_permission_number` string · `environmental_clearance` boolean (Yes/No select; empty option = null "not answered") · `building_permission_number` string · `commencement_certificate_number` string · `construction_start_date` date · `website_url` string(512) · `youtube_link` string(512) · 4× `{slot}_path` string + 4× `{slot}_original_name` string(255).

**projects — columns with no v12 field:** `parking_slots`, `configurations` stay in the DB but are removed from forms/show (legacy; flagged to client).

**units:** `project_wing_id`, `floor_plan_id` → **nullable** (FK actions unchanged: cascade only fires for rows referencing the deleted parent). **Uniqueness becomes project-scoped:** the CSV format has no wing column, so unit numbers must identify a unit within the project. **Migration order is load-bearing (MySQL errno 1553):** the FK `units_project_wing_id_foreign` is served by the composite unique's leftmost prefix, so dropping it directly fails. Sequence: (a) `index('project_wing_id')`, (b) `dropUnique(['project_wing_id','unit_number'])`, (c) `unique(['project_id','unit_number'])`, (d) the two `nullable()->change()` calls. `down()` reverses in mirror order — re-add the composite unique BEFORE dropping the plain index. Migration precondition: assert no cross-wing duplicate `(project_id, unit_number)` pairs exist (abort with message listing them); verify seeds comply. `Store/UpdateUnitRequest` uniqueness rules re-scoped to project (still counting soft-deleted rows, same message pattern as the standing rule). Unit model `@property` docblocks → `?int` for both FKs; Larastan level 6 re-checked at all call sites (UnitsDataTable/show already null-guard; the only floor→rise derivation is `DemoProjectSeeder::seedUnits()`, which always has a floor plan — no other null-floor handling exists to change).

**Validation (projects):** required per v12 marks — name, type, status, (`code` is starred in v12 but readonly/auto-generated — `Project::nextCode()` server-side, absent from the rule set so it stays unpostable and immutable), promoter_name, address_line1, city, state, pincode, towers_count, **floors_per_tower** (v12 marks Number of Floors required; column stays nullable), rera_no. URL fields `url:http,https|max:512`. `promoter_contact` `regex:/^[0-9+\-\s()]{7,20}$/`. Dates: `construction_start_date` ≤ `expected_completion_date` when both present. `environmental_clearance` `nullable|in:1,0` (nullable FIRST; the global `ConvertEmptyStringsToNull` middleware maps `''` → null in practice — the `prepareForValidation` branch is kept as defence-in-depth, documented as such). `Project` model casts declared explicitly for all new columns: `environmental_clearance` bool, `rera_valid_until`/`construction_start_date`/`expected_completion_date` date, decimals as today; `description` stays string. **Boolean-select trap:** `form_input_value()` stringifies, and `(string) false === ''` matches the placeholder — so the edit view must pass `:value="$project->environmental_clearance === null ? null : (int) $project->environmental_clearance"` so a saved "No" round-trips as `'0'`; §9.1 requires a test that a saved "No" survives an edit-page load + resave.

## 4. v12 page mapping (source of truth: docs/wireframes-v12/)

- `add-project.html` → `projects/create.blade.php`: sections **Basic Information** (incl. Project Description textarea), **Location Details** (City is a free-text input per v12 — replaces the select2 on create/edit ONLY; `Project::CITY_OPTIONS` stays — it backs the index filter dropdown; update the stale comment in `StoreProjectRequest` around the city rule), **Structure Details** (amenity grid nested inside this section per v12 — no separate Amenities card), **Compliance & Legal** (incl. the RERA Certificate upload zone — v12 puts ONLY that one here), **Marketing & Media** (Brochure + Project Logo + Site Plan upload zones per v12 markup, Website URL, YouTube link) — corrected 2026-08-24: an earlier spec revision wrongly placed all four uploads in Compliance & Legal; v12 add-project.html:273-343 is authoritative, **Unit Inventory** (§6). Footer: Cancel + Save Project.
- `edit-project.html` → `projects/edit.blade.php`: single "Project Details" tab strip retained; same sections; each upload slot shows current file (original name + download link) with replace control.
- `view-project.html` → `projects/show.blade.php`: Promoter labels (per-screen v12 wording: "Promoter" here, "Developing Entity Name" on the form — both are v12's own); **the detail screen shows what the form captures** — v12's view page draws no rows for the new compliance/marketing fields, but stripping them would make them invisible to view-only roles (projects.view without projects.edit), so show renders Compliance & Legal + Marketing & Media cards mirroring the form's grouping — DELIBERATE ADDITION past the wireframe, on the client-confirmation list below; the principle is a boundary, not a licence — anything beyond "what the form captures" is a fresh decision; header **Download Brochure** button → brochure slot download route, hidden when the slot is empty; unassigned units appear in the flat units table of `projects/show.blade.php` (NOT `partials/wings.blade.php` — that is the per-wing sales-performance section, which a wing-less group structurally cannot join) as an **"Unassigned"** group rendered LAST: a group-header `<tr>` above the null-wing block, ordering set in `ProjectController::show`'s `units` eager-load closure via `orderByRaw('project_wing_id IS NULL')` ahead of the existing ordering (constant expression, no bindings, no user input — pre-cleared for the §9.4 security review under the whereRaw-requires-review rule).
- `project-management.html` → index: column header Promoter.
- **"Unassigned" label:** new lang key `units.unassigned` = "Unassigned", adopted on all three null-wing surfaces — the projects/show units grid, `units/show.blade.php`, and `UnitsDataTable` (replacing the `units.not_recorded` em-dash fallback for the wing cell only; other not-recorded uses unchanged). §9.3 evidence must show it on view-project and the units index.
- **Label scope:** "RERA Carpet Area (sq.ft)" appears **only** in the project-form inventory rows (v12 uses plain "Carpet Area (sq.ft)" on units module screens — those strings stay unchanged).

## 5. Upload security requirements (hard acceptance criteria)

Per-slot server rules, exactly matching the v12 hint text (which the UI must display):
| Slot | Types | Max |
|---|---|---|
| rera_certificate | pdf, jpg, png | 5 MB |
| brochure | pdf | 10 MB |
| logo | png, jpg | 2 MB |
| site_plan | pdf, png, jpg | 5 MB |

- Rules written as **string rules** (`file|mimes:...|mimetypes:...|max:...`), not `File::` rule objects — see §8 jsvalidation constraint. Both extension and sniffed MIME must pass. Stored name = `{slot}-{random40}.{ext}` with ext derived from the validated real type, never the client name. Original client filename stored only in `{slot}_original_name`, HTML-escaped on display. Download filename goes through the framework: `response()->download($path, $name, $headers)` / `HeaderUtils::makeDisposition()` (RFC-compliant quoting + ASCII fallback); a pre-sanitiser stripping control chars and path separators stays as belt-and-braces.
- Non-public `local` disk; no route serves arbitrary paths (closed slot enum in route + controller).
- Download response: `Content-Disposition: attachment` (never inline), explicit `Content-Type` from the stored validated type (never sniffed), `X-Content-Type-Options: nosniff`. If a future screen renders the logo in `<img>`, that gets a separate image-only route — not this one.
- Replace deletes the old file in the same request after successful validation+store of the new one. Files of soft-deleted projects persist (deferred, §1).
- Feature tests: valid upload per slot; oversize per slot; wrong-MIME-right-extension (crafted); php-in-png polyglot rejected; replace deletes old; download requires `projects.view`, streams right bytes with attachment+nosniff headers; guest redirected; invalid slot 404s.
- README deploy notes: `upload_max_filesize`/`post_max_size` ≥ 12M; note that a body exceeding `post_max_size` yields an empty request (Laravel 413/419 path) and the form warns about the 10 MB brochure cap client-side (`file.size` check) to make that outcome unlikely.

## 6. Unit Inventory on project forms — exact behaviour

- Manual rows: one empty editable row + Theme A "add row" button (declared deviation — wireframe shows static rows); rows with both cells empty are ignored server-side.
- Upload File button accepts `.csv` (UI copy: "CSV"). On parse: previously-parsed rows removed, manual rows kept; each data row renders `readonly` inputs (Theme A read-only styling) with a "from file" badge and per-row remove ×; summary line "N units from {file}" + clear-all link. Parse errors (empty file, non-numeric area, >1 MB) surface as an inline alert with offending line numbers; offending rows are not rendered; extra columns beyond the first two are ignored. Client-side cap 500 rows (alert beyond).
- CSV column 1 = Unit Number, column 2 = RERA Carpet Area; header auto-skip per §2.
- Server (`StoreProjectRequest`/`UpdateProjectRequest`): `units` => `sometimes|array|max:500` with integer keys; each row `unit_number` required string ≤20 `/^[A-Za-z0-9\/\- ]+$/`, `carpet_area` required numeric 1–999999.99. Duplicates within payload → row-keyed errors; duplicates against existing project units (incl. soft-deleted; same message pattern as the standing soft-delete-uniqueness rule) → row-keyed errors. Create: project + units in one transaction. `RetryOnUniqueCollision::once` continues to wrap the transaction for the project-code race ONLY: unit inserts are wrapped in their own try inside the transaction, and a `UniqueConstraintViolationException` from them is rethrown as a `ValidationException` keyed to `units.N.unit_number` — ValidationException passes through the retry wrapper untouched, so a duplicate unit number becomes a row-keyed error, never a wasted retry (which would burn a `Project::nextCode()`) or a 500. DB backstop = the new `unique(project_id, unit_number)` index.
- **Validation failure must not lose the import:** both twin views re-render rows from `old('units')`, preserving a cosmetic `units[N][from_file]` marker (untrusted, display-only) so file rows come back read-only; per-row inline errors (`units.N.unit_number` etc.) plus a summary alert at the section top.
- Edit form: submitted rows are additions only; existing units shown as count + link to the Units module (never listed/modified here).
- Created units: `project_id` set, wing/floor null, `status=available`, `carpet_area` set, `unit_type_id` null; price columns left at their **zero defaults** (NOT NULL default 0) — imported units show ₹0 in listings/show until priced in the Units module; `total_price` remains structurally unpostable.
- jsvalidation covers scalar project fields only — `units.*` keys and file rules are excluded from the rendered rule set (§8); our JS mirror validates rows (required + numeric) at submit; server authoritative.

## 7. Global alignment

- Sidebar exactly v12 (Main / Inventory / Sales / Operations / Document Uploads / Administration; **no Resale/Transfer**). Deliberate additions kept and documented, ALL flagged for client confirmation as one list: (1) Unit Inventory + Unit Types sidebar entries (built screens need reachability); (2) show-page Compliance & Legal + Marketing & Media cards (view-only roles need the captured data); (3) add-row button + per-row remove on the project form's unit rows (§6).
- Promoter/label changes in lang files only.
- `docs/adding-a-module.md` updated: twin-file rule replaces the shared-partial step; new components documented; v12 vendored wireframes = markup source of truth; CSV import pattern referenced. (§9 doc references point at README.md — this repo has no HANDOFF.md.)
- Memory/knowledge (outside repo): v12 canonical; this repo the only active one; Theme A static pages stale for future modules.

## 8. Components: x-form.file / x-form.radio + jsvalidation constraint

- `x-form.file`: label, name, accept, hint line (types+max per slot), current-file display (escaped original name + download link + replace) for edit, error-aware (file old input can't repopulate — errors render, rest of form intact). Theme A upload-zone styling (clickable zone wrapping hidden input) per v12 visuals. Rendered via spatie `html()->file()`.
- `x-form.radio`: radio group (name, options, inline/stacked, checked via `form_old`, strict string comparison) via `html()->radio()`. No current consumer — built for Bookings. Component tests like the existing eight.
- **jsvalidation constraint (tested):** proengsoft cannot express `File::` rule objects or wildcard `units.*` rules against not-yet-rendered rows — unknown rule objects degrade or are skipped. Therefore upload rules are string rules, and the `<x-form.jsvalidation>` integration must exclude `units.*` and file-slot keys from the rendered set. A test asserts create/edit pages emit parseable jsvalidation config with no `laravelValidationRemote` and no units/file keys.

## 9. Testing & acceptance criteria

1. Existing tests stay green or are updated only where labels/columns legitimately changed; new tests for every behaviour in §§3–8 (uploads incl. adversarial cases, CSV row validation incl. 501-row payload rejection, old-input row re-render, nullable wing/floor rendering incl. "Unassigned" group and ₹0 display, twin-parity, both new components, project-scoped uniqueness incl. soft-deleted collision, environmental_clearance saved-"No" round-trip through edit-page load + resave, migration data conversions: promoter rename, possession rename, sqft→sqyd ÷9). **Deliberate behaviour reversals — rewrite to assert the NEW behaviour, a red run here is expected:** `tests/Feature/Models/InventorySchemaTest.php` "allows the same unit number under a different wing" and `tests/Feature/Units/UnitCrudTest.php` "accepts the same unit number under a different wing" now assert rejection (project-scoped uniqueness). **Expected fallout of `floors_per_tower`, `towers_count` and `rera_no` becoming form-required (columns stay nullable):** `ProjectFactory`, seeds, and store/update tests that omit it must supply a value; editing a legacy null-floor project forces entry — accepted.
2. Larastan level 6 + Pint clean (incl. `?int` property updates).
3. Browser evidence at 390/768/1280: create project (all sections, upload zones, inventory rows), edit project (current-file slots), view project (Promoter labels, Download Brochure, Unassigned group), units index with an unassigned unit. Headless CSV flow: fixture CSV → read-only rows → re-upload replaces → submit → units exist → failed submit re-renders rows with errors.
4. Security review: upload pipeline, download route headers, CSV mass-assignment surface (only unit_number/carpet_area reachable), no XSS via CSV cells or stored original filenames.
5. Seeds/factories/demo data updated to renamed columns (verify no cross-wing duplicate unit numbers before the new unique index); `composer setup` works from scratch; repo-wide grep for `developer_name`, `total_plot_area`, `expected_possession`, `unit_no` ends empty outside migration history; per-wing-uniqueness prose is also purged — update the docblocks in `StoreUnitRequest` (lines ~52-70), `StoreWingRequest`, `StoreUnitTypeRequest`, `UnitController::destroy` ("number stays reserved inside its wing"), `UnitFactory`, and the units migration note.
6. Docs: adding-a-module.md, README (deploy notes: storage dir, PHP upload limits, new env expectations).

## 10. Risks / notes

- Column renames touch DataTables, factories, seeders, lang keys — grep gate in §9.5.
- Uniqueness re-scoping is a deliberate behaviour change from the boilerplate's per-wing decision; recorded here as superseding it (driven by the CSV format having no wing column).
- sq ft → sq yd conversion changes displayed numbers for existing projects; demo seeds re-express values in sq.yards directly.
- down() aborts if unassigned units exist; conversion reversals documented in the migration docblock.

## 11. CR-1 — Unit Inventory feedback (developer/client, 2026-08-25) — APPROVED

Developer review of the shipped module directed the following. Status: supersedes the §6 bullets it touches; the server contract (§6 validation, creation, additions-only) is UNCHANGED.

1. **Sidebar: remove the "Unit Inventory" entry.** It was deliberate-addition #1 on the §7 client-confirmation list; the answer is now no. The units index page itself stays routable (reached via project screens / "Manage units"); only the menu entry goes. Unit Types entry stays.
2. **The project Add/Edit form is the inventory-management surface.** In its Unit Inventory section:
   - REMOVE the "Add Row" button and the default empty manual row (both twins). These were declared deviations from v12 (§6); withdrawn — v12 itself draws neither.
   - REMOVE the per-row delete (×) from parsed CSV rows. v12 draws no delete buttons ("Unit Inventory Rows (no delete buttons)" in its own markup).
   - ALL Unit Number / RERA Carpet Area fields in this section are READ-ONLY, always. v12 renders editable inputs here; the client explicitly overrides — recorded as a client-directed deviation. CSV upload is the only entry path on this form; manual unit CRUD lives in the Units module.
   - **Edit twin: existing units of the project MUST load and display** in the section as read-only DISPLAY rows (unit number + carpet area), ordered as the units index orders them. Display-only: NO name attributes — they must not post (a resubmit would otherwise collide with the very rows it displays). Newly-parsed CSV rows append after them; the §6 additions-only server contract is unchanged.
   - KEEP: the Upload File (CSV) button, parse/validate/replace-on-reupload behaviour, the 500 cap, the summary line, and the "remove uploaded rows" clear-all link (the undo for a wrong file — it touches only not-yet-saved parsed rows, never stored units). Flagged: if the client wants clear-all gone too, it is one deletion.
   - Failed-submit re-render (old('units')) keeps working: file rows come back read-only, now without the ×.
3. **Client-confirmation list (README):** mark the sidebar-entry and add-row/per-row-remove items RESOLVED by this feedback; the read-only override is added as a new recorded decision.
4. Tests follow: AppShellTest sidebar assertions; ProjectUnitImportViewTest reworked (no manual row anywhere, edit shows existing units read-only AND not-posted); TwinViewParityTest must stay green (display rows carry no names, so name-set parity is unaffected); JS loses manual-row/add-row/per-row-remove/submit-time-manual-validation code paths.

## 12. CR-2 — Exact v12 wireframe design adoption (client, 2026-08-25) — APPROVED

Client compared the running app against https://nrems-wireframes.virtualcarry.com/v12/view-project.html and directed: the app must carry the wireframe's EXACT design — fonts, header, colors, tags/badges, icons, layout, sections — for the shell and the projects & units screens. This supersedes the boilerplate's Theme A (Metronic) styling decision for these surfaces. Markup+CSS source of truth: docs/wireframes-v12/ (identical to the client's C:\Users\vivek\Downloads\wireframes-v12).

**Strategy (decided): wireframe-first skin over a Bootstrap utility base.**
- Vendor `docs/wireframes-v12/css/style.css` into `public/assets/css/nrems-wireframe.css` (verbatim; local edits only in a clearly-marked "app additions" tail — e.g. validation error states the wireframe never drew). Loaded AFTER the Bootstrap/vendor bundles so wireframe classes win wherever names collide (.btn, .card, .form-control, .badge…). Bootstrap keeps supplying what the wireframe never styles (grid cols where still used, utilities, modal/JS behaviours, select2/DataTables plumbing).
- Fonts: Inter stays (already matching). Icons: FontAwesome 6 everywhere the wireframe uses it (fa-solid/fa-regular, per-page 1:1 with the wireframe's icons); Metronic keenicons/duotone usage in these surfaces is replaced.
- The Metronic bundles stay loaded for now (plumbing risk); a later slimming pass may drop them. Visual authority = the wireframe css.

**Scope, in order:**
1. **Shell**: `layouts/app.blade.php` + partials rebuilt on the wireframe's own structure (index.html/view-project.html): `.sidebar` (260px, .nav-section/.nav-item, active = primary fill), `.header` (64px: burger, page-title + .breadcrumb-nav, header-right bell + .user-avatar initials chip), `.main-content`/`.content-wrapper`. Sidebar entries/order unchanged from CR-1 state. Login/guest pages: OUT of scope (no wireframe page exists) — untouched, noted.
2. **Shared components re-skinned to wireframe classes**: x-card → `.card`/`.card-header`/`.card-footer`; form components → `.form-group`/`.form-label`/`.form-control` (+ `.required` asterisk span per wireframe), section headers → `.form-section`/`.form-section-title` with icon; rows → wireframe `.form-row.form-row-2/-3` grids (replacing bootstrap cols inside these modules); buttons → `.btn.btn-primary/.btn-outline/.btn-sm`; badges/status → wireframe `.badge` palette (status colors mapped from the wireframe pages' own usage); tabs → `.tabs`/`.tab-item`; upload zones already wireframe-shaped — verify 1:1 against style.css; unit-inventory rows per the wireframe's inline layout; tables → `.data-table` styling with our DataTables JS still driving them (toolbar/filter bars restyled to the wireframe's toolbar look, wiring unchanged); pagination/info styled acceptably close (wireframe shows static pagination — DataTables' controls adopt the palette). select2 KEPT functionally (original requirement) — skinned to sit visually on `.form-control` metrics; native `.form-control` select where the wireframe shows plain selects and no search benefit exists is acceptable.
3. **Projects screens exact**: index ↔ project-management.html (incl. its stat strip if drawn), create ↔ add-project.html, edit ↔ edit-project.html, show ↔ view-project.html (header block, .detail-grid label/value rows, section/card order, icons, badge colors). The recorded deliberate additions (extra show cards, CSV-only read-only inventory per CR-1) keep their behaviour, restyled to the same design language.
4. **Units screens exact**: units index ↔ unit-inventory.html, unit show ↔ view-unit.html, unit create/edit ↔ new-unit.html; unit_types (no wireframe page) restyled consistently with the same components. Wings/floors modals + partials on project show: same design language (wireframe draws no modal — style with the app-additions tail, visually consistent).
5. **Fidelity gate**: side-by-side screenshots (wireframe page vs app page) at 1280 for view-project, add-project, edit-project, project-management, unit-inventory, view-unit + the responsive trio for the app pages; a checklist in the evidence README naming font/size/color/icon/section-order parity per page. Behavioural evidence (CSV flow, guard) re-run green. All three gates; tests asserting Theme A classes updated to the new classes WITHOUT weakening what they assert (AppShellTest's sidebar extractors move from menu-* to .nav-* anchors; upload-zone/component tests likewise).

**Constraints**: server contract untouched; no route/controller/validation changes except view-data needs; twin rule + parity tests keep holding; Js::from/escaping rules unchanged; jsvalidation/select2/DataTables keep functioning (their markup hooks are ids/names — preserve them).

## 13. CR-3 — Switchable upload storage: local ⇄ AWS S3 (client, 2026-08-26) — APPROVED

Client will deploy either on their own server (Windows/Ubuntu, local storage) or on AWS with documents in S3; the choice is made before go-live and must be a configuration switch, not a code change.

**Contract:**
1. New env `NREMS_UPLOADS_DISK` (default `local`). `ProjectFileService` resolves its disk from `config('nrems.uploads_disk')` (new tiny config file `config/nrems.php` — future app-level settings live there too) instead of the hard-coded `local`. The service remains the ONLY storage toucher; no other file changes behaviour.
2. `config/filesystems.php` gains an `s3` disk per Laravel convention (`league/flysystem-aws-s3-v3` composer dep): AWS_* env keys, `'visibility' => 'private'`, `'throw' => false` matching local, `'serve'` N/A. `.env.example` documents both modes with the AWS keys commented out.
3. **Downloads keep streaming through the app** on both disks — policy gate, attachment + explicit Content-Type + nosniff, sanitized names — identical behaviour. NO url()/temporaryUrl() anywhere (the standing rule extends to s3: a future signed-URL optimisation is a separate decision, recorded as such in the service docblock). Note: `response()->download($absolutePath…)` works only for local paths — the service's download path must stream via the disk API (`Storage::download()`/`streamDownload` with our headers) so it is disk-agnostic; verify the exact headers survive (attachment disposition, stored Content-Type, nosniff — the existing tests pin them).
4. The afterCommit-guarded replace-delete, naming scheme, validation, audit rows: unchanged and re-proven per disk.
5. Tests: existing ProjectFileTest suite runs green with the default; new cases run the store/replace/download/delete cycle against `Storage::fake('s3')` + `NREMS_UPLOADS_DISK=s3` (config-set in test), incl. the header trio and the afterCommit rollback/commit pair. A config test pins the default (`local`) and that the s3 disk declares private visibility.
6. README deploy checklist: the switch, the S3 best-practice block (bucket private + Block Public Access ON; IAM user scoped to Get/Put/DeleteObject on the one bucket; enable versioning for backup), and the note that local mode's storage/app/private backup line applies only to local mode.
7. Out of scope: signed-URL delivery, migration tooling for moving existing files between disks (a one-off artisan command can be added when the client actually switches with data in place — recorded as future work).

## 14. CR-4 — User-visible upload storage failures (client, 2026-08-26) — APPROVED

Today a storage-layer upload failure (disk full, S3 outage, permissions) surfaces as a generic 500. The dangerous invariants already hold (path column written only after a verified disk write; failures throw, never silently skip; the typed project data is saved because uploads run post-transaction) — what is missing is telling the user.

**Contract:**
1. `ProjectController::storeUploads()` catches per-slot storage failures (`RuntimeException` from the service — NOT `InvalidArgumentException`, which signals a validation bypass and must keep failing loud), continues with the remaining slots, and `report()`s each caught exception so ops sees the real cause.
2. The redirect gains a warning flash when any slot failed: "Project saved. These files could not be uploaded: {labels}. Please try again from the Edit screen." — lang-backed, listing the human slot labels (the projects.fields.* upload labels), rendered by the existing flash partial in a warning tone (verify the flash component supports warning; it does — CR-2a listed the tones). The success flash still fires (the project DID save); message ordering/stacking per the flash partial's convention.
3. Replace-path nuance: a failed REPLACE leaves the old file and columns untouched (the service's ordering already guarantees this) — the warning wording must not imply the old file was lost. One wording for both cases is fine ("could not be uploaded"); docblock records why it's accurate for both.
4. Tests: per-slot failure (mock/bind a service double or force the disk to fail — Storage::fake + a pre-created colliding directory, or partial mock of the service — implementer's choice, must be a REAL storage-layer failure path, not a thrown test stub asserting only the catch) → project saved, other slots stored, warning flash lists exactly the failed label(s), exception reported (Exceptions::fake or report spy), DB columns for the failed slot untouched; multi-failure lists both; InvalidArgumentException still propagates (test pins it). Same coverage on the update path incl. failed-replace-keeps-old-file.
5. README: one line in the ops notes — storage failures are reported to the log and surfaced to the user as a warning; the project data itself is never lost to an upload failure.

## 15. CR-5 — Developer/client feedback round 2 (2026-08-27) — APPROVED

Nine items from the client + the developer who received the handoff package. Where an item
contradicts wireframe v12 or an earlier section, THIS section wins and the fidelity-gate
registry records the divergence with a reason. Process constraint from the client: no full
suite run — targeted tests for the changed behaviour only (full run deferred to the next
natural checkpoint); `composer analyse` + `composer lint` still gate every commit.

### 15.1 Demolition — wings, floor plans, unit types, standalone units module

Client: "Remove project_wings, floor_plans and unit_types table and related code. Not
required project wings and floor plan management. Remove Wings & Floor Plans from projects
view." Plus: units are no longer editable anywhere ("edit feature not required"), and the
"Manage units" link goes (15.3) — which strips the standalone units module of its last
purpose. DECISION: the standalone Units listing/CRUD and Unit Types module are removed
with the tables. Units exist ONLY as rows imported on the project form and displayed on
the project view.

Removals (code): ProjectWingController, FloorPlanController, UnitTypeController,
UnitController, UnitInventoryController; Store/UpdateWingRequest, Store/UpdateFloorPlanRequest,
Store/UpdateUnitTypeRequest, Store/UpdateUnitRequest; ProjectWing, FloorPlan, UnitType models;
ProjectWingPolicy, UnitTypePolicy, UnitPolicy IF it exists as standalone (units keep NO policy
of their own — they are project data, gated by ProjectPolicy); WingType, FloorType,
UnitTypeCategory enums (UnitCategory: check usage — remove if only units-module); UnitsDataTable,
UnitTypesDataTable; ResolvesInventoryParent concern; CascadeController + its /api cascade
routes IF nothing else consumes them (the consumers were the wing/floor/unit-type cascades);
routes/modules/units.php, unit_types.php, the wing/floor-plan routes in projects.php;
views: projects/partials/{wings,wing-fields,floor-fields}.blade.php, units/*, unit_types/*;
sidebar entries + nav lang keys for units/unit-types; wings/floor-plan/unit-type permissions
from RolePermissionSeeder (units.* permissions likewise — project gates cover everything);
factories (ProjectWingFactory, FloorPlanFactory; UnitFactory SLIMS, not removed);
DemoProjectSeeder loses wings/floors/types, keeps projects + imported-shape units;
nrems-unit-price.js and any cascade JS; tests that exist to cover removed behaviour are
DELETED (WingFloorCrudTest, unit-types/units-module suites), tests that cover kept behaviour
are updated, never weakened.

ProjectController::show() loses the wings/floor-plan aggregates + section; destroy() loses
the wing/floor/type cascade deletes (units delete stays); store()/update() unchanged here
(15.4 changes update). The `units_sold`/stat aggregates on show survive only where the
view still draws them.

### 15.2 Schema — slim units, multi project type, three optional fields

One new migration (2026_08_27), irreversibility documented like the realign migration:

1. `units` keeps ONLY: id, project_id, unit_number, carpet_area, status, created_at,
   updated_at, deleted_at — plus NEW `created_by` (nullable unsignedBigInteger, FK →
   users.id, nullOnDelete; set to auth()->id() on every import-created unit; backfill
   existing rows NULL). Drop every other column (wing/floor/type FKs, price, and the rest)
   after dropping their FK constraints — MySQL errno-1553 ordering lesson from the realign
   migration applies (plain index takeover before dropping any index a FK leans on; the
   project-scoped unique on (project_id, unit_number) STAYS).
2. Drop tables `floor_plans`, `unit_types`, `project_wings` (children first — floor_plans
   references project_wings; units' references are already dropped by then).
3. `projects.project_type` (string enum) → `project_types` JSON array. Data migration:
   existing scalar becomes a one-element array. Model: AsEnumCollection::of(ProjectType).
   down(): first element back to scalar (lossy — documented).
4. `projects.total_towers`, `projects.total_floors`, `projects.rera_registration_number`
   → NULLABLE. Client: no longer mandatory. Blank stays NULL (no ZERO_DEFAULTED coercion
   for towers/floors any more — a blank means "not provided", not zero; adjust
   prepareForValidation and document why the two diverge from total_units).

### 15.3 Project form — fields and Unit Inventory behaviour

1. **Project Type multi-select**: field name `project_types[]`, x-form.select2 with
   `multiple`; validation `project_types` required|array|min:1, `project_types.*`
   Rule::enum(ProjectType). Old-input/value handling via the components' existing
   array-field paths (form_old_array). Edit pre-selects the stored set. DataTable column +
   show page render the labels comma-joined; any project_type filter moves to
   whereJsonContains semantics or is dropped if the DataTable never filtered on it.
2. **Optional trio**: required rules → nullable (+integer/string rules stay); labels lose
   the asterisk (required prop off); jsvalidation follows the FormRequest automatically.
3. **Error message position** (client screenshot): label → control → error, ALWAYS.
   Server-rendered errors already comply (group.blade appends after the slot). The bug is
   client-side jQuery Validation `errorPlacement` defaulting to insertAfter(the hidden
   native select) — i.e. BEFORE the select2 widget. Fix: a shared errorPlacement override
   wherever jsvalidation binds (the x-form.jsvalidation push or nrems-custom.js): if the
   element is a select2-backed select, insert the message after the `.select2-container`
   (and generally: after the last control-ish sibling inside the .form-group, so file
   inputs/inputs keep today's position). Must match the server-rendered position so the
   two render identically; must keep writing into/next to the `invalid-feedback` node
   jsvalidation toggles.
4. **"Manage units" link removed** from edit's Unit Inventory section (route target is
   gone with 15.1 anyway).
5. **Unit Inventory accepts CSV, XLS, XLSX**: vendor SheetJS community edition
   (public/assets/js/xlsx.full.min.js, 0.20.3, Apache-2.0 — license header kept; loaded
   ONLY on project create/edit). nrems-unit-import.js parses all three via SheetJS
   (sheet_to_json on first worksheet; CSV may keep the existing hand parser or go through
   SheetJS too — implementer's call, but ONE code path preferred; header row mapping and
   all existing validation/preview behaviour preserved). accept=".csv,.xls,.xlsx" +
   upload-guard size/type messages updated. The file itself is still never posted for
   data — rows go as the existing hidden inputs; server row validation unchanged. The
   downloadable template stays CSV.
6. **Replace-on-new-file (edit)**: choosing a new file CLEARS the saved read-only rows
   from the section (client-side) and shows only the new file's rows, with a visible
   lang-backed note that saving will REPLACE all existing units. Clearing the file
   selection restores the saved rows (re-render, they were server-rendered — hide/show,
   don't destroy). Server (15.4) does the actual replace.

### 15.4 Server — replace semantics on update

Update path: when the request carries a non-empty validated `units` array, the project's
existing units are removed and the posted rows inserted, atomically with the project row
update (same DB::transaction). Removal is a HARD delete (forceDelete/query delete): the
client's words are "remove old Unit Inventory from database", and soft-deleted rows would
block re-importing the same unit numbers on the project-scoped unique index. The activity
log records the replacement (counts: N removed, M created) on the project entry; each
created unit carries created_by = the importing user. When NO units are posted (no new
file chosen), existing units are untouched — byte-for-byte today's behaviour. The
duplicate-unit-number validation on update no longer checks against the project's own
EXISTING units (they are being replaced) — only in-file duplicates and rows-shape rules.
Create path unchanged except created_by.

### 15.5 Project view (show) — Unit Inventory tab

Columns: Unit No., Carpet Area, Status. ONLY. Remove the Actions column (no edit), the
Add Unit button, and any wing/floor/type grouping (gone with 15.1). Wings & Floor Plans
section/tab removed entirely. Whatever stat tiles referenced wings/types go; unit counts
that only need the units table may stay if the wireframe page draws them.

### 15.6 Windows handoff failure (developer report) — root-caused, docs only

`composer install` on the developer's machine failed in Composer's classmap generator:
file_get_contents(...ProjectCrudTest.php): Invalid argument. The shipped zip is VERIFIED
intact (unzip -t OK; entry byte-identical to main). The failure is a bad local extraction
(interrupted extract / AV interference / OneDrive-style placeholder), consistent with
"replaced the file and it worked". Fix = documentation: handoff README gains a
Troubleshooting note (symptom, cause, remedy: re-extract with 7-Zip or clone the .bundle,
which is immune to extractor quirks). No code change.

### 15.7 Gates for this CR

Targeted Pest runs for: project CRUD with project_types multi + optional trio, import
replace-on-update (incl. same-numbers re-import, created_by stamped, counts logged,
no-file-touch-nothing), show-page column set, removed-routes-404/permission pruning,
TwinViewParityTest still green. Full-suite run explicitly DEFERRED (client instruction);
recorded here so the next session runs it before any further feature work. Larastan +
Pint green. Browser evidence: create with multi-type + optional-blank trio; edit with
xlsx file replacing saved rows; error-below-dropdown screenshot matching the client's
requested order. Fidelity registry updated where sections/columns were removed, each with
"CR-5 §15.x" as the recorded reason. Handoff package rebuilt at the end.
