# Backend Module Reference

> Senarai modul dalam `resources/views/backend-user/module/` berserta penerangan fungsi, struktur controller, model, migration, dan pattern translations.

---

## 1. Dashboard

| Item | Value |
|------|-------|
| **Route** | `/admin/dashboard` |
| **Controller** | Closure in `web.php` |
| **View** | `dashboard.blade.php` |

Halaman utama admin. Paparan statik tanpa logic controller.

---

## User Management

### 2. BackendUser

| Item | Value |
|------|-------|
| **Route prefix** | `/admin/backend-user` |
| **Controller** | `BackendUserController` |
| **Model** | `BackendUser` (table: `backend_users`) |
| **Permission prefix** | `backend-user.*` |
| **Views** | `index`, `create`, `edit`, `show` |

**Fungsi:** Pengurusan admin users. Setiap admin user boleh assign roles (Spatie Permission). Ada fitur:
- Login history (`last_login_at`)
- Two-factor authentication (`two_factor_code`, `two_factor_expires_at`)
- Email verification
- Profile edit sendiri

**Key fields:** `username`, `email`, `password`, `first_name`, `last_name`, `is_active`

### 3. FrontendUser

| Item | Value |
|------|-------|
| **Route prefix** | `/admin/frontend-user` |
| **Controller** | `FrontendUserController` |
| **Model** | `FrontendUser` (table: `frontend_users`) |
| **Permission prefix** | `frontend-user.*` |
| **Views** | `index`, `create`, `edit`, `show` |

**Fungsi:** Pengurusan public/frontend users. Sama struktur dengan BackendUser tapi tanpa role assignment (tiada `HasRoles` trait).

### 4. Role

| Item | Value |
|------|-------|
| **Route prefix** | `/admin/role` |
| **Controller** | `RoleController` |
| **Model** | `Spatie\Permission\Models\Role` (table: `roles`) |
| **Permission prefix** | `role.*` |
| **Views** | `index`, `create`, `edit` |

**Fungsi:** Pengurusan roles untuk BackendUser guard `admin`. Setiap role boleh assign multiple permissions. Role `super-admin` dilindungi dari deletion.

### 5. Permission

| Item | Value |
|------|-------|
| **Route prefix** | `/admin/permission` |
| **Controller** | `PermissionController` |
| **Model** | `Spatie\Permission\Models\Permission` (table: `permissions`) |
| **Permission prefix** | - |
| **Views** | `index`, `create`, `edit` |

**Fungsi:** Pengurusan individual permissions. Setiap permission guna format `{module}.{action}`.

---

## Translation Patterns

### Pattern A: Separate Translation Table

| Module | Parent Table | Translation Table |
|--------|-------------|-------------------|
| ContentArticle | `content_article` | `content_article_translations` |
| ContentApplication | `content_applications` | `content_application_translations` |
| ContentSlider | `content_slider` | `content_slider_translations` |
| ContentVideo | `content_video` | `content_video_translations` |

### Pattern B: Flat Sibling (Self-Referential)

| Module | Table | Main Field | Parent Field | Language Field |
|--------|-------|------------|-------------|----------------|
| ContentDownload | `content_downloads` | `download_main` | `download_parent_id` | `download_language` |
| ContentImage | `content_images` | `image_main` | `image_parent_id` | `image_language` |
| ContentPhotoGallery | `content_photo_gallery_translations` | - (separate table) | `gallery_translation_parent_id` → `gallery_id` | `gallery_translation_language` |
| ContentPhotoList | `content_photo_list` | `photo_main` | `photo_parent_id` | `photo_language` |

### View Translation UI Detail

| Module | Pattern | Language Switcher | Translatable Fields | Inline Alpine Tabs? | Add/Delete Method |
|--------|---------|-----------------|---------------------|:-------------------:|-------------------|
| ContentArticle | Separate table | ✅ Pill buttons | `title`, `content` (Summernote) | ✅ | Delete + Reinsert on submit |
| ContentSlider | Separate table | ✅ Pill buttons | `title`, `img` (FilePond per language) | ✅ | Delete + Reinsert with image preservation |
| ContentApplication | Separate table | ✅ Dropdown | `title` | ✅ | Delete + Reinsert on submit |
| ContentVideo | Separate table | ✅ Dropdown | `title` (DB juga ada `content` tapi tak diisi dlm view) | ✅ | Delete + Reinsert on submit |
| ContentPhotoGallery | Separate table | ✅ Dropdown | `title`, `descr` (Summernote) | ✅ | Delete + Reinsert on submit |
| ContentPhotoList | Flat sibling | ✅ Pill buttons | `descr` | ✅ | Delete + Reinsert siblings |
| ContentDownload | Flat sibling | ❌ Single select | `title`, `img`, `file` | ❌ | Separate add/delete page |
| ContentImage | Flat sibling | ❌ Single select | `title`, `file` | ❌ | Separate add/delete page |

> **Pill buttons** = Language tabs warna brand (macam ContentPhotoList). **Dropdown** = Language selector guna `<select>`. **Single select** = Satu language sahaja per form submission.

### Key Implementation Differences

**ContentSlider** — Guna image preservation pattern: sebelum delete translations, capture `slider_translation_img` paths dulu, then restore kalau user tak upload baru.

**ContentApplication & ContentVideo** — Translations card diletakkan SEBELUM main info card (berbeza dari modul lain yang letak translations card selepas main info card).

**ContentDownload & ContentImage** — Masih guna old pattern: create main entry dengan 1 language, then "Add Translation" button → page berasingan untuk tambah sibling translation. Belum ditukar ke inline Alpine tabs.

---

## Content Modules — Detailed Flow

Setiap modul content mengikut flow CRUD standard dengan variasi pada cara translations dikendalikan.

### Common Flow (all modules)

```
Browser                          Controller                         Database
──────                          ──────────                         ────────
  GET /index ──────────────────→ index()
                                  ├─ Query parent rows
                                  ├─ Load Ref dropdowns
                                  └─ Return view ─────────────────→ items
  GET /create ─────────────────→ create()
                                  ├─ Load Ref dropdowns
                                  └─ Return view
  POST /store ─────────────────→ store(Req)
                                  ├─ Upload files
                                  ├─ Create parent row ───────────→ INSERT parent
                                  ├─ Create translations ─────────→ INSERT siblings
                                  └─ Redirect to index
  GET /{id}/edit ──────────────→ edit($id)
                                  ├─ Find parent with translations
                                  ├─ Load Ref dropdowns
                                  └─ Return view
  PUT /{id} ───────────────────→ update(Req, $id)
                                  ├─ Upload/Replace files
                                  ├─ Update parent ────────────────→ UPDATE parent
                                  ├─ Delete old translations ──────→ DELETE siblings
                                  ├─ Re-create translations ───────→ INSERT siblings
                                  └─ Redirect to index
  DELETE /{id} ────────────────→ destroy($id)
                                  ├─ Delete translation files
                                  ├─ Delete parent files
                                  ├─ Delete translations ──────────→ DELETE siblings
                                  └─ Delete parent ────────────────→ DELETE parent
```

### View Structure (create/edit)

```
Main Card (shared fields)
├── Portal / Code (full width)
├── Status + Category (2-column dropdowns)
├── Date + Location (2-column)
├── Thumbnail / Image (FilePond)
└── (other shared fields)

Translations Card (Alpine tabs)
├── Language pill buttons
│   ├── EN │ MS │ ZH │ ...
├── Per-tab content (shown based on selected tab)
│   ├── Title input
│   ├── Description / Content (Summernote for some modules)
│   └── ✓ Set as main translation (radio)
└── Hidden inputs: translations[code][field]
```

---

### 6. ContentArticle

| Item | Value |
|------|-------|
| **Route prefix** | `/admin/content-article` |
| **Controller** | `ContentArticleController` |
| **Model** | `ContentArticle` (table: `content_article`) |
| **Translation model** | `ContentArticleTranslation` (table: `content_article_translations`) |
| **Permission prefix** | `content-article.*` |
| **Views** | `index`, `create`, `edit` |

**Fungsi:** Pengurusan artikel/content berita. Setiap artikel ada satu main translation + multiple translations.

#### Database

**Migration:** `2026_05_13_142707_create_content_article_table.php`

| Column | Type | Notes |
|--------|------|-------|
| `article_id` | bigint unsigned PK | Auto-increment |
| `article_code` | varchar(255) | Nullable |
| `article_date` | date | Nullable |
| `article_category` | varchar(255) | Nullable, Ref: `ARTICLE_CAT` |
| `article_subcategory` | varchar(255) | Nullable, Ref: `ARTICLE_SUBCAT` |
| `article_status` | varchar(255) | Nullable, Ref: `ARTICLE_STATUS` |
| `article_url` | varchar(255) | Nullable |
| `article_sorting` | int(11) | Default `0` |
| `article_image` | varchar(255) | File path |
| `article_portal_category` | varchar(255) | Nullable |
| `article_start_date` / `_time` | varchar(255) | Scheduling |
| `article_end_date` / `_time` | varchar(255) | Scheduling |
| `menu_set` | varchar(500) | Menu attachment |
| `created_by`, `updated_by` | int(11) | Audit |

**Migration:** `2026_05_13_142708_create_content_article_translation_table.php`

| Column | Type | Notes |
|--------|------|-------|
| `article_translation_id` | bigint unsigned PK | |
| `article_translation_parent_id` | int(11) | FK → `article_id` |
| `article_translation_title` | text | Translatable |
| `article_translation_content` | longtext | Translatable (Summernote) |
| `article_translation_main` | int(11) | `1` = main |
| `article_translation_language` | varchar(255) | e.g. `en`, `ms` |
| `created_by`, `updated_by` | int(11) | |

#### Controller

```
File: app/Http/Controllers/Backend/ContentArticleController.php
Class: ContentArticleController extends Controller
Uses: Controller, ContentArticleRequest, ContentArticle, ContentArticleTranslation, Ref, Request

index(Request)
  ├── ContentArticle::with('translations')
  ├── ->when(search, ...) — searches article_code, article_category, article_status, translation title
  ├── ->orderBy('article_sorting')->orderBy('created_at', 'desc')
  ├── ->paginate(10)
  ├── Load $categories, $subcategories, $statuses from Ref
  └── Return view with $articles

create()
  ├── Load $categories, $subcategories, $statuses, $languages from Ref
  └── Return view

store(ContentArticleRequest)
  ├── Upload 'article_image' → storeAs('content-article', ...)
  ├── ContentArticle::create([...shared fields...])
  ├── Loop $request->translations as $lang => $trans:
  │   └── ContentArticleTranslation::create([title, content, main, language, parent_id])
  └── flash + redirect index

edit($id)
  ├── ContentArticle::with('translations')->findOrFail($id)
  ├── Load all Ref dropdowns
  └── Return view with $article

update(ContentArticleRequest, $id)
  ├── Handle image replacement (delete old if new uploaded)
  ├── $article->update([...shared fields...])
  ├── $article->translations()->delete()  ← DELETE all
  ├── Loop $request->translations → ContentArticleTranslation::create() each  ← RE-INSERT
  └── flash + redirect index

destroy($id)
  ├── Delete article_image from storage
  ├── $article->translations()->delete()
  ├── $article->delete()
  └── flash + redirect index
```

#### View Flow

**index.blade.php:** Table of articles, search bar, status/category filter badges, action buttons (Edit/Delete).

**create.blade.php:**
```
Form → POST /content-article
├── Main Card:
│   ├── article_code (text)
│   ├── article_date (date)
│   ├── article_category + article_subcategory (2-col selects, Ref)
│   ├── article_status + article_portal_category (2-col selects, Ref)
│   ├── article_image (FilePond)
│   ├── article_sorting (number)
│   ├── article_url (text)
│   ├── article_start_date/time + article_end_date/time (2-col)
│   └── menu_set (text)
├── Translations Card (Alpine tabs):
│   ├── Language pill buttons
│   ├── Title input per language
│   ├── Content (Summernote) per language — id="summernote-{code}"
│   └── Main translation radio
└── Cancel + Save buttons
```

**edit.blade.php:** Sama macam create, tapi pre-populated.

---

### 7. ContentApplication

| Item | Value |
|------|-------|
| **Route prefix** | `/admin/content-application` |
| **Controller** | `ContentApplicationController` |
| **Model** | `ContentApplication` (table: `content_applications`) |
| **Translation model** | `ContentApplicationTranslation` (table: `content_application_translations`) |
| **Permission prefix** | `content-application.*` |
| **Views** | `index`, `create`, `edit` |

**Fungsi:** Pengurusan aplikasi/pautan luar. Boleh upload gambar ikon.

#### Database

**Migration:** `2026_05_11_130658_create_content_applications_table.php`

| Column | Type |
|--------|------|
| `application_id` | int(10) unsigned PK |
| `application_cat` | varchar(255), Ref: `APPLICATION_CAT` |
| `application_status` | varchar(255), Ref: `APPLICATION_STATUS` |
| `application_url` | varchar(255) |
| `application_img` | varchar(255), file path |
| `application_sort` | int(11) |
| `created_by`, `updated_by` | int(11) |

**Migration:** `2026_05_11_130803_create_content_application_translations_table.php`

| Column | Type |
|--------|------|
| `application_translation_id` | int(10) unsigned PK |
| `application_translation_parent_id` | int(11), FK |
| `application_translation_title` | varchar(255) |
| `application_translation_main` | int(11) |
| `application_translation_language` | varchar(255) |

#### Controller

```
File: app/Http/Controllers/Backend/ContentApplicationController.php
Class: ContentApplicationController extends Controller

index()   — ContentApplication::with('translations'), search, paginate 10
create()  — Load $categories, $statuses, $languages from Ref
store()   — Upload image, create parent, create translations
edit()    — with('translations')->findOrFail, load dropdowns
update()  — update parent, delete+reinsert translations
destroy() — delete image + translations + parent
```

#### View Flow

Sama macam ContentArticle tapi ringkas — hanya `application_cat` (select), `application_status`, `application_url`, `application_img` (FilePond), `application_sort`.

---

### 8. ContentDownload

| Item | Value |
|------|-------|
| **Route prefix** | `/admin/content-download` |
| **Controller** | `ContentDownloadController` |
| **Model** | `ContentDownload` (table: `content_downloads`) |
| **Permission prefix** | `content-download.*` |
| **Translation pattern** | Flat sibling |
| **Translation UI** | Separate add/delete page flow (not yet inline) |
| **Views** | `index`, `create`, `edit` |

**Fungsi:** Pengurusan fail muat turun. Setiap download ada `download_img` (preview) dan `download_file` — berbeza mengikut bahasa.

#### Database

**Migration:** `2026_05_14_112409_create_content_downloads_table.php`

| Column | Type | Notes |
|--------|------|-------|
| `download_id` | bigint unsigned PK | |
| `download_main` | varchar(255) | `1` = parent, `0` = sibling |
| `download_parent_id` | varchar(255) | FK ke parent `download_id` |
| `download_language` | varchar(255) | |
| `download_category` | varchar(255) | Ref: `DOWNLOAD_CAT` |
| `download_status` | varchar(255) | Ref: `DOWNLOAD_STATUS` |
| `download_date` | date | |
| `download_source` | varchar(255) | |
| `download_start_date` / `_time` | varchar(255) | |
| `download_end_date` / `_time` | varchar(255) | |
| `download_title` | varchar(255) | Translatable |
| `download_img` | varchar(255) | File path, translatable |
| `download_file` | varchar(255) | File path, translatable |
| `created_by`, `updated_by` | varchar(255) | |

#### Controller

```
File: app/Http/Controllers/Backend/ContentDownloadController.php

index()
  ├── ContentDownload::where('download_main', '1')->withCount('translations')
  ├── search by download_title, download_category, download_status
  ├── paginate 10
  └── Load $categories, $statuses from Ref

create()
  ├── Load $categories, $statuses, $languages from Ref
  └── Return view

store()
  ├── Upload download_img → content-download/images/
  ├── Upload download_file → content-download/files/
  ├── ContentDownload::create(['download_main' => '1', ...])
  └── flash + redirect index

edit($id)
  ├── ContentDownload::findOrFail($id)
  ├── If main: $translations = ContentDownload::where('download_parent_id', $id)->get()
  └── Return view with $download, $translations

update()
  ├── Handle image/file replacement
  ├── $download->update([...fields...])
  └── flash + redirect index
  └── Note: hanya update row sendiri — translations diupdate via edit child

addTranslation($id)
  ├── Check if parent is main
  ├── ContentDownload::create(['download_main'=>'0', 'download_parent_id'=>$id, ...])
  ├── Pre-fill from parent + '(Translation)' suffix
  └── Redirect to edit child

deleteTranslation($id)
  ├── Delete image + file from storage
  ├── Delete child row
  └── Redirect back to parent edit

destroy($id)
  ├── If main: loop translations → delete each img/file → delete each
  ├── Delete parent img/file
  └── Delete parent row
```

#### View Flow

**create.blade.php:** Form dengan `download_title`, `download_language`, `download_category`, `download_status`, `download_date`, `download_source`, `download_img` (FilePond), `download_file` (FilePond), scheduling dates.

**edit.blade.php:** Sama macam create. Tambahan: **Translations table section** — senarai child translations dengan button Edit/Delete. "Add Translation" button → ke `addTranslation()`.

---

### 9. ContentImage

| Item | Value |
|------|-------|
| **Route prefix** | `/admin/content-image` |
| **Controller** | `ContentImageController` |
| **Model** | `ContentImage` (table: `content_images`) |
| **Permission prefix** | `content-image.*` |
| **Translation pattern** | Flat sibling |
| **Translation UI** | Separate add/delete page flow |
| **Views** | `index`, `create`, `edit` |

**Fungsi:** Pengurusan imej. Dikategorikan mengikut `image_cat` dan `image_type`.

#### Database

**Migration:** `2026_05_14_225313_create_content_images_table.php`

| Column | Type |
|--------|------|
| `image_id` | int(10) unsigned PK |
| `image_cat` | varchar(255), Ref: `IMAGE_CAT` |
| `image_type` | varchar(255), Ref: `IMAGE_TYPE` |
| `image_status` | varchar(255), Ref: `IMAGE_STATUS` |
| `image_title` | varchar(255), translatable |
| `image_file` | varchar(255), translatable file |
| `image_url` | varchar(255) |
| `image_sort` | int(11) |
| `image_main` | int(11) |
| `image_parent_id` | int(11) |
| `image_language` | varchar(255) |

#### Controller

```
File: app/Http/Controllers/Backend/ContentImageController.php

index()   — where('image_main', '1')->withCount('translations'), search, paginate
create()  — Load $categories, $types, $statuses, $languages from Ref
store()   — Upload file, create parent
edit()    — findOrFail, load siblings if main
update()  — Handle file replacement, update row
addTranslation()  — Create child row with INACTIVE status
deleteTranslation()  — Delete file + child row
destroy() — Delete all children + files → delete parent
```

#### View Flow

Sama macam ContentDownload. Form: `image_cat`, `image_type`, `image_status`, `image_title`, `image_file` (FilePond), `image_url`, `image_sort`.

---

### 10. ContentPhotoGallery

| Item | Value |
|------|-------|
| **Route prefix** | `/admin/content-photo-gallery` |
| **Controller** | `ContentPhotoGalleryController` |
| **Model** | `ContentPhotoGallery` (table: `content_photo_gallery`) |
| **Translation model** | `ContentPhotoGalleryTranslation` (table: `content_photo_gallery_translations`) |
| **Permission prefix** | `content-photo-gallery.*` |
| **Translation pattern** | Separate table (inline Alpine tabs) |
| **Views** | `index`, `create`, `edit` |

**Fungsi:** Pengurusan galeri foto. Satu gallery = satu thumbnail + multiple photos.

#### Database

**Migration:** `2026_05_16_172752_create_content_photo_gallery_table.php`

| Column | Type | Notes |
|--------|------|-------|
| `gallery_id` | int(10) unsigned PK | |
| `gallery_date` | varchar(255) | |
| `gallery_location` | varchar(255) | |
| `gallery_status` | varchar(255) | Ref: `GALLERY_STATUS` |
| `gallery_portal` | varchar(255) | |
| `gallery_thumbnail` | varchar(255) | File path |
| `gallery_cat` | varchar(255) | Ref: `GALLERY_CAT` |
| `gallery_code` | varchar(255) | UUID slug |
| `gallery_sort` | int(11) | Default `0`, NOT NULL |

**Translation Migration:** `2026_05_16_180000_create_content_photo_gallery_translations_table.php`

| Column | Type | Notes |
|--------|------|-------|
| `gallery_translation_id` | bigint unsigned PK | |
| `gallery_translation_parent_id` | bigint unsigned | FK → `gallery_id` (cascade) |
| `gallery_translation_title` | varchar(255) | Translatable |
| `gallery_translation_descr` | text | Translatable (Summernote) |
| `gallery_translation_main` | boolean | `1` = main |
| `gallery_translation_language` | varchar(10) | e.g. `en`, `ms` |
| `created_by`, `updated_by` | bigint unsigned | |

#### Model

```
File: app/Models/Backend/ContentPhotoGallery.php
Table: content_photo_gallery
Fillable: [gallery_date, gallery_location, gallery_status, gallery_portal, gallery_thumbnail,
           gallery_cat, gallery_code, gallery_sort, created_by, updated_by]

Relationships:
  translations()  → hasMany(ContentPhotoGalleryTranslation::class, 'gallery_translation_parent_id', 'gallery_id')
  photoLists()    → hasMany(ContentPhotoList::class, 'photo_gallery_id', 'gallery_id')
```

#### Controller

```
File: app/Http/Controllers/Backend/ContentPhotoGalleryController.php

index(Request)
  ├── ContentPhotoGallery::with('translations')->withCount('photoLists')
  ├── ->when(search, ...) — search by gallery_cat, gallery_status, gallery_code,
  │     orWhereHas('translations', gallery_translation_title)
  ├── ->orderBy('gallery_sort')->orderBy('created_at', 'desc')
  ├── ->paginate(10)
  ├── Load $categories (GALLERY_CAT), $statuses (GALLERY_STATUS) from Ref
  └── Return view with $galleries, $categories, $statuses, $search

create()
  ├── Load $categories, $statuses, $languages from Ref
  └── Return view

store(ContentPhotoGalleryRequest)
  ├── Upload 'gallery_thumbnail' → storeAs('content-photo-gallery', ...)
  ├── ContentPhotoGallery::create([...shared fields...])
  ├── Loop $request->translations as $lang => $trans:
  │   └── If title not empty → ContentPhotoGalleryTranslation::create([
  │         parent_id, title, descr, main, language])
  └── flash + redirect index

edit($id)
  ├── $gallery = ContentPhotoGallery::with('translations')->findOrFail($id)
  ├── Load $categories, $statuses, $languages from Ref
  └── Return view with $gallery, dropdowns

update(ContentPhotoGalleryRequest, $id)
  ├── Handle thumbnail replacement
  ├── $gallery->update([...shared fields...])
  ├── $gallery->translations()->delete()  ← DELETE all
  ├── Loop $request->translations → ContentPhotoGalleryTranslation::create() each  ← RE-INSERT
  └── flash + redirect index

destroy($id)
  ├── Delete thumbnail file
  ├── $gallery->translations()->delete()
  ├── ContentPhotoList::where('photo_gallery_id', $id)->delete()
  └── $gallery->delete()
```

#### View Flow

**index.blade.php:**
```
Table: #, Thumbnail (img), Title, Photos count, Category (badge),
       Status (badge), Actions (Edit/Delete)
Title from main translation (gallery_translation_main=1) via $gallery->translations
"Add Gallery" button → create
"Add Photo" on each row → content-photo-list.index?gallery_id=X
```

**create.blade.php:**
```
Form → POST /content-photo-gallery
├── Main Card:
│   ├── gallery_portal (text)
│   ├── gallery_code (UUID, auto-generated + regenerate button)
│   ├── gallery_status + gallery_cat (2-col selects, Ref)
│   ├── gallery_date (date picker) + gallery_location (2-col)
│   └── gallery_thumbnail (FilePond)
├── Translations Card (Alpine pill tabs):
│   ├── Pill tabs: EN | MS | ZH | ...
│   ├── Title input per language
│   ├── Description (Summernote) per language — id="summernote-{code}"
│   └── ✓ Set as main translation (radio)
└── Cancel + Save buttons
```

**edit.blade.php:** Sama macam create, tapi pre-populated dari `$gallery->translations`.
- Thumbnail tunjuk current image + FilePond untuk replacement
- Translations card pre-populated via `$gallery->translations->firstWhere('gallery_translation_language', $code)`

---

### 11. ContentPhotoList

| Item | Value |
|------|-------|
| **Route prefix** | `/admin/content-photo-list` |
| **Controller** | `ContentPhotoListController` |
| **Model** | `ContentPhotoList` (table: `content_photo_list`) |
| **Permission prefix** | `content-photo-list.*` |
| **Translation pattern** | Flat sibling (inline Alpine tabs) |
| **Views** | `index`, `create`, `edit` |

**Fungsi:** Pengurusan foto dalam galeri. Setiap foto dimiliki satu `ContentPhotoGallery`.

#### Database

**Migration:** `2026_05_16_172753_create_content_photo_list_table.php`

| Column | Type | Notes |
|--------|------|-------|
| `photo_id` | int(10) unsigned PK | |
| `photo_gallery_id` | int(11) | FK → `gallery_id` |
| `photo_main` | int(11) | `1` = parent |
| `photo_parent_id` | int(11) | FK |
| `photo_language` | varchar(255) | |
| `photo_sort` | int(11) | |
| `photo_url` | varchar(255) | File path |
| `photo_descr` | text | Translatable |

#### Model

```
File: app/Models/Backend/ContentPhotoList.php
Table: content_photo_list

Relationships:
  translations() → hasMany(ContentPhotoList::class, 'photo_parent_id', 'photo_id')
  gallery()      → belongsTo(ContentPhotoGallery::class, 'photo_gallery_id', 'gallery_id')
```

#### Controller

```
File: app/Http/Controllers/Backend/ContentPhotoListController.php

index(Request)
  ├── ContentPhotoList::where('photo_main', '1')->with('gallery')
  ├── ->when($galleryId, ...) — filter by photo_gallery_id
  ├── ->when($search, ...) — search by photo_descr
  ├── ->orderBy('photo_sort')->orderBy('created_at', 'desc')
  ├── ->paginate(10)
  ├── Load $galleries (parent only), $statuses (PHOTO_STATUS) from Ref
  └── Return view with $photos, $galleries, $galleryId

create()
  ├── Load $galleries (parent only), $languages from Ref
  └── Return view

store(ContentPhotoListRequest)
  ├── Upload 'photo_url' → storeAs('content-photo-list', ...)
  ├── Find $mainLang from translations
  ├── Create parent (photo_main=1, photo_language=mainLang, descr=translations[mainLang][descr])
  ├── Loop other translations → create siblings
  ├── If _redirect → redirect to _redirect
  └── Else → redirect index

edit($id)
  ├── $photo = findOrFail
  ├── If main: $translations = where('photo_parent_id', $id)->get()
  ├── Load $galleries, $languages
  └── Return view

update(ContentPhotoListRequest, $id)
  ├── Handle photo_url replacement
  ├── Find $mainLang, update parent
  ├── Delete siblings → re-create
  └── Redirect to index with gallery_id

destroy($id)
  ├── If main: delete children + their files
  ├── Delete own photo file
  └── redirect()->back()

bulkStore(Request)
  ├── Validate: gallery_id + files array
  ├── Find $mainLang from translations
  ├── For each uploaded file:
  │   ├── Store file
  │   ├── Create parent
  │   └── Create siblings for other languages
  └── Flash count + redirect
```

#### View Flow

**index.blade.php:**
```
If filtered by gallery_id:
  ├── Breadcrumb: Home > Galleries > Gallery Name > Photo List
  ├── "Back" button
  ├── Heading: "Photos in: [Gallery Name]"
Table: #, Thumbnail (56×80px fixed), Description, Sort, Actions (Edit/Delete)
Buttons: "Add Single Photo" + "Bulk Upload"
Pagination 10 per page
```

**create.blade.php — Single Upload:**
```
Form → POST /content-photo-list
├── Gallery (select/readonly) + Sort (2-column)
├── Image (FilePond single)
├── Translations (pill tabs):
│   ├── Language pill buttons
│   ├── Description textarea per language
│   └── ✓ Set as main translation
└── Cancel + Save
```

**create.blade.php — Bulk Upload:**
```
Form → POST /content-photo-list/bulk-upload
├── Gallery (select/readonly)
├── Images (FilePond multiple)
├── Translations (same pill tabs):
│   ├── Description per language — (applied to all)
│   └── ✓ Set as main translation
└── Cancel + Upload All
```

**edit.blade.php:**
```
Form → PUT /content-photo-list/{id}
├── Gallery (select) + Sort (2-column)
├── Image: current preview + FilePond replacement
├── Translations (pill tabs, if photo_main==1):
│   ├── Description per language, pre-populated
│   └── ✓ Set as main translation
└── Cancel + Update
```

---

### 12. ContentSlider

| Item | Value |
|------|-------|
| **Route prefix** | `/admin/content-slider` |
| **Controller** | `ContentSliderController` |
| **Model** | `ContentSlider` (table: `content_slider`) |
| **Translation model** | `ContentSliderTranslation` (table: `content_slider_translations`) |
| **Permission prefix** | `content-slider.*` |
| **Translation pattern** | Separate table (inline Alpine tabs) |
| **Views** | `index`, `create`, `edit` |

**Fungsi:** Pengurusan slider/banner. Setiap slider ada per-language image.

#### Database

**Migration:** `2026_05_14_172227_create_content_slider_table.php`

| Column | Type |
|--------|------|
| `slider_id` | int(10) unsigned PK |
| `slider_portal` | varchar(255) |
| `slider_status` | varchar(255) |
| `slider_sort` | int(11) |
| `slider_url_type` | varchar(255) |
| `slider_url` | varchar(255) |
| `slider_url_upload` | varchar(255) |
| `slider_type` | varchar(255) |
| `slider_dimension` | varchar(255) |
| `slider_transition` | varchar(255) |
| `slider_start_date` / `_time` | varchar(255) |
| `slider_end_date` / `_time` | varchar(255) |

**Migration:** `2026_05_14_172233_create_content_slider_translation_table.php`

| Column | Type |
|--------|------|
| `slider_translation_id` | int(10) unsigned PK |
| `slider_translation_parent_id` | int(11) |
| `slider_translation_title` | text |
| `slider_translation_img` | text, file path per bahasa |
| `slider_translation_main` | int(11) |
| `slider_translation_language` | varchar(255) |

#### Controller

```
File: app/Http/Controllers/Backend/ContentSliderController.php

index()   — ContentSlider::with('translations'), search, paginate 10
create()  — Load $statuses, $languages, $types, $dimensions, $urlTypes, $portals from Ref
store()   — Upload slider_url_upload, create parent, loop translations: per-language image upload + create translation
edit()    — with('translations')->findOrFail, load all dropdowns
update()  — Handle url_upload replacement, capture old images before delete → re-insert preserving old images if no new file
destroy() — Loop translations delete each image → delete parent url_upload → delete translations + parent
```

**Update image preservation pattern (unique to Slider):**
```php
// Sebelum delete translations, capture gambar lama
$oldImgs = $slider->translations->pluck('slider_translation_img', 'slider_translation_language');
//    e.g.: ['en' => 'sliders/en-image.jpg', 'ms' => 'sliders/ms-image.jpg']

$slider->translations()->delete();

// Loop re-insert
foreach ($request->translations as $lang => $trans) {
    $imgPath = $oldImgs[$lang] ?? null;  // guna semula gambar lama
    
    if (isset($trans['img']) && $trans['img'] instanceof UploadedFile) {
        // upload baru → delete old, store new
        if ($imgPath) Storage::delete($imgPath);
        $imgPath = $file->storeAs('content-slider', $filename, 'public');
    }
    
    ContentSliderTranslation::create([... 'slider_translation_img' => $imgPath ...]);
}
```

#### View Flow

Form dengan banyak dropdown: `slider_portal`, `slider_type`, `slider_dimension`, `slider_url_type`, `slider_status`, `slider_sort`, `slider_url`, `slider_url_upload` (FilePond), `slider_transition`, scheduling dates.

Translations card: Title + Image upload per language.

---

### 13. ContentVideo

| Item | Value |
|------|-------|
| **Route prefix** | `/admin/content-video` |
| **Controller** | `ContentVideoController` |
| **Model** | `ContentVideo` (table: `content_video`) |
| **Translation model** | `ContentVideoTranslation` (table: `content_video_translations`) |
| **Permission prefix** | `content-video.*` |
| **Translation pattern** | Separate table |
| **Translation UI** | Not yet inline |
| **Views** | `index`, `create`, `edit` |

**Fungsi:** Pengurusan video. Support multiple sources (upload, external URL, YouTube embed).

#### Database

**Migration:** `2026_05_15_011242_create_content_video_table.php`

| Column | Type |
|--------|------|
| `video_id` | int(11) PK |
| `video_code` | varchar(255), auto-generated |
| `video_page` | varchar(255) |
| `video_status` | varchar(255) |
| `video_date` | date |
| `video_source` | varchar(255) |
| `video_url` | varchar(255) |
| `video_upload` | varchar(255) |
| `video_external` | varchar(255) |
| `video_img` | varchar(255) |
| `video_category` | varchar(255) |
| `video_sorting` | int(11) |
| `meta_title`, `meta_descr`, `meta_keyword` | text |
| `meta_author`, `meta_img` | text |
| `publish_date_start` / `_time` | varchar(256) |
| `publish_date_end` / `_time` | varchar(256) |

**Migration:** `2026_05_15_011337_create_content_video_translation_table.php`

| Column | Type |
|--------|------|
| `video_translation_id` | int(11) PK |
| `video_translation_parent_id` | int(11) |
| `video_translation_title` | text |
| `video_translation_content` | text |
| `video_translation_location` | text |
| `video_translation_url` | varchar(255) |
| `video_translation_main` | int(11) |
| `video_translation_language` | varchar(255) |

#### Controller

```
File: app/Http/Controllers/Backend/ContentVideoController.php

index()   — ContentVideo::with('translations'), search, paginate 10
create()  — Load $categories, $statuses, $languages from Ref
store()   — Upload video_img + video_upload, auto-generate video_code, create parent + translations
edit()    — with('translations')->findOrFail
update()  — Handle file replacement, delete+reinsert translations
destroy() — Delete files + translations + parent

Private methods:
  getMainTranslationTitle($request) — extract main title from translations array
  generateVideoCode($title, $ignoreId) — generate unique slug-based code
```

---

## Support Modules

### 14. Ref (Reference)

| Item | Value |
|------|-------|
| **Route prefix** | `/admin/ref` |
| **Controller** | `RefController` |
| **Model** | `Ref` (table: `ref`) |
| **Permission prefix** | `ref.*` |
| **Views** | `index`, `create`, `edit`, `show` |

**Fungsi:** Reference/dropdown data untuk seluruh sistem. Setiap modul content guna `Ref` untuk populates dropdown values (categories, statuses, languages).

**Migration:** `2026_05_11_104607_create_ref_table.php`

| Column | Type | Notes |
|--------|------|-------|
| `id` | bigint unsigned PK | |
| `cat` | varchar(255) | Category group, e.g. `ARTICLE_STATUS` |
| `code` | varchar(255) | Value stored in content tables |
| `descr` | varchar(255) | Label in BM |
| `descr_en` | varchar(255) | Label in EN |
| `sort` | int(11) | Sort order |
| `parent` | varchar(255) | Hierarchical parent |
| `icon_name` | varchar(255) | Icon reference |

**Usage pattern:**
```php
$statuses = Ref::where('cat', 'ARTICLE_STATUS')->orderBy('sort')->pluck('descr', 'code');
// Result: ['ACTIVE' => 'Active', 'INACTIVE' => 'Inactive', ...]
```

**Ref categories by module:**

| Module | Ref Categories |
|--------|---------------|
| ContentArticle | `ARTICLE_CAT`, `ARTICLE_SUBCAT`, `ARTICLE_STATUS`, `ARTICLE_PORTAL_CATEGORY`, `LANGUAGE` |
| ContentApplication | `APPLICATION_CAT`, `APPLICATION_STATUS`, `LANGUAGE` |
| ContentDownload | `DOWNLOAD_CAT`, `DOWNLOAD_STATUS`, `LANGUAGE` |
| ContentImage | `IMAGE_CAT`, `IMAGE_TYPE`, `IMAGE_STATUS`, `LANGUAGE` |
| ContentPhotoGallery | `GALLERY_CAT`, `GALLERY_STATUS`, `LANGUAGE` |
| ContentPhotoList | `PHOTO_STATUS`, `LANGUAGE` |
| ContentSlider | `SLIDER_STATUS`, `SLIDER_TYPE`, `SLIDER_DIMENSION`, `SLIDER_URL_TYPE`, `SLIDER_PORTAL`, `LANGUAGE` |
| ContentVideo | `VIDEO_CATEGORY`, `VIDEO_STATUS`, `LANGUAGE` |

### 15. ActivityLog

| Item | Value |
|------|-------|
| **Route prefix** | `/admin/activity-log` |
| **Controller** | `ActivityLogController` |
| **Model** | `Spatie\Activitylog\Models\Activity` (table: `activity_log`) |
| **Permission prefix** | `activity-log.view` |
| **Views** | `index` |

**Fungsi:** Log aktiviti untuk semua perubahan data oleh admin users. Guna package Spatie Activitylog.

**Migration:** `2026_04_22_220206_create_activity_log_table.php`

| Column | Type |
|--------|------|
| `id` | bigint unsigned PK |
| `log_name` | varchar(255), indexed |
| `description` | text, NOT NULL |
| `subject_type` / `subject_id` | morphs nullable |
| `event` | varchar(255) |
| `causer_type` / `causer_id` | morphs nullable |
| `attribute_changes` | json |
| `properties` | json |
| `url` | varchar(255) |
| `ip_address` | varchar(45), indexed |
| `user_agent` | text |

**Controller:** Only `index()` — list with search by log_name, description, event; eager-load causer.

### 16. AutoPermission

| Item | Value |
|------|-------|
| **Route prefix** | `/admin/auto-permission` |
| **Controller** | `AutoPermissionController` |
| **Model** | - |
| **Permission prefix** | - |
| **Views** | `index` |

**Fungsi:** Auto-generate permissions untuk models dalam `app/Models/Backend/`.

**How it works:**
1. Scan `app/Models/Backend/` — exclude translation models, BackendUser, FrontendUser
2. Generate: `{kebab-model}.view`, `.create`, `.update`, `.delete`
3. Mapping: `ContentArticle` → `content-article.*`, `ContentPhotoGallery` → `content-photo-gallery.*`

### 17. FileManager

| Item | Value |
|------|-------|
| **Views** | `index.blade.php` |

Custom file manager using elFinder.

---

## Common Patterns

### Permission Middleware

Setiap route guna middleware `permission:{module}.{action},admin`:
```
Route::get('/content-article', 'index')->middleware('permission:content-article.view,admin');
Route::post('/content-article', 'store')->middleware('permission:content-article.create,admin');
Route::put('/content-article/{id}', 'update')->middleware('permission:content-article.update,admin');
Route::delete('/content-article/{id}', 'destroy')->middleware('permission:content-article.delete,admin');
```

### File Upload Pattern

Semua modul guna pattern sama untuk file upload:
```php
if ($request->hasFile('field_name')) {
    $file = $request->file('field_name');
    $name = pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME);
    $filename = $name . '_' . now()->format('Ymd') . '_' . substr(uniqid(), -5) . '.' . $file->getClientOriginalExtension();
    $path = $file->storeAs('module-folder', $filename, 'public');
}
```

Storage directories:
| Module | Directory |
|--------|-----------|
| ContentArticle | `content-article/` |
| ContentSlider | `content-slider/` |
| ContentDownload (images) | `content-download/images/` |
| ContentDownload (files) | `content-download/files/` |
| ContentPhotoGallery | `content-photo-gallery/` |
| ContentPhotoList | `content-photo-list/` |

### Delete-and-Reinsert Translation Strategy

Untuk modul dengan inline Alpine tabs, update translations guna:
```php
// 1. Delete all existing translation rows
$parent->translations()->delete();  // Separate table
// atau
Module::where('parent_id', $id)->delete();  // Flat sibling

// 2. Re-create from form data
foreach ($request->translations as $lang => $trans) {
    if (!empty($trans['title'])) {  // or !empty($trans['descr'])
        ModuleTranslation::create([...]);
    }
}
```

**Pengecualian:** ContentSlider — capture old images BEFORE delete, then restore if no new upload.

### Search Pattern

```php
->when($search, function ($q) use ($search) {
    $q->where(function ($sub) use ($search) {
        $sub->where('field1', 'like', "%{$search}%")
            ->orWhere('field2', 'like', "%{$search}%")
            ->orWhereHas('translations', function ($t) use ($search) {
                $t->where('translation_title', 'like', "%{$search}%");
            });
    });
})
```

### Audit Fields

Semua modul content ada `created_by` dan `updated_by` — diisi dengan `auth()->id()`.

### Pagination

```php
->paginate(10)->onEachSide(1)->withQueryString()
```

---

## Menu Module

### 18. Backend Menu

| Item | Value |
|------|-------|
| **Route prefix** | `/admin/backend-menu` |
| **Controller** | `MenuController` |
| **Model** | `App\Models\Backend\menu\backend\Menu` (table: `backend_menu`) |
| **Mapping model** | `App\Models\Backend\menu\backend\RoleMapping` (table: `backend_role_mapping`) |
| **Permission prefix** | `backend-menu.*` |
| **Views** | `index`, `create`, `edit`, `form`, `assign`, `_tree_node` |

**Fungsi:** Backend sidebar navigation management. Super-admin builds a role-based menu tree that appears in the admin sidebar. Each role (super-admin, admin, staff) sees different menus.

#### Database

**Migration:** `2026_01_01_000001_create_backend_menu_table.php`

| Column | Type | Notes |
|--------|------|-------|
| `menu_id` | bigint auto-increment PK | |
| `menu_name` | varchar(255) | Display label |
| `menu_param` | varchar(255) nullable | URL query parameter |
| `menu_class` | varchar(50) nullable | FontAwesome or sidebar built-in icon (`menu-icon-backend`, `menu-icon-frontend`, `menu-icon-content`) |
| `menu_link` | varchar(255) nullable | Laravel route name or `#` for parent container |
| `menu_active_route` | text nullable | JSON array of routes that trigger active highlight, e.g. `["backend-user.index","backend-user.create"]` |
| `menu_parent_id` | integer default 0 | `0` = top-level, else child of that menu_id |
| `menu_status` | boolean nullable | `1` = visible, `0` = hidden |

**Migration:** `2026_01_01_000002_create_backend_role_mapping_table.php`

| Column | Type | Notes |
|--------|------|-------|
| `id` | bigint auto-increment PK | |
| `role_code` | varchar(255) | Spatie role name, e.g. `super-admin` |
| `menu_id` | FK → menu_id | Which menu this mapping belongs to |
| `parent_id` | integer default 0 | `0` = top-level in tree |
| `sort` | integer | Ordering within same parent+role |
| `created_by` | varchar(255) | |
| `updated_by` | varchar(255) nullable | |

#### Models

**Menu model** (`app/Models/Backend/menu/backend/Menu.php`):

```php
#[Table('backend_menu', key: 'menu_id')]
#[Fillable(['menu_name', 'menu_param', 'menu_class', 'menu_link', 'menu_active_route', 'menu_parent_id', 'menu_status'])]
class Menu extends Model
{
    use HasFactory;

    // Casts
    protected function casts(): array
    {
        return [
            'menu_active_route' => 'array',
            'menu_parent_id' => 'integer',
            'menu_status' => 'boolean',
        ];
    }

    // Helper scopes
    public static function dropdown()  // pluck(menu_name, menu_id) — for select dropdowns
    public static function listofmenu() // where(menu_status, true) — active menus only
    public static function getRoute()  // collect(Route::getRoutes()) — all named Laravel routes
    public static function getRole()   // Role::where('guard_name', 'admin') — Spatie roles for dropdown

    // Relationship
    public function roleMappings()     // hasMany(RoleMapping::class, 'menu_id', 'menu_id')
}
```

**RoleMapping model** (`app/Models/Backend/menu/backend/RoleMapping.php`):

```php
#[Table('backend_role_mapping')]
#[Fillable(['role_code', 'menu_id', 'parent_id', 'sort', 'created_by', 'updated_by'])]
class RoleMapping extends Model
{
    use HasFactory;

    // Recursive tree builder
    public static function nestedmenu(int $id, string $role, int $level = 0): array
    // Returns: [{mapping: RoleMapping, level: 0, children: [...]}, ...]

    // Relationship
    public function menu()  // belongsTo(Menu::class, 'menu_id', 'menu_id')
}
```

#### Controller Flow

```
File: app/Http/Controllers/Backend/MenuController.php

index(Request)
  ├── Menu::query()->when(search, name/link filter)
  ├── ->orderBy('menu_name')
  ├── ->paginate(10)
  ├── Load $parentNames from Menu::dropdown()
  └── Return view with $menus, $search, $parentNames

create()
  ├── Load $parents (Menu::dropdown()), $routes (Menu::getRoute())
  └── Return view

store(Request)
  ├── Validate: menu_name, menu_class, menu_link, menu_active_route[], menu_parent_id, menu_status
  ├── Menu::create(data)
  ├── Cache::forget('backend_menu_super-admin')
  └── flash + redirect

edit($id) / update($id)
  └── Same pattern, Menu::findOrFail + update

destroy($id)
  ├── Menu::findOrFail->delete()
  └── Cache bust + redirect

assignMenu(Request)
  ├── GET: Load $roles (guard admin), current $role from query, $menus (dropdown), $assignedMenus (nested tree)
  │   └── Return assign.blade.php with tree view
  └── POST:
      ├── Validate: role_code, menu_id, parent_id
      ├── Auto-calc sort = max(sort)+1 for same role+parent
      ├── RoleMapping::create(data)
      └── flash + redirect back

level($id, $role, $pos, $sort)
  ├── DB::transaction: swap sort values between current and adjacent sibling
  └── back()

roledelete($id)
  ├── RoleMapping::findOrFail($id)->delete()
  ├── Cache bust
  └── back()

stub()
  └── Return json(MenuHelper::getMenu())  // API debug endpoint
```

#### Sidebar Integration

File: `resources/views/backend/layouts/partials/sidebar.blade.php`

The sidebar has two display modes:

1. **Dynamic Menu** (from database via `MenuHelper::getMenu()`):
   - Calls `MenuHelper::getMenu()` which hits cache or queries `RoleMapping`
   - Returns nested array: `[{id, label, icon, url, active_routes, items, active}, ...]`
   - Renders via `sidebar-dynamic-items.blade.php` recursive partial

2. **Static Fallback** (hardcoded HTML):
   - Shows only if dynamic menu is empty (no roles assigned)
   - Uses `@canany` / `@can` Spatie permissions to control visibility
   - Categories: Backend, Frontend, Content — each with sub-items

**Menu Cache Busting:**
- Cache key: `backend_menu_{slug-of-user-roles}`
- Invalidated on every CRUD action and role assignment change
- Rebuilt on next `MenuHelper::getMenu()` call (3600s TTL)

### 19. Frontend Menu

| Item | Value |
|------|-------|
| **Route prefix** | `/admin/frontend-menu` |
| **Controller** | `FrontendMenuController` |
| **Model** | `App\Models\Backend\menu\frontend\Menu` (table: `frontend_menu`) |
| **Mapping model** | `App\Models\Backend\menu\frontend\RoleMapping` (table: `frontend_role_mapping`) |
| **Permission prefix** | `frontend-menu.*` |
| **Views** | `index`, `create`, `edit`, `form`, `assign`, `_tree_node` |

**Fungsi:** Public website navigation management. Builds multi-context menu trees for frontend visitors — supports multi-site, multi-page, multiple menu sets (TOPMENU, SIDEMENU, FOOTER etc.), and role-based visibility.

#### Database

**Migration:** `2026_01_01_000003_create_frontend_menu_table.php`

| Column | Type | Notes |
|--------|------|-------|
| `menu_id` | bigint auto-increment PK | |
| `menu_name` | varchar(255) | Display label |
| `menu_status` | boolean default 1 | Enable/disable toggle |
| `menu_class` | varchar(50) nullable | FontAwesome class for icon |
| `menu_field_id` | varchar(50) nullable | CSS ID for front-end targeting |
| `menu_link` | varchar(255) nullable | URL or route name |
| `menu_icon` | varchar(255) nullable | Raw HTML icon code |
| `menu_descr` | varchar(255) nullable | Brief description tooltip |
| `menu_img` | varchar(255) nullable | Uploaded image path |
| `menu_parent_id` | integer default 0 | Hierarchy |

**Migration:** `2026_01_01_000004_create_frontend_role_mapping_table.php`

| Column | Type | Notes |
|--------|------|-------|
| `id` | bigint auto-increment PK | |
| `role_code` | varchar(255) | Role name from guard `web`/`user` |
| `menu_set` | varchar(255) | Group identifier: `DEFAULT`, `TOPMENU`, `FOOTER`, etc. |
| `menu_group` | varchar(255) | Sub-group within set |
| `menu_id` | FK → menu_id | Which menu item |
| `parent_id` | integer default 0 | Tree position, `0` = root |
| `sort` | integer nullable | Ordering |
| `page_id` | integer default 0 | `0` = show on all pages |
| `site_id` | integer default 0 | `0` = show on all sites |
| `category` | varchar(50) nullable | Category grouping |
| `status` | boolean default 1 | Enable/disable this mapping |
| `created_by`, `updated_by` | varchar(255) | |

**Migration:** `2026_01_01_000005_create_frontend_menu_category_table.php`

| Column | Type | Notes |
|--------|------|-------|
| `category_id` | bigint auto-increment PK | |
| `category_name` | varchar(255) nullable | |
| `category_description` | varchar(255) nullable | |
| `category_status` | boolean default 1 | |
| `created_by`, `updated_by` | integer nullable | |

#### Models

**Menu model** (`app/Models/Backend/menu/frontend/Menu.php`):

```php
#[Table('frontend_menu', key: 'menu_id')]
#[Fillable(['menu_name', 'menu_status', 'menu_class', 'menu_field_id',
            'menu_link', 'menu_icon', 'menu_descr', 'menu_img', 'menu_parent_id'])]
class Menu extends Model
{
    use HasFactory;

    // Accessors
    public function getPageIdAttribute(): ?int   // Stub — override with actual Page model lookup
    public function getMenuTypeAttribute(): ?string
    public function getParamAttribute(): array
    public function getParamItemAttribute(): array

    // Helpers
    public static function dropdown()            // pluck(menu_name, menu_id)
    public static function listofmenu()          // active menus
    public static function getRoute()            // all named routes
    public static function getRole()             // guard web|user roles
    public static function getPage()             // Stub — page list
    public static function getPageList()         // Stub — page dropdown
    public static function getSiteList()         // Stub — site dropdown
    public static function getArticleList()      // ContentArticle::pluck
    public static function getImageSliderList()  // ContentSlider::pluck
    public static function paramProvider()       // Bundled {route, article, slider}
    public static function typeahead(?string $query) // Search autocomplete

    // Relationship
    public function roleMappings()  // hasMany(RoleMapping::class, 'menu_id', 'menu_id')
}
```

**RoleMapping model** (`app/Models/Backend/menu/frontend/RoleMapping.php`):

```php
#[Table('frontend_role_mapping')]
#[Fillable(['role_code', 'menu_set', 'menu_group', 'menu_id', 'parent_id', 'sort',
            'created_by', 'updated_by', 'page_id', 'site_id', 'status', 'category'])]
class RoleMapping extends Model
{
    // Recursive tree with status filter
    public static function nestedmenu(int $id, string $role, bool|int $status = true, int $level = 0): array

    // Query param helpers for state persistence
    public static function getParam(): array     // request()->only([role_code, menu_set, menu_group, page_id, site_id, category, status])
    public static function setBaseParam(array): array  // filter out null values

    // Relationship
    public function menu()  // belongsTo(Menu::class, 'menu_id', 'menu_id')
}
```

#### Controller Flow

```
File: app/Http/Controllers/Backend/FrontendMenuController.php

index(Request)
  ├── Menu::query()->when(search, name/link filter)
  ├── orderBy('menu_parent_id')->orderBy('menu_name')
  ├── paginate(10)
  ├── $parentNames = Menu::dropdown()
  └── Return view with $menus, $search, $parentNames

create() / edit($id)
  ├── Load $parents (dropdown) + $routes (named routes)  [Backend pattern]
  └── Return view (form.blade.php)

store(Request) / update(Request, $id)
  ├── Validate: menu_name*, menu_status, menu_class, menu_link, menu_icon, menu_parent_id
  ├── Handle <input type="file"> menu_img via Storage
  └── flash + redirect

destroy($id)
  ├── Delete menu_img from storage if exists
  ├── Menu::findOrFail->delete()
  └── flash + redirect

assignMenu(Request)
  ├── GET:
  │   ├── $roles = Menu::getRole() (guard web/user)
  │   ├── $menus = Menu::dropdown()
  │   ├── $assignedMenus = RoleMapping::nestedmenu(0, $role, $status, 0)
  │   ├── $pages = Menu::getPageList()   // Stub: returns collect()
  │   ├── $sites = Menu::getSiteList()   // Stub: returns collect()
  │   └── Return assign.blade.php with tree view + submenu modal
  └── POST:
      ├── Validate: role_code*, menu_set*, menu_group*, menu_id*, parent_id, page_id, site_id, category, status
      ├── Auto-calc sort = max(sort)+1 for same role+set+parent
      ├── RoleMapping::create(data)
      └── flash + redirect

level($id, $role, $pos, $sort, $set)
  ├── DB::transaction: swap sort between adjacent sibling (same role+set+parent)
  └── back()

disabled($id)
  └── Menu::findOrFail($id)->update(['menu_status' => !menu_status]) // toggle

articleItem()   └── Return json(Menu::getArticleList())   // API for dropdown
sliderItem()    └── Return json(Menu::getImageSliderList()) // API for dropdown

roledelete($id)
  ├── RoleMapping::findOrFail($id)->delete()
  └── back with query params preserved
```

#### Key Differences from Backend Menu

| Aspect | Backend | Frontend |
|--------|---------|----------|
| Role guard | `admin` | `web` / `user` |
| Active route detection | `menu_active_route` JSON array | Via `RoleMapping.nestedmenu()` status |
| Context filtering | Role only | Role + menu_set + page_id + site_id |
| Toggle disable | Delete mapping | `disabled()` boolean toggle + `status` per mapping |
| Dynamic content | - | Article/Slider dropdown endpoints |
| Cache | `MenuHelper` with Cache facade | No cache (realtime per context) |
| Images | - | `menu_img` per menu item |
| Categories | - | Category grouping via `menu-category` |

### 20. MenuHelper

| Item | Value |
|------|-------|
| **File** | `app/Http/Helpers/MenuHelper.php` |
| **Scope** | Backend sidebar only (dynamic menu tree) |

**Fungsi:** Builds the cached nested menu tree for the admin sidebar. Reads the current user's Spatie roles, queries `backend_role_mapping`, builds a recursive tree with active-state detection.

#### Core Flow

```
User Login → auth('admin')->user()
  ↓
MenuHelper::getMenu()
  ├── getCurrentRoles() → "super-admin" or "admin,staff" (comma-separated)
  ├── getCacheName() → "backend_menu_super-admin"
  ├── Cache::remember(cacheKey, 3600, fn() => getAssignedMenu())
  │   ├── RoleMapping::with('menu')
  │   │   ->whereIn('role_code', userRoles)
  │   │   ->orderBy('sort')
  │   │   ->get()
  │   ├── Filter: parent_id = 0 → top-level items
  │   └── formatItem() each + getChild() recursive
  │       └── Output: {id, label, icon, url, active_routes, items: [...], active}
  ├── setActiveMenu() → loopSetChildItems()
  │   └── checkActive() → compare current routeName against menu_active_route array
  └── Return final nested array for sidebar-dynamic-items.blade.php
```

#### Helper Methods

```
getCacheName()    → "backend_menu_" + slug-of-role-codes
getCurrentRoles() → auth('admin')->user()->getRoleNames()->sort()->implode(',')
getMenu()         → cached menu tree with active highlighting
bustCache()       → Cache::forget(current user's cache key)
getAssignedMenu() → query RoleMapping, build tree
getChild($id)     → recursive children for a given parent mapping
formatItem()      → map RoleMapping + Menu to sidebar array format
parentId()        → resolve parent from mapping or menu record
setActiveMenu()   → recursive active flag computation
checkActive()     → match current request()->route()->getName() against active_routes
getPageTitle()    → lookup menu_name by route; strip .create/.edit/.show/.index suffix
resolveUrl()      → route($link) if named route exists, else url($link)
```

#### Usage in Views

```blade
<!-- Sidebar -->
@php($dynamicMenus = \App\Http\Helpers\MenuHelper::getMenu())

<!-- Page title in breadcrumb -->
@section('title', \App\Http\Helpers\MenuHelper::getPageTitle())

<!-- Anywhere need page name -->
<h2 x-text="pageName">{{ \App\Http\Helpers\MenuHelper::getPageTitle() }}</h2>
```

#### Cache Busting

Cache is invalidated automatically on:
- Menu CRUD (create/update/delete) — `Cache::forget('backend_menu_super-admin')`
- Role assignment change (`assignMenu` POST)
- Role mapping delete (`roledelete`)
- Seeder (`BackendMenuSeeder` calls `Cache::forget`)

### 21. BackendMenuSeeder

| Item | Value |
|------|-------|
| **File** | `database/seeders/BackendMenuSeeder.php` |

Creates the default menu structure and assigns all items to `super-admin` role.

**Default Menu Structure:**

```
Dashboard (hardcoded)
├── Backend
│   ├── Activity Log        → activity-log.index
│   ├── Parameters           → ref.index
│   ├── Roles & Permissions  → # (container)
│   │   ├── Roles            → roles.index
│   │   ├── Permissions       → permissions.index
│   │   └── Auto Permissions → auto-permissions.index
│   ├── Menu                 → backend-menu.index
│   ├── Assign Menu          → backend-menu.assign
│   └── User                 → backend-user.index
├── Frontend
│   ├── File Manager         → file-manager.index
│   ├── Menu                 → frontend-menu.index
│   ├── Assign Menu          → frontend-menu.assign
│   ├── Menu Categories      → menu-category.index
│   └── User                 → frontend-user.index
└── Content
    ├── Articles             → content-article.index
    ├── Links                → content-application.index
    ├── Documents            → content-download.index
    ├── Photo Galleries      → content-photo-gallery.index
    ├── Slider Images        → content-slider.index
    ├── Images               → content-image.index
    └── Videos               → content-video.index
```

**Seeder Helper Methods:**
```php
menu(name, link, class, activeRoutes, parentId)     // Menu::updateOrCreate — unique on name+link
assignRoleMenu(roleCode, [[menuId, parentId], ...])  // RoleMapping::updateOrCreate — unique on role_code+menu_id
findMenuId(name)                                      // Menu::where('menu_name', name)->value('menu_id')
```

**Permissions created** (16 total for guard `admin`):
```
backend-user.view, backend-menu.view, activity-log.view, ref.view, role.view,
permission.view, permission.create, frontend-user.view, file.view,
frontend-menu.view, menu-category.view,
content-article.view, content-application.view, content-download.view,
content-photo-gallery.view, content-slider.view, content-image.view, content-video.view
```

**How to use:** `php artisan db:seed --class=BackendMenuSeeder`

### 22. Menu Routes

All menu routes are grouped under `/admin` prefix with `auth:admin` middleware.

```php
// routes/web.php

// BACKEND MENU
Route::resource('backend-menu', MenuController::class)->except(['show']);
Route::post('backend-menu/delete', [MenuController::class, 'delete']);
Route::post('backend-menu/roledelete/{id}', [MenuController::class, 'roledelete']);
Route::match(['get','post'], 'backend-menu/assign-menu', [MenuController::class, 'assignMenu'])
    ->name('backend-menu.assign');
Route::get('backend-menu/level/{id}/{role}/{pos}/{sort}', [MenuController::class, 'level'])
    ->name('backend-menu.level');
Route::get('backend-menu/stub', [MenuController::class, 'stub']);

// FRONTEND MENU
Route::resource('frontend-menu', FrontendMenuController::class)->except(['show']);
Route::post('frontend-menu/delete', [FrontendMenuController::class, 'delete']);
Route::post('frontend-menu/roledelete/{id}', [FrontendMenuController::class, 'roledelete']);
Route::match(['get','post'], 'frontend-menu/assign-menu', [FrontendMenuController::class, 'assignMenu'])
    ->name('frontend-menu.assign');
Route::get('frontend-menu/level/{id}/{role}/{pos}/{sort}/{set}', [FrontendMenuController::class, 'level'])
    ->name('frontend-menu.level');
Route::get('frontend-menu/disabled/{id}', [FrontendMenuController::class, 'disabled']);
Route::get('frontend-menu/article-item', [FrontendMenuController::class, 'articleItem']);
Route::get('frontend-menu/slider-item', [FrontendMenuController::class, 'sliderItem']);

// MENU CATEGORY
Route::resource('menu-category', MenuCategoryController::class);
```

#### Route Naming Convention

| Action | Backend | Frontend | Category |
|--------|---------|----------|----------|
| List | `backend-menu.index` | `frontend-menu.index` | `menu-category.index` |
| Create form | `backend-menu.create` | `frontend-menu.create` | `menu-category.create` |
| Store | `backend-menu.store` | `frontend-menu.store` | `menu-category.store` |
| Edit form | `backend-menu.edit` | `frontend-menu.edit` | `menu-category.edit` |
| Update | `backend-menu.update` | `frontend-menu.update` | `menu-category.update` |
| Delete | `backend-menu.destroy` | `frontend-menu.destroy` | `menu-category.destroy` |
| Assign menu | `backend-menu.assign` | `frontend-menu.assign` | - |
| Reorder | `backend-menu.level` | `frontend-menu.level` | - |
| Toggle status | - | `frontend-menu.disabled` | - |
