# CHANGELOG — 21 Oktober 2026

> Ringkasan semua perubahan yang dibuat pada sesi 21 Oktober 2026 untuk projek **nres-dev**.
>
> - Bahagian **1 & 2** (Theme Settings, Favicon, Page Title, filter UI) sudah masuk dalam commit `f8bd439 — refactor(ui): improve asset handling and refine backend module layouts`.
> - Bahagian **3, 4, 5 & 10** (Content Directory hierarki, Import Staff position, admin guard, hierarki tanpa had aras) masih **belum commit**.

---

## Ringkasan

| # | Area | Perubahan | Status |
|---|---|---|---|
| 1 | Theme Settings | Fix crop branding images, buang server-side exact-dimension, favicon, Page Title, UI `.theme-field`, fallback imej | ✅ commit |
| 2 | Admin UI | Filter bar calendar/public-holiday 1 baris, ikon Filter/Reset, default "All", lebar Parent/Department/Status | ✅ commit |
| 3 | Content Directory | Hierarki department **tanpa had aras** (asalnya 3 aras — lihat §10) + anti-cycle + re-import fix | ⏳ belum commit |
| 4 | Import Staff | Kolum jawatan `position_code`/`position_bm`/`position_en` + checkbox “Create missing positions” + kod akronim | ⏳ belum commit |
| 5 | Admin Middleware | `Auth::shouldUse('admin')` supaya `auth()`/`@can` konsisten | ⏳ belum commit |
| 6 | Docs | `docs/module/content-directory-import.md` dikemas kini + fail ini | ⏳ belum commit |

---

## 1. Theme Settings

### 1.1 Bug: crop imej branding hasilkan saiz salah

**Punca:** dalam `theme-setting/index.blade.php`, handler “Use Crop” guna `activePond.element.dataset.cropWidth`. Dalam FilePond v4, `pond.element` ialah **root `<div class="filepond--root">`**, dan FilePond juga menggantikan `<input>` asal (yang membawa `data-crop-*`) dengan input dalaman. Jadi nilai menjadi `NaN` → `cropper.getCroppedCanvas()` tidak resize → fail “cropped” kekal saiz asal → server menolak dengan mesej *must be exactly NxN pixels*.

**Fix:** simpan elemen input asal (`activeInput`) semasa fail dipilih, dan guna untuk mendapatkan saiz sasaran + guard `Number.isFinite()`.

**Kesan (diuji dalam Chrome headless):** output crop tepat — 400×130, 64×64, 224×224, 1920×1080, 1920×400.

### 1.2 Server-side exact dimension dibuang

`ThemeSettingController::save()` — semua `$exactDimensions(...)` untuk 5 medan dibuang (crop client-side kekal). Validation yang tinggal: `mimes` (jpeg/jpg/png/svg/webp, favicon + ico), `max:15360` (15MB), dan flag `remove_*`.

### 1.3 Favicon

- Layout admin (`backend/layouts/app.blade.php`) dan auth (`auth-base.blade.php`) dulu hardcode `<link rel="icon" href="data:,">` → favicon yang di-upload tidak pernah digunakan.
- Sekarang: favicon dinamik + cache-buster `?v={settings.updated_at}` + fallback `data:,` bila tiada.
- Helper baru `SettingHelper::faviconRelativeUrl()` — URL relatif (`/storage/...`) supaya tidak bergantung pada `APP_URL`.
- `PortalHandler` (2 tempat) turut guna URL relatif.

### 1.4 Page Title

- `<title>` admin dulu hardcode `CMS` → sekarang `SettingHelper::get('site_name', 'CMS')`.
- Label medan: **Page Title (Browser tab title & email fallback)**.

### 1.5 UI medan theme-setting

- CSS baru `.theme-field` (sama konsep `.auth-field` di login): tinggi 44px, radius 8px, font 14px, focus ring `rgba(70,95,255,.10)`.
- `autofocus` pada Page Title; label + description untuk setiap param dinamik; semua medan teks **penuh lebar**.

### 1.6 Fallback imej

- Helper baru `SettingHelper::imageUrl($path, $default)` — jika fail tiada dalam storage, guna imej bundled (`dynaweb4/img/...`).
- Digunakan pada: login logo/background, sidebar logo (sidebar & header).

---

## 2. Admin UI — filter bar

| Modul | Perubahan |
|---|---|
| `content-calendar` | Grid → flex sebaris. Year (112px) · Category (144px) · Type (128px) · Filter · Reset dalam **1 baris**; Search turun baris penuh bila ruang tak cukup. Header “Filter & Search” + description dibuang. Default option = **All**. |
| `content-public-holiday` | Sama: Year (112px) · Status (144px) · Filter · Reset sebaris; Search `min-w-[140px]`. Ikon kanta dalam search dibuang, placeholder = **Search...**. Default option = **All**. |
| `content-directory-department` | Parent filter `lg:min-w-[240px] lg:flex-1` + kumpulan kiri `lg:flex-1` → Parent mengembang (318px @1280, 404px @1366, 478px @1440). Ikon pada butang Filter/Reset. |
| `content-directory-staff` | Grid `[1.9fr / 0.6fr(min 96px) / auto / 1fr(min 120px)]` — Department lebih lebar, Status lebih kecil. Ikon pada butang Filter/Reset. |

---

## 3. Content Directory — hierarki department (asalnya 3 aras)

> **Nota:** had 3 aras yang diterangkan dalam bahagian ini sudah **dibuang** — lihat **§10**. Bahagian ini kekal sebagai rekod kerja asal.

Struktur rasmi NRES (rujukan: direktori pegawai nres.gov.my):

```text
Aras 1  Pejabat TKSU / SUBK
└── Aras 2  Bahagian / Pejabat Setiausaha Bahagian
    └── Aras 3  Seksyen / Pusat / Unit
```

### 3.1 Model `ContentDirectoryDepartment`

| Helper | Fungsi |
|---|---|
| `MAX_DEPTH = 3` | Had aras (satu tempat kawalan) — **dibuang** dalam §10 |
| `parentMap()` | `[id => parent_id]` untuk pengiraan depth |
| `depthOf($id)` | Aras department (root = 1); pulangkan `0` jika cycle |
| `wouldCreateCycle($id, $parentId)` | Kesan loop |
| `indentedOptions($english = false)` | Semua department dengan indentasi `— ` |
| `parentOptions($excludeId)` | Semua aras (selepas §10); tanpa diri sendiri + anak-cucu |

### 3.2 Import Department

- Buang semakan “Parent must be a root department”.
- Ganti dengan **had 3 aras** + **anti-cycle** (`fileRowDepth()`), termasuk semakan depth untuk parent dari DB. *(Had dibuang dalam §10 — kini `fileRowHasCycle()` + `existingChainHasCycle()`.)*
- Mesej baru: `Parent is already at the deepest level...` *(dibuang dalam §10)* dan `The parent hierarchy contains a circular reference.` *(kekal)*
- “Cannot be moved under another parent” kini **hanya** menyekat jika parent benar-benar berubah (re-import selamat).

### 3.3 Form create/edit & filter

- Dropdown Parent: `parentOptions()` (berindentasi; selepas §10 semua aras).
- Filter Parent: `indentedOptions()` (semua aras).
- `ContentDirectoryDepartmentRequest`: validate wujud, bukan diri sendiri, anti-cycle. *(Semakan `parent depth < 3` dibuang dalam §10.)*

### 3.4 Dropdown department pada modul Staff

- Filter/create/edit guna `indentedOptions()` (BM) dan `indentedOptions(true)` (EN).

### 3.5 Ujian (data rasmi)

```text
TKSU (KA) depth=1 · BPI depth=2 · SDPI/SDIL depth=3
SUBK depth=1 · BPM depth=2 · SPP/SPS depth=3      → import 8/8 valid, insert OK
Aras 4 (X1 under G1)                              → ditolak (max depth) — kini DIBENARKAN selepas §10
Cycle CY1 ↔ CY2                                   → ditolak (circular)
Re-import (update)                                → 7/7 update, tiada error
```

---

## 4. Import Staff — jawatan (position)

### 4.1 Kolum baru (9 kolum)

```text
name | email | phone | position_code | position_bm | position_en | department | sort | status
```

- `position` (kolum lama) masih diterima sebagai alias untuk `position_bm`.
- Template Excel/CSV dikemas kini (header + 2 baris contoh).
- Template Staff **diselaraskan dengan template Department**: department `BPM` & `SDPI` (3 aras: `TKSU (KA)` → `BPI` → `SDPI`), dan `position_code` dibiarkan kosong supaya kod dijana automatik apabila “Create missing positions” ditick.

### 4.2 Sumber jawatan — module Parameters

Table `ref`, `cat = 'DIRECTORY_STAFF_POSITION'`; padanan mengikut urutan:

```text
position_code → ref.code
position_bm   → ref.descr
position_en   → ref.descr_en
```

Yang disimpan dalam staff: **`directory_staff_position = ref.descr` (BM)** — sama seperti form dropdown. Tiada FK.

### 4.3 Checkbox “Create missing positions”

| Keadaan | Tingkah laku |
|---|---|
| Tidak ditick | Jawatan tiada dalam `ref` → baris **Invalid** |
| Ditick | Jawatan tiada → **New**, dicipta dalam `ref` semasa Confirm |

Kod dicipta sebagai **akronim** huruf pertama setiap perkataan:

| position_bm | Kod |
|---|---|
| Pegawai Tadbir | `PT` |
| Seksyen Dasar Perubahan Iklim | `SDPI` |
| Pusat Gas Rumah Kaca Kebangsaan | `PGRKK` |
| Setiausaha Bahagian (Jusa C) | `SBJC` |

- Nama sama dalam satu fail → kongsi satu kod
- Nama berbeza, akronim sama → `PT`, `PT_2`, `PT_3`, …
- Kod sudah wujud dalam `ref` → tambah suffix

### 4.4 Preview

- Lajur Position papar **BM + EN + kod**
- Badge `New: PT (will be created)` untuk jawatan baru
- Banner makluman bila checkbox aktif

### 4.5 Ujian

```
Checkbox OFF + jawatan tiada   → Invalid (mesej jelas)
Checkbox ON  + jawatan tiada   → New → Confirm cipta ref + staff
Re-import (checkbox OFF)       → existing (termasuk padanan melalui EN)
position_code sah / salah      → existing / Invalid
Akronim & collision            → PT / PT_2 / SDPI / PGRKK / SBJC
```

---

## 5. Admin Middleware — guard

`app/Http/Middleware/AdminMiddleware.php` — tambah selepas semakan login:

```php
Auth::shouldUse('admin');
```

**Sebab:** `auth()`, `@auth` dan `@can` tanpa guard menggunakan default guard `user`. Dalam request admin ini boleh menghasilkan `created_by = 0`, header user menu null, dan elemen `@can` tidak muncul (bergantung pada env `AUTH_GUARD`).

**Ujian (kernel request dalam CLI):**

| | Sebelum | Selepas |
|---|---|---|
| `auth()->id()` | `NULL` | `1` |
| Butang Create (`@can`) | tiada | ada |
| Status response | — | 200 |

**Regresi (HTTP):** portal `200`, login `200`, admin tanpa auth `302 → login` (tiada loop).

---

## 6. Perlu tindakan (action items)

1. **Import Department dahulu** sebelum import Staff — department **tidak** auto-create. Fail staff yang mengandungi `PM`, `SUBK`, `BPI` akan Invalid selagi department belum wujud.
2. **Tick “Create missing positions”** pada page import staff jika jawatan belum ada dalam Parameters.
3. **Upload semula imej theme** (login logo/background, sidebar logo, favicon) — fail lama terpadam semasa cleanup ujian; sistem kini guna imej default fallback.
4. *(Pilihan)* Isi semula `background-gradient` dalam Theme Settings — kini kosong, jadi login page tiada gradient.

---

## 7. Nota operasi

- **CLI vs Web DB:** OS env `DB_DATABASE=cms_laravel_v1` mengatasi `.env` untuk `php artisan`. Sentiasa jalankan:
  ```powershell
  $env:DB_DATABASE='nres_dev'; php artisan ...
  ```
- **APP_URL:** `.env` = `http://localhost` manakala akses = `http://127.0.0.1:8000`. Favicon kini guna URL relatif; jika perlu, selaraskan `APP_URL`.
- **Cache prefix:** web dan CLI boleh guna prefix berbeza (`laravel-cache-*` vs `cms-cache-*`). Untuk reset cache web: padam row dalam table `cache`, atau `php artisan cache:clear` dari proses yang sama.
- **PHP 8.4:** `fputcsv()` kini dipanggil dengan parameter `$escape` eksplisit (template & error-report CSV) untuk elak deprecation.

---

## 8. Fail yang terlibat (belum commit)

| Fail | Perubahan |
|---|---|
| `app/Models/Backend/ContentDirectoryDepartment.php` | + helper depth / cycle / options |
| `app/Services/ContentDirectoryDepartmentImportService.php` | hierarki + anti-cycle + re-import fix |
| `app/Services/ContentDirectoryStaffImportService.php` | kolum jawatan, resolve, auto-create, akronim, template, fputcsv |
| `app/Http/Controllers/Backend/ContentDirectoryDepartmentController.php` | parent options + filter indented |
| `app/Http/Controllers/Backend/ContentDirectoryStaffController.php` | dropdown department berindentasi |
| `app/Http/Controllers/Backend/ContentDirectoryStaffImportController.php` | checkbox flag + error report |
| `app/Http/Controllers/Backend/ContentDirectoryDepartmentImportController.php` | fputcsv escape |
| `app/Http/Requests/Backend/ContentDirectoryDepartmentRequest.php` | depth + cycle validation |
| `app/Http/Middleware/AdminMiddleware.php` | `Auth::shouldUse('admin')` |
| `resources/views/backend/module/contentDirectoryDepartment/import.blade.php` | teks hierarki parent |
| `resources/views/backend/module/contentDirectoryStaff/import.blade.php` | kolum jawatan + checkbox + Important rules |
| `resources/views/backend/module/contentDirectoryStaff/import-preview.blade.php` | BM/EN + badge New |
| `docs/module/content-directory-import.md` | dokumentasi hierarki + jawatan |
| `docs/CHANGELOG-2026-10-21.md` | fail ini |

---

## 9. Ujian yang dijalankan

| Ujian | Kaedah | Hasil |
|---|---|---|
| Crop branding 5 medan | Chrome headless (CDP) | Tepat 400×130 / 64×64 / 224×224 / 1920×1080 / 1920×400 |
| Favicon dimuatkan | CDP network log | 200 `image/png` |
| Filter bar 1 baris | CDP ukur `getBoundingClientRect` | 1024–1440 OK |
| Department 3 aras | tinker + service | 8/8 valid, insert & depth betul |
| Aras 4 & cycle | tinker + service | Kedua-duanya ditolak — **aras 4 kini dibenarkan selepas §10**, cycle kekal ditolak |
| Re-import department | tinker + service | 7/7 update tanpa error |
| Staff position (4 senario) | tinker + service | Lulus |
| Admin guard | kernel request + HTTP | `auth()->id()=1`, `@can` render, tiada redirect loop |

**Langkah seterusnya:** commit perubahan bahagian 3–5, kemudian import Department → Staff mengikut urutan di bahagian 6.

---

## 10. Hierarki Department tanpa had aras

`MAX_DEPTH` dibuang sepenuhnya. Struktur rasmi 3 aras (Pejabat → Bahagian → Seksyen/Unit) kekal sebagai **cadangan**, bukan sekatan. Anti-cycle dikekalkan sebagai satu-satunya sekatan struktur.

| Fail | Perubahan |
|---|---|
| `Models/Backend/ContentDirectoryDepartment.php` | Buang `const MAX_DEPTH`; `depthOf()` pulangkan `0` bila cycle (loop tetap berhenti); `parentOptions()` masukkan **semua** aras |
| `Http/Requests/Backend/ContentDirectoryDepartmentRequest.php` | Buang semakan `depthOf >= MAX_DEPTH`; kekalkan `wouldCreateCycle()` |
| `Services/ContentDirectoryDepartmentImportService.php` | `fileRowDepth()` → `fileRowHasCycle()`; tambah `existingChainHasCycle()`; buang 2 error "deepest level" |
| `Http/Controllers/Backend/ContentDirectoryDepartmentController.php` | `index()` guna **walk rekursif** (bukan 3 loop hardcode) untuk depth, `parent_name`, `display_sort` |
| `resources/views/.../contentDirectoryDepartment/index.blade.php` | Warna garis aras kitar 5 warna; indentasi `min($depth, 6) * 24px` supaya tak lari ke kanan |
| `resources/views/.../contentDirectoryDepartment/import.blade.php` | Teks `parent_code`: "there is no depth limit" |
| `docs/module/content-directory-import.md` | Seksyen 6.3, jadual perbandingan, limitasi, ringkasan dikemas kini |
| `docs/module/content-overview.md` | Parent rules, staff import 9 kolum + position lookup (`ref`), "unlimited depth" |
| `tests/Feature/ContentDirectoryDepartmentValidationTest.php` | +2 test: 4 aras dibenarkan, cycle ditolak |
| `tests/Feature/ContentDirectoryStaffImportTest.php` | Selaras template 9 kolum + `create_missing_positions` |

**Kekal (jaring keselamatan):** `wouldCreateCycle()` untuk form, `fileRowHasCycle()` untuk rantaian dalam fail, `existingChainHasCycle()` untuk parent sedia ada dalam DB.

**Ujian (tinker, DB `nres_dev`):**

| Ujian | Hasil |
|---|---|
| Rantaian 5 aras dicipta | `depth = 1,2,3,4,5` |
| `parentOptions()` | 10 pilihan; aras 5 boleh jadi parent; **SDPI kini muncul** |
| Parent = cucu sendiri | ❌ ditolak — "This parent would create a circular department hierarchy." |
| Render index 5 aras | `padding 88px` (aras 4), `112px` (aras 5), nama parent betul |
| Import 4 aras (dulu invalid) | 4/4 valid |
| Import cycle | 0/2 valid — "The parent hierarchy contains a circular reference." |

**Test suite:** `ContentDirectoryDepartmentValidationTest` + `ContentDirectoryStaffImportTest` → **22 passed (121 assertions)**.

---

## 11. Fix: reorder tidak lagi rosak bila paginate / search

**Masalah (ditemui masa uji):** `reorder()` tulis `sort = offset + 1..n` ikut `page`. Betul untuk pagination biasa, tapi **rosak bila ada search**: senarai yang ditapis (contoh 4 daripada 12 baris) ditulis semula `1..4`, bercanggah dengan adik-beradik yang tersorok → **sort duplicate** (diuji: 3 nilai bertindih). `$perPage = 10` juga hardcoded, jadi tukar pagination nanti akan salahkan offset.

**Fix (department + staff):** `sort` sedia ada bagi baris yang dihantar diambil, diisih, kemudian **diagih semula** ikut susunan baru. Baris yang tidak dihantar (halaman lain / tersorok oleh search) tidak disentuh, dan tiada nilai bertindih. Offset/`page` tak diperlukan lagi.

Tambah satu lagi lapisan: jika kumpulan itu mempunyai **sort duplicate** (contoh import tanpa kolum `sort` → semua `0`, drag nampak "tak jadi apa-apa"), kumpulan itu dinormalkan `1..n` ikut susunan paparan dahulu, kemudian barulah agihan slot dijalankan.

| Senario | Sebelum | Selepas |
|---|---|---|
| Semua anak `sort=0`, drag D ke atas | tiada perubahan ❌ | `1:D 2:A 3:B 4:C` ✅ |

| Fail | Perubahan |
|---|---|
| `ContentDirectoryDepartmentController.php` | `reorder()` guna agihan slot sort; buang `page`/`perPage`/`offset` |
| `ContentDirectoryStaffController.php` | Sama (skop `department_id`) |
| `contentDirectoryDepartment/index.blade.php` | Payload JS: `{ ids, parent_id }` (buang `page`) |
| `contentDirectoryStaff/index.blade.php` | Payload JS: `{ ids, department_id }` (buang `page`) |
| `contentDirectoryDepartment/index.blade.php` | Banner info ringkas gaya Staff + **garis tree (├── / └──)** ganti bar warna pada All view |
| `tests/Feature/ContentDirectoryDepartmentImportTest.php` | Assertion `Table legend` → `department level` |
| `ContentDirectoryDepartmentReorderTest.php` (baru) | 6 test: subset, search, pagination, parent lain, ids kosong, normalisasi sort duplicate |
| `ContentDirectoryStaffReorderTest.php` (baru) | 4 test: search subset, department lain, filter wajib, normalisasi sort duplicate |

**Ujian (tinker, DB `nres_dev`):**

| Senario | Sebelum | Selepas |
|---|---|---|
| 25 anak, drag page 2 (10 baris) | 11–20 ditulis semula | 11–20 ditulis semula, page 1 & 3 kekal, **0 duplicate** |
| 25 anak, search "Anak 1" (10 baris) → drag | 3 sort duplicate ❌ | slot asal diagih semula, baris tersorok kekal, **0 duplicate** ✅ |

**Test suite:** `php artisan test --filter=ContentDirectory` → **55 passed (265 assertions)**.

---

## 12. All view: paginate ikut root + collapse/expand

All view sebelum ini render **semua baris** tanpa pagination — OK untuk 22 baris, tapi jadi page panjang bila data sebenar masuk (dan subtree tak boleh dipotong ikut baris).

| Perkara | Perubahan |
|---|---|
| **Root pagination** | `whereNull(parent_id)->paginate(10)`; subtree setiap root dirender penuh pada halaman itu; teks *"Showing root departments x–y of z"* + `vendor.pagination.custom` |
| **Collapse/expand** | Chevron pada baris yang ada anak; klik untuk toggle; butang **Expand all / Collapse all**; state disimpan dalam `localStorage` (`dept-tree-collapsed`) |
| **Search** | Bila `?search=` ada, semua cabang auto-expand supaya hasil nampak |
| **Filtered view** | Tidak berubah (senarai rata + drag + paginate 10/page) |

**Fail:** `ContentDirectoryDepartmentController.php` (root paginator + `has_children`), `contentDirectoryDepartment/index.blade.php` (`data-depth`, chevron, toolbar, pagination, JS), test baru `ContentDirectoryDepartmentIndexTest.php`.

**Ujian:**

| Ujian | Hasil |
|---|---|
| 12 root → page 1 | 10 root + subtree, *"1–10 of 12"*, `ROOT-11` tiada |
| page 2 | `ROOT-11/12` + subtree, `ROOT-01` tiada |
| Collapse (headless Chrome) | collapse A → anak/cucu hidden, root lain kekal; collapse B → hanya cucu hidden; Collapse all / Expand all betul; `localStorage` = `[]` selepas expand all |
| Filtered view | tiada chevron / Expand all, drag masih aktif |

---

## 13. Module baru: Tender & Sebut Harga (`contentTender`)

Modul pertama yang dibina khusus selepas analisis menu portal NRES. Dokumen penuh: [`docs/module/content-tender.md`](module/content-tender.md).

| Perkara | Butiran |
|---|---|
| Table | `content_tender`, `content_tender_translation`, `content_tender_document` |
| Bilingual | `ms` / `en` (+ `zh` dari ref `LANGUAGE`), ada penanda *main translation* |
| Dokumen | 1 dokumen utama + lampiran berbilang (label) + 1 dokumen keputusan |
| Tarikh | buka/tutup, taklimat (tarikh/masa/tempat), lawatan tapak |
| Status | `DRAFT` / `PUBLISHED` / `CANCELLED`; **Open/Closed dikira automatik** dari tarikh tutup |
| Storan | `content-tender/{files,results,attachments}` pada disk `public` |
| Permission | `content-tender.view/create/update/delete` (super-admin) |
| Menu | Content → Tenders (`backend_menu` id 47 + `backend_role_mapping`) |
| Ref | `TENDER_TYPE`, `TENDER_STATUS`, `TENDER_CATEGORY`, `TENDER_METHOD` |
| Test | `tests/Feature/ContentTenderTest.php` — **11 passed (56 assertions)** |

**Fail baru:** 3 migration, 3 model, 1 controller, 1 form request, 3 view + 1 partial `_form`, 1 test, 1 dokumen modul.
**Fail diubah:** `routes/web.php` (+8 route), `docs/module/content-overview.md` (+1 baris jadual modul).

**Belum dibuat (fasa T3):** frontend `tender-web` + komponen senarai/butiran tender untuk portal awam.



