# PRD — Complaint & Returns (C&R) Module

> ## ATURAN ABSOLUT #0 — legacy files are LOCAL ONLY
> The 81 legacy PHP reference screens live in `Just An Example, Do not Link/` in this folder and
> are **never** staged, committed or pushed — guarded by `.gitignore`, enforced by `README.md`
> rule 1. **Files, not knowledge:** summarise a legacy rule in your own words and cite its
> filename here, but never paste verbatim legacy PHP or SQL-as-written into this committed
> document. Never read the raw legacy install at `/opt/lampp` to work around a missing file —
> that is a hard rail.
>
> ## ATURAN ABSOLUT #0b — parallel sessions share this tree
> Several Claude sessions edit this checkout at once. mtime-check before every edit, create new
> files only after `ls`/`git status` on the exact path, commit by explicit pathspec only, and
> never run repo-global destructive git. See `README.md` rules 2 and 12.
>
> ## 🔴 ATURAN ABSOLUT #0c — LEGACY IS A REFERENCE, NOT A TEMPLATE
> User directive, 2026-07-21, verbatim: *"legacy is just a reference. do not use it as is.
> follow the claude md, and memory."* Everything in §4, §5 and §6 below is the **business rule**,
> read out of the legacy source and recorded as fact. **How it gets implemented is governed by
> CLAUDE.md, not by the legacy code** — Eloquent not raw SQL, FormRequest not inline checks,
> Policy + `rolemenu` gate not "whoever loads the page", transactions, TDD. §7 lists the legacy
> defects that must **not** be carried forward. See `README.md` rule 15.
>
> ## ⚠️ ATURAN ABSOLUT #0d — three column names in this module lie
> `DepartmentID` is not `department.ID`; `SatuanPackingID` is not a `satuan`; and
> `BarangListID` is not a foreign key. All three are measured, all three will bite silently.
> See §5.8. A fourth trap has the same shape but is about *values*, not names: no datetime
> column here is ever NULL — "no date" is stored as the zero-date `0000-00-00` (§5.8 #9).
>
> ## 🔁 ATURAN ABSOLUT #0e — this document was ADVERSARIALLY RE-VERIFIED on 2026-07-21
> Every §4–§7 claim was re-read against the 81 legacy files, the live dev DB, the ten generated
> models, the prototype and the mail Blade. Most survived. **These did not, and are corrected
> in place — do not reinstate them from memory or from an older copy:**
>
> | Was claimed | Actually |
> |---|---|
> | "`users.DepartmentID` is never used" | Used — it hides every commercial column from Logistic (§4.1 #1) |
> | "Status 9 has no writer / is dead" | No *workflow* writer, but written by the override and **live on 11 action rows** (§5.7, §6.4) |
> | "The generated `satuanPacking()` encodes the trap" | It is **correct**; the three `department()` relations are the wrong ones (§5.8 #8) |
> | "Search terms concatenated into `LIKE`" | Only one `LIKE` exists module-wide; the rest is raw **numeric equality** + raw `ORDER BY`/`LIMIT` (§7) |
> | "CEO assign emails once per department" | Once per **(line × department)** row (§6.6) |
> | "View All Read Only is byte-identical to View All" | Differs on lines 2–3 (§4.2) |
> | "View Request displays `UserIDSales`" | Selected by all five lists, **rendered by none** (§4.2) |
> | "Prototype: nine files, four `Handling*`" | **Eleven** files, **six** `Handling*` (§11) |
> | "`header.php` was deliberately not supplied" | **Found and read** — and it discards its own authorization check (§5.8 #11) |
>
> ⚠️ **One claim was nearly deleted as false and is TRUE:** the two self-referencing
> `INSERT…SELECT`s (§7). A single-line grep misses them because the `SELECT` is on the next
> line. Verify multi-line before "correcting" anything in §7.

---

## 1. Document Metadata

| Field | Value |
|---|---|
| **Title** | PRD — Complaint & Returns (C&R) Module |
| **Module** | Complaint & Returns — a customer complaint about delivered goods, filed per product line, fanned out to six departments for review, assigned by the CEO, worked, and closed. Legacy root menu **225** (`ShowName` "Complain & Returns"). |
| **Status** | **NOT BUILT.** No controller, routes, pages, policy, request, seeder or tests. Exists: 10 auto-generated models, a dormant mail Blade, a UI prototype, four live lookup tables. **Legacy source RECEIVED and ANALYSED 2026-07-21 (81 PHP files, 77 distinct filenames).** **RE-VERIFIED adversarially 2026-07-21** — nine claims corrected, three new traps found, `header.php` located; see Aturan Absolut #0e. |
| **Owner** | IT — PT Colorindo Chemtra |
| **Date** | 2026-07-21 (data model verified against live dev `colorindochemtrainertia`; workflow verified against the legacy source) |
| **Stack** | Laravel 13.8 / PHP 8.4 / Inertia.js v3 / React 19.2 / Tailwind v4 / MySQL 8.4 — existing production schema, no structural changes |
| **Document Type** | Hybrid PRD — **verified** data model and **verified** workflow, the prototype's design inventory, a defect inventory, a phased roadmap, and the residual open questions. Inferences are labelled as such. |

### Locked decisions (user)

| Date | Decision |
|---|---|
| 2026-07-21 | **Scope is legacy root menu 225 ONLY.** "Complain Handling" (root **363**, the `complainitem*` + `complaintosupplier*` families, 18 leaves) is a **separate module**, out of scope. See `README.md` rule 20. |
| 2026-07-21 | Module folder is `Pages/MenuComplaintAndReturns/`, PRD filename `ComplaintAndReturns PRD.md`. |
| 2026-07-21 | Documents use the **UI spelling "Complaint"**; all code touching the schema uses the DB's **"Complain"** verbatim. Do not rename either. |
| 2026-07-21 | Self-contained README rather than cross-referencing Visit Plan's. Rules shared with Visit Plan / Budget and Target are tagged `[SHARED]` and change together. |
| 2026-07-21 | Read-only DB access (`SHOW` / `DESCRIBE` / `SELECT COUNT`) pre-approved. |
| **2026-07-21** | **Legacy is a REFERENCE, not a template** — extract the business rule, implement per CLAUDE.md and the memories. Aturan Absolut #0c. |
| **2026-07-21** | **Email is built but NOT enabled** in the final phase; the session stops for explicit sign-off before any live dispatch. |
| **2026-07-21** | **The two ERP print screens are OUT OF SCOPE** — `complainprintsuratjalan.php` / `complainprintinvoice.php` read a separate PostgreSQL ERP database. Documented in §6.8, not ported. |
| **2026-07-22** | **Q17 answered — rebuild the admin status-override as a gated capability**, not the legacy's ungated version: its own menu leaf, its own Policy, an explicit allowed-transition matrix, and an audit row recording who overrode what. See §6.7, §9. |
| **2026-07-22** | **Q19 answered — Logistic's commercial-column blackout applies module-wide** across Complaint & Returns (business rule: Logistic must not see money-related data anywhere in this module), not just the one legacy screen. Decision is scoped to **this module only** — no claim is made about any other module. See §4.1 #1, §9. |
| **2026-07-22** | **Q9 answered — Finance/Accounting/Logistic queues stay unscoped**, reproducing legacy exactly (company-wide visibility to anyone holding that queue's menu grant). No department-membership scoping added. |
| **2026-07-22** | **Q13 answered — promotion stays complaint-wide**, reproducing legacy exactly: a complaint reaches the CEO only once all six departments have reviewed every line (not line-by-line), and becomes closeable only once every department the CEO assigned across every line has finished (also not line-by-line). User's own words: "the complain only ask for CEO's approval once everyone has reviewed it, and can be closed once everyone that CEO had ask has complete their assignment" — confirmed as a restatement of complaint-wide promotion. |
| **2026-07-22** | **Q4 answered — the 6 Feedback screens (244–249) are dropped.** Zero live usage, never supplied, structurally unreachable via the seeded menu tree (`IsShow=0` on the parent group). No phase budgeted for them. |
| **2026-07-22** | **Q11 answered — Cancel/Close authorization is purely `rolemenu`-grant-based, no separate ownership field-check.** Cancel: derives from which list-scoping menu a role holds (a role granted only View Request can only reach/cancel its own complaints, because that list itself scopes to `UserIDInput=actor`; a role granted View All can cancel any complaint), gated on header status ∈ {1,2,3}. Close: its own standalone menu-256 grant, gated on status 4, no ownership check at all — matches legacy exactly. |
| **2026-07-22** | **Q6 answered — attachments ARE built** (header-level upload on Create, despite 0-of-34 legacy usage), using the `file_uploads` convention. `{menu_name}` = **`complaintreturns`**. |
| **2026-07-22** | **Q8 answered — role/menu grants seeded for Administrator (RoleID 1) only for now.** User will assign the remaining 28 leaves to other roles later via the existing Role-Menu Assignments admin screen; not blocking the build. |
| **2026-07-22** | **Q12 answered — no cap on `ComplainQuantity`**, matching legacy (the apparent cap is dead code reading warehouse stock, not a real business limit). |
| **2026-07-22** | **Q14 answered — Create for Others (325) IS built**, but rebuilt properly: real `HeadID`-subtree scoping on the Sales picker, fixed reason-dropdown bug, fixed refund-field JS bug — the functionality is real, just badly implemented in legacy. |
| **2026-07-22** | **Q15 answered — the department code map is kept as a hardcoded module constant** (1=SM, 2=PM, 5=Logistic, 6=Finance, 8=CS, 11=Accounting), never resolved through the `department` table (rule 19/§5.9). User: "keep the hardcode, I'm not sure where they got it either... hardcode works as of now." |
| **2026-07-22** | **Q16 answered — the two send-back branches (Back to Review, Back to Approval CEO) DO notify**, unlike legacy (which notifies nobody). New behavior beyond the port — recipients/wiring decided in Phase 7/8. |
| **2026-07-22** | **Q18 answered — the 19 legacy zero-dates render as `—`** (blank dash) at the display boundary, never written back. |
| **2026-07-22** | **Phase 1 (`complaintype` lookup CRUD, menu 253) — DONE.** See §8. |
| **2026-08-07** | **The detail screen gets PAGE-LEVEL header actions**, matching `/quotations/view-all/{id}`: the cluster sits on the right of the hero, left of Back to List. The Close screen keeps its pinned `DecisionBar` pill — Close/Back-to-Review/Back-to-CEO are decisions against the record, Print/Cancel/Change Status are page-level actions (`.claude/rules/ui-conventions.md`). |
| **2026-08-07** | **One detail ROUTE per list scope, one shared page** (`complaints.show`, `.head-dept.show`, `.all.show`, `.all-readonly.show`, `.all-report.show`). The scope is named by the URL and `show()` authorizes against it (`ComplainPolicy::viewInScope`). Replaces `effectiveView()`, which returned the first grant that matched and so gave multi-grant users the wrong scope, the wrong Back-to-List target, and (once buttons existed) the wrong action set. Cross-module drill-through goes through `complaints.open`, the single place a scope may be inferred. |
| **2026-08-07** | **Cancel is narrowed to View Request + View All** (`ComplainPolicy::CANCEL_VIEWS`), button and POST alike. Q11 only ever specified those two; delegating to `view()` had silently also granted Cancel to head-dept / all-readonly / all-report holders. Legacy rendered no Cancel button on any of the three. |
| **2026-08-07** | **Print appears on every detail screen EXCEPT head-dept**, whose legacy Action row is commented out in full. Enabled at status {3,4,5,6}, disabled "Cannot Print" twin otherwise. Legacy's btn-print writes nothing, so neither does ours. |
| **2026-08-07** | **The printed report is LEGACY-EXACT and ignores two on-screen restrictions**: all-report's per-line scope and the Logistic commercial blackout. ⚠️ Raised and accepted: a Logistic-department user can read the commercial columns their screen blanks by pressing Print. See §7 divergences; do not "fix" without going back to the user. |
| **2026-08-07** | **The Change Status dropdown offers all 9 `complainstatus` rows** (legacy parity), Cancel and Reject by CEO included. It stays the only path that can set status 9 (Re-Review). ⚠️ This **overrides the "explicit allowed-transition matrix"** half of Q17 (2026-07-22). The other three halves of Q17 are built: its own Policy (`ComplainPolicy::changeStatus`), a required comment, and an audit row per override. **Still open from Q17: a dedicated menu leaf** — the capability is currently keyed on the View All grant (`/complaints/all`), which is where legacy put it. |

*(Add every future user decision here, dated — `README.md` §5 On-end requires it.)*

---

## 2. Overview & Purpose

A customer receives goods and something is wrong — wrong item, wrong quantity, quality defect,
damaged packaging, or an overpayment. A salesperson files a **Complaint & Returns report**: one
header identifying the company, contact person, division and sales owner, plus one or more
**detail lines**, each naming a product and carrying the commercial paper trail that proves the
claim — delivery note (SJ) number and date, invoice number and date, quantity received vs
quantity complained about, unit price in USD and IDR with the exchange rate, total value at
stake, whether it has been paid, and refund bank details.

Each line also records three narrative fields that are the actual point of the document:
**what went wrong** (`ComplainDesc`), **what the customer wants** (`CustomerExpectation`), and
**what the reporter is asking the company to do** (`ReporterRequestExpectation`).

**The mechanic that defines this module:** at creation, every product line is fanned out to
**all six departments at once** — PM, SM, CS, Finance, Accounting, Logistic — by inserting six
`complaindetailsaction` rows per line. Each department then reviews its own row. Once every
department has reviewed, the complaint reaches the **CEO**, who picks which departments must
actually *act* and gives each one an instruction. Those departments complete their assignment,
and the complaint is closed.

**So this is not an approval ladder.** It is a parallel fan-out to six work queues with a
central dispatcher in the middle. Visit Plan's linear tier chain is the wrong mental model;
the closer analogy is a ticket broadcast to every team, triaged by one person, then worked by
the teams that were picked.

### 2.1 What the whole menu is for (screen-by-screen)

Root **225** holds 28 leaves under 3 group rows; Pengelolaan menu **252** holds one more.
Verified against the dev `menu` table 2026-07-21 (full table in §10).

- **Create** (226) and **Create for Others** (325) — the entry forms. On-behalf is real but
  rare: **1 of 34** live headers has `UserIDInput ≠ UserIDSales`.
- **Review** (group 227) → six per-department queues (228–234).
- **Approval CEO** (235) — the dispatcher. Picks departments, attaches per-department
  instructions, or rejects the whole complaint.
- **Complete Assignment** (group 236) → six per-department queues (237–242).
- **Feedback** (group 243) → six screens (244–249) — ⚠️ **almost certainly dead**, see §9 Q4.
- **Close** (256) — the terminal action, plus two send-back paths.
- **View Request** (250) / **View Head Dept** (422) / **View All** (251) / **View All (Read
  Only)** (326) / **View All Report (Read Only)** (385) — the same list scoped five ways (§4.2).
- **Complain & Returns Type** (253, under Pengelolaan 252) — the `complaintype` lookup CRUD.

---

## 3. Scope

### 3.1 In Scope — nothing built yet

No phase has been scoped or approved. §8 proposes an order; each phase must be confirmed with
the user before it starts.

### 3.2 Explicitly NOT in this pass (2026-07-21)

- Any controller, route, page, policy, request, seeder, migration or test. **Nothing was built.**
- Any change to the models, the dormant mail Blade, or the prototype.

### 3.3 Out of Scope until explicitly scheduled

- **Complaint Handling** (legacy root 363) — a different module. §1 Locked decisions.
- **Live email.** Built in Phase 8 but **not enabled** without sign-off (§1, §8).
- **A dedicated Status-9 (Re-Review) transition.** There is no *workflow* screen that produces
  it — but it is **not** unwritten: the generic admin status-override (§6.7) can set any status
  in the lookup, and **live data carries status 9 on 1 header, 1 line and 11 action rows**. So:
  build no bespoke transition, but the value **must render** everywhere status is displayed
  (§6.4). Treating 9 as non-existent will break the read model on real rows.
- **The two ERP print screens** — `Review/complainprintsuratjalan.php` and
  `complainprintinvoice.php` query a **second, PostgreSQL ERP database**, not the app's MySQL
  (§6.8). **User decision 2026-07-21: out of scope — documented, not ported.**
- **Status 5 (Feedback) as a workflow stage** — §9 Q4.
- **The legacy PDF report** (`reportcomplaint.php`, FPDF). If a printable report is wanted it is
  a new build on the client-side react-pdf convention (CLAUDE.md), not a port.

---

## 4. Users & Permissions — VERIFIED from the legacy source, 2026-07-21

Access follows the project-wide Role/Menu contract (CLAUDE.md): a `rolemenu` grant from the
user's role to a `menu` row, joined on `menu.LinkLaravel`. **Sidebar visibility and the server
403 gate must use IDENTICAL grant conditions**, enforced at the leaf.

> ⚠️ **The legacy has no authorization to port.** Every screen's only gate is an `include` of
> the app-wide `header.php` (deliberately not supplied — `README.md` rule 17); the write handlers
> take the department from `?DepID=` in the URL and never verify the caller may act for it; the
> two "Read Only" screens still carry live cancel and status-change POST handlers. **The scopes
> below are what the legacy uses to decide what to SHOW. The port must additionally ENFORCE
> them server-side** (Policy + `authorize()`), which the legacy never did.

### 4.1 The six department queues — three different rules, not one

Each queue applies **two independent filters**. Filter A is the same everywhere; filter B is not.

**Filter A — the department gate (all six, identical).** An INNER JOIN to a derived table over
`complaindetailsaction` selecting lines where `ComplainDetailsStatusID = 1` AND
`DepartmentID = <the queue's constant>`. A line appears only while *that department's own action
row* is still un-reviewed. ⚠️ The legacy subquery omits `IsDeleted = 0` — the port must add it.

**Filter B — the ownership gate (differs per queue).**

| Queue | Dept code | Ownership predicate | Keyed on |
|---|---|---|---|
| **PM** | 2 | `userprincipal` where `IsHeadDiv=1` AND not deleted AND `UserID` = actor | the **product's** principal (`barang.PrincipalID`) — *not* `complaindetails.PrincipalID` |
| **SM** | 1 | `userdivision` where `IsDeleted=0` AND **`IsHeadDiv=1`** AND `UserID` = actor | the **complaint header's** `DivisionID` |
| **CS** | 8 | `userdivision` where `IsDeleted=0` AND `UserID` = actor — **no `IsHeadDiv`** | the header's `DivisionID` |
| **Finance** | 6 | **NONE** | — |
| **Accounting** | 11 | **NONE** | — |
| **Logistic** | 5 | **NONE** | — |

Three findings worth stating plainly:

1. **`users.DepartmentID` does not bind a user to a queue — but it is NOT unused.** No queue
   consults it (hence the puzzle that no live user carries `DepartmentID` 1 or 2 — as *queue*
   codes those are screen constants, never user attributes). It is nevertheless read in exactly
   one place, and it carries a real rule there: `listcomplainallreportdetailsreadonlyview.php`
   loads the actor's `users.DepartmentID` (lines 6–11) and passes it to four gates
   (lines 107, 118, 176, 222) that suppress **every commercial column** — unit prices, total
   values, USD rate, invoice no/date, bank/account/refund, paid flag — when the actor's
   department is **5 (Logistic)**. **28 live users sit at `DepartmentID = 5`, so this fires.**
   It is the module's only column-level access control. ⚠️ A previous revision of this PRD
   claimed the column was "never used"; porting on that basis would silently drop the rule.
   (Live spread: 12×29, 5×28, 6×13, 4×11, 9×6, 0×6, 8×4, 11×4, 10×2, NULL×1 — **none at 1 or 2**.)
   **Answered 2026-07-22 (§9 Q19):** apply the blackout **module-wide** — every screen in
   Complaint & Returns that would otherwise show a commercial column to a `DepartmentID = 5`
   user suppresses it, not just this one legacy screen. Decision is scoped to this module only.
2. **CS is deliberately weaker than SM** — the two subqueries are otherwise identical, and CS
   simply omits `IsHeadDiv`, so any division member sees the CS queue. Reads as intentional;
   **confirm with the user** (§9 Q9).
3. **Finance, Accounting and Logistic have no ownership scoping at all** — every pending line
   company-wide, to anyone who can open the page. Verified: the only session-variable references
   in those three files sit inside **commented-out Visit Plan leftovers** (`vp.`/`vd.` aliases),
   not live SQL. This is the single biggest access-control decision in the module — **§9 Q9.**

⚠️ Because PM's gate joins through `barang`, any line whose `BarangID` does not resolve to a
`barang` row is silently invisible to PM. Reproduce that only if the user wants it.

The **Complete Assignment** queues (237–242) apply the identical six-way pattern, one status
later (their department gate looks for `ComplainDetailsStatusID = 3`).

### 4.2 The five list views — five different scopes

| View | Menu | Scope predicate | Tables |
|---|---|---|---|
| **View Request** | 250 | `complain.UserIDInput` = actor — **creator only**. ⚠️ It is the **only** one of the five that sanitises the session id (`abs(intval(…))`) | `complain` |
| **View Head Dept** | 422 | `complain.UserIDSales` IN (actor + the actor's full **`users.HeadID` subtree**, computed recursively) | `complain`, `users` |
| **View All** | 251 | unscoped (`IsDeleted = 0` only) | `complain` |
| **View All (Read Only)** | 326 | unscoped — the view file differs from View All's **only on lines 2–3** (the two link targets: `HrefURL`, `paginationUrl`). ⚠️ Not "byte-identical" as an earlier revision claimed — 23,169 vs 23,153 bytes | `complain` |
| **View All Report (Read Only)** | 385 | complaints the actor holds an **assignment row** on (`complaindetailsassignment.userid`) — "things that passed through my hands". **Plus two scopes the other four do not have**, see below | `complain`, `complaindetailsassignment` |

⚠️ **View Request scopes on `UserIDInput` while View Head Dept scopes on `UserIDSales`.** A head
who *creates* a complaint whose sales owner sits outside their subtree sees it in one list and
not the other. Verified asymmetry — decide deliberately rather than inheriting it.

⚠️ **"Read only" is a scope difference for one of the two.** For **View All (Read Only)** the
label is honest: same query, the difference is only which actions the linked detail page renders
— and in the legacy those actions are merely *hidden*, not blocked (§7). For **View All Report
(Read Only)** it is not: that screen is simultaneously read-only **and the most heavily scoped
variant in the module**, because its detail page is a *fork* of the shared one
(`listcomplainallreportdetailsreadonlyview.php`, where the other four include the shared
`listcomplaindetailsview.php`) and adds two rules found nowhere else:

1. **Detail lines are assignment-scoped too** — an inner join on
   `complaindetailsassignment.userid = actor` restricts which *lines* of a complaint are shown.
   The other four detail pages show every line.
2. **Commercial columns are hidden from Logistic** — the `users.DepartmentID != 5` gates of
   §4.1 #1.

⚠️ **All five lists render an IDENTICAL column set** (ReportNo · Details · ComplainType ·
CompanyName · Status · Creator · Division · ComplainDate · InputDate · Address · Telp). They
differ **only** in scope. `UserIDSales` is SELECTed by every one of them and **rendered by none**
— it appears only on the detail page. (An earlier revision said View Request "displays" it.)

The **CEO** queue (235) filters on header status = 2 with **no user scoping**; the **Close**
queue (256) on header status = 4, likewise unscoped. Both need a real grant + Policy in the port.

### 4.3 Cancel and Close — VERIFIED 2026-07-21

Both rules are real; they simply live in the **button render conditions**, not in the POST
handlers (which is why a handler-only reading of the source misses them).

| Action | Status window | Who |
|---|---|---|
| **Cancel** → 8 | header status **1, 2 or 3** | the **creator**, via View Request — that screen is scoped to `complain.UserIDInput` (§4.2), so the only complaints a user can reach there are their own. The same button also renders on **View All**, which is unscoped — so anyone holding the View-All grant can cancel anyone's complaint. Outside the window a "Cannot Cancel" placeholder is shown |
| **Close** → 6, plus **Back to Review** → 1 and **Back to Approval CEO** → 2 | header status **4** only | **whoever holds the menu grant for menu 256.** The Close queue applies no user scoping at all, so this is purely a `rolemenu` question — exactly the legacy's intent |

⚠️ **On the two "Read Only" detail screens the Cancel button is commented out, but the POST
handler is still live** — a forged or replayed request still cancels. That is a defect (§7), not
a permission rule: in the port, hiding a button is never a control.

**Port guidance:** implement Cancel as `creator OR view-all grant`, gated on status ∈ {1,2,3},
enforced in a Policy; implement Close/send-back as a pure menu-grant capability gated on status
4. Both need the server-side check the legacy never had.

**Unresolved:** which roles get which of the 29 leaves (§9 Q8).

---

## 5. Data Model — VERIFIED against dev DB, 2026-07-21

Six data tables + four lookups, all `ID int auto_increment` PK + manual `IsDeleted`,
`$timestamps = false`. **Every one of the six data tables has zero soft-deleted rows.**

### 5.0 Header + lines + a per-department matrix + two history systems

```
complain (header, 34)
├── complainassignment (197)                  ← header history
└── complaindetails (35, the product lines)
    ├── complaindetailsassignment (785)       ← line history, carries DepartmentID
    └── complaindetailsaction (210 = 35 × 6)  ← ONE ROW PER (line × department)
        └── complaindetailsactionassignment (982)   ← history of an action row
```

**`complaindetailsaction` is the real state machine.** Its `ComplainDetailsStatusID` is the
per-department state; `complaindetails.ComplainDetailsStatusID` and
`complain.ComplainStatusID` are **roll-ups** derived from it (§6.3).

### 5.1 `complain` — the header (34 rows) — model `App\Models\Complain`

| Column | Type | Notes |
|---|---|---|
| `ID` | int AI | PK |
| `IsDeleted` | int | 0 on all 34 rows |
| `ComplainStatusID` | int NULL | FK → `complainstatus` |
| `ComplainTypeID` | int NULL | FK → `complaintype` — where "Returns" lives |
| `ComplainReasonID` | int NULL | FK → `complainreason` — ⚠️ **`0` on all 34 rows, §5.8 #1** |
| `DivisionID` | int NULL | FK → `division` — **derived from the company** on the main create form |
| `CompanyID` / `CompanyCPID` | int NULL | FK → `company` / `companycp` |
| `ComplainDate` | datetime | the customer's complaint date — live range **2019-04-16 … 2024-03-25** |
| `InputDate` | datetime | server clock at filing (`Asia/Jakarta`) — distinct from `ComplainDate` |
| `UserIDInput` / `UserIDSales` | int NULL | creator / salesperson — differ on **1 of 34** |
| `CompanyAddress` | varchar(500) | ⚠️ AJAX-prefilled from the company but **user-editable**; a frozen snapshot |
| `Telp` | varchar(200) | ⚠️ likewise |
| `UploadName/Type/Size/Content` | varchar / longblob | legacy attachment quad — **unused in live data, §5.8 #5** |
| `RemarkCEO` | varchar(500) | written **only** on CEO reject; set on 1 row |
| `CountAssignFromCEO` | int | ⚠️ **write-only** — set to the number of rows the CEO ticked, and **read nowhere** in the entire legacy codebase. Max 12 live |

**FKs enforced on dev** (all RESTRICT): `ComplainStatusID`, `ComplainTypeID`, `ComplainReasonID`,
`DivisionID`, `CompanyID`, `CompanyCPID`, `UserIDInput`, `UserIDSales`.

### 5.2 `complaindetails` — the product lines (35 rows)

34 headers, 35 lines — live complaints are almost always single-line, but the schema, the menus
and the prototype are all multi-line. Do not collapse it.

| Column | Type | Notes |
|---|---|---|
| `ComplainID` | int NULL | FK → `complain` |
| `ComplainDetailsStatusID` | int NULL | FK → `complaindetailsstatus` — a roll-up (§6.3) |
| `PrincipalID` + `PrincipalName` | int + varchar(400) | FK → `principal`, **plus a denormalised snapshot of the dropdown label** |
| `BarangID` + `ProductName` | int + varchar(400) | FK → `barang`, **plus** a name snapshot |
| `ApplicationID` | int NULL | FK → `application` |
| `QuantityPacking` | decimal(20,5) | |
| `SatuanPackingID` | int NULL | ⚠️ FK → **`quotationpack`**, NOT `satuan` — §5.8 #2 |
| `BarangListID` | varchar(200) | ⚠️ **free-text lot number, not a foreign key** — §5.8 #3 |
| `ComplainQuantity` | decimal(20,5) | ⚠️ **uncapped** — §5.8 #4 |
| `SatuanComplainQuantityID` | int NULL | FK → `satuan` |
| `UnitPriceUSD` / `UnitPriceIDR` / `USDRate` | decimal(20,10) | ⚠️ **10 decimal places** |
| `SatuanUnitPriceUSD` | int NULL | FK → `satuan` |
| `TotalValueUSD` / `TotalValueIDR` | decimal(20,10) | stored, not recomputed on read |
| `SJNo` / `SJDate` / `InvoiceNo` / `InvoiceDate` | varchar(200) / datetime | the paper trail |
| `BankName` / `BankAccountName` / `BankAccountNumber` | varchar(200) | refund destination — free text, **no FK to `bank`**; required by the legacy only when type = 4 (Refund) |
| `TotalRefund` | decimal(20,5) | ⚠️ 5 decimals, unlike the prices |
| `IsPaid` / `PaidDate` | int / datetime | ⚠️ legacy always writes `IsPaid = 1` — §7 |
| `ComplainDesc` / `CustomerExpectation` / `ReporterRequestExpectation` | varchar(500) ×3 | the narrative core |

**FKs enforced on dev:** `ComplainID`, `ComplainDetailsStatusID`, `PrincipalID`, `BarangID`,
`ApplicationID`, `SatuanPackingID` (→ `quotationpack`), `SatuanComplainQuantityID` (→ `satuan`),
`SatuanUnitPriceUSD` (→ `satuan`). **No 0-sentinels and no NULLs** — this table is clean.

### 5.3–5.6 The history and action tables

- **`complainassignment`** (197) — `ComplainID`, `ComplainStatusID`, `Tanggal`, `UserID`,
  `Comment` (**text**). The house `<entity>assignment` pattern. ~5.8 rows per header.
- **`complaindetailsassignment`** (785) — adds `ComplainDetailsID`, **`DepartmentID`**, and
  **five** remark columns: `ReviewRemark`, `AssignRemark`, `DoneRemark`, `FeedbackRemark`,
  `Remark`. **One column per lifecycle stage** — §6.2 says which stage writes which.
  `DepartmentID` distribution: `0`×86, `1`×129, `2`×125, `5`×115, `6`×109, `8`×109, `11`×112.
- **`complaindetailsaction`** (210) — the (line × department) matrix. Same four stage remarks,
  plus the legacy upload quad, `Tanggal`, `UserID`. **Exactly 35 × 6, no gaps** — because the
  six rows are created eagerly at complaint creation (§6.1).
- **`complaindetailsactionassignment`** (982) — history of an action row.
  ⚠️ **`DepartmentID` is `0` on all 982 rows** — dead weight that still violates its own FK.
  **Mechanism (verified):** the column exists in the table, but it is **not in the INSERT column
  list of any legacy write path**, so every row takes the DB default `0`. The department of such
  a row is knowable only transitively, via `ComplainDetailsActionID → complaindetailsaction.
  DepartmentID` — and on a repeat submit even that FK is left NULL, making those rows
  unattributable. **Port rule:** either write a real code or drop the column from the model's
  concerns; never resolve it through `Department` (§5.9).

### 5.7 Lookups

- **`complainstatus`** (9) and **`complaindetailsstatus`** (9) — **identical vocabularies**:

  | ID | Name | | ID | Name |
  |---|---|---|---|---|
  | 1 | Request | | 6 | Close |
  | 2 | Review | | 7 | Reject by CEO |
  | 3 | Assign | | 8 | Cancel |
  | 4 | Done | | 9 | Re-Review ⚠️ **no workflow writer — but present in live data** |
  | 5 | Feedback ⚠️ **never reached in live data** | | | |

  ⚠️ Both column names are **`StatusName`** (not `ComplainStatusName` / `…DetailsStatusName`).
  Neither 5 nor 9 has a workflow screen; both are reachable only via the admin status-override
  (§6.7). The difference between them is empirical, not structural: **5 has never been used
  (0 rows anywhere, `FeedbackRemark` non-empty on 0 of 785), while 9 has** (1 header, 1 line,
  11 action rows). Do not model either as dead; model 5 as unused and 9 as live-but-orphaned.

  Live header distribution: **1**×13, **2**×3, **3**×5, **4**×5, **6**×2, **7**×1, **8**×4,
  **9**×1. Lines match except **2**×4 — header and line status can diverge.

- **`complaintype`** (6 rows, 1 soft-deleted) — **where "Returns" lives**:

  | ID | Name | Live headers | Note |
  |---|---|---|---|
  | 1 | Complaint | 25 | |
  | 2 | Retur Barang *(goods return)* | 2 | |
  | 3 | Tukar Barang *(goods exchange)* | 1 | |
  | 4 | **Refund** | 5 | ⚠️ a **mode switch** — see §6.5 |
  | 5 | Others | 1 | |
  | 6 | ~~www~~ | — | `IsDeleted=1`, junk |

- **`complainreason`** (6) — Overpayment, Purchased Wrong Item, Quality Problem, Quantity
  Problem, Packaging Problem, Received Wrong Item. **Referenced by zero live records** despite
  the legacy form requiring it (§5.8 #1). No `ID = 0`; min ID is 1.

### 5.8 ⚠️ Schema traps

1. **`ComplainReasonID = 0` on 100% of headers — and the legacy says that should be
   impossible.** No `complainreason.ID = 0` exists, yet dev enforces the FK with RESTRICT, so
   those 34 rows violate their own constraint and a **new** insert copying the sentinel dies
   with `SQLSTATE[23000]`. Prod probably lacks the FK — a dev-only failure that looks
   mysterious. ⚠️ The legacy create screen **does** render a reason dropdown from
   `complainreason` and **does** include it in the submit guard (and PHP's `empty('0')` is true,
   so the "Select…" placeholder is rejected). So the existing rows predate the field or came
   from an older revision. **Resolution: collect it, write a real FK, never `0`.**
2. **`SatuanPackingID` is not a `satuan`** — it is FK'd to **`quotationpack`** (`ID`,
   `QuotationPackName` — bag/box/drum/kg), while `SatuanComplainQuantityID` and
   `SatuanUnitPriceUSD` really do point at `satuan` (`SatuanName`). The legacy **create** form
   agrees with the FK (it populates that field from `quotationpack`), but every legacy **display**
   query joins it against `satuan` — so the packing unit shown on the assignment screens and in
   the PDF is whichever `satuan` row happens to share an ID. **Port the `quotationpack` relation;
   do not port the join.** ⚠️ The dormant mail Blade repeats the same error. Live values used:
   1, 2, 3, 4, 5, 14, 15, 18. Same lesson as [[company-product-pivots]].
3. **`BarangListID` is a free-text lot number, not a foreign key.** It was once fed by a lot
   picker (`getlotnumber.php`, returning `baranglists.ID`), but **every call site is commented
   out** and the form is now a plain text input whose typed string is written into the column.
   Expect a mix of genuine ids and free text. Model it as a string; do not add a relation.
4. **Complaint quantity is uncapped.** `getmaxquantity.php` looks like a cap but (a) is never
   called — its JS wrapper has no trigger and its target element does not exist — and (b) reads
   **warehouse stock for a lot**, not the delivered or invoiced quantity. There is no check
   anywhere that `ComplainQuantity ≤ QuantityPacking`. **Whether to add one is a user decision
   (§9 Q12), not a port.**
5. **The upload quad is unused in live data** (0 of 34, 0 of 210) even though the legacy create
   form writes it. **Good news:** no legacy-BLOB fallback is needed — go straight to the
   `file_uploads` convention (CLAUDE.md), and ask the user for the `{menu_name}` (§9 Q6).
   ⚠️ The legacy stores bytes **in the database** as a BLOB; the project convention is bytes on
   a private disk, metadata in columns, written after commit.
6. **A hidden menu group orphans its visible children.** Menu **243** "Feedback" is `IsShow=0`
   while its six children are `IsShow=1`. `Menu::navTreeForRole()` filters `IsShow=1` across the
   **whole** tree including group rows, then builds children from surviving parents — so the six
   Feedback screens cannot appear in the sidebar as seeded. Menu **232** is likewise a dead
   `IsShow=0` duplicate of **233**.
7. **The header freezes `CompanyAddress` and `Telp`.** Both are AJAX-prefilled from the company
   but remain editable, so the stored value is a deliberate snapshot. Display it; do not join
   `company` to "correct" it ([[userid-sales-snapshot]]).
8. **The models are auto-generated — now INSPECTED (2026-07-21), not merely "unverified".**
   Attribute style (`#[Table]`/`#[Fillable]`), so `protected $table` greps find nothing.
   **Do not regenerate.** What the inspection actually found:
   - ✅ **`ComplainDetails::satuanPacking()` is CORRECT** — it already targets
     `QuotationPack::class` (`ComplainDetails.php:86`), matching the real FK. ⚠️ Earlier
     revisions of this PRD, the README and the BUILD PROMPT all warned that this relation
     "encodes trap #2". **It does not.** The wrong `satuan` join lives in the legacy SQL and in
     the dormant mail Blade — not in the model.
   - 🔴 **Three `department()` relations DO encode §5.9** — `ComplainDetailsAssignment.php:68`,
     `ComplainDetailsAction.php:68`, `ComplainDetailsActionAssignment.php:74`, each
     `belongsTo(Department::class, 'DepartmentID', 'ID')`. On 1,215 live rows that relation
     resolves to nothing. **Remove or rename all three**; resolve the code through a module
     constant instead.
   - 🔴 **No datetime casts and no decimal casts anywhere.** `casts()` covers `IsDeleted` +
     the FK ints only. Money/quantity columns therefore arrive as **strings**, and `IsPaid` is
     not cast to bool. Adding a naive `'datetime'` cast is actively unsafe — see trap #9.

9. **⚠️ MySQL zero-dates live in `complaindetails` — and they fail SILENTLY, not loudly.**
   Measured 2026-07-21: `SJDate` **×4**, `InvoiceDate` **×4**, `PaidDate` **×11** hold
   `0000-00-00 00:00:00`. **Not one datetime column in the family is ever NULL** — the legacy
   writes the zero-date instead of NULL. Consequences, all tested rather than assumed:
   - `Carbon::parse('0000-00-00 00:00:00')` **does not throw** — it returns **`-0001-11-30`**.
     So a `'datetime'` cast produces a plausible-looking Carbon object holding year −1. This
     passes every smoke test and corrupts only the specific records that carry it.
   - The dev connection's `sql_mode` is
     `ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION`
     — i.e. **`NO_ZERO_DATE` is absent**, so dev currently *accepts* these writes. MySQL 8.4's
     stock default does include it, so this is server configuration, not a guarantee. An
     edit-and-save round trip that echoes the value back is one `sql_mode` change from failing.
   - **Port rule:** treat the zero-date as "no date" at the boundary (cast to `null`), never
     write it back, and never add a bare `'datetime'` cast to `SJDate`/`InvoiceDate`/`PaidDate`
     without that guard. This is the date-shaped twin of the 0-vs-NULL rule in trap #1.
10. **🔴 The CEO assign path is BROKEN in the supplied revision — do not port it as written.**
    `listcomplaindetailsceo.php` matches ticked departments with
    an equality test between the posted detail-id array and the ticked-checkbox indexes, where
    the detail-id array comes from a POST field named **`CID`**. But that field is emitted
    **only inside the commented-out old renderer**
    (`listcomplaindetailsviewceo.php:399`); the live renderer (`:467-792`) never emits it. So on
    the code as shipped: **no `complaindetailsaction` row, no `complaindetails` row and no
    per-line history is written, and no assign email is sent** — yet the header **still**
    advances to status 3 and `CountAssignFromCEO` is still set. A second regression rides along:
    the checkbox `value` counter is re-initialised **inside** the per-line loop (`:727`), so
    values restart at 0 on every line while the payload arrays stay flat — index aliasing across
    lines. **Live data proves it used to work** (65 action rows carry `AssignRemark`; 15 sit at
    status 3), so this is a regression introduced by the renderer rewrite, not the design.
    ➜ **Port the INTENT documented in §6.2/§6.4 — one write per (line × department) — never the
    shipped matcher.** And do not conclude from reading this file that assignment "doesn't
    write action rows".
11. **`header.php` — FOUND and read, 2026-07-21.** It was long recorded as "deliberately not
    supplied". A copy is present in a sibling module's curated legacy folder
    (`MenuBudgetAndTargets/Just An Example, Do not Link/Additional File/header.php`, git-ignored
    like ours). Treating it as the same app-wide file is justified: the three helpers present in
    **both** curated folders (`getcompanydetail`, `getcompanydivision`, `getpmapproval`) are
    **byte-identical**, so these folders are extracts of one flat legacy directory. Two verified
    facts, both load-bearing:
    - **The `rolemenu` authorization check is computed and discarded.** It joins `menu` +
      `rolemenu` for the user's `RoleID` and sets `$cek = false/true` — and **`$cek` is never
      read again** (its only three occurrences are a comment, the init, and the assignment).
      **Any authenticated legacy user could open any screen regardless of grants.**
    - **The authentication redirect does not halt.** The anonymous path issues a PHP `Location`
      redirect to the login page, and the file contains **no `exit`/`die` at all** (count: 0),
      so execution continues into whatever included it.
    ➜ This finally explains §4's "the legacy has no authorization to port": it is not that the
    gate lived somewhere unseen — **the gate was written and then ignored.** The port builds
    authorization fresh on the `rolemenu` contract (CLAUDE.md), and this is the reason it must.

### 5.9 `DepartmentID` — the lying FK

Every `DepartmentID` column carries a legacy **department code**, now confirmed three ways in
the source (the create screen's six-iteration loop; each queue's hardcoded constant; the label
switch in the write handlers):

| Code | Department | | Code | Department |
|---|---|---|---|---|
| 1 | SM | | 6 | Finance |
| 2 | PM | | 8 | CS |
| 5 | Logistic | | 11 | Accounting |

The FK points at `department.ID`, whose rows run **1–10 with no 11**. Measured violation count
on dev: **1,215 rows** — 198 in `complaindetailsassignment` (86 at `0`, 112 at `11`), 35 in
`complaindetailsaction` (at `11`), all 982 of `complaindetailsactionassignment` (at `0`).
Treat the six codes as a **module constant**; never resolve them through `Department`
([[dev-db-fk-drift]]).

---

## 6. The Workflow — VERIFIED from the legacy source, 2026-07-21

### 6.1 Creation

One transaction. Inserts, in order: `complain` (1) → `complainassignment` (1) → then per
product line: `complaindetails` (1) → `complaindetailsassignment` (1) → **`complaindetailsaction`
(6, one per department code 1, 2, 5, 6, 8, 11)** → `complaindetailsactionassignment` (6).
**Total = 2 + 14N rows for N lines.** Everything starts at **status 1 (Request)**; the header
history comment is a fixed string.

**The six action rows are created eagerly — this is the module's defining fact.** No department
is "routed to" later; all six are live from the moment the complaint exists, which is exactly
what populates all six Review queues. **No email is sent on create** (the only mail call is
commented out, and it is a copy-paste of a Quotation function).

**Three creation rules an earlier revision omitted, all verified in `createcomplain.php`:**

1. **Who you may file against is scoped.** The Company dropdown is not "all companies" — it is
   restricted to companies whose sales owner is the actor **or anyone in the actor's full
   recursive `users.HeadID` subtree** (`:1334-1339`, using the same recursion helper as View
   Head Dept). So a create-time access rule exists and must be ported alongside the list scopes. The
   option label appends the owner's name when the company belongs to a subordinate.
2. **The visible "Sales" dropdown is discarded.** The form renders a Sales `<select>` listing
   every non-deleted user (`:1354-1359`), but `InsertSales` is **never read server-side** —
   `UserIDSales` is hardcoded to the creator (`:20`). That is why only **1 of 34** headers has
   `UserIDInput ≠ UserIDSales`: the sole on-behalf path is `createcomplainothers.php`, where
   `UserIDSales` *is* taken from the form and checked against nothing (§7).
3. **`DivisionID` is derived on the main form and free on the on-behalf form.** `createcomplain`
   fills a read-only Division from the chosen company; `createcomplainothers` makes Division the
   **first** field of the cascade (Division → Sales → Company → CP), scoped to the creator's own
   `userdivision` rows. Consequence: on the on-behalf screen `complain.DivisionID` can
   contradict `company.DivisionID`, and nothing reconciles them.

⚠️ The create handlers **do** use PDO prepared statements with bound parameters — the raw-SQL
problem in this module is concentrated in the *list/search* queries and the helpers, not in the
create writes. (They also pass every header value through `mysqli_real_escape_string` **before**
binding it, which double-escapes; no live row shows the backslash fingerprint, so treat it as
noise to drop, not as data to migrate.)

### 6.2 Which stage writes which remark column

| Stage | Status | Written by | Remark column |
|---|---|---|---|
| Review | 2 | a department reviewer | `ReviewRemark` |
| Assign | 3 | **the CEO** | `AssignRemark` |
| Done | 4 | a department completing its assignment | `DoneRemark` |
| Feedback | 5 | *(admin status-override only — §9 Q4)* | `FeedbackRemark` |
| Reject | 7 | the CEO | `complain.RemarkCEO` |

`complaindetailsactionassignment` uses a generic `Remark`. Each stage writes a history row **and**
updates the department's own `complaindetailsaction` row — the action row is **UPDATEd, never
inserted**, outside creation.

### 6.3 ⚠️ Promotion is complaint-wide, not per-line

Every promotion in the legacy is gated by a `COUNT(*)` over the whole complaint and applied with
`WHERE ComplainID = …`, never `WHERE ID = <line>`:

- **Review → 2:** when **no** `complaindetailsaction` row anywhere on the complaint is still at
  status 1, **every** line flips to 2 at once, then the header follows.
- **Done → 4:** when no action row is still at status 3, every line flips to 4, then the header.

**There is no genuine per-line lifecycle**, despite the schema supporting one. A line whose six
departments have all finished waits for every other line on the complaint. ⚠️ Neither count
filters `IsDeleted = 0`, so a single soft-deleted row at the blocking status stalls the complaint
permanently. **Whether to keep complaint-wide promotion is a user decision (§9 Q13)** — it is
arguably a bug, but changing it changes user-visible behaviour.

### 6.4 The verified state machine

```mermaid
flowchart TD
    A[Sales files report] -->|status 1 Request<br/>6 action rows per line| B[Six department queues]
    B -->|each dept reviews its own action row<br/>ReviewRemark| C[status 2 Review<br/>only when ALL depts done]
    C --> D{CEO}
    D -->|picks depts + per-dept instruction<br/>AssignRemark| E[status 3 Assign]
    D -->|reject whole complaint<br/>RemarkCEO| R[status 7 Reject by CEO]
    E -->|each assigned dept completes<br/>DoneRemark| F[status 4 Done<br/>only when ALL assigned depts done]
    F -->|Close| G[status 6 Close]
    F -->|Back to Review — resets ALL action rows| A
    F -->|Back to Approval CEO| C
    A -->|Cancel| X[status 8 Cancel]
    F -.->|status 5 Feedback: admin override only,<br/>never used — Q4| Z[ ]
```

**Notes on the branches:**
- The **Close** screen is reachable only for headers at status 4, and offers three actions:
  Close (6), Back to Review (resets the header, lines and **all** action rows to 1), and Back to
  Approval CEO (2).
- **Cancel (8)** is written by the *detail* screens, not by a queue. **It IS gated** — the gate
  lives in the button's render condition, not in the POST handler: the Cancel button is rendered
  only when the header is at **status 1 (Request), 2 (Review) or 3 (Assign)**, and otherwise a
  disabled-looking "Cannot Cancel" button is shown instead. **Who** may cancel follows from which
  detail screen renders it (§4.3).
- **Close (6)**, **Back to Review** and **Back to Approval CEO** are all rendered only when the
  header is at **status 4 (Done)** — a single guard wrapping all three buttons on the Close
  screen.
- **Status 9 (Re-Review) has no *workflow* writer** — the "send it back" behaviour is implemented
  as a reset to status **1**, not 9. But 9 **is** reachable through the admin status-override
  (§6.7) and **exists in live data** (1 header, 1 line, 11 action rows). Build no transition for
  it; still render it. ⚠️ Do not "treat 9 as a dead lookup value" — an earlier revision of this
  PRD said exactly that while its own distribution table recorded `9×1`.

### 6.5 Type 4 (Refund) is a mode switch

When the complaint type is **Refund**, the legacy makes the four bank/refund fields mandatory,
and **locks the complaint to refund-only or non-refund once the first line is added** — the two
cannot be mixed in one complaint. This is a real business rule and the prototype does not model
it at all (`README.md` rule 21).

⚠️ **Both halves are enforced in JavaScript ONLY — there is no server-side counterpart.** The
lock is `hidecomplainttype()`, which merely **hides `<option>` elements** (it does not disable
the select, and option-hiding is not supported in Safari); the mandatory-fields rule lives in
the add-row guard, which refuses to append a line unless the four refund fields are non-empty.
The server's submit guard checks only type / reason / division / company / CP / date / at-least-
one-product — it never looks at the bank fields and never re-checks the lock. A crafted POST
mixes refund and non-refund lines freely. **The port must enforce this in a FormRequest** —
it is exactly the "re-validate everything the legacy only checked in JavaScript" case
(`README.md` rule 15). Note also that the bank/refund quad is stored **per detail line**, so an
N-line refund complaint duplicates one bank account N times.

### 6.6 Email — what the legacy actually sends

| Trigger | Function | Timing |
|---|---|---|
| Department completes an assignment | `sendEmailComplainAssign` | post-commit (`assigncomplain.php:276-280`). ⚠️ the *draft* "Submit Remark Only" branch sends nothing |
| CEO assigns | `sendEmailComplainCEO` | post-commit, **once per (line × department) row** — *not* once per department. 3 lines × 2 depts = **6** emails. ⚠️ Unreachable today: trap #10 leaves the send list empty |
| CEO rejects | `sendEmailComplainReject` | ⚠️ **pre-commit** — a defect (§7). Worse, the mailer builds its body from the same connection **inside** the open transaction, so it reads uncommitted state; a rollback leaves a sent email describing a state that never existed |
| Close | `sendEmailComplainClose` | post-commit (`closecomplain.php:108-112`) — **only** the Close branch |
| **Create** | — | **nothing.** `sendEmailComplain` exists in the mail library but **no screen calls it** |
| Review / Submit-Remark-Only / **Cancel** / **Back to Review** / **Back to Approval CEO** | — | nothing |

Two consequences worth stating plainly:

- A "complaint created" notification would be **new behaviour**, not a port (§9 Q10) — and the
  legacy `sendEmailComplain` has a **hardcoded recipient**.
- **The two send-backs notify nobody.** When a complaint bounces from Close back to Review or
  back to the CEO, the creator, the sales owner and all six departments are simply never told.
  Whether the port should fix that is a user decision (§9 Q16), not a port detail.

⚠️ There is a **fifth** email path that is not a workflow stage: the admin status-override
(§6.7) fires `sendEmailComplainReject` (status 7), `sendEmailComplainClose` (status 6) and
`sendEmailComplainCEO` (status 3) directly — the last in a **nested loop over every posted line
× all six departments**, i.e. 60 emails from one click on a 10-line complaint, driven by ids the
client supplies.

### 6.7 The admin status-override — an undocumented sixth write path

Alongside the six workflow screens, the **detail** pages carry a generic
"pick any status + type a comment + Change" control (`btn-change`). It is not a stage; it is a
back door, and it matters for three reasons:

- It is the **only writer of status 5 (Feedback)** and the only way status **9 (Re-Review)** can
  have reached live data — the two statuses with no workflow screen.
- Its dropdown is bound to the **whole** `complainstatus` lookup, so it can set *any* status,
  from any status, with no transition rules.
- Its entire guard is `status != 0 && comment != ''`. No role check, no ownership check, no
  current-status check, no CSRF.
- It fans out per-department rows itself (codes 1/2/5/6/8/11) and routes the comment into the
  stage-appropriate remark column: 2→`ReviewRemark`, 3→`AssignRemark`, 4→`DoneRemark`,
  5→`FeedbackRemark`, else the generic `Remark`.

**Port guidance — answered 2026-07-22 (§9 Q17):** do **not** reproduce it as a general capability.
Rebuild it as a gated administrative correction path: its own menu leaf, its own Policy, an
explicit allowed-transition matrix, and an audit row that says who overrode what.

### 6.8 The two ERP print screens — OUT OF SCOPE (user decision, 2026-07-21)

`Review/complainprintsuratjalan.php` and `Review/complainprintinvoice.php` are reached from
buttons on the Review detail page and **do not query the application database at all** — they
open a **second, PostgreSQL ERP connection** (`DBHandlerPostGres()`) and read the accounting
system's delivery-note / invoice tables (`vf04_fak2`, `vf04_fak`, `vf04_faktur`, `t04_*`,
`t05_*`, `f04_001`), keyed on `?Nomor=` concatenated raw into the SQL.

Recorded because it is real and because nothing else in the ruling mentioned a second database:

- **Button visibility is department-conditional** — Surat Jalan is hidden from PM (dept 2);
  Invoice is hidden from PM **and** Logistic (dept 5).
- **The Invoice button passes `SJNo`, not `InvoiceNo`** — a live bug; the invoice page looks the
  value up as `sjdetails.fak_no`, and the `InvoiceNo` the screen displays is never used.
- They `include_once('header.php')`, so they inherit the same (non-halting, non-enforcing) gate
  as everything else — but there is **no check that the requested document relates to any
  complaint the actor may see**, and the parameter is injectable against the ERP database.

**Decision: out of scope. Documented, not ported.** The port does not open an ERP connection.
If printing SJ/invoice from a complaint is ever wanted it is a new, scoped integration with its
own authorization story — reopen it as a fresh decision, not as a port of these two files.

---

## 7. Legacy defects — do NOT reproduce (verified 2026-07-21)

Per Aturan Absolut #0c, these are implementation faults, not business rules.

### 7.0 Print Complaint — what the port changed, and the one thing it deliberately did not

Built 2026-08-07 from `reportcomplaint.php` (FPDF landscape A4 cm → `@react-pdf/renderer`;
payload in `App\Support\ComplainReportPayload`, layout in
`Components/MenuComplaintAndReturns/pdf/ComplaintReportPdf.jsx`).

**Legacy defects fixed:**
- the details query has **no `IsDeleted` filter**, so the legacy PDF prints soft-deleted lines.
  Ours filters `IsDeleted = 0` on both lines and history rows.
- zero-dates go through `strtotime('0000-00-00')` and print garbage. Ours renders `—` (Q18).
- the Packing unit is joined from **`satuan`**, but `SatuanPackingID` is FK'd to
  **`quotationpack`** (§5.8 #2). The legacy therefore prints whatever `satuan` row happens to
  share that id. ⚠️ **Visible change on every row** of the printed document.
- the header SELECT lists `c.CompanyAddress` **and** `cy.CompanyAddress` under the same bare
  key, so PDO's last-duplicate-wins makes the address fall back to the **company master**, not
  the complaint's create-time snapshot the detail screen shows. Reproduced as-is (the printed
  document keeps reading the master address); flagged here because it is not obvious from the
  source, and flipping it to the snapshot is a one-line change if wanted.

**⚠️ NOT fixed, by explicit user decision (2026-08-07) — the report is legacy-exact:** it applies
neither all-report's per-line scope nor the Logistic commercial blackout (§4.1 #1, Q19). A
Logistic-department user can therefore read the prices/invoice/bank/refund their screen blanks by
pressing Print, and an all-report user prints lines the screen does not show them. This was
raised before implementation and accepted. `ComplainReportPayload` is deliberately separate from
`LoadsComplainDetail::loadFullDetail()` so the two can never be merged by accident; both
behaviours are locked by `tests/Feature/ComplainDetailActionsTest.php`.

**Security**
- `downloadfile.php` takes the **table and column names from the query string** and interpolates
  them into SQL — arbitrary-table BLOB read, with no session check. Replace with a gated route +
  `Storage::disk('file_uploads')`.
- `reportcomplaint.php` (the PDF) includes no session bootstrap at all.
- **9 of the 17 `Additional File/` helpers include only the DB handler** (no session bootstrap
  at all): `downloadfile`, `getcompanyaddress`, `getlotnumber`, `getmaxquantity`,
  `getpmapproval`, `getsatuan`, `getuserbydivision`, `getuserdivision`,
  `listreviewcomplainhistorypopup`. Only **3 of 17** use bound parameters; five concatenate
  `$_REQUEST` with no sanitisation at all. `getuserbydivision.php` will enumerate every user in
  the company when handed an empty `parent_id`.
- **Raw SQL concatenation — but not where an earlier revision said.** There is **no text search
  in this module at all** except one box: `Complain Type/listcomplaintypeview.php:47` builds
  a wildcard name match from raw input. Everywhere else the filters are raw-concatenated
  **numeric ID equality** (complaint id, principal, product, sales, company, division), and the
  sort **column**, sort
  **direction** and **page size** (`$_GET['orderby' | 'order' | 'npp']`) go raw into
  `ORDER BY … LIMIT …` on every list page. Note the inversion worth remembering: the *session*
  id is often defended (`abs(intval(…))`) while every *attacker-controlled* value is not.
- No CSRF token anywhere.
- **No authorization at all:** the department comes from `?DepID=` and is never checked against
  the caller; list scoping is presentational; the "Read Only" screens keep live cancel and
  status-change handlers behind hidden buttons.
- The on-behalf create screen has **no permission check** and does not validate the chosen sales
  user against the creator's permitted set. `InsertUserIDS` is absent from the submit guard
  entirely, so a complaint can be filed with **`UserIDSales = 0`** (a non-existent user), or with
  any user id posted directly.
- **Three live arbitrary-status-change endpoints with no UI.** The admin status-override
  (`btn-change`, §6.7) is a live handler in **four** detail files but rendered in only **one**
  (View All). In View Head Dept, View All (Read Only) and View All Report the control was
  deleted from the page while the handler stayed — reachable only by forged POST. Its entire
  guard is "status ≠ 0 and comment ≠ ''". Likewise a `btn-submit` handler is live in all five
  detail files behind a commented-out button, and writes rows with `ComplainID = NULL`.
- **The five detail screens have no ownership predicate of their own.** Every one loads by
  not-deleted plus the id from the query string, and nothing else. The five "scopes" of §4.2
  exist **only on the list
  pages** — any authenticated user can open, and POST to, any complaint id through any of them.
  Textbook IDOR.
- **The guard idiom redirects without halting.** Throughout the module a failed parameter check
  issues a PHP `Location` redirect and then **keeps executing** — there is no `exit`/`die`. Combined with `ob_start()`, the browser follows the 302 and
  discards a body whose writes already happened.
- ⚠️ **`header.php` has now been read (2026-07-21) — see §5.8 #11.** It authenticates but does
  not halt, and it computes a `rolemenu` grant check whose result it **throws away**. This
  retires the old "the legacy's auth model is unverifiable" caveat.

**Logic**
- `IsPaid` is always written as **1** regardless of the checkbox. **Exact mechanism** (worth
  stating, because a casual read suggests the opposite): the guard is
  an `isset()` test on the posted paid-flag array at the current line index — and the grid cell
  it tests is **not a checkbox**. The row builder emits it as a **readonly text input**
  (`createcomplain.php:1029-1033`). A text input **always** submits, so the array element always
  exists, so the `isset()` is always true and the write always takes the "1" branch. The tick state is faithfully carried into the field's
  *value* and then never read. ⚠️ The "`IsPaid=1` + empty `PaidDate`" fingerprint an earlier
  revision cited does **not** hold: **no** row has a NULL `PaidDate`, and 22 of 35 carry a real
  date. Live truth is `IsPaid=1` ×34, `IsPaid=0` ×1 — and that single 0 is line 2 of the only
  multi-line complaint, i.e. residue of an older revision when the field really was a checkbox.
- "Back to Review" writes a **status-6 ("Close")** header-history row while setting the header to
  1 — the audit trail lies.
- **Two history `INSERT…SELECT`s read from the history table itself** instead of the action
  table — `closecomplain.php:203-205` (Back to Review) and `:316-318` (Back to Approval CEO),
  both `… select … from complaindetailsactionassignment …`. Each bounce therefore **doubles**
  that complaint's `complaindetailsactionassignment` rows, writes the history row's own PK into
  `ComplainDetailsID`, and never sets `ComplainDetailsActionID`. That this is a bug and not a
  house idiom is proved by the **correct** form of the same statement elsewhere —
  `View Request/listcomplaindetails.php:171-172` selects `from complaindetailsaction`.
  ⚠️ Verification note: a single-line `grep 'INSERT INTO'` finds **zero** of these, because the
  `SELECT` sits on the next line. Confirm multi-line before ever "correcting" this entry away.
- The promotion `COUNT(*)` gates omit `IsDeleted = 0` (§6.3).
- CEO reject sends its email **before** commit.
- "Submit Remark Only" inserts a status-2 history row while deliberately leaving the action row's
  status alone — so "remark only" is not status-neutral in the history; and it inserts a header
  history row without the matching header update.
- After a full assignment submit, the action row's `DoneRemark` keeps the last *draft*, not the
  submitted text.
- The Complain Type "Update & Restore" button never clears `IsDeleted` — both button variants
  post the same `btn-update`, whose UPDATE sets **the name column and nothing else** — it never
  touches `IsDeleted` (`listcomplaintypeedit.php:20`, labels at `:91-94`). Delete works, restore does not: a
  one-way door. The whole lookup CRUD also has **zero validation**, server or client — an empty
  Name inserts an empty row.
- **No promotion UPDATE filters anything.** The status-4 roll-up
  (`assigncomplain.php:239-240`) stamps every `complaindetails` row of the complaint —
  including soft-deleted lines and lines legitimately at another status — because it carries
  neither `IsDeleted = 0` nor a source-status predicate. Its follow-up gate then re-counts the
  rows it just overwrote, so the second check can never fail.
- **The full assignment submit does not write the action row's remark.** Only the *draft*
  ("Submit Remark Only") path writes `complaindetailsaction.DoneRemark`; the real submit
  updates status alone (`assigncomplain.php:220-221`, the both-columns variant sitting
  commented at `:166-172`). So the action row keeps the last draft — or stays empty if the user
  never saved one — while the history tables get the submitted text.
- **No Post/Redirect/Get anywhere.** Every write path "redirects" by echoing
  `window.location.href=…`, so a back-button re-POST silently re-runs an unguarded action.
  Nothing in the module is idempotent: a double submit inserts a second full set of history rows.
- **`complainassignment.Comment` is only ever written at creation** (the fixed string
  `"Created."`). Every later header-history row — CEO assign, CEO reject, Close, both
  send-backs, Cancel — stores a blank comment, while the header-history table renders that
  column as "Remark". The CEO's reject reason survives only in the single overwritable
  `complain.RemarkCEO`.
- **Cancel binds an undefined variable** as its remark (`$InsertComment` is assigned only in the
  *status-override* branch), so every cancel writes a null remark and emits a PHP warning.
- **Silent failure everywhere.** Every `catch` is `{ rollback(); }` — no log, no message, no
  re-throw. A failed create is indistinguishable from a successful one to the user. And because
  it catches `Exception`, a PHP 8 `Error`/`TypeError` escapes entirely, leaving the transaction
  open.
- **`createcomplainothers.php` is more broken than its sibling**, in two ways that matter:
  its reason dropdown echoes `$rowS1['Name']` from a table whose column is **`ReasonName`**, so
  every option renders **blank** and the user picks by position; and its refund block has a
  duplicated `id="reknametr"` with **no** `reknumtr`, so `complainttype()` throws mid-function
  and **No Rekening + Total Refund are unreachable in the UI** — they are written as empty
  strings on every refund line.

**Performance**
- Every list page re-runs its **full unlimited query** a second time purely to count rows. Use
  `COUNT(*)` / Laravel pagination.

**Dead code — do not port**
- **`getmaxquantity.php`** — never called (its JS wrapper has **zero** invocations across the
  tree, and the element it would write to exists nowhere). It also would not be a cap: it returns
  a **stock lot's** on-hand quantity, keyed on a `baranglists.ID` despite its parameter being
  named `idbarang`. Nothing anywhere compares `ComplainQuantity` to `QuantityPacking` or to any
  delivered/invoiced figure.
- **The lot-number and satuan cascades.** ⚠️ Precise mechanism, because an earlier revision said
  "all call sites commented out" and that is **wrong on the letter**: only 6 of 33 lot-picker
  call sites are commented — the other 27 are live JS **bound to elements that are absent or
  `readonly`**, so they can never fire. Net effect is the same (`BarangListID` is a free-text
  box), but do not go looking for comment markers to confirm it. The satuan cascade is deader
  still: its target element does not exist anywhere in the tree.
- **`getpmapproval.php` as a gate** — display-only, live on just 2 of the 11 screens that define
  its wrapper, and it omits `IsHeadDiv`, so it over-reports versus the real PM rule.
- **`CountAssignFromCEO`** — write-only, read nowhere in the module. Note it counts *ticked
  boxes*, not rows actually written, so it can be non-zero while zero assignments exist.
- **The `#InsertUser` → division cascades**, wired to elements that do not exist.

---

## 8. Roadmap (proposed — each phase re-confirms scope with the user first)

| Phase | Build | Gate before starting | Done when |
|---|---|---|---|
| **1** | `complaintype` lookup CRUD (menu 253) | none — **start here** | ✅ **DONE 2026-07-22.** CRUD + working restore (fixing the legacy bug) + Policy + Pest green (11/11) + menu/rolemenu (Administrator) + built |
| **2** | Read-only own list (250) + the header/line/action/history read model | Phase 1 | List renders real data, scoped to `UserIDInput`, gated, tested |
| **3** | Create (226) — header + N lines + **the 6-per-line action matrix**, one transaction | ~~Q2, Q6, Q12~~ all answered | 2+14N rows written transactionally; refund mode switch enforced server-side; header-level attachment (`file_uploads/complaintreturns`) |
| **4** | The other 4 list variants (422/251/326/385) via a `ComplaintAccess` service generalised from `VisitPlanAccess` | none blocking | ✅ **DONE 2026-07-22.** Each of the five scopes from §4.2 enforced server-side (not just displayed); `effectiveView()` prefers the least-restrictive matching grant |
| **5** | The six **Review** queues (228–234) | ~~Q9~~ answered — Finance/Accounting/Logistic stay unscoped | ✅ **DONE 2026-07-22.** Six queues, each scoped per §4.1 and gated by menu grant; promotion is complaint-wide (Q13) |
| **6** | **CEO** approval (235) — assign-with-instruction + reject | ~~Q11~~ answered | ✅ **DONE 2026-07-22.** Per-department assignment writes status 3 + `AssignRemark` (one real write per ticked pair — porting the intent of the broken legacy matcher, §5.8 #10); reject writes 7 + `RemarkCEO` |
| **7** | The six **Complete Assignment** queues (237–242) + **Close** (256) + Cancel | ~~Q4, Q11, Q13, Q16~~ all answered | ✅ **DONE 2026-07-22.** Status 4 roll-up (complaint-wide); close/send-back branches (both self-referencing INSERT…SELECT bugs, §7, fixed by construction); cancel/close gated purely by `rolemenu` grant (Q11) |
| **8** | Create-for-Others (325, rebuilt properly per Q14); XLS export; the 4 real emails | ~~Q10~~ (still genuinely open, non-blocking) — **and the email stop below** | ✅ **DONE 2026-07-22.** Create for Others built with real HeadID-subtree Sales scoping; XLS export reuses the exact `index()` query; all 4 Mailables built + render-tested, dormant Blade's 3 real bugs fixed (wrong `SatuanName`→`QuotationPackName`, two guessed relation names, header-vs-line history model) — **dispatch left OFF, not even wired into any controller yet** |

> **🛑 The email rule (user decision, 2026-07-21):** write the Mailables, Blade wiring and tests
> (tests force `MAIL_MAILER=array`, so they never send), then **STOP and show the user every
> trigger point for sign-off before enabling live dispatch.** `.env` is **live Brevo SMTP**, not
> the log driver ([[mail-driver-check-env]]), and **no queue worker is running** (issue **#155**),
> so queued mail silently accumulates in `jobs` — "it didn't send" will not mean "it's safe".

---

## 9. Open Questions

**✅ ANSWERED by the legacy source, 2026-07-21** — recorded here so nobody re-opens them:

| # | Question | Answer |
|---|---|---|
| Q1 | What binds a user to a department queue? | **§4.1.** Not `users.DepartmentID`. PM → `userprincipal.IsHeadDiv` on the *product's* principal; SM → `userdivision.IsHeadDiv` on the header's division; CS → `userdivision` membership (no `IsHeadDiv`); Finance/Accounting/Logistic → **nothing** (→ Q9) |
| Q2 | Is `complainreason` used? | The legacy form **collects and requires** it; the 34 live rows predate it (§5.8 #1). **Collect it; write a real FK.** |
| Q3 | Is `complaindetailsaction` pre-created? | **Yes** — 6 rows per line at creation, codes 1/2/5/6/8/11 (§6.1) |
| Q5 | The four remark columns / two history tables | **§6.2.** One column per stage; both history tables are written every stage |
| Q7 | Six routes or one `{department}` route? | Legacy uses six files + a hardcoded constant each. Either shape works; the **menu gate needs six `LinkLaravel` rows regardless.** Implementation choice, not a business rule |
| Q8b | View All Report vs View All Read Only | **§4.2** — different *scope* (assignment-based vs unscoped), not different columns |
| Q10 | Email recipients | **§6.6.** Create sends nothing; four real triggers |
| Q12b | Is complaint quantity capped? | **No** — the apparent cap is dead code reading warehouse stock (§5.8 #4) |
| — | Status 9 (Re-Review) | **No workflow writer, but NOT dead** — written by the admin status-override (§6.7) and present on 1 header / 1 line / 11 action rows. Render it; build no transition (§6.4) |
| — | What writes status 5? | The admin status-override only (§6.7) — the sole `FeedbackRemark` writer in the module. Never used in live data |
| — | Does `users.DepartmentID` matter? | Not for queues, **yes for columns** — it hides all commercial fields from Logistic on View All Report (§4.1 #1) |
| Q17 | Keep an admin status-override at all? | **Yes — but rebuild it, don't port it.** Answered 2026-07-22: a gated capability with its own menu leaf, its own Policy, an explicit transition matrix, and an audit row of who overrode what. The legacy's ungated any-status-to-any-status version is not reproduced (§6.7) |
| Q19 | Is Logistic's commercial-column blackout wanted? | **Yes — apply it module-wide.** Answered 2026-07-22: every commercial column in Complaint & Returns is hidden from `DepartmentID = 5` users, not just the one legacy screen (§4.1 #1). Business rule: Logistic must not see money-related data. Decision scoped to this module only |
| Q4 | Are the 6 Feedback screens real? | **No — dropped.** Answered 2026-07-22. Zero live usage, never supplied, structurally unreachable via the seeded menu tree. No phase budgeted for them |
| Q9 | Is "no ownership gate" for Finance/Accounting/Logistic intended? | **Yes — reproduce as unscoped.** Answered 2026-07-22. Matches verified legacy behaviour exactly; can be tightened later as a deliberate follow-up if needed |
| Q11 | Who may Cancel (8) and Close (6)? | **Purely `rolemenu` grant + status window, no separate ownership check.** Answered 2026-07-22. Cancel: derives from which list-scoping menu a role holds (View Request grant → own only; View All grant → anyone), status ∈ {1,2,3}. Close: its own standalone menu-256 grant, status 4, no ownership check — matches legacy exactly |
| Q6 | Keep attachments? What `{menu_name}`? | **Yes, build them.** Answered 2026-07-22. `{menu_name}` = `complaintreturns` |
| Q8 | Which roles get each of the 29 leaves? | **Administrator (RoleID 1) only for now.** Answered 2026-07-22. User will assign the rest later via the existing Role-Menu Assignments admin screen — not a build blocker |
| Q13 | Keep complaint-wide promotion, or per-line? | **Keep complaint-wide.** Answered 2026-07-22, matches legacy exactly (§6.3) |
| Q12 | Should `ComplainQuantity` be capped? | **No cap.** Answered 2026-07-22, matches legacy (the apparent cap is dead code, §5.8 #4) |
| Q14 | Is Create for Others (325) worth building? | **Yes, rebuilt properly.** Answered 2026-07-22 — real `HeadID`-subtree scoping, fixed reason-dropdown and refund-field bugs |
| Q15 | Confirm the department code map | **Confirmed — kept as hardcoded module constant.** Answered 2026-07-22 |
| Q16 | Should the two send-backs notify anyone? | **Yes, notify.** Answered 2026-07-22 — new behaviour beyond the legacy port |
| Q18 | How should the 19 legacy zero-dates display? | **Render as `—`, never write back.** Answered 2026-07-22 |

**⏳ STILL OPEN — ask the user, do not guess:**

| # | Question | Blocks |
|---|---|---|
| Q10 | Email recipient for a hypothetical "complaint created" notification — legacy `sendEmailComplain` has a hardcoded recipient (`devi@colorindochemtra.com`) and is never actually called. Is a create notification wanted, and if so, who receives it? | Phase 8 |
| — | Sidebar/menu display label: keep the legacy DB spelling "Complain & Returns Type" etc., or rename to "Complaint" spelling for UI consistency with the module folder name? Low-stakes, cosmetic, reversible — proceeding with the existing DB `ShowName` values unless told otherwise. | Cosmetic, any phase |

---

## 10. Legacy Menu Tree (dev DB `menu` table, verified 2026-07-21)

Root **225** "Complain & Returns" (`SortNo` 8). **All 29 leaves carry a legacy `Link` and an
empty `LinkLaravel`** — nothing is wired to Laravel yet. Filling `LinkLaravel` + granting
`rolemenu` is part of each phase's wiring; CLAUDE.md's duplicate-legacy-menu trap applies (gate
on `LinkLaravel`; the admin screen searches `Name` while the sidebar renders `ShowName`).

| Menu | Parent | ShowName | Legacy file | Note |
|---|---|---|---|---|
| 226 | 225 | Create Complain & Returns | `createcomplain.php` | |
| 325 | 225 | Create Complain & Returns for Others | `createcomplainothers.php` | on-behalf |
| 227 | 225 | *Review* | — | group |
| 228–234 | 227 | Review PM / SM / CS / Finance / Accounting / Logistic | `listreviewcomplain{pm,sm,cs,finance,accounting,logistic}.php` | ⚠️ **232** is a dead `IsShow=0` duplicate of **233** |
| 235 | 225 | Approval CEO | `listcomplainceo.php` | the dispatcher |
| 236 | 225 | *Complete Assignment* | — | group |
| 237–242 | 236 | Complete Assignment ×6 | `listassigncomplain{pm,sm,cs,finance,accounting,logistic}.php` | |
| 243 | 225 | *Feedback* | — | group, ⚠️ **`IsShow=0`** → orphans 244–249 (§5.8 #6) |
| 244–249 | 243 | Feedback ×6 | `listfeedbackcomplain{…}.php` | ⚠️ **never supplied — likely dead, Q4** |
| 256 | 225 | Close Complain & Returns | `listclosecomplain.php` | |
| 250 | 225 | View Request | `listcomplain.php` | |
| 422 | 225 | View Head Dept | `listcomplainheaddept.php` | |
| 251 | 225 | View All | `listcomplainall.php` | |
| 326 | 225 | View All (Read Only) | `listcomplainallreadonly.php` | |
| 385 | 225 | View All Report (Read Only) | `listcomplainallreportreadonly.php` | |

Under **Pengelolaan** (root 11) → **252** (group): **253** Complain & Returns Type →
`listcomplaintype.php`.

⚠️ **Each list screen's SQL lives in a sibling `*view.php`**, not in the file the menu names.
There is **no lookup menu for `complainreason`** anywhere in the tree.

**Out of scope:** root **363** "Complain Handling" (18 leaves) — a different module (§1).

---

## 11. Page Inventory — the PROTOTYPE (analysed 2026-07-21)

`Pages/Proto/Complaints/*` + mock data in `resources/js/Proto/complaintsData.js`. All data is
mock; `PrototypeController` renders every page with **zero props**; every handler is a stub
alert. **The folder holds ELEVEN `.jsx` files, of which five are ours** — the **six**
`Handling*` files are Complaint Handling (§1): `HandlingApprovalCeoDetail`,
`HandlingApprovalCeoList`, `HandlingLogisticCreate`, `HandlingPmCreate`,
`HandlingReviewPmDetail`, `HandlingReviewPmList`. (Earlier revisions said "nine files, four
`Handling*`" — recounted 2026-07-21.)

- **`Create.jsx`** (306 lines) — customer block + items list via a modal. ⚠️ **The item modal is
  not implemented**, so the module's most detail-heavy screen (SJ / invoice / quantity / price /
  refund bank / three narrative fields) has **no design to port**. No reason field. Wrong type
  list (rule 21). Uses the banned gradient button.
- **`ViewAll.jsx`** (175) — 11 resizable columns, filter pills, `ListFooter` pagination. Matches
  the house list grammar; reusable.
- **`ViewAllDetail.jsx`** (259) — document-style detail + status/comment action. Wrong status
  list (rule 21).
- **`ReviewPmList.jsx`** (189) — 13 columns. **The template for all six Review queues** (and
  probably the six Complete-Assignment ones); only PM was prototyped.
- **`ReviewPmDetail.jsx`** (255) — detail + editable fields + **Submit Review** / **Submit Remark
  Only** + history. The two-action split matches the legacy exactly.

**Not covered by the prototype:** Create-for-Others · the CEO screen · Complete Assignment ·
Close · Head-Dept and read-only variants · the line-item editor · any permission model · email ·
the lookup CRUD. **Five of twenty-nine menus have a design** — budget the rest as design work,
modelled on sibling modules per `README.md` rule 10.

---

## 12. Binding Conventions for Every Future Pass

1. **Read `README.md` in this folder top-to-bottom first.** Its GATE 1 is a blocking
   precondition, not advice.
2. **Legacy is a reference, not a template** (Aturan Absolut #0c) — extract the rule, implement
   it the CLAUDE.md way, and never reproduce §7.
3. **Invoke `new-feature` + `inertia-v3-stack` before writing code** (CLAUDE.md hard rule).
4. **TDD** — Pest red first. `DatabaseTransactions` against the live dev DB.
5. **FormRequest-only validation**; **Policy + `authorize()`** on every non-read action;
   Eloquent or bound parameters only. The legacy validated almost nothing server-side — the port
   must validate everything.
6. **Never assert global row counts** on `complain*`. Assert presence/scope of your own rows.
7. **Mint real `role` rows** for synthetic test grants — `rolemenu.RoleID → role.ID` is enforced.
8. **Never insert a 0-sentinel** (§5.8 #1) and never a `DepartmentID` of 0 or 11 (§5.9).
9. **Bind every status/type dropdown to its lookup table**, never a hardcoded array.
10. **Inertia render prefix is `MenuComplaintAndReturns/...`** — page path matches the folder,
    and tests assert the same string.
11. **Additive-only migrations**; no structural change to the existing schema.
12. **After any `.jsx` edit run `bash nodebuild.sh`** from the outer root, after the mandatory
    pre-build sweep (`README.md` rule 7).
13. **Commit by explicit pathspec only**, never bare, and never anything from the legacy folder.
14. **Update this PRD and the README at session end** (`README.md` §5 On-end), and record user
    decisions in §1 "Locked decisions" with a date.

---

## 13. References

| What | Where |
|---|---|
| Module rules / onboarding | `README.md` (this folder) |
| Legacy source (81 files, local only) | `Just An Example, Do not Link/` — the four write handlers + the CEO screen hold nearly all the logic |
| Build order for all 8 phases | `Just An Example, Do not Link/BUILD PROMPT.md` (local only) |
| Pattern template — `<entity>assignment` history, scoping service, gated download, XLS export | `../MenuVisitPlans/` (README + PRD + code); `app/Services/VisitPlanAccess.php` |
| Sibling blueprint on the same template | `../MenuBudgetAndTargets/` |
| UI design source (5 of the 9 files) | `../Proto/Complaints/*`; mock data `resources/js/Proto/complaintsData.js` |
| Lookup-CRUD reference (Phase 1) | the `bank` reference feature — CLAUDE.md "Reference Feature" |
| Mail port recipe + the dormant Blade | `resources/views/mail/complain-document.blade.php` (⚠️ its `satuanPacking` claim is wrong, §5.8 #2); `app/Mail/VisitPlanToUserMail.php` |
| Legacy email catalogue (5 Complain emails) | CLAUDE.md → "Legacy Email Port Catalog" |
| File-upload convention (if Q6 says yes) | CLAUDE.md → "File Upload (`file_uploads` naming convention)" |
| Out-of-scope sibling | Complaint Handling — legacy root 363 |
| Out-of-scope ERP screens | `Review/complainprintsuratjalan.php`, `Review/complainprintinvoice.php` — PostgreSQL ERP, §6.8 |
| `header.php` (the legacy gate — FOUND) | `../MenuBudgetAndTargets/Just An Example, Do not Link/Additional File/header.php` — local-only, git-ignored. §5.8 #11 |
| Project contract | `ColorindoChemtraInertia/CLAUDE.md` (read in full) |
| Queue/mail delivery warning | GitHub issue #155 |
