# AI PROJECT MEMORY — SALFANET RADIUS

> **Untuk AI/LLM yang melanjutkan pengembangan project ini.**
> Baca file ini terlebih dahulu sebelum mulai membantu agar tidak mengulang hal yang sudah selesai atau membuat kesalahan yang sudah diketahui.

---

## 📌 Project Overview

**Salfanet Radius** adalah sistem billing ISP/RTRW.NET berbasis web dengan integrasi FreeRADIUS penuh. Mendukung PPPoE dan Hotspot, cocok untuk ISP kecil-menengah di Indonesia.

- **Version**: 2.21.0
- **Status**: Production-ready, deployed di VPS
- **Last Updated**: April 22, 2026
- **Latest Commit**: `62b0c88` — fix: PATCH WG updates wg0.conf Address and info.subnet when gatewayIp changes
- **GitHub**: https://github.com/s4lfanet/salfanet-radius (public)
- **Live URL**: https://radius.hotspotapp.net

### Recent Patch Log (April 22, 2026 — VPS Built-in VPN Pool IP Config)

- **Feat: Panel "Konfigurasi VPS Built-in VPN" di VPN Client page** (`1903085`, Apr 22, 2026)
  - Panel collapsible baru untuk mengatur pool IP & gateway WireGuard dan L2TP yang berjalan langsung di VPS.
  - Terpisah dari VPN Server page (yang khusus MikroTik CHR).
  - **Files**: `src/app/admin/network/vpn-client/page.tsx`

- **Feat: PATCH endpoint vps-wg-peer** (`1903085`, Apr 22, 2026)
  - `PATCH /api/network/vps-wg-peer` — update `poolStart`, `poolEnd`, `gatewayIp` di `wg-server-info.json`.
  - Saat gatewayIp disimpan: juga update baris `Address =` di `wg0.conf`, update `info.subnet`, reload via `wg syncconf`.
  - **Files**: `src/app/api/network/vps-wg-peer/route.ts`

- **Feat: PATCH endpoint vps-l2tp-peer** (`1903085`, Apr 22, 2026)
  - `PATCH /api/network/vps-l2tp-peer` — update `poolStart`, `poolEnd`, `gateway` di `l2tp-server-info.json`.
  - **Files**: `src/app/api/network/vps-l2tp-peer/route.ts`

- **Fix: `loadWgServerInfo` data mapping** (`17d83da`, Apr 22, 2026)
  - **Root cause**: membaca `data.info?.publicIp` dll. padahal API mengembalikan fields di top level (`data.publicIp`).
  - **Fix**: mapping diubah ke `data.X` langsung.
  - **Files**: `src/app/admin/network/vpn-client/page.tsx`

- **Fix: pool config menerima full IP address** (`17d83da`, Apr 22, 2026)
  - Input poolStart/poolEnd sebelumnya hanya angka terakhir (mis. `2`). Sekarang full IP (mis. `172.16.212.2`).
  - Validasi backend: full IPv4 regex, bukan range integer 2–254.
  - **Files**: `vps-wg-peer/route.ts`, `vps-l2tp-peer/route.ts`, `vpn-client/page.tsx`

- **Fix: `nextAvailableIp` / `getNextAvailableIp` selalu gunakan prefix `info.subnet`** (`8636800`, Apr 22, 2026)
  - **Root cause**: selalu `base = info.subnet.prefix` → alokasi IP selalu di subnet WG interface default.
  - **Fix**: jika `poolStart` adalah full IP string, gunakan prefixnya sebagai base. Scan "used IPs" dibatasi ke prefix yang sama.
  - WG ADD response juga diperbaiki: `vpnSubnet` dan `gatewayIp` dihitung dari pool prefix, bukan `info.subnet`.
  - **Files**: `src/app/api/network/vps-wg-peer/route.ts`, `src/app/api/network/vps-l2tp-peer/route.ts`

- **Fix: display subnet footer pakai `info.subnet` bukan pool subnet** (`6a8bd04`, Apr 22, 2026)
  - Footer kini tampilkan "Pool subnet: xxx.xxx.xxx.0/24" diturunkan dari prefix poolStart.
  - Edit button prefill diperbaiki: gunakan prefix dari poolStart yang tersimpan (bukan selalu `info.subnet`).
  - **Files**: `src/app/admin/network/vpn-client/page.tsx`

- **Fix: Remove redirect paksa di VPN Client jika tidak ada CHR** (`1903085`, Apr 22, 2026)
  - Sebelumnya: jika `vpnServers.length === 0`, halaman redirect ke VPN Server setup → user tidak bisa akses VPS built-in VPN.
  - Sekarang: redirect dihapus, halaman selalu tampil normal.
  - **Files**: `src/app/admin/network/vpn-client/page.tsx`

### Key Architecture Notes — VPN

- **VPN Server page** (`/admin/network/vpn-server`): HANYA untuk MikroTik CHR. Tidak ada VPS built-in config di sini.
- **VPN Client page** (`/admin/network/vpn-client`): Untuk NAS/router yang connect ke VPS. Panel "Konfigurasi VPS Built-in VPN" ada di sini.
- **`__vps_wg__`** dan **`__vps_l2tp__`**: Virtual server IDs untuk WireGuard dan L2TP peer yang dikelola langsung di VPS.
- **`wg-server-info.json`** (`/etc/wireguard/wg-server-info.json`): Fields: `publicIp`, `publicKey`, `listenPort`, `subnet`, `poolStart`, `poolEnd`, `gatewayIp`.
- **`l2tp-server-info.json`** (`/etc/salfanet/l2tp/l2tp-server-info.json`): Fields: `publicIp`, `ipsecPsk`, `subnet`, `localIp`, `poolStart`, `poolEnd`, `gateway`.
- **Pool base rule**: saat `poolStart` adalah full IP (mis. `172.16.212.2`), base prefix = `172.16.212`. Selalu gunakan pool prefix, bukan `info.subnet`, saat alokasi IP baru.

- **Fix: `getUserMedia` error langsung fallback ke native camera** (`382dbb3`, Apr 11, 2026)
  - **Root cause**: saat `getUserMedia` melempar error apapun (`NotAllowedError`, Permissions Policy, dll), kode lama menampilkan pesan error merah alih-alih membuka native camera.
  - **Fix**: `catch` block pada `startCamera()` (`CameraPhotoInput`) dan `startStream()` (`CameraViewfinder`) sekarang langsung fallback ke `captureRef.current?.click()` / `setUseNativeCapture(true)`. State `cameraError` + render block error merah dihapus sepenuhnya.
  - **Files**: `src/components/CameraPhotoInput.tsx`, `src/components/CameraViewfinder.tsx`

- **Feat: Kompresi foto otomatis + perbaikan tampilan kamera** (`8ff86c1`, Apr 11, 2026)
  - **Util baru**: `compressImage(file, maxDimension=1280, quality=0.78)` di `src/lib/utils.ts` — resize + JPEG 78%. Estimasi: foto 5MB HP → 200–400KB di DB.
  - **Berlaku di**: `handleFile`, `handleCaptureFile`, `takePhoto` di `CameraPhotoInput` + `takePhoto`, `handleNativeFile` di `CameraViewfinder`.
  - **Tampilan viewfinder**: `h-48` (fixed) → `aspect-[4/3]` (proporsional). Corner guide overlay (4 sudut biru cyan).
  - **Tampilan preview**: border hijau, badge "✓ Foto tersimpan", action bar Galeri|Kamera di bagian bawah.
  - **Canvas resize**: `takePhoto` di kedua komponen sekarang scale down ke max 1280px sebelum `toBlob(..., 0.78)`.
  - **Files**: `src/lib/utils.ts`, `src/components/CameraPhotoInput.tsx`, `src/components/CameraViewfinder.tsx`

- **Feat: Tab "📷 Foto" di UserDetailModal** (`817887a`, Apr 11, 2026)
  - Tab baru "📷 Foto" di sebelah kanan Invoice — tampilkan KTP + foto instalasi read-only.
  - **KTP**: foto full-width, NIK di pojok kanan, placeholder jika kosong.
  - **Foto Instalasi**: grid 2 kolom, label "Foto 1/2/…", placeholder jika kosong.
  - **Lightbox**: klik foto → full screen overlay; klik luar / tombol × untuk tutup. State `lightboxUrl: string | null`.
  - **Note**: tambah/hapus foto tetap di tab "Info Pengguna".
  - **Files**: `src/components/UserDetailModal.tsx`

### Recent Patch Log (April 11, 2026 — Camera Hardening v1 / getUserMedia Rewrite)

- **CRITICAL FIX: `camera=()` Permissions-Policy memblokir semua akses kamera** (`84434ec`, Apr 11, 2026)
  - **Root cause**: `next.config.ts` menyetel `Permissions-Policy: camera=()` — melarang semua akses kamera di semua origin.
  - **Fix**: Ubah ke `camera=(self)` agar `getUserMedia` dapat berjalan di origin sendiri.
  - **File**: `next.config.ts`

- **Feat: HTTP fallback `capture="environment"` saat `getUserMedia` tidak tersedia** (`3643438`, Apr 11, 2026)
  - `CameraPhotoInput`: tambah check `!navigator.mediaDevices?.getUserMedia` → `captureRef.current?.click()`.
  - `CameraViewfinder`: tambah check serupa → `setUseNativeCapture(true)`.

- **Feat: Rewrite kamera menggunakan `getUserMedia`** (`39f3dcb`, Apr 11, 2026)
  - `CameraPhotoInput.tsx` — full rewrite: live video viewfinder via `getUserMedia`, tombol ambil foto + flip kamera.
  - `CameraViewfinder.tsx` — komponen baru untuk foto instalasi inline di admin & UserDetailModal.
  - `admin/pppoe/users/page.tsx` + `UserDetailModal.tsx` — ganti inline camera dengan `CameraViewfinder`.

### Recent Patch Log (April 11, 2026 — Camera Hardening v2 / Permissions-Policy)

- **Feat: `CameraPhotoInput` reusable component** (Apr 10, 2026)
  - **File baru**: `src/components/CameraPhotoInput.tsx`
  - Dua tombol: [🖼 Galeri] buka gallery biasa, [📷 Kamera HP] pakai `capture="environment"` → langsung buka kamera belakang di mobile
  - Setelah upload sukses: auto `navigator.geolocation.getCurrentPosition` → tampilkan badge 📍 lat,lng clickable ke Google Maps
  - Props: `photoUrl`, `onRemove`, `onUploadFile (async → string|null)`, `uploading`, `onGpsCapture?`, `theme ('dark'|'light')`, `hint`, `previewClassName`
  - Theme `dark`: cyberpunk (untuk `/daftar`); theme `light`: admin/modal biasa

- **Updated: 4 halaman/komponen** (Apr 10, 2026)
  - `src/app/daftar/page.tsx` — KTP photo → CameraPhotoInput (dark); GPS mengisi `formData.latitude/longitude`
  - `src/app/admin/pppoe/users/page.tsx` — KTP → CameraPhotoInput; foto instalasi → [Galeri][Kamera HP] inline + capture GPS ke lat/lng
  - `src/app/technician/(portal)/register/page.tsx` — KTP → CameraPhotoInput; removed `ktpInputRef` + `handleUploadKtp`
  - `src/components/UserDetailModal.tsx` — KTP → CameraPhotoInput; foto instalasi → [Galeri][Kamera HP] + GPS ke lat/lng

- **CRITICAL FIX: Push subscription tidak tersimpan ke DB** (`57f6169`, April 10, 2026)
  - **Root cause**: `fetch('/api/push/technician-subscribe', ...)` tidak menyertakan `credentials: 'same-origin'` sehingga cookie `technician-token` tidak dikirim ke server. Tanpa cookie, admin_user tidak terdeteksi → API cari ID di tabel `technician` → 404 → subscription tidak tersimpan.
  - **Fix**: Tambah `credentials: 'same-origin'` ke 3 fetch calls di `SidebarPushToggle`: silent sync, subscribe toggle, unsubscribe toggle.
  - **Files**: `src/app/technician/TechnicianPortalLayout.tsx`

- **CRITICAL FIX: `admin_user` push subscription diabaikan** (`7df3a8f`, April 10, 2026)
  - **Root cause**: `POST /api/push/technician-subscribe` mengembalikan `{skipped:true}` untuk `admin_user` tanpa menyimpan ke DB.
  - **Fix**: Tambah model `adminPushSubscription` di schema Prisma → tabel `admin_push_subscriptions`. Route kini menyimpan subscription admin ke tabel ini.
  - **New functions di push-notification.service.ts**: `upsertAdminPushSubscription()`, `removeAdminPushSubscription()`, `getAdminSubscriptions()`
  - **Files**: `prisma/schema.prisma`, `src/server/services/push-notification.service.ts`, `src/app/api/push/technician-subscribe/route.ts`, `src/app/api/push/technician-unsubscribe/route.ts`

- **Fix: Dashboard teknisi pakai model ticket** (`1602b7e`, `ed3619b`, April 10, 2026)
  - Dashboard menggunakan `work_orders` yang sudah dihapus. Dimigrasi ke model `ticket`.
  - **Files**: `src/app/technician/dashboard/page.tsx`

- **Feat: GitHub Actions auto-deploy** (`e195e4f`, April 2026)
  - Workflow `.github/workflows/deploy.yml` → auto SSH ke VPS, jalankan `bash scripts/update.sh`, saat push ke `master`.

- **Feat: Toggle push notif sidebar teknisi** (`d0a97ec`, April 2026)
  - `SidebarPushToggle` komponen di `TechnicianPortalLayout.tsx` — selalu tampil, state ON/OFF jelas.
  - Mendukung silent sync: saat portal dibuka, jika browser punya push sub aktif, langsung sync ke DB.

- **Feat: Dispatch tiket ke semua teknisi via WA + push** (`1eb9358`, April 2026)
  - Saat tiket baru dibuat/di-assign, broadcast WA + push notification ke semua teknisi aktif.

- **Fix: update.sh auto-rebuild standalone** (`8ee6c03`, April 2026)
  - Jika `.next/standalone/server.js` hilang, build dipaksa otomatis.
  - API check mengembalikan `needsBuild: true`, UI tampilkan tombol "Rebuild Now".

### Push Notification Architecture (Web Push / VAPID)

> **PENTING**: Sistem push ini menggunakan **Web Push API** (VAPID), **BUKAN** Firebase Cloud Messaging (FCM).
> `fcmTokens` field di `pppoeUser` adalah untuk mobile app Flutter terpisah — tidak terkait dengan sistem push ini.

- **VAPID Keys**: Di `.env`: `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY`, `VAPID_CONTACT_EMAIL`
- **Service Worker**: `/public/sw.js` — event `push` → `showNotification()`, `notificationclick` → buka URL PWA
- **Scope**: SW didaftarkan dengan `scope: '/'` via `navigator.serviceWorker.register('/sw.js', { scope: '/' })`

#### Tabel Push Subscriptions (4 tabel):

| Tabel | Portal | Route Registrasi |
|-------|--------|-----------------|
| `push_subscriptions` | Customer | `POST /api/push/subscribe` (Bearer token dari localStorage) |
| `technician_push_subscriptions` | Teknisi | `POST /api/push/technician-subscribe` (cookie `technician-token`, type `technician`) |
| `admin_push_subscriptions` | Admin via portal teknisi | `POST /api/push/technician-subscribe` (cookie `technician-token`, type `admin_user`) |
| `agent_push_subscriptions` | Agent | `POST /api/push/agent-subscribe` |

#### Cara Kerja Subscribe (Teknisi/Admin):
1. SidebarPushToggle memanggil `fetch('/api/push/technician-subscribe', { credentials: 'same-origin', ... })`
2. Cookie `technician-token` dikirim otomatis → API baca JWT
3. Jika `payload.type === 'admin_user'` → simpan ke `adminPushSubscription`
4. Jika `payload.type === 'technician'` → simpan ke `technicianPushSubscription`
5. **WAJIB `credentials: 'same-origin'`** — tanpa ini, cookie tidak terkirim → subscription gagal

#### Silent Sync:
- Saat portal dibuka, jika browser masih punya push subscription aktif, langsung re-register ke DB (mengatasi DB restore atau tabel dikosongkan).
- Customer: `usePushNotification.ts` → `refresh()` → `fetch('/api/push/subscribe', { Authorization: Bearer ... })`
- Teknisi/Admin: `TechnicianPortalLayout.tsx` → `SidebarPushToggle.refresh()` → `fetch('/api/push/technician-subscribe', { credentials: 'same-origin', ... })`



- **Redesign UI: Modern Clean Blue/Indigo theme** (`6ec9783`, April 5, 2026)
  - Seluruh halaman login (admin, technician, customer, agent/`agent/page.tsx`) didesain ulang dari cyberpunk/neon ke tampilan modern bersih.
  - `globals.css`: CSS variables diperbarui — dark mode navy bg + blue primary; light mode blue-600 primary; dark mode neon remap dihapus.
  - `CyberButton.tsx`: semua warna neon (cyan/pink/yellow/green) → blue/indigo/emerald palette.
  - **Files**: `globals.css`, `admin/login/page.tsx`, `technician/login/page.tsx`, `customer/login/page.tsx`, `agent/page.tsx`, `CyberButton.tsx`

- **Fix: VPN Client — VPS IP field hanya manual** (`910cddd`, `5049e02`, April 5, 2026)
  - Auto-fill VPS IP sekarang skip domain name (Cloudflare-proxied): regex check `/^\d+\.\d+\.\d+\.\d+$/`.
  - VPS IP di halaman VPN Client menjadi input manual penuh — tidak lagi menarik domain dari API `/api/network/vps-info`.
  - **Files**: `src/app/api/network/vps-info/route.ts`, `src/app/admin/network/vpn-client/page.tsx`

- **Fix: `scripts/update.sh` — hardening static copy** (`7c85dd3`, April 5, 2026)
  - Sebelumnya `cp -r .next/static .next/standalone/.next/static 2>/dev/null || true` — kegagalan copy diabaikan.
  - Sekarang: `mkdir -p .next/standalone/.next/static && cp -r .next/static/. .next/standalone/.next/static/ || err "..."` — abort jika gagal, tidak ada nesting bug.
  - **Why it matters**: jika static copy gagal lalu pm2 reload jalan, app bisa serve halaman tanpa CSS/JS.

### Recent Patch Log (April 2026 — WhatsApp Wablas, Kirimi.id & Broadcast)

- **Fix: Wablas send gagal — endpoint salah** (`e8bdf6b`, April 5, 2026)
  - **Root cause**: `sendViaWablas()` menggunakan `POST /api/v2/send-message` (JSON body) dengan header `Authorization`. Endpoint v2 tidak tersedia di semua server Wablas.
  - **Fix**: Ganti ke `GET /api/send-message?token=TOKEN.SECRET_KEY&phone=...&message=...&flag=instant` sesuai docs Wablas `#send-text`.
  - Format API key di DB: `token.secret_key` (titik sebagai separator) — diisi langsung di query param `token`.
  - **Files**: `whatsapp.service.ts`, `providers/[id]/test/route.ts`, `providers/page.tsx`

- **Fix + Feat: Kirimi.id provider sepenuhnya berfungsi** (`11bc666`, `b7e0544`, April 5, 2026)
  - **Root cause**: Endpoint salah `/send-message` → harusnya `/v1/send-message`. Field penerima salah `number` → harusnya `receiver`. Kedua bug didapat dari screenshot docs resmi Kirimi.id v2.0.
  - **Fix**: Endpoint `/v1/send-message`, field `receiver`, strip trailing slash di `provider.apiUrl`.
  - **Files**: `whatsapp.service.ts`, `providers/[id]/test/route.ts`

- **Feat: Kirimi.id native broadcast** (`fa136f1`, `f4b3d4c`, April 5, 2026)
  - `sendBroadcast()` ditambahkan ke `WhatsAppService` — untuk Kirimi.id pakai `/v1/broadcast-message`, provider lain loop satu-per-satu.
  - 1 penerima otomatis fallback ke `/v1/send-message` (Kirimi.id tidak menerima broadcast 1 nomor).
  - Pesan dengan konten identik dikelompokkan dalam 1 API call. Delay 30 detik (rekomendasi resmi).
  - **Files**: `whatsapp.service.ts`, `broadcast/route.ts`

- **Fix: Per-provider error detail & HTTP status** (`b7e0544`, April 5, 2026)
  - `sendMessage()` tidak lagi throw saat semua provider gagal — return `{success:false, error, attempts}` agar detail per provider bisa sampai ke UI.
  - HTTP status catch block diubah 502 → 500.
  - Toast di halaman Send sekarang menampilkan error spesifik per provider.
  - **Files**: `whatsapp.service.ts`, `send/route.ts`

- **Fix: Broadcast response format** (`f4b3d4c`, April 5, 2026)
  - Route `POST /api/whatsapp/broadcast` sekarang return `successCount` dan `failCount` di top-level.
  - Sebelumnya frontend toast menampilkan `✅ undefined | ❌ undefined`.

- **Feat: Webhook endpoint pesan masuk** (`d2ff368`, `48a213d`, April 4, 2026)
  - Buat `src/app/api/whatsapp/webhook/route.ts` untuk terima pesan masuk dari Kirimi.id, Wablas, Fonnte, WAHA.
  - Pesan dicatat ke `whatsapp_history` dengan `status: incoming`.
  - Panel webhook URL + tombol copy di halaman providers.

### WhatsApp Kirimi.id — Setup & Known Facts

- **Base URL**: `https://api.kirimi.id`
- **API Key format**: `user_code:secret` (pisah dengan titik dua)
- **Sender Number field** = **Device ID** (format `D-XXXXX`)
- **Send endpoint**: `POST https://api.kirimi.id/v1/send-message`
  - Body: `{user_code, secret, device_id, receiver, message}`
- **Broadcast endpoint**: `POST https://api.kirimi.id/v1/broadcast-message`
  - Body: `{user_code, secret, device_id, label, numbers: [...], message, delay: 30}`
  - Minimal 2 nomor. 1 nomor → pakai send endpoint.
- **Status "menunggu"** di dashboard = normal, pesan dalam antrian.

### Recent Patch Log (April 2026 — Isolasi, CoA, PPPoE)

- **Fix: Isolasi manual PPPoE — radusergroup dioverwrite saat edit user** (`958fc3a`, April 2, 2026)
  - **Root cause**: `updatePppoeUser` di `pppoe.service.ts` selalu menjalankan RADIUS re-sync saat ada edit data user (username/password/profile/ip/router), dan **selalu** menulis ulang `radusergroup = profile.groupName` tanpa memeriksa status user. Jika user diisolir lalu admin membuka form edit dan save (tanpa mengubah status), `radusergroup` dikembalikan ke profile aslinya (`paket 5mbps` dll), sehingga user lolos isolir.
  - **Fix** (`pppoe.service.ts`): RADIUS re-sync sekarang memeriksa `effectiveStatus` (status baru jika diubah, atau status saat ini). Perilaku per status:
    - `isolated` → tulis `Cleartext-Password` + `radusergroup = 'isolir'`, tanpa `Framed-IP-Address`
    - `blocked` / `stop` → tabel RADIUS sudah dikosongkan sebelumnya — jangan re-insert apapun
    - `active` → sync penuh (password + profile group + static IP)
  - **Fix** (`coa-handler.service.ts`): tambahkan `-d /usr/share/freeradius` ke perintah `radclient disconnect` (sama seperti fix `coa.service.ts` sebelumnya) agar MikroTik vendor dictionary dimuat dan Disconnect-Request valid.

- **Fix: CoA / Disconnect ke MikroTik selalu gagal — route VPN hilang** (April 2, 2026, VPS config)
  - **Root cause**: `/etc/ppp/ip-up.d/99-vpn-routes` punya bug variabel: `VPN_SUBNET=$10.20.30.0/24` → bash membaca `$10` (positional arg ke-10, selalu kosong) + `.20.30.0/24`. Route `10.20.30.0/24` via ppp0 tidak pernah ditambahkan otomatis → VPS tidak bisa reach `10.20.30.12` (MikroTik) → semua CoA/disconnect gagal.
  - **Fix**: Script ditulis ulang dengan `VPN_SUBNET=10.20.30.0/24` sebagai variabel terpisah, dan digunakan sebagai `${VPN_SUBNET}` di semua perintah. Script tersimpan di `production/99-vpn-routes` untuk referensi fresh install.
  - **Note**: Route aktual di VPS ditambahkan manual via `ip route add 10.20.30.0/24 via 10.20.30.1 dev ppp0 metric 100`. Script ip-up menangani otomatisasi di reconnect.

- **Fix: setup-isolir menggunakan hardcoded IP pool dan rate limit** (`cb91699`)
  - `setup-isolir/route.ts` sebelumnya hardcode `pool-isolir` range `10.255.255.2-10.255.255.254`, rate `64k/64k`, gateway `10.255.255.1`.
  - Fix: baca `isolationIpPool` + `isolationRateLimit` dari DB company, gunakan `getCidrRange()` untuk hitung range dan gateway.

- **Fix: 9739 duplicate rows di radgroupreply** (`cb91699`)
  - `freeradius-health.ts` menggunakan `INSERT IGNORE` untuk `Mikrotik-Group` dan `Framed-Pool` pada tabel `radgroupreply`. Karena tidak ada UNIQUE constraint, setiap health check menambah baris baru → 9739 duplikat terakumulasi.
  - Fix: ganti ke pola `DELETE + INSERT` untuk semua 3 atribut isolir (`Mikrotik-Rate-Limit`, `Mikrotik-Group`, `Framed-Pool`).
  - DB production dibersihkan, sekarang 4 baris bersih.

- **Fix: CoA "Bad Requests=133, Acks=0" — MikroTik vendor dict tidak dimuat** (`b2fe4fa`)
  - `radclient` di `coa.service.ts` tidak punya flag `-d /usr/share/freeradius` → `Mikrotik-Rate-Limit` dikirim tanpa vendor ID → MikroTik reject semua request sebagai "Bad Request".
  - Fix: tambahkan `-d /usr/share/freeradius` ke `executeRadclient()` di `coa.service.ts`.
  - Verified: CoA-ACK diterima dari MikroTik setelah fix.

- **Fix: footerAgent tidak tersimpan ke database** (`2adef92`)
  - `footerAgent` ada di CREATE query tapi tidak di UPDATE query di `/api/company/route.ts`.

- **Fix: Footer login agent hardcode fallback** (`f70967f`)
  - `agent/page.tsx` punya fallback `"Powered by ${poweredBy}"` yang dihardcode — dihapus.

---

### Recent Patch Log (March 2026)

- **Fix: billingDay reset to 1 on edit + MikroTik local-address verification** (`53688ee`, `28183d6`, March 28, 2026)
  - **Root cause**: `UserDetailModal.tsx` (the ACTUAL edit modal) had `user.subscriptionType || 'PREPAID'` — wrong default. POSTPAID users showed PREPAID view, hiding billing day field entirely, always resetting it to 1.
  - **Fix 1 (UserDetailModal.tsx)**: `subscriptionType: user.subscriptionType ?? 'POSTPAID'` and `billingDay: user.billingDay ?? new Date(user.expiredAt).getDate()` (infer from expiredAt when billingDay is null).
  - **Fix 2 (users/page.tsx handleEdit)**: same `??` nullish coalescing fixes (fallback to SimpleModal add form, minor but fixed).
  - **Fix 3 (pppoe.service.ts createPppoeUser)**: clamp billingDay to 1-28 (matches DB CHECK constraint; was 1-31).
  - **Enhancement (sync-mikrotik/route.ts)**: after syncing local-address to RouterOS PPP profile, now reads back the profile to verify the value was stored. Shows actionable warning if RouterOS didn't persist it — RouterOS requires the IP to be configured as an interface address first.
  - **Key architecture note**: `UserDetailModal.tsx` is the REAL edit dialog (`isOpen={isDialogOpen && !!editingUser}`). `SimpleModal` with `isOpen={isDialogOpen && !editingUser}` is the ADD form only — completely separate.

- **Fix: NAS IP, billingDay/expiredAt, Area badge & form — PPPoE UI revamp** (`1a6d30e`, `a33d8d0`, `f3fd754`)
  - **Network column** di tabel PPPoE: label "IP:" diganti "IP NAS:", nilai diubah dari `user.ipAddress` (IP statis user) menjadi `user.router?.ipAddress ?? user.router?.nasname` (IP rekap router NAS dari DB). IP statis user tetap di kolom PPPoE.
  - **updatePppoeUser service**: saat edit user POSTPAID dengan billingDay berubah, `expiredAt` kini di-recalculate ke tanggal tagihan bulan depan (`billingDay`). Sebelumnya `expiredAt` di-overwrite langsung dari nilai form tanpa memperhitungkan logika billingDay POSTPAID. Untuk PREPAID: `expiredAt` tetap ikut nilai form.
  - **Kolom Data Pelanggan**: badge Area (kuning, ikon MapPin) ditampilkan di bawah info pelanggan. Sebelumnya area tidak ditampilkan sama sekali di tabel.
  - **Form Tambah Pelanggan** (`SimpleModal`): tambah select Area (opsional) setelah NAS select. State `formData.areaId` sudah ada tapi tidak ada elemen UI-nya.
  - **Action buttons** (Phase 15): 5 ikon bersih — Eye, Pencil, RefreshCw, Shield, Trash. API `POST /api/pppoe/users/[userId]/sync-radius` dibuat untuk sync RADIUS per-user. Badge customerId & jumlah langganan bisa diklik sebagai filter.
  - **PPN calculation** (Phase 16): formula `ppnAmount = round(base * ppn/100)` diterapkan konsisten di 9 file billing. Koordinat GPS bisa diklik ke Google Maps.

- **Fix: Ghost sessions filtered from display and RADIUS authorize** (`4e89616`)
  - `sessions/route.ts`: tambah `.filter()` sebelum `.map()` — skip session yang tidak ada di `pppoeUser` maupun `hotspotVoucher`.
  - `authorize/route.ts`: pengguna tidak terdaftar sekarang dikembalikan REJECT (`control:Auth-Type = Reject`) bukan `{}` (allow).

- **Fix: Dashboard hotspot count cross-ref ke hotspotVoucher** (`57db2e6`)
  - `dashboard/stats/route.ts`: counter `activeSessionsHotspot` hanya naik jika username ada di tabel `hotspotVoucher`.
  - Tambah `Promise.all` lookup `hotspotVoucherSet` bersamaan dengan `pppoeByUsername`.
  - Sesi yang tidak terdaftar di table manapun (ghost) sepenuhnya diabaikan dari hitungan.

- **Fix: Next.js prerender crash pada `/_global-error`** (`bc3086c`)
  - Buat `src/app/global-error.tsx` sebagai `'use client'` component dengan `<html>/<body>` tags.
  - Tanpa file ini, Next.js 16 auto-generate `/_global-error` yang crash saat prerender dengan `TypeError: Cannot read properties of null (reading 'useContext')`.

- **Fix: Customer WiFi page padding** (`027749e`)
  - Semua `CyberCard` di `src/app/customer/wifi/page.tsx` kini punya `p-4 sm:p-5` eksplisit.
  - Container wrapper menggunakan `p-4 sm:p-5 lg:p-6 space-y-4 sm:space-y-5`.

- **Chore: Cleanup npm scripts + cross-platform deploy wrapper** (`9101c90`, `d646116`)
  - Restore `scripts/scan-api-endpoints.js` dan `scripts/test-all-apis.js` yang hilang.
  - Fix path deploy dari `bash smart-deploy.sh` → `bash production/smart-deploy.sh`.
  - Buat `scripts/run-deploy.js` — wrapper cross-platform; di Windows menampilkan panduan WSL/Git Bash; di Linux/macOS meneruskan ke `production/smart-deploy.sh`.
  - Tambah `npm run clean:local` dan `clean:all` untuk membersihkan `.next`, tsbuildinfo, dll.
  - Tidy `.gitignore`: hapus duplikat, hapus entry `/.git/`, rapikan per section.

- **Enhancement: Manual agent deposit with transfer proof + target admin account**
  - Agent manual top-up sekarang memilih rekening tujuan admin dari `company.bankAccounts` (API: `/api/company/info`).
  - Modal top-up agent menambahkan input transfer manual lengkap: rekening tujuan, nama/nomor rekening pengirim, catatan, dan upload bukti transfer.
  - Bukti transfer diproses via endpoint existing `/api/upload/payment-proof` lalu disimpan pada request manual.
  - Admin page verifikasi deposit agent (`/admin/hotspot/agent/deposits`) kini menampilkan rekening tujuan transfer, data pengirim, catatan, dan link bukti transfer.
  - API yang diperbarui:
    - `POST /api/agent/deposit/manual-request`
    - `GET/PATCH /api/admin/agent-deposits`
    - `GET /api/company/info` (tambahan `bankAccounts`)
  - Schema update `agentDeposit`:
    - `targetBankName`, `targetBankAccountNumber`, `targetBankAccountName`
    - `senderAccountName`, `senderAccountNumber`
    - `receiptImage`, `note`
  - Migration: `prisma/migrations/20260318120000_add_agent_manual_deposit_fields/migration.sql`

- **Fix: MapPicker z-index behind form modal**
  - `MapPicker` membuat `fixed` overlay tanpa `createPortal`, sehingga ancestor layout (sidebar, transform) membentuk stacking context yang menjebak z-index-nya di bawah `SimpleModal` portal.
  - Fix: tambah `createPortal(jsx, document.body)` pada return `MapPicker` agar render di root level (sama seperti `SimpleModal`).
  - File: `src/components/MapPicker.tsx`
  - Mempengaruhi: tambah/edit pelanggan PPPoE (`/admin/pppoe/users`), fiber joint closures, ODCs, dan semua halaman yang menggunakan `MapPicker` di atas form modal.

- **Refactor: Hapus bahasa Inggris — full Bahasa Indonesia only**
  - Hapus `src/locales/en.json` dan `src/components/LanguageSwitcher.tsx`.
  - Simplifikasi `useTranslation` hook: hardcode locale `'id'`, hapus English fallback, hapus import `en.json`.
  - Hapus tombol language toggle dari: Admin layout, Agent login, Agent layout, Customer layout, Technician layout.
  - Hapus import `Globe` yang tidak terpakai lagi di beberapa layout.
  - `store.ts`: locale type disederhanakan menjadi `'id'` only.
  - Hook `useTranslation` tetap mengembalikan `{ t, locale, setLocale, isID, isEN }` untuk kompatibilitas 131 file caller (setLocale jadi no-op, isEN selalu false).

- **System Update hardening (admin `/admin/system`)**
  - Fix spawn stdio issue (`fd: null`) by using `openSync` for log fd.
  - Resolve standalone `process.cwd()` mismatch with `getAppDir()` for system info/update routes.
  - Sanitize spawn environment to avoid PM2/Next inherited vars breaking `next build`.
  - Stabilize SSE live log stream with heartbeat + anti-buffering headers + auto reconnect.
  - Update script now uses zero-downtime `pm2 reload salfanet-radius` (cron tetap restart).

- **Fix: Nginx manifest 404 (final fix)** (`bca095f`, March 29, 2026)
  - **Root cause 1**: nginx `alias` directive with regex location + `try_files` is fundamentally broken — nginx cannot resolve try_files paths correctly with alias in regex locations.
  - **Root cause 2**: `cp -r public .next/standalone/public/` creates nested `public/public/` when target dir already exists (Next.js creates `.next/standalone/public/` during build).
  - **Fix**: All nginx manifest/sw.js/pwa blocks now use `root /var/www/salfanet-radius/public;` (matching production VPS). No `alias`, no `try_files`, no `@nextjs` named locations.
  - **Fix**: `cp -r public .next/standalone/public/` → `cp -r public/. .next/standalone/public/` (copy contents, not directory) in install-pm2.sh and fix-pwa-nginx.sh.
  - Files changed: `vps-install/install-nginx.sh`, `vps-install/install-pm2.sh`, `vps-install/fix-pwa-nginx.sh`, `production/nginx-salfanet-radius.conf`
  - Verified: `curl -I http://192.168.54.200/manifest-admin.json` → 200 OK

- **Fix: Earlier nginx manifest attempt (superseded)** (`914d8c4`, `940f194`)
  - First attempt used `alias` + `try_files` — broken in nginx with regex locations.
  - `fix-pwa-nginx.sh` created but had same bugs. Fully rewritten in `bca095f`.

- **UI spacing polish (admin cards)**
  - Push Notifications page: explicit `CardHeader`/`CardContent` paddings, refined icon-title gaps.
  - Additional consistency fixes applied on Manual Payments and Network Trace pages.

- **Fix: Hotspot profile modal i18n key** (`f8e5702`)
  - Key `hotspot.eVoucherAccess` (capital V) → `hotspot.evoucherAccess` (lowercase) di `src/app/admin/hotspot/profile/page.tsx`.
  - Locale files sudah benar (`evoucherAccess`) — hanya pemanggil di page.tsx yang salah casing.

- **Fix: Dashboard SESI HOTSPOT AKTIF selalu 0** (`667b158`)
  - Hapus pengecekan RADIUS attrs (`service.includes('framed')`) yang menyebabkan hotspot MikroTik (mengirim `Service-Type = Framed-User`) salah diklasifikasi sebagai PPPoE.
  - Ganti ke logika sederhana: lookup `pppoeUser` → PPPoE, selainnya → Hotspot (sama seperti halaman Sesi).
  - Tambah Redis `online:users` sebagai supplement agar sesi yang belum masuk `radacct` tetap terhitung.
  - File: `src/app/api/dashboard/stats/route.ts`

---

## 🖥️ Production VPS

| Item | Value |
|------|-------|
| IP | `YOUR_VPS_IP` |
| OS | Ubuntu 22.04.1 LTS |
| App Path | `/var/www/salfanet-radius` |
| Domain | `https://radius.yourdomain.com` (Cloudflare proxy) |
| Node.js | 20.20.1 |
| MySQL | 8.0.45 |
| PM2 | 6.0.14 |
| FreeRADIUS | 3.0.26 |

**PM2 Apps:**
- `salfanet-radius` — Next.js app (cluster mode, port 3000)
- `salfanet-cron` — Cron service (fork mode)

**Database:**
- DB Name: `salfanet_radius`
- User: `salfanet_user` / Password: `YOUR_DB_PASSWORD`
- Root password: `YOUR_ROOT_PASSWORD`

**Default Login:**
- URL: `https://radius.yourdomain.com/login`
- Username: `superadmin`
- Password: `admin123`

---

## 🛠️ Tech Stack

| Layer | Technology |
|-------|-----------|
| Framework | Next.js 16 (App Router + Turbopack) |
| Language | TypeScript |
| Styling | Tailwind CSS + shadcn/ui + Radix UI |
| Database | MySQL 8.0 + Prisma ORM v6 |
| Auth | next-auth v4, bcryptjs, JWT |
| RADIUS | FreeRADIUS 3.0.26 (MySQL + REST) |
| Jobs | node-cron (via `cron-service.js`) |
| Icons | Lucide React |
| Maps | Leaflet / OpenStreetMap |
| Charts | Recharts |
| Payments | Midtrans, Xendit, Duitku, Tripay |
| Integrations | MikroTik RouterOS API, GenieACS TR-069, Firebase Admin, Nodemailer, WhatsApp |
| Timezone | WIB / Asia/Jakarta (UTC+7) |

---

## 🏗️ Architecture

```
src/
├── app/
│   ├── admin/          # Admin panel (5 role templates)
│   ├── agent/          # Agent/reseller portal
│   ├── customer/       # Customer self-service portal
│   ├── technician/     # Technician portal (route group: (portal)/)
│   ├── coordinator/    # Coordinator portal
│   └── api/            # Thin API route handlers
├── server/             # Server-only code
│   ├── db/             # Prisma client
│   ├── services/       # Business logic
│   ├── jobs/           # Cron job functions
│   ├── cache/          # Redis utilities
│   ├── auth/           # next-auth config
│   └── middleware/     # Request middleware
├── features/           # Vertical slices (queries, schemas, types per domain)
├── components/         # Shared UI components only
├── lib/                # Pure utilities + re-export proxies (migration artifacts)
├── hooks/              # Custom React hooks
├── locales/            # i18n translations (id, en)
└── types/              # Shared TypeScript types
```

**Important rule:** `src/app/api/` handlers must be thin — validate → call service → respond. No business logic directly in route handlers.

---

## 🔑 Key Rules & Known Issues

### 1. `cron-service.js` (root)
Standalone Node.js process launched by PM2. Calls HTTP API endpoints at `localhost:3000`. **DO NOT change this to import server code directly.** PM2 entrypoint.

### 2. Technician portal route structure
Route group pattern: `src/app/technician/(portal)/[page]/page.tsx`  
There was a duplicate `technician/dashboard/page.tsx` (outside group) — **already deleted**.  
Always use `(portal)/` group for all technician pages.

### 3. FreeRADIUS mods-enabled = standalone files, NOT symlinks
On this VPS, `mods-enabled/sql` and `sites-enabled/default` are **standalone files**, not symlinks.  
The install script copies files directly. Do not assume symlink behavior.

### 4. Prisma migrations vs db push
Fresh VPS install uses `prisma db push --accept-data-loss` (not `prisma migrate deploy`), because migrations assume pre-existing tables (`nas`).

### 5. Environment variable `TZ=Asia/Jakarta`
**Critical** — set in `ecosystem.config.js` and `.env`. Without this, all cron jobs and date calculations will be wrong.

### 6. `src/lib/` = re-export proxies
Old location. New canonical code is in `src/server/services/` and `src/server/jobs/`. `src/lib/` files just re-export for backward compatibility.

### 7. Both `upload/` and `uploads/` API routes exist
`src/app/api/upload/` and `src/app/api/uploads/` — both present for backward compat.

### 8. Windows development note
Scripts in `vps-install/*.sh` have UTF-8 BOM when created on Windows. Run `sed -i 's/^\xef\xbb\xbf//' script.sh` to strip BOM before executing on VPS.

### 9. VPS install must run from app directory
`install-freeradius.sh` and other VPS scripts call `check_directory()` which requires CWD to contain "salfanet-radius". Always run from `/var/www/salfanet-radius`.

### 10. UFW was previously configured but not auto-enabled
Installer lama hanya menambahkan rule `ufw allow`, tetapi tidak menjalankan `ufw enable`. Current installer fix:
- auto-detect SSH port aktif (`22` atau custom seperti `2020`)
- allow SSH + `80/tcp` + `443/tcp`
- set `default deny incoming`, `default allow outgoing`
- run `ufw --force enable`
- skip only for Proxmox LXC (`SKIP_UFW=true`)

### 11. Web app inaccessible on Proxmox VM usually means NAT / public edge issue, not app issue
If inside guest `ss -tulpn` shows Node.js on `:3000` and Nginx on `:80`/`:443`, but public IP still fails:
- `:2020` is SSH/custom admin port, not web app URL
- problem is usually DNAT / router / Proxmox firewall / provider security group
- guest-side `ufw inactive` is not the same as host-side NAT missing
- installer now includes external access diagnostics in `install-nginx.sh`

### 12. Redis installer needed production hardening
Redis failure pattern seen in production: `redis-server.service` crash-loop after install. Current installer fix ensures:
- `bind 127.0.0.1 ::1`
- `protected-mode yes`
- `supervised systemd`
- `daemonize no`
- runtime/log/data directories exist with `redis:redis`
- restart failure prints `systemctl status`, `journalctl`, and Redis log tail

### 13. VPN route ke MikroTik WAJIB ada untuk CoA/disconnect berfungsi
VPS terhubung ke MikroTik via L2TP/PPP VPN di interface `ppp0`. VPS IP: `10.20.30.10`, MikroTik: `10.20.30.12`.
Route `10.20.30.0/24` HARUS ada di routing table VPS agar CoA packet bisa reach MikroTik.  
Script `/etc/ppp/ip-up.d/99-vpn-routes` menambahkan route otomatis saat ppp0 connect.  
Jika CoA selalu gagal (No Route / timeout), cek: `ip route show | grep 10.20.30`  
Fix manual: `ip route add 10.20.30.0/24 via 10.20.30.1 dev ppp0 metric 100`

### 14. `radusergroup` WAJIB ditulis `isolir` saat user diisolir, bukan profile group asli
`updatePppoeUser` di `pppoe.service.ts` punya logika `effectiveStatus` yang menentukan isi radusergroup:
- `isolated` → groupname = `'isolir'`, no Framed-IP-Address
- `blocked`/`stop` → jangan insert apapun ke RADIUS tables
- `active` → groupname = profile.groupName, restore Framed-IP-Address

Jika ada bug isolir (user tetap dapat profil normal setelah isolasi), cek apakah edit user via form admin tidak sengaja meng-override radusergroup.

### 15. Payment callback pages must support both `token` and `order_id`
Real-world issue: top-up success page received `order_id=TOPUP-TEMP-...` and showed `payment.paymentNotFound`.
Current fix:
- top-up direct flow now creates invoice first, then uses stable `orderId = invoice.invoiceNumber`
- added `GET /api/payment/check-order` to resolve invoice/deposit status from `order_id`
- `payment/success`, `payment/pending`, `payment/failed` now handle `order_id` fallback
- added alias pages: `/payment/failure` and `/payment/cancel`
- webhook now marks invoice `CANCELLED` for `expire|cancel|deny|failed`

---

## 🚀 Completed Features (by version)

| Version | Feature |
|---------|---------|
| v2.10.27 | Restructuring complete (5 phases), technician portal (11 pages + 19 API routes) |
| v2.10.x | Customers page → table layout redesign |
| v2.9.x | L2TP VPN client control, MikroTik CHR support |
| v2.8.0 | Balance/deposit system, auto-renewal prepaid, RADIUS restore on renewal |
| v2.7.6 | GenieACS TR-069 device management, WiFi configuration from portal |
| v2.7.2 | Broadcast notifications, template pages mobile responsive, maintenance resolved template |
| v2.7.0 | Manual payment upload + approval workflow, multiple bank accounts, customer ID |
| v2.6.x | PPPoE isolation system, isolation templates (WhatsApp/Email/HTML) |
| v2.5.x | Agent/reseller system, voucher commission tracking |
| v2.4.x | CoA service (real-time disconnect), auto-disconnect cronjob |
| v2.3.1 | Multi-timezone support, WIB/WITA/WIT |
| v2.2.x | FTTH network (OLT/ODC/ODP), network map, GPS coordinates |
| v2.1.x | Full RADIUS mode (radacct sessions, no MikroTik API) |
| v2.0.x | FreeRADIUS integration, SQL + REST modules |

---

## 📦 Database Schema Summary

**~45 Prisma models.** Key models:

| Model | Purpose |
|-------|---------|
| `User` | Admin users + roles + permissions |
| `Customer` | ISP customers (PPPoE/Hotspot) |
| `Voucher` | Hotspot vouchers |
| `Invoice` | Billing invoices |
| `Transaction` | Financial transactions |
| `Router` / `Nas` | MikroTik routers |
| `Agent` | Reseller/agent accounts |
| `Olt` / `Odc` / `Odp` | FTTH network topology |
| `radcheck` / `radreply` | FreeRADIUS auth tables |
| `radacct` | RADIUS accounting/sessions |
| `radpostauth` | RADIUS auth logs |
| `CronJobExecution` | Cron history |
| `ActivityLog` | Admin activity audit |
| `Setting` | App settings (key-value) |
| `NotificationTemplate` | WhatsApp/Email templates |

---

## 🔧 Common Commands

```bash
# Development
npm run dev              # Start Next.js dev server (Turbopack)
npm run build            # Production build
npx tsc --noEmit         # TypeScript check (must = 0 errors)
npm run lint             # ESLint
npm run test:run         # Vitest (must pass)
npm run test:api         # Smoke test public API endpoints
npm run test:scan        # Scan & document all API endpoints → API_ENDPOINTS.md
npm run clean:local      # Remove .next, tsconfig.tsbuildinfo, coverage, .turbo, .cache
npm run clean:all        # clean:local + remove console.log (cleanup)
npm run deploy           # Run production/smart-deploy.sh (bash required)
npm run deploy:quick     # Quick deploy (build + PM2 restart)
npm run deploy:full      # Full deploy (install deps + build + restart)
npm run deploy:status    # Check deploy status
npm run deploy:rollback  # Rollback last deploy

# Database
npx prisma db push       # Sync schema to DB (fresh install)
npx prisma migrate dev   # Create migration (development)
npx prisma studio        # DB browser
npm run db:seed          # Run all seeds (tsx prisma/seeds/seed-all.ts)

# PM2 (on VPS)
pm2 status               # Check all apps
pm2 logs salfanet-radius # App logs
pm2 restart salfanet-radius --update-env
pm2 restart salfanet-cron

# FreeRADIUS (on VPS)
systemctl status freeradius
freeradius -X            # Debug mode (verbose)
freeradius -CX           # Config test only
radtest user pass localhost 0 testing123  # Test auth

# VPS Install scripts (run from /var/www/salfanet-radius)
bash /tmp/vps-install/install-freeradius.sh
bash /tmp/vps-install/install-nodejs.sh
```

---

## 📡 FreeRADIUS Architecture

```
MikroTik (NAS) → FreeRADIUS → MySQL (radcheck/radreply/radgroupreply)
                            ↓
                   REST API (/api/radius/*)
                            ↓
                   - /api/radius/authorize  → check user status
                   - /api/radius/post-auth  → set firstLoginAt, expiresAt
                   - /api/radius/accounting → update Redis online-users
```

**Config files location:** `/etc/freeradius/3.0/`
- `mods-enabled/sql` — MySQL connection (standalone file)
- `mods-enabled/rest` — REST API integration (symlink → mods-available/rest)
- `sites-enabled/default` — Auth logic (standalone file)
- `sites-available/coa` — CoA/Disconnect (symlink)
- `clients.conf` — NAS clients + `$INCLUDE clients.d/`
- `clients.d/nas-from-db.conf` — Auto-generated NAS from DB

**Project backup:** `freeradius-config/` directory in repo root.

---

## 🌐 Portals Summary

| Portal | URL | Users |
|--------|-----|-------|
| Admin | `/admin` | SUPER_ADMIN, FINANCE, CS, TECHNICIAN, MARKETING, VIEWER |
| Customer | `/customer` | ISP customers |
| Agent | `/agent` | Resellers/agents |
| Technician | `/technician` | Field technicians |
| Coordinator | `/coordinator` | Area coordinators |

---

## 🌍 Translations / i18n

Files in `src/locales/`:
- `id.json` — Indonesian (satu-satunya bahasa)

Bahasa Inggris (`en.json`) sudah dihapus. Semua UI menggunakan Bahasa Indonesia.
Hook `useTranslation()` tetap digunakan di 131+ file, hanya saja locale hardcoded ke `'id'`.
Language switcher sudah dihapus dari semua portal (Admin, Agent, Customer, Technician).

---

## 📁 Important Files

| File | Purpose |
|------|---------|
| `ecosystem.config.js` → `production/ecosystem.config.js` | PM2 config (deployed to `/var/www/salfanet-radius/`) |
| `cron-service.js` | Cron PM2 entrypoint (root) |
| `prisma/schema.prisma` | Database schema |
| `prisma/seeds/seed-all.ts` | Run all seeds |
| `src/instrumentation.ts` | Next.js instrumentation hook |
| `vps-install/` | VPS installer scripts |
| `freeradius-config/` | FreeRADIUS config backup |
| `production/nginx-salfanet-radius.conf` | Nginx config template |

---

## 🔐 Security Notes

- `.env` is gitignored — never commit real credentials
- `.env.example` and `.env.production.example` are safe templates
- Firebase service account files are gitignored (`*firebase-service-account*.json`)
- RADIUS `testing123` secret is for local testing only — change in production `clients.conf`
- `require_message_authenticator = no` set for localhost client (compatibility)

---

## 📝 Recent Changes (March 2026)

- ✅ Customers page redesigned to table layout (from card grid)
- ✅ Deleted duplicate `src/app/technician/dashboard/page.tsx`
- ✅ FreeRADIUS installed and running on VPS
- ✅ GitHub repo made public
- ✅ Removed: `chk-pg.js`, `kill-ports.ps1`, `start-dev.ps1` (debug/dev-only files)
- ✅ VPS fully deployed: Node.js 20, MySQL 8.0.45, Redis, Nginx, PM2, FreeRADIUS 3.0.26
- ✅ DB seeded: superadmin, templates, 19 ticket categories, email templates, isolation templates
- ✅ **Network/Fiber Management Translation Audit & Fixes:**
  - Expanded `network.tracing` from 3 keys to full 41-key set covering `PathTracerTool`, `TraceResultDisplay`, `ImpactAnalysisPanel` components
  - Expanded `network.jointClosure` from 1 key to full 34-key set for CRUD labels
  - Added complete Indonesian translations for both sections in `id.json`
  - All other network section keys also added: `network.diagram.*`, `network.unifiedMap.*`, `common.created`, `common.updated`
- ✅ **New Pages Created (fiber management routes):**
  - `/admin/network/fiber-joint-closures` — Full CRUD management for `network_joint_closures` model (uses `/api/network/joint-closures` API)
  - `/admin/network/fiber-odcs` — Redirect to `/admin/network/odcs`
  - `/admin/network/fiber-odps` — Redirect to `/admin/network/odps`

---

## � Recent Changes (April 2026)

### Customer Invoice Print Dialog (commit `32a01d9`, `2218fe6`)
- **New file**: `src/lib/invoice-print.ts` — shared print helper with `printInvoiceStandard(invoiceId, toast)` and `printInvoiceThermal(invoiceId, toast)`; both call `/api/invoices/${invoiceId}/pdf`
- **`src/app/customer/history/page.tsx`** — added print dialog via `SimpleModal`:
  - State: `const [printDialogPayment, setPrintDialogPayment] = useState<PaymentHistory | null>(null)`
  - Print button now opens modal instead of calling API directly
  - Dialog buttons: "Cetak Standard A4" (Standard A4) and "Cetak Thermal 58/80mm"
  - Imports added: `SimpleModal, ModalHeader, ModalTitle, ModalDescription, ModalBody, ModalFooter, ModalButton` from `@/components/cyberpunk`; `printInvoiceStandard, printInvoiceThermal` from `@/lib/invoice-print`
  - Full restore from `d875dcc` required (earlier patch had corrupted file — merged `handleSubmitOfflinePayment` body into `handlePrintThermal`, eating 13 state declarations and 7 handler functions)
- **`src/app/customer/invoices/page.tsx`** — same print dialog pattern

### Customer WiFi / GenieACS TR-069 Fixes (commit `ffd53d7`)
- **`src/app/api/customer/wifi/route.ts`** — 4 bugs fixed:
  1. **Missing SSIDs**: Only WLANs with non-empty SSID names were included — ONTs with blank SSID but active devices were silently excluded. Fix: `include if hasValidSsid || assocCount > 0`
  2. **Band detection**: Was using unreliable `index >= 5` heuristic. Fix: Channel > 14 is authoritative 5GHz indicator; also checks `'n5'`/`'5ghz'` standard strings
  3. **assocCount accuracy**: Was using TR-069 `TotalAssociations` field (unreliable). Fix: count actual `AssociatedDevice` child entries; use `Math.max(TotalAssociations, actualCount)`
  4. **`associatedDevice` field**: Was storing SSID name string (could duplicate across SSIDs). Fix: now stores `String(wlan.index)` — WLAN index — enables reliable per-SSID device grouping on frontend
- **`src/app/customer/wifi/page.tsx`** — removed standalone flat "Connected Devices" section at page bottom; each WLAN card now shows its own devices inline, filtered by `h.associatedDevice === String(wlan.index)`

### Customer Dashboard WiFi Multi-SSID (commit `073f372`)
- **`src/app/customer/page.tsx`** — WiFi section rewritten:
  - `editingWifi: boolean` → `editingWifi: number | null` (stores WLAN index being edited, null = not editing)
  - Was showing only `wlanConfigs?.[0]` with hardcoded `wlanIndex: 1`; now iterates all `wlanConfigs`
  - Each SSID renders its own card with SSID name + band badge (2.4GHz / 5GHz)
  - Each SSID has its own Edit button → sets `editingWifi = wlan.index`; form sends `wlanIndex: editingWifi ?? 1`
  - Connected devices grouped per SSID: `connectedDevices.filter(d => d.associatedDevice === String(wlan.index))`

---

## �🗺️ Network/Fiber Management Routes

| Route | Description |
|-------|-------------|
| `/admin/network/fiber-cables` | Fiber cable management (GPON/ADSS etc.) |
| `/admin/network/fiber-cores` | Fiber core management |
| `/admin/network/splice-points` | Splice point management |
| `/admin/network/fiber-joint-closures` | Joint Closure (JC) CRUD — new, uses `network_joint_closures` model |
| `/admin/network/fiber-odcs` | Redirect → `/admin/network/odcs` |
| `/admin/network/fiber-odps` | Redirect → `/admin/network/odps` |
| `/admin/network/odcs` | ODC management (Optical Distribution Cabinet) |
| `/admin/network/odps` | ODP management (Optical Distribution Point) |
| `/admin/network/olts` | OLT management |
| `/admin/network/diagrams` | Network splitter diagrams (links to fiber-joint-closures/odcs/odps) |
| `/admin/network/trace` | Network path tracing (logical + physical) |
| `/admin/network/unified-map` | Unified network map |
| `/admin/network/infrastruktur` | Infrastructure overview |
| `/admin/network/map` | Network map |

