# PRD — Bank (Master Data) Module

## 1. Document Metadata

| Field | Value |
|---|---|
| **Title** | PRD — Bank (Master Data) Module |
| **Module** | Bank (company bank-account master data) |
| **Status** | Shipped — reverse-engineered from production source. Phase-3 reference/template implementation. |
| **Owner** | IT — PT Colorindo Chemtra |
| **Date** | 2026-06-03 |
| **Stack** | Laravel 13.8 / Inertia.js v3 / React 19.2 / Tailwind v4 / MySQL 8.4 |
| **Document Type** | Reverse-engineering PRD (documents already-shipped behavior; not a forward-looking proposal) |

---

## 2. Overview & Purpose

The **Bank** module is an internal admin CRUD module for maintaining the master list of company bank accounts used by PT Colorindo Chemtra. Each record captures a bank name, account number, account holder name ("atas nama"), branch ("cabang"), an optional remark, and an optional link to a quotation price-description record.

**Why it exists:** Bank-account master data is referenced elsewhere in the business application (e.g., quotation/pricing contexts). Centralizing it in a managed CRUD with search, soft-delete, and restore gives administrators a controlled, auditable way to add and maintain these records without raw database access.

**Strategic role — Phase-3 reference template:** This module is the canonical **Phase-3 template implementation**. It establishes the end-to-end pattern — PHP-attribute-driven model config (`#[Table]`, `#[Fillable]`, `#[ScopedBy]`), custom soft-delete via `IsDeleted` flag + global scope, FormRequest validation with `prepareForValidation()` normalization, Policy-per-action authorization, and the Inertia + React page layout (`Index` / `Create` / `Show` / `Edit`) — that all other CRUD master-data modules are modeled on. Its conventions (PascalCase DB columns, Indonesian UI labels, `Pengelolaan/` page-path prefix, custom soft-delete) are deliberately copyable.

---

## 3. Scope

### 3.1 In Scope (6 capabilities)

1. **List / Search / Filter** — paginated listing (10/page, ordered by `ID`) with two text searches (Nama Bank, Cabang) and an "IsDeleted" toggle to include soft-deleted rows.
2. **Create** — form to add a new bank record.
3. **View / Show** — read-only detail page for a single record.
4. **Edit / Update** — form to modify an existing record.
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`).

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

- **Hard delete** — no permanent/physical deletion exists; `destroy()` performs a soft delete only.
- **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 `bank` 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 `orderBy('ID')`; no user-selectable column sorting.
- **Per-page size control** — pagination is fixed at 10/page.
- **Granular role/permission enforcement** — the `BankPolicy` is currently an all-permissive stub (see §4); real permission logic is deferred.

---

## 4. Users & Permissions

### 4.1 Access prerequisite

All Bank routes live inside the `Route::middleware('auth')->group(...)` block in `routes/web.php`. **Only authenticated users** can reach any Bank page or action. Unauthenticated requests are blocked by the `auth` middleware.

### 4.2 Authorization model — Policy per action

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

| Action | Policy ability | Notes |
|---|---|---|
| `index()` | `viewAny` | |
| `create()`, `store()` | `create` | |
| `show()` | `view` | |
| `edit()`, `update()` | `update` | |
| `destroy()` | `delete` | |
| `restore()` | `restore` | Authorization is checked **after** the record is looked up via `findOrFail` (because the row is hidden by the global scope and must be fetched first). |

### 4.3 Current Policy behavior (permissive stub)

`app/Policies/BankPolicy.php` defines six methods — `viewAny`, `view`, `create`, `update`, `delete`, `restore` — **all of which currently `return true`** (all-permissive stub). All methods type-hint a **nullable** `?User $user`.

Per the docblock: in **Phase 3** these stubs return `true` to establish the Policy pattern; **Phase 9 (security audit)** will replace the bodies with real role/permission checks. The nullable `User` supports contexts where the request is not yet authenticated.

> **Implication:** Today, any authenticated user can perform any Bank action. The authorization *plumbing* is fully wired (so tightening is a body-only change), but no actual restriction is enforced yet.

Authentication context is shared to the frontend via `HandleInertiaRequests::share()` as `auth.user` (`null` when guest), and the sidebar navigation is built per-role from `Menu::navTreeForRole($roleId)` (returns `[]` when there is no user or `RoleID <= 0`).

---

## 5. Data Model

### 5.1 `bank` table

Model: `app/Models/Bank.php` — `#[Table(name: 'bank', key: 'ID', keyType: 'int', incrementing: true, timestamps: false)]`.

| Column | Type | Length | Required | Fillable | Notes |
|---|---|---|---|---|---|
| `ID` | int | — | auto | ❌ | Primary key, auto-increment. Not fillable. |
| `IsDeleted` | int | — | auto | ❌ | Soft-delete flag (`0` = active, `1` = deleted). Cast to `boolean`. Defaults to `0` on create. Not fillable. |
| `Bank` | varchar | 200 | ✅ | ✅ | Nama bank (display utama). |
| `ACNo` | varchar | 500 | ✅ | ✅ | Nomor rekening (account number). |
| `AN` | varchar | 500 | ✅ | ✅ | Atas nama (account holder). |
| `Cabang` | varchar | 500 | ✅ | ✅ | Cabang bank (branch). |
| `Remark` | varchar | 500 | ❌ (optional) | ✅ | Catatan. NOT NULL in DB; defaults to `''` on create. |
| `QuotationPriceDescID` | int | — | ❌ (optional) | ✅ | FK → `quotationpricedesc.ID`. Cast to `integer`. Nullable in form. |

**Fillable order** (`#[Fillable(...)]`): `Bank`, `ACNo`, `AN`, `Cabang`, `Remark`, `QuotationPriceDescID`. `ID` and `IsDeleted` are **not** fillable.

**Casts:** `['IsDeleted' => 'boolean', 'QuotationPriceDescID' => 'integer']`.

### 5.2 Relationship — `belongsTo quotationpricedesc`

```
Bank::quotationPriceDesc() → belongsTo(QuotationPriceDesc::class, 'QuotationPriceDescID', 'ID')
```

- FK on `bank` = `QuotationPriceDescID`; owner key on `quotationpricedesc` = `ID`.
- Related model `app/Models/QuotationPriceDesc.php`: `#[Table(name: 'quotationpricedesc', key: 'ID', keyType: 'int', incrementing: true, timestamps: false)]`, fillable `QuotationUnitPriceDescName`, `VATPercentage`, `CCFax`; casts `['IsDeleted' => 'boolean', 'VATPercentage' => 'decimal:2']`; also `#[ScopedBy(NotDeletedScope::class)]`. It has no `softDelete()`/`restore()`/`belongsTo` of its own. Its `creating()` defaults `IsDeleted ??= 0` and `CCFax ??= ''`.

### 5.3 Soft-delete semantics

- **No Laravel `SoftDeletes` trait** and **no `deleted_at` column**. Soft delete is a custom integer flag (`IsDeleted`).
- **Global scope:** `Bank` is decorated `#[ScopedBy(NotDeletedScope::class)]` (`App\Models\Scopes\NotDeletedScope`). By default, all queries return only `IsDeleted = 0` rows.
- **`softDelete(): bool`** sets `IsDeleted = 1` then `save()`.
- **`restore(): bool`** sets `IsDeleted = 0` then `save()`.
- To see deleted rows, callers must explicitly remove the scope: `withoutGlobalScope(NotDeletedScope::class)`.

### 5.4 `creating()` defaults

`booted()` registers `static::creating(fn (Bank $bank) => { $bank->IsDeleted ??= 0; $bank->Remark ??= ''; })`. Rationale (per code comment): the DB columns are `NOT NULL` without `DEFAULT` clauses, so sane defaults are supplied on create — `IsDeleted` → `0`, `Remark` → `''`.

---

## 6. Functional Requirements

### 6.1 List / Index

**User story:** As an authenticated admin, I want to browse, search, and filter bank records so that I can quickly find a specific account.

- **FR-1.1** The index requires the `viewAny` ability before rendering.
- **FR-1.2** The list renders the Inertia component `Pengelolaan/Banks/Index` with props `banks` (paginator) and `filters` (`search_bank`, `search_cabang`, `isdeleted`).
- **FR-1.3** Results are paginated at **10 per page**, ordered by **`ID` ascending**, with the current query string preserved across pages (`withQueryString()`).
- **FR-1.4** Each row eager-loads the related price description, selecting only `ID, QuotationUnitPriceDescName` (`with('quotationPriceDesc:ID,QuotationUnitPriceDescName')`).
- **FR-1.5 — Search by Nama Bank:** when `search_bank` is non-empty, filter `WHERE Bank LIKE %term%`.
- **FR-1.6 — Search by Cabang:** when `search_cabang` is non-empty, filter `WHERE Cabang LIKE %term%`.
- **FR-1.7 — IsDeleted toggle:** input `isdeleted` is parsed as boolean. When truthy, the query removes `NotDeletedScope` (`withoutGlobalScope`) so **both active and deleted** rows appear. When falsy/absent, the global scope limits results to `IsDeleted = 0` (active only).
- **FR-1.8 — Clean-URL params:** the frontend includes only non-default params in the URL. Empty search strings are omitted; the deleted toggle is sent literally as `isdeleted=on` only when enabled. Filter navigation uses `router.get` with `preserveState`, `preserveScroll`, `replace: true`.
- **FR-1.9 — Reset:** a Reset control appears only when at least one filter is active (`searchBank`, `searchCabang`, or `isDeleted`), and clears all three filters.
- **FR-1.10 — 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.11 — Empty state:** when there are no rows, display "Belum ada data bank." across all 8 columns.

### 6.2 Create

**User story:** As an admin, I want to add a new bank record.

- **FR-2.1** `create()` requires the `create` ability and renders `Pengelolaan/Banks/Create` with `quotationPriceDescOptions` (all NON-DELETED `quotationpricedesc` rows — `IsDeleted = 0`, via the active `NotDeletedScope` global scope — `ID` + `QuotationUnitPriceDescName` only, ordered by `QuotationUnitPriceDescName`).
- **FR-2.2** Submission posts to `banks.store`. `store()` re-checks the `create` ability, then `Bank::create($request->validated())`.
- **FR-2.3** On success, redirect to `banks.index` with flash `success` = **"Bank 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 an admin, I want to view full details of one bank record.

- **FR-3.1** `show(Bank $bank)` uses route-model binding, requires the `view` ability, then loads `quotationPriceDesc:ID,QuotationUnitPriceDescName`.
- **FR-3.2** Renders `Pengelolaan/Banks/Show` with the `bank` prop, displaying all fields read-only (see §9.2).

### 6.4 Edit / Update

**User story:** As an admin, I want to modify an existing bank record.

- **FR-4.1** `edit(Bank $bank)` requires the `update` ability and renders `Pengelolaan/Banks/Edit` with `bank` and `quotationPriceDescOptions` (same option query as Create: all NON-DELETED `quotationpricedesc` rows — `IsDeleted = 0`, via the active `NotDeletedScope` global scope — `ID` + `QuotationUnitPriceDescName` only, ordered by `QuotationUnitPriceDescName`).
- **FR-4.2** Submission uses `PUT` to `banks.update` with `bank.ID` (via `form.put(route('banks.update', bank.ID))`). `update()` re-checks `update`, then `$bank->update($request->validated())`.
- **FR-4.3** On success, redirect to `banks.index` with flash `success` = **"Bank berhasil diupdate."**

### 6.5 Delete (soft)

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

- **FR-5.1** `destroy(Bank $bank)` requires the `delete` ability, then calls `$bank->softDelete()` (sets `IsDeleted = 1`). **No hard delete occurs.**
- **FR-5.2** Deletion is initiated from the list with a confirmation modal (`DeleteModal`) labeled with the bank name; only on confirm does the `DELETE` fire (`router.delete` on `banks.destroy`, `preserveScroll`).
- **FR-5.3** Redirects back with flash `success` = **"Bank dihapus (bisa di-restore)."**

### 6.6 Restore

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

- **FR-6.1** `restore(int $id)` takes a **raw integer id** (not route-model binding), looks up the row via `Bank::withoutGlobalScope(NotDeletedScope::class)->findOrFail($id)`, **then** checks the `restore` ability, then calls `$bank->restore()` (sets `IsDeleted = 0`).
- **FR-6.2** Restore is triggered from the list for soft-deleted rows via `router.post` to `banks.restore` with `bank.ID` (empty body, `preserveScroll`). Deleted rows are only visible when the IsDeleted toggle is ON.
- **FR-6.3** Redirects back with flash `success` = **"Bank di-restore."**

---

## 7. Validation Rules

`StoreBankRequest` and `UpdateBankRequest` are **field-by-field identical** (same `rules()` and same `prepareForValidation()`). Both `authorize()` methods `return true` — authorization is delegated to the controller/Policy, so the FormRequest stays open as the single source of truth.

| Field | Required? | Type | Max | Extra rules |
|---|---|---|---|---|
| `Bank` | required | string | 200 | — |
| `ACNo` | required | string | 500 | — |
| `AN` | required | string | 500 | — |
| `Cabang` | required | string | 500 | — |
| `Remark` | nullable | string | 500 | Optional (not required). |
| `QuotationPriceDescID` | nullable | integer | — | `Rule::exists('quotationpricedesc', 'ID')->where('IsDeleted', 0)` — FK must reference a **non-deleted** price-desc row. |

### 7.1 `prepareForValidation()` normalization (both requests)

```php
$this->merge([
    'Remark'               => $this->input('Remark') ?? '',
    'QuotationPriceDescID' => $this->input('QuotationPriceDescID') ?: null,
]);
```

- `Remark` → normalized to `''` (empty string) when null/missing (DB column is `NOT NULL`).
- `QuotationPriceDescID` → normalized to `null` when falsy (using `?:`, so `''`, `0`, and `'0'` all become `null`) **before** validation runs.

> **Note:** Store and Update validation are identical; there is no per-field uniqueness rule on `Bank`/`ACNo`.

---

## 8. Routes / API

All routes below are inside the `auth` middleware group in `routes/web.php`. The first seven are produced by `Route::resource('banks', BankController::class)`; the eighth is an explicit custom route.

| Verb | URI | Route name | Controller method | Authorize ability |
|---|---|---|---|---|
| GET | `/banks` | `banks.index` | `index` | `viewAny` |
| GET | `/banks/create` | `banks.create` | `create` | `create` |
| POST | `/banks` | `banks.store` | `store` | `create` |
| GET | `/banks/{bank}` | `banks.show` | `show` | `view` |
| GET | `/banks/{bank}/edit` | `banks.edit` | `edit` | `update` |
| PUT/PATCH | `/banks/{bank}` | `banks.update` | `update` | `update` |
| DELETE | `/banks/{bank}` | `banks.destroy` | `destroy` | `delete` |
| POST | `/banks/{id}/restore` | `banks.restore` | `restore` | `restore` |

**Restore route detail:** `Route::post('banks/{id}/restore', [BankController::class, 'restore'])->name('banks.restore')->whereNumber('id')`. It uses `{id}` (not `{bank}`) to **bypass route-model binding's global scope**, so soft-deleted records can be looked up via `withoutGlobalScope` in the controller; `whereNumber('id')` constrains the param to numeric.

---

## 9. UI / UX Requirements

**Conventions across all pages:** Indonesian UI labels with PascalCase DB column names underneath; persistent `AppLayout` assigned as an array (`Component.layout = [AppLayout]`); pages live under the `Pengelolaan/Banks/` path prefix. Data is fetched/submitted through Inertia (no axios).

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

- **Header:** `TopBar` with title **"Banks"**, breadcrumb **Banks → List** (`Crumb` "Banks" / `CrumbCurrent` "List"), and a primary action button **"New bank"** linking to `banks.create`.
- **Toolbar — search:** primary-styled **"Cari"** button (with `Search` icon) that triggers `applyFilters()`.
- **Reset:** `ResetButton` shown only when `hasActiveFilter` is true.
- **Search fields:** two `SearchField` inputs — placeholder **"Search Nama Bank..."** (bound to `searchBank`) and **"Search Cabang..."** (bound to `searchCabang`); pressing **Enter** in either triggers `applyFilters()`.
- **IsDeleted toggle:** an accessible switch (`role="switch"`, `aria-checked`) labeled **"IsDeleted"**.
  - ON → `aria-label` "Tampilkan semua (termasuk terhapus)", `title` "ON — tampil semua data (IsDeleted 0 & 1)".
  - OFF → `aria-label` "Hanya data aktif (IsDeleted=0)", `title` "OFF — hanya data aktif (IsDeleted=0)".
  - Clicking only toggles local state (it does not itself re-query); the query runs on the next `applyFilters()`.
- **Table** (`ListTable`, `min-w-[920px]`), 8 columns in order: **ID, Bank, ACNo, AN, Cabang, Remark, Price Desc, Actions** (Actions right-aligned). Cell formatting:
  - `ID` → `TdId`.
  - `Bank` → bold/heading.
  - `ACNo` → monospace, small, `break-all`.
  - `AN`, `Cabang` → plain.
  - `Remark` → muted; **"—"** fallback when empty.
  - `Price Desc` → `bank.quotation_price_desc?.QuotationUnitPriceDescName` else muted **"—"**.
  - `Actions` → `RowActions` keyed off `bank.IsDeleted` (Show / Edit / Delete / Restore).
- **Empty state:** "Belum ada data bank." across `colSpan={8}`.
- **Pagination/footer:** `ListFooter` wraps `Pagination` fed by `banks.links`.
- **Delete modal:** `DeleteModal` rendered when a delete target is set; label = the bank name (`deleteTarget.Bank`); Cancel clears the target, Confirm fires the soft delete.

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

- Heading **"Bank Detail"**; top buttons **"Back to List"** (neutral, → `banks.index`) and **"Edit"** (blue, → `banks.edit` with `bank.ID`).
- Definition list (3-column grid) in order: **ID**, **Nama Bank** (`Bank`), **Nomor Rekening** (`ACNo`, `break-all`), **Atas Nama** (`AN`), **Cabang**, **Catatan** (`Remark`, "—" fallback), **Price Description** (`quotation_price_desc?.QuotationUnitPriceDescName`, "—" fallback).

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

- Header **"Back to List"** link (→ `banks.index`); heading **"Create Bank"**.
- `useForm` with 6 keys all empty: `Bank`, `ACNo`, `AN`, `Cabang`, `Remark`, `QuotationPriceDescID`.
- Fields (Indonesian labels) with inline per-field error display:
  1. **Nama Bank** (`Bank`) — text input, **required ★**.
  2. **Nomor Rekening** (`ACNo`) — text input, **required ★**.
  3. **Atas Nama** (`AN`) — text input, **required ★**.
  4. **Cabang** (`Cabang`) — text input, **required ★**.
  5. **Catatan** (`Remark`) — `textarea rows={3}`, no asterisk (optional).
  6. **Quotation Price Description** (`QuotationPriceDescID`) — `select`, no asterisk; first option **"— Pilih (opsional) —"** (`value=""`), remaining options map `opt.ID → value`, label `opt.QuotationUnitPriceDescName`. The option list (`quotationPriceDescOptions`) contains only NON-DELETED `quotationpricedesc` rows (`IsDeleted = 0`, via the active `NotDeletedScope` global scope), ordered by `QuotationUnitPriceDescName`.
- **Submit:** `form.post(route('banks.store'))`; button **"Simpan"** → "Menyimpan..." while `form.processing`; disabled during processing. **Cancel:** "Batal" link → `banks.index`.

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

- Identical layout to Create. Heading **"Edit Bank"**; receives `bank` + `quotationPriceDescOptions`.
- `useForm` pre-filled from `bank.*` with `?? ''` fallback for all 6 keys (`QuotationPriceDescID: bank.QuotationPriceDescID ?? ''`).
- Same six fields, same four required asterisks (Nama Bank, Nomor Rekening, Atas Nama, Cabang), same optional `Catatan`/`Quotation Price Description`.
- **Submit:** `form.put(route('banks.update', bank.ID))`; button **"Update"** → "Menyimpan..." while processing, disabled during processing. **Cancel:** "Batal" → `banks.index`.

### 9.5 Relation prop casing (frontend)

Scalar attributes are consumed in **PascalCase** (`bank.ID`, `bank.Bank`, `bank.ACNo`, `bank.AN`, `bank.Cabang`, `bank.Remark`, `bank.IsDeleted`), but the eager-loaded relation arrives under Laravel's default **snake_case** key `bank.quotation_price_desc`, then `.QuotationUnitPriceDescName` on it.

---

## 10. Business Rules & Constraints

- **BR-1 Soft-delete only:** records are never physically deleted; `destroy()` sets `IsDeleted = 1` and remains restorable. Flash message confirms recoverability ("…bisa di-restore.").
- **BR-2 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 IsDeleted toggle and by `restore()`).
- **BR-3 App-layer FK integrity:** referential integrity for `QuotationPriceDescID` is enforced in the application via `Rule::exists('quotationpricedesc', 'ID')->where('IsDeleted', 0)` — the referenced price-desc must exist **and** be non-deleted.
- **BR-4 NOT NULL defaults:** `bank` columns are `NOT NULL` without DB defaults; `Remark` and `IsDeleted` are defaulted at the model (`creating()`) and request (`prepareForValidation()`) layers so optional/missing input is normalized before persistence.
- **BR-5 PascalCase DB columns:** column and FK names are PascalCase (`Bank`, `ACNo`, `AN`, `Cabang`, `Remark`, `QuotationPriceDescID`, `IsDeleted`, `ID`); used verbatim in queries (e.g. `where('Bank', 'like', ...)`).
- **BR-6 Page path prefix:** all Inertia pages render under `Pengelolaan/Banks/*` (`Index`, `Create`, `Show`, `Edit`).
- **BR-7 Exact flash strings (Indonesian):**
  - Create → `Bank berhasil dibuat.`
  - Update → `Bank berhasil diupdate.`
  - Delete → `Bank dihapus (bisa di-restore).`
  - Restore → `Bank di-restore.`
  - All set via `->with('success', ...)` and surfaced through `HandleInertiaRequests` shared `flash.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]`).
- **NFR-2 Internationalization (i18n):** all user-facing labels, buttons, placeholders, modal text, and flash messages are in **Indonesian**, while DB columns/attributes remain PascalCase English-ish identifiers. (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 `BankPolicy` (6 abilities).
  - Data transport via **Inertia v3 XHR** — no axios/standalone API client.
  - Root Inertia view is `app`; shared props expose `auth.user` (null for guest), lazy `flash`, and per-role `navItems`.
- **NFR-4 Maintainability:** this module is the deliberate **reference template** for Phase-3 CRUD modules; its structure (model attributes, custom soft delete, FormRequest normalization, Policy-per-action, 4-page Inertia layout) is intended to be copied consistently.
- **NFR-5 Platform:** responsive web admin only (desktop-first table with `min-w-[920px]` horizontal scroll); no native/mobile app.

---

## 12. Acceptance Criteria / Test Coverage

The module is covered by a feature test suite (Pest), `BankTest` (~26 scenarios), exercising CRUD happy paths plus authorization, validation, scope, and restore behavior. Representative scenarios:

- **Index/List:** index renders the `Pengelolaan/Banks/Index` component with a paginated `banks` prop (10/page, ordered by `ID`); search by `search_bank` filters on `Bank LIKE`; search by `search_cabang` filters on `Cabang LIKE`; default index excludes `IsDeleted = 1` rows; `isdeleted=on` includes deleted rows.
- **Create:** valid payload creates a row and redirects to `banks.index` with flash "Bank berhasil dibuat."; `IsDeleted` defaults to `0` and `Remark` defaults to `''`.
- **Validation:** missing `Bank`/`ACNo`/`AN`/`Cabang` fail validation; over-max lengths fail (`Bank` > 200, others > 500); `QuotationPriceDescID` referencing a missing or soft-deleted price-desc fails the `exists` rule; empty `QuotationPriceDescID` normalizes to `null`; missing `Remark` normalizes to `''`.
- **Show:** renders `Pengelolaan/Banks/Show` with the eager-loaded relation.
- **Update:** valid update persists changes and redirects with "Bank berhasil diupdate."; Update rules match Store rules.
- **Delete (soft):** `destroy` sets `IsDeleted = 1` (row not physically removed), redirects with "Bank dihapus (bisa di-restore)."
- **Restore:** `restore` on a soft-deleted id sets `IsDeleted = 0` and redirects with "Bank di-restore."; restore looks up the row with the global scope removed.
- **Auth:** unauthenticated access to Bank routes is rejected by the `auth` middleware; each action invokes its corresponding Policy ability.

*(With the current permissive Policy stub, authorization assertions confirm the ability is invoked rather than that specific roles are denied.)*

---

## 13. Edge Cases & Known Constraints

- **Restore bypasses route-model binding:** because soft-deleted rows are hidden by `NotDeletedScope`, standard `{bank}` binding cannot find them. The restore route uses `{id}` + `whereNumber('id')`, and the controller looks the row up via `withoutGlobalScope(...)->findOrFail($id)` **before** authorizing.
- **Optional `Remark`:** form-optional but DB `NOT NULL`; normalized to `''` at both model-create and request-validation layers.
- **Optional `QuotationPriceDescID`:** nullable; falsy values (`''`, `0`, `'0'`) are coerced to `null` via `?:` before validation; FK existence is checked only when a value is present, and only against non-deleted price-desc rows.
- **Snake_case relation key on the frontend:** the eager-loaded relation is consumed as `bank.quotation_price_desc` (Laravel default), even though the underlying FK column is PascalCase `QuotationPriceDescID`. Both Index and Show then read `.QuotationUnitPriceDescName` with a "—" fallback.
- **Deleted rows are invisible by default:** restore is only reachable once the IsDeleted toggle is ON (which removes the scope so deleted rows render with a Restore action).
- **Clean-URL filters:** default filter values are intentionally omitted from the URL; only divergent values appear, and the toggle is encoded literally as `isdeleted=on`.
- **No uniqueness constraints:** duplicate `Bank` names or account numbers are not prevented by validation.

---

## 14. Out of Scope / Future Enhancements

- **Tighten the Policy (Phase 9 — security audit):** the all-permissive `BankPolicy` stub (all six methods `return true`) is planned to be replaced with real role/permission checks. The wiring is already in place (per-action `authorize()` calls), so only the method bodies change.
- **UI refinement (Phase 8):** further UI/UX polish is anticipated per the phased roadmap referenced in the codebase.
- **Capabilities not present today** (candidates only if a future requirement arises): hard delete, bulk operations, data export/import, user-selectable sorting/page size, uniqueness constraints, and an audit trail / timestamps.

*All future items above are stated only to the extent the source code and its phase annotations support them; no additional behavior is implied.*
