# Content Directory Import — Department & Staff

Dokumen ini menerangkan secara terperinci cara import **Department** dan **Staff** dalam modul Content Directory: format fail, validation, matching, preview, confirm, error report, had fail, permission, dan implementasi kod.

Dokumen ini berdasarkan implementasi semasa:

- `ContentDirectoryDepartmentImportService`
- `ContentDirectoryDepartmentImportController`
- `ContentDirectoryStaffImportService`
- `ContentDirectoryStaffImportController`

> **Kemas kini 2026-10-21:** Department kini menyokong **hierarki tanpa had aras** (struktur rasmi 3 aras: Pejabat → Bahagian → Seksyen/Unit) dengan anti-cycle, dan import Staff menyokong kolum `position_code` / `position_bm` / `position_en` + checkbox **Create missing positions** (kod jawatan = akronim BM). Ringkasan penuh perubahan ada di [`docs/CHANGELOG-2026-10-21.md`](../CHANGELOG-2026-10-21.md).

---

## Table of contents

1. [Overview](#1-overview)
2. [Menu, URL dan permission](#2-menu-url-dan-permission)
3. [Shared file rules](#3-shared-file-rules)
4. [Maksud ROW dalam preview](#4-maksud-row-dalam-preview)
5. [Preview summary](#5-preview-summary)
6. [Department Import](#6-department-import)
7. [Staff Import](#7-staff-import)
8. [Perbandingan Department vs Staff](#8-perbandingan-department-vs-staff)
9. [Template download](#9-template-download)
10. [Technical implementation](#10-technical-implementation)
11. [Mesej error dan maksud](#11-mesej-error-dan-maksud)
12. [Testing](#12-testing)
13. [FAQ dan kes biasa](#13-faq-dan-kes-biasa)
14. [Limitasi semasa](#14-limitasi-semasa)
15. [Recommended workflow](#15-recommended-workflow)
16. [Ringkasan satu halaman](#16-ringkasan-satu-halaman)

---

## 1. Overview

Content Directory mempunyai dua import berasingan:

| Import | Tujuan | Key matching |
|---|---|---|
| Department Import | Cipta/update department dan hierarchy parent-child | `code` |
| Staff Import | Cipta/update staff dan link ke department sedia ada | `email` |

Kedua-duanya menggunakan flow yang sama:

```text
Upload file
    │
    ▼
Preview
    │  baca fail
    │  validate setiap row
    │  simpan payload sementara
    ▼
Preview page
    │  tunjuk valid/invalid
    │  tunjuk insert/update
    │  tunjuk error per row
    ▼
Confirm Import
    │  validate semula
    │  DB transaction
    │  insert/update
    ▼
Index page + flash message
```

Prinsip utama:

- **Preview dahulu, confirm kemudian**
- **All-or-nothing** — jika ada satu row invalid, tiada data ditulis
- **Matching dibuat secara case-insensitive**
- **Tiada auto-create department dalam Staff import**
- **File hanya dibaca dari worksheet pertama**
- **Baris kosong di-skip**
- **Import menggunakan DB transaction**

---

## 2. Menu, URL dan permission

### Department

```text
Menu: Content → Directory → Departments
Index: /admin/content-directory-department
Import: /admin/content-directory-department/import
```

Permission:

```text
content-directory-department.import
```

### Staff

```text
Menu: Content → Directory → Staff
Index: /admin/content-directory-staff
Import: /admin/content-directory-staff/import
```

Permission:

```text
content-directory-staff.import
```

Butang **Import** pada index hanya dipaparkan jika user mempunyai permission import yang berkaitan.

Route menggunakan middleware:

```php
permission:content-directory-staff.import,admin
```

### Frontend — Direktori Pegawai (halaman awam)

Halaman awam yang memaparkan department + staff kepada pelawat (konsep sama dengan
`https://www.nres.gov.my/ms-my/hubungikami/Pages/Direktori-Pegawai.aspx`).

```text
Site     : nres (site_fk = 2)
Page     : Officer Directory — id 28, slug officer-directory
URL awam : /nres/officer-directory
Admin    : /portal-k7m4x9/frontend-site/pages/28/edit → bahagian "Page Content"
```

Kod halaman **tersimpan sepenuhnya dalam `frontend_pages.page_content`** (page 28) — tiada
komponen kongsi, tiada blade partial. Ia boleh diubah terus dari CMS page editor (Monaco →
`#page-content-input`).

Parameter URL:

| Parameter | Maksud |
|---|---|
| `tab` | `pegawai` (lalai) · `perhubungan` · `bahagian` — nilai tidak sah akan jatuh balik ke `pegawai` |
| `q` | Carian bebas — nama, jawatan, e-mel atau telefon (`LIKE %q%`) — hanya tab `pegawai` |
| `dept` | Id department — **termasuk semua sub-department di bawahnya** — hanya tab `pegawai` |

Tab (konsep `nav-pills` seperti laman NRES: Hubungi Kami / Direktori Pegawai / Pegawai Perhubungan Awam):

| Tab | Kandungan | Kiraan pada pil |
|---|---|---|
| **Direktori Pegawai** | Bar carian + tapisan bahagian + **jadual** semua pegawai aktif | jumlah pegawai aktif |
| **Pegawai Perhubungan Awam** | Jadual pegawai unit perhubungan awam sahaja | bilangan pegawai PR |
| **Bahagian & Unit** | Kad struktur organisasi (bahagian akar → unit) + **badge kiraan pegawai** (kumulatif: sendiri + unit di bawah). Nama unit **boleh diklik** → `?tab=pegawai&dept=<id>` | jumlah bahagian/unit |

Unit "perhubungan awam" dikesan secara automatik: `directory_department_code` = `UKK` **atau** nama
mengandungi "komunikasi korporat" / "perhubungan awam" (lihat `$dirPrCodes` dalam page content).

Paparan jadual (lajur): `#` · **Nama Pegawai** (avatar/inisial) · **Jawatan** · **Bahagian / Unit**
(+ badge kod) · **E-mel** · **Telefon**. Susunan baris ikut hierarki bahagian, kemudian `directory_staff_sort`.

Peraturan paparan:

| Keadaan | Apa yang dipaparkan |
|---|---|
| Ada pegawai | Jadual, dikumpulkan ikut hierarki bahagian (lajur "Bahagian / Unit") |
| Carian / tapisan tiada padanan | Baris jadual "Tiada pegawai sepadan…" + pautan set semula |
| `content_directory_staff` kosong | Baris jadual "Direktori pegawai sedang dikemaskini…" (arahkan ke tab Bahagian & Unit) |
| Tab `perhubungan` tanpa pegawai | "Tiada pegawai perhubungan awam didaftarkan." |

Sumber data dan peraturan:

- `content_directory_departments` — hierarki ikut `directory_department_parent_id`; nama ikut
  bahasa sesi (`$lang`): `directory_department_name_en` bila locale `en`, jika tidak BM
- `content_directory_staff` — **hanya `directory_staff_status = 1`** dipaparkan
- Foto: `image_url(directory_staff_photo)`; jika tiada foto → avatar inisial nama
- **Telefon** — dipaparkan sebagai **teks biasa** (tiada pautan `tel:`), mengikut format dalam medan
- **E-mel** — konsep **`Emel [@nres.gov.my]`**: domain dikeluarkan ke **tajuk lajur**, sel papar
  **nama pengguna sahaja** (`nurul.aisyah`). Domain dikesan automatik (domain paling kerap dalam
  `content_directory_staff`). Mod boleh tukar melalui `$dirEmailMode` dalam page content:
  - `'local'` (lalai) → `nurul.aisyah` + domain pada tajuk lajur
  - `'mask'` → `nur•••@nres.gov.my`
  - `'label'` → teks "E-mel" sahaja
  - `'full'` → alamat penuh (tidak disyorkan)

  Jika e-mel pegawai menggunakan domain **berbeza** daripada domain lajur, alamat penuh dipaparkan
  untuk baris itu. Lajur e-mel ialah **teks biasa** (tiada pautan `mailto:`) — pelawat menggabungkan
  nama pengguna dengan domain pada tajuk lajur untuk mendapatkan alamat penuh.
- Nota penggunaan data (larangan guna untuk pengiklanan) + pautan ke Aduan & Maklum Balas

Mengisi data: guna **import** (seksyen 6 & 7) atau CRUD `/admin/content-directory-staff`.
Perubahan data terus muncul di halaman awam (tiada cache). Bila **kod** `page_content`
diubah, sync blade mirror berlaku automatik bila disimpan dari CMS; jika diubah terus
dalam DB, jalankan `BladeSyncService::syncPageContent()` (atau **Flush All Cache**).

Halaman ini **sudah** berada dalam menu: **Mengenai Kami → Direktori Pegawai**
(`frontend_menu` #28 → `/nres/officer-directory`).

---

## 3. Shared file rules

Kedua-dua import berkongsi peraturan fail yang sama.

| Perkara | Peraturan |
|---|---|
| Format | `.xlsx` atau `.csv` |
| `.xls` lama | Tidak disokong; convert ke `.xlsx` |
| Worksheet | Hanya worksheet pertama dibaca |
| Header | Baris pertama yang tidak kosong dianggap header |
| Maksimum rows | 5,000 data rows |
| Maksimum saiz | 200MB |
| Preview table | Maksimum 100 rows dipaparkan |
| Validation | Semua rows divalidasi, bukan hanya 100 |
| Empty rows | Di-skip |
| Formula/CSV injection | Nilai yang bermula dengan `=`, `+`, `-`, `@` di-escape dalam error report |

Header tidak semestinya 100% sama kerana service mempunyai alias (contohnya `department_code` untuk `code`, `parent` untuk `parent_code`, dan `sort_order` untuk `sort`). Walau bagaimanapun, gunakan template rasmi untuk mengelakkan kekeliruan.

### 3.1 Header aliases

Header akan dinormalisasi:

- BOM UTF-8 dibuang
- lowercase
- karakter bukan alphanumeric ditukar kepada `_`
- underscore di awal/akhir dibuang

Alias Department:

| Canonical | Alias |
|---|---|
| `code` | `department_code`, `directory_department_code` |
| `name_bm` | `name`, `department_name`, `directory_department_name` |
| `name_en` | `department_name_en`, `directory_department_name_en` |
| `parent_code` | `parent`, `directory_department_parent_code` |
| `sort` | `sort_order`, `directory_department_sort` |

Alias Staff:

| Canonical | Alias |
|---|---|
| `name` | `staff_name`, `directory_staff_name` |
| `email` | `staff_email`, `directory_staff_email` |
| `phone` | `staff_phone`, `directory_staff_phone` |
| `position` | `staff_position`, `directory_staff_position` |
| `department` | `department_code`, `department_name`, `directory_staff_department` |
| `sort` | `sort_order`, `directory_staff_sort` |
| `status` | `directory_staff_status` |

---

## 4. Maksud ROW dalam preview

`ROW` ialah **nombor baris sebenar dalam fail Excel/CSV**, bukan:

- ID database
- nombor urutan data
- bilangan row yang berjaya

Contoh:

| Excel Row | Kandungan | Status |
|---:|---|---|
| 1 | Header | Header |
| 2 | Staff A | Data |
| 3 | Staff B | Data |
| 4 | Kosong | Skip |
| 5 | Staff C | Data |

Preview akan memaparkan:

```text
Row 2 - Staff A
Row 3 - Staff B
Row 5 - Staff C
```

Jadi jika error tertulis:

```text
Row 5: Department "UNKNOWN" was not found.
```

Maksudnya baris **5** dalam fail perlu dibetulkan.

### Blank row

Baris kosong di-skip sepenuhnya.

Tetapi baris yang mempunyai **apa-apa nilai**, walaupun hanya satu column, bukan baris kosong. Contohnya row yang hanya ada nama akan dianggap sebagai data Staff yang sah kerana `name` wajib dan column lain optional.

---

## 5. Preview summary

Preview page memaparkan:

| Summary | Maksud |
|---|---|
| Total rows | Bilangan data rows yang tidak kosong |
| Valid rows | Rows yang tiada error |
| Invalid rows | Rows yang ada sekurang-kurangnya satu error |
| Will insert | Rows valid yang akan mencipta rekod baru |
| Will update | Rows valid yang akan mengemas kini rekod sedia ada |

Jika `invalidRows > 0`:

- butang **Confirm Import** disabled
- butang **Download Error Report** dipaparkan
- tiada data ditulis ke database

Jika semua rows valid:

- **Confirm Import** boleh ditekan
- service akan validate semula sebelum menulis
- semua perubahan dibuat dalam satu transaksi

---

## 6. Department Import

### 6.1 Template columns

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

| Column | Wajib | Maksud |
|---|---|---|
| `code` | Ya | Kod unik department; digunakan untuk matching |
| `name_bm` | Ya | Nama department dalam Bahasa Malaysia |
| `name_en` | Ya | Nama department dalam English |
| `parent_code` | Tidak | Kod parent department; kosong = root |
| `sort` | Tidak | Susunan paparan; kosong = `0` |

Contoh (sama dengan template yang dimuat turun — 3 aras, serasi dengan template Staff; sistem juga menerima aras lebih dalam):

```csv
code,name_bm,name_en,parent_code,sort
TKSU (KA),Pejabat TKSU (Kelestarian Alam),Office of DCS (Environmental Sustainability),,1
BPI,Bahagian Perubahan Iklim,Climate Change Division,TKSU (KA),1
SDPI,Seksyen Dasar Perubahan Iklim,Climate Change Policy Section,BPI,1
SUBK,Pejabat Setiausaha Bahagian Kanan (Pengurusan),Senior Secretary's Office (Management),,2
BPM,Bahagian Pengurusan Maklumat,Information Management Division,SUBK,1
```

### 6.2 Matching dan action

Matching menggunakan `code` secara **case-insensitive**.

| Keadaan | Action |
|---|---|
| Code sudah wujud | Update department |
| Code baru | Insert department |
| Code sama dalam fail | Invalid |
| Lebih daripada satu rekod DB guna code sama | Invalid |
| Code kosong | Invalid |

Semasa update, service menulis:

```text
directory_department_code
directory_department_name
directory_department_name_en
directory_department_parent_id
directory_department_sort
updated_by
```

`code`, `name_bm`, `name_en`, `parent_code` dan `sort` di-trim sebelum validation dan matching.

Jika `parent_code` kosong, parent akan menjadi `NULL` (root).

### 6.3 Parent dan hierarchy rules

Hierarki department **tiada had aras**. Struktur rasmi NRES yang disyorkan ialah 3 aras:

```text
Aras 1  Pejabat / Root          (parent_code kosong)
└── Aras 2  Bahagian / Pejabat Setiausaha Bahagian
    └── Aras 3  Seksyen / Pusat / Unit
```

Aras 4 dan seterusnya **dibenarkan** jika benar-benar perlu (contoh: Unit di bawah Seksyen):

```text
Aras 1 → Aras 2 → Aras 3 → Aras 4 → …   ✅
```

Peraturan parent:

- `parent_code` kosong = root (aras 1)
- `parent_code` boleh merujuk department dalam fail yang sama
- `parent_code` boleh merujuk department sedia ada dalam DB
- Mana-mana aras boleh menjadi parent (tiada had kedalaman)
- Parent boleh berada di baris sebelum atau selepas child dalam fail
- Department tidak boleh menjadi parent kepada dirinya sendiri
- **Circular reference** (A parent B, B parent A) ditolak
- Parent code yang duplicate dalam fail ditolak
- Parent yang tidak wujud ditolak
- Parent yang mempunyai lebih daripada satu matching code ditolak
- Department sedia ada yang mempunyai children hanya boleh dipindahkan jika parent benar-benar berubah

Jika parent row mempunyai validation error, child row juga akan ditandakan invalid.

Contoh sah (3 aras, mengikut struktur rasmi — aras lebih dalam juga sah):

```csv
code,name_bm,name_en,parent_code,sort
TKSU (KA),Pejabat TKSU (Kelestarian Alam),Office of DCS (Environmental Sustainability),,1
BPI,Bahagian Perubahan Iklim,Climate Change Division,TKSU (KA),1
SDPI,Seksyen Dasar Perubahan Iklim,Climate Change Policy Section,BPI,1
PGRKK,Pusat Gas Rumah Kaca Kebangsaan,National Greenhouse Gas Centre,BPI,3
```

Contoh parent di baris bawah (masih sah):

```csv
code,name_bm,name_en,parent_code,sort
IT-SUPPORT,Unit Sokongan IT,IT Support Unit,IT,1
IT,Jabatan Teknologi Maklumat,Information Technology,,1
```

Ini **sah** kerana service membina peta semua code dalam fail sebelum resolve parent.

Contoh sah (aras 4 — dibenarkan):

```csv
code,name_bm,name_en,parent_code,sort
A,Jabatan A,Department A,,1
B,Unit B,Unit B,A,1
C,Unit C,Unit C,B,1
D,Unit D,Unit D,C,1
```

Keempat-empat baris sah; `D` berada pada aras 4 dan hanya dipaparkan dengan indentasi lebih dalam.

Contoh invalid (cycle):

```csv
code,name_bm,name_en,parent_code,sort
CY1,Cycle A,Cycle A,CY2,1
CY2,Cycle B,Cycle B,CY1,1
```

Kedua-dua baris ditolak dengan mesej:
`The parent hierarchy contains a circular reference.`

Tiada had aras dikenakan. Satu-satunya sekatan struktur ialah **cycle**, ditapis oleh:

```php
ContentDirectoryDepartment::wouldCreateCycle($departmentId, $parentId);
```

### 6.4 Validation Department

Setiap row divalidasi untuk:

| Field | Peraturan |
|---|---|
| `code` | Wajib, max 255 |
| `name_bm` | Wajib, max 255 |
| `name_en` | Wajib, max 255 |
| `sort` | Optional; mesti integer >= 0; kosong = 0 |

Contoh row invalid:

| code | name_bm | name_en | Sebab |
|---|---|---|---|
| | Jabatan A | Department A | Code required |
| A | | Department A | Name BM required |
| A | Jabatan A | | Name EN required |
| A | Jabatan A | Department A | Duplicate code dalam fail |
| A | Jabatan A | Department A | Unknown parent |

Jika `code` bermula dengan `0`, set column sebagai **Text** dalam Excel supaya leading zero tidak hilang.

### 6.5 Sort dan duplicate sort

`sort` digunakan untuk susunan paparan.

- `sort` kosong menjadi `0`
- `sort` negatif ditolak
- Nombor perpuluhan ditolak
- `sort` yang sama **tidak** menyebabkan error
- Jika `sort` sama, sistem menggunakan `directory_department_id` sebagai tie-breaker
- Drag di halaman Department akan menormalkan kumpulan yang seri kepada `1..n` secara automatik

Susunan Department:

```text
Root: sort ASC, id ASC
Child: sort ASC, id ASC
```

`sort` tidak unik dan bukan key matching.

### 6.6 Confirm Department Import

Apabila **Confirm Import** ditekan:

1. Token preview disahkan
2. Payload preview dibaca
3. Semua rows dianalisis semula
4. Jika ada invalid row, import dibatalkan
5. DB transaction dimulakan
6. Semua department di-insert/update dengan `parent_id = NULL`
7. Parent ID di-resolve menggunakan peta code
8. Parent ID ditulis untuk rows yang mempunyai `parent_code`
9. Transaction commit
10. Payload preview dipadam
11. User di-redirect ke index department
12. Flash message memaparkan bilangan inserted/updated

Jika apa-apa exception berlaku dalam transaction, semua perubahan di-rollback.

### 6.7 Department import dan department sedia ada

Jika department sedia ada di-update:

- `code`, `name_bm`, `name_en`, `sort` akan ditulis semula
- `parent_code` kosong akan menjadikan department itu root
- Department yang mempunyai children tidak boleh dipindahkan ke parent lain
- `updated_by` dikemas kini
- `created_by` tidak diubah

Jika department baru:

- `created_by` dan `updated_by` diisi dengan user semasa
- `parent_id` diisi selepas semua rows selesai diproses
- `directory_department_code`, nama BM/EN dan sort diambil dari fail

---

## 7. Staff Import

### 7.1 Template columns

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

| Column | Wajib | Maksud |
|---|---|---|
| `name` | Ya | Nama staff; max 255 |
| `email` | Tidak | Email/free text; tiada validasi format dan `@` tidak wajib. Digunakan untuk matching dan update. Maksimum 255 aksara |
| `phone` | Tidak | Nombor telefon; format bebas yang munasabah |
| `position_code` | Tidak | Kod jawatan dalam module **Parameters** (`ref`, cat `DIRECTORY_STAFF_POSITION`). Kalau kosong, kod dijana automatik (akronim BM) |
| `position_bm` (alias lama: `position`) | Tidak | Nama jawatan BM; dipadankan dengan `ref.descr` |
| `position_en` | Tidak | Nama jawatan EN; dipadankan dengan `ref.descr_en` |
| `department` | Tidak | Department sedia ada; code, nama BM atau nama EN |
| `sort` | Tidak | Susunan paparan; kosong = `0` |
| `status` | Tidak | Active/Inactive; kosong = Active |

Contoh (sama dengan template yang dimuat turun; department `BPM` dan `SDPI` diambil daripada template Department):

```csv
name,email,phone,position_code,position_bm,position_en,department,sort,status
Ahmad Ali,ahmad@example.com,03-8091 7245,,Pegawai Tadbir,Administrative Officer,BPM,1,Active
Siti Aminah,siti@example.com,+60 12-345 6789,,Setiausaha Bahagian (Jusa C),Division Secretary (Jusa C),SDPI,2,Active
```

### 7.1.1 Position (jawatan) — module Parameters

Jawatan **tidak** disimpan sebagai FK. Yang disimpan dalam `content_directory_staff` ialah **teks BM** (`directory_staff_position`), sama seperti nilai dropdown pada form create/edit.

Sumber jawatan: module **Parameters** → table `ref`, `cat = 'DIRECTORY_STAFF_POSITION'`:

| Kolum `ref` | Maksud |
|---|---|
| `code` | Kod ringkas jawatan (cth `PT`, `SDPI`) |
| `descr` | Nama jawatan BM |
| `descr_en` | Nama jawatan EN |

Urutan padanan semasa preview:

```text
1) position_code  → ref.code
2) position_bm    → ref.descr
3) position_en    → ref.descr_en
```

Jika `position_code` diberi tetapi tiada dalam `ref` → baris Invalid (kod tidak dijana semula).

#### Checkbox "Create missing positions"

| Keadaan | Maksud |
|---|---|
| **Tidak ditick** (default) | Jawatan yang tiada dalam Parameters → baris **Invalid** dengan mesej: `Position "X" was not found in Ref (DIRECTORY_STAFF_POSITION). Add it in the Ref module first, or tick "Create missing positions".` |
| **Ditick** | Jawatan yang tiada → ditanda **New**, dan dicipta dalam `ref` semasa **Confirm** (dalam transaksi yang sama) |

Semasa mencipta jawatan baru:

```text
cat      = DIRECTORY_STAFF_POSITION
code     = position_code (jika ada) ATAU akronim BM
descr    = position_bm
descr_en = position_en (nullable)
sort     = max(sort) + 1
```

#### Kod automatik (akronim)

Kod dijana daripada **huruf pertama setiap perkataan** nama BM:

| position_bm | Kod |
|---|---|
| Pegawai Tadbir | `PT` |
| Seksyen Dasar Perubahan Iklim | `SDPI` |
| Pusat Gas Rumah Kaca Kebangsaan | `PGRKK` |
| Setiausaha Bahagian (Jusa C) | `SBJC` |

Peraturan pertindihan:

- Nama jawatan **sama** dalam satu fail → kongsi satu kod (tidak jadi `PT_2`)
- **Nama berbeza** tetapi akronim sama → `PT`, `PT_2`, `PT_3`, …
- Kod sudah wujud dalam `ref` → tambah suffix

#### Paparan dalam preview

Lajur **Position** menunjukkan:

```text
Pegawai Tadbir            ← BM (ref.descr)
Administrative Officer    ← EN (ref.descr_en)
PT                        ← kod (ref.code)

New: M (will be created)  ← badge untuk jawatan baru
```

Jika checkbox ditick, banner di atas preview akan memaklumkan bahawa jawatan `New` akan dicipta semasa Confirm.

### 7.2 Matching dan action

Matching menggunakan `email` secara **case-insensitive**.

| Keadaan | Action |
|---|---|
| Email sudah wujud | Update staff |
| Email baru | Insert staff |
| Email kosong | Insert staff |
| Email sama dalam fail | Invalid |
| Lebih daripada satu staff DB guna email sama | Invalid |
| Email tidak sah | Tiada validasi format |

Email untuk Staff **tidak menggunakan validasi format email yang strict**:

- `@` tidak wajib
- domain dan titik tidak wajib
- nilai hanya perlu string, maksimum 255 aksara
- nilai kosong dibenarkan dan akan disimpan sebagai `NULL`
- nilai masih di-trim dan di-lowercase semasa import

Contoh nilai yang diterima:

```text
ahmad@example.com
ahmad@agency
siti@localhost
ahmad-agency
staff.agency.com
```

Matching masih dibuat menggunakan nilai email tersebut secara case-insensitive.

Semasa update, service menulis:

```text
directory_staff_name
directory_staff_email
directory_staff_phone
directory_staff_position
directory_staff_department_id
directory_staff_sort
directory_staff_status
updated_by
```

`directory_staff_photo` tidak dimasukkan dalam data update, jadi photo sedia ada **kekal**.

Semasa insert:

```text
directory_staff_photo = NULL
created_by = user semasa
updated_by = user semasa
```

Normalization yang dibuat sebelum matching:

- `name`, `phone`, `position` dan `department` di-trim
- `email` di-trim dan ditukar kepada lowercase
- `sort` dan `status` di-trim sebelum parsing

### 7.3 Department resolution

Staff import **tidak mencipta department**.

Column `department` akan dicari mengikut urutan:

```text
1. department code
2. name_bm
3. name_en
```

Semua perbandingan adalah case-insensitive dan di-trim.

Contoh, jika department wujud:

```text
directory_department_code     = BTM
directory_department_name     = Bahagian Teknologi Maklumat
directory_department_name_en  = Information Technology Division
```

Maka semua nilai ini boleh digunakan:

```text
BTM
Bahagian Teknologi Maklumat
Information Technology Division
btm
```

Jika `department` kosong:

```text
directory_staff_department_id = NULL
```

Jika `department` tidak wujud:

```text
Department "XXX" was not found. Create or import the department first.
```

Jika satu nama/code memadankan lebih daripada satu department:

```text
Department "XXX" matches more than one existing department by ...
```

Jika ambiguous, row invalid. Gunakan department **code** untuk mengelakkan ambiguity.

### 7.4 Row nama sahaja

Kerana hanya `name` wajib, row seperti ini adalah **valid**:

```csv
name,email,phone,position,department,sort,status
Ahmad,,,,,
```

Ia akan insert staff baru dengan:

```text
name       = Ahmad
email      = NULL
phone      = NULL
position   = NULL
department = NULL
sort       = 0
status     = 1 (Active)
```

Perkara penting:

- Nama sama dibenarkan
- Nama tidak digunakan untuk matching
- Jika nama sama dengan staff sedia ada tetapi email kosong, sistem akan **insert rekod baru**, bukan update
- Ini boleh menyebabkan duplicate nama

Jika baris itu tersilap, padam nama tersebut sebelum import.

### 7.5 Status

Status yang diterima:

```text
Active
Inactive
Aktif
Tidak Aktif
1
0
```

Semua perbandingan case-insensitive.

| Nilai | Disimpan |
|---|---:|
| Kosong | 1 |
| Active / Aktif / 1 | 1 |
| Inactive / Tidak Aktif / 0 | 0 |
| Nilai lain | Invalid |

Status yang invalid akan menyebabkan row ditolak.

### 7.6 Phone formats

Phone disimpan sebagai string, bukan integer.

Format yang diterima termasuk:

```text
0123456789
03-8091 7245
(03) 8091 7245
+60 12-345 6789
+60123456789
```

Service hanya melakukan `trim`; ia tidak membuang `+`, ruang, hyphen atau parentheses. Nilai disimpan sebagaimana yang dimasukkan.

Nota Excel:

- Jika phone bermula dengan `0`, Excel mungkin membuang leading zero
- Jika phone bermula dengan `+60`, Excel mungkin menganggapnya sebagai formula
- Set column `phone` sebagai **Text** sebelum mengisi nilai
- Jika Excel mengubah nilai, preview akan memaparkan nilai yang telah diubah oleh Excel

### 7.7 Sort dan duplicate sort

- `sort` kosong menjadi `0`
- `sort` negatif ditolak
- Nombor perpuluhan ditolak
- `sort` sama dibenarkan
- Tie-breaker menggunakan `directory_staff_id`

Susunan Staff dalam index:

```text
Jika filter department dipilih:
sort ASC, id ASC

Jika tiada filter department:
department_id ASC, sort ASC, id ASC
```

Jadi staff dengan `sort` sama akan disusun mengikut ID. ID yang lebih kecil biasanya dipaparkan dahulu.

Jika mahu susunan tepat, gunakan `sort` unik:

```text
1, 2, 3, 4, 5
```

### 7.8 Confirm Staff Import

Apabila **Confirm Import** ditekan:

1. Token preview disahkan
2. Payload preview dibaca
3. Semua rows dianalisis semula
4. Jika ada invalid row, import dibatalkan
5. DB transaction dimulakan
6. Staff sedia ada dimuatkan mengikut email
7. Setiap row di-insert/update
8. Transaction commit
9. Payload preview dipadam
10. User di-redirect ke index staff
11. Flash message memaparkan bilangan inserted/updated

Jika berlaku exception, semua perubahan di-rollback.

### 7.9 Staff import dan photo

- Photo tidak diimport
- Staff baru akan mempunyai `directory_staff_photo = NULL`
- Staff sedia ada yang di-update akan **kekalkan photo lama**
- Untuk menukar photo, gunakan form edit Staff

---

## 8. Perbandingan Department vs Staff

| Perkara | Department | Staff |
|---|---|---|
| Key matching | `code` | `email` |
| Wajib | `code`, `name_bm`, `name_en` | `name` |
| Optional | `parent_code`, `sort` | `email`, `phone`, `position_code`, `position_bm`, `position_en`, `department`, `sort`, `status` |
| Key kosong | Invalid | Email kosong → insert |
| Duplicate key dalam fail | Ditolak | Ditolak |
| Duplicate key dalam DB | Ditolak jika matching ambiguous | Ditolak jika matching ambiguous |
| Parent hierarchy | Ya, tanpa had aras | Tiada |
| Department relation | Parent-child (tanpa had aras) | Mesti link ke department sedia ada |
| Cipta department | Ya | Tidak |
| Cipta jawatan (position) | Tiada | Ya, jika "Create missing positions" ditick |
| Status | Tiada | Ada |
| Photo | Tiada | Ada, tetapi tidak diimport |
| Row dengan hanya satu nilai | Invalid jika code/name wajib tiada | Valid jika `name` ada |
| Sort tie-breaker | `directory_department_id` | `directory_staff_id` |

---

## 9. Template download

Setiap import page menyediakan dua template:

```text
Excel Template  → .xlsx
CSV Template    → .csv
```

Route:

```text
GET /admin/content-directory-department/import/template?format=xlsx
GET /admin/content-directory-department/import/template?format=csv
GET /admin/content-directory-staff/import/template?format=xlsx
GET /admin/content-directory-staff/import/template?format=csv
```

Jika `format` bukan `csv`, default ialah `xlsx`.

Template **Department** dan **Staff** diselaraskan supaya boleh diuji berturutan:

- Department: 5 baris contoh 3 aras — `TKSU (KA)` (root) → `BPI` → `SDPI`, dan `SUBK` (root) → `BPM`.
- Staff: 2 baris contoh yang merujuk department `BPM` dan `SDPI` daripada template Department; `position_code` dibiarkan kosong supaya kod jawatan dijana automatik (akronim) apabila **Create missing positions** ditick.

Aliran ujian template:

```text
1) Import template Department   → 5/5 valid  → Confirm
2) Import template Staff + tick "Create missing positions"
   → department BPM/SDPI dijumpai
   → jawatan baru: PT (Pegawai Tadbir) & SBJC (Setiausaha Bahagian (Jusa C))
   → 2/2 valid → Confirm
```


---

## 10. Technical implementation

### 10.1 Routes

#### Department

| Method | URI | Controller method | Route name |
|---|---|---|---|
| GET | `/admin/content-directory-department/import` | `create` | `content-directory-department.import` |
| POST | `/admin/content-directory-department/import/preview` | `preview` | `content-directory-department.import.preview` |
| POST | `/admin/content-directory-department/import` | `store` | `content-directory-department.import.store` |
| GET | `/admin/content-directory-department/import/template` | `template` | `content-directory-department.import.template` |
| GET | `/admin/content-directory-department/import/errors` | `errors` | `content-directory-department.import.errors` |

#### Staff

| Method | URI | Controller method | Route name |
|---|---|---|---|
| GET | `/admin/content-directory-staff/import` | `create` | `content-directory-staff.import` |
| POST | `/admin/content-directory-staff/import/preview` | `preview` | `content-directory-staff.import.preview` |
| POST | `/admin/content-directory-staff/import` | `store` | `content-directory-staff.import.store` |
| GET | `/admin/content-directory-staff/import/template` | `template` | `content-directory-staff.import.template` |
| GET | `/admin/content-directory-staff/import/errors` | `errors` | `content-directory-staff.import.errors` |

Semua route menggunakan permission import masing-masing dan guard `admin`.

### 10.2 Controller validation

Preview request:

```php
'file' => ['required', 'file', 'extensions:xlsx,csv', 'max:204800']
```

Maksudnya:

- fail wajib dipilih
- extension mesti `xlsx` atau `csv`
- saiz maksimum 204,800 KB = 200MB

Confirm dan error report request:

```php
'token' => ['required', 'string', 'size:40']
```

Jika file gagal dibaca, controller akan:

- report exception
- papar mesej umum kepada user
- tidak menulis apa-apa ke database

### 10.3 Service methods

Kedua-dua service mempunyai public methods yang sama:

| Method | Fungsi |
|---|---|
| `preview()` | Baca fail, validate, simpan payload, return preview data |
| `confirm()` | Validate semula payload dan tulis ke DB dalam transaction |
| `errorReport()` | Return invalid rows untuk CSV download |
| `forget()` | Padam payload preview |
| `template()` | Generate template XLSX/CSV |

Constants:

```text
MAX_ROWS      = 5000
PREVIEW_LIMIT = 100
```

### 10.4 Payload preview

Preview tidak menyimpan fail asal secara kekal. Sebaliknya service menyimpan payload JSON sementara:

```text
Department: directory-department-imports/{token}.json
Staff:      directory-staff-imports/{token}.json
```

Lokasi disk `local`:

```text
storage/app/private/directory-department-imports/
storage/app/private/directory-staff-imports/
```

Payload mengandungi:

- token
- user ID
- nama fail asal
- timestamp
- source rows
- analyzed rows
- summary counts

Token:

- 40 aksara alphanumeric
- hanya boleh digunakan oleh user yang sama
- payload lama dibersihkan selepas 24 jam
- payload dipadam selepas confirm berjaya

Jika token invalid atau expired:

```text
The import session is invalid or has expired.
```

### 10.5 Database transaction

Department confirm:

```text
DB::transaction
  ├── insert/update semua department
  ├── resolve parent IDs
  └── update parent_id untuk child rows
```

Staff confirm:

```text
DB::transaction
  ├── load existing staff by email
  └── insert/update setiap row
```

Jika satu row gagal semasa transaction, semua perubahan di-rollback.

### 10.6 Error report

Error report hanya mengandungi rows yang mempunyai error.

Department columns:

```text
Row | Code | Name (BM) | Name (EN) | Parent Code | Sort | Errors
```

Staff columns:

```text
Row | Name | Email | Phone | Position | Department | Sort | Status | Errors
```

Fail dinamakan:

```text
department-import-errors-YYYYMMDD-HHMMSS.csv
staff-import-errors-YYYYMMDD-HHMMSS.csv
```

Untuk mengelakkan CSV formula injection, nilai yang bermula dengan:

```text
=
+
-
@
```

akan ditambah `'` di hadapan dalam error report.

### 10.7 Permission dan seeder

Permission dicipta dalam:

```text
database/seeders/BackendMenuSeeder.php
database/seeders/AdminRolePermissionSeeder.php
```

Permission yang berkaitan:

```text
content-directory-department.import
content-directory-staff.import
```

Selepas menambah permission pada seeder, jalankan:

```bash
php artisan db:seed --class=BackendMenuSeeder --force
php artisan permission:cache-reset
php artisan optimize:clear
```

### 10.8 Implementation files

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

Route ditakrifkan dalam:

```text
routes/web.php
```

### 10.9 Database fields yang ditulis

Department:

| Field | Insert | Update |
|---|---|---|
| `directory_department_code` | Ya | Ya |
| `directory_department_name` | Ya | Ya |
| `directory_department_name_en` | Ya | Ya |
| `directory_department_parent_id` | Ya | Ya |
| `directory_department_sort` | Ya | Ya |
| `created_by` | Ya | Tidak |
| `updated_by` | Ya | Ya |

Staff:

| Field | Insert | Update |
|---|---|---|
| `directory_staff_name` | Ya | Ya |
| `directory_staff_email` | Ya, `NULL` jika kosong | Ya, `NULL` jika kosong |
| `directory_staff_phone` | Ya, `NULL` jika kosong | Ya, `NULL` jika kosong |
| `directory_staff_position` | Ya, `NULL` jika kosong | Ya, `NULL` jika kosong |
| `directory_staff_department_id` | Ya, `NULL` jika kosong | Ya, `NULL` jika kosong |
| `directory_staff_sort` | Ya | Ya |
| `directory_staff_status` | Ya | Ya |
| `directory_staff_photo` | `NULL` | Tidak diubah |
| `created_by` | Ya | Tidak |
| `updated_by` | Ya | Ya |

---

## 11. Mesej error dan maksud

### Department

| Mesej | Maksud |
|---|---|
| `Only .xlsx and .csv files are supported...` | Format fail tidak disokong |
| `Missing required column(s): ...` | Header wajib tiada |
| `The file contains too many rows...` | Melebihi 5,000 rows |
| `The file only contains a header row...` | Tiada data row |
| `Code is required.` | Column code kosong |
| `Name (BM) is required.` | Nama BM kosong |
| `Name (EN) is required.` | Nama EN kosong |
| `This code appears more than once in the file.` | Duplicate code dalam fail |
| `More than one existing department uses this code.` | Duplicate code dalam DB |
| `A department cannot be its own parent.` | `parent_code` sama dengan `code` |
| `The parent code appears more than once in the file.` | Parent code duplicate |
| `The parent hierarchy contains a circular reference.` | Parent chain berulang (A→B→A) |
| `The parent row contains validation errors.` | Parent row invalid |
| `Parent department "X" was not found.` | Parent tidak wujud |
| `The parent code matches more than one existing department.` | Parent ambiguous |
| `This department has child departments and cannot be moved under another parent.` | Department ada children |
| `Sort must be a whole number greater than or equal to 0.` | Sort invalid |

### Staff

| Mesej | Maksud |
|---|---|
| `Missing required column: name.` | Header `name` tiada |
| `The file only contains a header row...` | Tiada data row |
| `Name is required.` | Nama kosong |
| `Name may not be greater than 255 characters.` | Nama terlalu panjang |
| `Email may not be greater than 255 characters.` | Email/free text melebihi 255 aksara. Tiada validasi format atau `@` |
| `This email appears more than once in the file.` | Duplicate email dalam fail |
| `More than one existing staff uses this email.` | Duplicate email dalam DB |
| `Department "X" was not found. Create or import the department first.` | Department tiada |
| `Department "X" matches more than one existing department by ...` | Department ambiguous |
| `Position "X" was not found in Ref (DIRECTORY_STAFF_POSITION). Add it in the Ref module first, or tick "Create missing positions".` | Jawatan tiada dalam Parameters & checkbox tidak ditick |
| `Position code "X" was not found in Ref (DIRECTORY_STAFF_POSITION).` | `position_code` diberi tetapi tiada dalam `ref` |
| `Position "X" matches more than one existing position by name (BM/EN).` | Dua rekod `ref` guna nama yang sama |
| `Status must be Active/Inactive, Aktif/Tidak Aktif, 1 or 0.` | Status invalid |
| `Sort must be a whole number greater than or equal to 0.` | Sort invalid |

### Shared

| Mesej | Maksud |
|---|---|
| `The import session is invalid or has expired.` | Token salah/expired |
| `The import contains invalid rows. Upload the corrected file again.` | Confirm cuba dibuat pada fail invalid |
| `The import does not contain any data rows.` | Tiada row untuk import |
| `The file could not be read...` | Fail rosak atau bukan fail sah |

---

## 12. Testing

Test files:

```text
tests/Feature/ContentDirectoryDepartmentImportTest.php
tests/Feature/ContentDirectoryStaffImportTest.php
```

Coverage Department:

- CSV preview dan confirm
- Parent dan child dalam fail yang sama
- Parent muncul selepas child
- Update by code
- Unknown parent
- Duplicate code dalam fail
- Template XLSX
- Error report
- Permission gate
- Import page
- Import button pada index

Coverage Staff:

- CSV preview dan confirm
- Update by email
- Department by code
- Department by BM name
- Department by EN name
- Unknown department
- Duplicate email dalam fail
- Invalid email/status/sort
- Default sort/status
- Format phone `03-8091 7245`, `(03) 8091 7245`, `+60 12-345 6789`
- XLSX import
- Template XLSX/CSV
- Error report
- Permission gate
- Import page
- Import button pada index

Jalankan:

```bash
php artisan test --filter=ContentDirectoryDepartmentImportTest
php artisan test --filter=ContentDirectoryStaffImportTest
```

Jika menggunakan PowerShell dan test mengambil masa lebih 30 saat, gunakan `Start-Process` dengan output ke fail supaya command tidak timeout.

---

## 13. FAQ dan kes biasa

### Adakah baris kosong di tengah fail dibenarkan?

Ya. Baris kosong di-skip. `ROW` akan melompat, contohnya `2, 3, 5`, dan itu normal.

### Jika row kosong ada nama sahaja?

Untuk Staff: row itu **valid** dan akan insert staff baru dengan field lain `NULL`.

Untuk Department: row itu **invalid** kerana `code`, `name_bm`, dan `name_en` wajib.

### Boleh guna nama yang sama untuk dua staff?

Boleh. Nama tidak unik dan tidak digunakan untuk matching. Matching hanya menggunakan email.

### Boleh guna sort yang sama?

Boleh. Sort tidak unik. Sistem susun mengikut `sort`, kemudian `id`.

### Adakah import staff mencipta department baru?

Tidak. Department mesti sudah wujud. Jika tidak wujud, row invalid.

### Perlukah import Department dahulu?

Ya, jika Staff import menggunakan department. Import Department dahulu, kemudian Staff.

### Boleh import fail yang sama dua kali?

Bergantung pada key:

- Department: code yang sama akan update, bukan duplicate
- Staff dengan email: akan update
- Staff tanpa email: akan insert rekod baru setiap kali
- Staff nama sahaja: akan insert duplicate setiap kali

Jadi jangan import semula fail yang mengandungi staff tanpa email jika tidak mahu duplicate.

### Boleh update parent department?

Boleh, tetapi:

- parent mesti root
- department yang mempunyai children tidak boleh dipindahkan ke parent lain
- `parent_code` kosong akan menjadikan department root

### Kenapa phone berubah selepas import?

Kemungkinan Excel mengubah format. Set column `phone` sebagai **Text**. Nilai phone yang sah termasuk `03-8091 7245`, `(03) 8091 7245`, dan `+60 12-345 6789`.

### Kenapa hanya worksheet pertama dibaca?

Service menggunakan worksheet pertama sahaja. Jika data berada dalam worksheet kedua, pindahkan ke worksheet pertama atau jadikan ia satu-satunya worksheet.

### Boleh import `.xls`?

Tidak. Convert ke `.xlsx` dahulu.

### Apa jadi jika satu row invalid?

Tiada data ditulis. Betulkan semua row invalid, upload semula, preview, kemudian confirm.

### Adakah data ditulis semasa preview?

Tidak. Preview hanya membaca fail dan menyimpan payload sementara. Database tidak diubah sehingga Confirm Import.

---

## 14. Limitasi semasa

- Tiada delete melalui import
- Tiada import photo Staff
- Tiada auto-create Department melalui Staff import
- Tiada validation unik untuk nama Staff
- Tiada validation unik untuk `sort`
- Department tiada had aras; UI mengindentasi maksimum 6 aras untuk kebolehbacaan
- Jawatan (position) tidak disimpan sebagai FK — staff simpan teks BM (`directory_staff_position`), jadi rename dalam `ref` tidak mengubah rekod lama
- Hanya worksheet pertama dibaca
- `.xls` tidak disokong
- Import dijalankan secara synchronous dalam request
- CSV dijangka menggunakan delimiter koma dan encoding UTF-8

Jika keperluan berubah, limitasi ini perlu dipertimbangkan sebelum menambah auto-create, queue, atau format lain.

---

## 15. Recommended workflow

### Jika kedua-dua department dan staff perlu diimport

```text
1. Download Department template
2. Isi department (root dahulu atau child dahulu, kedua-duanya boleh)
3. Preview Department
4. Betulkan error jika ada
5. Confirm Department
6. Download Staff template
7. Isi staff dan gunakan department code/name yang sudah wujud
8. Preview Staff
9. Betulkan error jika ada
10. Confirm Staff
```

### Jika hanya staff baru

```text
1. Pastikan department berkaitan sudah wujud
2. Download Staff template
3. Isi nama dan email
4. Preview
5. Confirm
```

### Jika mahu update staff sedia ada

```text
1. Pastikan email dalam fail sama dengan email dalam database
2. Isi field yang mahu dikemas kini
3. Preview — pastikan action menunjukkan "Update"
4. Confirm
```

---

## 16. Ringkasan satu halaman

```text
DEPARTMENT
- Key: code
- Wajib: code, name_bm, name_en
- Optional: parent_code, sort
- Boleh cipta department baru
- Parent tiada had aras (disyorkan 3: Pejabat → Bahagian → Seksyen/Unit)
- Sort sama dibenarkan
- Semua row mesti valid

STAFF
- Key: email
- Wajib: name
- Optional: email, phone, position_code, position_bm, position_en, department, sort, status
- Tidak boleh cipta department
- Department mesti wujud
- Email kosong = insert baru
- Nama sama dibenarkan
- Photo tidak diimport
- Sort sama dibenarkan
- Semua row mesti valid
```
