# Content Speech — Teks Ucapan

Modul admin untuk **Teks Ucapan** (ucapan menteri/pegawai). Struktur & architecture **sama seperti `content-download`**: satu table dengan **self-referencing translation** (`speech_main` = `1` parent, `0` translation), publish window, sorting ikut kategori, dan reorder drag & drop.

## 1. Table

`content_speeches` (PK `speech_id`)

| Kolum | Jenis | Nota |
|---|---|---|
`speech_main` | varchar(255) null | `1` = parent, `0` = translation |
`speech_parent_id` | varchar(255) null | FK ke `speech_id` parent |
`speech_language` | varchar(255) null | kod bahasa (`ref` `LANGUAGE`) |
`speech_category` | varchar(255) null | `ref` `SPEECH_CATEGORY` |
`speech_status` | varchar(255) null | `ref` `SPEECH_STATUS` (`PUBLISHED`, `DRAFT`, `ARCHIVED`) |
`speech_date` | date null | tarikh ucapan |
`speech_speaker` | varchar(255) null | penceramah |
`speech_speaker_category` | varchar(50) null | kategori penceramah — `ref` `SPEECH_SPEAKER` (`MENTERI`, `TIMBALAN_MENTERI`, `KETUA_SETIAUSAHA`) |
`speech_position` | varchar(255) null | jawatan — dropdown dari `ref` `DIRECTORY_STAFF_POSITION` (senarai jawatan staff, sama seperti modul Directory) |
`speech_event` | varchar(255) null | majlis |
`speech_location` | varchar(255) null | tempat |
`speech_start_date` / `speech_start_time` | varchar null | publish window |
`speech_end_date` / `speech_end_time` | varchar null | publish window |
`speech_title` | varchar(255) null | tajuk ucapan |
`speech_img` | varchar(255) null | imej (opsional) |
`speech_file` | varchar(255) null | PDF/DOC/DOCX/PPTX |
`speech_sorting` | int default 0 | susunan dalam kategori |
`created_by`, `updated_by` | varchar(255) null | |

Index: `speech_parent_id`, `speech_category`.

## 2. Fail

| Jenis | Path |
|---|---|
Model | `app/Models/Backend/ContentSpeech.php` (guna `HasPublishWindow`) |
Request | `app/Http/Requests/Backend/ContentSpeechRequest.php` |
Controller | `app/Http/Controllers/Backend/ContentSpeechController.php` |
Views | `resources/views/backend/module/contentSpeech/{index,create,edit,_form}.blade.php` + `partials/{_select,_date-input,_time-input}.blade.php` |
Storan | `storage/app/public/content-speech/{files,images}` |
Migration | `database/migrations/2026_10_22_100001_create_content_speeches_table.php` |
Test | `tests/Feature/ContentSpeechTest.php` |

## 3. Routes

| Method | URI | Permission | Name |
|---|---|---|---|
GET | `/content-speech` | `content-speech.view` | `content-speech.index` |
GET | `/content-speech/create` | `.create` | `content-speech.create` |
POST | `/content-speech` | `.create` | `content-speech.store` |
GET | `/content-speech/{id}/edit` | `.update` | `content-speech.edit` |
PUT | `/content-speech/{id}` | `.update` | `content-speech.update` |
DELETE | `/content-speech/{id}` | `.delete` | `content-speech.destroy` |
GET | `/content-speech/{id}/add-translation` | `.update` | `content-speech.add-translation` |
DELETE | `/content-speech/translation/{id}` | `.delete` | `content-speech.delete-translation` |
POST | `/content-speech/reorder` | `.update` | `content-speech.reorder` |

Menu admin: **Content → Speeches** (`backend_menu` #48, parent 32) + `backend_role_mapping`.

## 3b. Senarai (index)

Column: **#** · **Title** (+ kiraan translation) · **Speaker** · **Category** · **Date** · **Status** · **Sort** · **Actions**

- Column **Speaker** hanya papar **badge Speaker Category** (dipetakan dari `ref` `SPEECH_SPEAKER` → `Minister` / `Deputy Minister` / `Secretary General`); jika kosong → `-`. `speech_speaker` (Speaker Name) dan `speech_position` **tidak** dipaparkan dalam senarai (masih disimpan & boleh diedit dalam borang).
- Filter bar: **Search** (title/speaker/event) · **Category** · **Speaker** (kategori pencerah) · **Year** + butang Reset (muncul bila ada filter aktif).

### Sort / susun semula (konsep sama seperti `content-download`)

- Konsep: **drag & drop** baris dalam senarai, aktif **hanya bila filter Category dipilih** (susunan disimpan per kategori ke `speech_sorting`).
- Sel **#** ada 3 keadaan:
  | Keadaan | Ikon | Notis |
  |---|---|---|
  | Tiada filter Category | grip **merah** (tak boleh drag) | "Use the **Category** filter above to view speeches under a specific category, then drag & drop to reorder." |
  | Filter Category + **1** rekod | grip **kuning** | "Add at least **2 speeches** in this category to enable drag & drop reordering." |
  | Filter Category + **≥2** rekod | grip **hijau** (`.drag-handle`) | — |
- Nombor baris jadi `cursor-not-allowed` bila reorder tidak aktif.
- JS: `vendor/sortablejs/Sortable.min.js` + `new Sortable('#reorder-table', { handle: '.drag-handle' })` → `POST content-speech.reorder` (`ids`, `cat`, `page`, `perPage`) → reload. Endpoint hanya kemas `speech_sorting` untuk baris dalam kategori berkenan; offset ikut halaman semasa.



## 4. Ref

| Cat | Kod |
|---|---|
`SPEECH_CATEGORY` | `UCAPAN`, `PERASMIAN`, `MAJLIS`, `KENYATAAN` |
`SPEECH_SPEAKER` | `MENTERI`, `TIMBALAN_MENTERI`, `KETUA_SETIAUSAHA` (ikut tab portal asal nres.gov.my/ucapan) |
`SPEECH_STATUS` | `PUBLISHED`, `DRAFT`, `ARCHIVED` |

Label dropdown guna `descr_en` fallback `descr`.

## 5. Aliran

> Medan **Position** guna **Select2** (`class="speech-select2"`, `data-placeholder`, `allowClear`) — sama seperti modul Directory Staff: `vendor/jquery.min.js` + `vendor/select2/dist/js/select2.min.js` + partial `_directory-select2-styles`.

1. **Content → Speeches → Create**
2. Susunan baris dalam kad *Speech Information* (rujuk gaya `content-article/create`):
   - **Baris 1** (penuh): **Title**
   - **Baris 2** (4 kolum): **Language** · **Speaker Category** (wajib) · **Speaker Name** · **Position** (Select2)
   - **Baris 3** (4 kolum): **Speech Date** · **Category** (wajib) · **Status** (wajib) · **Sort Order**
   - **Baris 4** (2 kolum): **Event** · **Location**
   - **Baris 5** (4 kolum): **Publish Start Date** · **Publish Start Time** · **Publish End Date** · **Publish End Time** — label sama seperti `content-article/create` (ikon kalendar / ikon jam + butang ✕ clear)
   - **Baris 6** (penuh): **Cover Image** (FilePond, opsional) — pratonton gambar + **crop** (lihat nota di bawah)
   - **Baris 7** (penuh): **Speech Document** (FilePond, wajib untuk publish)
3. Tiada kad **Publish Window** / **Files** / blok **Additional details** berasingan — semuanya dalam satu kad
4. **Save** → redirect ke senarai
5. **Edit** → butang **Update**; kad **Translations** → **Add Translation** (cipta rekod anak `speech_main = 0`, status `DRAFT`) → edit → isi tajuk terjemahan. Badge **Status** dalam jadual Translations guna warna sama seperti senarai (index): `PUBLISHED` = hijau, `DRAFT` = kuning, lain-lain = kelabu
6. Pilih **kategori** dalam senarai untuk aktifkan **drag & drop** susunan (disimpan ke `speech_sorting`)
7. Padam translation guna ikon 🗑 (SweetAlert); padam parent akan padam semua translation + fail

> **Crop Cover Image** — konsep sama seperti *Image Preview* dalam `content-download/create`: selepas pilih gambar, modal **Crop image** (CropperJS) muncul; tekan **Use Crop** untuk simpan hasil crop pada saiz tetap **1240 × 1754 px** (nisbah A4 potret). Saiz sasaran ditakrif dalam peta `cropSizes` di `resources/js/filepond-image-cropper.js` (`speech_img: [1240, 1754]`, sama seperti `download_img`). Pratonton FilePond dihadkan `max-w-[200px]` supaya nampak macam kulit buku. Selepas ubah JS: `npm run build` (aset `public/build` di-gitignore).


## 6. Ujian

`tests/Feature/ContentSpeechTest.php` — **19 passed (72 assertions)**:

- index & create render
- validation wajib (title/category/status) + tarikh akhir ≥ tarikh mula
- store parent (`speech_main = 1`), simpan fail & imej
- update → redirect index
- add translation (child, DRAFT) & delete translation (child sahaja)
- destroy parent → translation + fail terpadam
- sort: notis + ikon sel `#` (merah tanpa filter · kuning 1 rekod · hijau ≥2 rekod)
- reorder: kemas `speech_sorting` ikut kategori sahaja · wajib ada `cat` (422)
- badge status baris translation dalam kad Translations = warna sama seperti index
