# Adding a module

The replication recipe for the next CRUD module (Inquiries, Bookings, Payments…).
Projects is the reference implementation: when this document and the code
disagree, **the code wins** — every rule below names the file that proves it.

Read `app/Http/Controllers/ProjectController.php`, `app/DataTables/ProjectsDataTable.php`
and `app/Http/Requests/Projects/StoreProjectRequest.php` once before you start.
Their docblocks carry the reasoning; this page is the order of operations.

Throughout, `Thing` is the new resource; `things` its table, `thing` its route
parameter, `things.*` its permissions and lang file.

---

## 0. Start from the wireframe, not from the schema

Every enum case, every field, every column and its order comes from **wireframe
v12, vendored at `docs/wireframes-v12/`**. That is the markup source of truth:
it is the client-canonical pack, it lives in this repo, and it is what the
projects module was realigned to. Open the add/edit page, the listing page and
the detail page for the module first.

Since CR-2 (spec §12) v12 is the *styling* source of truth too. Its stylesheet
is vendored at `public/assets/css/nrems-wireframe.css`, loads last and wins on
class names, so take the classes straight off the v12 page you are copying —
`.card`, `.form-section`, `.form-row.form-row-2`, `.detail-grid`, `.data-table`,
`.badge.badge-*`.

**Before converting a screen, open its wireframe page's own `<head><style>`
block, not just `css/style.css`.** At least three v12 pages carry page-local
declarations, and they are not decoration: `view-project.html` styles
`.tower-card` and its three children there (lines 43-71), plus `.amenity-check`
and `.map-placeholder`. **A class with no rule in `style.css` is not evidence
that the wireframe renders it unstyled** — CR-2b shipped that exact wrong
conclusion, in four places, on a screen whose tower cards render perfectly in a
browser. Grep the page, then grep the stylesheet. And when a page-local rule
exists, decide about it explicitly: a declaration the pack never renders
(`.amenity-check` is one) is still the wireframe author's answer for that
content, and is usually a better starting point than something invented here.

Three more things `style.css` will not tell you. Its utility set is not
Bootstrap's and not complete: it declares six `.text-*` colours and **no
`.text-info`**, so that one name falls through to Metronic's purple — check
that a `.text-*` / `.fw-*` / `.gap-*` you are about to use is actually in there
(the app-additions tail now supplies `.text-info`, block 12b). And v12's own
utility names differ from Bootstrap's for flex — `.align-center` and
`.justify-between`, not `align-items-center` / `justify-content-between`. Use
v12's inside these screens; Bootstrap's still work, but two spellings of one
idea in one file is how a reader loses the thread.

The third is the same trap pointing the other way, and CR-2c found it live: a
Metronic class name that v12 has **no rule for at all** renders as nothing,
silently. `badge-light` and `badge-light-primary` were the two tones of the
unit-types "Units" cell (a screen CR-5 §15.1 has since removed), and under the
wireframe stylesheet both came out as a `.badge` with no ground — the
busy-vs-unused contrast the cell existed for was invisible on screen and
nothing failed. v12's tone set is the seven in
style.css lines 379-385 (`badge-success/warning/danger/info/primary/secondary/purple`)
and no others; the same goes for buttons (`btn-primary/outline/success/danger/warning`,
plus `btn-sm`/`btn-lg`/`btn-icon` — there is no `btn-light`, and no
`btn-light-*`). Search the stylesheet for a class before you keep it.

The Theme A static pages at `/home/vivek/nrems/nrems-themes/theme-a-wireframe/`
were generated from **v10** and are now a fallback only: for a screen v12 does
not draw at all, that page is all there is, and it is stale by definition —
check its field list against v12's nearest equivalent, and translate its
Metronic classes to the v12 ones above rather than copying them.

- Enum cases are generated from the `<option>` values in the form —
  `app/Enums/ProjectType.php` says so in its docblock and mirrors
  `add-project.html` exactly.
- Where the HTML and the spec disagree on **presentation**, the HTML governs
  (spec §7). Where they disagree on **data** — the canonical status enum, the
  seeded project codes — the spec governs. Both precedents are documented at
  `app/Enums/UnitStatus.php` and `database/seeders/DemoProjectSeeder.php`.
- A wireframe control with no column in the approved schema is **not rendered**,
  and the omission is written down. The surviving worked example is the
  "Deliberate differences from the wireframe" list at the head of
  `resources/views/projects/create.blade.php` and its twin `edit.blade.php` —
  every place the screen departs from `add-project.html`, each with its reason
  and the spec section that authorised it. (The four-control example that used
  to be cited here was on the Add Unit screen, removed with its module in CR-5
  §15.1.)

---

## 1. Migration + enums

One migration per table, `declare(strict_types=1)`, a docblock that says what
the table is *for* and why any denormalised column exists.

- Enum-backed columns are plain `string`, never MySQL `ENUM`: the PHP enum owns
  the allowed values so a new case is not a migration
  (`database/migrations/2026_08_23_090001_create_projects_table.php`).
- `timestamps()` + `softDeletes()` on everything.
- Index what the listing filters and sorts on. `projects` indexes `status` and
  `city`, which is what the listing's two filter dropdowns hit.
- Unique indexes: state the soft-delete consequence in a comment. A plain unique
  index covers trashed rows, so a deleted row keeps its number reserved. That
  is spelled out on `units`' `(project_id, unit_number)` in
  `database/migrations/2026_08_23_090005_create_units_table.php` — copy the
  reasoning or overrule it consciously, don't leave it silent. CR-5 §15.4 is
  what overruling it looks like: replace-on-import **hard** deletes the units it
  replaces, precisely so re-importing a file with the same unit numbers is not
  blocked by rows the user believes are gone.
- Denormalising a parent id is allowed when a screen would otherwise take a
  two-join hop on every draw. It was `units.project_id`'s original reason, when
  a unit reached its project through a wing and a floor plan; since CR-5 §15.1
  it is simply the only parent there is. Both FKs must cascade so the value
  cannot drift.

Enums live in `app/Enums`, are `string`-backed, snake_case values, and carry a
`label(): string` `match` that returns the wireframe's exact wording. A `match`
without a `default` is the point: adding a case then fails to compile until every
label is written.

---

## 2. Model

`app/Models/Project.php` is the template.

```php
/** @use HasFactory<ProjectFactory> */
use HasAudit, HasFactory;
```

`HasAudit` (`app/Models/Concerns/HasAudit.php`) is `SoftDeletes` + spatie
activity logging. Everything auditable gets it.

**The larastan enum-cast gotcha — this is why every model here has a `@property`
block.** larastan reads `casts()` as `array<string, string>` and therefore hands
back a plain `string` for an enum-cast column, which loses `->label()` at every
call site and fails analysis at level 6. Spelling the column types out in the
class docblock is what makes `$project->project_type->label()` analysable:

```php
 * @property Collection<int, ProjectType> $project_types  // AsEnumCollection
 * @property ProjectStatus $status
 * @property string|null $total_land_area   // decimal:2 really returns a string
 * @property-read int $units_total          // withCount alias, see ProjectsDataTable
```

Two consequences worth internalising:

- `decimal:N` casts return **strings**. Cast to `float` deliberately before
  arithmetic — `app/Http/Controllers/UnitController.php::priceFor()` does exactly
  that, field by field.
- `withCount()` aliases are `@property-read int`, annotated with the DataTable
  that produces them.

Keep `$fillable`, `casts()` and the `@property` block in step with the
migration. Constants that are *data* (`Project::AMENITIES`, `CITY_OPTIONS`) live
on the model, not in a lang file: they are written into the database verbatim and
feed the checkbox group, the validator and the detail page from one list.

---

## 3. Factory

`database/factories/UnitFactory.php`. Realistic values, named states for the ones
tests reach for (`available()`, `hold()`, `corner()`), and a `configure()`
`afterMaking` hook to backfill anything denormalised so a factory-built row is
never internally inconsistent.

If the factory writes a derived column, say in a comment that it is a plausible
sticker value only and name the service that owns the real one.

---

## 4. Permissions + policy

Permissions are `<resource>.<action>`, four per resource, added to the
`PERMISSIONS` **constant** in `database/seeders/RolePermissionSeeder.php` and
granted in the `ROLES` constant. They are constants so tests can assert the
matrix against the same list the seeder uses.

Not every table gets permissions. `units` deliberately has none: a unit is
project data — imported on the project form, displayed on the project page,
editable nowhere (CR-5 §15.1) — so every gate it needs is a `projects.*` one.
Wings and floor plans were the original example of the same rule before the
tables went; the surviving one is units, and the reasoning is unchanged:
a child that has no screen of its own has no permission of its own
(`app/Policies/ProjectWingPolicy.php`). Decide that consciously and write it in
the policy docblock.

`app/Policies/ProjectPolicy.php` is five one-line methods mapping straight onto
the permission strings. `Super Admin` never reaches them — the `Gate::before`
hook in `app/Providers/AppServiceProvider.php` short-circuits every check — so
the role stays current as permissions are added.

---

## 5. FormRequests

One `Store…Request`, one `Update…Request` extending it and swapping only
`authorize()` and any unique rule
(`app/Http/Requests/Projects/UpdateProjectRequest.php`).

**Authorise through the policy, never inline:**

```php
public function authorize(): bool
{
    return $this->user()?->can('create', Project::class) === true;
}
```

**Validation must never be laxer than the database.** If the table has a plain
unique index, `Rule::unique` must be unscoped too — scoping it with
`whereNull('deleted_at')` would pass validation and then 500 on the insert. The
long-form reasoning is in `app/Http/Requests/Units/StoreUnitRequest.php`; the
same class shows the other half, `Rule::exists(...)->whereNull('deleted_at')`,
because plain `exists` happily accepts a soft-deleted parent.

**Cross-field checks go in `after()`, one callback per concern:**

```php
public function after(): array
{
    return [
        fn (Validator $v) => $this->validateParentage($v),
        fn (Validator $v) => $this->validateAreaOrder($v),
    ];
}
```

The area-sanity rule (carpet ≤ built-up ≤ super built-up) is the pattern to copy
for any "these numbers must be ordered" invariant: pairs in a class constant,
each pair skipped when either operand is missing or already has an error, so the
user gets one precise message instead of three overlapping ones. `gte:`/`lte:`
rules are not used because they fire on a missing operand.

**Derived columns are absent from `rules()`.** `code` is not in the project
rule set — the server owns the sequence — so a submitted project code can never
reach `validated()` and therefore never reaches the model. Stronger and cheaper
than stripping it afterwards. (`total_price` on a unit was the original example;
CR-5 §15.1 removed the column and the calculator behind it.)

**`prepareForValidation()`** handles the two shapes HTML forms get wrong: a
checkbox group that submits nothing when everything is unticked must be merged
as `[]`, not treated as "unchanged" (`StoreProjectRequest`); a blank optional
number whose column is `NOT NULL DEFAULT 0` must be merged as `0`
(`StoreProjectRequest::ZERO_DEFAULTED`, which holds `total_units` and nothing
else since CR-5 §15.2.4 — `towers_count` and `floors_per_tower` left it when
they became nullable, because a blank tower count means "not provided" and not
"zero towers", and the request's docblock says so).

`attributes()` maps every field to `__('things.fields.…')` so messages read in
the client's words.

**The same class drives client-side validation.** The page binds it:

```blade
<x-form.jsvalidation :request="$request" selector="#nrems_thing_form" />
```

One binding per form, and the selector must match exactly one element —
jsvalidation binds the first match, so a screen carrying two forms needs two
bindings with two selectors. (The worked example used to be the wings screen's
one `<script>` per modal; CR-5 §15.1 removed it, and every screen in the app is
now single-form.) Rules that cannot be expressed client-side are simply not
emitted; the server is still the authority.

Where the message LANDS is `NREMS.Forms.placeValidationError` in
`public/assets/js/nrems-custom.js`: it appends into the `.form-group`, so the
client-rendered message sits exactly where `<x-form.group>` puts the
server-rendered one — label, control, error (CR-5 §15.3.3). Do not let a form
bind jsvalidation without also calling
`NREMS.Forms.bindValidationErrorPlacement`; `<x-form.jsvalidation>` does it for
you.

Some rules must be kept out on purpose rather than merely failing to render.
proengsoft cannot express `File::` rule objects or a wildcard rule against rows
that do not exist in the DOM yet, and what it does with them is degrade quietly.
Pass the keys out explicitly:

```blade
<x-form.jsvalidation :request="$request" selector="#..."
                     :except="\App\Http\Requests\Projects\StoreProjectRequest::clientRuleExclusions()" />
```

`clientRuleExclusions()` is a static on the Store request, inherited by the
Update twin, and `ProjectJsValidationTest` asserts the emitted config is
parseable, carries no `laravelValidationRemote`, and contains none of the
excluded keys. One known laxness is recorded there rather than "fixed": the
parameterised `url:http,https` rule is silently dropped client-side. Do not
swap it for a bare `url` to make it render — that reopens the `javascript:`
scheme the parameters exist to block.

---

## 6. DataTable class

`app/DataTables/ProjectsDataTable.php`. **Read its class docblock before writing
yours** — it contains the one trap in this layer.

Whitelist discipline:

```php
private const WHITELISTED_COLUMNS = ['code', 'name', 'project_type', /* … */];
```

DataTables sends the orderable/searchable flags with every draw, so they are
**client input**. yajra's default whitelist is `*`, which lets a hand-rolled
request put any column name into an `ORDER BY`. `whitelist()` is the real
boundary; the per-column `->orderable(false)`/`->searchable(false)` calls are the
honest-client half of the same rule.

The trap, quoted from that file:

> yajra's whitelist is ONE list governing ordering *and* searching, so it cannot
> express "orderable but never searchable" — which is exactly what a
> `withCount()`/`selectRaw()` alias needs. `units_total` is a
> subquery alias: `ORDER BY units_total` is legal SQL, `WHERE units_total LIKE
> '%x%'` is not (no aggregate/alias in a WHERE clause). Whitelisting them so they
> could be sorted therefore also opened them to search, and a request with
> `columns[N][searchable]=true` plus any global term produced a SQL error that
> yajra re-throws when `APP_DEBUG` is off — an HTTP 500 on a public listing, and
> a leaked SQL fragment when debug is on.
>
> The rule, for this class and for every module copied from it: **an aggregate or
> otherwise computed column NEVER goes on the whitelist.** It earns its ordering
> back with an explicit `orderColumn()` handler, whose SQL is ours and constant,
> and stays unsearchable because there is no `filterColumn()` for it. Put only
> real, physical table columns on the whitelist. If a computed column must also
> be searchable, give it a `filterColumn()` that searches the underlying real
> columns — the way `location` searches city and state.

The rest of the class:

- `query()` — an **explicit projection**, then `withCount()` *after* `select()`
  (a later `select()` throws the subselects away), then
  `withCasts(['x_total' => 'integer'])` because MySQL returns aggregates as
  strings, then `->tap($this->applyFilters(...))`.
- `applyFilters()` — the filter card's selects arrive as extra ajax parameters,
  not as DataTables columns. Match enums with `Enum::tryFrom()` (an unknown value
  **drops** the filter rather than emptying the listing — the friendly reading of
  a stale bookmark) and parse ids strictly:

  ```php
  // Stricter than request()->integer(), which casts: "3 BHK" would arrive as 3
  // and silently filter to a project the user never picked.
  $value = is_scalar($raw) ? trim((string) $raw) : '';
  if ($value === '' || ! ctype_digit($value)) { return null; }
  ```

  (`app/DataTables/UnitTypesDataTable.php::id()` — copy it verbatim; the day a
  third listing needs it, promote it to a shared helper.)
- `rawColumns()` only for the status badge and the actions partial. Everything
  else is escaped by yajra.
- `setRowId('id')` so the browser row carries the model key.
- Relations for a parent column: `->with('project:id,name')`, **not** a join — a
  join puts `projects.name` and `things.name` in the same result row and the
  later one wins (`UnitTypesDataTable::query()`).
- `html()` leaves DataTables' own search box out of the `dom` string, because the
  filter card above the table owns it.
- Column titles come from the lang file; the column list exists only here.

---

## 7. Controller

Thin. `final class`, `declare(strict_types=1)`.

- **Authorisation at the top of every action**: a FormRequest for the two writes,
  `Gate::authorize('view', $thing)` for the rest.
- Validation entirely in the FormRequest, listing entirely in the DataTable
  class. The controller moves validated data into the model and flashes a
  sentence about it.
- `index()` serves double duty — the page for a normal request, the DataTables
  JSON for an XHR — via `$dataTable->render('things.index', [...])`. Skip any
  page-only payload on `$request->ajax()`.
- **Multi-row writes go in `DB::transaction()`.** `ProjectController::destroy()`
  removes the whole subtree in one, deepest first, because soft deletes do not
  fire the database's cascade.
- **Generated codes** — wrap the whole transaction, not just the insert:

  ```php
  $project = RetryOnUniqueCollision::once(static fn (): Project => DB::transaction(static function () use ($request): Project {
      $attributes = $request->validated();
      $attributes['code'] = Project::nextCode();   // row lock, inside the tx

      return Project::create($attributes);
  }));
  ```

  `app/Support/Database/RetryOnUniqueCollision.php` explains why once and only
  once, and why `DB::transaction($fn, $attempts)` is no help (it retries
  deadlocks, and a duplicate key is not one).
- **User-supplied unique values** get no retry — a re-run would collide with the
  same value. Convert the violation into a field error instead:

  ```php
  try {
      return $write();
  } catch (UniqueConstraintViolationException) {
      throw ValidationException::withMessages(['code' => __('projects.validation.code_taken')]);
  }
  ```

  Catch that one exception, around the write only: every other database failure
  stays the 500 it is. `app/Support/RetryOnUniqueCollision.php` is the shipped
  worked example — the project code sequence.
- Never let a controller compute a derived value inline; put it in a service and
  call that service from every screen that shows the value, so no two screens
  can disagree. `app/Services/Projects/ProjectFileService.php` is the surviving
  example: one place decides where an upload lands and what a replacement
  deletes. (`UnitPriceCalculator` was the original example; CR-5 §15.1 removed
  the priced columns and the service with them.)
- N+1: eager-load with explicit column lists and aliased aggregates. See
  `ProjectController::show()` — the units grid is one query whatever the row
  count.

---

## 8. Routes

One file per module in `routes/modules/`, required from the authenticated group
in `routes/web.php`:

```php
Route::middleware('auth')->group(function (): void {
    require __DIR__.'/modules/projects.php';
    require __DIR__.'/modules/things.php';   // ← add here
});
```

The file itself never repeats `auth`. Conventions:

- URI kebab-case (`unit-types`), route **names** snake_case (`unit_types.index`)
  so they match the permission names, the view directory and the lang file.
  `->names()` and `->parameters()` are what do that.
- Omit routes you have no screen for and say why in the file header. Excluding
  `show` makes `GET /things/{id}` answer **405**, not 404 — the member URI
  still exists for PUT and DELETE.
- **Shallow nesting** for sub-resources: the parent appears only where it is
  needed to identify the child. That removes the cross-parent mismatch by
  construction — no route carries two ids that could disagree. Nothing in the
  app is nested any more: CR-5 §15.1 removed the wing and floor-plan routes and
  the `ResolvesInventoryParent` concern that resolved their dangling parents, so
  `routes/modules/projects.php` is one flat resource plus a file-download member
  route. The rule is here for the next module that needs it.
- JSON endpoints for a form's own cascade stay in the module file under an
  `/api` prefix inside the web group: the session cookie is already the
  authentication. The prefix matters — `bootstrap/app.php` renders JSON for
  `api/*`, so an expired session gets a **401 JSON** instead of a 302 to a login
  page the script would parse as an empty list. Nothing uses it today — the
  cascade endpoints fed the wing / floor / unit-type dropdowns and went with
  them — and `bootstrap/app.php` still routes `api/*` that way for the next one.
- Every route is covered by a policy check or `can:` middleware. No exceptions.

---

## 9. Views

`resources/views/things/` — `index`, `create`, `edit`, `show`, and
`partials/{actions,status}.blade.php`.

- **Twin form files, no shared form partial** (spec §0 decision 5). `create` and
  `edit` each spell out **every field**, with the action, the method, the submit
  label, the cancel destination and the FormRequest resolved inline for that one
  screen. There is no `partials/form.blade.php` in this codebase any more —
  projects, units and unit_types were all built that way, the partials were
  deleted, and CR-5 §15.1 then removed the units and unit-types screens
  outright. The canonical (and now only) example is the projects pair
  (`resources/views/projects/create.blade.php` +
  `resources/views/projects/edit.blade.php`).

  The rule costs a duplicate, so it comes with two guardrails and you owe both:

  1. A header docblock in **both** files naming the other, stating that a field
     added, removed or renamed in one must be mirrored in the other in the same
     commit, and listing under a "what this screen has that its twin does not"
     heading everything that legitimately differs (the prefills, the verb, the
     rule source, the trail, the button wording).
  2. A case in `tests/Feature/TwinViewParityTest.php`: add the module to the
     `twin modules` dataset, a branch to `twinHtml()` that seeds a record rich
     enough for the edit screen to render its fullest server-side state, and its
     sentinel names to `twinSentinelNames()`. The test then compares the two
     screens' rendered `name` sets and required-attribute sets over real
     authenticated GETs.

  A script carried inline in both twins gets a third guardrail: extract it from
  both rendered pages and assert byte equality. There is none left to guard —
  the ~200-line units cascade was the one, and CR-5 §15.1 took it,
  `twinCascadeScript()` and that test away together — so the next one brings the
  guardrail back with it. A comment asking the next editor to mirror 200 lines
  is not a guard.

  What the parity test does **not** cover, and therefore what the docblock has
  to make somebody's job: label wording, field order and validation-rule drift.

- **Layout: ONE listing card, with the filter bar as its header strip.** Every
  v12 management page draws it that way — `<div class="card"><div class="filter-bar">…`
  and the `.data-table` under it in the same card, with the primary "Add …"
  button on its own right-aligned row ABOVE the card. That is also the reason
  the DataTables `dom` string drops the plugin search box: the wireframe's
  `.search-input` in the filter bar is the one the user sees, and the page
  script feeds the plugin from it. The card's footer strip is v12's
  `.pagination-wrapper`, which is what the `dom` string wraps `ip` in.
  (`resources/views/projects/index.blade.php`. Before CR-2b the filters lived
  in an `<x-card>` of their own above a second card; they do not any more.)
- **Layout: ONE form card, with a `.form-section` per heading.** Both form
  twins are `<div class="card">` → a `.form-section` (its `.form-section-title`
  carrying the v12 page's own icon) per heading → `<div class="card-footer">`
  with Cancel (`.btn-outline`) then the primary action. Fields inside a section
  are grouped by v12's `.form-row.form-row-2` / `-3` grids, so **no field
  carries a `group-class`**: the row owns the columns and `.form-group` owns its
  own bottom margin. Do not wrap a form section in `<x-card>` — that component
  adds a `.card-body` and the section already has v12's 24px padding.
- **Layout: a detail screen is `.detail-grid` label/value pairs**, two columns
  of `<div class="detail-item"><span class="label">…</span><span class="value">…</span></div>`,
  inside an `<x-card>` per panel. `.detail-item-wide` (app additions) runs one
  the full width for a paragraph or a url.
- **Breadcrumbs go in a section, not in the content.** v12 puts the trail in the
  header beside the page title, and `layouts/partials/topbar.blade.php` yields
  it, so a view writes
  `@section('breadcrumbs') <x-breadcrumbs :items="…" /> @endsection` above its
  `@section('content')`. A view that declares none gets a Home-only trail, which
  is what the dashboard does.
- Fields are `<x-form.*>` components only — `input`, `select`, `select2`, `date`,
  `textarea`, `checkbox-group`, `file`, `radio`, `submit`, and `<x-form.group>`
  when you need a control the library does not ship. They give you the label,
  the wireframe's red asterisk, old-input handling and the inline error for free
  (`resources/views/components/form/group.blade.php`).

  Since CR-2 they emit v12's class names: `.form-group` wrapper, `.form-label`
  with a `<span class="required">` asterisk, `.form-control` on every control
  including selects (v12 uses no `.form-select`), `.btn.btn-outline` for a
  Cancel. Do not add `form-control-solid`, `fv-row`, `fw-semibold` or
  `btn-light` to markup of your own — those are Metronic's. CR-2 phase C had two
  screen sets left to convert, units and unit types, and CR-5 §15.1 removed
  both — so there is no unconverted screen left to copy the wrong thing from.
  Copy from the projects views.

  One name is load-bearing beyond the styling: `nrems-upload-guard.js` finds the
  node it hangs its message off by walking up to an ancestor with
  `<x-form.group>`'s wrapper class, which it hard-codes as `GROUP_CLASS`. If
  that wrapper class ever changes again, change it there in the same commit —
  a mismatch makes the guard clear an over-cap file with nothing on screen to
  say why, and no PHP test can see it.

  Two of them are newer than the rest and worth reading before first use:

  - `<x-form.file>` — the wireframe's upload zone: a clickable label wrapping a
    hidden `<input type="file">`, the hint line that states the slot's accepted
    types and size cap, and — pass `:current-name` / `:current-url` — the
    current-file block the edit twin shows. A file input cannot be repopulated
    from old input, so on a failed submit the error renders and the control
    comes back empty; say so in the copy rather than pretending otherwise
    (`resources/views/projects/edit.blade.php`). Pass `:max-kb` as well — the
    same method the FormRequest builds its `max:` rule from, never a literal —
    and `nrems-upload-guard.js` clears an over-cap pick on choose and shows the
    reason in the field's normal error line. That is a convenience for the case
    PHP would otherwise drop before Laravel sees it, not a control.
    The server half is `ProjectFileService`, and it is the pattern to copy:
    uploads go to **the disk `config('nrems.uploads_disk')` names** — `local`
    or `s3`, a deployment switch (spec §13) — so module code never writes a
    disk literal and never hands a stored file to `url()`/`temporaryUrl()`; it
    streams the bytes back through an authorised route instead.
  - `<x-form.radio>` — a radio group with strict-string checked resolution
    through `form_old()`, inline or stacked. Built for Bookings; it has no
    consumer yet, so its tests in `FormComponentsTest.php` are its
    specification.
- Modals get **per-form error bags** so a failed edit in one modal does not light
  up the others. The worked example was the wings editor, removed in CR-5
  §15.1; `<x-modal>` and its tests in
  `tests/Feature/Components/DatatableModalTest.php` are what is left of it.
- **`Js::from()` for ALL JS-context interpolation.** Commit `3492ec1` fixed a
  stored XSS: a project named `');alert(1)//` broke out of the `confirm()` string
  in the row-actions partial. `{{ }}` escapes for *HTML*, not for Javascript.

  ```blade
  onsubmit="return confirm({{ Js::from(__('projects.delete_confirm', ['name' => $model->name])) }});"
  ```

  The same rule covers any value reaching a script: prefer a `data-` attribute
  the script reads, which is how `nrems-unit-import.js` gets every string it can
  put on the screen — one JSON `data-strings` attribute on the section, built in
  `resources/views/projects/create.blade.php`, and no wording of its own.
- Delete is a POST form with `method="DELETE"`, never a link: a GET that
  destroys data is triggered by any crawler or prefetch. Gate each control on its
  own ability so a view-only user gets the eye and nothing else.
- Every data-entry form opens with `<x-form.open id="..." :action="..." />` and
  closes with `<x-form.close />` — the component emits the CSRF token, the
  `_method` spoof field and `novalidate`, so no view writes `@csrf`/`@method`
  by hand. Pass `:novalidate="false"` on a form with nothing to validate (a
  logout button, a delete confirmation) and `:multipart="true"` on one with a
  file input. The index filter bars are the documented exception: they are
  client-side `onsubmit="return false"` wrappers that never reach the server
  and stay plain HTML.
- A partial rendered through `rawColumns()` is injected unescaped. Say so at the
  top of the file and keep user data out of it except through `Js::from()`.
- User-facing strings live in `lang/en/things.php`. Blade uses `{{ }}` only;
  every `{!! !!}` needs a comment saying why it is safe (today they are all
  yajra table/init output and jsvalidation's script block).

### CSV import, when a form takes a file of rows

The project form's Unit Inventory section is the reference implementation, and
the next module that imports rows should copy its shape rather than invent one.
Three files:

- `public/assets/js/nrems-unit-import.js` — the client module. ES5, no build
  step, no dependency. `FileReader` on the chosen `.csv`, the size checked
  against the cap **before a byte is read**, quoted fields / CRLF / BOM handled,
  and the header row skipped only when line 1's **second** cell is present,
  non-empty and not a number (read its docblock — the guard has two edges, both
  covered by the recorded node-fixture run in the module's *Measured, not
  assumed* block. There is no JS test harness in this repo, so that recorded
  run is the evidence; copying the pattern owes the same recorded run).
  Two rules it exists to demonstrate: every parsed cell
  reaches the DOM through `.value` or `createTextNode`, **never**
  `innerHTML`; and every user-facing
  string comes in from Blade in one JSON `data-` attribute, so the module holds
  no copy of the wording and the CSV's own filename is the only untrusted text
  it ever renders.
- `tests/Feature/Projects/ProjectUnitImportTest.php` — the **server** half, and
  the one to read first. Rows post as `units[N][column]`, are validated by the
  parent's FormRequest and become real rows in one transaction. The properties
  it pins are the ones any import needs: a row reaches exactly the columns you
  meant it to and no others, validation is never laxer than the unique index
  (soft-deleted rows included), duplicate rows come back **keyed to their own
  row** rather than as one anonymous error, and the residual insert race is
  converted to a row error rather than a 500 or a wasted retry.
- `tests/Feature/Projects/ProjectUnitImportViewTest.php` — the **rendered** half:
  the section is on both twins, and a rejected submit hands the parsed rows back
  from `old()` instead of making the user re-upload — together with the state
  that re-render has to be in (CR-5 §15.3.6): saved rows hidden, replace warning
  shown, clear-all link present. Note its stated scope: a Blade test cannot run
  the parser, so the parse itself belongs to the headless flow in
  `tools/v12-evidence.mjs` (the browser-evidence task owns it).

Two conventions worth carrying over verbatim: the payload keys are the DB
column names, and error **keys** stay 0-based while the row **labels** the user
reads are 1-based (`StoreProjectRequest::unitRowAttributes()`). `readonly` on a
parsed row is cosmetic — the server re-validates every cell.

One shape decision the client made for this section and worth knowing before
you copy it (CR-1, spec §11): the file is the **only** entry path. No add-row
button, no manual row, no per-row delete, every field read-only, and the undo is
a clear-all that drops the whole parsed batch. The edit twin additionally
*displays* the rows already stored — with **no `name` attribute on any of them**,
which is what stops the form re-posting the very rows it is showing and is the
detail to copy if your import screen ever shows saved rows alongside new ones.

CR-5 §15.4 then made a posted batch **replace** the stored rows rather than add
to them, and that carries a rule worth copying whole: any state the client
module puts on the screen to warn about a destructive save has to be
server-rendered as well, for the re-render after a rejected submit. No `change`
event fires on a freshly drawn page, so the module never runs there — and the
first version of this shipped a screen that showed the saved rows and the file's
rows side by side, with no warning, one save away from deleting the former
silently.

---

## 10. Sidebar entry

`resources/views/layouts/partials/sidebar.blade.php`. Add the entry inside the
right section, gated on the module's view permission, with the section heading
gated on `@canany` of everything under it — otherwise a heading renders over an
empty section. Active state keys off `request()->routeIs('things.*')` and sets
`aria-current="page"`.

Preview-only entries (modules not built yet) render inert and are shown to a
**Super Admin only** — `$previewMenu` at the top of the file — so an ordinary
user is never handed a dead link. As your module lands, move its entry out of
`$previewMenu` and into a permission gate; that is the whole ceremony.

The item list and the six section headings are wireframe v12's. That is pinned
by tests in `tests/Feature/Layout/AppShellTest.php`, which assert every heading
and every label in document order — a new entry means updating that test, on
purpose. The header of the sidebar partial explains the rest.

A screen being built is not by itself a reason for a menu entry, and this module
is the cautionary tale twice over: the Unit Inventory entry was added on that
logic and withdrawn by the client (CR-1, spec §11), and CR-5 §15.1 then removed
the screen it pointed at, along with the Unit Types entry that was this
document's one "documented addition" past v12. If v12 gives no way into your
screen, say so on the client-confirmation list in README rather than assuming
the entry is wanted.

---

## 11. Tests

`tests/Feature/Things/ThingCrudTest.php` and, for a listing,
`ThingsDataTableTest.php`. Pest, `declare(strict_types=1)`, one behaviour per
`it()`. The checklist, all of which exist for Projects:

- **Permission matrix** — for each of index/create/store/edit/update/destroy:
  guest redirects to `/login`, an authenticated user without the permission gets
  403, the permitted role gets through. `tests/Feature/Policies/InventoryPolicyTest.php`
  covers the policy side per role.
- **Validation paths** — every required field, every boundary (the column
  maxima), and every cross-field rule in `after()`.
- **Tamper regressions** — the ones already found, so they cannot come back:
  a submitted `code` on a project (the server owns the sequence), a
  `columns[N][searchable]=true` on an aggregate alias, a non-numeric filter id,
  a `GET` on a member URI with no `show` route, and — since CR-5 §15.4 — a
  hand-rolled post carrying `units` rows, which replaces an inventory and so has
  to be gated on `projects.edit` like everything else on that form.
- **XSS probes** — store a row whose name is `<script>alert(1)</script>` and
  `');alert(1)//`, then assert the escaped form appears in the listing HTML, the
  DataTables JSON and the delete confirmation.
- **N+1 pin** — count queries around the detail page and assert the number, so
  an eager load removed later fails a test instead of a page.
- **Front-end assets** — if the page pushes a vendored script, assert the file
  exists and that the page links it *before* yajra's init
  (`tests/Feature/Projects/ProjectsDataTableTest.php`).
- **Seeds** — if the module ships demo rows, recompute anything derived rather
  than asserting a literal (`tests/Feature/Seeds/DemoSeedTest.php`).

Gates before every commit:

```bash
composer test && composer analyse && composer lint
```

Then re-run the responsive smoke if you added or changed a page, and commit the
new screenshots:

```bash
cd tools && npm install && npm run smoke
```

Add the page to the `walk()` list in `tools/responsive-smoke.mjs` — a page with
no evidence PNG is a page nobody checked at 390px.
