# Sanggraloka One — Backend (plain PHP 8.2, 3 lapis)

Backend web + REST API **Sanggraloka One**: satu cangkang aplikasi untuk resort
Sanggraloka Ubud yang menyatukan tiga modul di atas inti maintenance yang sudah
berfungsi:

- **Maintenance (FIX)** — work order, PM, aset, checklist (sudah terpasang).
- **SDM (CREW)** — kepegawaian, absensi, cuti, roster, university, KPI, payroll.
- **Sales & Marketing (SELL)** — mitra, booking, penawaran, KOL, studio konten.

Inti (login/logout/session/CSRF/pilih-properti + pola `BasePage`/`UiBuilder`/
`BaseModel`) DIPAKAI APA ADANYA — modul baru menumpang, tidak mengganti. Dua
tulang punggung lintas modul:

- **Papan Task SATU** — `public/tasks.php` (`TaskBoardService`) menyatukan
  `hr_tasks` (label CREW/SELL) + work order terbuka (label FIX read-only).
- **Kalender SATU** — `calendar.php`/`my-calendar.php` (`CalendarOverlayService`)
  meng-overlay shift, cuti, task due, dan booking venue di atas kalender WO.

Dibangun **persis** mengikuti [ARSITEKTUR-BACKEND-PHP.md](../docs/ARSITEKTUR-BACKEND-PHP.md),
[SPEK-HOTELFIX-MASTER.md](../docs/SPEK-HOTELFIX-MASTER.md), dan kontrak PM
[PM-PLAN-SANGGRALOKA-ONE.md](../docs/PM-PLAN-SANGGRALOKA-ONE.md): plain PHP (bukan
Laravel), 3 lapis, class-based dengan inheritance page builder, satu CSS global,
satu file per halaman. Nav dikelompokkan per modul (Maintenance / SDM /
Sales & Marketing / Bersama), dirender server-side sesuai role.

## Setup

1. **Konfigurasi**
   ```
   cp config/config.sample.php config/config.php
   ```
   Isi kredensial DB, `base_url`, dan buat `api.token_secret` acak:
   ```
   php -r "echo bin2hex(random_bytes(32));"
   ```
   `config/config.php` tidak masuk git.

2. **Database & migrasi** — file SQL disediakan tim DB Engineer di
   `db/migrations/` (folder itu TIDAK disentuh backend). Jalankan:
   ```
   php bin/migrate.php            # terapkan migrasi baru
   php bin/migrate.php --status   # lihat status
   ```
   Tabel pelacak `schema_migrations` dibuat otomatis dan idempoten.

3. **Jalankan (server built-in PHP)** — document root = `public/`:
   ```
   php -S localhost:8080 -t public
   ```
   Buka http://localhost:8080/login.php

## Peta folder

```
backend/
├── public/                 DOCUMENT ROOT (hanya folder ini terekspos)
│   ├── index.php login.php logout.php select-property.php
│   ├── work-orders.php work-order.php report.php   file thin 2-baris
│   ├── file.php            gateway file ber-otorisasi (storage/ di luar root)
│   ├── .htaccess           rewrite pretty path /api/v1/* -> api/v1/index.php (Apache)
│   ├── router.php          router utk `php -S` (dev) -- lihat §"Menjalankan
│   │                       untuk aplikasi Android" di bawah
│   ├── api/v1/             index.php (front controller pretty path REST) +
│   │                       auth.php wo.php report.php costs.php parts.php
│   │                       assets.php qr.php sync.php notifications.php
│   │                       meter.php (bentuk lama ?action=..., tetap jalan)
│   └── assets/             app.css (satu CSS global), app.js
├── src/
│   ├── Core/               bootstrap, Config, Database, Request, Response,
│   │                       Session, Csrf, Flash
│   ├── Auth/               Auth (session+token), Acl, Role
│   ├── Model/              BaseModel (+scoping property_id) & model konkret
│   ├── Service/            WorkOrderService, CostService, ReportService,
│   │                       NotificationService
│   ├── View/               Layout, UiBuilder
│   ├── Page/               BasePage + halaman web
│   ├── Api/                BaseApiController + controller REST
│   └── Support/            FileGateway
├── db/                     (milik DB Engineer — JANGAN diubah dari backend)
├── config/                 config.sample.php  (config.php lokal, gitignored)
├── storage/                upload (gitignored)
├── bin/migrate.php         runner migrasi
└── composer.json           autoload PSR-4 App\ -> src/
```

Aturan ketergantungan: **View → Domain → Data** satu arah. View tak menyentuh
PDO; Model tak meng-echo HTML; Service memegang aturan bisnis (dipakai web & API).

## Resep menambah satu halaman (ringkas §9 arsitektur)

1. Buat `src/Page/NamaPage.php` `extends BasePage`; set `$allowedRoles`,
   `$title`; isi `render()` (dan `handlePost()` bila ada form).
2. Susun tampilan lewat `$this->ui->...` (UiBuilder) — jangan tulis HTML mentah
   / inline style.
3. Ambil data lewat Model (`Nama::query(...)`, selalu scoped `property_id`);
   logika bisnis lewat Service.
4. Buat file thin `public/nama.php` (2 baris) yang memanggil Page.
5. Tautan menu muncul otomatis sesuai role. **Sumber tunggal nav = `Acl::visibleMenu($user)`**
   (grup terurut+terfilter role, tiap item `label/href/icon/badge`); Layout (UX/IX)
   merender hasilnya, tidak menghitung role sendiri.
6. Bila perlu gaya baru, itu urusan UX/IX (`public/assets/` + `UiBuilder`); modul
   TIDAK menulis HTML/CSS mentah.

## Tabel listing: Sort universal + Export Excel (TableSpec / ListTable)

Semua tabel listing memakai SATU template. Deklarasikan kolom SEKALI; framework
menurunkan (a) whitelist sort aman, (b) header sortable, (c) tombol + file
Export Excel. **Jangan lagi merakit `SortSpec` map manual per halaman.**

Kelas: `src/Support/TableSpec.php` (deklarasi kolom), `src/Support/ListTable.php`
(render + export), `src/Support/XlsxExporter.php` (.xlsx minimal via ZipArchive,
fallback CSV bila ekstensi zip mati), `UiBuilder::exportButton()`.

```php
$ui   = $this->ui;
$spec = TableSpec::make()
    ->col('wo_number', 'No. WO', 'wo.wo_number')
    ->col('location', 'Lokasi', 'l.name', ['field' => 'location_name'])          // kolom JOIN → sortable
    ->col('due_date', 'Due', '(wo.due_date IS NULL), wo.due_date', ['field' => 'due_date', 'format' => 'date'])
    ->col('amount',   'Nilai', 'q.amount', ['field' => 'amount', 'format' => 'money'])
    ->col('status',   'Status', 'wo.status', ['field' => 'status', 'format' => 'badge'])
    ->display('Aksi');                                                            // kolom non-data (tak sortable/ekspor)

$sort = SortSpec::resolve($this->req, $spec->sortMap(), 'wo_number', 'asc');      // utk filterBar hidden + tombol export

$table = ListTable::render($this->req, $ui, $spec, $listUrl, [
    // Panggil Model YANG SAMA — WHERE/filter TIDAK berubah. $limit null = export semua baris.
    'fetch' => static fn (string $orderBy, ?int $limit): array =>
        Model::listForProperty($pid, $filters, $limit ?? ListTable::EXPORT_MAX, $orderBy),
    'row'   => static fn (array $r): array => [ /* sel HTML, urut = kolom spec */ ],
    'default_sort' => 'wo_number', 'default_dir' => 'asc', 'limit' => 200,
    'preserve' => $currentFilters,                                                // filter aktif utk link sort
    'empty'    => 'Belum ada data.',
    'export'   => ['module' => 'fix', 'entity' => 'work_orders', 'filename' => 'work-orders'],
]);

$exportBtn = $ui->exportButton($listUrl, $currentFilters + ['sort' => $sort->alias, 'dir' => $sort->dir]);
return $this->ui->card('Filter', $filterBar) . $this->ui->card('Daftar', $table, $exportBtn);
```

Aturan penting:
- `col($alias,$label,$sqlExpr,$opts)`: `$sqlExpr` = ekspresi ORDER BY aman
  (identifier / `t.col` / `FIELD(t.col,'a','b')` / `(t.col IS NULL), t.col`);
  divalidasi `SortSpec::assertOrderBySyntax` (tanpa subquery). `field` = kunci
  baris hasil query (default = alias; kolom JOIN biasanya beda). `format` =
  `text|date|datetime|money|number|badge` (money/number ditulis sebagai ANGKA
  di xlsx). `sortable`/`export` default `true`.
- Kolom agregat (mis. `COUNT(...) AS asset_count`) boleh sortable via alias
  SELECT-nya (`->col('asset_count','Jml','asset_count',[...])`).
- **Kolom object (declaration-driven, OBJECT-STANDARD §2):** kolom yang
  menyebut object dideklarasikan SEKALI di spec dan framework merender
  link-nya OTOMATIS — row formatter cukup mengembalikan teks polos
  (`$ui->e(...)`), TANPA `objectLink()` manual:

  ```php
  ->col('asset_code', 'Kode', 'a.asset_code', ['object' => 'asset', 'object_id' => 'id'])
  ->col('location', 'Lokasi', 'l.name', ['field' => 'location_name', 'object' => 'location'])
  // 'object'    = tipe ObjectRegistry; keputusan link vs <span> = gate
  //               kewenangan yang SAMA dgn objectLink manual (satu pintu).
  // 'object_id' = kunci id di baris hasil query; default "{alias}_id"
  //               (kolom 'location' → 'location_id'). Id null/0 → sel dibiarkan.
  ```

  ListTable melewatkan sel yang sudah mengandung `<a ` (link manual/custom
  tidak dibungkus dobel). Export TIDAK terpengaruh: file xlsx/csv tetap berisi
  nilai mentah (teks polos), bukan markup. `objectLink()` manual tetap sah —
  dipakai untuk penyebutan object DI LUAR tabel listing (kartu, detail, dsb.).
  Contoh hidup: `src/Page/AssetListPage.php`.
- Export = SELURUH baris sesuai filter+sort aktif (tanpa limit halaman), nama
  file `{filename}-{Ymd-His}.xlsx`, header = label kolom, dicatat
  `AuditService::log(module,'export',entity,...)`. Akses ekspor = peran halaman
  (BasePage sudah men-guard di konstruktor — tidak ada gate baru).
- `BasePage::run()` menahan output layout sampai `render()` selesai sehingga
  ListTable bisa memancarkan file + `exit` sebelum ada markup (backward-compat).
- **PK/FK di export (task #21 owner, 9 Jul 2026 — "biar mudah di-VLOOKUP"):**
  `TableSpec::exportHeader()`/`exportRow()` OTOMATIS menyisipkan kolom `id`
  (PK baris, dari `$row['id']`) sebagai kolom PERTAMA, lalu setelah tiap
  kolom `export=true` yang mendeklarasikan `'object'`, satu kolom FK
  tambahan berjudul nama field id-nya sendiri (`object_id`, mis.
  `location_id`, `asset_id` — bukan label terjemahan). Kolom FK DILEWATI
  bila `object_id` sudah `'id'` (kolom menaut ke baris itu sendiri, mis.
  `wo_number` di `WorkOrderListPage` — sudah terwakili kolom PK pertama,
  dihindari agar tak ada dua header `id` yang membingungkan). Semua nilai
  id ditulis sebagai ANGKA di xlsx (bukan teks) — jalur CSV fallback ikut
  aturan yang sama (satu gerbang `TableSpec`, tidak ada kode ganda di
  `CsvExporter`/`XlsxExporter`). TAMPILAN LAYAR TIDAK BERUBAH — mekanisme
  ini murni jalur export. Baris tanpa kunci `'id'` (hasil query
  hand-picked/agregat tanpa PK, bukan `SELECT alias.*`) -> sel PK kosong,
  TIDAK fatal. Per audit 9 Jul 2026, 4 dari 35 halaman TableSpec begini:
  `AdminUsersPage` (`UserPropertyAccess::listForProperty` — id hanya
  sebagai `access_id`), `HrCompetenciesPage` (`HrEmployeeComp::matrix`),
  `HrCompetencyDetailPage` (`HrEmployeeComp::holdersOf`),
  `HrKpiDefinitionDetailPage` (`HrKpiDefinition::tasksLinked` — id hanya
  sebagai `task_id`) — follow-up (opsional, bukan bug): tambahkan `id`
  polos ke SELECT Model terkait bila owner ingin PK ikut ter-export di
  keempat halaman itu juga.

### Empty-state ber-tombol "+ Buat" (task #18 owner, 9 Jul 2026)

Owner: SETIAP tabel/daftar yang KOSONG harus menampilkan tombol menuju form
pembuatan item baru (link ke halaman form yang sudah ada; modal tidak wajib),
dengan role access tetap dihormati (tanpa hak → tombol tidak dirender).
Mekanisme deklaratif, opsional, ADDITIVE (tak mengubah perilaku halaman yang
belum diisi opsi ini):

- `UiBuilder::createButton(?array $action): string` — komponen dasar. `null`
  atau `href` kosong → `''`. `$action = ['href'=>string,'label'=>?string,
  'capability'=>?string]`. Bila key `'capability'` hadir & bukan `null`,
  digerbang OTOMATIS via `Acl::can(Auth::user()['role'], capability)`
  ('*' super_admin dihormati) — pemanggil TIDAK perlu menggerbang dobel;
  aman dipanggil dengan aksi mentah (belum tergerbang) ATAUPUN aksi yang
  sudah tergerbang (tanpa key `'capability'` → selalu dirender).
- `UiBuilder::table()`, `tableSortable()`, `treeTable()` — parameter/opsi
  `create` (opsional, param terakhir pada table()/tableSortable(); key
  `$opts['create']` pada treeTable()): dirender HANYA saat data kosong,
  lewat `createButton()` di atas (posisi: baris `<td>` kosong, di bawah teks
  empty existing — tidak mengubah tampilan saat data ADA).
- `ListTable::render()` — opsi `$opts['create']` (sama bentuk), diteruskan ke
  `tableSortable()`. Dipakai bila tabel dibangun dari `TableSpec`.
- `ObjectRegistry` — field `'create'` OPSIONAL per tipe di `MAP`
  (`['href'=>...,'label'=>...,'capability'=>?string]`, href = nama halaman
  RELATIF yang SUDAH punya form tambah nyata) + helper
  `ObjectRegistry::createAction(array $user, string $type): ?array` —
  `null` bila tipe tak mendeklarasikan `'create'` ATAU `$user` tanpa
  kapabilitasnya (Acl::can, `$user` eksplisit — pola sama dgn `canView()`/
  `canAutocomplete()`); selain itu `['href'=>base_url absolut,'label'=>...]`
  (siap dioper langsung ke `createButton()`/opsi `create` di atas TANPA key
  `'capability'` lagi — sudah tergerbang). Tipe terdaftar per 9 Jul 2026:
  `work_order` (→ report.php, `report.create`), `part`/`vendor`
  (→ parts.php/vendors.php, `asset.manage`), `partner`/`booking`/`quotation`
  (→ partners.php/bookings.php/quotes.php, `sell.commercial.view`),
  `product`/`kol` (→ products.php/kol.php, `sell.view`). Tipe LAIN
  (`employee`, `competency`, `course`, `faculty`, `position`, `department`,
  `category`) SENGAJA tidak diisi — halaman list-nya tidak punya form tambah
  nyata (master dikelola admin/import/proses lain); JANGAN isi `'create'`
  tanpa form nyata di baliknya (verifikasi: grep `formOpen`/`handlePost` di
  Page tujuan).

Contoh (sub-tabel di halaman detail, target BUKAN tipe registry — deklarasi
langsung tanpa lewat `ObjectRegistry`):

```php
$pmTable = $this->ui->table(
    ['Nama jadwal', 'Frekuensi', 'Checklist', 'Status', ''],
    $pmRows,
    'Belum ada jadwal PM ber-scope kategori ini.',
    ['href' => $base . '/pm-schedules.php', 'label' => 'Buat Jadwal PM', 'capability' => 'schedule.manage']
);
```

Contoh (target = tipe registry, via `ObjectRegistry::createAction()`):

```php
$waitingTable = $this->ui->table(
    ['No. WO', 'Deskripsi', 'Prioritas', 'Due date'],
    $waitingRows,
    'Tidak ada WO yang menunggu part ini.',
    ObjectRegistry::createAction($this->user, 'work_order')
);
```

Contoh hidup: `AssetCategoryDetailPage` (jadwal PM & checklist default),
`PartDetailPage` (WO menunggu part), `VendorDetailPage` (kontrak),
`MktProductDetailPage`/`MktPartnerPage` (booking/penawaran via
`ListTable::render` opsi `create`), `HrEmployeeDetailPage` (cuti).

### Pengecualian resmi (audit kepatuhan TableSpec, 9 Jul 2026)

Owner meminta audit "semuanya sudah ikut TableSpec". Semua halaman listing
data yang layak (filter+sort+export, satu entitas per baris) sudah dikonversi
ke `TableSpec`/`ListTable` dan kolom object-nya sudah deklaratif (`'object'`/
`'object_id'`, bukan `objectLink()` manual di row closure). Kategori berikut
DIKECUALIKAN dari kewajiban TableSpec (bukan listing filter+sort+export
biasa) — `objectLink()`/`ui->table()`/`ui->tableSortable()` manual di halaman
ini tetap sah sesuai OBJECT-STANDARD §2 ("di luar tabel listing"):

- **Kanban**: `MktQuotesPage` (quotes.php), `MktReviewQueuePage`
  (review-queue.php) — funnel kolom status, bukan baris flat.
- **Kalender**: `CalendarPage`, `CalendarDayPage`, `MyCalendarPage`,
  `MktVenueCalendarPage`.
- **Feed/dashboard** (kartu ringkasan, bukan tabel filter): `DashboardPage`
  (index), `HrEngagementPage`, `MktSellDashboardPage`, `EnergyDashboardPage`,
  `TodayPage` (kelompok "due hari ini" per zona).
- **Composer** (form/alat kerja, bukan listing): `HrBriefingDailyPage`,
  `HrRosterPage` (grid roster), `ChecklistBuilderPage`, `HrAiIntakePage`,
  `MktStudioPage` (chat AI), `PmPlanPage` (editor rencana PM).
- **Guest-cards deck**: `HrGuestCardsPage`.
- **Compliance** (multi-sumber PHP, agregasi lintas tabel): `CompliancePage`.
- **Payroll (zona merah)**: `HrPayrollPage`, `HrPayrollParamsPage`.
- **Sub-tabel halaman detail** (360°, relasi milik SATU objek — bukan listing
  independen): `AssetDetailPage`, `LocationDetailPage`, `WorkOrderDetailPage`,
  `TaskDetailPage`, `HrEmployeeDetailPage`, `HrCoursePage`, `MktPartnerPage`,
  `MktBookingPage`, `VendorDetailPage`, `PartDetailPage`,
  `HrCandidateDetailPage`, `HrCompetencyDetailPage`, `MktKolDetailPage`,
  `MktQuotationDetailPage`, `MktProductDetailPage` (+ halaman detail baru
  sejenis yang menyusul).
- **Laporan agregat/cetak** (bukan browsing baris mentah; unit ekspor sendiri
  atau tanpa sort user): `ReportsPage` (KPI + CSV via `reports-export.php`),
  `BriefingPage` (rekap harian, tombol cetak), `AssetHealthPage` (skor
  dihitung PHP, urutan tetap merah→kuning→hijau by rule, bukan sort kolom).
- **Lookup satu kode**: `QrLookupPage` (hasil scan satu aset/lokasi, bukan
  daftar).
- **Form input massal**: `MeterPage` (satu form menyimpan banyak titik ukur
  sekaligus, bukan tabel yang di-browse).
- **Upload/import — pratinjau & histori**: `HrGuestUploadPage`,
  `MktSellUploadPage`.
- **Demo/style guide** (bukan data nyata): `StyleguidePage`.
- **Profil pribadi** (ringkasan milik sendiri, bukan listing multi-user):
  `ProfilePage`.
- **Kolom polymorphic** (tipe object berbeda per baris tergantung nilai
  kolom lain — tak bisa dideklarasikan statis di TableSpec): kolom "Scope"
  `PmSchedulesPage` (aset/lokasi/kategori bergantian; `objectLink()` manual
  di row closure tetap sah untuk kolom ini SAJA — kolom lain di halaman yang
  sama tetap TableSpec biasa).
- **Sub-tabel manual di halaman yang tabel utamanya SUDAH TableSpec** (bukan
  tabel utama, tidak perlu sort/export sendiri): `PartListPage` (laporan
  pemakaian + WO menunggu part), `HrKpiPage` (daftar skor per periode),
  `HrOnboardingPage` (DW pool), `HrUniversityPage` (placement test),
  `LogbookPage` (ringkasan kondisi saat ini).

`HrRecruitmentPage` (candidates) SENGAJA TIDAK disentuh audit ini — sedang
dikerjakan paralel oleh agent lain (halaman detail `candidate.php` +
`ObjectRegistry`); audit ulang setelah pekerjaan itu selesai.



- **Nav:** `App\Auth\Acl::visibleMenu(array $user, array $badges = []): array`
  → `[['group'=>..,'solo'=>bool,'items'=>[['label','href','icon'(Tabler),'badge'?],..]],..]`.
  Grup: Dashboard · Hari Ini · Maintenance · SDM · Sales & Marketing · Bersama · Admin.
  Item boleh menunjuk file modul gelombang 2 yang belum dibuat.
- **Papan task:** `App\Service\TaskBoardService` — `createTask(array): int`,
  `completeTask(int,int): void`, `board(int,array): array` (gabung `hr_tasks`
  CREW/SELL + `work_orders` terbuka label FIX read-only; toleran migrasi belum jalan).
- **Audit:** `App\Service\AuditService::log(module, action, entity, ?id, ?before, ?after)`
  → `audit_logs` append-only, best-effort (no-op bila tabel/kolom belum ada).
- **Kalender:** `App\Service\CalendarOverlayService` — overlay shift/cuti/task/booking
  di atas kalender WO (`calendar.php` tim, `my-calendar.php` pribadi).
- **Tema:** `App\Service\ThemePreferenceService` — `terang|senja|malam` (default
  `terang`), dipilih di Profil Saya; Layout membacanya sebagai `data-theme`.

## Konvensi kualitas (§8 arsitektur — ditegakkan)

- `declare(strict_types=1)` di semua file; 1 file = 1 kelas.
- PDO prepared statement untuk SEMUA query (tanpa kecuali).
- CSRF wajib di setiap POST (`BasePage::run()` memverifikasi otomatis).
- PRG (Post/Redirect/Get) + flash message untuk semua form.
- Scoping `property_id` selalu di lapis Model (isolasi multi-property).
- Uploads ke `storage/` di luar document root, dilayani `public/file.php`
  ber-otorisasi.

## Menjalankan untuk aplikasi Android

Kontrak resmi (§9, dikunci PM): **pretty path menang** — Android (Retrofit)
memanggil `/api/v1/auth/login`, `/api/v1/wo/mine`, `/api/v1/wo/{id}/start`,
dst. (lihat `HotelFixApi.kt`), BUKAN `/api/v1/wo.php?action=...`. Backend
menyediakan front controller `public/api/v1/index.php` yang mem-parse path
tsb dan meneruskan ke controller `App\Api\*` yang sama persis dipakai bentuk
lama — jadi aturan bisnis (Service) tidak pernah dobel-tulis, dan bentuk lama
(`?action=...`) tetap berfungsi untuk kompatibilitas.

**Opsi A — server built-in PHP (dev, tanpa Apache):**

```
php -S localhost:8080 -t public public/router.php
```

`router.php` meneruskan `/api/v1/*` ke front controller, melayani file statis
apa adanya, dan tetap mendukung halaman web (`/login` → `login.php`, dst.)
serta bentuk lama `/api/v1/wo.php?action=...`. Set `BuildConfig.BASE_URL`
Android ke `http://10.0.2.2:8080/api/v1/` (emulator) atau
`http://<ip-lan-komputer>:8080/api/v1/` (perangkat fisik di jaringan sama).

**Opsi B — Apache:** cukup pastikan `mod_rewrite` aktif dan document root
= `public/`; `public/.htaccess` sudah menyertakan aturan rewrite
`/api/v1/<apapun>` → `public/api/v1/index.php` tanpa mengganggu halaman web
atau aset statis.

**Uji cepat dengan curl** (login → ambil token → GET wo/mine):

```bash
# 1) Login (property_id TIDAK dikirim; §2.3 — bila multi-akses, token
#    diterbitkan utk properti pertama, lalu panggil select-property bila perlu)
curl -s -X POST http://localhost:8080/api/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"teknisi1","password":"rahasia"}' | tee /tmp/login.json

TOKEN=$(php -r '$j=json_decode(file_get_contents("/tmp/login.json"),true); echo $j["access_token"];')

# 2) (opsional) pilih properti lain bila akun multi-properti
curl -s -X POST http://localhost:8080/api/v1/auth/select-property \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"property_id":"2"}'

# 3) WO milikku
curl -s http://localhost:8080/api/v1/wo/mine -H "Authorization: Bearer $TOKEN"

# 4) Mulai kerjakan WO id=1
curl -s -X POST http://localhost:8080/api/v1/wo/1/start -H "Authorization: Bearer $TOKEN"

# 5) Selesaikan WO (multipart: payload JSON + foto after wajib)
curl -s -X POST http://localhost:8080/api/v1/wo/1/done \
  -H "Authorization: Bearer $TOKEN" \
  -F 'payload={"repair_note":"Sudah diperbaiki","labor_minutes":30};type=application/json' \
  -F 'afterPhoto=@/path/ke/foto-after.jpg;type=image/jpeg'

# 6) Lapor defect baru (multipart: payload JSON + foto wajib)
curl -s -X POST http://localhost:8080/api/v1/report \
  -H "Authorization: Bearer $TOKEN" \
  -F 'payload={"location_id":"1","category_id":"1","description":"AC bocor","priority":"high"};type=application/json' \
  -F 'photo=@/path/ke/foto.jpg;type=image/jpeg'
```

Catatan: front controller & controller di `src/Api/*` TIDAK menyentuh
`src/Page/` atau `src/View/`; halaman web dashboard/admin berjalan seperti
biasa tanpa terpengaruh perubahan ini.

## Catatan desain — token API (di luar skema §5)

Skema §5 tidak memuat tabel `api_tokens`. Untuk Fase 1 dipakai **token
stateless HMAC-signed** (payload: user_id, property_id, role, exp) sehingga
tidak perlu tabel baru (kontrak DB Engineer tak berubah) dan respons dapat
dibatasi `property_id` token tanpa query. Detail & TODO revoke ada di
`src/Auth/Auth.php`. Push FCM di-stub dengan interface jelas di
`src/Service/NotificationService.php` (aktif saat `fcm.server_key` diisi).
