# CR-7 — Booking Types & Customer Document Types (Administration masters)

Status: **approved for implementation** · Author: BA/reviewer session 2026-08-31
Wireframes (design source of truth, byte-identical vendored copies):
`docs/wireframes-v12/booking-type.html` · `docs/wireframes-v12/customer-doc-type.html`
Recipe: follow `docs/adding-a-module.md` (§0–§11). Where this document and the
recipe disagree, this document wins and says why; where the wireframe and this
document disagree, the wireframe wins.

These are the first two **modal-CRUD-on-index** modules in the app: no create /
edit / show pages, one listing screen each with an Add modal and an Edit modal.
They are also small enough to build together as one CR — Customer Doc Types
depends on Booking Types (its checkbox list), so Booking Types lands first in
the migration order.

---

## 1. Scope

**In scope**
- `booking_types` table + full modal CRUD listing at `/booking-types`.
- `customer_doc_types` table + pivot `booking_type_customer_doc_type` + full
  modal CRUD listing at `/customer-doc-types`.
- Permissions `booking_types.*` and `customer_doc_types.*` (4 each), policies,
  sidebar activation of the two existing inert Administration entries.
- Seeders: the six wireframe booking types as an idempotent default-data seeder;
  the five wireframe doc types (with pivots) as a demo seeder.
- CSS: port `.tag-wrap`, `.tag-pill`, `.checkbox-list` from the wireframes'
  page-local `<style>` blocks into the app-additions tail of
  `public/assets/css/nrems-wireframe.css`.

**Non-goals (do not build)**
- No status toggle UI. The wireframe modals contain only the name field; the
  Status column renders from the DB and nothing on these screens changes it.
- No search box, no filter bar. The wireframe listings have neither (unlike
  Projects/Inquiries). Do not add a `.filter-bar`.
- No consumption of these masters elsewhere (the Booking module is a future CR;
  `upload-document.html` is a future CR). Nothing outside these two screens
  reads the new tables yet except the doc-type checkbox list reading
  booking_types.
- No restore-from-trash UI.

---

## 2. Database

### 2.1 `booking_types`

| column | type | notes |
|---|---|---|
| id | id | |
| name | string, **unique** | the "Type" field; e.g. Individual, HUF |
| is_active | boolean, default **true** | see decision D2 |
| created_by / updated_by | FK users, nullable, nullOnDelete | house audit pair for masters is created_by only — but these rows are edited in place via modal, so both; justify in the migration docblock like `follow_ups.updated_by` |
| timestamps, softDeletes | | |

### 2.2 `customer_doc_types`

| column | type | notes |
|---|---|---|
| id | id | |
| name | string, **unique** | the "Document Type" field; e.g. Aadhar Card |
| is_active | boolean, default true | |
| created_by / updated_by | as above | |
| timestamps, softDeletes | | |

### 2.3 `booking_type_customer_doc_type` (pivot — first in the app)

Laravel's default alphabetical name, kept deliberately (`booking_type` <
`customer_doc_type`): `booking_type_customer_doc_type` with
`booking_type_id` + `customer_doc_type_id`, both `constrained()->cascadeOnDelete()`,
a composite **unique** on the pair, no timestamps, no id column needed
(composite primary or plain unique index — implementer's choice, document it).
`belongsToMany` on both models with default naming (no overrides required —
that is the point of keeping the default table name).

**Why a pivot and not the `projects.amenities` JSON pattern (decision D1):**
booking types are user-editable rows, not a fixed constant list. A rename must
update every pill on the doc-types listing; a delete must be visible to
integrity rules. JSON of names goes stale on rename; JSON of ids has no FK.
Referential integrity wins. Record this in both models' docblocks.

### 2.4 Uniqueness and soft deletes

`name` gets a **DB unique index** (brokers precedent) and validation must match
the constraint exactly: `Rule::unique(...)` **without** `withoutTrashed()`, so a
name held by a soft-deleted row fails validation with the friendly message
instead of passing validation and detonating on the index (500). On update,
`->ignore($model->id)`. Case sensitivity is whatever the DB collation gives —
do not add a custom case-insensitive rule.

---

## 3. Behaviour rules

### 3.1 Booking Types

- **Create**: modal → `POST /booking-types` → name required|string|max:255|unique.
  Redirect to index with a success flash.
- **Update**: modal → `PUT /booking-types/{booking_type}` → same rules with ignore.
- **Delete**: row button → the house POST-form-with-DELETE pattern (copy the
  Projects listing's delete confirm affordance exactly as it exists there).
  **Delete is blocked while any non-deleted customer doc type references the
  row** (decision D3): the controller checks the pivot against non-trashed doc
  types and redirects back with an error flash naming the count — e.g.
  "Cannot delete: 3 customer document types use this booking type." No orphaned
  pill must ever be possible. (Future CR note: when Bookings reference this
  table, the same guard grows a second clause.)
- Validation failures return `redirect()->back()` with the named error bag and
  reopen the right modal (see §5).

### 3.2 Customer Doc Types

- **Create**: modal → `POST /customer-doc-types` → `name` rules as above, plus
  `booking_types` **required|array|min:1**, each element
  `integer|distinct|exists:booking_types,id,deleted_at,NULL`. Tamper rule: a
  soft-deleted or nonexistent booking type id must fail validation, not be
  silently dropped.
- **Update**: `PUT /customer-doc-types/{customer_doc_type}` → same; pivot is
  `sync()`ed inside the same transaction as the row update.
- **Delete**: house pattern, no guard needed (pivot cascades; nothing else
  references doc types yet).
- The checkbox list renders **all non-deleted booking types** ordered by id asc
  (wireframe order), regardless of `is_active` — there is no deactivate UI yet,
  so filtering on it would be dead code. Revisit when a toggle exists.
- Pills on the listing render the related booking types' **current** names,
  ordered by booking type id asc.

---

## 4. Pixel-perfect screen specs

General: both pages extend `layouts.app` with the standard breadcrumbs
(`Home / Administration / <page>`). Card header exactly as the wireframe:
`<h5>` with the module icon (`fa-tags` / `fa-file-circle-check`, primary color,
8px right margin) + title, and the primary Add button right-aligned
(`<i class="fa-solid fa-plus"></i> Add Booking Type` / `Add Document Type`).
**No filter bar, no search input.** Table uses the house `data-table` +
`table-wrapper` classes.

### 4.1 Booking Types listing

Columns and widths from the wireframe: `#` (60px), `Type`, `Status` (120px),
`Actions` (100px).
- `#` — sequential row index (yajra `addIndexColumn()` / `DT_RowIndex`),
  not the DB id; non-orderable, non-searchable.
- `Type` — cell class `fw-500`, plain text (escaped).
- `Status` — `<span class="badge badge-success"><span class="dot"></span> Active</span>`
  when `is_active`; when false render the equivalent muted/danger badge the CSS
  already offers, label "Inactive" (partial + `rawColumns`, values escaped).
- `Actions` — `.action-btns` div: edit pencil `btn-icon-sm text-primary`
  (`title="Edit"`), delete trash `btn-icon-sm text-danger` (`title="Delete"`).

### 4.2 Booking Type modals

Reuse `<x-modal>` + the existing modal CSS (nrems-wireframe.css already maps the
Bootstrap modal onto the wireframe look — do NOT copy the wireframes'
page-local modal styles). Fidelity points from the wireframe:
- Add: title "Add Booking Type" with `fa-plus` icon; body = one required field
  labelled `Type *`, placeholder `e.g. Trust`, helper text
  `Enter the booking type name`; footer = outline `Cancel` + primary
  `<i class="fa-solid fa-check"></i> Save Type`. Dialog max-width 440px.
- Edit: title "Edit Booking Type" with `fa-pen`; same field prefilled, helper
  `Update the booking type name`; primary button `Update Type`. 440px.
- Focus moves to the text input on open; Escape and backdrop click close
  (Bootstrap gives both).

### 4.3 Customer Doc Types listing

Columns: `#` (60px), `Document Type`, `Applicable Booking Types`,
`Status` (120px), `Actions` (100px). The pills column:
`<div class="tag-wrap"><span class="tag-pill">Individual</span>…</div>` —
names escaped, rendered via a partial + `rawColumns`.

### 4.4 Customer Doc Type modals

Dialog max-width **520px** (wireframe differs from booking-type's 440px).
- Field 1: `Document Type *`, placeholder `e.g. Voter ID Card`.
- Field 2: `Applicable Booking Types *` as the wireframe's `.checkbox-list`
  (bordered, radius, **max-height 160px, overflow-y auto**, one full-width
  `<label>` row per booking type with a leading checkbox, row hover background,
  `.checked` row highlight in `--primary-light`); helper text
  `Select one or more applicable booking types`. Checkbox `name`:
  `booking_types[]`, value = id, label text = name.
- Footer: `Save Document Type` / `Update Document Type`.
- Port `.checkbox-list`, `.tag-wrap`, `.tag-pill` rules from
  `docs/wireframes-v12/customer-doc-type.html` lines 71–108 into the
  app-additions tail of `nrems-wireframe.css` **verbatim values** (colors via
  the existing CSS variables).

### 4.5 Sidebar

Replace the two inert preview entries with live, gated entries following the
exact Inquiries pattern (`@can('booking_types.view')` /
`@can('customer_doc_types.view')`, active-state via
`request()->routeIs('booking_types.*')`, `aria-current`). The Administration
**section heading** must now render for users holding either permission (extend
the heading's visibility condition the way the Sales heading does), while the
other three Administration entries stay preview-inert.
`lang/en/nav.php` labels already exist — reuse, don't duplicate.
**`tests/Feature/Layout/AppShellTest.php` pins the sidebar and must be updated
deliberately** (headings + item order list + new gating cases).

---

## 5. Architecture

### 5.1 Modal CRUD mechanics (the load-bearing part)

Follow the Inquiries listing-modal precedent (`resources/views/inquiries/partials/modals.blade.php`
+ `public/assets/js/nrems-inquiry-actions.js`) exactly in spirit:

- **One Add modal + one Edit modal per page**, rendered once at the foot of the
  index; the Edit modal is re-targeted per row by JS.
- Each form wraps its modal (`<x-form.open>` outside `<x-modal>`); the Edit
  form's action is a placeholder (`route('…update', ['booking_type' => 0])`)
  rewritten client-side from the row's server-generated URL. JS never
  constructs a path from an id.
- **Per-form error bags**: `StoreBookingTypeRequest` → bag `store`,
  `UpdateBookingTypeRequest` → bag `update` (same pair per module). Controls
  inside the modals are rendered manually within `<x-form.group>` reading
  `$errors->getBag(...)` — the `<x-form.input>` family only reads the default
  bag.
- **Reopen-after-redirect**: the index computes which bag is non-empty and hands
  the JS one config div, e.g.
  `<div id="nrems_booking_types_page" data-reopen="store|update|" …>`, plus a
  hidden `reopen_id` input in the edit form (restored via `old()`) so the right
  row's edit modal reopens with the user's rejected input intact. Fresh open
  clears fields; reopen must NOT clear (both lessons already learned in CR-6 —
  including waiting for yajra's DataTable registry before wiring a reopen that
  needs a drawn table row).
- **Row payload**: each row's edit button carries **one JSON data attribute**
  (e.g. `data-row`) with `{id, name, url}` — and for doc types additionally
  `booking_type_ids: [..]`. All user-facing strings ride a single
  `data-strings` JSON attribute on the page config div; zero wording inside JS.
- **Delegated event binding** on `document` (DataTables replaces the tbody every
  draw); all DOM writes via `createElement`/`textContent`, never `innerHTML`;
  modals opened with `bootstrap.Modal.getOrCreateInstance`.
- **jsvalidation is not used on modals** (matches CR-6; the recipe's
  one-binding-per-form constraint makes it a poor fit). Server-side validation +
  reopen is the contract. Add `novalidate`-free HTML `required` attributes for
  the browser's first line of defence where the wireframe marks `*`.

**Shared JS module (decision D4):** implement ONE file,
`public/assets/js/nrems-master-modals.js`, driven entirely by the page config
div + row payloads: booking-types is the degenerate case (name field only),
customer-doc-types adds the checkbox list (prefill = check ids in
`booking_type_ids`, toggle the label's `.checked` class on change like the
wireframe). Both pages load the same file. If during implementation this
abstraction fights you, split into two files and note why — do not force it.

### 5.2 Backend shape (per module, recipe §§1–8)

- Models `BookingType`, `CustomerDocType`: `HasAudit, HasFactory`, `$fillable`
  = `['name']` only — `is_active`, `created_by`, `updated_by` are server-owned
  and set via `forceFill`/model events per house pattern; full `@property`
  docblocks (larastan level 6). `belongsToMany` both directions.
- Factories for both (+ pivot attachment states).
- Policies: standard five one-liners mapping to the permission strings; policy
  autodiscovery (no manual registration).
- Permissions: append 8 strings to `RolePermissionSeeder::PERMISSIONS`;
  **ROLES: Admin → all 8; Sales → none; Operations → none** (decision D5 —
  Administration masters are admin-only; Super Admin bypasses via `Gate::before`).
- FormRequests in `app/Http/Requests/BookingTypes/` and `…/CustomerDocTypes/`
  with `authorize()` via the policy and the named `$errorBag`.
- DataTables: `BookingTypesDataTable`, `CustomerDocTypesDataTable` following
  the house skeleton — explicit projection, `whitelist()` of **physical columns
  only** (`name`), `addIndexColumn()`, computed columns unsearchable and
  non-orderable, `searching => false` in parameters (no search UI exists),
  default order by id asc (wireframe order), `dom` without the `f` search box
  (reuse the house `dom` string). Doc types eager-load `bookingTypes:id,name`
  (relation, never a join) — N+1 pinned by test.
- Controllers: `final`, thin; store/update/destroy + index only. Pivot `sync`
  and row write share one transaction. Delete guard per §3.1.
- Routes: `routes/modules/booking-types.php` and
  `…/customer-doc-types.php`, `require`d from the auth group in `routes/web.php`;
  `Route::resource(...)->only(['index','store','update','destroy'])` with
  `->names()` snake_case (`booking_types.*`) and `->parameters()` snake_case.
  Omitted routes documented in the file header (recipe §8: omission = 405).

### 5.3 Seeders

- `DefaultBookingTypeSeeder`: `firstOrCreate` the six wireframe types in order
  (Individual, HUF, Proprietorship, LLP, Partnership, Company). Idempotent;
  safe for production; wire into `DatabaseSeeder`.
  > **Superseded 2026-09-12 (client decision).** The shipped list is now SEVEN:
  > Individual, **NRI**, HUF, Proprietorship, **Partnership**, **LLP**, Company.
  > `DefaultBookingTypeSeeder::TYPES` is the single source; the paragraph above
  > records what CR-7 agreed, not what ships. NRI is mapped to no document type
  > in the demo seeder below — see that seeder's docblock for the two CR-15
  > behaviours that follow from an empty required-document set.
- `DemoCustomerDocTypeSeeder`: the five wireframe doc types with their exact
  pivot sets (Aadhar Card → Individual/HUF/Partnership; PAN Card →
  Individual/HUF/LLP/Company; Passport → Individual; MOA / AOA → Company/LLP;
  Partnership Deed → Partnership/LLP). Demo-only, NOT in `DatabaseSeeder`
  (mirrors `DemoBrokerSeeder`).

---

## 6. Security gates (review will check each)

1. Every route behind auth + policy (`authorizeResource` or per-method).
2. Mass assignment: only `name` fillable; `booking_types[]` reaches the pivot
   via validated ids and `sync()`, never via fill.
3. The pivot tamper rule (§3.2) has negative tests: nonexistent id,
   soft-deleted id, non-integer, duplicate ids.
4. XSS: a booking type named `<script>alert(1)</script>` renders inert in the
   Type cell, the pill, the checkbox label, and the edit modal prefill
   (`data-row` JSON is attribute-escaped by blade; JS uses `textContent`).
5. DataTable whitelist: physical columns only; a crafted `order`/`columns`
   payload naming a computed column must not reach SQL.
6. Delete guard (§3.1) cannot be bypassed by hitting the endpoint directly.
7. No wording in JS files; all strings via `data-strings` / lang.

## 7. Tests (Pest, per module unless noted)

- Permission matrix: all four roles × index/store/update/destroy (+ guest 302).
- CRUD happy paths through the real endpoints with flash assertions.
- Validation: required name, max length, unique (incl. the soft-deleted-name
  case asserting the friendly failure), update ignoring self; doc types:
  `booking_types` required/min:1 + the four tamper cases from §6.3.
- Delete: booking type in use → blocked with error flash + row survives;
  unused → soft-deleted; doc type delete → pivot rows gone, booking types
  untouched.
- DataTable: response shape, index column, pills content, N+1 pin (query count
  on the doc types listing is constant as rows grow), whitelist probe.
- Modal parity guard (new, replaces TwinViewParityTest which does not apply to
  modal-only modules — do NOT add these modules to its dataset): on one index
  GET, the Add form and Edit form render the identical set of field names and
  the identical set of `required` names (booking_types: `name`; doc types:
  `name`, `booking_types[]`).
- Sidebar: `AppShellTest` updated — gated visibility per permission, active
  state, section heading logic, item order list.
- Seeders: default seeder idempotent (run twice → six rows); demo seeder
  produces the exact wireframe pivot matrix.
- Gates before handover: `php artisan test` (846+ pass, no new failures),
  `composer analyse` (level 6, 0 errors), `vendor/bin/pint --test` scoped to
  the CR-7 footprint. (Unscoped `composer lint` stays broken by the
  pre-existing corrupt `ProjectCrudTest.php` — not this CR's problem.)

## 8. Decisions & assumptions (BA-owned)

- **D1 Pivot over JSON** — §2.3. First pivot in the app; default naming kept.
- **D2 `is_active` boolean, default true** — first boolean flag in the schema,
  chosen over a two-case status enum because the wireframe exposes no lifecycle
  and a binary flag with no transitions doesn't earn an enum. No toggle UI this
  CR; the column exists so a future CR can add one without a migration. Justify
  in the migration docblock (the `follow_ups.updated_by` precedent for
  documented convention exceptions).
- **D3 Restrict-delete for in-use booking types** — orphaned pills are worse
  than a blocked delete; error flash names the count.
- **D4 One shared modal JS module** — with an explicit escape hatch.
- **D5 Admin-only masters** — Sales/Operations get no `booking_types.*` or
  `customer_doc_types.*` permissions.
- **A1** The Status column is display-only this CR (everything seeds/creates as
  Active), matching the wireframe exactly.
- **A2** No pagination behaviour change: the house DataTable pagination appears
  only past the page length; with ≤25 rows the screen matches the wireframe.
