# Sanggraloka One — Standar Object Universal

Versi 1.0 · 9 Juli 2026 · Ditetapkan PM dari arahan owner.
Melengkapi: PM-PLAN §1/§8, DESIGN-SYSTEM.md, README backend §TableSpec.

## 0. Prinsip (arahan owner, dikunci)

1. **Setiap object punya halaman sendiri** (360°: identitas + semua yang
   terkait dengannya).
2. **Setiap penyebutan object di halaman mana pun adalah LINK** ke halaman
   object itu (aset di halaman lokasi → klik → halaman aset; nama PIC →
   klik → halaman karyawan). Lintas modul (FIX/CREW/SELL) sama saja.
3. **Security jelas & server-side**: user tanpa kewenangan TIDAK BISA masuk
   halaman object (guard di server, bukan sembunyi menu) — dan **link tidak
   dirender sebagai link** bila user tak berwenang membukanya (tampil teks
   polos).
4. **Karyawan = 2 tampilan**: (a) **profil publik** — nama, jabatan,
   departemen, foto — boleh dilihat semua user login (dipakai link PIC);
   (b) **halaman privat** — data sensitif (NIK, kontak, gaji-terkait,
   dokumen, teguran) — hanya pemilik sendiri + HR/GM sesuai matriks.
5. **Filter entitas = autocomplete** (anti-typo saat item banyak),
   anti-SQL-injection by construction.

## 1. ObjectRegistry (pengganti aman pola `tb_object`)

Satu peta terpusat di kode — `src/Support/ObjectRegistry.php`:

```php
'asset' => [
  'table'      => 'assets',            // nama tabel (HARDCODE, bukan input)
  'id'         => 'id',
  'name'       => 'name',              // kolom utk autocomplete & label
  'extra'      => ['asset_code'],      // kolom label tambahan (opsional)
  'page'       => 'asset.php',         // halaman object → asset.php?id={id}
  'capability' => 'asset.view',        // hak minimal utk MEMBUKA & MELIHAT LINK
  'scoped'     => true,                // wajib property_id
],
'employee' => [ 'page' => 'employee.php', 'public_page' => 'employee-profile.php',
                'capability' => 'employee.view', 'public_capability' => null /* semua login */ ],
'location' => [...], 'work_order' => [...], 'partner' => [...], 'kol' => [...],
'part' => [...], 'vendor' => [...], 'product' => [...], 'booking' => [...],
'quotation' => [...], 'competency' => [...], 'course' => [...], 'task' => [...],
```

Aturan: tipe object BARU wajib didaftarkan di registry ini — dari satu
pendaftaran otomatis dapat: link universal, autocomplete, dan guard.

## 2. Link universal — `UiBuilder::objectLink()`

```php
$this->ui->objectLink('asset', $id, $label)
// → <a href="asset.php?id=…">label</a>  bila user punya capability-nya
// → <span>label</span>                  bila tidak (tanpa href — bukan CSS hide)
```

- Keputusan boleh/tidak = `Acl` di server saat render.
- Untuk `employee`: user biasa mendapat link ke **profil publik**; pemilik
  sendiri/HR/GM mendapat link ke halaman privat.
- Semua Page WAJIB memakai ini untuk menyebut object — dilarang menulis
  `<a href="asset.php?id=` manual (supaya aturan security link satu pintu).
- **ATURAN DEFAULT (penegasan owner, 9 Jul 2026): penyebutan object TANPA
  link = BUG**, bukan fitur yang menunggu diminta. Berlaku di SEMUA tempat:
  kolom tabel listing (row formatter TableSpec), sub-tabel halaman detail,
  kartu kanban, chip kalender, dashboard, briefing. Setiap halaman baru
  atau retrofit apa pun WAJIB lolos cek: "adakah nama/nomor object yang
  tampil sebagai teks polos padahal user berwenang membukanya?" → nol.
  Ini masuk QC gate §6 butir 2 dan resep object-creator langkah 4–5.

**Cara by-mechanism (declaration-driven) — tabel listing.** Di tabel listing
kolom yang menyebut object TIDAK lagi memanggil `objectLink()` manual di row
formatter: deklarasikan sekali di TableSpec via opsi
`'object' => '{tipe}'` (+ `'object_id' => '{kunci_id_row}'`, default
`{alias}_id`), dan `ListTable::render()` otomatis membungkus nilai sel dgn
`UiBuilder::objectLink()` — gate kewenangan yang dipakai persis sama
(ObjectRegistry, satu pintu), export tetap teks polos, sel yang sudah
ber-`<a ` tidak dibungkus dobel, id null/0 dibiarkan apa adanya. Contoh:
`->col('location','Lokasi','l.name',['field'=>'location_name','object'=>'location'])`
(lihat README backend §TableSpec + `src/Page/AssetListPage.php`). `objectLink()`
manual tetap sah, dipakai HANYA untuk penyebutan object di luar tabel listing
(kartu detail, kanban, kalender, dashboard, briefing).

**Kasus khusus — field `department` (KODE string, bukan FK).** Kolom
`department` di `hr_employees`/`hr_positions`/`hr_jobdesks`/`hr_kpi_definitions`/
`asset_categories`/dll TETAP kode string (bukan `department_id` FK) — mekanisme
declaration-driven TableSpec di atas TIDAK berlaku langsung (ia mensyaratkan
id numerik di kolom `object_id`). **WAJIB pakai `UiBuilder::departmentLink($kode,
$propertyId)`** (bukan `$ui->e($kode)` mentah) di MANA PUN field ini disebut —
row formatter tabel, kv() detail, sub-tabel, kartu apa pun. Method ini satu
pintu resolve kode→`HrDepartment::findByCode()`→`objectLink()`, dgn fallback
teks polos kalau kodenya tak terdaftar. **Riwayat:** owner menemukan pola
tambal-manual (try/findByCode/objectLink diulang per halaman) ketinggalan di
~22 titik lain (9 Jul 2026, temuan ke-3) — makanya sekarang WAJIB satu method
ini, bukan menulis ulang try/catch-nya.

## 3. Guard halaman object (server-side)

- Halaman object memakai `$allowedRoles`/capability seperti biasa **plus**
  cek scoping `property_id` di Model (sudah standar BaseModel).
- `employee-profile.php` (BARU): profil publik — semua user login; HANYA
  menampilkan nama, jabatan, departemen, foto, ekstensi internal (bila ada).
  Tidak ada data sensitif sama sekali di query-nya (bukan disembunyikan di
  view — kolom sensitif tidak di-SELECT).
- `employee.php` (privat) tetap: hr_admin/hr_staff/gm/fc(baca)/dept_head
  (timnya) + karyawan ybs utk dirinya.

## 4. Autocomplete universal (anti-typo, anti-injection)

- **Endpoint tunggal**: `public/api/autocomplete.php?type={tipe}&q={kata}`
  — session login wajib; `type` harus terdaftar di ObjectRegistry DAN user
  punya capability-nya (kalau tidak → 403/daftar kosong).
- Query SELALU prepared statement:
  `SELECT {id},{name} FROM {table} WHERE property_id=? AND {name} LIKE ? LIMIT 20`
  — `{table}/{id}/{name}` dari registry (hardcode), keyword di-bind
  (`'%'.$q.'%'` sebagai parameter). **Tidak ada satu pun bagian query yang
  berasal dari input user.** Output JSON `[ {id, label} ]`.
- **Komponen UI**: `UiBuilder::autocompleteField($label,$name,$type,$opts)`
  → input teks + hidden `{name}_id` + listbox; JS `S1Autocomplete` di
  app.js (debounce 250ms, keyboard ↑↓ Enter, aria-combobox). Filter bar
  entitas (lokasi/aset/karyawan/mitra/produk) memakai ini menggantikan
  dropdown panjang; nilai yang dikirim ke server = **id** (bukan teks bebas).
- Fallback tanpa JS: input tetap berfungsi sebagai pencarian teks biasa.

## 5. Kaitan dgn standar lain

- Tabel listing: kolom object dirender via `objectLink` di dalam `row`
  formatter TableSpec — sortable tetap by kolom SQL-nya.
- Design terpisah tetap berlaku: markup link/autocomplete hanya lahir di
  UiBuilder; gaya di app.css; perilaku di app.js.
- Audit: pembukaan halaman sensitif (employee privat) sudah/harus tercatat
  `view_sensitive` (pola zona merah).

## 6a. RESEP: OBJECT BARU (kontrak dgn owner — "saya sebutkan object baru,
## anda paham apa yang harus dibuat")

Bila owner menyebut object baru (mis. "buatkan object Kendaraan"), yang
WAJIB dikerjakan tanpa ditanya lagi:

1. **Skema**: tabel ber-`property_id` (+migrasi bernomor); kolom minimal
   id + nama + status. Kolom sensitif dipisahkan sejak desain.
2. **Registrasi** di `ObjectRegistry` (tabel, kolom nama, halaman,
   capability, scoped) → otomatis dapat: link universal, autocomplete,
   guard link.
3. **Halaman list** memakai `TableSpec`+`ListTable` → otomatis: SEMUA kolom
   sortable (termasuk JOIN), toolbar filter (entitas = autocomplete),
   tombol Export Excel, audit export.
4. **Halaman detail 360°**: identitas + SEMUA yang terkait (relasi tampil
   sebagai `objectLink` yang bisa diklik; task terkait; riwayat; dokumen)
   + `$allowedRoles`/capability server-side + audit `view_sensitive` bila
   memuat data sensitif.
   **JANGAN pernah menaruh tombol "‹ Kembali ke …" hardcoded** di halaman
   detail (baik di `pageHeader(...)` argumen aksi maupun di-`prepend` ke
   `$out`). Tombol back ditangani UNIVERSAL & satu titik oleh
   `App\Support\BackTrail` (dirender `App\View\Layout::open()` untuk SETIAP
   halaman ber-`?id=`, merujuk halaman asal via HTTP_REFERER, dengan
   fallback "Kembali ke Daftar {label}"). Tombol hardcoded = DUPLIKAT yang
   menutupi BackTrail (bug). Argumen aksi `pageHeader` HANYA untuk aksi
   nyata (mis. tombol Cetak/print). Pengecualian: tombol back di dalam
   `emptyState`/not-found (object tak ditemukan) BOLEH tetap ada sebagai
   CTA — itu konteks berbeda, bukan duplikat view utama.
5. **Nav**: daftarkan href di `Acl::allowedHrefs` role yang berhak +
   `menuBlueprint` (yang tak berhak tidak melihat menu; guard tetap 403).
6. **Design**: nol HTML/CSS di Page — komponen UiBuilder + token; ikut
   3 tema.
7. **Seed dummy** berlabel supaya halaman langsung hidup utk demo.
8. QC: lint + login 2 role (berhak vs tidak) + sort/export + link objek.

## 6b. RESEP: WORKFLOW BARU (kontrak dgn owner — "saya sebutkan workflow
## baru, anda tahu apa yang diharapkan")

Bila owner menyebut workflow baru (mis. "workflow pengadaan seragam"):

1. **Template journey** didaftarkan di `TaskJourneys` (urutan tahapan
   ber-label) — satu-satunya definisi.
2. Otomatis didapat tanpa kode tambahan: task `kind=staged` ber-journey
   itu, **stepper** di detail task, log tahapan append-only (siapa/kapan/
   catatan), **opsi tampilan KANBAN** (kolom = tahapan, pindah kartu =
   maju tahap tervalidasi server), status seragam
   (Menunggu/On Progress/Done/Not Done/Cancel), tampil di papan task
   terpadu ber-badge sumber, reminder/eskalasi via TaskBoardService.
3. **Kaitan KPI**: tautkan `kpi_definition_id` bila workflow diukur —
   done vs not-done masuk statistik indikator.
4. Definisi DONE ditanyakan sekali di awal ("apa syarat selesai?" — mis.
   penawaran = kontrak ttd/ditolak) lalu dikunci di tahapan terakhir.
5. Bila workflow menempel object (mis. pengadaan → item), kartu/tahapan
   memakai `objectLink` + `source_ref`.
6. **Titik lahir nyata — WAJIB, gerbang terpisah dari "mekanisme jalan"**
   (temuan owner 10 Jul 2026, ditemukan saat kerja di JK-IMS): mendaftarkan
   template di `TaskJourneys` + membuktikan 1 task manual bisa dimajukan
   sampai Done **TIDAK CUKUP**. Workflow yang terdaftar tanpa ada satu pun
   titik kode nyata yang memanggil `createTask()` dengan `journey_template`
   itu (di luar pengujian manual) = **kode mati** — kelas bug yang sama
   dengan halaman ber-kelas `Page` tanpa entrypoint `public/*.php`. Setiap
   template WAJIB py salah satu: (a) form/aksi nyata di halaman object
   terkait, (b) pemicu otomatis dari Service saat kejadian tertentu, atau
   (c) — hanya utk template ad-hoc generik — form "Buat task" yang BENAR
   ADA di papan task terpadu. Verifikasi akhir: `grep -rn
   "journey_template.*'{key}'" src/` HARUS menunjukkan lokasi di luar
   `TaskJourneys.php` sendiri. Detail & contoh: lihat skill
   `workflow-creator` §3+§5.

## 6. Definisi selesai (QC)

1. Registry berisi minimal: asset, location, work_order, employee (2 mode),
   partner, kol, part, vendor, product, booking, quotation, competency,
   course, task.
2. `objectLink` dipakai di: LocationDetail (aset→asset.php ✔ diseragamkan),
   WO detail (aset, lokasi, teknisi→profil publik), tasks/task (PIC),
   partner (booking, quotation), kanban card (klien/PIC) — sapu bertahap.
3. Profil publik karyawan: user role `employee` bisa buka profil rekan
   (nama/jabatan/foto) tapi TIDAK bisa buka employee.php orang lain (403).
4. Autocomplete: endpoint menolak `type` tak terdaftar; uji injeksi
   (`' OR 1=1 --`, `%`, `_`) aman; filter listing utama memakai komponen.
5. Link tak berwenang dirender `<span>` (cek HTML role employee).
6. **Setiap `journey_template` terdaftar di `TaskJourneys` py titik lahir
   nyata** (§6b poin 6) — audit berkala: `grep` tiap key template, pastikan
   muncul di ≥1 file selain `TaskJourneys.php` sendiri.
