# Module: Tender & Sebut Harga (`contentTender`)

> Rujukan pelaksanaan: `app/Http/Controllers/Backend/ContentTenderController.php`

## 1. Tujuan

Mengurus **Tender** dan **Sebut Harga** untuk portal: no. rujukan, jenis, kategori, kaedah perolehan, tarikh buka/tutup, taklimat & lawatan tapak, dokumen utama, lampiran berbilang, dokumen keputusan, pegawai untuk dihubungi, dan terjemahan BM/EN.

## 2. Struktur database

### `content_tender`

| Kolum | Jenis | Nota |
|---|---|---|
`tender_id` | bigint PK | |
`tender_code` | string(100) **unique** | No. rujukan tender |
`tender_type` | string(50) | ref `TENDER_TYPE` |
`tender_category` | string(50) | ref `TENDER_CATEGORY` |
`tender_method` | string(50) null | ref `TENDER_METHOD` |
`tender_status` | string(30) | ref `TENDER_STATUS` (`DRAFT`/`PUBLISHED`/`CANCELLED`) |
`tender_open_date` / `tender_open_time` | date / time | Mula terbit |
`tender_close_date` / `tender_close_time` | date / time | Tarikh & masa tutup |
`tender_briefing_date` / `tender_briefing_time` / `tender_briefing_place` | date / time / string | Taklimat |
`tender_site_visit_date` | date null | Lawatan tapak |
`tender_document` | string(255) null | Dokumen utama (PDF/Word/Excel/ZIP) |
`tender_result_document` | string(255) null | Keputusan / surat setuju terima |
`tender_result_notes` | text null | Catatan keputusan |
`tender_reference_url` | string(500) null | Pautan ePerolehan |
`tender_contact_name` / `_phone` / `_email` | string null | Pegawai untuk dihubungi |
`tender_portal` | string(50) null | Portal — **select** dari `frontend_sites` (slug), default = site `is_default` |
`tender_sorting` | integer | Disimpan untuk masa depan |
`created_by` / `updated_by` | string(255) | Ikut konvensyen modul sedia ada |

### `content_tender_translation`

`id`, `tender_id` (FK cascade), `language` (`ms`/`en`/`zh` dari ref `LANGUAGE`), `title`, `description`, `eligibility`, `scope_of_work`, `main` (bool), timestamps. **Unique**: (`tender_id`, `language`).

> UI ikut konsep **`content-application`**: kad *Translations* di **paling atas** borang, dropdown **Language** untuk tukar bahasa, dan kotak **Main Translation** untuk tanda bahasa utama.

### `content_tender_document`

`id`, `tender_id` (FK cascade), `label`, `file`, `sort`, timestamps — lampiran berbilang (BOQ, borang, lukisan, spesifikasi).

## 3. Status

| Status tersimpan | Maksud |
|---|---|
`DRAFT` | Belum terbit — tidak dipaparkan di portal |
`PUBLISHED` | Terbit — paparan **Open/Closed** dikira dari tarikh. **Default semasa create** |
`CANCELLED` | Dibatalkan |

> Urutan pilihan ikut `ref.sort`, dan pilihan **pertama** dipilih automatik (`$loop->first` — sama konsep dengan `content-application`). `ref` `TENDER_STATUS` disusun `PUBLISHED (1) → DRAFT (2) → CANCELLED (3)` supaya default kekal *Published*.

**Open/Closed dikira automatik** (accessor `display_status`):

```text
PUBLISHED + (open_date null atau <= hari ini) + (close null atau now <= close_date+close_time)  → OPEN
PUBLISHED selain daripada itu                                                                  → CLOSED
DRAFT / CANCELLED                                                                               → ikut status
```

Tiada cron/scheduler diperlukan.

### 3.1 Input tarikh & masa

Medan di **Important Dates** guna partial `contentTender/partials/_date-input.blade.php` dan `_time-input.blade.php` — konsep sama seperti `content-article/create`:

| Ciri | `_date-input` | `_time-input` |
|---|---|---|
| Ikon | kalendar (kanan) | jam (kanan) |
| Picker | `onclick="this.showPicker()"` | `onclick="this.showPicker()"` |
| Butang clear | — | ✕ (`x-show="v"` + `x-cloak`, `@click="v = ''"`) |
| Padding input | `pr-11` | `pr-16` |
| Format nilai | `Y-m-d` | `H:i` (detik `:00` dibuang) |
| Border error | `$errors->has($name)` → `border-error-300` | sama |

### 3.2 Susun atur borang

- **Index** menyamai UI `content-application/index`: tajuk *All Tenders* + butang **Create** (ikon grid), bar filter satu baris (*Type / Category / Any State / Status / **Reset*** di kiri, kotak **Search** `lg:w-56` di kanan), jadual `px-5` dengan header uppercase kecil, baris `group hover:bg-gray-50`, badge status & kategori `rounded-md` (OPEN = success, CLOSED = gray, DRAFT = warning, CANCELLED = error; kategori = brand), butang Edit/Delete ikon, pagination `vendor.pagination.custom`.
  - Bar filter: `flex-col … lg:flex-row` — pada skrin kecil semua bertindan; pada laptop 1 baris. Kumpulan filter di kiri `min-w-0 flex-1 flex-wrap` (select boleh wrap dalam kumpulan), **Reset** duduk terus di kanan *select Status* (`shrink-0`), dan **Search** di hujung kanan.
  - **Reset** dirender sendiri dalam form (bukan dijana oleh `_index-reset-button`), supaya JS partial itu tidak memindahkannya ke tempat lain.

- **Tender Information** — baris 1: *Tender Reference No.* + *Portal Display* (`md:grid-cols-2`); baris 2: *Type / Category / Method / Status* (`sm:grid-cols-2 lg:grid-cols-4`).
- **Important Dates** — dua kumpulan dengan kapsyen kecil uppercase: **Tender Period** (opening/closing date & time) dan **Briefing & Site Visit** (briefing date/time, site visit, venue). Setiap kumpulan `lg:grid-cols-4`.
- Dropdown **Type / Category / Method / Status / Portal** guna partial `contentTender/partials/_select.blade.php`: placeholder `-- Select … --`, chevron tersendiri, border error automatik, dan opsyen `firstWhenEmpty` (pilihan pertama `ref.sort` dipilih bila tiada nilai — konsep `content-article`).
- Halaman **edit** guna butang **Update** (bukan *Save Changes*). Lampiran **disatukan dalam kad Documents** (borang utama): senarai fail sedia ada (ikon + label + nama fail + tarikh + butang padam) dan input `attachments[]` **multiple** guna **FilePond yang sama seperti Tender Document (main)** (`storeAsFile: true`, `allowMultiple`, `data-max-files="20"`) — semua disimpan bila tekan **Update**. Butang 🗑 padam lampiran guna **SweetAlert2** (`.delete-attachment-form`, fallback `confirm()` jika `Swal` tiada).

## 4. Routes

| Method | URI | Permission | Name |
|---|---|---|---|
GET | `/content-tender` | `content-tender.view` | `content-tender.index` |
GET | `/content-tender/create` | `content-tender.create` | `content-tender.create` |
POST | `/content-tender` | `content-tender.create` | `content-tender.store` |
GET | `/content-tender/{id}/edit` | `content-tender.update` | `content-tender.edit` |
PUT | `/content-tender/{id}` | `content-tender.update` | `content-tender.update` |
DELETE | `/content-tender/{id}` | `content-tender.delete` | `content-tender.destroy` |
DELETE | `/content-tender/documents/{id}` | `content-tender.delete` | `content-tender.documents.destroy` |

Menu admin: **Content → Tenders** (`backend_menu` + `backend_role_mapping`).

> Selepas **create** dan **update**, pengguna di-redirect ke senarai (`content-tender.index`) dengan flash *Tender created/updated successfully.* Padam lampiran redirect ke halaman edit (kerana tindakan itu dibuat dari halaman edit).

## 5. Ref kategori

| Cat | Kod |
|---|---|
`TENDER_TYPE` | `TENDER` (Tender), `SEBUT_HARGA` (Sebut Harga) |
`TENDER_STATUS` | `DRAFT`, `PUBLISHED`, `CANCELLED` |
`TENDER_CATEGORY` | `WORK`, `SUPPLY`, `SERVICE`, `CONSULTANCY` |
`TENDER_METHOD` | `OPEN`, `LIMITED`, `QUOTATION`, `NEGOTIATION` |

> Label dropdown guna `ref.descr_en` (English) supaya konsisten dengan UI module. `descr` (BM) kekal dalam DB untuk kegunaan lain.

## 6. Fail & storan

```text
Dokumen utama   : storage/app/public/content-tender/files/
Dokumen keputusan: storage/app/public/content-tender/results/
Lampiran        : storage/app/public/content-tender/attachments/
```

Nama fail: `<slug-nama>_<Ymd>_<5-aksara>.<ext>`. Semua fail dipadam apabila rekod/lampiran dipadam.

> **URL fail:** guna `url('storage/'.$file)` (URL generator), bukan `Storage::disk('public')->url()` — supaya host ikut permintaan semasa. `AppServiceProvider::boot()` juga panggil `URL::forceRootUrl(request()->getSchemeAndHttpHost())` dalam `local` kerana OS env `APP_URL` mengatasi `.env` (contoh `http://localhost` sedangkan app dihidang di `http://127.0.0.1:8000`). Pastikan `php artisan storage:link` sudah dibuat (`public/storage` → `storage/app/public`).

## 7. Validation utama

- `tender_code` wajib + unik
- `tender_type`, `tender_category`, `tender_status` wajib + mesti wujud dalam ref
- `tender_close_date >= tender_open_date`; jika tarikh sama, `close_time >= open_time`
- Fail: `pdf,doc,docx,xls,xlsx,zip`, maksimum 150MB
- Sekurang-kurangnya **satu** terjemahan mesti ada `title`
- Hanya **satu** bahasa boleh ditanda `main` (jika tiada, bahasa pertama jadi main)
- `tender_reference_url` mesti URL sah; `tender_contact_email` mesti e-mel sah

## 8. Aliran kerja admin

1. **Content → Tenders → Create**
2. Isi no. rujukan, jenis, kategori, kaedah, status
3. Isi tarikh buka/tutup (+ taklimat/lawatan tapak jika ada)
4. Muat naik dokumen utama (dan dokumen keputusan bila selesai)
5. Isi pegawai untuk dihubungi + pautan ePerolehan
6. Isi terjemahan **ms** (wajib) dan **en**; tick **Main Translation (Bahasa Melayu)**
7. Pilih lampiran berbilang (`attachments[]`) dalam kad **Documents** — disimpan bersama butang **Save**/**Update**
8. Tukar status ke `PUBLISHED` untuk terbitkan

## 9. Ujian

`tests/Feature/ContentTenderTest.php` — 11 test / 59 assertions:

- index & create render
- store + 2 terjemahan (main = ms)
- validation wajib, kod unik, tarikh tutup
- status OPEN/CLOSED/DRAFT/CANCELLED
- edit render (terjemahan + medan lampiran, butang **Update**)
- upload lampiran melalui borang utama (`attachments[]`) & padam lampiran (storage)
- delete tender → fail + terjemahan terpadam
- 403 tanpa permission
