# Module: Borang Aduan & Maklum Balas (`content-form`)

> Modul borang awam **"satu enjin, banyak borang"** — admin susun borang dalam pangkalan data,
> portal awam jana borang itu sendiri, jawapan masuk **peti masuk** admin, admin balas & tutup tiket.
>
> Status: **aktif (fasa F1 selesai)** · Rujukan perancangan: [`planning-aduan-maklum-balas.md`](planning-adum-maklum-balas.md)
> Halaman admin: `/portal-k7m4x9/content-form` · Halaman awam: `/nres/{slug}`

---

## 0. Ringkasan cepat (TL;DR)

| Perkara | Nilai |
|---|---|
| Nama modul | `content-form` |
| Menu admin | **Content → Form & Feedback** (`fa-solid fa-inbox`) |
| Peti masuk admin | `/portal-k7m4x9/content-form` |
| Bilangan jadual | **9** (`content_form`, `_translation`, `_section`, `_field`, `_option`, `_submission`, `_answer`, `_attachment`, `_message`) |
| Borang sedia ada | 4 — Aduan Awam (SISPAA) · Aduan Integriti · Kajian Kepuasan Pelanggan 2026 · Kajian Kepuasan Perkhidmatan Digital 2026 |
| No. Tiket | `{PREFIX}/{TAHUN}/{4-digit}` cth `AI/2026/0003`, `KJ/2026/0001`, `AD/2026/0001` |
| Halaman awam | 4 halaman CMS di site **`nres`** (site_id = 2) |
| Eksport | CSV · XLSX · PDF |
| E-mel | Pengesahan pengadu + notifikasi admin + notifikasi balasan |
| Ujian | `tests/Feature/ContentFormSubmissionTest.php` (9) · `tests/Feature/ContentFormAdminTest.php` (12) |

**Satu ayat konsep:** admin "taip" definisi borang (seksyen → medan → pilihan) dalam DB, CMS jana
HTML borang itu di portal awam, pelawat isi & hantar, sistem simpan jawapan + jana No. Tiket +
hantar e-mel, kemudian admin urus tiket dari peti masuk (balas · tukar status · eksport).

---

## 1. Tujuan & konsep

### 1.1 Analogi

Fikir modul ini = **Google Forms + Google Sheet + peti masuk e-mel**, tetapi semuanya di dalam
portal NRES dan diurus dari satu tempat:

| Google | Modul ini |
|---|---|
| Google Form (borang) | borang dipapar **dalam portal NRES** (tema & bahasa portal) |
| Google Sheet (jawapan) | jawapan masuk **pangkalan data CMS** (`content_form_submission` + `_answer`) |
| Baca satu-satu dalam Sheet | **peti masuk** dengan badge status, penapis, carian |
| Tiada balasan rasmi | **utas balasan** admin ↔ pengadu + **No. Tiket** |
| Kira peratus manual | eksport CSV/XLSX/PDF (laporan agregat: fasa seterusnya) |

### 1.2 "Satu enjin, banyak borang"

**Jangan tulis kod berasingan untuk setiap borang.** Sebaliknya definisi borang disimpan sebagai
data, dan satu enjin menjana HTML-nya:

```
BORANG: "Kajian Kepuasan Pelanggan NRES 2026"      (content_form)
 ├── SEKSYEN: "BAHAGIAN A: DEMOGRAFI"              (content_form_section)
 │     ├── FIELD: Jantina       (RADIO)  → Lelaki · Perempuan      (content_form_field)
 │     ├── FIELD: Umur          (RADIO)  → <20 · 21–40 · 41–60 · 61>   (+ content_form_option)
 │     └── FIELD: Bahagian/Unit (SELECT) → dari ref `NRES_DEPARTMENT` (field_ref_cat)
 ├── SEKSYEN: "BAHAGIAN C: TAHAP KEPUASAN"
 │     ├── FIELD: 9 pernyataan  (SCALE 1–5 + label skala)
 │     └── FIELD: Cadangan      (TEXTAREA)
 └── ...
```

Kesannya:
- tambah borang baharu = **tambah data**, bukan tambah kod;
- semua borang berkongsi satu enjin (validasi, No. Tiket, e-mel, peti masuk, eksport);
- borang boleh dibuka/tutup ikut tarikh (`form_start` / `form_end`).

### 1.3 Dua peranan

| Peranan | Di mana | Apa dia buat |
|---|---|---|
| **Pelawat (awam)** | Portal NRES `/{site}/{slug}` | isi & hantar borang, terima No. Tiket, semak status (`?tiket=1`) |
| **Admin** | `/portal-k7m4x9/content-form` | baca tiket, balas, tukar status/keutamaan/pegawai, eksport, padam |

### 1.4 Dua jenis borang dalam satu enjin

| Jenis (`form_type`) | Prefix tiket | Contoh | Ciri |
|---|---|---|---|
| `COMPLAINT` (Aduan) | `AD` | `AD/2026/0001` | ada No. Tiket, balasan, status |
| `SURVEY` (Kajian) | `KJ` | `KJ/2026/0001` | jawapan agregat (laporan = fasa seterusnya) |
| `FEEDBACK` | `MB` | `MB/2026/0001` | maklum balas umum |
| `ENQUIRY` | `PT` | `PT/2026/0001` | pertanyaan |
| **Aduan Integriti** (akses terhad) | **`AI`** | `AI/2026/0003` | prefix khas kerana `form_access = content-form.integrity` |

---

## 2. Struktur pangkalan data (9 jadual)

**Migration:** `database/migrations/2026_10_23_110001_create_content_form_tables.php`
(guna `increments()` — PK integer auto-increment, bukan `id()` bigint)

```
content_form (borang)
   ├─< content_form_translation (BM/EN tajuk + keterangan)
   ├─< content_form_section (BAHAGIAN A/B/C)
   │      └─< content_form_field (soalan)
   │             └─< content_form_option (pilihan jawapan)
   └─< content_form_submission (tiket)
          ├─< content_form_answer (jawapan per medan)
          ├─< content_form_attachment (fail lampiran)
          └─< content_form_message (utas balasan)
```

### 2.1 `content_form` — definisi borang

| Kolum | Jenis | Nota |
|---|---|---|
| `form_id` | increments (PK) | |
| `form_code` | string(60) **unique** | kod dalaman |
| `form_slug` | string(80) **unique** | **juga slug halaman CMS** (cth `integrity-complaint`) |
| `form_type` | string(40) | `COMPLAINT` · `FEEDBACK` · `ENQUIRY` · `SURVEY` (ref `FORM_TYPE`) |
| `form_title` | string(255) | tajuk asal |
| `form_desc` | text | keterangan asal |
| `form_start` / `form_end` | date | julat buka; `scopeOpen()` semak tarikh |
| `form_status` | string(20) | `DRAFT` · **`OPEN`** · `CLOSED` |
| `form_external_url` | string(255) | jika ada → halaman awam papar kad "Buka Sistem" (cth SISPAA) |
| `form_captcha` | bool | (belum diguna) |
| `form_attachment` | bool | benarkan lampiran |
| `form_reply` | bool | benarkan balasan |
| `form_report` | bool | penanda jenis kajian |
| `form_anonymous` | bool | benarkan penghantaran anonim |
| `form_thanks` | text | mesej selepas hantar (fallback: "Borang anda telah diterima.") |
| `form_access` | string(100) | **permission tambahan** untuk baca (cth `content-form.integrity`) |
| `form_sort` | int | susunan senarai borang |
| `created_at/by`, `updated_at/by` | | audit ringkas |

### 2.2 `content_form_translation` — terjemahan borang

`form_translation_parent_id` · `form_translation_language` (ms/en) · `form_translation_title` ·
`form_translation_desc` · `form_translation_main` (penanda terjemahan utama).
**Unique:** (parent, language). Diguna oleh `ContentForm::title()` / `translation()`.

### 2.3 `content_form_section` — seksyen borang

`section_id` · `section_form_fk` · `section_title` · `section_desc` · `section_sort`.

### 2.4 `content_form_field` — medan/soalan

| Kolum | Nota |
|---|---|
| `field_code` | **kunci jawapan** — jadi key `fields[<code>]` dalam HTML & `HEADER_MAP` tiket |
| `field_label` | label yang dipapar |
| `field_help` | teks bantuan (kecil di bawah label) |
| `field_type` | `TEXT` · `TEXTAREA` · `EMAIL` · `PHONE` · `DATE` · `SELECT` · `RADIO` · `CHECKBOX` · `SCALE` · `FILE` |
| `field_required` | wajib diisi? (→ atribut HTML `required` + validasi server) |
| `field_ref_cat` | ambil pilihan dari table **`ref`** (cth `NRES_DEPARTMENT`) |
| `field_scale_min/max` | julat skala (cth 1–5) |
| `field_scale_labels` | JSON label skala cth `{"1":"Sangat tidak setuju","5":"Sangat setuju"}` |
| `field_sort`, `field_status` | susunan & aktif/tidak |

**Unique:** (`field_form_fk`, `field_code`).

### 2.5 `content_form_option` — pilihan jawapan

`option_field_fk` · `option_label` · `option_value` (fallback: label) · `option_sort`.

### 2.6 `content_form_submission` — tiket (satu penghantaran)

| Kolum | Nota |
|---|---|
| `submission_ticket_no` | **unique** — `PREFIX/TAHUN/0000` |
| `submission_form_fk` | borang yang dihantar |
| `submission_type` | salinan `form_type` semasa hantar |
| `submission_name/email/phone/org/state` | data pengadu |
| `submission_category/unit/subject/body` | data utama (dipetakan dari `field_code` melalui `HEADER_MAP`) |
| `submission_anonymous` | 1 = identiti disembunyikan dalam peti masuk |
| `submission_status` | `NEW` → `IN_REVIEW` → `PENDING_INFO` → `REPLIED` → `CLOSED` / `REJECTED` |
| `submission_priority` | `LOW` · `NORMAL` · `HIGH` · `URGENT` |
| `submission_assigned_to` | FK → `backend_users.id` (pegawai bertanggungjawab) |
| `submission_channel` | `WEB` |
| `submission_locale` | `ms` |
| `submission_ip_hash` | SHA-256(ip + APP_KEY) — **IP mentah tidak disimpan** (privasi) |
| `submission_user_agent` | UA ringkas |
| `submission_submitted_at` / `_replied_at` / `_closed_at` | cap masa penting |

### 2.7 `content_form_answer` — jawapan per medan

`answer_submission_fk` · `answer_field_fk` · `answer_field_code` · **`answer_field_label`**
(salinan label — supaya tiket lama kekal boleh dibaca walaupun soalan diubah) ·
`answer_text` (teks / pilihan) · `answer_scale` (decimal 5,2 untuk medan SCALE).

### 2.8 `content_form_attachment` — lampiran

`attachment_submission_fk` · `attachment_field_code` · `attachment_path` (disk **public**) ·
`attachment_name` · `attachment_size` · `attachment_mime`.

### 2.9 `content_form_message` — utas balasan

`message_submission_fk` · `message_body` · `message_from` (`USER` / `ADMIN`) ·
`message_read_status` (`UNREAD` / `READ`) · `message_sent_to`.

> **Ciri penting:** model `ContentFormSubmission::booted()` memadam **cascade manual**
> (answers → messages → fail lampiran di disk → attachments) bila tiket dipadam.

---

## 3. Model & relasi

| Model | Table | Relasi / helper utama |
|---|---|---|
| `ContentForm` | `content_form` | `sections()` · `fields()` · `translations()` · `submissions()` · `scopeOpen()` · `isOpen()` · `title()` · `translation()` · `isRestricted()` · `accessPermission()` |
| `ContentFormTranslation` | `content_form_translation` | `form()` |
| `ContentFormSection` | `content_form_section` | `form()` · `fields()` |
| `ContentFormField` | `content_form_field` | `form()` · `section()` · `options()` · **`optionList()`** · **`scaleRange()`** · **`scaleLabel()`** · `hasOptions()` |
| `ContentFormOption` | `content_form_option` | `field()` |
| `ContentFormSubmission` | `content_form_submission` | `form()` · `answers()` · `attachments()` · `messages()` · `statusLabel()` · `statusColor()` · `answerFor()` · `generateTicketNo()` |
| `ContentFormAnswer` | `content_form_answer` | `submission()` · `field()` · `values()` |
| `ContentFormAttachment` | `content_form_attachment` | `submission()` · `humanSize()` |
| `ContentFormMessage` | `content_form_message` | `submission()` · `isFromAdmin()` |

**Nota penting tentang label status:**
- `ContentFormSubmission::STATUS_LABELS` = label **BM** (`Baharu`, `Dalam Semakan`, …) — diguna oleh
  **portal awam** (kad semak status `?tiket=1`) dan e-mel.
- **Peti masuk admin guna `descr_en` dari table `ref`** (English: `New`, `In Review`, …) supaya UI
  admin selaras dengan modul lain. Jangan tukar `STATUS_LABELS` untuk ubah paparan admin.

---

## 4. Ref kategori (dropdown)

| Cat | Nilai |
|---|---|
| `FORM_TYPE` | `COMPLAINT` Aduan · `FEEDBACK` Maklum Balas · `ENQUIRY` Pertanyaan · `SURVEY` Kajian |
| `SUBMISSION_STATUS` | `NEW` Baharu · `IN_REVIEW` Dalam Semakan · `PENDING_INFO` Menunggu Maklumat · `REPLIED` Dibalas · `CLOSED` Ditutup · `REJECTED` Ditolak |
| `SUBMISSION_PRIORITY` | `LOW` Rendah · `NORMAL` Biasa · `HIGH` Tinggi · `URGENT` Segera |
| `NRES_DEPARTMENT` | 17 bahagian/unit NRES (cth `INTEGRITY` = Unit Integriti) — diguna sebagai pilihan medan `unit` |

Semua cat ada `descr` (BM) + `descr_en` (EN) → **portal awam guna BM, admin guna EN**.

---

## 5. Routes

### 5.1 Admin (prefix `/{config('admin.path')}` = `/portal-k7m4x9`)

| Method | URI | Nama | Permission | Controller |
|---|---|---|---|---|
| GET | `/content-form` | `content-form.index` | `content-form.view` | `index` — peti masuk |
| GET | `/content-form/export` | `content-form.export` | `content-form.export` | `export` — CSV/XLSX/PDF |
| GET | `/content-form/{id}` | `content-form.show` | `content-form.view` | `show` — butiran tiket |
| POST | `/content-form/{id}/reply` | `content-form.reply` | `content-form.reply` | `reply` — balas pengadu |
| PUT | `/content-form/{id}` | `content-form.update` | `content-form.update` | `update` — status/keutamaan/pegawai |
| DELETE | `/content-form/{id}` | `content-form.destroy` | `content-form.delete` | `destroy` — padam tiket |

Semua dalam `routes/web.php` → `Route::controller(ContentFormSubmissionController::class)`.

### 5.2 Awam (portal)

| Method | URI | Nama | Throttle | Nota |
|---|---|---|---|---|
| GET | `/{site}/{slug}` | `frontend-site.page` | — | **halaman CMS** yang memaparkan borang (site `nres`) |
| POST | `/{site}/aduan/{formSlug}` | `frontend-form.store` | **5/minit** | hantar borang |
| POST | `/{site}/aduan/tiket` | `frontend-form.track.submit` | **10/minit** | semak status tiket |

> ⚠️ Route awam ini **mesti didaftar SEBELUM** catch-all `{site}/{slug}` di `routes/web.php`,
> jika tidak ia akan "ditelan" oleh route CMS.
>
> Nota: borang **tidak** ada route GET sendiri — halaman borang ialah **halaman CMS** biasa
> (lihat §7). Hanya POST yang melalui controller.

---

## 6. Aliran awam (portal)

### 6.1 Halaman CMS — satu borang = satu halaman

Borang **bukan** route PHP sendiri. Ia **halaman CMS** (`frontend_pages`) dalam site **`nres`**
(site_id = 2). Reka bentuk asal: **slug halaman = `form_slug`**.

| Page (CMS) | Page ID | URL | Borang |
|---|---|---|---|
| `complaints-feedback` | 24 | `/nres/complaints-feedback` | **senarai semua borang dibuka** (+ sokong `?form=`) |
| `integrity-complaint` | 25 | `/nres/integrity-complaint` | Aduan Integriti |
| `customer-satisfaction-2026` | 26 | `/nres/customer-satisfaction-2026` | Kajian Kepuasan Pelanggan NRES 2026 |
| `digital-satisfaction-2026` | 27 | `/nres/digital-satisfaction-2026` | Kajian Kepuasan Perkhidmatan Digital NRES 2026 |

Halaman `complaints-feedback` juga berfungsi sebagai **halaman senarai** — butang "Isi Borang"
(open in new tab) menghala ke halaman borang masing-masing.

### 6.2 Bagaimana halaman menjana borang (reka bentuk semasa)

> **Keputusan reka bentuk:** semua markup borang (CSS + `@php` + HTML + JS) ditulis **terus dalam
> `page_content` setiap halaman CMS** — **tiada** component kongsi dan **tiada** blade partial.
> Sebabnya: setiap halaman borang boleh diubah suai terus dari CMS page builder tanpa menyentuh
> kod, dan tiada satu titik kegagalan (component) yang dikongsi 4 halaman.

Setiap `page_content` bermula dengan:

```blade
@php
    $cfDefaultSlug = 'integrity-complaint';   // '' = papar SENARAI borang
    ...
@endphp
```

| Page | `$cfDefaultSlug` |
|---|---|
| `complaints-feedback` | `''` → papar senarai |
| `integrity-complaint` | `'integrity-complaint'` |
| `customer-satisfaction-2026` | `'customer-satisfaction-2026'` |
| `digital-satisfaction-2026` | `'digital-satisfaction-2026'` |

**Untuk tukar borang yang dipapar sesuatu halaman:** buka CMS → Pages → edit halaman → Page Content
→ ubah `$cfDefaultSlug` (atau letak `''` untuk papar senarai).

Susunan logik dalam `page_content`:

```
1. @php   → $cfDefaultSlug, $cfSite, $cfQuery, $cfSvc, $cfForm, $cfSubmission, $cfTracked, $cfGroups
2. <style> → gaya borang (.cf-*)
3. Cabang paparan:
     ?selesai=1  → kad "Terima kasih" + No. Tiket
     ?tiket=1    → borang semak status + keputusan
     form luar   → kad "Buka Sistem"
     ada borang  → borang penuh (seksyen → medan → butang)
     tiada       → senarai semua borang dibuka
4. <script> → (a) checkbox group wajib, (b) jaring keselamatan ralat HTML5
```

### 6.3 Parameter URL yang disokong

| Parameter | Kesan |
|---|---|
| `?form={form_slug}` | paksa papar borang tertentu (cth `/nres/complaints-feedback?form=digital-satisfaction-2026`) |
| `?selesai=1` | papar kad **"Terima kasih"** + No. Tiket (dibaca dari `session('form_submission_id')`) |
| `?tiket=1` | papar **borang semak status** (+ keputusan jika `session('tracked_submission_id')` ada) |

Keutamaan resolusi borang: **`?form=` → `$cfDefaultSlug` → slug halaman**.

### 6.4 Hantar borang (POST `/{site}/aduan/{formSlug}`)

```
[PELAWAT] isi borang → klik Hantar
     │
     ▼
1. throttle:5,1                    → maks 5 hantar/minit/IP
2. ContentFormSubmissionRequest    → validasi DINAMIK dari definisi medan
     • authorize() → true (awam)
     • rules()     → ContentFormService::rulesFor($form)
     • messages()  → mesej BM dengan label medan sebenar
     • attributes()→ label dari field_label
     │  gagal? → redirect()->back()->withErrors() + old() (lihat §6.8)
     ▼
3. ContentFormPublicController@store
     • $form = request()->form()   (form OPEN sahaja; null → 404)
     │
     ▼
4. ContentFormService::createSubmission()  ← DALAM SATU TRANSACTION
     a. $anonymous = checkbox 'anonymous' ditanda
     b. No. Tiket  = nextTicketNo($form)
     c. INSERT content_form_submission:
          - type/status=NEW/priority=NORMAL/channel=WEB
          - HEADER_MAP: nama→submission_name, email→submission_email,
            telefon→submission_phone, organisasi→submission_org,
            negeri→submission_state, kategori→submission_category,
            unit→submission_unit, subjek→submission_subject, butiran→submission_body
          - anonymous → nama/e-mel/telefon/organisasi = NULL
          - ip_hash = sha256(ip + APP_KEY) · user_agent
          - submitted_at = now()
     d. INSERT content_form_answer untuk SETIAP medan
          (SCALE → answer_scale · lain → answer_text · CHECKBOX → dipisah ", ")
          + salinan answer_field_label (tiket lama kekal boleh dibaca)
     e. Lampiran (medan FILE) → disk `public`, folder `content-form/files`
     │
     ▼
5. E-mel (lihat §9)
     • pengadu  → ContentFormSubmittedMail(type: 'user')   [jika ada e-mel]
     • admin    → ContentFormSubmittedMail(type: 'admin')  [SettingHelper::get('email_contact_email')]
     │
     ▼
6. redirect → /{site}/complaints-feedback?selesai=1
     with('form_submission_id', $submission->submission_id)
```

### 6.5 No. Tiket

Format: `{PREFIX}/{TAHUN}/{4-digit}` — cth `AI/2026/0003`, `KJ/2026/0001`.

| Keadaan | Prefix |
|---|---|
| `form_access = 'content-form.integrity'` | **`AI`** (Aduan Integriti) |
| `form_type = COMPLAINT` | `AD` |
| `form_type = FEEDBACK` | `MB` |
| `form_type = ENQUIRY` | `PT` |
| `form_type = SURVEY` | `KJ` |
| lain-lain | `NR` |

Jana ikut **sequence tertinggi tahun semasa** (`ContentFormSubmission::generateTicketNo()`), dengan
gelung anti-collision (cuba offset 1..50 jika nombor sudah wujud).

### 6.6 Semak status tiket (`?tiket=1` → POST `/{site}/aduan/tiket`)

1. Pelawat buka `/{site}/complaints-feedback?tiket=1` → papar borang **No. Tiket + e-mel**.
2. POST → `ContentFormPublicController@trackSubmit` (throttle 10/minit).
3. Cari tiket: `submission_ticket_no` **dan** e-mel mesti padan (`LOWER(email)`), jika tidak →
   mesej `track_error` *"No. Tiket atau e-mel tidak sepadan."*
4. Jika jumpa → `session('tracked_submission_id')` + redirect `?tiket=1` → papar:
   jenis · subjek · tarikh hantar/tutup · status badge · **utas balasan admin**.
5. **Tiket anonim tidak boleh disemak** (tiada e-mel).

### 6.7 Validasi & paparan ralat (dua lapis)

**Lapis 1 — HTML5 (pelayar):**
- Medan `field_required` → atribut `required` pada input/select/textarea.
- RADIO/SCALE → `required` pada setiap radio (HTML5 anggap satu kumpulan).
- **CHECKBOX group** → `required` pada **semua** kotak + `data-cf-req-group`; skrip kecil
  membuang `required` sebaik **mana-mana satu** ditanda → semantik sebenar
  **"sekurang-kurangnya satu"** (bukan "semua mesti ditanda").
- Mesej khas: *"Sila pilih sekurang-kurangnya satu pilihan."*

**Lapis 2 — Server (Laravel):**
- `ContentFormService::rulesFor()` bina peraturan dari definisi medan:
  `required|string` · `required|email` · `required|array|min:1` (checkbox) ·
  `required|in:<senarai pilihan>` (select/radio) · `required|numeric|min/max` (scale) ·
  `files.<code>` → `file|max:10240|mimes:pdf,jpg,jpeg,png,docx,xlsx`
- Mesej BM lengkap dalam `ContentFormSubmissionRequest::messages()`, termasuk:
  - `website.size` → *"Permintaan anda tidak dapat diproses. Sila cuba lagi."* (honeypot)
  - `files.*.mimes` → *"Format fail :attribute tidak dibenarkan."*

**Paparannya dalam halaman borang (semua dalam `page_content`):**
| Elemen | Fungsi |
|---|---|
| **Ringkasan ralat** (`alert-danger cf-alert`) | senaraikan **SEMUA** mesej ralat — menjamin tiada ralat "senyap" (termasuk `files.*` & honeypot) |
| `is-invalid` + border merah | medan yang gagal |
| `@error(...)` bawah medan | mesej khusus medan (termasuk `files.<code>` untuk medan FILE) |
| **Auto-scroll** ke ralat pertama | skrip kecil (`.cf-field-error, .cf-alert`) |
| **Jaring keselamatan HTML5** | bila pelayar sekat hantar, alert merah muncul **di atas medan bermasalah** |
| `old()` | semua nilai yang diisi dikekalkan (pengguna tak perlu taip semula) |

> **Kenapa ada dua lapis?** HTML5 memberi maklum balas segera tanpa reload, tetapi sesetengah
> pelayar tidak boleh memaparkan "bubble" pada elemen lutsinar (cth radio skala) → borang nampak
> "tergantung". Server + ringkasan ralat memastikan pengguna **sentiasa** nampak sebabnya.

### 6.8 Honeypot & anti-spam

- Setiap borang ada medan tersembunyi `website` (`.cf-hp`, off-screen).
- Peraturan: `website` → `nullable|size:0` → jika diisi (bot), penghantaran **ditolak**.
- Mesejnya sengaja generik: *"Permintaan anda tidak dapat diproses. Sila cuba lagi."*
- Tambahan: `throttle:5,1` pada POST borang & `10,1` pada semak tiket.

### 6.9 Penghantaran anonim

- Borang Integriti ada checkbox `anonymous` (field_code = `anonymous`, `ContentFormService::ANONYMOUS_CODE`).
- Bila ditanda → `submission_anonymous = 1`, dan **nama/e-mel/telefon/organisasi di-NULL-kan**.
- Dalam peti masuk: badge **ANONYMOUS** menggantikan nama.
- Nota: borang anonim **tidak** menghantar e-mel pengesahan (tiada e-mel) dan tidak boleh disemak status.

---

## 7. Aliran admin (peti masuk)

### 7.1 Senarai — `GET /content-form` (`index`)

**Statistik** (4 kad): All · New · In Progress (`NEW`+`IN_REVIEW`+`PENDING_INFO`) · Closed (`CLOSED`+`REJECTED`).

**Penapis + carian** (satu baris, konsep sama seperti `content-directory-department`):

| Kawalan | Kelakuan |
|---|---|
| `form` (All forms) | dropdown → **auto-submit** (`onchange="this.form.submit()"`) |
| `status` (All statuses) | dropdown → auto-submit |
| `unit` (All units) | dropdown → auto-submit |
| **Reset** (ikon) | muncul **hanya bila ada penapis aktif** |
| `search` | carian No. Tiket / nama / e-mel / subjek / butiran — **debounce 1.5s** auto-submit |

> Tiada butang "Filter" — semua kawalan menghantar sendiri.

**Jadual** (konvensyen UI modul lain): header UPPERCASE `tracking-wider`, `tbody divide-y`, baris
`hover:bg-gray-50`, sel `px-5 py-4`.

| Kolum | Nota |
|---|---|
| Ticket No. | pautan ke halaman butiran (mono, brand) |
| Type | `descr_en` (`Complaint`, `Survey`, …) |
| Complainant | nama + e-mel · atau badge **ANONYMOUS** |
| Subject | 70 aksara pertama (tooltip penuh) |
| Unit | `descr_en` dari `NRES_DEPARTMENT` |
| Status | badge + kiraan mesej **belum dibaca** (merah) |
| Date | `submission_submitted_at` |
| Actions | butang **ikon**: 👁 View (`bg-brand-50`) · 🗑 Delete (`bg-error-50`, ada confirm SweetAlert) |

Pagination guna `vendor.pagination.custom` (15 baris/halaman, `withQueryString()`).

### 7.2 Butiran tiket — `GET /content-form/{id}` (`show`)

1. Muat tiket + `form` + `answers` + `attachments` + `messages`.
2. `authorizeSubmission()` → jika borang **terhad** dan pengguna tiada permission → **403**.
3. **Tanda mesej pengadu sebagai `READ`** (`message_from = USER`).
4. Papar:
   - **Kad kepala**: No. Tiket + badge status/ANONYMOUS + meta (borang, tarikh hantar/balas) +
     butang Delete / Back to List.
   - **Details**: kotak label UPPERCASE — Type · Category · Unit/Division · Priority · Name ·
     E-mail · Phone · Organisation · Reference No. · Channel · Subject · Full Details.
   - **Answers (n)**: setiap jawapan dengan label soalan (termasuk label skala).
   - **Attachments (n)**: nama fail + saiz + MIME + butang Download.
   - **Update Ticket**: Status · Priority · Assigned Officer (senarai `backend_users`) + Save.
   - **Reply to Complainant**: textarea + Send Reply.
   - **Reply Thread (n)**: bubble chat — pengadu **kiri** (kelabu), NRES **kanan** (biru brand).

### 7.3 Balas — `POST /content-form/{id}/reply`

1. `ContentFormReplyRequest` → `message_body` wajib, min 5 aksara, maks 5000.
2. INSERT `content_form_message` (`message_from = ADMIN`, `UNREAD`, `message_sent_to = e-mel pengadu`).
3. Tiket dikemas kini: `submission_status = REPLIED`, `submission_replied_at = now()`.
4. Hantar `ContentFormRepliedMail` ke e-mel pengadu (jika ada) — **kegagalan e-mel tidak menggagalkan
   aliran** (try/catch).
5. `flash()->success('Reply sent.')` → redirect ke halaman butiran.

### 7.4 Kemas kini — `PUT /content-form/{id}`

`ContentFormStatusRequest` (status wajib + mesti dalam ref `SUBMISSION_STATUS`) →

| Perubahan | Kesan sampingan |
|---|---|
| `status = REPLIED` | set `submission_replied_at` (jika belum ada) |
| `status = CLOSED` | set `submission_closed_at` |
| status lain | `submission_closed_at` dikosongkan semula |

Juga: `submission_priority` + `submission_assigned_to` (boleh kosong = "Not assigned").

### 7.5 Padam — `DELETE /content-form/{id}`

Model memadam **anak-anak dahulu** (answers, messages, fail lampiran di disk, attachments), kemudian
tiket. Ada dialog pengesahan SweetAlert.

### 7.6 Akses terhad — Aduan Integriti

- Borang dengan `form_access` (cth `content-form.integrity`) = **terhad**.
- `canViewRestricted()` → pengguna mesti ada permission `content-form.integrity` **atau** role `super-admin`.
- Peti masuk **menyembunyikan** tiket terhad daripada pengguna tanpa permission (query filter),
  dan halaman butiran/balas/kemas kini memulangkan **403**.

---

## 8. Eksport — `GET /content-form/export?format=csv|xlsx|pdf`

`app/Exports/ContentFormExport.php` — menerima **penapis semasa** (`search, form, type, status,
priority, unit, from, to`) supaya eksport = apa yang dilihat.

| Format | Nota |
|---|---|
| **CSV** | stream terus (UTF-8) |
| **XLSX** | OpenSpout |
| **PDF** | Dompdf (landscape A4) — view `export-pdf.blade.php` |

Kolum (English): `No · Ticket No · Type · Status · Priority · Name · E-mail · Phone · Organisation ·
Category · Unit · Subject · Details · Anonymous · Submitted · Replied · Closed`
— label enum diterjemah dari `ref.descr_en`; anonim → `ANONYMOUS`, `Yes/No`.

Tajuk PDF: **"Inbox — Complaints & Feedback"** + "Ministry of Natural Resources and Environmental
Sustainability (NRES)" + "Generated {tarikh}" + "Total records: n".

---

## 9. E-mel

| Mailable | Bila | Kepada | Template key | Blade fallback |
|---|---|---|---|---|
| `ContentFormSubmittedMail` (type `user`) | selepas hantar | e-mel pengadu (jika ada) | `form.user_confirmation` | `emails.content-form.user-confirmation` |
| `ContentFormSubmittedMail` (type `admin`) | selepas hantar | `SettingHelper::get('email_contact_email')` (fallback `mail.from.address`) | `form.admin_notification` | `emails.content-form.admin-notification` |
| `ContentFormRepliedMail` | selepas admin balas | e-mel pengadu | `form.reply` | `emails.content-form.reply-notification` |

**Cara render:** `EmailTemplateService::render($key, $data)` — jika ada baris `email_template` aktif
dengan `template_key` itu, HTML/subjek dari DB digunakan (boleh diedit admin di
**Email → Templates**); jika tiada, Mailable jatuh balik ke blade view dalam kod.

**Pembolehubah yang tersedia untuk template:** `ticket_no` · `name` · `subject` · `type` ·
`submitted_at` · `track_url`.

**Subjek lalai:**
- pengadu → `Penerimaan Borang NRES — {ticket_no}`
- admin → `[{ticket_no}] {subjek / "Penghantaran baharu"}`

---

## 10. Permissions & menu

**Permission** (didaftar dalam `AdminRolePermissionSeeder` + `BackendMenuSeeder`):

| Permission | Guna |
|---|---|
| `content-form.view` | buka peti masuk + halaman butiran |
| `content-form.reply` | balas tiket |
| `content-form.update` | tukar status/keutamaan/pegawai |
| `content-form.delete` | padam tiket |
| `content-form.export` | eksport CSV/XLSX/PDF |
| `content-form.integrity` | **baca borang terhad** (Aduan Integriti) |

**Menu:** `Content → Form & Feedback` (`fa-solid fa-inbox`) → `content-form.index`
(permissions: `content-form.index`, `.show`, `.reply`, `.update`, `.export`).

---

## 11. Frontend (CMS) — cara ia berfungsi

### 11.1 Kenapa page_content, bukan component

| Pendekatan | Sebab ditolak/diterima |
|---|---|
| ~~Component kongsi (cth `$form_nres`)~~ | 4 halaman bergantung pada 1 component — tukar 1 halaman = tukar semua |
| ~~Blade partial (`@include('frontend.form._form')`)~~ | perlu deploy kod untuk ubah apa-apa pada borang |
| ✅ **Markup terus dalam `page_content`** | setiap halaman borang berdikari, boleh edit terus dari CMS page builder |

Setiap `page_content` mengandungi **segala-galanya**: `<style>`, `@php` konteks, kad terima kasih,
borang semak status, kad "Buka Sistem", borang penuh, senarai borang, dan `<script>`.

**Saiz:** ~34 KB setiap halaman · mirror blade disimpan di
`storage/app/private/PAGE_CONTENT/nres/{slug}.blade.php` (dijana oleh `BladeSyncService::syncAll()`).

### 11.2 Struktur halaman awam (ringkas)

```
@php
    $cfDefaultSlug = '...';        ← SATU-SATUNYA baris yang biasanya perlu diubah
    $cfSite  = $siteSlug ?? request()->segment(1);
    $cfQuery = request()->query();
    $cfSvc   = app(\App\Services\ContentFormService::class);
    $cfForm  = $cfSvc->findOpenForm($cfSlug);   // null jika tutup/tiada
    ...
@endphp

<style>/* gaya .cf-* (kad, medan, skala, badge, alert, utas) */</style>

<section class="py-4 py-lg-5">
  <div class="container-xl">
     @if ($cfSubmission)  ... kad "Terima kasih" + No. Tiket ...
     @elseif (?tiket=1)   ... borang semak status + keputusan ...
     @elseif (form luar)  ... kad "Buka Sistem" ...
     @elseif ($cfForm)    ... BORANG PENUH ...
     @else                ... senarai borang dibuka ...
     @endif
  </div>
</section>

<script>/* (1) checkbox group wajib  (2) jaring keselamatan ralat HTML5 */</script>
```

### 11.3 URL ringkas

| URL | Papar |
|---|---|
| `/nres/complaints-feedback` | senarai semua borang dibuka |
| `/nres/complaints-feedback?form=digital-satisfaction-2026` | borang kajian digital |
| `/nres/complaints-feedback?tiket=1` | borang semak status |
| `/nres/complaints-feedback?selesai=1` | kad terima kasih (selepas hantar) |
| `/nres/integrity-complaint` | borang Aduan Integriti |
| `/nres/customer-satisfaction-2026` | borang Kajian Kepuasan Pelanggan |
| `/nres/digital-satisfaction-2026` | borang Kajian Kepuasan Perkhidmatan Digital |

---

## 12. Cara guna (resepi langkah demi langkah)

### 12.1 Sebagai pelawat (awam)

1. Buka `https://<domain>/nres/complaints-feedback` → senarai borang.
2. Klik **Isi Borang** (buka tab baharu) → isi borang → **Hantar**.
3. Jika ada medan wajib yang tertinggal → mesej ralat muncul (border merah + ringkasan di atas borang).
4. Berjaya → kad **"Terima kasih"** + **No. Tiket** (cth `AI/2026/0003`) + salinan e-mel.
5. Simpan No. Tiket → semak status bila-bila di `?tiket=1` (No. Tiket + e-mel mesti padan).

### 12.2 Sebagai admin (urus tiket)

1. **Content → Form & Feedback** (`/portal-k7m4x9/content-form`).
2. Guna penapis/carian untuk cari tiket → klik 👁 **View**.
3. Di halaman butiran:
   - baca **Details**, **Answers**, **Attachments**;
   - **Update Ticket** → tukar Status / Priority / Assigned Officer → **Save**;
   - **Reply to Complainant** → taip balasan → **Send Reply** (status auto jadi `Replied`, e-mel dihantar);
   - **Delete** (ikon tong sampah) untuk buang tiket + semua anaknya.
4. Klik **Export** → CSV / Excel (XLSX) / PDF (ikut penapis semasa).

### 12.3 Tukar borang yang dipapar sesuatu halaman CMS

1. **Frontend → Pages** → tapis `site_id = 2 (nres)`.
2. Edit halaman (cth **Integrity Complaint**) → buka editor **Page Content**.
3. Cari baris pertama:
   ```php
   $cfDefaultSlug = 'integrity-complaint';
   ```
4. Tukar kepada `form_slug` lain (cth `'digital-satisfaction-2026'`), atau `''` untuk papar
   **senarai semua borang**.
5. **Save**. Selesai — tiada perubahan kod diperlukan.

### 12.4 Tambah borang BAHARU (end-to-end)

> ⚠️ **Belum ada UI "form builder"** (fasa F2). Buat masa ini borang ditambah melalui
> **seeder** atau **SQL/tinker**.

1. **Definisi borang** — tambah dalam `database/seeders/ContentFormSeeder.php` (ikuti corak sedia
   ada), atau terus ke DB:
   ```php
   $form = \App\Models\Backend\ContentForm::create([
       'form_code' => 'my-new-form', 'form_slug' => 'my-new-form',
       'form_type' => 'FEEDBACK', 'form_title' => 'Maklum Balas Baharu',
       'form_status' => 'OPEN', 'form_sort' => 10,
   ]);
   // + content_form_section, content_form_field, content_form_option
   ```
   Jalankan: `php artisan db:seed --class=ContentFormSeeder`
2. **Halaman CMS** (supaya ada URL awam):
   - **Frontend → Pages → Create** (site `nres`): Name = `My New Form`, Slug = `my-new-form`
     (mesti **sama** dengan `form_slug`), Status = aktif.
   - Buka **Page Content** → tampal markup borang (salin dari halaman sedia ada, cth
     `integrity-complaint`) → ubah baris `$cfDefaultSlug = 'my-new-form';` → Save.
3. Uji: buka `/nres/my-new-form` → hantar borang → semak peti masuk.

> **Penting:** `form_slug` (DB) dan `slug` (halaman CMS) **mesti sepadan** supaya `?form=`,
> pautan senarai & kad "Isi Borang" berfungsi.

### 12.5 Ubah soalan / pilihan jawapan

- **Label/teks soalan** → `content_form_field.field_label` (+ `field_help`).
- **Pilihan RADIO/CHECKBOX/SELECT** → `content_form_option` (label/value/sort) atau, jika
  `field_ref_cat` diisi, dari table **`ref`** (cth `NRES_DEPARTMENT`) — ubah di **Ref** module.
- **Wajib/tidak** → `field_required`.
- **Skala** → `field_scale_min/max` + `field_scale_labels` (JSON).
- ⚠️ **Jangan tukar `field_code`** untuk soalan yang sudah ada jawapan — jawapan lama disimpan
  dengan `answer_field_code` tersebut. Kalau perlu, tambah medan baharu dan biarkan yang lama.

### 12.6 Buka / tutup borang

| Cara | Kesan |
|---|---|
| `form_status = OPEN` | borang muncul & boleh dihantar |
| `form_status = DRAFT` / `CLOSED` | `findOpenForm()` → null → halaman papar **senarai** (bukan borang) |
| `form_start` / `form_end` | had tarikh automatik (`scopeOpen()`); di luar julat = tidak dibuka |
| `form_external_url` | halaman papar kad **"Buka Sistem"** (borang luaran, cth SISPAA) |

### 12.7 Semak data (tanpa UI)

```bash
php artisan tinker
>>> App\Models\Backend\ContentForm::with('sections.fields.options')->get();
>>> App\Models\Backend\ContentFormSubmission::with(['answers','messages'])->latest('submission_id')->first();
>>> App\Models\Backend\ContentFormSubmission::where('submission_ticket_no','AI/2026/0003')->first();
```

### 12.8 Backup / pulih page_content borang

Skrip bantuan (dalam `storage/app/cms-page-content/`, **gitignored**):

| Skrip | Guna |
|---|---|
| `php storage/app/cms-page-content/verify.php` | render semua URL portal & lapor status/ralat |
| `php storage/app/cms-page-content/apply.php dry` | validasi template page_content (Blade compile + `php -l`) tanpa sentuh DB |
| `php storage/app/cms-page-content/apply.php apply` | tulis semula page_content 4 halaman dari `template.txt` (backup automatik) |
| `php storage/app/cms-page-content/state.php` | audit page_content/pivot component |
| `php storage/app/cms-page-content/show-pages-test.php` | uji semua halaman butiran tiket (200 + tiada ralat) |
| `php storage/app/cms-page-content/admin-lang-test.php` | login admin & semak teks UI (index/butiran/eksport) |
| `node storage/app/cms-page-content/browser-admin-shot.mjs /content-form shot.png` | screenshot halaman admin (login automatik) |
| `php storage/app/cms-page-content/test-message.php add\|remove` | insert/padam mesej ujian untuk semak utas balasan |

Backup page_content: `storage/app/cms-page-content/backups/cms-backup-*.json`.

---

## 13. Ujian

```bash
php artisan test --filter=ContentForm
```

**`tests/Feature/ContentFormSubmissionTest.php`** (awam, 9 ujian):

| Ujian | Semak |
|---|---|
| `test_valid_submission_is_saved_with_ticket_and_answers` | tiket + jawapan + No. Tiket |
| `test_honeypot_blocks_automated_submission` | medan `website` diisi → ditolak |
| `test_required_fields_are_validated` | medan wajib |
| `test_invalid_select_option_is_rejected` | nilai pilihan tidak sah |
| `test_anonymous_submission_hides_personal_details` | nama/e-mel di-NULL |
| `test_closed_form_returns_not_found` | borang tutup → 404 |
| `test_confirmation_and_admin_notification_emails_are_sent` | 2 e-mel dihantar |
| `test_tracking_finds_submission_when_ticket_and_email_match` | semak status berjaya |
| `test_tracking_rejects_wrong_email` | e-mel salah ditolak |

**`tests/Feature/ContentFormAdminTest.php`** (admin, 12 ujian): senarai, butiran + answers + officers,
auto-mark mesej dibaca, balas (status → REPLIED), min panjang balasan, status update (REPLIED/CLOSED
+ cap masa), status tidak sah, eksport csv/xlsx/pdf, padam + anak-anak, **akses terhad** (senarai
tersembunyi + butiran 403).

---

## 14. Fail & storan

| Perkara | Lokasi |
|---|---|
| Lampiran borang | disk `public` → `content-form/files/` (URL: `/storage/content-form/files/...`) |
| Eksport XLSX sementara | `storage/app/content-form-export.xlsx` (auto-padam selepas muat turun) |
| Mirror blade halaman CMS | `storage/app/private/PAGE_CONTENT/nres/*.blade.php` |
| Backup page_content | `storage/app/cms-page-content/backups/*.json` |
| Blade borang (fallback, kini **tidak digunakan**) | `resources/views/frontend/form/*.blade.php` |

---

## 15. Gotchas & troubleshooting

| Gejala | Punca / Penyelesaian |
|---|---|
| **Halaman borang papar senarai, bukan borang** | `form_status ≠ OPEN`, atau di luar `form_start/end`, atau `$cfDefaultSlug` salah/`''`. Semak juga `form_slug` ada dalam DB. |
| **Borang nampak "tergantung" (klik Hantar tiada apa-apa)** | Validasi HTML5 menyekat tetapi bubble tidak boleh dipapar (elemen lutsinar). Sudah ditangani: alert merah muncul di atas medan + radio skala tidak lagi 0×0. Jika masih berlaku, semak skrip inline dalam `page_content` hadir. |
| **Error tidak dipaparkan (cth fail/honeypot)** | Pastikan blok **ringkasan ralat** (`$errors->any()`) ada dalam `page_content`. |
| **Ubah component code → halaman rosak** | `FrontendComponentController` **tidak** auto-kemas kini rujukan `{!! $code !!}` dalam page_content. Guna `storage/app/cms-page-content/fix-refs.php <lama> <baru> apply`. |
| **Perubahan DB tidak muncul di portal** | `BladeSyncService::syncAll()` mesti dipanggil (CRUD admin sudah buat) — ia tulis mirror + rebuild cache site (`FrontendSiteCacheService`). Manual: `php artisan tinker` → `app(App\Services\BladeSyncService::class)->syncAll();` |
| **HTTP 429 semasa ujian POST** | Throttle `5/minit` (borang) & `10/minit` (semak tiket) per IP. Tunggu 1 minit atau guna IP berbeza dalam ujian. |
| **Tiket Integriti tidak nampak dalam peti masuk** | Pengguna tiada `content-form.integrity` — memang direka begitu (§7.6). |
| **Tiket anonim tidak boleh disemak pelawat** | Memang direka: tiada e-mel untuk padan. |
| **E-mel tidak dihantar** | Semak `SettingHelper::get('email_contact_email')` (admin) & konfigurasi SMTP. Kegagalan e-mel **tidak** menggagalkan penghantaran borang (try/catch). |
| **Ubah UI borang awam** | Semua gaya berada dalam `<style>` di dalam `page_content` (kelas `.cf-*`). Edit di CMS → Pages → Page Content. |
| **Teks label soalan Melayu walaupun portal English** | Label soalan ialah **data** (`field_label`) — memang BM untuk pengadu. |

---

## 16. Belum dibuat (fasa seterusnya)

| Item | Fasa | Nota |
|---|---|---|
| UI **form builder** (tambah/edit borang, seksyen, medan, pilihan dari admin) | F2 | kini perlu seeder/SQL |
| **Duplikasi borang** (cth Kajian 2027) | F2 | |
| **Laporan agregat** kajian (% per pilihan, purata skala, bilangan responden) + eksport | F2 | |
| Penghantaran semula borang dari peti masuk (resend e-mel) | — | |
| Lampiran dalam e-mel notifikasi admin | — | |
| CAPTCHA (kolum `form_captcha` sudah ada) | — | kini hanya honeypot + throttle |
| Pengesahan e-mel tiket untuk semak status (kini No. Tiket + e-mel) | — | |

---

## 17. Fail berkaitan (peta kod)

| Lapisan | Fail |
|---|---|
| Migration | `database/migrations/2026_10_23_110001_create_content_form_tables.php` |
| Model | `app/Models/Backend/ContentForm*.php` (9 model) |
| Service | `app/Services/ContentFormService.php` |
| Controller (awam) | `app/Http/Controllers/Frontend/ContentFormPublicController.php` |
| Controller (admin) | `app/Http/Controllers/Backend/ContentFormSubmissionController.php` |
| Request | `app/Http/Requests/Frontend/ContentFormSubmissionRequest.php` · `app/Http/Requests/Backend/ContentForm{Reply,Status}Request.php` |
| Mail | `app/Mail/ContentForm{Submitted,Replied}Mail.php` |
| Export | `app/Exports/ContentFormExport.php` |
| Views (admin) | `resources/views/backend/module/contentForm/{index,show,export-pdf}.blade.php` |
| Views (e-mel) | `resources/views/emails/content-form/{user-confirmation,admin-notification,reply-notification}.blade.php` |
| Views (awam, fallback) | `resources/views/frontend/form/{_form,_field,_list,_success,_track}.blade.php` |
| Seeder | `database/seeders/ContentFormSeeder.php` · `RefDataSeeder.php` · `BackendMenuSeeder.php` |
| Routes | `routes/web.php` (bahagian admin + bahagian awam) |
| Halaman CMS | `frontend_pages` id **24–27** (site `nres`) — markup dalam `page_content` |
| Ujian | `tests/Feature/ContentForm{Submission,Admin}Test.php` |

---

## 18. Aliran penuh (satu gambar)

```
[DEFINISI]  seeder / DB → content_form + _section + _field + _option
                                  │
                                  ▼
[HALAMAN]   CMS page (site nres) → page_content ($cfDefaultSlug) → jana HTML borang
                                  │
                                  ▼
[PELAWAT]   isi borang  ──POST──► /{site}/aduan/{formSlug}
                                  │  (throttle 5/min · validasi dinamik · honeypot)
                                  ▼
[SISTEM]    createSubmission()  → tiket (No. Tiket) + jawapan + lampiran
                                  │  → e-mel pengadu + e-mel admin
                                  ▼
[PELAWAT]   kad "Terima kasih" + No. Tiket  (?selesai=1)
                                  │  → semak status bila-bila (?tiket=1)
                                  ▼
[ADMIN]     peti masuk /content-form
              ├─ tapis/cari → butiran tiket
              ├─ Update Ticket (status · priority · officer)
              ├─ Reply (→ status REPLIED + e-mel + utas)
              ├─ Export (CSV · XLSX · PDF)
              └─ Delete (cascade anak-anak)
```










