# Content Modules — Overview

## 1. Pengenalan

Terdapat **11 content modules** yang mengurus pelbagai jenis content untuk public site. Setiap module mempunyai struktur yang seragam — CRUD dengan **multi-language support** (translations), kecuali Public Holiday yang flat tanpa translation.

| Module | Content Type | Table (Main) | Table (Translation) | Parent-only? |
|--------|-------------|--------------|---------------------|--------------|
| **contentArticle** | Articles | `content_article` | `content_article_translation` | No (single table with translations) |
| **contentSlider** | Slider Images | `content_slider` | `content_slider_translation` | No (parent + translation tables) |
| **contentFeedback** | Feedback/Ticketing | `content_feedback_main` | `content_feedback_messages` | No (no translations, single language) |
| **contentDownload** | Downloads | `content_downloads` | Self-referencing | Yes (main + child translations) |
| **contentImage** | Images | `content_images` | Self-referencing | Yes (main + child translations) |
| **contentVideo** | Videos | `content_videos` | Self-referencing | Yes (main + child translations) |
| **contentApplication** | Links | `content_applications` | `content_application_translations` | No (parent + translation tables) |
| **contentPhotoGallery** | Photo Galleries | `content_photo_gallery` | `content_photo_gallery_translations` | No (parent + translation tables) |
| **contentPhotoList** | Photo Lists | `content_photo_list` | Self-referencing | Yes (main + child translations) |
| **contentPublicHoliday** | Public Holidays | `content_public_holidays` | - | Flat (no translations) |
| **contentCalendar** | Calendar Events | `content_calendar` | `content_calendar_translations` | No (parent + translation tables) |
| **contentDirectory** | Departments & Staff | `content_directory_departments` + `content_directory_staff` | - | Flat (no translations, 2 related tables) |
| **contentTender** | Tenders & Quotations | `content_tender` + `content_tender_translation` + `content_tender_document` | `content_tender_translation` | Yes (parent + translation + attachments) |
| **contentSpeech** | Speeches (Teks Ucapan) | `content_speeches` | Self-referencing (`speech_main` / `speech_parent_id`) | Yes (parent + child translation, ikut pola `contentDownload`) |
| **contentMedia** | Media Library (Keratan Akhbar + Kenyataan/Siaran Media) | `content_media` | `content_media_translations` | No (parent + translation tables, ikut pola `contentApplication`) |
| **contentParliamentQa** | Parliament Q&A (Soal Jawab Parlimen · Kamar Khas · MQT · RUU) | `content_parliament_qa` + `…_categories` + `…_documents` | `content_parliament_qa_translations` | No (parent + translation + kategori multi-value + lampiran berbilang) |
| **content-form** | Borang Aduan & Maklum Balas (enjin borang awam + peti masuk tiket) | `content_form` + `…_section` + `…_field` + `…_option` + `…_submission` + `…_answer` + `…_attachment` + `…_message` | `content_form_translation` | **Ya** — 9 jadual; bukan CRUD kandungan biasa (lihat [`content-form.md`](content-form.md)) |

---

## 2. Common Architecture

### Translation Patterns

Ada **dua pattern translation** dalam sistem:

**Pattern A — Separate Translation Table:**
- Main table + Translation table (one-to-many)
- Contoh: `content_article` ↔ `content_article_translation`
- Controller delete all translations then recreate on update
- Used by: Article, Application, Slider, Video, Photo Gallery, Calendar

**Pattern B — Self-Referencing:**
- Satu table dengan `parent_id` pointing ke main record
- `download_main = '1'` untuk parent, `download_main = '0'` untuk child
- Contoh: `content_downloads`, `content_images`, `content_videos`
- Tambahan route: `addTranslation`, `deleteTranslation`

### Common Features
- **Multi-language** — Setiap content boleh ada translation dalam BM/EN
- **File/Image Upload** — Guna FilePond, simpan di `storage/app/public/{module}/`
- **Ref-based dropdowns** — Category, Status dari `ref` table
- **WYSIWYG Editor** — Summernote untuk content artikel
- **Sort Order** — Optional (`nullable|integer|min:0`) pada module yang mempunyai field Sort; nilai kosong valid
- **Publish Schedule** — Start/End date dan time

### Validation & Required Fields

Semua content module guna server-side validation. HTML `required` tidak digunakan; label required ditanda `*` dan field yang gagal dipaparkan dengan border `border-error-300` serta mesej `text-theme-xs text-error-500`.

| Module | Required | Nota |
|--------|----------|------|
| `contentArticle` | Main-language title, Category, Status | Sort optional |
| `contentApplication` | Main-language title, Category, Status | Sort optional |
| `contentDownload` | Title, Category, Status | Sort optional |
| `contentImage` | Title, Category, Status | Sort optional (`min:0`) |
| `contentVideo` | Main-language title, Category, Status | Sort optional |
| `contentSlider` | Main-language title, Status | Sort optional |
| `contentPhotoGallery` | Main-language title, Category, Status | Sort optional; tiada input Sort dalam create/edit |
| `contentPublicHoliday` | Title, Start Date, Status | End Date optional |
| `contentCalendar` | Main-language title, Category, Status | Start/End date dan time optional |
| `contentDirectoryDepartment` | Code, Name (BM), Name (EN) | Sort optional, default `0` |
| `contentDirectoryStaff` | Name, Status | Sort optional, default `0` |
| `contentMedia` | Media Type, Main-language title, News Source (wajib untuk Keratan Akhbar), Publish Date, Status | Article Link optional (`url`), Cover Image optional (`image`), Scan File (wajib untuk Kenyataan/Siaran Media; PDF/JPG/PNG, max 15MB), Sort optional |
| `contentParliamentQa` | Type, Main-language title, House (Dewan), Parliament No., Term (Penggal), Meeting (Sesi), Parliament Date, Parliament Year, Status | Question Type (wajib hanya untuk `SOAL_JAWAB`), Question No., Member/Senator, Area, Ministry, Categories (multi), Answer PDF (`pdf`), Attachments (`pdf,doc,docx,xls,xlsx,zip`), Sort optional (`0` = auto hujung kumpulan sesi) |

Rujukan penuh: [`docs/VALIDATION-UI-GUIDE.md`](../VALIDATION-UI-GUIDE.md).

---

## 3. contentArticle

### Database

**Table:** `content_article` (PK: `article_id`)
| Column | Type | Description |
|--------|------|-------------|
| article_id | bigint (PK) | Primary key |
| article_code | string (nullable) | Unique slug |
| article_date | date (nullable) | Article date |
| article_category | string (nullable) | Category (from ref: ARTICLE_CAT) |
| article_subcategory | string (nullable) | Subcategory (from ref: ARTICLE_SUBCAT) |
| article_status | string (nullable) | Status (from ref: ARTICLE_STATUS) |
| article_url | string (nullable) | External URL |
| article_image | string (nullable) | Featured image path |
| article_sorting | integer | Sort order |
| article_portal_category | string (nullable) | Portal display |
| article_start_date/start_time | string (nullable) | Publish start |
| article_end_date/end_time | string (nullable) | Publish end |
| menu_set | string (nullable) | Menu category assignment |

**Table:** `content_article_translation` (PK: `article_translation_id`)
| Column | Type | Description |
|--------|------|-------------|
| article_translation_parent_id | integer | FK to article_id |
| article_translation_title | text | Translated title |
| article_translation_content | longText | Translated content (HTML) |
| article_translation_main | integer | 1 = main translation |
| article_translation_language | string | Language code |

### Routes

| Method | URI | Permission | Name |
|--------|-----|------------|------|
| GET | `/admin/content-article` | `content-article.view` | `content-article.index` |
| GET | `/admin/content-article/create` | `content-article.create` | `content-article.create` |
| POST | `/admin/content-article` | `content-article.create` | `content-article.store` |
| GET | `/admin/content-article/{id}/edit` | `content-article.update` | `content-article.edit` |
| PUT | `/admin/content-article/{id}` | `content-article.update` | `content-article.update` |
| DELETE | `/admin/content-article/{id}` | `content-article.delete` | `content-article.destroy` |
| POST | `/admin/content-article/reorder` | `content-article.update` | `content-article.reorder` |

### Controller — `ContentArticleController`

- `index()` — List articles with search (code, category, status, translation title), eager load translations
- `create()` — Load refs: ARTICLE_CAT, ARTICLE_SUBCAT, ARTICLE_STATUS, LANGUAGE, PORTAL, menu categories
- `store(ContentArticleRequest)` — Upload image → create article → create translations
- `edit($id)` — Load article with translations + refs
- `update(ContentArticleRequest, $id)` — Handle image replace → update article → delete old translations → recreate
- `destroy($id)` — Delete image, translations, article

### Views

- `index.blade.php` — Table: No, Slug, Title, Category, Status, Sort, Actions
- `create.blade.php` — Translations card (Alpine.js language switcher + Summernote for content) + Article Info card (Portal, Menu Category, Slug auto-generate UUID, Category, Subcategory, Status, Date, Publish Schedule, Sort, Image via FilePond)
- `edit.blade.php` — Same with pre-filled + image preview

### Frontend Display

```php
// HomeController@contentArticle($slug)
$article = ContentArticle::where('article_code', $slug)
    ->where('article_status', 'ACTIVE')
    ->first();
return view('home.module.article', ['article' => $article]);
```

---

## 4. contentSlider

### Database

**Table:** `content_slider` (PK: `slider_id`)
**Table:** `content_slider_translation` (PK: `slider_translation_id`)

Slider fields:
- slider_portal, slider_status, slider_sort, slider_type, slider_dimension, slider_transition
- slider_url_type (external/upload), slider_url, slider_url_upload
- slider_start_date/start_time/end_date/end_time

Translation fields:
- slider_translation_parent_id, slider_translation_title, slider_translation_img, slider_translation_main, slider_translation_language

### Routes

Standard CRUD with `content-slider.*` permission prefix.

### Features
- **Image Type** — Dynamic show/hide dimension & transition based on type
- **URL Type** — Toggle between external URL and file upload
- **Per-language image upload** — Setiap translation boleh ada gambar berbeza

---

## 5. contentDownload

### Database

**Table:** `content_downloads` (PK: `download_id`)

Self-referencing with `download_main` flag:
- `download_main = '1'` — Main record
- `download_main = '0'` — Translation child (`download_parent_id` → parent)

Fields: download_category, download_status, download_date, download_source, download_title, download_img, download_file, download_language, start/end dates/times

### Routes

| Method | URI | Permission | Name |
|--------|-----|------------|------|
| GET | `/admin/content-download` | `content-download.view` | `content-download.index` |
| GET | `/admin/content-download/create` | `content-download.create` | `content-download.create` |
| POST | `/admin/content-download` | `content-download.create` | `content-download.store` |
| GET | `/admin/content-download/{id}/edit` | `content-download.update` | `content-download.edit` |
| PUT | `/admin/content-download/{id}` | `content-download.update` | `content-download.update` |
| DELETE | `/admin/content-download/{id}` | `content-download.delete` | `content-download.destroy` |
| GET | `/admin/content-download/{id}/add-translation` | `content-download.update` | `content-download.add-translation` |
| DELETE | `/admin/content-download/translation/{id}` | `content-download.delete` | `content-download.delete-translation` |

### Features
- File upload (max 150MB, PDF/Word/Excel/PowerPoint) + Image upload (max 15MB)
- Translation children with add/delete per record

---

## 6. contentImage

### Database

**Table:** `content_images` (PK: `image_id`)

Self-referencing same pattern as contentDownload.

Fields: image_cat, image_type, image_status, image_title, image_file, image_url, image_sort, image_main, image_parent_id, image_language

### Routes

Standard CRUD + addTranslation/deleteTranslation, permission prefix `content-image.*`.

### Features
- Language select default kepada bahasa pertama yang tersedia
- Sort optional (`nullable|integer|min:0`); border error hanya muncul jika nilai tidak sah
- Title, Category, dan Status required (server-side validation)

---

## 7. contentVideo

### Database

**Table:** `content_videos` (PK: `video_id`)

Self-referencing same pattern.

Fields: video_cat, video_status, video_title, video_url, video_sort, video_file, video_main, video_parent_id, video_language, video_date, video_source, start/end dates/times

### Routes

Standard CRUD + addTranslation/deleteTranslation, permission prefix `content-video.*`.

---

## 8. contentApplication

### Database

**Table:** `content_applications` (PK: `application_id`)
**Table:** `content_application_translations` (PK: `application_translation_id`)

Fields: application_cat, **application_code**, application_status, application_url, application_img, **application_icon**, application_sort

> `application_code` — kod pendek item (cth. `INSTUN`, `JAS`). Ditambah oleh migration
> `2026_10_25_100000_add_application_code_to_content_applications_table.php`; sebelum ini
> `ContentApplicationController::index()` sudah merujuk lajur ini untuk carian tetapi lajur
> tidak wujud → carian gagal (`Unknown column 'application_code'`).
>
> `application_icon` — markup SVG ikon inline (migration `2026_10_25_110000_*`), digunakan
> oleh seksyen **Capaian Pantas** (#23) yang memerlukan ikon tanpa fail imej.
>
> Kategori Ref `APPLICATION_CAT` termasuk `AGENCY_LINKS` (Pautan Agensi, #25), `QUICK_LINKS`
> (Capaian Pantas, #23), `RELATED_LINKS` (Pautan Berkaitan, footer #21) dan `SOCIAL_MEDIA`
> (Media Sosial, footer #21).

Translation: application_translation_title, application_translation_main, application_translation_language

### Routes

Standard CRUD without addTranslation, permission prefix `content-application.*`.

---

## 9. contentPhotoGallery & contentPhotoList

Module untuk photo galleries — gallery mengandungi multiple photos.

**contentPhotoGallery** — Manage galleries (grouping).
**contentPhotoList** — Manage photos within each gallery.

### Routes

Standard CRUD with permission prefixes `content-photo-gallery.*` dan `content-photo-list.*`.

### Features
- Image upload via FilePond
- Multi-language support for gallery descriptions
- Sort order for photos

---

## 10. contentPublicHoliday

### Database

**Table:** `content_public_holidays` (PK: `public_holiday_id`)

| Column | Type | Description |
|--------|------|-------------|
| public_holiday_title | string (nullable) | Holiday title |
| public_holiday_start_date | date (nullable) | Start date |
| public_holiday_end_date | date (nullable) | End date |
| public_holiday_status | string (nullable) | Status (from ref: HOLIDAY_STATUS) |

### Routes

Standard CRUD with `content-public-holiday.*` permission prefix.

### Features
- Flat CRUD without translations
- Required at form level: Title, Start Date, Status (DB columns kekal nullable)
- Filter by status and year
- Status badge: ACTIVE (green), INACTIVE (red), ARCHIVE (gray)

---

## 11. contentCalendar

### Database

**Table:** `content_calendar` (PK: `calendar_id`)

| Column | Type | Description |
|--------|------|-------------|
| calendar_date_start | date (nullable) | Event start date |
| calendar_date_end | date (nullable) | Event end date |
| calendar_time_start | string (nullable) | Start time |
| calendar_time_end | string (nullable) | End time |
| calendar_category | string (nullable) | Category (from ref: CALENDAR_CATEGORY) |
| calendar_display | string (nullable) | Display mode (from ref: CALENDAR_DISPLAY) |
| calendar_status | string (nullable) | Status (from ref: CALENDAR_STATUS) |
| calendar_type | string (nullable) | Type (from ref: CALENDAR_TYPE) |
| calendar_owner_id | string (nullable) | Owner FK → backend_users.id |
| total_participant | integer (nullable) | Participant count |

**Table:** `content_calendar_translations` (PK: `calendar_translation_id`)

| Column | Type | Description |
|--------|------|-------------|
| calendar_translation_parent_id | integer | FK to calendar_id |
| calendar_translation_title | text | Agenda title |
| calendar_translation_content | text | Agenda details |
| calendar_translation_location | text | Event location |
| calendar_translation_main | integer | 1 = main translation |
| calendar_translation_language | string | Language code |

### Routes

Standard CRUD with `content-calendar.*` permission prefix.

### Features
- Multi-language translations via Alpine.js language dropdown
- Event Details card (translations) placed BEFORE Event Information card
- Start Date dan Start Time optional; End Date divalidasi `after_or_equal` hanya jika Start Date diisi
- Owner Select2 dropdown (backend_users) — selectable only on edit; `null` on create
- Index columns: No, Agenda, Category, Type, Start Date, End Date, Status, Actions
- Time displayed below date in index
- Status badge: ACTIVE (green), INACTIVE (red), ARCHIVE (gray)

---

## 12. contentDirectory

Module untuk mengurus **Departments** dan **Staff** dalam struktur hierarki.

> Dokumen terperinci untuk import Department dan Staff: [`content-directory-import.md`](content-directory-import.md).

### Database

**Table:** `content_directory_departments` (PK: `directory_department_id`)

| Column | Type | Description |
|--------|------|-------------|
| directory_department_id | bigint (PK) | Primary key |
| directory_department_code | string (nullable) | Department code |
| directory_department_name | string (nullable) | Name (BM) |
| directory_department_name_en | string (nullable) | Name (EN) |
| directory_department_parent_id | integer (nullable) | FK → self (parent) |
| directory_department_sort | integer | Sort order |
| created_by / updated_by | integer | User FKs |

**Table:** `content_directory_staff` (PK: `directory_staff_id`)

| Column | Type | Description |
|--------|------|-------------|
| directory_staff_id | bigint (PK) | Primary key |
| directory_staff_department_id | integer | FK → departments |
| directory_staff_name | string | Staff name |
| directory_staff_email | string (nullable) | Email |
| directory_staff_phone | string (nullable) | Phone |
| directory_staff_position | string (nullable) | Position |
| directory_staff_photo | string (nullable) | Photo path (FilePond upload) |
| directory_staff_sort | integer | Sort order |
| directory_staff_status | integer | 1=Active, 0=Inactive |
| created_by / updated_by | integer | User FKs |

### Routes

| Method | URI | Permission | Name |
|--------|-----|------------|------|
| GET | `/admin/content-directory-department` | `content-directory-department.view` | `content-directory-department.index` |
| GET/POST | `/admin/content-directory-department/create` | `content-directory-department.create` | `.create` / `.store` |
| GET/PUT | `/admin/content-directory-department/{id}/edit` | `content-directory-department.update` | `.edit` / `.update` |
| DELETE | `/admin/content-directory-department/{id}` | `content-directory-department.delete` | `.destroy` |
| POST | `/admin/content-directory-department/reorder` | `content-directory-department.update` | `.reorder` |
| GET | `/admin/content-directory-department/import` | `content-directory-department.import` | `.import` |
| POST | `/admin/content-directory-department/import/preview` | `content-directory-department.import` | `.import.preview` |
| POST | `/admin/content-directory-department/import` | `content-directory-department.import` | `.import.store` |
| GET | `/admin/content-directory-department/import/template` | `content-directory-department.import` | `.import.template` |
| GET | `/admin/content-directory-department/import/errors` | `content-directory-department.import` | `.import.errors` |
| GET | `/admin/content-directory-staff/import` | `content-directory-staff.import` | `.import` |
| POST | `/admin/content-directory-staff/import/preview` | `content-directory-staff.import` | `.import.preview` |
| POST | `/admin/content-directory-staff/import` | `content-directory-staff.import` | `.import.store` |
| GET | `/admin/content-directory-staff/import/template` | `content-directory-staff.import` | `.import.template` |
| GET | `/admin/content-directory-staff/import/errors` | `content-directory-staff.import` | `.import.errors` |

### Department Import

Import menyokong fail `.xlsx` dan `.csv` melalui OpenSpout. `.xls` lama tidak disokong.

Format template:

```text
code | name_bm | name_en | parent_code | sort
```

- `code`, `name_bm`, `name_en` wajib
- `parent_code` optional; kosong bermaksud root department
- Parent boleh merujuk department dalam fail yang sama atau department sedia ada
- Parent boleh berada pada mana-mana aras (tiada had kedalaman); cycle ditolak
- `sort` kosong menjadi `0`
- Jika `code` bermula dengan `0`, set format column sebagai Text dalam Excel
- Matching menggunakan `code` secara case-insensitive
- Code yang sudah wujud akan di-update; code baru akan di-insert
- Duplicate code dalam fail atau duplicate code dalam DB akan ditolak
- Unknown parent, self-parent dan circular reference akan ditolak
- Preview memaparkan valid/invalid rows sebelum Confirm Import
- Import menggunakan DB transaction; jika ada row invalid, tiada data dimasukkan
- Maximum 5,000 rows dan 200MB
- Error report boleh dimuat turun sebagai CSV
- Logo/photo tidak termasuk dalam flow ini

**Implementation files:**

```text
app/Http/Controllers/Backend/ContentDirectoryDepartmentImportController.php
app/Services/ContentDirectoryDepartmentImportService.php
resources/views/backend/module/contentDirectoryDepartment/import.blade.php
resources/views/backend/module/contentDirectoryDepartment/import-preview.blade.php
tests/Feature/ContentDirectoryDepartmentImportTest.php
```

### Staff Import

Import menyokong fail `.xlsx` dan `.csv` melalui OpenSpout. `.xls` lama tidak disokong.

Format template:

```text
name | email | phone | position_code | position_bm | position_en | department | sort | status
```

- `name` wajib; column lain optional
- Jawatan dirujuk ke modul **Parameters** (table `ref`, cat `DIRECTORY_STAFF_POSITION`): `position_code` → `code`, `position_bm` → `descr`, `position_en` → `descr_en`
- Jika `position_code` diisi, ia diutamakan; jika kosong, sistem cuba padan `position_bm` dahulu, kemudian `position_en`
- Jawatan yang tiada akan ditolak, kecuali checkbox **Create missing positions** ditick — jawatan baru dicipta dengan kod akronim BM (contoh `SDPI`), dan `_2`, `_3` jika kod sudah digunakan
- `email` tidak menggunakan validasi format strict dan `@` tidak wajib; nilai digunakan untuk matching staff sedia ada dan maksimum 255 aksara
- Email yang sudah wujud akan di-update; email baru atau kosong akan di-insert
- Duplicate email dalam fail atau duplicate email dalam DB akan ditolak
- `department` boleh guna department code (disyorkan), nama BM, atau nama EN
- Department mesti sudah wujud; Staff import tidak mencipta department
- Department kosong bermaksud staff tanpa department
- Department yang tidak wujud atau ambiguous akan ditolak
- `sort` kosong menjadi `0`
- `status` menerima Active/Inactive, Aktif/Tidak Aktif, 1/0; kosong menjadi Active
- `photo` tidak diimport dan tidak menjejaskan photo sedia ada semasa update
- `phone` menerima format tempatan atau antarabangsa seperti `03-8091 7245`, `(03) 8091 7245` atau `+60 12-345 6789`; set column sebagai Text jika Excel mengubah nilai
- Preview memaparkan valid/invalid rows sebelum Confirm Import
- Import menggunakan DB transaction; jika ada row invalid, tiada data dimasukkan
- Maximum 5,000 rows dan 200MB
- Error report boleh dimuat turun sebagai CSV

**Implementation files:**

```text
app/Http/Controllers/Backend/ContentDirectoryStaffImportController.php
app/Services/ContentDirectoryStaffImportService.php
resources/views/backend/module/contentDirectoryStaff/import.blade.php
resources/views/backend/module/contentDirectoryStaff/import-preview.blade.php
tests/Feature/ContentDirectoryStaffImportTest.php
```

### Features

- **Required fields** — Department: Code, Name (BM), Name (EN); Staff: Name, Status. Sort optional dan default `0`.
- **Digital Contact** — Action QR pada index staff; scan untuk simpan vCard. Butang disabled jika staff tiada email dan phone.
- **Hierarchical Departments** — Parent-child tree (unlimited depth), All view shows flattened tree with indentation
- **Per-parent Display Numbering** — Children reset to #1, #2, #3 under each parent in All view
- **Per-department Staff Sorting** — Staff reorder only within selected department filter; nilai `sort` sedia ada diagih semula (dan dinormalkan `1..n` jika ada duplicate) supaya pagination/search tidak menghasilkan duplicate
- **Photo Upload** — FilePond, stored at `storage/directory-staff/`, auto-delete on update/replace
- **Drag-and-drop Reorder** — Only enabled when a parent filter is selected (grey icon when no filter)
- **Collapsible tree (All view)** — Chevron pada baris yang ada anak, butang Expand all / Collapse all, state disimpan dalam `localStorage`
- **Root pagination (All view)** — 10 root per page; subtree setiap root kekal utuh pada halaman yang sama
- **Root Department Highlight** — `bg-brand-50` background for rows without parent
- **Info Banner** — Banner biru `brand-50` di index department & staff bila tiada filter: *"Use the Parent/Department filter above … then drag & drop to reorder."*

### Views — Department Index

Columns: #, Code, Name (BM), Name (EN), Parent, Actions
- Filter: Parent dropdown (All / Root / specific parent) + Search
- All view: hierarchical tree dengan **garis penghubung (├── / └──)**, per-parent numbering, **paginate ikut root (10 root/page)** dan **collapse/expand** (chevron, Expand/Collapse all, state disimpan dalam `localStorage`)
- Filtered view: paginated flat list (10/page) with drag handles — tiada chevron (senarai rata)
- Reorder: nilai `sort` sedia ada diagih semula ikut susunan baru — baris di halaman lain atau tersorok oleh search tidak berubah (tiada duplicate). Jika kumpulan ada `sort` duplicate (cth. semua `0`), ia dinormalkan `1..n` dahulu.

### Views — Staff Index

Columns: #, Photo, Name, Department, Position, Email, Phone, Sort, Status, Actions
- Filter: Department dropdown + Status dropdown + Search
- Drag handles only when department filter selected
- Pagination; reorder ikut agihan slot `sort` (tiada page offset)

---

## 13. Common Permissions

Setiap content module ada 4 permissions asas:

| Permission | Description |
|------------|-------------|
| `{module}.view` | View list |
| `{module}.create` | Create content |
| `{module}.update` | Edit content |
| `{module}.delete` | Delete content |

Module Directory Department dan Staff juga mempunyai permission tambahan:

| Permission | Description |
|------------|-------------|
| `content-directory-department.import` | Import department dari Excel/CSV |
| `content-directory-staff.import` | Import staff dari Excel/CSV |

Module prefixes:
- `content-article`
- `content-slider`
- `content-download`
- `content-image`
- `content-video`
- `content-application`
- `content-photo-gallery`
- `content-photo-list`
- `content-public-holiday`
- `content-calendar`
- `content-directory-department`
- `content-directory-staff`
- `content-media`
- `content-speech`
- `content-tender`
- `content-parliament-qa`
- `content-form` (borang awam + peti masuk tiket — lihat [`content-form.md`](content-form.md))
