# PRD — Bank Division (Master Data) Module

## 1. Document Metadata

| Field | Value |
|---|---|
| **Title** | PRD — Bank Division (Master Data) Module |
| **Module** | Bank Division (bank ↔ division assignment pivot) |
| **Status** | Reverse-engineered from shipped source (documents already-shipped behavior; not a forward-looking proposal). |
| **Owner** | IT — PT Colorindo Chemtra |
| **Date** | 2026-06-04 |
| **Stack** | Laravel 13.8 / Inertia.js v3 / React 19.2 / Tailwind v4 / MySQL 8.4 |
| **Document Type** | Reverse-engineering PRD |

---

## 2. Overview & Purpose

The **Bank Division** module is an internal admin CRUD module for maintaining **assignments between a Bank and a Division** at PT Colorindo Chemtra. Each record is a composite pair — a `BankID` plus a `DivisionID` — with an optional `Remark`. Physically it is a junction/pivot table (`bankdivision`) linking `bank` ↔ `division`, but it is treated as a soft-deletable **leaf entity** in its own right: it carries its own surrogate primary key (`ID`) and its own `IsDeleted` soft-delete flag rather than being a pure many-to-many pivot.

**Why it exists:** It records which Banks are assigned to which Divisions. Managing these assignments through a controlled CRUD with FK-validated dropdowns, pair-uniqueness enforcement, soft-delete, and restore gives administrators an auditable way to maintain the mapping without raw database access.

**Relationship to the Bank reference template:** This module follows the same Phase-3 conventions established by the Bank reference (PHP-attribute model config via `#[Table]`/`#[Fillable]`/`#[ScopedBy]`, custom soft-delete via the `IsDeleted` flag + `NotDeletedScope`, FormRequest validation, Policy-per-action authorization, and the `Pengelolaan/` Inertia page prefix). It differs from Bank in important ways documented throughout this PRD: it is a **composite pair** entity guarded by a custom `UniqueBankDivisionPair` rule, its inputs are **two FK dropdowns** (Bank, Division) rather than free-text fields, its authorization is **real rolemenu-based** (not a permissive stub), and its frontend exhibits a status-filter and styling inconsistency (see §13).

---

## 3. Scope

### 3.1 In Scope (6 capabilities)

1. **List / Filter** — paginated listing (10/page, ordered by `ID` **descending**) with two FK filter dropdowns (Bank, Division) and a running "No" column. The controller also supports a 3-state `status` filter (active/deleted/all), but the React UI does not expose a control for it (see §13).
2. **Create** — form to add a new Bank↔Division assignment via two FK dropdowns.
3. **View / Show** — read-only detail page for a single assignment.
4. **Edit / Update** — form to reassign an existing record's Bank and/or Division.
5. **Delete (soft)** — flag a record as deleted (`IsDeleted = 1`) via a confirmation modal; recoverable.
6. **Restore** — un-delete a soft-deleted record (`IsDeleted = 0`). Wired end-to-end on the backend but unreachable from the UI as shipped (see §13).

### 3.2 Out of Scope (genuinely absent in the shipped code)

- **Hard delete** — no permanent/physical deletion exists; `destroy()` performs a soft delete only (`softDelete()` is a non-cascading leaf operation — code comment: "Leaf record — no cascade.").
- **Bulk operations** — no multi-select, bulk delete, bulk restore, or bulk edit.
- **Export / import** — no CSV/Excel/PDF export or data import.
- **Inline editing** — edits happen on a dedicated Edit page; the list is not editable in place.
- **Audit trail / timestamps** — the `bankdivision` table has `timestamps: false`; no `created_at`/`updated_at`/`deleted_at` columns and no Laravel `SoftDeletes` trait. No per-change history is recorded.
- **Sorting controls** — list order is fixed to `orderByDesc('ID')`; no user-selectable column sorting.
- **Per-page size control** — pagination is fixed at 10/page.
- **A `Remark` input on the form** — although `Remark` is a fillable column with a model default of `''`, neither the Create nor Edit React form exposes a `Remark` field; the form `useForm` keys are only `BankID` and `DivisionID` (see §9). The notes do not show `Remark` being submitted from the UI.

---

## 4. Users & Permissions

### 4.1 Access prerequisite

All Bank Division routes live inside the single `Route::middleware('auth')->group(...)` block in `routes/web.php`. **Only authenticated users** can reach any Bank Division page or action. Unauthenticated requests are bounced to `/login` by the `auth` middleware. There is no additional middleware on these routes beyond `auth`; authorization is entirely policy-driven inside the controller.

### 4.2 Authorization model — Policy per action

`BankDivisionController` uses the `AuthorizesRequests` trait and calls `$this->authorize(...)` at the start of **every** action. Each controller method maps to exactly one `BankDivisionPolicy` ability:

| Action | Policy ability | Subject | Notes |
|---|---|---|---|
| `index()` | `viewAny` | `BankDivision::class` | |
| `create()`, `store()` | `create` | `BankDivision::class` | |
| `show()` | `view` | `$bankDivision` (bound instance) | |
| `edit()`, `update()` | `update` | `$bankDivision` (bound instance) | |
| `destroy()` | `delete` | `$bankDivision` (bound instance) | |
| `restore()` | `restore` | `$bd` (looked up via `withoutGlobalScope`) | Authorization is checked **after** the record is looked up via `findOrFail` (the row is hidden by the global scope and must be fetched first). A non-existent ID yields 404 before the policy check. |

The model instance passed to `view`/`update`/`delete`/`restore` is **ignored** for the access decision — gating is purely menu-link-based and identical across all abilities (see §4.3).

### 4.3 Current Policy behavior — REAL rolemenu-based authorization (NOT a permissive stub)

`app/Policies/BankDivisionPolicy.php` is **not** an all-permissive stub. Every ability (`viewAny`, `view`, `create`, `update`, `delete`, `restore`) funnels through a private `allowed(?User $user)` helper that checks a hard-coded menu-link constant:

```php
private const MENU_LINK = '/bank-divisions';

private function allowed(?User $user): bool
{
    return $user !== null && $user->canAccessMenuLink(self::MENU_LINK);
}
```

So every ability requires **a non-null user AND `$user->canAccessMenuLink('/bank-divisions')`**. This is the shared pattern used by all 8 resource policies (Bank, BankDivision, CompanyCategory, Division, GroupDivision, Menu, Role, RoleMenu), each with its own `MENU_LINK` constant.

**The underlying mechanism** (`User::canAccessMenuLink()` → `allowedMenuLinks()`): a grant for the current user's `RoleID` is matched only when ALL of the following hold:
1. A `rolemenu` row exists with `RoleID = user.RoleID` and `MenuID = menu.ID`, and `rolemenu.IsDeleted = 0` (active grant).
2. The joined `menu` row has `IsDeleted = 0` (menu not soft-deleted).
3. `menu.IsShow = 1` (a hidden menu both disappears from the sidebar AND denies access — server authorization deliberately mirrors sidebar visibility).
4. `menu.LinkLaravel <> ''` (the menu is wired to a Laravel route).
5. The resulting `LinkLaravel` string equals `'/bank-divisions'` **exactly** (strict `in_array(..., true)` comparison — case-sensitive, no normalization).

The result is memoized per request (one query regardless of how many `authorize()` calls fire). `RoleID <= 0` returns an empty allow-list, so guests / unassigned roles see nothing.

**No admin bypass.** There is no `Gate::before(...)`, no explicit `$policies` map, and no super-admin override anywhere in `app/`; policies are resolved by Laravel's naming convention (`App\Models\BankDivision` → `App\Policies\BankDivisionPolicy`), and `AppServiceProvider::boot()` is empty. Per project memory, **even RoleID/ID = 1 (Administrator) must hold a real active `rolemenu` grant** — it is granted access only because the data contains such a row, not via any code branch.

> **Known gap (seeding):** There is no seeder that registers a `menu` with `LinkLaravel = '/bank-divisions'` nor any `rolemenu` grant for it. The only menu-wiring seeder, `DivisionMenuSeeder`, hard-codes `/divisions` only. As a result, no seeder registers the `/bank-divisions` menu link from a fresh seed; the feature tests instead depend on a pre-existing `rolemenu` grant for RoleID 1 in the dev DB. Per `tests/Pest.php`, RoleID 1 ("Administrator") holds active grants to all eight managed-resource menus (including bank-divisions) in the dev DB, so `actingAsAdmin()` passes the normal `canAccessMenuLink` check through the standard path — the tests do **not** construct any menu or `rolemenu` row inline.

### 4.4 FormRequest `authorize()`

Both `StoreBankDivisionRequest::authorize()` and `UpdateBankDivisionRequest::authorize()` `return true` unconditionally — there is no gatekeeping at the FormRequest level. Authorization lives entirely in the controller/Policy layer described above, so the FormRequest stays open as the single source of truth for validation.

---

## 5. Data Model

### 5.1 `bankdivision` table

Model: `app/Models/BankDivision.php` — configured via PHP attributes (there are no classic `protected $table`/`$primaryKey`/`$fillable` properties; `$casts` is expressed via the `casts()` method):

```php
#[Table(name: 'bankdivision', key: 'ID', keyType: 'int', incrementing: true, timestamps: false)]
#[Fillable('BankID', 'DivisionID', 'Remark')]
#[ScopedBy(NotDeletedScope::class)]
class BankDivision extends Model
```

The columns below combine the model's attribute/cast configuration with the **confirmed live schema** (`SHOW CREATE TABLE bankdivision` against the dev DB `colorindochemtrainertia`). The table is `ENGINE=InnoDB DEFAULT CHARSET=latin1` (collation `latin1_swedish_ci`), with `AUTO_INCREMENT` currently at `303`. Verbatim DDL:

```sql
CREATE TABLE `bankdivision` (
  `ID` int NOT NULL AUTO_INCREMENT,
  `IsDeleted` int NOT NULL,
  `DivisionID` int DEFAULT NULL,
  `BankID` int DEFAULT NULL,
  `Remark` varchar(500) NOT NULL,
  PRIMARY KEY (`ID`),
  KEY `DivisionID` (`DivisionID`),
  KEY `DivisionID_2` (`DivisionID`,`BankID`),
  KEY `bankdivision_BankID` (`BankID`),
  CONSTRAINT `bankdivision_BankID` FOREIGN KEY (`BankID`) REFERENCES `bank` (`ID`) ON DELETE RESTRICT ON UPDATE RESTRICT,
  CONSTRAINT `bankdivision_DivisionID` FOREIGN KEY (`DivisionID`) REFERENCES `division` (`ID`) ON DELETE RESTRICT ON UPDATE RESTRICT
) ENGINE=InnoDB AUTO_INCREMENT=303 DEFAULT CHARSET=latin1
```

| Column | DB type | Nullability | Fillable | Cast | Notes |
|---|---|---|---|---|---|
| `ID` | `int` | NOT NULL, `AUTO_INCREMENT` | ❌ | — (PK) | Primary key (`incrementing: true`). Guarded. |
| `IsDeleted` | `int` | **NOT NULL, no DB default** | ❌ | `boolean` | Soft-delete flag (`0`=active, `1`=deleted). Because the column is `NOT NULL` without a DB default, the model `creating()` hook supplying `0` is **load-bearing** (an insert omitting it would otherwise error). Guarded. |
| `DivisionID` | `int` | `DEFAULT NULL` (nullable in DB) | ✅ | `integer` | FK → `division.ID` (DB-level constraint `bankdivision_DivisionID`, RESTRICT/RESTRICT). Nullable at the DB level but `required` in the FormRequests. No model default; caller-supplied. |
| `BankID` | `int` | `DEFAULT NULL` (nullable in DB) | ✅ | `integer` | FK → `bank.ID` (DB-level constraint `bankdivision_BankID`, RESTRICT/RESTRICT). Nullable at the DB level but `required` in the FormRequests. No model default; caller-supplied. |
| `Remark` | `varchar(500)` | **NOT NULL, no DB default** | ✅ | none (string) | Because the column is `NOT NULL` without a DB default, the model `creating()` hook supplying `''` is **load-bearing**. Not exposed on the React form (see §3.2). |

**Fillable order** (`#[Fillable(...)]`): `BankID`, `DivisionID`, `Remark`. `ID` and `IsDeleted` are **not** fillable (guarded PK + soft-delete flag).

**Casts** (via `casts()` method): `['IsDeleted' => 'boolean', 'BankID' => 'integer', 'DivisionID' => 'integer']`. `Remark` is not cast.

### 5.2 Relationships — two `belongsTo`

```
BankDivision::bank()     → belongsTo(Bank::class,     'BankID',     'ID')
BankDivision::division() → belongsTo(Division::class, 'DivisionID', 'ID')
```

- `bank()` — FK on `bankdivision` = `BankID`; owner key on `bank` = `ID`. `Bank` is `#[Table(name: 'bank', ...)]` and is itself `#[ScopedBy(NotDeletedScope::class)]`, so eager-loading `bank` only returns **non-deleted** banks.
- `division()` — FK on `bankdivision` = `DivisionID`; owner key on `division` = `ID`. `Division` is `#[Table(name: 'division', ...)]`, also `#[ScopedBy(NotDeletedScope::class)]`; its fillable is `('GroupDivisionID', 'IsLabDiv', 'DivisionName', 'Remark')` with `IsLabDiv` cast `boolean`.

Both FK columns are `integer`-cast, matching the relationship key types.

### 5.3 DB-level FK constraints & indexes (confirmed)

`bankdivision` **does** carry real DB-level foreign-key constraints (this table is one of the "beberapa tabel ADA" cases CLAUDE.md warns about — verified per table, not generalized):

| Constraint | Column | References | On Update | On Delete |
|---|---|---|---|---|
| `bankdivision_BankID` | `BankID` | `bank(ID)` | RESTRICT | RESTRICT |
| `bankdivision_DivisionID` | `DivisionID` | `division(ID)` | RESTRICT | RESTRICT |

Because the related `Bank`/`Division` use **soft-delete** (`IsDeleted`) rather than hard delete, parent rows are never physically deleted, so the `RESTRICT ON DELETE` rule does not fire in normal app flows. These DB constraints are a **safety net**; the **primary integrity defense is the app layer** (`Rule::exists('bank','ID')->where('IsDeleted', 0)` and `Rule::exists('division','ID')->where('IsDeleted', 0)` in the FormRequests), which additionally enforces "non-deleted parent" — something the DB FK alone cannot express.

**Indexes:** `PRIMARY KEY (ID)`; `KEY DivisionID (DivisionID)`; `KEY bankdivision_BankID (BankID)`; and a compound `KEY DivisionID_2 (DivisionID, BankID)`. **Note the compound index is a plain (non-`UNIQUE`) `KEY`** — the DB does **not** enforce pair-uniqueness. The `(BankID, DivisionID)` uniqueness rule (§7.2) is enforced **only** at the application layer by `UniqueBankDivisionPair`; concurrent inserts could in principle race past it since there is no DB unique constraint backing it.

### 5.4 Soft-delete semantics

- **No Laravel `SoftDeletes` trait** and **no `deleted_at` column**. Soft delete is a custom integer flag (`IsDeleted`).
- **Global scope:** `#[ScopedBy(NotDeletedScope::class)]` (`App\Models\Scopes\NotDeletedScope`) appends `WHERE bankdivision.IsDeleted = 0` to every default query.
- **`softDelete(): bool`** sets `IsDeleted = 1` then `save()`. It is a non-cascading **leaf** operation (comment: "Leaf record — no cascade.").
- **`restore(): bool`** sets `IsDeleted = 0` then `save()`.
- To see deleted rows, callers must explicitly remove the scope: `withoutGlobalScope(NotDeletedScope::class)`.

### 5.5 `creating()` defaults

`booted()` registers `static::creating(fn (BankDivision $bd) => { $bd->IsDeleted ??= 0; $bd->Remark ??= ''; })`. This hook supplies `IsDeleted = 0` and `Remark = ''` on inserts that omit them. It is **load-bearing**: the confirmed schema (§5.1) has both `IsDeleted` and `Remark` as `NOT NULL` with no DB default, so an insert omitting either would error at the DB without this hook. `BankID`/`DivisionID` get no default (caller-supplied; they are nullable at the DB level but `required` by the FormRequests).

The factory (`BankDivisionFactory::definition()`) auto-creates parent `Bank` and `Division` records (the Division gets a unique `DivisionName` `BDIV_FACTORY_<uniqid>`), and sets `IsDeleted => 0` / `Remark => ''` explicitly (redundant with the model defaults, but deterministic).

---

## 6. Functional Requirements

### 6.1 List / Index

**User story:** As an authenticated, granted admin, I want to browse and filter Bank↔Division assignments so that I can find a specific assignment.

- **FR-1.1** The index requires the `viewAny` ability (subject `BankDivision::class`) before rendering.
- **FR-1.2** The list renders the Inertia component `Pengelolaan/BankDivisions/Index` with props `bankDivisions` (paginator), `bankOptions`, `divisionOptions`, and `filters`.
- **FR-1.3** Results are paginated at **10 per page**, ordered by **`ID` descending** (`orderByDesc('ID')` — newest first), with the current query string preserved across pages (`withQueryString()`).
- **FR-1.4** Each row eager-loads `bank:ID,Bank` and `division:ID,DivisionName` (constrained columns).
- **FR-1.5 — 3-state status filter (controller-side):** the controller reads `$status = $request->string('status', 'active')->toString()` (default `'active'`):
  - `'deleted'` → `withoutGlobalScope(NotDeletedScope::class)->where('bankdivision.IsDeleted', 1)` (only soft-deleted rows).
  - `'all'` → `withoutGlobalScope(NotDeletedScope::class)` (active + deleted, no `IsDeleted` predicate).
  - `'active'` (default / any other value) → no branch runs; the global `NotDeletedScope` stays applied, so only `IsDeleted = 0`.
  - **Frontend gap:** the React UI exposes **no control that sends a `status` param** — see FR-1.8 and §13.
- **FR-1.6 — Bank filter:** input `bank_id`; when `is_numeric($bankId)`, apply `WHERE BankID = (int) bank_id`. A blank/non-numeric value means "no filter".
- **FR-1.7 — Division filter:** input `division_id`; when `is_numeric($divisionId)`, apply `WHERE DivisionID = (int) division_id`. A blank/non-numeric value means "no filter".
- **FR-1.8 — Filter params sent by the UI:** Index only ever sends `bank_id` and `division_id` (via Search) or `{}` (via Reset). It **never** sends `status`. Filter navigation uses `router.get` with `preserveState`, `preserveScroll`, `replace`.
- **FR-1.9 — `filters` prop echo:** `['bank_id' => is_numeric($bankId) ? (int)$bankId : null, 'division_id' => is_numeric($divisionId) ? (int)$divisionId : null, 'status' => $status]` — the normalized filter state is echoed back to the UI.
- **FR-1.10 — Running "No" column:** the first column is a running number `startNo + idx`, where `startNo = bankDivisions.from ?? 1` (derived from the paginator `from`). It is not a DB column.
- **FR-1.11 — Row actions** are driven by each row's `IsDeleted` value: Show, Edit, Delete (active rows), and Restore (deleted rows). Show/Edit navigate via `router.visit`.
- **FR-1.12 — Empty state:** when there are no rows, display "Belum ada assignment." across the table.

### 6.2 Create

**User story:** As a granted admin, I want to add a new Bank↔Division assignment.

- **FR-2.1** `create()` requires the `create` ability (subject `BankDivision::class`) and renders `Pengelolaan/BankDivisions/Create` with `bankOptions` (`Bank::query()->orderBy('Bank')->get(['ID','Bank'])`) and `divisionOptions` (`Division::query()->orderBy('DivisionName')->get(['ID','DivisionName'])`). Both option lists are non-deleted only (via each model's active `NotDeletedScope`).
- **FR-2.2** Submission posts to `bank-divisions.store`. `store()` re-checks the `create` ability, then `BankDivision::create($request->validated())` (the model `creating` hook defaults `IsDeleted ??= 0`, `Remark ??= ''`).
- **FR-2.3** On success, redirect to `bank-divisions.index` with flash `success` = **"Bank Division berhasil dibuat."**
- **FR-2.4** Validation errors are returned to the form and rendered inline per field (Inertia `useForm` errors).

### 6.3 View / Show

**User story:** As a granted admin, I want to view full details of one assignment.

- **FR-3.1** `show(BankDivision $bankDivision)` uses route-model binding, requires the `view` ability (subject = bound instance), then `load(['bank:ID,Bank', 'division:ID,DivisionName'])`.
- **FR-3.2** Renders `Pengelolaan/BankDivisions/Show` with the `bankDivision` prop (see §9.2). Because route-model binding resolves via the model, the global `NotDeletedScope` applies — a soft-deleted record returns **404** on this route.

### 6.4 Edit / Update

**User story:** As a granted admin, I want to reassign an existing record's Bank and/or Division.

- **FR-4.1** `edit(BankDivision $bankDivision)` requires the `update` ability (subject = bound instance) and renders `Pengelolaan/BankDivisions/Edit` with `bankDivision`, `bankOptions`, and `divisionOptions` (same option queries as Create).
- **FR-4.2** Submission uses `PUT` to `bank-divisions.update` with `bankDivision.ID` (`form.put(route('bank-divisions.update', bankDivision.ID))`). `update()` re-checks `update`, then `$bankDivision->update($request->validated())`.
- **FR-4.3** On success, redirect to `bank-divisions.index` with flash `success` = **"Bank Division berhasil diupdate."**

### 6.5 Delete (soft)

**User story:** As a granted admin, I want to remove an assignment but be able to recover it.

- **FR-5.1** `destroy(BankDivision $bankDivision)` requires the `delete` ability, then calls `$bankDivision->softDelete()` (sets `IsDeleted = 1`; no cascade). **No hard delete occurs.**
- **FR-5.2** Deletion is initiated from the list with a confirmation modal (`DeleteModal`); only on confirm does the `DELETE` fire (`router.delete` on `bank-divisions.destroy`, `preserveScroll`).
- **FR-5.3** Redirects **back** (`redirect()->back()`, not to the index route) with flash `success` = **"Bank Division dihapus."**

### 6.6 Restore

**User story:** As a granted admin, I want to recover a previously soft-deleted assignment.

- **FR-6.1** `restore(int $id)` takes a **raw integer id** (not route-model binding), looks up the row via `BankDivision::withoutGlobalScope(NotDeletedScope::class)->findOrFail($id)`, **then** checks the `restore` ability (subject = the looked-up, possibly-deleted instance), then calls `$bd->restore()` (sets `IsDeleted = 0`). A non-existent ID yields 404 before the policy check.
- **FR-6.2** Restore is wired from the list for soft-deleted rows via `router.post` to `bank-divisions.restore` with `bd.ID` (empty body, `preserveScroll`). **However, the UI never requests deleted rows, so this action is unreachable through the shipped UI** (see §13).
- **FR-6.3** Redirects **back** with flash `success` = **"Bank Division di-restore."**

---

## 7. Validation Rules

`StoreBankDivisionRequest` and `UpdateBankDivisionRequest` share the same `BankID` rules and the same `DivisionID` base rules. Both `authorize()` methods `return true`. **Neither request defines `prepareForValidation()`** — there is no input normalization/mutation (no trimming, casting, or default-injection before rules run).

| Field | Required? | Type | Extra rules |
|---|---|---|---|
| `BankID` | required | integer | `Rule::exists('bank', 'ID')->where('IsDeleted', 0)` — must reference a **non-deleted** bank. A soft-deleted bank (`IsDeleted = 1`) fails existence. |
| `DivisionID` | required | integer | `Rule::exists('division', 'ID')->where('IsDeleted', 0)` — must reference a **non-deleted** division; **plus** the custom `UniqueBankDivisionPair` rule (see §7.2). |

### 7.1 The single difference between Store and Update

The ONLY behavioral difference is the second argument to `UniqueBankDivisionPair` on the `DivisionID` field:

| Aspect | Store | Update |
|---|---|---|
| `BankID` rules | `required, integer, exists(bank,ID where IsDeleted=0)` | identical |
| `DivisionID` base rules | `required, integer, exists(division,ID where IsDeleted=0)` | identical |
| `$currentId` lookup | none | `$this->route('bankDivision')->ID` resolved at the top of `rules()` |
| Custom rule construction | `new UniqueBankDivisionPair((int)($this->input('BankID') ?? 0))` | `new UniqueBankDivisionPair((int)($this->input('BankID') ?? 0), ignoreId: $currentId)` |
| Self-row exclusion | **No** (`ignoreId` defaults to `null`) | **Yes** (current record's own row excluded) |

Consequence: saving an unchanged Bank/Division pair on the same record passes on Update (its own row is excluded), but the same pair existing on a **different** active row is flagged as a duplicate.

> Note on `$currentId`: it is resolved eagerly via `$this->route('bankDivision')->ID`, assuming route-model binding always yields a non-null model. On a normal `Route::resource` update the model is present.

### 7.2 `UniqueBankDivisionPair` rule — precise logic

Implements the modern `ValidationRule` interface (single `validate(string $attribute, mixed $value, Closure $fail)` method).

- **Constructor:** `__construct(protected int $bankId, protected ?int $ignoreId = null)`. `$bankId` is fed from `(int) ($this->input('BankID') ?? 0)`; `$ignoreId` defaults to `null` (Store) and is the route model's `ID` on Update.
- **The pair columns** are `BankID` + `DivisionID` on `bankdivision` (queried via the `BankDivision` model): `BankID` from the injected `$this->bankId`, `DivisionID` from `(int) $value` (the attribute under validation).
- **Early-return guard:** the rule short-circuits (passes silently) when `$value === null` OR `$this->bankId === 0`. `0` is the explicit "no bank provided" sentinel produced when `BankID` is missing/null/non-numeric (a legitimate `bank.ID` is never `0`). This prevents a meaningless duplicate hit when half the pair is absent.
- **IsDeleted=0 scoping (implicit):** the query is built from `BankDivision::query()`; the rule itself adds **no** explicit `->where('IsDeleted', 0)`. Active-only scoping is delegated to the model's `NotDeletedScope` global scope (which appends `WHERE bankdivision.IsDeleted = 0`). **Net effect: the duplicate check considers only active rows.** A soft-deleted pair does NOT block re-creating the same pair — consistent with the project rule that unique validation must ignore `IsDeleted=1` rows. (Caveat: this correctness is coupled to the model actually booting `NotDeletedScope`; the rule is not self-contained.)
- **Update-ignore:** when `$ignoreId !== null`, the query adds `AND ID != <ignoreId>`, excluding the current record's own row. When `null` (Store), this clause is skipped and every active matching row counts as a collision.
- **Failure:** if `$query->exists()`, it calls `$fail('Kombinasi Bank + Division ini sudah ada.')` — a hardcoded Indonesian message ("This Bank + Division combination already exists."), not from a translation file, not parameterized. The rule reports against the `DivisionID` field.

**Effective SQL (conceptual):**
- Store: `SELECT EXISTS(SELECT * FROM bankdivision WHERE BankID = ? AND DivisionID = ? AND IsDeleted = 0)`
- Update: `SELECT EXISTS(SELECT * FROM bankdivision WHERE BankID = ? AND DivisionID = ? AND ID != ? AND IsDeleted = 0)`

(The `IsDeleted = 0` clause is contributed by `NotDeletedScope`, not by the rule's own code.)

---

## 8. Routes / API

All routes below are inside the single `auth` middleware group in `routes/web.php`. The first seven are produced by `Route::resource('bank-divisions', BankDivisionController::class)->parameters(['bank-divisions' => 'bankDivision'])` (the parameter is renamed to `{bankDivision}` to match the controller's type-hinted `BankDivision $bankDivision`); the eighth is an explicit custom route.

| Verb | URI | Route name | Controller method | Authorize (ability + subject) |
|---|---|---|---|---|
| GET | `/bank-divisions` | `bank-divisions.index` | `index` | `viewAny`, `BankDivision::class` |
| GET | `/bank-divisions/create` | `bank-divisions.create` | `create` | `create`, `BankDivision::class` |
| POST | `/bank-divisions` | `bank-divisions.store` | `store` | `create`, `BankDivision::class` |
| GET | `/bank-divisions/{bankDivision}` | `bank-divisions.show` | `show` | `view`, `$bankDivision` |
| GET | `/bank-divisions/{bankDivision}/edit` | `bank-divisions.edit` | `edit` | `update`, `$bankDivision` |
| PUT/PATCH | `/bank-divisions/{bankDivision}` | `bank-divisions.update` | `update` | `update`, `$bankDivision` |
| DELETE | `/bank-divisions/{bankDivision}` | `bank-divisions.destroy` | `destroy` | `delete`, `$bankDivision` |
| POST | `/bank-divisions/{id}/restore` | `bank-divisions.restore` | `restore` | `restore`, `$bd` (looked up via `withoutGlobalScope`) |

**Restore route detail:** `Route::post('bank-divisions/{id}/restore', [BankDivisionController::class, 'restore'])->name('bank-divisions.restore')->whereNumber('id')`. It uses `{id}` (not `{bankDivision}`) to **bypass route-model binding's global scope**, so soft-deleted records can be looked up via `withoutGlobalScope(NotDeletedScope::class)->findOrFail($id)` in the controller; `whereNumber('id')` constrains the param to numeric. (This mirrors the documented pattern on the `banks` restore route.) The resource routes use the default `{bankDivision}` binding with no numeric constraint.

---

## 9. UI / UX Requirements

**Conventions across all pages:** all four pages assign `*.layout = [AppLayout]` and live under the `resources/js/Pages/Pengelolaan/BankDivisions/` prefix; route names are `bank-divisions.*`. Data is fetched/submitted through Inertia (no axios).

> **Styling inconsistency (module-wide):** `Index.jsx` is fully migrated to the **design-system tokens** (`bg-primary`, `bg-primary-hover`, `text-text-muted`, `text-text-heading`, `border-border`, `bg-surface`) and the shared `@/Components/Table` primitives. `Create.jsx`, `Edit.jsx`, and `Show.jsx` are **plain Tailwind grays/blues** (`gray-300`/`gray-700`/`gray-900`, `blue-600`/`blue-700`, `red-500`/`red-600`). The lone exception is Edit's `#{ID}` span using `text-text-muted`. So the list page looks like the design system; the create/edit/show pages do not.

### 9.1 Index (list & toolbar) — `Pengelolaan/BankDivisions/Index`

- **Props:** `{ bankDivisions, bankOptions, divisionOptions, filters }`. `bankOptions` is an array of `{ ID, Bank }`; `divisionOptions` is `{ ID, DivisionName }`; `filters` exposes `filters.bank_id` and `filters.division_id` (used to seed local pending state).
- **Local state:** `pendingBankId` ← `filters.bank_id ? String(filters.bank_id) : ''`; `pendingDivId` ← `filters.division_id ? String(filters.division_id) : ''`; `deleteTarget` ← `null`. No `useForm` on Index.
- **Header:** `TopBar` with title **"Bank Divisions"**, breadcrumb **Bank Divisions → List** (`Crumb` "Bank Divisions" / `CrumbCurrent` "List"), and a primary action button **"New assignment"** linking to `bank-divisions.create`.
- **Toolbar — two `SelectField` filters** (each `minWidth="min-w-[180px]"`):
  - **Bank select** — `ariaLabel="Bank"`, value `pendingBankId`; first option `{ value: '', label: '— Semua Bank —' }`, then `bankOptions.map` → `{ value: String(b.ID), label: b.Bank }`.
  - **Division select** — `ariaLabel="Division"`, value `pendingDivId`; first option `{ value: '', label: '— Semua Division —' }`, then `divisionOptions.map` → `{ value: String(d.ID), label: d.DivisionName }`.
- **Toolbar — right actions:**
  - **"Search"** (primary-styled `bg-primary text-white hover:bg-primary-hover`) → `applySearch()` → `router.get(route('bank-divisions.index'), { bank_id: pendingBankId, division_id: pendingDivId }, { preserveState, preserveScroll, replace })`.
  - **"Reset"** (`border border-border bg-surface text-text-muted hover:border-primary hover:text-primary`) → `resetFilters()` → clears both pending states and `router.get(route('bank-divisions.index'), {}, …)`.
- **Status / deleted filter UI — ABSENT.** There is no status/`IsDeleted`/`all`/`deleted` control anywhere on Index. The only query params Index sends are `bank_id` and `division_id`. See §13.
- **Table** (`ListTable`, `min-w-[640px]`), 4 columns in order: **No, Bank, Division, Actions** (Actions right-aligned). Cell formatting:
  - `No` → `startNo + idx` (`startNo = bankDivisions.from ?? 1`), styled `text-text-muted tabular-nums pl-7`.
  - `Bank` → `bd.bank?.Bank ?? '—'`, styled `font-semibold text-text-heading`.
  - `Division` → `bd.division?.DivisionName ?? '—'`.
  - `Actions` → `<TdActions>` wrapping `<RowActions … />`.
  - Note relation accessors are **lowercase** (`bd.bank` / `bd.division`, the Eloquent relation keys) while the columns inside are PascalCase (`Bank` / `DivisionName`).
- **RowActions wiring:** `isDeleted={bd.IsDeleted}` toggles delete vs. restore presentation; `onShow`/`onEdit` → `router.visit` to show/edit; `onDelete` → `setDeleteTarget(bd)`; `onRestore` → `restoreOne(bd)` → `router.post(route('bank-divisions.restore', bd.ID), {}, { preserveScroll: true })`.
- **Empty state:** "Belum ada assignment." across `colSpan={4}` (shown when `bankDivisions.data.length === 0`).
- **Delete modal:** rendered when `deleteTarget` is truthy. Label format `${deleteTarget.bank?.Bank} → ${deleteTarget.division?.DivisionName}` — bank name, a literal arrow `→`, division name (e.g. `BCA → Finance`). Uses optional chaining but **no `?? '—'` fallback**, so a missing relation renders the literal string `"undefined → undefined"`. `confirmDelete()` → `router.delete(route('bank-divisions.destroy', deleteTarget.ID), { preserveScroll, onSuccess: () => setDeleteTarget(null) })`.
- **Footer:** `<ListFooter paginator={bankDivisions}><Pagination links={bankDivisions.links} /></ListFooter>`.
- **Ordering:** none set on the frontend; row order is whatever the backend paginator returns (`orderByDesc('ID')`). The `No` column simply numbers rows from `from`.

### 9.2 Show (detail) — `Pengelolaan/BankDivisions/Show`

- **Props:** `{ bankDivision }` (no options props). No `useForm`.
- Heading **"Bank Division Detail"**; top buttons **"Back to List"** (neutral bordered gray, → `bank-divisions.index`) and **"Edit"** (`bg-blue-600 text-white hover:bg-blue-700`, → `bank-divisions.edit` with `bankDivision.ID`).
- Definition list (`grid grid-cols-3`), three rows: **ID** (`{bankDivision.ID}`, `tabular-nums`), **Bank** (`{bankDivision.bank?.Bank ?? '—'}`), **Division** (`{bankDivision.division?.DivisionName ?? '—'}`). Uses lowercase relation accessors, same as Index. Plain Tailwind grays/blues; no design-system tokens.

### 9.3 Create form — `Pengelolaan/BankDivisions/Create`

- **Props:** `{ bankOptions, divisionOptions }`. Header **"Back to List"** link (→ `bank-divisions.index`); heading **"Create Bank Division"**.
- `useForm({ BankID: '', DivisionID: '' })` — both empty strings, **PascalCase** keys (differing from Index's snake_case filter keys).
- Fields (inline per-field error display in `text-red-600`):
  1. **Bank** (`BankID`) — label "Bank" + required asterisk `*`. Native `<select>` bound to `form.data.BankID`; first option `<option value="">— Pilih Bank —</option>`, then `bankOptions.map` → `<option value={b.ID}>{b.Bank}</option>`. Error: `form.errors.BankID`.
  2. **Division** (`DivisionID`) — label "Division" + required asterisk `*`. Native `<select>` bound to `form.data.DivisionID`; first option `<option value="">— Pilih Division —</option>`, then `divisionOptions.map` → `<option value={d.ID}>{d.DivisionName}</option>`. Error: `form.errors.DivisionID`.
- **Submit:** `form.post(route('bank-divisions.store'))`; button text `{form.processing ? 'Menyimpan...' : 'Simpan'}`, `disabled={form.processing}`, styled `bg-blue-600 hover:bg-blue-700 disabled:opacity-50`. **Cancel:** "Batal" link → `bank-divisions.index`.
- Plain Tailwind (`text-gray-900`, `border-gray-300`, `bg-white`, `bg-blue-600`, `text-red-500/600`); no design-system tokens.

### 9.4 Edit form — `Pengelolaan/BankDivisions/Edit`

- **Props:** `{ bankDivision, bankOptions, divisionOptions }`. Header **"Back to List"** link; heading **"Edit Bank Division #{bankDivision.ID}"** (the `#{ID}` span uses the design-system token `text-text-muted text-sm font-normal` — the one token leak in an otherwise plain-Tailwind page).
- `useForm({ BankID: bankDivision.BankID ?? '', DivisionID: bankDivision.DivisionID ?? '' })` — PascalCase keys seeded from the record's **raw FK columns**.
- Fields:
  1. **ID** (read-only, edit-only) — label "ID"; `<input value={bankDivision.ID} readOnly disabled>` styled `border-gray-200 bg-gray-50 tabular-nums`. Not part of `useForm`; display-only.
  2. **Bank** (`BankID`) — identical control/options/error wiring to Create (placeholder "— Pilih Bank —").
  3. **Division** (`DivisionID`) — identical to Create (placeholder "— Pilih Division —").
- **Submit:** `form.put(route('bank-divisions.update', bankDivision.ID))`; button text `{form.processing ? 'Menyimpan...' : 'Update'}`, `disabled={form.processing}`, styled `bg-blue-600 hover:bg-blue-700`. **Cancel:** "Batal" → `bank-divisions.index`.

### 9.5 Prop casing (frontend)

- **Display reads** use lowercase Eloquent relation keys then PascalCase columns: `bd.bank?.Bank`, `bd.division?.DivisionName` (Index, Show, delete-modal label).
- **Editable record** uses PascalCase FK columns directly: `bankDivision.BankID`, `bankDivision.DivisionID` (Edit `useForm` seed), plus `bankDivision.ID`. The backend must expose both the loaded relations and the raw FK columns for the pages to work.
- **Filter query keys** are snake_case (`bank_id`, `division_id`); **form keys** are PascalCase (`BankID`, `DivisionID`).
- **Placeholder wording differs by purpose:** Index selects use **"— Semua Bank —"** / **"— Semua Division —"** (filter = "all"); Create/Edit use **"— Pilih Bank —"** / **"— Pilih Division —"** (form = "choose").

---

## 10. Business Rules & Constraints

- **BR-1 Composite-pair uniqueness:** a `(BankID, DivisionID)` pair must be unique among **active** rows. Enforced in the app layer by `UniqueBankDivisionPair` (scoped to `IsDeleted=0` via the model's global scope; on Update it ignores the current record's own `ID`). Violation message: **"Kombinasi Bank + Division ini sudah ada."** A soft-deleted pair does NOT block re-creating the same pair.
- **BR-2 Soft-delete only (leaf):** records are never physically deleted; `destroy()` sets `IsDeleted = 1` and remains restorable. `softDelete()` is non-cascading (this is a leaf record).
- **BR-3 Default active-only scope:** all queries return only `IsDeleted = 0` rows via the `NotDeletedScope` global scope, unless explicitly bypassed with `withoutGlobalScope(NotDeletedScope::class)` (used by the controller's `status=deleted`/`status=all` branches and by `restore()`).
- **BR-4 App-layer FK integrity (non-deleted parents only):** referential integrity for both FKs is enforced via `Rule::exists('bank','ID')->where('IsDeleted', 0)` and `Rule::exists('division','ID')->where('IsDeleted', 0)` — the referenced Bank and Division must exist **and** be non-deleted. This app-layer check is the **primary** defense (it adds the "non-deleted" predicate the DB cannot). Confirmed DB-level `RESTRICT` FK constraints (`bankdivision_BankID`, `bankdivision_DivisionID` → `bank`/`division`) exist as an additional safety net (see §5.3).
- **BR-5 NOT NULL defaults:** the model `creating()` hook supplies `0` for `IsDeleted` and `''` for `Remark` (the FormRequests do **not** normalize — there is no `prepareForValidation()`). The confirmed schema (§5.1) shows both columns are `NOT NULL` with no DB default, so this hook is load-bearing on insert.
- **BR-6 No prepareForValidation:** neither request mutates raw input before rules run; `BankID` type coercion happens only inside `rules()` when constructing the custom rule (`(int)($this->input('BankID') ?? 0)`).
- **BR-7 Real rolemenu authorization:** every action requires the user to hold an active `rolemenu` grant for `menu.LinkLaravel = '/bank-divisions'` (with `IsShow=1`, `IsDeleted=0`). No admin/`RoleID=1` bypass exists (see §4.3).
- **BR-8 PascalCase DB columns:** column and FK names are PascalCase (`BankID`, `DivisionID`, `Remark`, `IsDeleted`, `ID`); used verbatim in queries (e.g. `where('BankID', ...)`, `orderByDesc('ID')`).
- **BR-9 Page path prefix:** all Inertia pages render under `Pengelolaan/BankDivisions/*` (`Index`, `Create`, `Show`, `Edit`).
- **BR-10 Exact flash strings (Indonesian):**
  - Create → `Bank Division berhasil dibuat.`
  - Update → `Bank Division berhasil diupdate.`
  - Delete → `Bank Division dihapus.`
  - Restore → `Bank Division di-restore.`
  - Create/Update set via `->route('bank-divisions.index')->with('success', ...)`; Delete/Restore set via `->back()->with('success', ...)`.

---

## 11. Non-Functional Requirements

- **NFR-1 Stack/version constraints:** Laravel 13.8, Inertia.js v3, React 19.2, Tailwind v4, MySQL 8.4. Model configuration is attribute-driven (`#[Table]`, `#[Fillable]`, `#[ScopedBy]`), with `$casts` via the `casts()` method.
- **NFR-2 Internationalization (i18n):** user-facing flash messages, the uniqueness error, and most placeholders/empty-state are in **Indonesian** ("Belum ada assignment.", "— Semua Bank —", "— Pilih Bank —", "Simpan"/"Menyimpan..."/"Batal"); however page headings and several UI labels are English ("Bank Divisions", "New assignment", "Create Bank Division", "Back to List", "Search", "Reset", "Update", "Bank Division Detail"). DB columns/attributes remain PascalCase. No multi-language switching is implemented.
- **NFR-3 Security:**
  - All routes gated by the `auth` middleware group.
  - Authorization checked **per action** via `$this->authorize()` against `BankDivisionPolicy` (6 abilities), which is **real rolemenu-based** (not a stub) with **no admin bypass** (§4.3).
  - Data transport via **Inertia v3 XHR** — no axios.
- **NFR-4 Maintainability:** follows the Bank Phase-3 reference structure (model attributes, custom soft delete, FormRequest validation, Policy-per-action, 4-page Inertia layout), adapted for a composite-pair pivot with two FK dropdowns. (Note: a known styling inconsistency between Index and the Create/Edit/Show pages — §9 — and the seeding gap — §4.3 — remain.)
- **NFR-5 Platform:** responsive web admin only (desktop-first table with `min-w-[640px]` horizontal scroll); no native/mobile app.

---

## 12. Acceptance Criteria / Test Coverage

The module is covered by a Pest feature suite, `tests/Feature/BankDivisionTest.php` (**14 test cases**, grouped: 2 Model, 2 Rule, 10 Controller/HTTP). Authentication uses a shared `actingAsAdmin()` helper (defined in `tests/Pest.php`); authorization in tests comes from that helper — an in-memory RoleID-1 user passing the normal `canAccessMenuLink` check against the **pre-existing dev-DB `rolemenu` grants** (not from any inline menu/grant construction; see §4.3). Tests run against the dev MySQL DB wrapped in `DatabaseTransactions`. All Inertia assertions use the `Pengelolaan/BankDivisions/...` component prefix. Each `Division` is created with a distinct uppercase `DivisionName` prefix + `uniqid()` to avoid collisions in the shared DB.

- **Model:** `softDelete()` sets `IsDeleted=1` (proven by `BankDivision::find()` returning `null` under the global scope); the `bank()`/`division()` `belongsTo` relations resolve to the correct parents.
- **Rule (unit):** `UniqueBankDivisionPair` **rejects** an existing active pair (fail closure invoked); **allows** the pair when the prior row is soft-deleted (`IsDeleted=1`) — the fail closure is not invoked.
- **Auth (guest gate):** unauthenticated `GET /bank-divisions` redirects to `route('login')`. (This is the only explicit auth test — there is no authenticated-but-unauthorized/missing-grant test, so the rolemenu authorization path itself is not directly asserted.)
- **Index:** authenticated user gets `200` and component `Pengelolaan/BankDivisions/Index` with props present (`bankDivisions.data`, `bankOptions`, `divisionOptions`) — presence only, no filter-applied assertions.
- **Create:** `create` page renders `Pengelolaan/BankDivisions/Create` with `bankOptions`/`divisionOptions`; `store` with a valid `BankID`+`DivisionID` redirects to `bank-divisions.index` and the row exists in the DB.
- **Show:** renders `Pengelolaan/BankDivisions/Show` with `bankDivision.ID` equal to the created ID (exact-value assertion).
- **Update:** reassigning a record's `BankID` (bankA → bankB) redirects to `bank-divisions.index` and `fresh()->BankID` equals the new bank.
- **Delete (soft):** `destroy` redirects (no target asserted) with `assertSessionHas('success')` (key only) and the row is hidden by the scope (`find()` is `null`; not hard-deleted).
- **Restore:** `restore` on a soft-deleted id (bound by raw `$bd->ID`) redirects with `assertSessionHas('success')`; the row is visible again (`find()` non-null).
- **Store validation:** a duplicate active pair → `assertSessionHasErrors(['DivisionID'])`; a soft-deleted `BankID` → `assertSessionHasErrors(['BankID'])`.

**Notable gaps (per the suite):** no assertion of the exact flash **string** anywhere (only the `success` key); **no Edit page test** and **no update-validation test** (e.g. updating into a duplicate pair, or updating to a soft-deleted bank); and **no authenticated-but-unauthorized (missing rolemenu grant) test** — the rolemenu authorization layer is not directly exercised.

---

## 13. Edge Cases & Known Constraints

- **Frontend ↔ backend status-filter mismatch:** the controller supports a 3-state `status` filter (`active`/`deleted`/`all`, default `active`), but the Index UI has **no control that sends a `status` param** — it only ever sends `bank_id`/`division_id` (Search) or `{}` (Reset). So the backend never receives `status=deleted` or `status=all`, and deleted rows are never requested.
- **Restore is unreachable from the UI (dead/unreachable wiring):** because Index never requests deleted rows, the default `NotDeletedScope` filters out `IsDeleted=1` rows, so `bd.IsDeleted` is always falsy in practice. The `RowActions` restore branch (`isDeleted={bd.IsDeleted}`) and `restoreOne` / `route('bank-divisions.restore', …)` are fully wired and the backend `restore()` works (and is tested), but the action **cannot be triggered through the shipped UI**.
- **Delete-modal label has no fallback:** Index's `DeleteModal` label uses `${deleteTarget.bank?.Bank} → ${deleteTarget.division?.DivisionName}` with optional chaining but **no `?? '—'`**, unlike the table cells and Show page. A null relation surfaces the literal text `"undefined → undefined"` rather than an em-dash.
- **Two design languages coexist:** Index uses design-system tokens; Create/Edit/Show use plain gray/blue Tailwind (lone exception: Edit's `#{ID}` span uses `text-text-muted`). See §9.
- **Restore bypasses route-model binding:** soft-deleted rows are hidden by `NotDeletedScope`, so standard `{bankDivision}` binding cannot find them. The restore route uses `{id}` + `whereNumber('id')`, and the controller resolves the row via `withoutGlobalScope(...)->findOrFail($id)` **before** authorizing.
- **Show returns 404 for soft-deleted records:** `show()` relies on route-model binding (scoped), so a soft-deleted assignment is not viewable.
- **Seeding gap (no `/bank-divisions` menu seeded):** no seeder registers a `menu` row with `LinkLaravel='/bank-divisions'` or a corresponding `rolemenu` grant (`DivisionMenuSeeder` wires only `/divisions`). From a fresh seed, a role cannot pass `BankDivisionPolicy::allowed()` until such a menu (`IsShow=1`/`IsDeleted=0`/non-blank link) and an active grant exist. The feature tests do not hit this gap because the dev DB already contains an active RoleID-1 grant for the bank-divisions menu (§4.3).
- **`Remark` is fillable but not collected by the form:** the model and DB support `Remark` (default `''`), but neither Create nor Edit exposes a `Remark` input; the notes do not show it being submitted from the UI.
- **`BankID`/`DivisionID` required in validation:** both FK columns have no model default, yet both are `required` in the FormRequests — so the app layer prevents null pairs.
- **No `prepareForValidation()`:** unlike the Bank reference, neither request normalizes input before validation; defaults for `IsDeleted`/`Remark` come solely from the model `creating()` hook.

---

## 14. Out of Scope / Future Enhancements

The following are **not** implemented in the shipped code; they are listed only to the extent the source supports them, and no additional behavior is implied.

- **Expose the status filter in the UI:** add an Index control (e.g. active/deleted/all) that actually sends `status`, so the existing 3-state controller branch is reachable and soft-deleted rows (and therefore the Restore action) become usable from the UI.
- **Unify the design system:** migrate Create/Edit/Show from plain Tailwind grays/blues to the design-system tokens used on Index (and add a `?? '—'` fallback to the delete-modal label).
- **Register the menu + grant:** add a `BankDivisionMenuSeeder` (analogous to `DivisionMenuSeeder`) that creates `menu.LinkLaravel = '/bank-divisions'` and an Administrator `rolemenu` grant, so the module is reachable from a fresh seed.
- **Expose `Remark` on the form** (it is already fillable and persisted with a default of `''`).
- **Test coverage gaps to close:** add an Edit-page test, update-validation tests (duplicate pair on update, soft-deleted FK on update), an authenticated-but-unauthorized (missing-grant) authorization test, and exact-flash-string assertions.
- **Capabilities not present today** (candidates only if a future requirement arises): hard delete, bulk operations, data export/import, user-selectable sorting/page size, and an audit trail / timestamps.