# Project Coding Guidelines

## Primary Directives

1. **Emulate Existing Patterns:** ALWAYS check the existing codebase for examples of similar implementations before writing new code. Adhere to the established patterns you find. This is the most important rule.
2. **Strict Separation of Concerns:**
    - **Controllers** handle HTTP requests, authorization, and data mapping.
    - **Repositories** handle all database write operations, always within `DB::transaction`.
    - **Form Requests** handle all validation.
    - **Models** define schema, relationships, and contain business logic (like ID generation) in static methods.
3. **Use Constants for States:** NEVER use magic numbers or strings. Define all statuses and types as `CONST` in the relevant model, and provide a descriptive `STATUSES` or `TYPES` array for UI purposes.
4. **Prioritize Data Integrity:** Every database write operation MUST be transactional. When deleting a parent record, ensure related child records are also handled appropriately (e.g., deleted or re-parented).
5. **Follow Naming Conventions:**
    - **Routes:** `context.module.submodule.action` (e.g., `manage.people.users.store`).
    - **Model Scopes:** `scopeActive`, `scopeReady`.
    - **Relationships:** `managedBy`, `clientPort`.
6. **Provide User Feedback:** Use the `flash()` helper after every CUD (Create, Update, Delete) operation to inform the user of the result.
7. **Structure Model Logic:** Place complex, reusable logic for generating data like reference IDs or consignment notes into `public static` methods on the corresponding Eloquent model.

---

## 1. Naming Conventions

- **Code Style:** All PHP code MUST adhere to PSR-12.
- **Language:** All code, comments, and documentation MUST be in English.
- **Imports:** Use `use` statements — never inline class references (except in Blade files). Do not include a leading backslash in `use` statements.
- **Variables:** camelCase (e.g., `$containerShipments`).
- **Constants & Environment Variables:** SCREAMING_SNAKE_CASE (e.g., `STATUS_IN_WAREHOUSE`, `DB_HOST`).
- **Classes, Interfaces, Traits:** PascalCase (e.g., `ContainerRepository`, `Shipment`).
- **Functions & Methods:** camelCase (e.g., `getConsignmentNote()`, `completeDeliver()`).
- **Simplicity (KISS):** Prefer clear, straightforward code over clever solutions.
- **DRY:** Abstract reusable logic into services, traits, or helpers. Avoid duplicating code.
- **Custom Helpers:** Use existing helpers like `data_only()` for filtering arrays and `hash_expand()`.

---

## 2. Architecture & Design Patterns

### Custom Core Library (`diver`)

- Eloquent models **MUST** extend `Diver\Database\Eloquent\Model`.
- Form Requests **MUST** extend `Diver\Http\Requests\FormRequest`.

### Repository Pattern

- All database write operations (create, update, delete, status changes) **MUST** be handled by a Repository class.
- **Reuse before you add.** Read the whole Repository class BEFORE writing a new method, and prefer extending an existing one over adding a sibling. A plain field write belongs in the existing `update()` — never add `updateSettings()` / `updateNotes()` / `updateX()` for a subset of the same columns. Near-duplicate writers drift: the same column ends up written two ways, and a fix applied to one path silently misses the other.
- **Give an operation its own method only when it does more than write fields** — a state change (`active()`, `inactive()`, `convert()`, `cancel()`) that also fires an event, sends a notification, or cascades to child records. The side effect is what justifies the separate method; having its own button, route, or form does NOT.
- Every repository method that modifies the database **MUST** be wrapped in `DB::transaction()`.
- Filter (using `data_only()`) and format input data **BEFORE** the `DB::transaction()` block.
- The `DB::transaction()` block MUST strictly contain only the database write operations.
- Execute model creation in a single line within the transaction, passing the prepared data array (e.g., `Model::create($data)`).
- Repository methods that create or update records **MUST** accept nested array data keyed by the model name (e.g., `$input['property_booking']['field_name']`).
- Repository methods that modify records **MUST** return the fresh/refreshed model instance.
- For status change methods (e.g., `active()`, `inactive()`, `convert()`, `cancel()`): update the status constant, use `save()` within a transaction, then return the refreshed model.

### Facades

- Facades for repositories **MUST** return the fully qualified class name of the concrete repository in `getFacadeAccessor` (e.g., `return \Src\Product\Repositories\ProductRepository::class;`).

### Form Request Validation

- All request validation **MUST** be in dedicated Form Request classes. Controllers must not contain validation logic.

### API Response Transformers

- All API endpoints (`v1`) responses **MUST** be formatted using a dedicated Transformer class (e.g., `app/Http/Transformers/v1/UserTransformer.php`).

### Action Classes

- Use single-purpose Action classes (`app/Actions`) for discrete operations that don't fit neatly into a controller or model.

---

## 3. Controllers

- **Single Responsibility:** Each controller method handles a single action.
- **Route Model Binding:** Controller methods MUST accept resource IDs as parameters, not model objects. Query the model within the method (e.g., `$product = Product::findOrFail($id);`).
- **Repository for Writes:** All data creation, updates, and deletions **MUST** be delegated to the appropriate Repository class.
- **Explicit Input Mapping:** When calling a repository method, **NEVER** use `$request->validated()` or `$request->all()`. ALWAYS explicitly map each input field one by one into a nested array keyed by the model name:
  ```php
  $data['product']['name'] = $request->input('name');
  $data['product']['price'] = $request->input('price');
  ```
- **Direct Model for Reads:** Querying data for views directly via Eloquent models is acceptable.
- **Query Requests:** For `index`/list methods requiring filtering or searching, use a dedicated `QueryRequest` class (extending `Diver\Http\Requests\QueryRequest`).
- **User Feedback:** Use `flash()` with specific, direct messages (e.g., `flash()->success("Product '{$product->name}' has been updated.")`). Do not use localization keys.

---

## 4. Models (Eloquent)

1. **Constants for States:** Define statuses, types, or magic values as integer or string `CONST` at the top of the model.
2. **Metadata Arrays:** Create a corresponding `public CONST` array (pluralized, e.g., `STATUSES`, `TYPES`) with human-readable names and UI colors.
3. **Static Helper Methods:** Complex, model-specific logic (e.g., generating unique IDs) MUST be `public static` methods on the model.
4. **Eager Loading:** Use `$with` or `->with()` to prevent N+1 problems.
5. **Relationship Methods:** Name in camelCase; declare explicit return types (e.g., `public function docs(): \Illuminate\Database\Eloquent\Relations\HasMany`).
6. **Mass Assignment:** Use `$fillable` to explicitly define mass-assignable attributes. **NEVER use `$guarded = []`**.
7. **No Query Scopes for Filtering:** Filtering logic **MUST NOT** be in model scopes. Use `QueryRequest` classes instead. Models contain only relationship methods, static helpers, and basic configuration.
8. **User Name Access:** ALWAYS use `$user->profile->full_name` — never `$user->name` directly. ALWAYS provide a fallback: `$user->profile ? $user->profile->full_name : $user->email`.

---

## 5. Views (Blade)

- **Form Input Persistence:** ALWAYS use `old('field_name', $defaultValue)` in forms to retain input after validation errors. In edit forms, `$defaultValue` is the model's existing value.
- **Dynamic Display Values:** NEVER hardcode display values like status names. ALWAYS retrieve from the model constant array (e.g., `Product::STATUSES[$product->status]`).
- **Fully Qualified Class Names:** In Blade files, ALWAYS use the fully qualified class name with a leading backslash when referencing model classes (e.g., `\Src\Property\PropertyBooking::STATUSES`).
- **Navigation:** When adding a new module with a UI entry point, add a corresponding menu item to the sidebar/navigation.

---

## 6. Routes

- **Route Naming:** ALWAYS name routes with `->name()` using `context.module.submodule.action` (e.g., `manage.people.users.list`, `manage.investment.investment-packages.store`).
- **Route Files:** Separate by context: `routes/web.php` (Manage), `routes/main.php` (Main), `routes/api/v1.php` (API).
- **URL Structure:**
    - URIs in **kebab-case**.
    - Plural nouns for resource collections (e.g., `/manage/people/users`).
    - Resource ID parameters MUST be `{id}` (e.g., `/manage/products/{id}/edit`).
    - No verbs in URIs for standard CRUD operations.
    - Nest resources hierarchically (e.g., `/clients/{id}/addresses`).
    - Append action to URI for non-CRUD operations (e.g., `POST /containers/{id}/in-warehouse`).

---

## 7. Database & Migrations

- **Naming:**
    - All database, table, and column names in **snake_case**, max 30 characters.
    - Table names MUST be plural (e.g., `shipments`, `containers`).
    - Boolean columns prefixed with `is_` (e.g., `is_active`).
    - Action timestamp columns suffixed with `_at` (e.g., `verified_at`).
    - Actor columns suffixed with `_by` (e.g., `created_by`).
- **Migrations:**
    - Use `bigIncrements()` for primary keys on high-volume tables.
    - Use `unsignedInteger()` for CONST columns (e.g., status); set default value referencing the model constant — but **the moment that constant is renamed or removed, write the literal into the migration instead**. A migration is replayed years later against whatever the model looks like *then*; the database that already ran it never notices the stale reference, so a deleted constant only ever fatals on a FRESH one (a new teammate's laptop, or a deploy). `php scripts/check-migration-constants.php` catches this.
    - **A migration filename must carry the date you actually wrote it.** Future-dating one silently reorders it against everything committed in between — an `ALTER` can end up sorted before the `CREATE` it depends on, which again only fatals on a fresh database. If two committed migrations are already mis-ordered, make them **order-independent** (guard the `ALTER` with `Schema::hasTable()`, and let the `CREATE` build the final shape) rather than re-dating either — a rename re-runs the migration on every database that already applied it.
    - **Prove it on an empty database, not on yours.** `DB_DATABASE=<a scratch db> php artisan migrate:fresh` is the only check that sees either bug; `php artisan migrate` on a machine that is merely up to date proves nothing.
    - Add `->nullable()` to all non-required fields.
    - Use `$table->softDeletes()` for tables where records should be archived.
    - Add `->index()` to foreign key columns and frequently queried columns.
    - **NEVER use schema-level foreign key constraints.** Manage relationships at the Eloquent level only.
    - NEVER modify a migration already committed to the main branch. Create a new migration for changes.
- **Audit columns (blame):** Audited tables carry `created_by`, `updated_by`, and — for soft-deletable tables — `deleted_by` (alongside `deleted_at`). Add `use \Diver\Database\Eloquent\Traits\RecordsBlame;` to the model and these columns are auto-populated from the authenticated user (`created_by`/`updated_by` on write, `deleted_by` on soft delete, cleared on restore). Explicitly-set values are preserved; no actor is recorded for console/seeder writes.
- **Standard key model:** Root/entity models have **both** an integer `id` (`bigIncrements`, the primary key **and** the target of every foreign key) and a unique **`uuid`** (public identifier). Add `use \Diver\Database\Eloquent\Traits\HasUuid;` — it auto-generates the `uuid` on create and makes route-model binding resolve by `uuid` (so sequential ids are never exposed in URLs/APIs). Compose a key model as: extend `Diver\Database\Eloquent\SoftDeleteModel` + `use HasUuid, RecordsBlame;` → `id`, `uuid`, `created_by`/`updated_by`/`deleted_by`, timestamps, soft delete. **Foreign keys ALWAYS reference `id`, never `uuid`.** Child/pivot tables don't need a `uuid`.
- **Shared / polymorphic data:** Reusable cross-model data uses a polymorphic table + a model in `src/Common`. The canonical example is **addresses** (`addresses` table, `Src\Common\Address`): attach to any model with `morphMany(\Src\Common\Address::class, 'addressable')`. Use `$table->morphs('addressable')` (indexes, no FK). Do NOT reintroduce the removed legacy Diver **Field/Dataset** tables (`fields_*`, `dataset_*`).
- **User profile:** A user's display name lives in `user_profiles` (`Src\People\UserProfile`, 1:1 via `hasOne`); always read it as `$user->profile->full_name` (GUIDELINES §4.8).

---

## 8. Validation

- All validation MUST be in dedicated Form Request classes extending `Diver\Http\Requests\FormRequest`.
- `UpdateRequest` SHOULD extend `StoreRequest` and merge/override rules as needed.
- **Unique Rule on Update:** MUST ignore the current record: `Rule::unique('products')->ignore($this->route('id'))`.
- **Verify Against Migration:** ALWAYS check the corresponding migration file before writing validation rules to confirm fields exist in the schema.

---

## 9. Query Requests

- **Purpose:** Handle all search, filtering, and sorting logic for resource lists.
- **Inheritance:** MUST extend `Diver\Http\Requests\QueryRequest`. For **Manage portal list pages**, extend `App\Http\Requests\Manage\ManageQueryRequest` instead — a thin base over the Diver class that adds reusable `applyIn` / `applyLike` / `applyDate` filter helpers (see §14).
- **Structure:**
    - Define the `$model` property.
    - Define the `$filterable` array with allowed filter keys.
    - Implement `param{FilterName}` methods to return filter metadata (title, formatted value).
    - Implement `filter{FilterName}` methods to apply filter logic to the query builder.

---

## 10. Security

- **Never Trust User Input:** Validate and sanitize all external data via Form Requests.
- **Use Laravel Features:** Use the ORM's SQL injection protection, CSRF protection, and output escaping.
- **Authorization:** Enforce user permissions at the start of every controller method.
- **Roles & Permissions (RBAC):** Use **spatie/laravel-permission**. Roles live in `Src\Auth\Role` (constants: `SUPER_ADMIN`, `ADMIN`, `MEMBER`, `NON_MEMBER`) and permissions in `Src\Auth\Permission` (both extend the spatie models; registered via `config/permission.php`). Assign with `$user->syncRoles([...])` / `$user->givePermissionTo(...)`; check with `$user->hasRole(...)` / `$user->can('permission-name')` and the `User` helpers (`isSuperAdmin()`, `isAdmin()`, `isMember()`, `isNonMember()`). Protect admin routes with the `admin` route middleware; use Laravel Gate (`$user->can(...)`) or `permission:`/`role:` middleware for finer checks. Seed roles/permissions in `database/seeds/RolesSeeder.php`. Do NOT use Bouncer (removed).

---

## 11. Comments & Documentation

- **PHPDoc Blocks:** All classes, methods, and functions MUST have PHPDoc blocks with `@param` and `@return`.
- **Clarity over Comments:** Write self-documenting code. Only comment to explain the "why" for complex or non-obvious logic.

---

## 12. Asset File Paths

The project is organized by **modules** (e.g., `People`, `Investment`) within **application contexts**.

**Contexts:**
- `Manage` — administration portal
- `Main` — primary user-facing web application
- `v1` — version 1 API

**File Path Conventions:**

| Asset | Path | Example |
|-------|------|---------|
| Models | `src/{Module}/{ModelName}.php` | `src/People/User.php` |
| Repositories | `src/{Module}/Repositories/{ModelName}Repository.php` | `src/People/Repositories/UserRepository.php` |
| Facades | `src/{Module}/Facades/{ModelName}Repository.php` | `src/People/Facades/UserRepository.php` |
| Controllers | `app/Http/Controllers/{Context}/{Module}/{Submodule}Controller.php` | `app/Http/Controllers/Manage/People/UsersController.php` |
| Form Requests | `app/Http/Requests/{Context}/{Module}/{Submodule}/{Action}Request.php` | `app/Http/Requests/Manage/People/Users/StoreRequest.php` |
| Views | `resources/views/{context}/{module}/{submodule}/{view_name}.blade.php` | `resources/views/manage/people/users/index.blade.php` |
| Route Names | `context.module.submodule.action` | `manage.people.users.list` |

---

## 13. Frontend Architecture (Inertia + Vue 3 + Tailwind)

- **Stack:** **Laravel + Inertia.js + Vue 3 + Tailwind CSS v4**, built with **Vite**. There are **no Blade UI pages** (Blade is used only as the Inertia root template), **no REST API for pages**, and **no** Bootstrap, Metronic, Laravel Mix, jQuery, or Alpine — **do NOT reintroduce them**.
- **Build:** `npm run dev` (Vite dev server / HMR) and `npm run build` (production). There is no `webpack.mix.js`.
- **Controllers return Inertia, not Blade:** Controller methods MUST return `Inertia::render('PageComponent', [...props])` instead of `view(...)`. **All other backend rules (Sections 1–12) still apply unchanged** — thin controllers, Repositories for writes inside `DB::transaction`, Form Requests for validation, explicit input mapping. Validation errors are auto-shared to the frontend, so Form Requests need no changes.
- **No separate API:** Pages receive data as **props** from the controller. Forms submit normally (Inertia performs the XHR); handle them the usual way — validate → repository → `redirect()->route()` with `flash()`.
- **Root template & shared data:** Root view is `resources/views/app.blade.php` (`@vite` + `@inertia`). Globally shared props (auth user, flash, app name) are defined in `app/Http/Middleware/HandleInertiaRequests.php`.
- **Vue file structure:**
    - Pages → `resources/js/Pages/{Context}/{Module}/{Action}.vue` (e.g., `Pages/Crm/Leads/Index.vue`); the name in `Inertia::render('Crm/Leads/Index')` maps to this path.
    - Layouts → `resources/js/Layouts/` (e.g., `AppLayout.vue`).
    - Reusable components → `resources/js/Components/`; **composables → `resources/js/composables/`** (e.g. `useResourceIndex`); helpers → `resources/js/utils/`. Do NOT create a generic `shared.js` dumping ground.
- **Styling:** **Tailwind utility classes only**, inside `.vue` files. NEVER add Bootstrap or another CSS framework. Use Tailwind v4 canonical class names (e.g., `bg-linear-to-r`, not `bg-gradient-to-r`).
- **Navigation & shared state:** Use Inertia `<Link>` (not `<a href>`) for internal navigation — **file downloads / exports are the one exception** (use a plain `<a href>` so the browser downloads the file instead of Inertia trying to parse it as a page response); use `usePage()` to read shared props (`auth`, `flash`).
- **Passing backend data:** ALWAYS via controller props in `Inertia::render(...)`. NEVER `window.*` globals or inline `<script>` data blocks.
- **Static Assets:** Frontend images live under `public/main/` (e.g., `public/main/images/`).
- **Dev console logging:** Use the `devLog` / `devWarn` / `devError` / `devTable` helpers from `resources/js/utils/devLog.js` — never raw `console.log`. They are gated by `import.meta.env.DEV`, so they print under `npm run dev` and compile to no-ops in `npm run build` (safe to commit). `app.js` already dev-logs the signed-in user on every page (and once distinctly on sign-in, via the `justSignedIn` shared prop).

---

## 14. Admin Index Pages (Shared DataTable Pattern)

Every Manage list page (Leads, Events, Memberships, …) is built from one shared foundation — **never hand-roll a table, filter bar, or export.** A new index page is mostly configuration.

**Frontend (`resources/js/`)**
- The **page** (`Pages/{Context}/{Module}/Index.vue`) stays thin: declare props, a `dimensions` config, `columns`, and row actions, then render `<DataTable>` + `<ActiveFilterChips>` + `<FilterDrawer>` (add `<ExportMenu>` only when the list needs CSV/Excel export).
- **`composables/useResourceIndex.js`** owns all filter state (search, multi-/single-select dims, optional date range, drawer `draft`, chips, `apply()`, URL sync). Config it with `dimensions: [{ key, type: 'multi'|'single', title, labels, counts, options, default }]` + `dateRange: bool`.
- Shared **`Components/`**: `DataTable` (Laravel paginator + `columns`, header-click sort, rows-per-page, `cell-{key}`/`actions`/`expand`/`empty`/`index`/`band-{group}` slots, zebra striping, scoped loading overlay), `FilterDrawer` (config-driven), `ActiveFilterChips`, `ExportMenu` (`:base-url`), plus `Modal` / `ConfirmModal` / `Drawer` / `FlashToast`. Use `ConfirmModal` for destructive actions — never the native `confirm()`.
    - **`index`** renders a compact secondary line UNDER the row number — the submission / creation date on Leads and Property Match, so a date column does not have to exist. **`band-{group}`** replaces a header BAND's label with a control. A band spans columns that answer one question, so a switch changing what all of them mean belongs there and nowhere else (Property Match's since-submission ⇄ lifetime toggle); five per-column switches would let a reader compare a since-figure against a lifetime one without noticing. Both fall back to their default rendering, so tables that ignore them are unchanged.

**Backend**
- **QueryRequest** extends `App\Http\Requests\Manage\ManageQueryRequest` (§9) and maps each `filter{X}` to the helpers: `applyIn($q,$col,$value)` (multi-select `whereIn`), `applyLike`, `applyDate($q,$col,$value,$op)` (+`parseDate`, Carbon-guarded). Multi-select filters arrive as `status[]` and serialize to arrays — Laravel parses both `foo[]=` and `foo[0]=`.
- **Controller** adds `use App\Http\Controllers\Concerns\ResolvesListQuery` (`listControls($http, $sortable)` → whitelisted `sort` / `direction` / `per_page`). `index` echoes `sort`/`direction` props + per-option `…Counts`; a per-controller `applySort()` orders results — **relational columns via correlated subqueries, never joins** (joins on non-unique/soft-deletable relations inflate the paginator count).
- **Export is opt-in, and it is a PATTERN, not a service.** There is no `ExportService` and there should not be: the parts that differ per list (which rows, which columns, how each is formatted) belong in that list's own class, and the parts that never differ are already shared. The full contract, the reference implementation, the current consumer list and the two client-side-filtered exceptions live in the [Export handbook](docs/modules_handbook/shared/export/readMe.md) — **read it before adding an export**; the rules below are its summary. To add one:
    1. `use App\Http\Controllers\Concerns\ExportsResource` → `downloadExport($export, $baseName, $format)` (xlsx by default, `?format=csv`, timestamped file name).
    2. An `App\Exports\{Module}Export` — maatwebsite/excel `FromCollection` + `WithHeadings` + `WithMapping` + `ShouldAutoSize`. **`FromCollection` only for a list bounded by its parent** (one session, one funnel, one membership — a few hundred rows). **An UNBOUNDED list takes `FromQuery`**, which the writer chunks (`excel.exports.chunk_size`) instead of materialising: `LeadsExport` on `FromCollection` hydrated ~11,800 leads and peaked at **406 MB**, killing the FPM worker outright once the CRM passed 12,000 leads — the same file is **158 MB** on `FromQuery`. `LeadsExport` and `CatalogueDatabaseExport` are the two.
    3. `<ExportMenu :base-url>` on the page. Its base-url is the LIST url with **no** `/export` (the component appends it), and it forwards the page's current query string so the file matches the view.
    4. A `GET export` route declared **before** `GET {id}` — a bare `export` segment is otherwise matched as an `{id}` uuid → 404. (A two-segment `GET {id}/export`, for an export scoped to one record, is equally valid — Membership and Sales Projects both use it.)
- **Export rules that are not cosmetic**, each learned from a file that was quietly wrong:
    - **Share the query with the page** — one method returning an *unpaginated* builder, which the list paginates and the export `get()`s, both running the same row mapper. A second hand-written query is how the file silently stops matching the screen (a filter applied in one place, not the other) with nothing failing.
    - **Never let the list's `per_page` reach the export.** The menu forwards the whole query string; honouring it truncates the download with no sign anything is missing.
    - **A `FromQuery` export needs a UNIQUE tiebreaker in its ORDER BY** (`->orderBy('{table}.id')` last). The writer walks the builder with `chunk()` — offset/limit over repeated queries — so rows tying on a non-unique sort (`created_at`, a status, a name) can swap places between one chunk and the next, and a row lands in the file twice or vanishes. Nothing about the download looks wrong.
    - **Keep every scoping rule** the page applies (`GroupScope`, `LeadVisibility`, …). An export is a file the user keeps, so a visibility hole here outlives the session.
    - **Cast money to `float`** — model `decimal:x` casts and `number_format()` both yield strings, which land as text no spreadsheet will total. **Keep phone as text** (Excel eats a leading `0` and a `+`). Use `''`, not `null`, for blanks: the CSV and XLSX writers render null differently.
    - **Escape user-typed text** with `App\Exports\Concerns\EscapesCsvFormulas` — a value starting `=`, `+`, `-`, `@` executes as a formula when the file is opened. TEXT columns only; running a number through it makes the column unsummable.
    - **Flatten what the screen only hints at.** A spreadsheet has no colour, tooltip, expand row or modal, so anything conveyed that way (an "estimated" badge, a detail modal's fields) needs its own column or the reader draws the wrong conclusion.

**Create / edit (modal).** Create and edit happen in a **modal** on the index, reusing the module's `Partials/{Module}Form.vue` — a dumb fields-only component that receives the Inertia `useForm` object as a `form` prop and renders inputs + `form.errors.*` messages. Wrap it in a `Partials/{Module}FormModal.vue` that owns the `useForm` state, a `mode: 'create'|'edit'` prop, a `hydrate()` that (re)seeds fields whenever the modal opens, and the `post`/`put` submit (e.g. memberships' tier + upsell + lock/fork note) — **there are no separate Create/Edit pages.**

**Detail (Show) page — reference: `Pages/Manage/Membership/Show.vue`.** Each row's **View** action opens `Pages/{Context}/{Module}/Show.vue`, structured top-to-bottom as: `PageHeader` (smart back link + header actions like Edit / Delete) → an **identity header card** (name, status badge, key meta) → **`Components/ShowTabs.vue`** hosting the related-module sections. Build `tabs` as a `computed` of `[{ key, label, icon?, count? }]` and provide each body via the `#tab-{key}` slot; ShowTabs lazy-mounts only the active tab, syncs it to `?tab=` in the URL (refresh / shared link lands on the same tab), and hides the strip when there's a single tab. Each tab body lives in its own **`Partials/Tabs/{Name}Tab.vue`** (e.g. `UpsellsTab`, `MembersTab`, `VersionsTab`) — a self-contained card that receives the parent record as a prop and owns its own modals / `ConfirmModal` actions. **Edit on the Show page reuses the same `{Module}FormModal` as the index row action** — never a second edit form. Show pages get the smart back-button via the `App\Http\Controllers\Concerns\ResolvesBackUrl` trait — `resolveBackUrl($request, $indexPath)` returns the list URL **with its filter/sort query preserved** when the user came from it, else the plain index. `store` / `update` (and child writes like upsells) return `back()` so one endpoint serves both the index modal and the Show page. Routes expose `GET {prefix}/{id}` (show, declared after any literal GET) and have **no** `create` / `{id}/edit` routes.

**New shared locations** (extend §12): `resources/js/composables/`, `app/Http/Controllers/Concerns/` (controller traits — `ResolvesListQuery`, `ExportsResource`, `ResolvesBackUrl`), `app/Exports/` (export classes), and the list base `app/Http/Requests/Manage/ManageQueryRequest.php`.

---

## 15. Navigation & Tab Strips

The Manage sidebar holds **one entry per module**, not one per page. A module's pages are reached by a **tab strip on the page itself**, so nothing is buried in a nested drawer. Never add a second sidebar child for a page that already belongs to a module with a strip.

**Two shared strips — pick by depth.**

- **One level → [`Components/SectionTabs.vue`](resources/js/Components/SectionTabs.vue).** Every section's tab table lives in its `SECTIONS` map, so a page mounts one line: `<SectionTabs section="zoom" />`. Current sections: `ai-copilot` (Summary + AI Agent + Analytics + Knowledge Base), `zoom` (Dashboard + AI Agent + Action Items + Recordings + Polls + Settings), `calls` and `f2f` (the Phone Call / Showroom F2F performance layers — Dashboard + Action Items + list + Devices + Settings), `payments` (Payment History + Payment Links + Unreconciled — mounted by the Project / Subsale suites; the Operations suite reaches Payment as a Sales-hub tab, see `PaymentTabs.vue`), `traffics` (Campaign Performance + Payment — the merged ads table plus a jump to the Sales hub's payments ledger), `lead-distribution` (its three `?tab=` panels + Groups). The map's own comments carry the WHY of each grouping — read them before adding a tab. Two former sections are gone for **different** reasons: `system` merged into the Setting hub (System Health is a Setting tab); `products` went with the **retired Products module** itself — there is no `src/Product`, no product route, no page and no Setting tab; only Purchase Histories survived, and it moved to the `payments` section. `traffics` shows the third fate **and its reversal**: it collapsed when its two ad views **merged into one page** (2026-08-03 — `Marketing/Campaigns.vue` with a Delivery | Return column toggle; `Marketing/Roas.vue` deleted, `/manage/marketing/roas` redirects) — a strip with a single tab has nothing to switch between — and was revived 2026-08-04 the moment a genuinely different sibling existed (Payment, a cross-section jump: that path stays the Sales entry's prefix, so the tab lands in the Sales hub by design). A section dies when its pages turn out to be one page, and returns only with a real sibling — never to hold a lone tab.
- **Two levels (underline mains + pill subs) → [`Components/HubTabs.vue`](resources/js/Components/HubTabs.vue).** Presentation only; each hub owns a thin config component that feeds it `mainTabs` / `activeMain` / `subTabs`: `DashboardTabs`, `FunnelMarketingTabs`, `SalesTabs`, `SettingTabs`, `Messages/MessagesTabs`, `Portal/PortalEngagementTabs`.

**A section graduates to a hub the moment one of its tabs owns pages.** Setting was a flat `system` strip until other modules' configuration moved into it from their own sidebar entries; once a tab owns pages of its own, a one-level strip can only expose them by adding siblings that do not belong at that level. Convert the section to a `HubTabs` config component rather than flattening the child pages up. *(The original worked example here was Products — Categories / Products / Purchase Histories. That module has since been retired outright, so the example is gone but the rule stands; `SettingTabs` is still a `HubTabs` config for the same reason.)*

**Siblings vs. stages.** Pills say "these are alternatives"; they cannot say "this one comes first." Pick by what the tabs *are*, not by which product they belong to — the same six sales product lines are **pills** under Dashboard → Pipeline (choose whose board to read) and **stages** in the Sales hub (walk one line's funnel). When a hub's second level is a **funnel** — consecutive steps of one product's journey — pass `stageTabs` / `activeStage` to `HubTabs` instead of `subTabs`, and it renders [`Components/StageTabs.vue`](resources/js/Components/StageTabs.vue): a chevron rail whose arrow shape carries the order. A stage is `{ key, stage, label, href, count?, tone? }` — `stage` is the overline (`Pipeline`), `label` the thing itself (`Property Match`), `tone` the active fill (`pipeline` = `navy-800`, `sales` = `brand-600`; inactive = `slate-200`). Stay inside the brand family — the stages read as a **value** progression (dark neutral → brand blue), never as different hues, and every fill is solid: `clip-path` leaves no border, so a pale tint loses the chevron silhouette. Never encode the order in the label (`"Pipeline: Property Match"`) — that is the smell this replaces. Reference: the Sales hub (`SalesTabs`), where **every one of the six product lines carries the same three stages** — Pipeline → Sales → Referral & Repeat — from its `PRODUCT_STAGES` table. Counts come from a `stageCounts` page prop the controller sends on **every** view of the page, since the rail is always on screen. A stage owns every `?view=` of its own **pivots**; a control that only re-pivots ONE stage (Bookings' By Booking List / By Project, stage 3's Customers / Referral Chain) stays an in-page segmented control, styled quieter than the rail — a pivot is not a stage.

**A stage that isn't built is `{ soon: true }`, not a missing stage.** It renders as a dimmed, non-clickable segment with a "Soon" tag, keeping the chevron shape — a product whose middle stage is missing must read as one funnel with a gap, never as a shorter funnel. The same holds one level up: a product line with no surface yet still gets a **real route** and a placeholder page (`Manage\Sales\ProductLinesController` → `Pages/Manage/Sales/ProductLine.vue`) that mounts the strip, so its plan is visible instead of its tab being dead. Give those paths to the sidebar entry's `prefixes` too, or the nav goes dark while the reader is standing inside the section.

**A third form, for the portal's AI Coach pages: the sub-level lives INSIDE the hero band.** Investment Prompt and Vibe Coding both open with the shared navy [`Components/Portal/PortalHero.vue`](resources/js/Components/Portal/PortalHero.vue), and their second level is a glass segmented control ([`HeroToggle.vue`](resources/js/Components/Portal/HeroToggle.vue)) in that band's `controls` slot. It is the same grammar as the two above, not an exception to it: `AiCoachTabs` is the underline main strip, and what follows it has to be quieter. Vibe Coding shipped with a second UNDERLINE strip directly beneath — two identical controls, with nothing saying which was the parent — which is exactly what this rule exists to prevent. A tabbed page that moves its control into the band keeps `ShowTabs` in the tree with **`hideStrip`**: that component still owns lazy-mounting ONLY the active tab and the `?tab=` URL sync, and a `v-if` chain replacing it silently mounts every panel at once.

**Rules for all three.**

- **Gate every tab.** Use `permission` / `permissionAny` (or `superAdmin: true`) and filter before rendering — a tab that 403s is a bug, not a hint.
- **Match by path, via [`useActivePath`](resources/js/composables/useActivePath.js).** A tab declares `prefix` or `prefixes`; `matches(tab)` resolves the active one. Two traps:
    - **A tab whose path is a prefix of its siblings' must be resolved LAST** (Messages' Inbox at `/manage/messages` would otherwise claim every tab under it).
    - **Tabs sharing one path** and differing only by query param cannot be told apart by path — pass `activeSub` to `HubTabs` explicitly (Sales → Property Booking's three `?view=` panels).
- **Sidebar highlighting.** A nav entry whose section spans sibling paths carries a `prefixes` array — honoured at all three levels in `AppShell.vue`: top-level links and the pinned footer via `isActive(href) || groupActive(item)`, group children via `childActive()`. Use `exact: true` when an href is a prefix of its siblings'. **Two entries under one parent path must each list their own sub-paths** — never the shared parent, or one stays lit while the reader stands in the other (Funnels vs. Traffics, both under `/manage/marketing`).
- **The sidebar has three zones, and things belong to one of them.** *Unsectioned top* — where you start, belonging to no one part of the business (Dashboard, Leads). *Sections* — the work, grouped by what it is. *Pinned footer* (`AppShell`'s `footerNav` prop, outside the scrolling `<nav>`) — Setting, then the signed-in user with Profile and Sign out. Configuration and identity are wayfinding, not work: they stay put however far the menu scrolls. Never invent a section called "Others" — that names a leftovers drawer rather than a thing, and whatever lands in it belongs somewhere real.
- **Leaving is not a menu item.** The two ways OUT of the sidebar live at the TOP of the **pinned footer** (above Setting and the identity block — they are wayfinding like everything else down there, and moved 2026-08-24 from a block above the menu, where they shouted at every glance; leaving is the last thing on the list, not the first). Configured by props on `AppShell` and never listed in `nav`: **back** (`showBackToHub` — the suite CHOOSER, `/manage/dashboard`, needed because a suite's own nav drops the Hub entry) and **switch** (`showPortalSwitch` — skip the chooser and land in the work: manage → `/dashboard`, portal → `useOperationsLanding()`). Both exist on both sides because an admin is also a lead and owns records on both; on the portal side both are gated on `auth.user.is_admin`, or a plain member gets a 403. **The two must never resolve to the same URL** — that is the whole reason the portal's switch is the resolved Operations landing rather than a hardcoded `/manage/dashboard`; when that composable returns `null` (nothing in the suite is this admin's) the switch hides instead of duplicating the back link.
- **One identity affordance per screen.** The sidebar footer owns it wherever there is a sidebar; the header user menu only appears where the sidebar cannot serve it (`hideSidebar`, and small screens where the sidebar is off-canvas).
- **Not-yet-built lines** are `{ soon: true }` with no href — rendered as dimmed text with a "Soon" tag by `HubTabsLink` and `NavGroup`, never as a dead link.
- **Preserve `?suite=`** on every tab href (the sidebar suite the admin came from), and on
  every other internal manage destination you build — use **`withSuite()`** from
  [`composables/useSuite.js`](resources/js/composables/useSuite.js) rather than concatenating the
  token yourself. An explicit token is what makes a URL shareable and the browser's back button
  honest.
- **The suite is RESOLVED, never read straight off the URL.** `useSuite()` is the single source of
  truth for both the sidebar and every strip, in this order: an explicit `?suite=` token → a path
  OWNED by one suite (`/manage/appointment-engine`, `/manage/projects`, `/manage/facebook`,
  `/manage/subsale-projects`, `/manage/subsale/library`) → what THIS history entry showed when it
  was last on screen (Inertia `router.remember`, so the back button is honest across a detour
  through another suite's owned path) → the suite remembered for this browser tab
  (`sessionStorage`) → Operations. Read `suite` from it; never re-parse `?suite=` in a component,
  and never write `get('suite') || 'other'` — that literal is what used to strand readers. The Hub
  belongs to no suite: it neither reads nor writes either memory. Only `ManageLayout` writes them,
  with the resolved value, on every navigation.
  *Why the memory exists:* the token alone was carried by 26 of ~940 manage destinations, so any
  link that forgot it silently flipped the sidebar to Operations mid-task — the AI Profiles button
  on `/manage/calls/ai-calls?suite=appointment` did exactly that. The resolver is a floor under
  every link, including ones written later; it does not excuse omitting the token.
- **A hardcoded token is now WORSE than a missing one.** An explicit `?suite=other` wins at step 1
  and is then remembered for the rest of the tab — so a literal on a page more than one suite
  reaches (FunnelMarketingTabs on `/manage/facebook`, a component that renders both a project and a
  subsale detail page) does not merely strand the reader, it moves them. Never write one outside
  `ManageLayout`; the guard below fails the build on it.
- **Server redirects keep the suite too — `$this->redirectWithSuite()`.** `redirect()->route()`
  yields a bare path, and when the destination is a path one suite OWNS (`/manage/facebook` after
  Meta OAuth) path ownership outranks the front end's memory: an Operations admin is walked into the
  New Project Suite by the redirect itself. Every Manage controller uses
  `App\Http\Controllers\Concerns\PreservesSuite` (own `?suite=`, else the Referer's — a form POST
  rarely carries it, the page it came from does); the helper adds nothing when no suite is known, so
  there is never a reason to bypass it. `back()` needs none of this. `ResolvesBackUrl`'s fallback
  branch stamps it the same way; its referer branch already returned the query intact. A route-file
  closure that redirects to a SHARED manage path forwards the query:
  `redirect()->route(name, request()->query())`.
- **The guard: `php scripts/check-suite-links.php`** (CI `guards` job, beside the migration-constant
  check, and mirrored for `vitest` in `useSuite.test.js`). Hard failures: a hardcoded token, a local
  re-parse, a bare `redirect()->route('manage.…')` in a Manage controller, a route-file redirect to a
  shared path that drops the query. A RATCHET on bare `/manage/…` destinations without
  `withSuite()`: harmless for the sidebar now, but each is a URL that cannot be shared in the suite
  it was made in — the count may fall, never rise. Lower the baseline in the script when it does.
- **A page with no sidebar entry owes the reader a way back** — a `PageHeader` back link (Leads → Distribution Setting, Wealth Planning → Deal Preset Setting).

**Page titles in the USER PORTAL — one treatment, everywhere (2026-08-26).** Every portal page is
mounted `headerless` (AppShell's sticky 64px title bar is OFF portal-wide) and prints its own
masthead as the first thing in the page body:

```html
<template #header><span class="sr-only">{page name}</span></template>

<div class="mb-5">
    <h1 class="text-2xl font-bold tracking-tight text-navy-900">{SIDEBAR SECTION}</h1>
    <p class="mt-1 max-w-2xl text-sm leading-relaxed text-gray-500">{one line}</p>
</div>
```

- **Every masthead carries a description, and it is ONE line.** A bare title tells a first-time
  member nothing; a three-line paragraph is skipped and repeats what the panels below already say.
  It describes the SECTION, like the title above it — the same copy on every page of that section.
- **One `<h1>` per page: the masthead.** A page's own content headings (a hero headline, a record
  name) are `<h2>` — keep their classes so nothing moves, just fix the level.
- **The masthead names the SIDEBAR SECTION, not the page** — "AI Coach", not "Investment Prompt".
  The tab strip directly under it says which of that section's pages you are on, so the two lines
  together read as *where I am* → *what I'm looking at*, and neither repeats the other.
- **Detail pages (Show / Lesson / a single record) print the RECORD's name instead** and skip the
  section masthead — they already carry a back link naming the section, and a second title above
  the record's own is noise.
- **The `#header` slot still carries the page name, `sr-only`.** It is what the document outline and
  a screen reader announce; dropping it entirely would make every portal page announce itself as
  "Dashboard" (the slot's default).
- **Why the bar is off at all:** with a title in the body, the sticky bar was a second title in
  different type on a different surface; with the title removed from the bar it was an empty 64px
  strip pushing the content down. Both were reported. One title, in the page.
- ⚠️ A **dual-chrome** page (one component rendered under both `SiteLayout` and `AppLayout`, e.g.
  New Projects) must bind `headerless` only for the portal chrome — `SiteLayout` has no such prop
  and no header slot.

**When adding a page to an existing module:** add it to that module's tab table, mount the strip, and update the module's handbook `readMe.md` **Nav:** line. Do not add a sidebar entry.
