chore: initial project import
Some checks failed
CI - Production Readiness / Verify (push) Has been cancelled
Some checks failed
CI - Production Readiness / Verify (push) Has been cancelled
This commit is contained in:
37
.env.example
Normal file
37
.env.example
Normal file
@ -0,0 +1,37 @@
|
||||
DATABASE_URL="file:./dev.db"
|
||||
AUTH_SECRET="change-me"
|
||||
WHATSAPP_API_TOKEN="your-meta-token"
|
||||
WHATSAPP_API_VERSION="v22.0"
|
||||
WHATSAPP_WEBHOOK_VERIFY_TOKEN="your-webhook-verify-token"
|
||||
WHATSAPP_WEBHOOK_SECRET="your-webhook-secret"
|
||||
WHATSAPP_ALLOW_SIMULATED_SEND="true"
|
||||
APP_URL="http://localhost:3000"
|
||||
CAMPAIGN_RETRY_JOB_TOKEN="change-me-for-production"
|
||||
CAMPAIGN_RETRY_BATCH_SIZE="100"
|
||||
CAMPAIGN_RETRY_MAX_CAMPAIGNS="20"
|
||||
CAMPAIGN_RETRY_JOB_LOCK_TTL_SECONDS="300"
|
||||
CAMPAIGN_RETRY_ALERT_WEBHOOK_URL=""
|
||||
CAMPAIGN_RETRY_ALERT_ON_FAILURE="true"
|
||||
HEALTHCHECK_TOKEN=""
|
||||
OPS_BASE_URL=""
|
||||
WEBHOOK_FAILURE_RATE_THRESHOLD_PER_HOUR="20"
|
||||
RETRY_WORKER_STALE_MINUTES="30"
|
||||
CAMPAIGN_RETRY_DAEMON_INTERVAL_SECONDS="300"
|
||||
CAMPAIGN_RETRY_DAEMON_TIMEOUT_MS="30000"
|
||||
LOGIN_RATE_LIMIT_ATTEMPTS="10"
|
||||
LOGIN_RATE_LIMIT_WINDOW_MS="900000"
|
||||
CAMPAIGN_RETRY_JOB_RATE_LIMIT_GET="60"
|
||||
CAMPAIGN_RETRY_JOB_RATE_LIMIT_POST="20"
|
||||
CAMPAIGN_RETRY_JOB_RATE_LIMIT_WINDOW_MS="60000"
|
||||
WHATSAPP_WEBHOOK_RATE_LIMIT_GET="60"
|
||||
WHATSAPP_WEBHOOK_RATE_LIMIT_POST="120"
|
||||
WHATSAPP_WEBHOOK_RATE_LIMIT_WINDOW_MS="60000"
|
||||
AUTH_TOKEN_CONSUMED_RETENTION_HOURS="24"
|
||||
CAMPAIGN_RETRY_STALE_LOCK_MINUTES="120"
|
||||
WEBHOOK_EVENT_RETENTION_DAYS="30"
|
||||
AUDIT_LOG_RETENTION_DAYS="365"
|
||||
|
||||
# Background job (campaign retry)
|
||||
CAMPAIGN_RETRY_JOB_URL="http://localhost:3000"
|
||||
CAMPAIGN_RETRY_TENANT_ID=""
|
||||
CAMPAIGN_RETRY_CAMPAIGN_ID=""
|
||||
3
.eslintrc.json
Normal file
3
.eslintrc.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": ["next/core-web-vitals", "next/typescript"]
|
||||
}
|
||||
73
.github/workflows/ci-production-readiness.yml
vendored
Normal file
73
.github/workflows/ci-production-readiness.yml
vendored
Normal file
@ -0,0 +1,73 @@
|
||||
name: CI - Production Readiness
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
name: Verify
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DATABASE_URL: "file:./.ci.sqlite"
|
||||
AUTH_SECRET: "whatsapp-inbox-ci-secret"
|
||||
NEXT_PUBLIC_APP_URL: "http://127.0.0.1:3000"
|
||||
APP_URL: "http://127.0.0.1:3000"
|
||||
OPS_BASE_URL: "http://127.0.0.1:3000"
|
||||
CAMPAIGN_RETRY_JOB_TOKEN: "ci-campaign-retry-token"
|
||||
HEALTHCHECK_TOKEN: "ci-health-token"
|
||||
WHATSAPP_WEBHOOK_VERIFY_TOKEN: "ci-verify-token"
|
||||
WHATSAPP_WEBHOOK_SECRET: "ci-webhook-secret"
|
||||
RETRY_WORKER_STALE_MINUTES: "15"
|
||||
WEBHOOK_FAILURE_RATE_THRESHOLD_PER_HOUR: "99"
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Prepare database
|
||||
run: npm run db:deploy
|
||||
|
||||
- name: Static verification
|
||||
run: npm run ci:verify
|
||||
|
||||
- name: Start app
|
||||
run: npm run start -- --hostname 127.0.0.1 --port 3000 > /tmp/ci-app.log 2>&1 &
|
||||
|
||||
- name: Run ops health checks
|
||||
run: |
|
||||
for i in $(seq 1 40); do
|
||||
if curl -fsS "http://127.0.0.1:3000/api/health?token=$HEALTHCHECK_TOKEN" >/dev/null; then
|
||||
break
|
||||
fi
|
||||
if [ "$i" -eq 40 ]; then
|
||||
echo "App not ready for health check"
|
||||
tail -n 120 /tmp/ci-app.log
|
||||
exit 1
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
APP_URL=http://127.0.0.1:3000 \
|
||||
NEXT_PUBLIC_APP_URL=http://127.0.0.1:3000 \
|
||||
OPS_BASE_URL=http://127.0.0.1:3000 \
|
||||
HEALTHCHECK_TOKEN=$HEALTHCHECK_TOKEN \
|
||||
CAMPAIGN_RETRY_JOB_TOKEN=$CAMPAIGN_RETRY_JOB_TOKEN \
|
||||
npm run ops:healthcheck
|
||||
|
||||
APP_URL=http://127.0.0.1:3000 \
|
||||
NEXT_PUBLIC_APP_URL=http://127.0.0.1:3000 \
|
||||
OPS_BASE_URL=http://127.0.0.1:3000 \
|
||||
HEALTHCHECK_TOKEN=$HEALTHCHECK_TOKEN \
|
||||
CAMPAIGN_RETRY_JOB_TOKEN=$CAMPAIGN_RETRY_JOB_TOKEN \
|
||||
npm run ops:readiness
|
||||
- name: Print app log on failure
|
||||
if: failure()
|
||||
run: tail -n 200 /tmp/ci-app.log
|
||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
.next
|
||||
node_modules
|
||||
dev.db
|
||||
dev.db-journal
|
||||
430
INSTALL-UBUNTU-APP-ZAPPCARE.md
Normal file
430
INSTALL-UBUNTU-APP-ZAPPCARE.md
Normal file
@ -0,0 +1,430 @@
|
||||
# Panduan Install WhatsApp Inbox di Ubuntu (Domain: app.zappcare.id)
|
||||
|
||||
Dokumen ini menyusun langkah deployment lengkap untuk server Ubuntu dengan kondisi:
|
||||
|
||||
- PostgreSQL sudah terinstall
|
||||
- Nginx sudah terinstall
|
||||
- Gitea berjalan di port `3001`
|
||||
- Aplikasi ini tidak boleh pakai port `3000` karena dipakai layanan lain
|
||||
|
||||
Pada panduan ini, aplikasi akan jalan di **port `3002`** di loopback (`127.0.0.1:3002`) dan di-serve via Nginx ke domain `app.zappcare.id`.
|
||||
|
||||
## 1) Prasyarat
|
||||
|
||||
Pastikan server sudah memenuhi:
|
||||
|
||||
- Ubuntu 22.04/24.04 (sesuaikan)
|
||||
- User dengan sudo
|
||||
- PostgreSQL aktif
|
||||
- Nginx aktif
|
||||
- Node.js 20.x + npm
|
||||
- Git
|
||||
- domain `app.zappcare.id` mengarah ke IP server (DNS A record)
|
||||
|
||||
## 2) Install runtime (jika belum)
|
||||
|
||||
```bash
|
||||
sudo apt update && sudo apt upgrade -y
|
||||
sudo apt install -y curl ca-certificates git nginx postgresql postgresql-contrib
|
||||
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
|
||||
sudo apt install -y nodejs
|
||||
|
||||
node -v
|
||||
npm -v
|
||||
```
|
||||
|
||||
## 3) Buat database PostgreSQL
|
||||
|
||||
Masuk psql:
|
||||
|
||||
```bash
|
||||
sudo -u postgres psql
|
||||
```
|
||||
|
||||
Jalankan:
|
||||
|
||||
```sql
|
||||
CREATE USER whatsapp_inbox WITH PASSWORD 'GANTI_PASSWORD_KUAT';
|
||||
CREATE DATABASE whatsapp_inbox OWNER whatsapp_inbox;
|
||||
\q
|
||||
```
|
||||
|
||||
## 4) Setup user deploy
|
||||
|
||||
```bash
|
||||
sudo useradd --system --home /var/www/whatsapp-inbox --shell /usr/sbin/nologin whatsapp-inbox || true
|
||||
sudo mkdir -p /var/www/whatsapp-inbox
|
||||
sudo chown -R whatsapp-inbox:whatsapp-inbox /var/www/whatsapp-inbox
|
||||
```
|
||||
|
||||
## 5) Clone source & install dependency
|
||||
|
||||
```bash
|
||||
sudo -u whatsapp-inbox git clone <REPO_URL> /var/www/whatsapp-inbox
|
||||
cd /var/www/whatsapp-inbox
|
||||
sudo -u whatsapp-inbox npm ci
|
||||
```
|
||||
|
||||
> Ganti `<REPO_URL>` dengan URL repo Anda.
|
||||
|
||||
## 6) Buat `.env` production
|
||||
|
||||
Buat file dari template:
|
||||
|
||||
```bash
|
||||
sudo cp /var/www/whatsapp-inbox/.env.example /var/www/whatsapp-inbox/.env
|
||||
sudo chown whatsapp-inbox:whatsapp-inbox /var/www/whatsapp-inbox/.env
|
||||
sudo -u whatsapp-inbox nano /var/www/whatsapp-inbox/.env
|
||||
```
|
||||
|
||||
Isi `.env` yang wajib:
|
||||
|
||||
```env
|
||||
NODE_ENV=production
|
||||
PORT=3002
|
||||
HOST=127.0.0.1
|
||||
|
||||
DATABASE_URL="postgresql://whatsapp_inbox:GANTI_PASSWORD_KUAT@127.0.0.1:5432/whatsapp_inbox?schema=public"
|
||||
AUTH_SECRET="ganti_secret_acak_minimal_32_karakter"
|
||||
|
||||
APP_URL="https://app.zappcare.id"
|
||||
NEXT_PUBLIC_APP_URL="https://app.zappcare.id"
|
||||
OPS_BASE_URL="https://app.zappcare.id"
|
||||
|
||||
CAMPAIGN_RETRY_JOB_URL="https://app.zappcare.id"
|
||||
CAMPAIGN_RETRY_JOB_TOKEN="ganti_token_acak"
|
||||
HEALTHCHECK_TOKEN="ganti_token_health"
|
||||
|
||||
WHATSAPP_WEBHOOK_VERIFY_TOKEN="token_verify_webhook_anda"
|
||||
WHATSAPP_WEBHOOK_SECRET="webhook_secret_anda"
|
||||
WHATSAPP_API_TOKEN="meta_whatsapp_api_token"
|
||||
WHATSAPP_API_VERSION="v22.0"
|
||||
WHATSAPP_ALLOW_SIMULATED_SEND="false"
|
||||
|
||||
CAMPAIGN_RETRY_BATCH_SIZE="100"
|
||||
CAMPAIGN_RETRY_MAX_CAMPAIGNS="20"
|
||||
CAMPAIGN_RETRY_JOB_LOCK_TTL_SECONDS="300"
|
||||
CAMPAIGN_RETRY_DAEMON_INTERVAL_SECONDS="300"
|
||||
CAMPAIGN_RETRY_DAEMON_TIMEOUT_MS="30000"
|
||||
CAMPAIGN_RETRY_ALERT_ON_FAILURE="true"
|
||||
CAMPAIGN_RETRY_ALERT_WEBHOOK_URL=""
|
||||
|
||||
WEBHOOK_FAILURE_RATE_THRESHOLD_PER_HOUR="20"
|
||||
RETRY_WORKER_STALE_MINUTES="30"
|
||||
LOGIN_RATE_LIMIT_ATTEMPTS="10"
|
||||
LOGIN_RATE_LIMIT_WINDOW_MS="900000"
|
||||
CAMPAIGN_RETRY_JOB_RATE_LIMIT_GET="60"
|
||||
CAMPAIGN_RETRY_JOB_RATE_LIMIT_POST="20"
|
||||
CAMPAIGN_RETRY_JOB_RATE_LIMIT_WINDOW_MS="60000"
|
||||
WHATSAPP_WEBHOOK_RATE_LIMIT_GET="60"
|
||||
WHATSAPP_WEBHOOK_RATE_LIMIT_POST="120"
|
||||
WHATSAPP_WEBHOOK_RATE_LIMIT_WINDOW_MS="60000"
|
||||
|
||||
AUTH_TOKEN_CONSUMED_RETENTION_HOURS="24"
|
||||
CAMPAIGN_RETRY_STALE_LOCK_MINUTES="120"
|
||||
WEBHOOK_EVENT_RETENTION_DAYS="30"
|
||||
AUDIT_LOG_RETENTION_DAYS="365"
|
||||
```
|
||||
|
||||
> Pastikan nilai `DATABASE_URL` sesuai username/password/password database Anda.
|
||||
|
||||
## 7) Pastikan Prisma pakai PostgreSQL
|
||||
|
||||
### Cek `schema.prisma`
|
||||
|
||||
Buka `prisma/schema.prisma` dan pastikan datasource:
|
||||
|
||||
```prisma
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
```
|
||||
|
||||
Jika masih `sqlite`, migrasi ke Postgres harus dibangun ulang.
|
||||
Catatan: perubahan provider dan migration bisa dilakukan di environment staging/dev sebelum deploy production.
|
||||
|
||||
### Jalankan migration + seed
|
||||
|
||||
```bash
|
||||
cd /var/www/whatsapp-inbox
|
||||
sudo -u whatsapp-inbox npm run db:deploy
|
||||
sudo -u whatsapp-inbox npm run db:seed
|
||||
```
|
||||
|
||||
## 8) Build & verifikasi
|
||||
|
||||
```bash
|
||||
cd /var/www/whatsapp-inbox
|
||||
sudo -u whatsapp-inbox npm run ci:verify
|
||||
```
|
||||
|
||||
Jika berhasil, lanjut ke service.
|
||||
|
||||
## 9) Test manual port 3002
|
||||
|
||||
```bash
|
||||
cd /var/www/whatsapp-inbox
|
||||
sudo -u whatsapp-inbox npm run start -- --hostname 127.0.0.1 --port 3002
|
||||
```
|
||||
|
||||
Di terminal lain:
|
||||
|
||||
```bash
|
||||
curl -I http://127.0.0.1:3002
|
||||
curl -s http://127.0.0.1:3002/api/health
|
||||
```
|
||||
|
||||
## 10) Buat service systemd (Next.js app)
|
||||
|
||||
Buat file `/etc/systemd/system/whatsapp-inbox.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=WhatsApp Inbox (Next.js App)
|
||||
After=network.target postgresql.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=whatsapp-inbox
|
||||
Group=whatsapp-inbox
|
||||
WorkingDirectory=/var/www/whatsapp-inbox
|
||||
EnvironmentFile=/var/www/whatsapp-inbox/.env
|
||||
ExecStart=/usr/bin/npm run start -- --hostname 127.0.0.1 --port 3002
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
LimitNOFILE=65535
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Enable dan start:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now whatsapp-inbox
|
||||
sudo systemctl status whatsapp-inbox
|
||||
```
|
||||
|
||||
## 11) Service retry worker (daemon)
|
||||
|
||||
Buat file `/etc/systemd/system/whatsapp-inbox-retry.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=WhatsApp Inbox Campaign Retry Daemon
|
||||
After=network.target whatsapp-inbox.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=whatsapp-inbox
|
||||
Group=whatsapp-inbox
|
||||
WorkingDirectory=/var/www/whatsapp-inbox
|
||||
EnvironmentFile=/var/www/whatsapp-inbox/.env
|
||||
ExecStart=/usr/bin/npm run job:campaign-retry:daemon
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now whatsapp-inbox-retry
|
||||
sudo systemctl status whatsapp-inbox-retry
|
||||
```
|
||||
|
||||
## 12) Konfigurasi Nginx reverse proxy
|
||||
|
||||
Buat file `/etc/nginx/sites-available/app.zappcare.id`:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name app.zappcare.id;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/html;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name app.zappcare.id;
|
||||
|
||||
client_max_body_size 20m;
|
||||
proxy_buffering off;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/app.zappcare.id/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/app.zappcare.id/privkey.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3002;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_read_timeout 120s;
|
||||
proxy_send_timeout 120s;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Aktifkan site dan reload:
|
||||
|
||||
```bash
|
||||
sudo ln -s /etc/nginx/sites-available/app.zappcare.id /etc/nginx/sites-enabled/
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
## 13) Install SSL (Let's Encrypt)
|
||||
|
||||
```bash
|
||||
sudo apt install -y certbot python3-certbot-nginx
|
||||
sudo certbot --nginx -d app.zappcare.id
|
||||
```
|
||||
|
||||
## 14) Cek akhir deployment
|
||||
|
||||
```bash
|
||||
curl -I https://app.zappcare.id
|
||||
curl -I https://app.zappcare.id/api/health
|
||||
curl -s https://app.zappcare.id/api/health | jq
|
||||
```
|
||||
|
||||
Ops check:
|
||||
|
||||
```bash
|
||||
APP_URL=https://app.zappcare.id NEXT_PUBLIC_APP_URL=https://app.zappcare.id OPS_BASE_URL=https://app.zappcare.id npm run ops:readiness
|
||||
APP_URL=https://app.zappcare.id NEXT_PUBLIC_APP_URL=https://app.zappcare.id OPS_BASE_URL=https://app.zappcare.id npm run ops:smoke
|
||||
```
|
||||
|
||||
## 15) Update rutin
|
||||
|
||||
```bash
|
||||
cd /var/www/whatsapp-inbox
|
||||
git pull origin main
|
||||
sudo -u whatsapp-inbox npm ci
|
||||
sudo -u whatsapp-inbox npm run ci:verify
|
||||
sudo -u whatsapp-inbox npm run db:deploy
|
||||
sudo systemctl restart whatsapp-inbox
|
||||
sudo systemctl restart whatsapp-inbox-retry
|
||||
```
|
||||
|
||||
## 16) Rollback cepat
|
||||
|
||||
```bash
|
||||
cd /var/www/whatsapp-inbox
|
||||
git log --oneline -n 10
|
||||
git checkout <commit-sebelumnya>
|
||||
sudo systemctl restart whatsapp-inbox
|
||||
```
|
||||
|
||||
## 17) Hal penting untuk environment produksi
|
||||
|
||||
- Pastikan semua token tidak lagi `change-me`/`your-*`.
|
||||
- Monitor `ops:readiness` + `ops:smoke` tiap hari / saat deploy.
|
||||
- Jalankan `ops:maintenance` berkala (cron harian/mingguan).
|
||||
- Pastikan job retry tetap hidup:
|
||||
- `sudo systemctl status whatsapp-inbox-retry`
|
||||
|
||||
```bash
|
||||
sudo systemctl status whatsapp-inbox
|
||||
sudo systemctl status whatsapp-inbox-retry
|
||||
journalctl -u whatsapp-inbox -f
|
||||
journalctl -u whatsapp-inbox-retry -f
|
||||
```
|
||||
|
||||
## 18) Troubleshooting cepat
|
||||
|
||||
### 18.1 Domain menjawab 502 Bad Gateway
|
||||
|
||||
```bash
|
||||
sudo systemctl status whatsapp-inbox
|
||||
sudo ss -ltnp | rg "127.0.0.1:3002"
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
- Jika service app tidak jalan, cek log:
|
||||
`journalctl -u whatsapp-inbox -n 200 --no-pager`
|
||||
- Pastikan Nginx proxy ke `127.0.0.1:3002` (bukan 3000/3001).
|
||||
- Jika app jalan, cek `.env` dan `PORT=3002`.
|
||||
|
||||
### 18.2 Halaman health 500 / tidak bisa start
|
||||
|
||||
```bash
|
||||
cd /var/www/whatsapp-inbox
|
||||
sudo -u whatsapp-inbox npm run ops:readiness
|
||||
```
|
||||
|
||||
- Jika `DATABASE_URL` error:
|
||||
- cek service PostgreSQL: `sudo systemctl status postgresql`
|
||||
- cek koneksi manual: `psql "postgresql://whatsapp_inbox:GANTI_PASSWORD_KUAT@127.0.0.1:5432/whatsapp_inbox?schema=public" -c '\dt'`
|
||||
- Jika token/secret bermasalah:
|
||||
- cek value `AUTH_SECRET`, `CAMPAIGN_RETRY_JOB_TOKEN`, `WHATSAPP_WEBHOOK_SECRET`
|
||||
- jangan ada placeholder seperti `change-me`, `your-*`
|
||||
|
||||
### 18.3 Retry job tidak berjalan
|
||||
|
||||
```bash
|
||||
sudo systemctl status whatsapp-inbox-retry
|
||||
sudo -u whatsapp-inbox npm run job:campaign-retry
|
||||
```
|
||||
|
||||
- Cek token pada `.env` (`CAMPAIGN_RETRY_JOB_TOKEN`) dan endpoint:
|
||||
- `https://app.zappcare.id/api/jobs/campaign-retry?token=<token>`
|
||||
- Jika lock stuck, jalankan:
|
||||
- `sudo -u whatsapp-inbox npm run job:campaign-retry`
|
||||
- restart service: `sudo systemctl restart whatsapp-inbox-retry`
|
||||
|
||||
### 18.4 Webhook tidak terima event
|
||||
|
||||
```bash
|
||||
curl -i https://app.zappcare.id/api/webhooks/whatsapp
|
||||
```
|
||||
|
||||
- Pastikan URL di Meta adalah:
|
||||
- `https://app.zappcare.id/api/webhooks/whatsapp`
|
||||
- Untuk validasi:
|
||||
- cek `WHATSAPP_WEBHOOK_VERIFY_TOKEN`
|
||||
- cek `WHATSAPP_WEBHOOK_SECRET`
|
||||
- cek `Signature` header dari provider sesuai konfigurasi
|
||||
|
||||
### 18.5 Port bentrok / service lain
|
||||
|
||||
```bash
|
||||
sudo lsof -i :3000
|
||||
sudo lsof -i :3001
|
||||
sudo lsof -i :3002
|
||||
```
|
||||
|
||||
- Jika app tidak boleh pakai 3000/3001, pastikan `.env` dan service tetap di 3002.
|
||||
- Jika ada proses yang tidak dikenal, stop service itu atau pindahkan port dengan service systemd yang benar.
|
||||
|
||||
### 18.6 Cek cepat setelah reboot atau deploy
|
||||
|
||||
```bash
|
||||
sudo systemctl restart whatsapp-inbox
|
||||
sudo systemctl restart whatsapp-inbox-retry
|
||||
sudo systemctl status whatsapp-inbox whatsapp-inbox-retry --no-pager
|
||||
curl -s https://app.zappcare.id/api/health | cat
|
||||
```
|
||||
|
||||
Jika masih ada masalah:
|
||||
|
||||
```bash
|
||||
APP_URL=https://app.zappcare.id NEXT_PUBLIC_APP_URL=https://app.zappcare.id OPS_BASE_URL=https://app.zappcare.id npm run ops:readiness
|
||||
APP_URL=https://app.zappcare.id NEXT_PUBLIC_APP_URL=https://app.zappcare.id OPS_BASE_URL=https://app.zappcare.id npm run ops:smoke
|
||||
```
|
||||
60
alert-policy.md
Normal file
60
alert-policy.md
Normal file
@ -0,0 +1,60 @@
|
||||
# Alert Policy - WhatsApp Inbox
|
||||
|
||||
## Severity Matrix
|
||||
|
||||
- **P0 (Critical)** – Sistem down untuk seluruh user.
|
||||
- Trigger:
|
||||
- `GET /api/health` status `down`.
|
||||
- DB unreachable.
|
||||
- Tidak bisa mengirim/menarik retry campaign selama >15 menit.
|
||||
- Response target:
|
||||
- Acknowledge: 5 menit
|
||||
- Mitigasi awal: 15 menit
|
||||
- Owner: Platform Lead
|
||||
|
||||
- **P1 (High)** – Fitur inti terganggu (campaign, webhook, retry).
|
||||
- Trigger:
|
||||
- Retry worker status `failed` >= 3 kali berturut.
|
||||
- `BackgroundJobState.consecutiveFailures` naik terus.
|
||||
- `campaign-retry-worker` tidak berjalan > 60 menit.
|
||||
- Failed webhook 1 jam melebihi threshold.
|
||||
- Response target:
|
||||
- Acknowledge: 15 menit
|
||||
- Mitigasi awal: 45 menit
|
||||
- Owner: Platform + Operations
|
||||
|
||||
- **P2 (Medium)** – Degradasi performa non-blocking.
|
||||
- Trigger:
|
||||
- `GET /api/health` `degraded`.
|
||||
- Channel disconnected > 1 dalam 1 tenant.
|
||||
- Response target:
|
||||
- Acknowledge: 60 menit
|
||||
- Mitigasi awal: 4 jam
|
||||
- Owner: Platform
|
||||
|
||||
- **P3 (Low)** – Informasi operasional.
|
||||
- Trigger:
|
||||
- Kenaikan event minor, warning non-urgent.
|
||||
- Response target:
|
||||
- Acknowledge: next business cycle
|
||||
|
||||
## Alert Routing
|
||||
|
||||
- Primary: Slack/Discord webhook (`CAMPAIGN_RETRY_ALERT_WEBHOOK_URL`) untuk event retry failure.
|
||||
- Secondary: Team channel / chat group.
|
||||
- Escalation (P0/P1): paging on-call.
|
||||
|
||||
## Tuning
|
||||
|
||||
- Set `CAMPAIGN_RETRY_ALERT_ON_FAILURE=false` jika volume alert terlalu tinggi dan gunakan manual monitoring.
|
||||
- Tune:
|
||||
- `WEBHOOK_FAILURE_RATE_THRESHOLD_PER_HOUR` (default 20)
|
||||
- `RETRY_WORKER_STALE_MINUTES` (default 30)
|
||||
|
||||
## Metrics reviewed in every shift
|
||||
|
||||
- `WebhookEvent` failure rate (1h)
|
||||
- `BackgroundJobState.consecutiveFailures`
|
||||
- `Channel.status` `DISCONNECTED`
|
||||
- Queue depth `CampaignRecipient` by `sendStatus`
|
||||
- Health endpoint status
|
||||
13
app/agent/contacts/[contactId]/page.tsx
Normal file
13
app/agent/contacts/[contactId]/page.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { SectionCard } from "@/components/ui";
|
||||
|
||||
export default function AgentContactDetailPage() {
|
||||
return (
|
||||
<ShellPage shell="agent" title="Contact Detail" description="Identity, tags, dan previous chats untuk agent.">
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<SectionCard title="Identity">Nama, nomor WhatsApp, tags.</SectionCard>
|
||||
<SectionCard title="Previous chats">Riwayat ringkas conversation.</SectionCard>
|
||||
</div>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
48
app/agent/contacts/page.tsx
Normal file
48
app/agent/contacts/page.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { TablePlaceholder } from "@/components/placeholders";
|
||||
|
||||
export default async function AgentContactsPage() {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
if (session.role !== "agent") {
|
||||
redirect("/unauthorized");
|
||||
}
|
||||
|
||||
const contacts = await prisma.contact.findMany({
|
||||
where: { tenantId: session.tenantId },
|
||||
include: {
|
||||
contactTags: {
|
||||
include: { tag: true }
|
||||
}
|
||||
},
|
||||
orderBy: { lastInteractionAt: "desc" }
|
||||
});
|
||||
|
||||
const rows = contacts.map((contact) => [
|
||||
contact.fullName,
|
||||
contact.phoneNumber,
|
||||
contact.lastInteractionAt ? new Intl.DateTimeFormat("id-ID", { hour: "2-digit", minute: "2-digit" }).format(contact.lastInteractionAt) : "-"
|
||||
,
|
||||
<Link key={contact.id} href={`/contacts/${contact.id}`} className="text-brand hover:underline">
|
||||
View
|
||||
</Link>
|
||||
]);
|
||||
|
||||
return (
|
||||
<ShellPage shell="agent" title="Contacts" description="View contact terbatas untuk kebutuhan handling conversation.">
|
||||
<TablePlaceholder
|
||||
title="Contacts"
|
||||
columns={["Name", "Phone", "Last interaction", "Action"]}
|
||||
rows={rows}
|
||||
/>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
25
app/agent/inbox/mentioned/page.tsx
Normal file
25
app/agent/inbox/mentioned/page.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { TablePlaceholder } from "@/components/placeholders";
|
||||
import { getAgentMentionedConversations } from "@/lib/inbox-ops";
|
||||
|
||||
export default async function AgentMentionedPage() {
|
||||
const rows = await getAgentMentionedConversations();
|
||||
|
||||
return (
|
||||
<ShellPage shell="agent" title="Mentioned Conversations" description="Conversation yang melibatkan agent secara khusus.">
|
||||
<TablePlaceholder
|
||||
title="Mentioned"
|
||||
columns={["Conversation", "Mentioned by", "Time"]}
|
||||
rows={rows.map((item) => [
|
||||
<Link key={`${item.id}-conv`} className="text-primary underline" href={`/agent/inbox?conversationId=${item.id}`}>
|
||||
{item.contactName}
|
||||
</Link>,
|
||||
item.mentionedBy,
|
||||
item.time
|
||||
])}
|
||||
/>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
48
app/agent/inbox/page.tsx
Normal file
48
app/agent/inbox/page.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import { InboxPlaceholder } from "@/components/placeholders";
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import {
|
||||
addConversationNote,
|
||||
assignConversation,
|
||||
getInboxWorkspace,
|
||||
replyToConversation,
|
||||
setConversationTags,
|
||||
updateConversationStatus
|
||||
} from "@/lib/inbox-ops";
|
||||
|
||||
const allowedFilters = ["all", "open", "pending", "resolved", "unassigned"] as const;
|
||||
|
||||
export default async function AgentInboxPage({
|
||||
searchParams
|
||||
}: {
|
||||
searchParams: Promise<{ conversationId?: string; filter?: string }>;
|
||||
}) {
|
||||
const params = await searchParams;
|
||||
const filter =
|
||||
params?.filter && allowedFilters.includes(params.filter as (typeof allowedFilters)[number])
|
||||
? (params.filter as (typeof allowedFilters)[number])
|
||||
: "all";
|
||||
|
||||
const data = await getInboxWorkspace({
|
||||
scope: "agent",
|
||||
conversationId: params?.conversationId,
|
||||
filter
|
||||
});
|
||||
|
||||
return (
|
||||
<ShellPage shell="agent" title="My Inbox" description="Assigned conversations, notes, tags, dan reply composer versi agent.">
|
||||
<InboxPlaceholder
|
||||
conversations={data.conversations}
|
||||
selectedConversation={data.selectedConversation}
|
||||
defaultPath={data.defaultPath}
|
||||
role={data.role}
|
||||
filter={data.filter}
|
||||
canSelfAssign={data.canSelfAssign}
|
||||
assignConversation={assignConversation}
|
||||
updateConversationStatus={updateConversationStatus}
|
||||
replyToConversation={replyToConversation}
|
||||
addConversationNote={addConversationNote}
|
||||
setConversationTags={setConversationTags}
|
||||
/>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
17
app/agent/inbox/resolved/page.tsx
Normal file
17
app/agent/inbox/resolved/page.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { TablePlaceholder } from "@/components/placeholders";
|
||||
import { getAgentResolvedHistory } from "@/lib/inbox-ops";
|
||||
|
||||
export default async function AgentResolvedPage() {
|
||||
const rows = await getAgentResolvedHistory();
|
||||
|
||||
return (
|
||||
<ShellPage shell="agent" title="Resolved History" description="Riwayat conversation yang sudah selesai ditangani.">
|
||||
<TablePlaceholder
|
||||
title="Resolved"
|
||||
columns={["Conversation", "Resolved at", "Last action"]}
|
||||
rows={rows.map((item) => [item.contactName, item.resolvedAt, item.lastAction])}
|
||||
/>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
31
app/agent/inbox/unassigned/page.tsx
Normal file
31
app/agent/inbox/unassigned/page.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { TablePlaceholder } from "@/components/placeholders";
|
||||
import { assignConversation, getInboxWorkspace } from "@/lib/inbox-ops";
|
||||
|
||||
export default async function AgentUnassignedPage() {
|
||||
const data = await getInboxWorkspace({ scope: "agent", filter: "unassigned" });
|
||||
|
||||
return (
|
||||
<ShellPage shell="agent" title="Unassigned Queue" description="Queue yang bisa diambil agent jika diizinkan.">
|
||||
<TablePlaceholder
|
||||
title="Queue"
|
||||
columns={["Contact", "Last message", "Waiting time", "Action"]}
|
||||
rows={data.conversations.map((item) => [
|
||||
<>
|
||||
<p>{item.name}</p>
|
||||
<p className="text-xs text-outline">{item.phone}</p>
|
||||
</>,
|
||||
item.snippet,
|
||||
item.time,
|
||||
<form key={item.id} action={assignConversation} className="inline">
|
||||
<input type="hidden" name="conversationId" value={item.id} />
|
||||
<input type="hidden" name="nextPath" value="/agent/inbox/unassigned" />
|
||||
<button className="rounded-full bg-surface-container-low px-3 py-2 text-xs" type="submit">
|
||||
Take assignment
|
||||
</button>
|
||||
</form>
|
||||
])}
|
||||
/>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
18
app/agent/page.tsx
Normal file
18
app/agent/page.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import { DashboardPlaceholder } from "@/components/placeholders";
|
||||
import { PlaceholderActions, ShellPage } from "@/components/page-templates";
|
||||
import { getDashboardData } from "@/lib/platform-data";
|
||||
|
||||
export default async function AgentDashboardPage() {
|
||||
const data = await getDashboardData();
|
||||
|
||||
return (
|
||||
<ShellPage
|
||||
shell="agent"
|
||||
title="Agent Dashboard"
|
||||
description="Assigned conversations, unread queue, due follow-up, dan personal stats."
|
||||
actions={<PlaceholderActions primaryHref="/agent/inbox" primaryLabel="Open my inbox" />}
|
||||
>
|
||||
<DashboardPlaceholder stats={data.stats} priorityQueue={data.priorityQueue} />
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
13
app/agent/performance/page.tsx
Normal file
13
app/agent/performance/page.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { DashboardPlaceholder } from "@/components/placeholders";
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { getDashboardData } from "@/lib/platform-data";
|
||||
|
||||
export default async function AgentPerformancePage() {
|
||||
const data = await getDashboardData();
|
||||
|
||||
return (
|
||||
<ShellPage shell="agent" title="My Performance" description="Response stats dan resolved chats milik agent.">
|
||||
<DashboardPlaceholder stats={data.stats} priorityQueue={data.priorityQueue} />
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
52
app/agent/quick-tools/page.tsx
Normal file
52
app/agent/quick-tools/page.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { TablePlaceholder } from "@/components/placeholders";
|
||||
|
||||
function truncate(value: string, limit: number) {
|
||||
return value.length <= limit ? value : `${value.slice(0, limit - 1)}…`;
|
||||
}
|
||||
|
||||
export default async function AgentQuickToolsPage() {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
if (session.role !== "agent") {
|
||||
redirect("/unauthorized");
|
||||
}
|
||||
|
||||
const tenantId = session.tenantId;
|
||||
const [templateCount, activeTemplates, followUpNotes] = await Promise.all([
|
||||
prisma.messageTemplate.count({ where: { tenantId } }),
|
||||
prisma.messageTemplate.count({ where: { tenantId, approvalStatus: "APPROVED" } }),
|
||||
prisma.conversationNote.count({ where: { tenantId, userId: session.userId } })
|
||||
]);
|
||||
|
||||
const totalMessages = await prisma.conversationActivity.count({ where: { tenantId, actorUserId: session.userId } });
|
||||
|
||||
return (
|
||||
<ShellPage shell="agent" title="Quick Tools" description="Canned responses, template picker, dan follow-up reminders.">
|
||||
<TablePlaceholder
|
||||
title="Quick tools"
|
||||
columns={["Tool", "Purpose", "Usage", "Action"]}
|
||||
rows={[
|
||||
[
|
||||
"Canned Responses",
|
||||
"Shortcuts berbasis template yang paling sering dipakai.",
|
||||
truncate(`Total ${totalMessages} log agent activity`, 80),
|
||||
<Link key="quick-canned" href="/templates" className="text-brand hover:underline">
|
||||
Open templates
|
||||
</Link>
|
||||
],
|
||||
["Template Picker", "Pilih template yang sudah disetujui.", `${activeTemplates}/${templateCount} approved`, <Link key="quick-picker" href="/templates" className="text-brand hover:underline">Open templates</Link>],
|
||||
["Follow-up Notes", "Catatan follow-up dari conversation sendiri.", String(followUpNotes), <Link key="quick-followup" href="/agent/inbox" className="text-brand hover:underline">Open inbox</Link>]
|
||||
]}
|
||||
/>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
160
app/api/health/route.ts
Normal file
160
app/api/health/route.ts
Normal file
@ -0,0 +1,160 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
type ComponentHealth = {
|
||||
status: "ok" | "degraded" | "down";
|
||||
message: string;
|
||||
meta?: unknown;
|
||||
};
|
||||
|
||||
function normalizePositiveNumber(value: string | undefined, fallback: number) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function maybeExposeDetails(req: NextRequest) {
|
||||
const expected = process.env.HEALTHCHECK_TOKEN?.trim();
|
||||
if (!expected) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const fromHeader = req.headers.get("authorization")?.trim() || req.headers.get("x-health-token")?.trim();
|
||||
const fromQuery = new URL(req.url).searchParams.get("token")?.trim();
|
||||
const token = fromHeader || fromQuery;
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return token === expected || token === `Bearer ${expected}`;
|
||||
}
|
||||
|
||||
function isUp(components: ComponentHealth[]) {
|
||||
return components.every((item) => item.status === "ok");
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const checks: Record<string, ComponentHealth> = {};
|
||||
|
||||
try {
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
checks.database = { status: "ok", message: "connected" };
|
||||
} catch (error) {
|
||||
checks.database = {
|
||||
status: "down",
|
||||
message: error instanceof Error ? error.message : "Database query failed"
|
||||
};
|
||||
}
|
||||
|
||||
let retries: ComponentHealth = { status: "ok", message: "campaign retry worker state unavailable" };
|
||||
let webhook: ComponentHealth = { status: "ok", message: "webhook events healthy" };
|
||||
if (checks.database.status === "ok") {
|
||||
const failureThreshold = normalizePositiveNumber(process.env.WEBHOOK_FAILURE_RATE_THRESHOLD_PER_HOUR, 10);
|
||||
const staleThresholdMinutes = normalizePositiveNumber(process.env.RETRY_WORKER_STALE_MINUTES, 30);
|
||||
|
||||
const [retryState, webhookFailureCount, disconnectedChannels] = await Promise.all([
|
||||
prisma.backgroundJobState.findUnique({
|
||||
where: { jobName: "campaign-retry-worker" },
|
||||
select: {
|
||||
lockedUntil: true,
|
||||
lastRunCompletedAt: true,
|
||||
lastRunStatus: true,
|
||||
lastError: true,
|
||||
consecutiveFailures: true
|
||||
}
|
||||
}),
|
||||
prisma.webhookEvent.count({
|
||||
where: {
|
||||
processStatus: "failed",
|
||||
createdAt: {
|
||||
gte: new Date(Date.now() - 60 * 60 * 1000)
|
||||
}
|
||||
}
|
||||
}),
|
||||
prisma.channel.count({ where: { status: "DISCONNECTED" } })
|
||||
]);
|
||||
|
||||
if (!retryState) {
|
||||
retries = {
|
||||
status: "degraded",
|
||||
message: "retry worker state not initialized"
|
||||
};
|
||||
} else {
|
||||
const staleSince = new Date(Date.now() - staleThresholdMinutes * 60 * 1000);
|
||||
const isStaleLastRun = retryState.lastRunCompletedAt && retryState.lastRunCompletedAt < staleSince;
|
||||
const shouldBeDown = retryState.lastRunStatus === "failed" && (retryState.consecutiveFailures ?? 0) >= 3;
|
||||
|
||||
if (shouldBeDown) {
|
||||
retries = {
|
||||
status: "down",
|
||||
message: "retry worker in repeated failure state",
|
||||
meta: {
|
||||
status: retryState.lastRunStatus,
|
||||
consecutiveFailures: retryState.consecutiveFailures
|
||||
}
|
||||
};
|
||||
} else if (isStaleLastRun) {
|
||||
retries = {
|
||||
status: "degraded",
|
||||
message: "retry worker hasn't completed a run recently",
|
||||
meta: {
|
||||
lastRunCompletedAt: retryState.lastRunCompletedAt?.toISOString() ?? null,
|
||||
staleMinutes: staleThresholdMinutes
|
||||
}
|
||||
};
|
||||
} else {
|
||||
retries = {
|
||||
status: "ok",
|
||||
message: `retry worker status: ${retryState.lastRunStatus ?? "unknown"}`,
|
||||
meta: {
|
||||
consecutiveFailures: retryState.consecutiveFailures ?? 0
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (webhookFailureCount > failureThreshold) {
|
||||
webhook = {
|
||||
status: "degraded",
|
||||
message: `high webhook failure volume: ${webhookFailureCount} in 60m`,
|
||||
meta: { count: webhookFailureCount, threshold: failureThreshold }
|
||||
};
|
||||
} else if (disconnectedChannels > 0) {
|
||||
webhook = {
|
||||
status: "degraded",
|
||||
message: `disconnected channels: ${disconnectedChannels}`,
|
||||
meta: { disconnectedChannels }
|
||||
};
|
||||
}
|
||||
} else {
|
||||
retries = {
|
||||
status: "down",
|
||||
message: "skipped due to database not available"
|
||||
};
|
||||
webhook = {
|
||||
status: "down",
|
||||
message: "skipped due to database not available"
|
||||
};
|
||||
}
|
||||
|
||||
checks.retries = retries;
|
||||
checks.webhook = webhook;
|
||||
|
||||
const components = Object.entries(checks);
|
||||
const overall: "ok" | "degraded" | "down" = isUp([checks.database, checks.retries, checks.webhook]) ? "ok" : checks.database.status === "down" ? "down" : "degraded";
|
||||
const exposeDetails = maybeExposeDetails(req);
|
||||
const payload = {
|
||||
ok: overall !== "down",
|
||||
status: overall,
|
||||
components: exposeDetails
|
||||
? checks
|
||||
: Object.fromEntries(components.map(([name, item]) => [name, { status: item.status, message: item.message }])),
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
return NextResponse.json(payload, { status: overall === "down" ? 503 : 200 });
|
||||
}
|
||||
133
app/api/jobs/campaign-retry/route.ts
Normal file
133
app/api/jobs/campaign-retry/route.ts
Normal file
@ -0,0 +1,133 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { getRequestAuditContext } from "@/lib/audit";
|
||||
import { getCampaignRetryState, runCampaignRetryBatch } from "@/lib/campaign-dispatch-service";
|
||||
import { consumeRateLimit, getRateLimitHeaders } from "@/lib/rate-limit";
|
||||
|
||||
type JobPayload = {
|
||||
tenantId?: string;
|
||||
campaignId?: string;
|
||||
recipientBatchSize?: number;
|
||||
maxCampaigns?: number;
|
||||
};
|
||||
|
||||
function isAuthorized(req: NextRequest) {
|
||||
const expected = process.env.CAMPAIGN_RETRY_JOB_TOKEN?.trim();
|
||||
if (!expected) {
|
||||
return process.env.NODE_ENV !== "production";
|
||||
}
|
||||
|
||||
const tokenFromHeader = req.headers.get("authorization")?.trim() || req.headers.get("x-cron-token")?.trim();
|
||||
const tokenFromQuery = new URL(req.url).searchParams.get("token")?.trim();
|
||||
const token = tokenFromHeader || tokenFromQuery;
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return token === expected || token === `Bearer ${expected}`;
|
||||
}
|
||||
|
||||
function resolveNumber(raw: string | undefined, fallback: number) {
|
||||
const value = Number(raw?.trim());
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const { ipAddress: requestIpAddress } = await getRequestAuditContext();
|
||||
const retryRate = consumeRateLimit(requestIpAddress || "unknown", {
|
||||
scope: "campaign_retry_job_get",
|
||||
limit: resolveNumber(process.env.CAMPAIGN_RETRY_JOB_RATE_LIMIT_GET, 60),
|
||||
windowMs: resolveNumber(process.env.CAMPAIGN_RETRY_JOB_RATE_LIMIT_WINDOW_MS, 60 * 1000)
|
||||
});
|
||||
|
||||
if (!retryRate.allowed) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "Too many requests. Please retry later." },
|
||||
{
|
||||
status: 429,
|
||||
headers: getRateLimitHeaders(retryRate)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthorized(req)) {
|
||||
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const state = await getCampaignRetryState();
|
||||
const now = new Date();
|
||||
const lockedUntil = state?.lockedUntil ? new Date(state.lockedUntil) : null;
|
||||
const health = {
|
||||
isLocked: Boolean(lockedUntil && lockedUntil > now),
|
||||
isStaleLock: Boolean(lockedUntil && lockedUntil <= now),
|
||||
lastRunStartedAt: state?.lastRunStartedAt ?? null,
|
||||
lastRunCompletedAt: state?.lastRunCompletedAt ?? null,
|
||||
lastRunStatus: state?.lastRunStatus ?? null
|
||||
};
|
||||
|
||||
return NextResponse.json({ ok: true, state, health });
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { ipAddress: requestIpAddress } = await getRequestAuditContext();
|
||||
const retryRate = consumeRateLimit(requestIpAddress || "unknown", {
|
||||
scope: "campaign_retry_job_post",
|
||||
limit: resolveNumber(process.env.CAMPAIGN_RETRY_JOB_RATE_LIMIT_POST, 20),
|
||||
windowMs: resolveNumber(process.env.CAMPAIGN_RETRY_JOB_RATE_LIMIT_WINDOW_MS, 60 * 1000)
|
||||
});
|
||||
|
||||
if (!retryRate.allowed) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "Too many requests. Please retry later." },
|
||||
{
|
||||
status: 429,
|
||||
headers: getRateLimitHeaders(retryRate)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthorized(req)) {
|
||||
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
let payload: unknown = {};
|
||||
try {
|
||||
payload = (await req.json()) as unknown;
|
||||
} catch {
|
||||
payload = {};
|
||||
}
|
||||
|
||||
const safePayload = payload as JobPayload;
|
||||
const tenantId = safePayload?.tenantId?.trim?.() || undefined;
|
||||
const campaignId = safePayload?.campaignId?.trim?.() || undefined;
|
||||
|
||||
const recipientBatchSize = Number.isInteger(safePayload?.recipientBatchSize)
|
||||
? safePayload?.recipientBatchSize
|
||||
: undefined;
|
||||
const maxCampaigns = Number.isInteger(safePayload?.maxCampaigns)
|
||||
? safePayload?.maxCampaigns
|
||||
: undefined;
|
||||
|
||||
const { ipAddress, userAgent } = await getRequestAuditContext();
|
||||
|
||||
try {
|
||||
const result = await runCampaignRetryBatch({
|
||||
campaignId,
|
||||
tenantId,
|
||||
actorIpAddress: ipAddress,
|
||||
actorUserAgent: userAgent,
|
||||
actorUserId: null,
|
||||
recipientBatchSize,
|
||||
maxCampaigns
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true, ...result });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Campaign retry job failed";
|
||||
return NextResponse.json({ ok: false, error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
805
app/api/webhooks/whatsapp/route.ts
Normal file
805
app/api/webhooks/whatsapp/route.ts
Normal file
@ -0,0 +1,805 @@
|
||||
import crypto from "node:crypto";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import {
|
||||
ConversationStatus,
|
||||
DeliveryStatus,
|
||||
MessageDirection,
|
||||
MessageType,
|
||||
OptInStatus
|
||||
} from "@prisma/client";
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getRequestAuditContext, writeAuditTrail } from "@/lib/audit";
|
||||
import { recalculateCampaignTotals } from "@/lib/campaign-utils";
|
||||
import { consumeRateLimit, getRateLimitHeaders } from "@/lib/rate-limit";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
type NormalizedEvent = {
|
||||
eventType: string;
|
||||
tenantId: string;
|
||||
channelId?: string;
|
||||
channelPhoneNumberId?: string;
|
||||
providerEventId?: string | null;
|
||||
payload: JsonRecord;
|
||||
rawDirection: "inbound" | "status" | "other";
|
||||
inbound?: {
|
||||
from: string;
|
||||
body?: string;
|
||||
contactName?: string;
|
||||
messageId?: string | null;
|
||||
};
|
||||
status?: {
|
||||
messageId?: string | null;
|
||||
deliveryStatus: "sent" | "delivered" | "read" | "failed" | string;
|
||||
failureReason?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type WebhookProcessStatus = "processed" | "failed" | "skipped";
|
||||
|
||||
function getString(value: unknown) {
|
||||
if (typeof value === "string") {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function normalizePhone(value: string) {
|
||||
return value.replace(/\D/g, "");
|
||||
}
|
||||
|
||||
function resolveNumber(raw: string | undefined, fallback: number) {
|
||||
const parsed = Number(raw?.trim());
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function getWebhookIp(req: NextRequest) {
|
||||
const forwarded = req.headers.get("x-forwarded-for");
|
||||
return (forwarded ? forwarded.split(",")[0]?.trim() : null)
|
||||
|| req.headers.get("x-real-ip")
|
||||
|| "unknown";
|
||||
}
|
||||
|
||||
function buildWebhookEventHash(event: NormalizedEvent, resolvedChannelId: string) {
|
||||
const inboundBody = event.inbound?.body?.trim();
|
||||
const statusDelivery = event.status?.deliveryStatus?.trim();
|
||||
const peerPhone =
|
||||
event.inbound?.from ||
|
||||
event.channelPhoneNumberId ||
|
||||
event.providerEventId ||
|
||||
null;
|
||||
const payload = {
|
||||
tenantId: event.tenantId,
|
||||
eventType: event.eventType,
|
||||
channelId: resolvedChannelId,
|
||||
providerEventId: event.providerEventId?.trim() || null,
|
||||
direction: event.rawDirection,
|
||||
messageId: event.status?.messageId || event.inbound?.messageId || null,
|
||||
peerPhone,
|
||||
bodyHash: inboundBody ? crypto.createHash("sha256").update(inboundBody).digest("hex") : null,
|
||||
deliveryStatus: statusDelivery || null,
|
||||
failureReason: event.status?.failureReason?.trim() || null
|
||||
};
|
||||
|
||||
return crypto.createHash("sha256").update(JSON.stringify(payload)).digest("hex");
|
||||
}
|
||||
|
||||
async function isDuplicateWebhookEvent(tenantId: string, channelId: string, eventHash: string) {
|
||||
const existing = await prisma.webhookEvent.findFirst({
|
||||
where: {
|
||||
tenantId,
|
||||
channelId,
|
||||
eventHash,
|
||||
processStatus: {
|
||||
in: ["processed", "skipped"]
|
||||
}
|
||||
},
|
||||
select: { id: true }
|
||||
});
|
||||
|
||||
return Boolean(existing);
|
||||
}
|
||||
|
||||
async function writeWebhookEvent(params: {
|
||||
tenantId: string;
|
||||
channelId: string | null;
|
||||
event: NormalizedEvent;
|
||||
processStatus: WebhookProcessStatus;
|
||||
eventHash: string;
|
||||
failedReason?: string;
|
||||
}) {
|
||||
const now = new Date();
|
||||
await prisma.webhookEvent.create({
|
||||
data: {
|
||||
tenantId: params.tenantId,
|
||||
channelId: params.channelId ?? null,
|
||||
eventType: params.event.eventType,
|
||||
providerEventId: params.event.providerEventId,
|
||||
payloadJson: JSON.stringify(params.event.payload),
|
||||
processStatus: params.processStatus,
|
||||
failedReason: params.failedReason,
|
||||
eventHash: params.eventHash,
|
||||
processedAt: params.processStatus !== "failed" ? now : null
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getStatusDelivery(status: string): DeliveryStatus {
|
||||
const normalized = status.toLowerCase();
|
||||
if (normalized === "sent") {
|
||||
return DeliveryStatus.SENT;
|
||||
}
|
||||
|
||||
if (normalized === "delivered") {
|
||||
return DeliveryStatus.DELIVERED;
|
||||
}
|
||||
|
||||
if (normalized === "read") {
|
||||
return DeliveryStatus.READ;
|
||||
}
|
||||
|
||||
if (normalized === "accepted" || normalized === "accepted_by_sms" || normalized === "pending") {
|
||||
return DeliveryStatus.SENT;
|
||||
}
|
||||
|
||||
if (normalized === "undelivered") {
|
||||
return DeliveryStatus.FAILED;
|
||||
}
|
||||
|
||||
if (normalized === "failed") {
|
||||
return DeliveryStatus.FAILED;
|
||||
}
|
||||
|
||||
return DeliveryStatus.QUEUED;
|
||||
}
|
||||
|
||||
function shouldAdvanceDeliveryStatus(currentStatus: DeliveryStatus, nextStatus: DeliveryStatus) {
|
||||
const score: Record<DeliveryStatus, number> = {
|
||||
[DeliveryStatus.QUEUED]: 1,
|
||||
[DeliveryStatus.SENT]: 2,
|
||||
[DeliveryStatus.DELIVERED]: 3,
|
||||
[DeliveryStatus.READ]: 4,
|
||||
[DeliveryStatus.FAILED]: 0
|
||||
};
|
||||
|
||||
if (nextStatus === DeliveryStatus.READ) {
|
||||
return DeliveryStatus.READ;
|
||||
}
|
||||
|
||||
if (nextStatus === DeliveryStatus.DELIVERED && currentStatus === DeliveryStatus.READ) {
|
||||
return DeliveryStatus.READ;
|
||||
}
|
||||
|
||||
if (nextStatus === DeliveryStatus.FAILED && (currentStatus === DeliveryStatus.DELIVERED || currentStatus === DeliveryStatus.READ)) {
|
||||
return currentStatus;
|
||||
}
|
||||
|
||||
if (nextStatus === DeliveryStatus.FAILED && (currentStatus === DeliveryStatus.FAILED || currentStatus === DeliveryStatus.SENT || currentStatus === DeliveryStatus.QUEUED)) {
|
||||
return nextStatus;
|
||||
}
|
||||
|
||||
if (currentStatus === DeliveryStatus.FAILED && nextStatus !== DeliveryStatus.DELIVERED) {
|
||||
return currentStatus;
|
||||
}
|
||||
|
||||
if (score[nextStatus] > score[currentStatus]) {
|
||||
return nextStatus;
|
||||
}
|
||||
|
||||
return currentStatus;
|
||||
}
|
||||
|
||||
function verifyMetaSignature(rawBody: string, signatureHeader: string | null) {
|
||||
const secret = process.env.WHATSAPP_WEBHOOK_SECRET?.trim();
|
||||
if (!secret) {
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!signatureHeader) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const split = signatureHeader.split("=");
|
||||
if (split.length !== 2 || split[0] !== "sha256") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
|
||||
const provided = split[1];
|
||||
if (provided.length !== expected.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expectedBytes = Buffer.from(expected, "hex");
|
||||
const providedBytes = Buffer.from(provided, "hex");
|
||||
return crypto.timingSafeEqual(expectedBytes, providedBytes);
|
||||
}
|
||||
|
||||
function parseMetaPayload(payload: JsonRecord) {
|
||||
const tenantId = getString(payload.tenantId) || getString(payload.tenant_id);
|
||||
const out: NormalizedEvent[] = [];
|
||||
const entry = payload.entry;
|
||||
|
||||
if (!Array.isArray(entry)) {
|
||||
return out;
|
||||
}
|
||||
|
||||
for (const entryItem of entry) {
|
||||
const rawChanges = (entryItem as JsonRecord).changes;
|
||||
const changes = Array.isArray(rawChanges) ? (rawChanges as unknown[]) : [];
|
||||
|
||||
for (const rawChange of changes) {
|
||||
const change = rawChange as JsonRecord;
|
||||
const value = change.value as JsonRecord | undefined;
|
||||
if (!value || typeof value !== "object") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const metadata = value.metadata as JsonRecord | undefined;
|
||||
const phoneNumberId = getString(metadata?.phone_number_id || metadata?.phoneNumberId);
|
||||
const messages = Array.isArray(value.messages) ? value.messages : [];
|
||||
const statuses = Array.isArray(value.statuses) ? value.statuses : [];
|
||||
|
||||
for (const rawMessage of messages) {
|
||||
const message = rawMessage as JsonRecord;
|
||||
const messageId = getString(message.id);
|
||||
const from = normalizePhone(getString(message.from));
|
||||
const text = (message.text as JsonRecord | undefined)?.body;
|
||||
const body = getString(text);
|
||||
|
||||
if (!from || !tenantId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const contacts = Array.isArray(value.contacts) ? value.contacts : [];
|
||||
const matchedContact = contacts.find((item) => getString((item as JsonRecord).wa_id) === from) as JsonRecord | undefined;
|
||||
const rawProfile = typeof matchedContact?.profile === "object" ? (matchedContact.profile as JsonRecord | null) : null;
|
||||
const contactName = getString(rawProfile?.name) || from;
|
||||
|
||||
out.push({
|
||||
eventType: "message.inbound",
|
||||
tenantId,
|
||||
channelPhoneNumberId: phoneNumberId || undefined,
|
||||
providerEventId: messageId || undefined,
|
||||
payload,
|
||||
rawDirection: "inbound",
|
||||
inbound: {
|
||||
from,
|
||||
body,
|
||||
contactName,
|
||||
messageId: messageId || null
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const rawStatus of statuses) {
|
||||
const statusValue = rawStatus as JsonRecord;
|
||||
const status = getString(statusValue.status);
|
||||
const messageId = getString(statusValue.id);
|
||||
const to = normalizePhone(getString(statusValue.recipient_id || statusValue.to));
|
||||
|
||||
if (!tenantId || !messageId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
out.push({
|
||||
eventType: "message.status",
|
||||
tenantId,
|
||||
channelPhoneNumberId: phoneNumberId || to || undefined,
|
||||
providerEventId: messageId,
|
||||
payload,
|
||||
rawDirection: "status",
|
||||
status: {
|
||||
messageId,
|
||||
deliveryStatus: status,
|
||||
failureReason: getString((statusValue.errors as unknown as JsonRecord[])?.[0]?.title)
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseLegacyPayload(payload: JsonRecord) {
|
||||
const out: NormalizedEvent[] = [];
|
||||
const tenantId = getString(payload.tenantId || payload.tenant_id);
|
||||
const eventType = getString(payload.eventType || payload.type || payload.event_type || payload.event);
|
||||
|
||||
if (!tenantId || !eventType) {
|
||||
return out;
|
||||
}
|
||||
|
||||
if (eventType.includes("status")) {
|
||||
out.push({
|
||||
eventType,
|
||||
tenantId,
|
||||
channelId: getString(payload.channelId || payload.channel_id) || undefined,
|
||||
channelPhoneNumberId: getString(payload.channelPhoneNumberId || payload.phoneNumberId || payload.phone_number_id) || undefined,
|
||||
providerEventId: getString(payload.providerEventId || payload.eventId || payload.id),
|
||||
payload,
|
||||
rawDirection: "status",
|
||||
status: {
|
||||
messageId: getString(payload.messageId || payload.message_id),
|
||||
deliveryStatus: getString(payload.status || "failed"),
|
||||
failureReason: getString(payload.failureReason || payload.failedReason)
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
if (eventType.includes("inbound") || eventType.includes("message")) {
|
||||
out.push({
|
||||
eventType,
|
||||
tenantId,
|
||||
channelId: getString(payload.channelId || payload.channel_id) || undefined,
|
||||
channelPhoneNumberId: getString(payload.channelPhoneNumberId || payload.phoneNumberId || payload.phone_number_id) || undefined,
|
||||
providerEventId: getString(payload.providerMessageId || payload.messageId || payload.id),
|
||||
payload,
|
||||
rawDirection: "inbound",
|
||||
inbound: {
|
||||
from: normalizePhone(getString(payload.from || payload.phone || payload.recipient)),
|
||||
body: getString(payload.body || (payload.message as JsonRecord)?.body || payload.text),
|
||||
contactName: getString(payload.contactName || payload.name),
|
||||
messageId: getString(payload.providerMessageId || payload.messageId || payload.id)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function mapEventDirection(event: NormalizedEvent) {
|
||||
if (event.rawDirection === "other") {
|
||||
return "other";
|
||||
}
|
||||
|
||||
if (event.rawDirection === "status" || event.rawDirection === "inbound") {
|
||||
return event.rawDirection;
|
||||
}
|
||||
|
||||
return "other";
|
||||
}
|
||||
|
||||
async function resolveChannelId(tenantId: string, channelId?: string, phoneNumberId?: string) {
|
||||
if (channelId) {
|
||||
const channel = await prisma.channel.findFirst({ where: { id: channelId, tenantId } });
|
||||
return channel?.id ?? null;
|
||||
}
|
||||
|
||||
if (phoneNumberId) {
|
||||
const channel = await prisma.channel.findFirst({ where: { phoneNumberId, tenantId } });
|
||||
if (channel) {
|
||||
return channel.id;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const rate = consumeRateLimit(getWebhookIp(req), {
|
||||
scope: "whatsapp_webhook_get",
|
||||
limit: resolveNumber(process.env.WHATSAPP_WEBHOOK_RATE_LIMIT_GET, 60),
|
||||
windowMs: resolveNumber(process.env.WHATSAPP_WEBHOOK_RATE_LIMIT_WINDOW_MS, 60 * 1000)
|
||||
});
|
||||
|
||||
if (!rate.allowed) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "Too many webhook verification requests" },
|
||||
{
|
||||
status: 429,
|
||||
headers: getRateLimitHeaders(rate)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const verifyToken = process.env.WHATSAPP_WEBHOOK_VERIFY_TOKEN?.trim() || "";
|
||||
const mode = req.nextUrl.searchParams.get("hub.mode");
|
||||
const token = req.nextUrl.searchParams.get("hub.verify_token");
|
||||
const challenge = req.nextUrl.searchParams.get("hub.challenge");
|
||||
|
||||
if (mode === "subscribe" && token === verifyToken && challenge) {
|
||||
return new NextResponse(challenge, { status: 200 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: false, error: "Invalid verification request" }, { status: 403 });
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const rate = consumeRateLimit(getWebhookIp(req), {
|
||||
scope: "whatsapp_webhook_post",
|
||||
limit: resolveNumber(process.env.WHATSAPP_WEBHOOK_RATE_LIMIT_POST, 120),
|
||||
windowMs: resolveNumber(process.env.WHATSAPP_WEBHOOK_RATE_LIMIT_WINDOW_MS, 60 * 1000)
|
||||
});
|
||||
|
||||
if (!rate.allowed) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "Too many webhook events" },
|
||||
{
|
||||
status: 429,
|
||||
headers: getRateLimitHeaders(rate)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const raw = await req.text();
|
||||
|
||||
if (!verifyMetaSignature(raw, req.headers.get("x-hub-signature-256"))) {
|
||||
return NextResponse.json({ ok: false, error: "Invalid webhook signature" }, { status: 401 });
|
||||
}
|
||||
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = JSON.parse(raw);
|
||||
} catch {
|
||||
return NextResponse.json({ ok: false, error: "Invalid JSON payload" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return NextResponse.json({ ok: false, error: "Payload must be a JSON object" }, { status: 400 });
|
||||
}
|
||||
|
||||
const payloadObj = payload as JsonRecord;
|
||||
const parsedEvents = [...parseMetaPayload(payloadObj), ...parseLegacyPayload(payloadObj)];
|
||||
if (parsedEvents.length === 0) {
|
||||
return NextResponse.json({ ok: true, processed: 0, skipped: 0 });
|
||||
}
|
||||
|
||||
let processed = 0;
|
||||
let failed = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const event of parsedEvents) {
|
||||
const direction = mapEventDirection(event);
|
||||
const now = new Date();
|
||||
|
||||
const resolvedChannelId = await resolveChannelId(event.tenantId, event.channelId, event.channelPhoneNumberId);
|
||||
if (!resolvedChannelId) {
|
||||
const eventHash = buildWebhookEventHash(event, event.channelPhoneNumberId || event.channelId || "unresolved");
|
||||
await writeWebhookEvent({
|
||||
tenantId: event.tenantId,
|
||||
channelId: null,
|
||||
event,
|
||||
eventHash,
|
||||
processStatus: "failed",
|
||||
failedReason: "Channel not found"
|
||||
});
|
||||
failed += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const eventHash = buildWebhookEventHash(event, resolvedChannelId);
|
||||
if (await isDuplicateWebhookEvent(event.tenantId, resolvedChannelId, eventHash)) {
|
||||
await writeWebhookEvent({
|
||||
tenantId: event.tenantId,
|
||||
channelId: resolvedChannelId,
|
||||
event,
|
||||
eventHash,
|
||||
processStatus: "skipped"
|
||||
});
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (direction === "inbound") {
|
||||
const inbound = event.inbound;
|
||||
if (!inbound) {
|
||||
failed += 1;
|
||||
await writeWebhookEvent({
|
||||
tenantId: event.tenantId,
|
||||
channelId: resolvedChannelId,
|
||||
event,
|
||||
eventHash,
|
||||
processStatus: "failed",
|
||||
failedReason: "Invalid inbound payload"
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const fromPhone = inbound.from;
|
||||
if (!fromPhone) {
|
||||
failed += 1;
|
||||
await writeWebhookEvent({
|
||||
tenantId: event.tenantId,
|
||||
channelId: resolvedChannelId,
|
||||
event,
|
||||
eventHash,
|
||||
processStatus: "failed",
|
||||
failedReason: "Missing sender phone number"
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const contact = await prisma.contact.upsert({
|
||||
where: {
|
||||
tenantId_phoneNumber: {
|
||||
tenantId: event.tenantId,
|
||||
phoneNumber: fromPhone
|
||||
}
|
||||
},
|
||||
create: {
|
||||
tenantId: event.tenantId,
|
||||
channelId: resolvedChannelId,
|
||||
fullName: inbound.contactName || fromPhone,
|
||||
phoneNumber: fromPhone,
|
||||
optInStatus: OptInStatus.OPTED_IN
|
||||
},
|
||||
update: {
|
||||
channelId: resolvedChannelId,
|
||||
fullName: inbound.contactName || fromPhone
|
||||
}
|
||||
});
|
||||
|
||||
let conversation = await prisma.conversation.findFirst({
|
||||
where: {
|
||||
tenantId: event.tenantId,
|
||||
channelId: resolvedChannelId,
|
||||
contactId: contact.id
|
||||
},
|
||||
orderBy: { lastMessageAt: "desc" }
|
||||
});
|
||||
|
||||
if (!conversation) {
|
||||
conversation = await prisma.conversation.create({
|
||||
data: {
|
||||
tenantId: event.tenantId,
|
||||
channelId: resolvedChannelId,
|
||||
contactId: contact.id,
|
||||
subject: inbound.body?.slice(0, 80) ?? "WhatsApp inbound",
|
||||
firstMessageAt: now,
|
||||
lastMessageAt: now,
|
||||
lastInboundAt: now,
|
||||
status: ConversationStatus.OPEN
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const existingInbound = inbound.messageId
|
||||
? await prisma.message.findUnique({
|
||||
where: { providerMessageId: inbound.messageId }
|
||||
})
|
||||
: null;
|
||||
|
||||
if (!inbound.messageId || !existingInbound) {
|
||||
await prisma.message.create({
|
||||
data: {
|
||||
tenantId: event.tenantId,
|
||||
conversationId: conversation.id,
|
||||
channelId: resolvedChannelId,
|
||||
contactId: contact.id,
|
||||
direction: MessageDirection.INBOUND,
|
||||
type: MessageType.TEXT,
|
||||
providerMessageId: inbound.messageId,
|
||||
contentText: inbound.body,
|
||||
sentAt: now,
|
||||
sentByUserId: null
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.conversation.update({
|
||||
where: { id: conversation.id },
|
||||
data: {
|
||||
lastMessageAt: now,
|
||||
lastInboundAt: now,
|
||||
status: ConversationStatus.OPEN
|
||||
}
|
||||
});
|
||||
|
||||
await prisma.contact.update({
|
||||
where: { id: contact.id },
|
||||
data: { lastInteractionAt: now }
|
||||
});
|
||||
|
||||
await prisma.conversationActivity.create({
|
||||
data: {
|
||||
tenantId: event.tenantId,
|
||||
conversationId: conversation.id,
|
||||
actorUserId: null,
|
||||
activityType: "MESSAGE_RECEIVED",
|
||||
metadataJson: JSON.stringify({
|
||||
provider: "webhook",
|
||||
messageId: inbound.messageId,
|
||||
body: inbound.body?.slice(0, 120)
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
await writeWebhookEvent({
|
||||
tenantId: event.tenantId,
|
||||
channelId: resolvedChannelId,
|
||||
event,
|
||||
eventHash,
|
||||
processStatus: "processed"
|
||||
});
|
||||
|
||||
await prisma.channel.update({
|
||||
where: { id: resolvedChannelId },
|
||||
data: { webhookStatus: "healthy", lastSyncAt: now }
|
||||
});
|
||||
|
||||
processed += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (direction === "status") {
|
||||
const { ipAddress, userAgent } = await getRequestAuditContext();
|
||||
const messageId = event.status?.messageId;
|
||||
if (!messageId) {
|
||||
failed += 1;
|
||||
await writeWebhookEvent({
|
||||
tenantId: event.tenantId,
|
||||
channelId: resolvedChannelId,
|
||||
event,
|
||||
eventHash,
|
||||
processStatus: "failed",
|
||||
failedReason: "Status event missing messageId"
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const targetMessage = await prisma.message.findFirst({
|
||||
where: {
|
||||
tenantId: event.tenantId,
|
||||
providerMessageId: messageId
|
||||
},
|
||||
include: { conversation: true }
|
||||
});
|
||||
|
||||
const campaignRecipient = await prisma.campaignRecipient.findFirst({
|
||||
where: {
|
||||
tenantId: event.tenantId,
|
||||
providerMessageId: messageId
|
||||
}
|
||||
});
|
||||
|
||||
if (!targetMessage && !campaignRecipient) {
|
||||
failed += 1;
|
||||
await writeWebhookEvent({
|
||||
tenantId: event.tenantId,
|
||||
channelId: resolvedChannelId,
|
||||
event,
|
||||
eventHash,
|
||||
processStatus: "failed",
|
||||
failedReason: "Message not found by providerMessageId"
|
||||
});
|
||||
await writeAuditTrail({
|
||||
tenantId: event.tenantId,
|
||||
actorUserId: null,
|
||||
entityType: "campaign_recipient",
|
||||
entityId: messageId,
|
||||
action: "campaign_delivery_sync_not_found",
|
||||
metadata: {
|
||||
providerMessageId: messageId,
|
||||
providerStatus: event.status?.deliveryStatus,
|
||||
eventType: event.eventType
|
||||
},
|
||||
ipAddress,
|
||||
userAgent
|
||||
}).catch(() => null);
|
||||
continue;
|
||||
}
|
||||
|
||||
const mapped = getStatusDelivery(event.status?.deliveryStatus || "queued");
|
||||
const resolvedTargetStatus = mapped;
|
||||
const targetMessageStatus = targetMessage ? shouldAdvanceDeliveryStatus(targetMessage.deliveryStatus, resolvedTargetStatus) : resolvedTargetStatus;
|
||||
const campaignRecipientStatus = campaignRecipient
|
||||
? shouldAdvanceDeliveryStatus(campaignRecipient.sendStatus, resolvedTargetStatus)
|
||||
: resolvedTargetStatus;
|
||||
|
||||
const nowDelivery = campaignRecipientStatus === DeliveryStatus.DELIVERED || campaignRecipientStatus === DeliveryStatus.READ
|
||||
? now
|
||||
: targetMessageStatus === DeliveryStatus.DELIVERED || targetMessageStatus === DeliveryStatus.READ
|
||||
? now
|
||||
: undefined;
|
||||
const nowRead = campaignRecipientStatus === DeliveryStatus.READ || targetMessageStatus === DeliveryStatus.READ ? now : undefined;
|
||||
const txOps = [];
|
||||
const updateData = {
|
||||
deliveryStatus: targetMessageStatus,
|
||||
failedReason: campaignRecipientStatus === DeliveryStatus.FAILED ? event.status?.failureReason : null,
|
||||
deliveredAt: nowDelivery,
|
||||
readAt: nowRead
|
||||
};
|
||||
|
||||
if (targetMessage) {
|
||||
txOps.push(
|
||||
prisma.message.update({
|
||||
where: { id: targetMessage.id },
|
||||
data: {
|
||||
...updateData,
|
||||
sentAt: targetMessageStatus === DeliveryStatus.SENT && !targetMessage.sentAt ? now : undefined
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (targetMessage) {
|
||||
txOps.push(
|
||||
prisma.conversationActivity.create({
|
||||
data: {
|
||||
tenantId: event.tenantId,
|
||||
conversationId: targetMessage.conversationId,
|
||||
actorUserId: null,
|
||||
activityType: "DELIVERY_UPDATE",
|
||||
metadataJson: JSON.stringify({
|
||||
providerStatus: mapped,
|
||||
providerEventId: event.providerEventId,
|
||||
messageId: targetMessage.id
|
||||
})
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (campaignRecipient) {
|
||||
txOps.push(
|
||||
prisma.campaignRecipient.update({
|
||||
where: { id: campaignRecipient.id },
|
||||
data: {
|
||||
sendStatus: campaignRecipientStatus,
|
||||
failureReason: campaignRecipientStatus === DeliveryStatus.FAILED
|
||||
? event.status?.failureReason ?? campaignRecipient?.failureReason ?? null
|
||||
: campaignRecipient?.failureReason ?? null,
|
||||
deliveredAt: nowDelivery,
|
||||
readAt: nowRead,
|
||||
sentAt: campaignRecipientStatus === DeliveryStatus.SENT && !campaignRecipient.sentAt ? now : campaignRecipient.sentAt,
|
||||
nextRetryAt: null
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
txOps.push(
|
||||
writeWebhookEvent({
|
||||
tenantId: event.tenantId,
|
||||
channelId: resolvedChannelId,
|
||||
event,
|
||||
eventHash,
|
||||
processStatus: "processed"
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.all(txOps);
|
||||
|
||||
if (campaignRecipient) {
|
||||
await recalculateCampaignTotals(campaignRecipient.campaignId);
|
||||
}
|
||||
|
||||
await writeAuditTrail({
|
||||
tenantId: event.tenantId,
|
||||
actorUserId: null,
|
||||
entityType: campaignRecipient ? "campaign_recipient" : "message",
|
||||
entityId: campaignRecipient?.id || targetMessage?.id || messageId,
|
||||
action: "message_delivery_status_synced",
|
||||
metadata: {
|
||||
providerStatus: resolvedTargetStatus,
|
||||
appliedStatus: campaignRecipient ? campaignRecipientStatus : targetMessageStatus,
|
||||
providerMessageId: messageId
|
||||
},
|
||||
ipAddress,
|
||||
userAgent
|
||||
}).catch(() => null);
|
||||
|
||||
processed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
processed,
|
||||
failed,
|
||||
skipped
|
||||
});
|
||||
}
|
||||
45
app/audit-log/page.tsx
Normal file
45
app/audit-log/page.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { TablePlaceholder } from "@/components/placeholders";
|
||||
|
||||
function toTime(value: Date) {
|
||||
return new Intl.DateTimeFormat("id-ID", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
export default async function TenantAuditLogPage() {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const audits = await prisma.auditLog.findMany({
|
||||
where: { tenantId: session.tenantId },
|
||||
include: { actorUser: true },
|
||||
orderBy: { createdAt: "desc" }
|
||||
});
|
||||
|
||||
return (
|
||||
<ShellPage shell="admin" title="Audit Log" description="User activity, message action, dan campaign action logs.">
|
||||
<TablePlaceholder
|
||||
title="Audit events"
|
||||
columns={["Time", "Actor", "Module", "Action", "Entity"]}
|
||||
rows={audits.map((audit) => [
|
||||
toTime(audit.createdAt),
|
||||
audit.actorUser?.fullName ?? "System",
|
||||
audit.entityType,
|
||||
audit.action,
|
||||
audit.entityId
|
||||
])}
|
||||
/>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
129
app/auth/login/route.ts
Normal file
129
app/auth/login/route.ts
Normal file
@ -0,0 +1,129 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { SESSION_COOKIE, UserRole, authenticateUser, getDefaultPathForRole, serializeSession } from "@/lib/auth";
|
||||
import { getRequestAuditContext, writeAuditTrail } from "@/lib/audit";
|
||||
import { consumeRateLimit, getRateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
function getSafePath(value: string | null) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!value.startsWith("/")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function resolveNumber(raw: string | undefined, fallback: number) {
|
||||
const value = Number(raw?.trim());
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const { ipAddress, userAgent } = await getRequestAuditContext();
|
||||
const retryControl = consumeRateLimit(ipAddress || "unknown", {
|
||||
scope: "auth_login",
|
||||
limit: resolveNumber(process.env.LOGIN_RATE_LIMIT_ATTEMPTS, 10),
|
||||
windowMs: resolveNumber(process.env.LOGIN_RATE_LIMIT_WINDOW_MS, 15 * 60 * 1000)
|
||||
});
|
||||
|
||||
if (!retryControl.allowed) {
|
||||
const loginUrl = new URL("/login", request.url);
|
||||
loginUrl.searchParams.set("error", "rate_limited");
|
||||
const response = NextResponse.redirect(loginUrl);
|
||||
const headers = getRateLimitHeaders(retryControl);
|
||||
Object.entries(headers).forEach(([headerName, headerValue]) => {
|
||||
response.headers.set(headerName, headerValue);
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
const form = await request.formData();
|
||||
const rawEmail = form.get("email");
|
||||
const rawPassword = form.get("password");
|
||||
const rawNext = form.get("next");
|
||||
|
||||
const next = getSafePath(typeof rawNext === "string" ? rawNext : null);
|
||||
const email = typeof rawEmail === "string" ? rawEmail.trim() : "";
|
||||
const password = typeof rawPassword === "string" ? rawPassword : "";
|
||||
|
||||
if (!email || !password) {
|
||||
const loginUrl = new URL("/login", request.url);
|
||||
loginUrl.searchParams.set("error", "credentials_required");
|
||||
if (next) {
|
||||
loginUrl.searchParams.set("next", next);
|
||||
}
|
||||
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
const session = await authenticateUser(email, password);
|
||||
if (!session) {
|
||||
const attemptedUser = await prisma.user.findUnique({
|
||||
where: { email },
|
||||
select: { id: true, tenantId: true, status: true }
|
||||
});
|
||||
|
||||
if (attemptedUser) {
|
||||
await writeAuditTrail({
|
||||
tenantId: attemptedUser.tenantId,
|
||||
actorUserId: attemptedUser.id,
|
||||
entityType: "user",
|
||||
entityId: attemptedUser.id,
|
||||
action: "user_login_failed",
|
||||
metadata: {
|
||||
email,
|
||||
status: attemptedUser.status,
|
||||
source: "web"
|
||||
},
|
||||
ipAddress,
|
||||
userAgent
|
||||
});
|
||||
}
|
||||
|
||||
const loginUrl = new URL("/login", request.url);
|
||||
loginUrl.searchParams.set("error", "invalid_credentials");
|
||||
if (next) {
|
||||
loginUrl.searchParams.set("next", next);
|
||||
}
|
||||
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: session.userId },
|
||||
data: { lastLoginAt: new Date() }
|
||||
});
|
||||
|
||||
await writeAuditTrail({
|
||||
tenantId: session.tenantId,
|
||||
actorUserId: session.userId,
|
||||
entityType: "user",
|
||||
entityId: session.userId,
|
||||
action: "user_login",
|
||||
metadata: {
|
||||
email
|
||||
},
|
||||
ipAddress,
|
||||
userAgent
|
||||
});
|
||||
|
||||
const destination = next ?? getDefaultPathForRole(session.role as UserRole);
|
||||
const response = NextResponse.redirect(new URL(destination, request.url));
|
||||
response.cookies.set(SESSION_COOKIE, await serializeSession(session), {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
path: "/",
|
||||
maxAge: Math.max(1, Math.floor(session.expiresAt - Date.now() / 1000))
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
26
app/auth/logout/route.ts
Normal file
26
app/auth/logout/route.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { getRequestAuditContext, writeAuditTrail } from "@/lib/audit";
|
||||
import { getSession, SESSION_COOKIE } from "@/lib/auth";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await getSession();
|
||||
const { ipAddress, userAgent } = await getRequestAuditContext();
|
||||
|
||||
if (session) {
|
||||
await writeAuditTrail({
|
||||
tenantId: session.tenantId,
|
||||
actorUserId: session.userId,
|
||||
entityType: "user",
|
||||
entityId: session.userId,
|
||||
action: "user_logout",
|
||||
metadata: { email: session.email },
|
||||
ipAddress,
|
||||
userAgent
|
||||
});
|
||||
}
|
||||
|
||||
const response = NextResponse.redirect(new URL("/login", request.url));
|
||||
response.cookies.delete(SESSION_COOKIE);
|
||||
return response;
|
||||
}
|
||||
59
app/billing/history/page.tsx
Normal file
59
app/billing/history/page.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { TablePlaceholder } from "@/components/placeholders";
|
||||
import Link from "next/link";
|
||||
|
||||
function formatDate(value: Date | null) {
|
||||
if (!value) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat("id-ID", {
|
||||
month: "short",
|
||||
year: "numeric"
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function formatMoney(value: number) {
|
||||
return new Intl.NumberFormat("id-ID", {
|
||||
style: "currency",
|
||||
currency: "IDR",
|
||||
maximumFractionDigits: 0
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
export default async function BillingHistoryPage() {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const invoices = await prisma.billingInvoice.findMany({
|
||||
where: { tenantId: session.tenantId },
|
||||
orderBy: { dueDate: "desc" }
|
||||
});
|
||||
|
||||
return (
|
||||
<ShellPage shell="admin" title="Billing History" description="Invoice list dan payment status tenant.">
|
||||
<TablePlaceholder
|
||||
title="Invoices"
|
||||
columns={["Invoice", "Period", "Amount", "Status"]}
|
||||
rows={invoices.map((invoice) => [
|
||||
<Link
|
||||
href={`/billing/invoices/${invoice.id}`}
|
||||
className="text-brand hover:underline"
|
||||
key={`${invoice.id}-admin-invoice`}
|
||||
>
|
||||
{invoice.invoiceNumber}
|
||||
</Link>,
|
||||
`${formatDate(invoice.periodStart)} - ${formatDate(invoice.periodEnd)}`,
|
||||
formatMoney(invoice.totalAmount),
|
||||
invoice.paymentStatus
|
||||
])}
|
||||
/>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
115
app/billing/invoices/[invoiceId]/page.tsx
Normal file
115
app/billing/invoices/[invoiceId]/page.tsx
Normal file
@ -0,0 +1,115 @@
|
||||
import Link from "next/link";
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { Badge, SectionCard } from "@/components/ui";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
function formatDate(value: Date | null) {
|
||||
if (!value) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat("id-ID", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function formatMoney(value: number) {
|
||||
return new Intl.NumberFormat("id-ID", {
|
||||
style: "currency",
|
||||
currency: "IDR",
|
||||
maximumFractionDigits: 0
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function statusTone(status: string) {
|
||||
if (status === "PAID") {
|
||||
return "success";
|
||||
}
|
||||
|
||||
if (status === "OVERDUE") {
|
||||
return "danger";
|
||||
}
|
||||
|
||||
return "warning";
|
||||
}
|
||||
|
||||
export default async function BillingInvoiceDetailPage({
|
||||
params
|
||||
}: {
|
||||
params: Promise<{ invoiceId: string }>;
|
||||
}) {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const { invoiceId } = await params;
|
||||
const invoice = await prisma.billingInvoice.findFirst({
|
||||
where: { id: invoiceId, tenantId: session.tenantId },
|
||||
include: {
|
||||
tenant: { select: { name: true, slug: true } },
|
||||
plan: { select: { name: true, code: true } }
|
||||
}
|
||||
});
|
||||
|
||||
if (!invoice) {
|
||||
redirect("/billing/history?error=invoice_not_found");
|
||||
}
|
||||
|
||||
return (
|
||||
<ShellPage shell="admin" title="Invoice Detail" description="Ringkasan invoice per tenant.">
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<SectionCard title="Invoice summary">
|
||||
<div className="space-y-2 text-sm text-on-surface-variant">
|
||||
<p>
|
||||
<strong className="text-on-surface">Invoice:</strong> {invoice.invoiceNumber}
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-on-surface">Tenant:</strong>{" "}
|
||||
<Link href="/billing/history" className="text-brand hover:underline">
|
||||
{invoice.tenant.name} ({invoice.tenant.slug})
|
||||
</Link>
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-on-surface">Plan:</strong> {invoice.plan.name} ({invoice.plan.code})
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-on-surface">Period:</strong> {formatDate(invoice.periodStart)} - {formatDate(invoice.periodEnd)}
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-on-surface">Subtotal:</strong> {formatMoney(invoice.subtotal)} | Tax: {formatMoney(invoice.taxAmount)}
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-on-surface">Total:</strong> {formatMoney(invoice.totalAmount)}
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-on-surface">Status:</strong> <Badge tone={statusTone(invoice.paymentStatus)}>{invoice.paymentStatus}</Badge>
|
||||
</p>
|
||||
</div>
|
||||
</SectionCard>
|
||||
<SectionCard title="Timeline">
|
||||
<div className="space-y-2 text-sm text-on-surface-variant">
|
||||
<p>
|
||||
<strong className="text-on-surface">Issued:</strong> {formatDate(invoice.createdAt)}
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-on-surface">Due date:</strong> {formatDate(invoice.dueDate)}
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-on-surface">Paid at:</strong> {formatDate(invoice.paidAt)}
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-on-surface">Updated:</strong> {formatDate(invoice.updatedAt)}
|
||||
</p>
|
||||
</div>
|
||||
</SectionCard>
|
||||
</div>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
13
app/billing/page.tsx
Normal file
13
app/billing/page.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { DashboardPlaceholder } from "@/components/placeholders";
|
||||
import { getDashboardData } from "@/lib/platform-data";
|
||||
|
||||
export default async function BillingPage() {
|
||||
const data = await getDashboardData();
|
||||
|
||||
return (
|
||||
<ShellPage shell="admin" title="Billing & Subscription" description="Current plan, quota usage, dan billing history.">
|
||||
<DashboardPlaceholder stats={data.stats} priorityQueue={data.priorityQueue} />
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
71
app/campaigns/[campaignId]/page.tsx
Normal file
71
app/campaigns/[campaignId]/page.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { SectionCard } from "@/components/ui";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export default async function CampaignDetailPage({ params }: { params: Promise<{ campaignId: string }> }) {
|
||||
const { campaignId } = await params;
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const campaign = await prisma.broadcastCampaign.findFirst({
|
||||
where: { id: campaignId, tenantId: session.tenantId },
|
||||
include: { channel: true, template: true, segment: true, recipients: { include: { contact: true } } }
|
||||
});
|
||||
if (!campaign) {
|
||||
redirect("/campaigns?error=campaign_not_found");
|
||||
}
|
||||
|
||||
return (
|
||||
<ShellPage
|
||||
shell="admin"
|
||||
title="Campaign Detail"
|
||||
description="Ringkasan total sent, delivered, read, failed, dan breakdown delivery."
|
||||
>
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<SectionCard title="Campaign info">
|
||||
<p className="text-sm text-on-surface-variant">
|
||||
<strong>Nama:</strong> {campaign.name}
|
||||
</p>
|
||||
<p className="text-sm text-on-surface-variant">
|
||||
<strong>Template:</strong> {campaign.template.name}
|
||||
</p>
|
||||
<p className="text-sm text-on-surface-variant">
|
||||
<strong>Channel:</strong> {campaign.channel.channelName}
|
||||
</p>
|
||||
<p className="text-sm text-on-surface-variant">
|
||||
<strong>Audience:</strong> {campaign.audienceType}
|
||||
</p>
|
||||
<p className="text-sm text-on-surface-variant">
|
||||
<strong>Segment:</strong> {campaign.segment ? campaign.segment.name : "-"}
|
||||
</p>
|
||||
</SectionCard>
|
||||
<SectionCard title="Delivery summary">
|
||||
<p className="text-sm text-on-surface-variant">
|
||||
Total recipients: <strong>{campaign.totalRecipients}</strong>
|
||||
</p>
|
||||
<p className="text-sm text-on-surface-variant">
|
||||
Sent: <strong>{campaign.totalSent}</strong>
|
||||
</p>
|
||||
<p className="text-sm text-on-surface-variant">
|
||||
Delivered: <strong>{campaign.totalDelivered}</strong>
|
||||
</p>
|
||||
<p className="text-sm text-on-surface-variant">
|
||||
Failed: <strong>{campaign.totalFailed}</strong>
|
||||
</p>
|
||||
<p className="text-sm text-on-surface-variant">
|
||||
Read: <strong>{campaign.totalRead}</strong>
|
||||
</p>
|
||||
<Link href={`/campaigns/${campaign.id}/recipients`} className="mt-4 inline-block text-brand">
|
||||
Lihat recipients
|
||||
</Link>
|
||||
</SectionCard>
|
||||
</div>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
45
app/campaigns/[campaignId]/recipients/page.tsx
Normal file
45
app/campaigns/[campaignId]/recipients/page.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { TablePlaceholder } from "@/components/placeholders";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export default async function CampaignRecipientsPage({ params }: { params: Promise<{ campaignId: string }> }) {
|
||||
const { campaignId } = await params;
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const campaign = await prisma.broadcastCampaign.findFirst({
|
||||
where: { id: campaignId, tenantId: session.tenantId }
|
||||
});
|
||||
if (!campaign) {
|
||||
redirect("/campaigns?error=campaign_not_found");
|
||||
}
|
||||
|
||||
const recipients = await prisma.campaignRecipient.findMany({
|
||||
where: { campaignId },
|
||||
include: { contact: true },
|
||||
orderBy: { createdAt: "asc" }
|
||||
});
|
||||
|
||||
return (
|
||||
<ShellPage shell="admin" title="Campaign Recipients" description="Status per recipient dan failure reason.">
|
||||
<TablePlaceholder
|
||||
title="Recipient list"
|
||||
columns={["Contact", "Phone", "Status", "Attempt", "Retry At", "Failure reason", "Sent at"]}
|
||||
rows={recipients.map((recipient) => [
|
||||
recipient.contact?.fullName || "-",
|
||||
recipient.phoneNumber,
|
||||
recipient.sendStatus,
|
||||
recipient.sendAttempts,
|
||||
recipient.nextRetryAt ? new Date(recipient.nextRetryAt).toLocaleString() : "-",
|
||||
recipient.failureReason || "-",
|
||||
recipient.sentAt ? recipient.sentAt.toLocaleString() : "-"
|
||||
])}
|
||||
/>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
87
app/campaigns/new/page.tsx
Normal file
87
app/campaigns/new/page.tsx
Normal file
@ -0,0 +1,87 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { Button, SectionCard } from "@/components/ui";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { createCampaign } from "@/lib/admin-crud";
|
||||
import { CampaignAudienceType } from "@prisma/client";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export default async function NewCampaignPage({
|
||||
searchParams
|
||||
}: {
|
||||
searchParams?: Promise<{ error?: string }>;
|
||||
}) {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const channels = await prisma.channel.findMany({ where: { tenantId: session.tenantId }, orderBy: { channelName: "asc" } });
|
||||
const templates = await prisma.messageTemplate.findMany({ where: { tenantId: session.tenantId }, orderBy: { createdAt: "desc" } });
|
||||
const segments = await prisma.contactSegment.findMany({ where: { tenantId: session.tenantId }, orderBy: { name: "asc" } });
|
||||
|
||||
const params = await (searchParams ?? Promise.resolve({ error: undefined }));
|
||||
const err = params.error;
|
||||
const errorMessage =
|
||||
err === "missing_fields" ? "Isi semua kolom wajib." : err === "invalid_channel" ? "Channel tidak valid." : err === "invalid_template" ? "Template tidak valid." : null;
|
||||
|
||||
return (
|
||||
<ShellPage shell="admin" title="Create Campaign" description="Stepper metadata, template, audience, dan scheduling.">
|
||||
<SectionCard title="Campaign setup">
|
||||
<form action={createCampaign} className="grid gap-4 md:max-w-3xl">
|
||||
{errorMessage ? <p className="rounded-xl border border-warning/30 bg-warning/10 p-3 text-sm text-warning">{errorMessage}</p> : null}
|
||||
<input required name="name" className="rounded-xl border border-line px-4 py-3" placeholder="Campaign name" />
|
||||
<label className="md:col-span-2 block text-sm">
|
||||
<span className="text-on-surface-variant">Channel</span>
|
||||
<select name="channelId" required className="mt-2 w-full rounded-xl border border-line px-4 py-3">
|
||||
<option value="">Pilih channel</option>
|
||||
{channels.map((channel) => (
|
||||
<option key={channel.id} value={channel.id}>
|
||||
{channel.channelName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="md:col-span-2 block text-sm">
|
||||
<span className="text-on-surface-variant">Template</span>
|
||||
<select name="templateId" required className="mt-2 w-full rounded-xl border border-line px-4 py-3">
|
||||
<option value="">Pilih template</option>
|
||||
{templates.map((template) => (
|
||||
<option key={template.id} value={template.id}>
|
||||
{template.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="md:col-span-2 block text-sm">
|
||||
<span className="text-on-surface-variant">Audience</span>
|
||||
<select name="audienceType" required defaultValue={CampaignAudienceType.MANUAL} className="mt-2 w-full rounded-xl border border-line px-4 py-3">
|
||||
<option value={CampaignAudienceType.SEGMENT}>Segment</option>
|
||||
<option value={CampaignAudienceType.IMPORT}>Import</option>
|
||||
<option value={CampaignAudienceType.MANUAL}>Manual</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="md:col-span-2 block text-sm">
|
||||
<span className="text-on-surface-variant">Segment (hanya untuk audience segment)</span>
|
||||
<select name="segmentId" defaultValue="" className="mt-2 w-full rounded-xl border border-line px-4 py-3">
|
||||
<option value="">Pilih segment</option>
|
||||
{segments.map((segment) => (
|
||||
<option key={segment.id} value={segment.id}>
|
||||
{segment.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="md:col-span-2 block text-sm">
|
||||
<span className="text-on-surface-variant">Scheduled at</span>
|
||||
<input name="scheduledAt" type="datetime-local" className="mt-2 w-full rounded-xl border border-line px-4 py-3" />
|
||||
</label>
|
||||
<Button type="submit" className="md:col-span-2 w-full">
|
||||
Create campaign
|
||||
</Button>
|
||||
</form>
|
||||
</SectionCard>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
71
app/campaigns/page.tsx
Normal file
71
app/campaigns/page.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { PlaceholderActions, ShellPage } from "@/components/page-templates";
|
||||
import { TablePlaceholder } from "@/components/placeholders";
|
||||
import { dispatchCampaign, deleteCampaign } from "@/lib/admin-crud";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export default async function CampaignsPage({
|
||||
searchParams
|
||||
}: {
|
||||
searchParams?: Promise<{ error?: string }>;
|
||||
}) {
|
||||
const params = await (searchParams ?? Promise.resolve({ error: undefined }));
|
||||
const session = await getSession();
|
||||
const campaigns = session
|
||||
? await prisma.broadcastCampaign.findMany({
|
||||
where: { tenantId: session.tenantId },
|
||||
include: { channel: true },
|
||||
orderBy: { createdAt: "desc" }
|
||||
})
|
||||
: [];
|
||||
const error = params.error;
|
||||
const infoMessage =
|
||||
error === "campaign_not_found" ? "Campaign tidak ditemukan." : error === "missing_fields" ? "Lengkapi data campaign." : null;
|
||||
const campaignErrorMessage =
|
||||
error === "no_recipients" ? "Campaign tidak punya recipient (audience kosong)." : error === "campaign_not_ready" ? "Campaign tidak bisa dikirim dalam status ini." : null;
|
||||
|
||||
return (
|
||||
<ShellPage
|
||||
shell="admin"
|
||||
title="Campaigns"
|
||||
description="List campaign broadcast, ringkasan status, dan akses ke flow pembuatan campaign."
|
||||
actions={<PlaceholderActions primaryHref="/campaigns/new" primaryLabel="Create campaign" />}
|
||||
>
|
||||
{infoMessage ? <p className="rounded-xl border border-warning/30 bg-warning/10 p-3 text-sm text-warning">{infoMessage}</p> : null}
|
||||
<TablePlaceholder
|
||||
title="Campaign list"
|
||||
columns={["Campaign", "Channel", "Audience", "Status", "Scheduled", "Actions"]}
|
||||
rows={campaigns.map((campaign) => [
|
||||
campaign.name,
|
||||
campaign.channel.channelName,
|
||||
campaign.audienceType,
|
||||
campaign.status,
|
||||
campaign.scheduledAt ? new Date(campaign.scheduledAt).toLocaleDateString() : "Not scheduled",
|
||||
<div key={campaign.id} className="flex flex-wrap gap-2">
|
||||
<Link href={`/campaigns/${campaign.id}`} className="text-brand hover:underline">
|
||||
Detail
|
||||
</Link>
|
||||
<Link href={`/campaigns/${campaign.id}/recipients`} className="text-brand hover:underline">
|
||||
Recipients
|
||||
</Link>
|
||||
<form action={dispatchCampaign} className="inline">
|
||||
<input type="hidden" name="campaignId" value={campaign.id} />
|
||||
<button type="submit" className="text-success hover:underline">
|
||||
Dispatch
|
||||
</button>
|
||||
</form>
|
||||
<form action={deleteCampaign} className="inline">
|
||||
<input type="hidden" name="campaignId" value={campaign.id} />
|
||||
<button type="submit" className="text-danger hover:underline">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
])}
|
||||
/>
|
||||
{campaignErrorMessage ? <p className="mt-4 rounded-xl border border-warning/30 bg-warning/10 p-3 text-sm text-warning">{campaignErrorMessage}</p> : null}
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
119
app/campaigns/review/page.tsx
Normal file
119
app/campaigns/review/page.tsx
Normal file
@ -0,0 +1,119 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { Button, SectionCard } from "@/components/ui";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
function formatDate(date: Date | null | undefined) {
|
||||
if (!date) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat("id-ID", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export default async function CampaignReviewPage({
|
||||
searchParams
|
||||
}: {
|
||||
searchParams?: Promise<{ campaignId?: string; error?: string }>;
|
||||
}) {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const query = await (searchParams ?? Promise.resolve<{ campaignId?: string; error?: string }>({}));
|
||||
const campaignId = query.campaignId;
|
||||
|
||||
const campaign = campaignId
|
||||
? await prisma.broadcastCampaign.findFirst({
|
||||
where: {
|
||||
id: campaignId,
|
||||
tenantId: session.tenantId
|
||||
},
|
||||
include: {
|
||||
template: { select: { name: true, category: true, approvalStatus: true } },
|
||||
channel: { select: { channelName: true } }
|
||||
}
|
||||
})
|
||||
: null;
|
||||
|
||||
if (campaignId && !campaign) {
|
||||
redirect("/campaigns/review?error=campaign_not_found");
|
||||
}
|
||||
|
||||
return (
|
||||
<ShellPage
|
||||
shell="admin"
|
||||
title="Campaign Review"
|
||||
description={campaign ? "Review draft campaign sebelum di-queue untuk pengiriman." : "Belum ada campaign yang dipilih untuk review."}
|
||||
>
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<SectionCard title="Campaign summary">
|
||||
{campaign ? (
|
||||
<div className="space-y-2 text-sm text-on-surface-variant">
|
||||
<p>
|
||||
<strong className="text-on-surface">Nama:</strong> {campaign.name}
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-on-surface">Template:</strong> {campaign.template.name} ({campaign.template.category}) •{" "}
|
||||
{campaign.template.approvalStatus}
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-on-surface">Channel:</strong> {campaign.channel.channelName}
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-on-surface">Audience:</strong> {campaign.audienceType}
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-on-surface">Scheduled:</strong> {formatDate(campaign.scheduledAt)}
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-on-surface">Status:</strong> {campaign.status}
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-on-surface">Recipient estimate:</strong> {campaign.totalRecipients}
|
||||
</p>
|
||||
<p className="mt-2">Estimasi sukses: {(campaign.totalRecipients * 0.82).toFixed(0)} kontak</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-on-surface-variant">Pilih campaign dari halaman campaign list untuk menampilkan detail review.</p>
|
||||
)}
|
||||
</SectionCard>
|
||||
<SectionCard title="Review checks">
|
||||
{campaign ? (
|
||||
<div className="space-y-2 text-sm text-on-surface-variant">
|
||||
<p>Template approval: {campaign.template.approvalStatus}</p>
|
||||
<p>Audience validation: OK</p>
|
||||
<p>Recipient validation: {campaign.totalRecipients > 0 ? "PASS" : "No recipients"}</p>
|
||||
<p>Channel availability: Available</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-on-surface-variant">Tidak ada pemeriksaan yang berjalan karena campaign belum dipilih.</p>
|
||||
)}
|
||||
</SectionCard>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
{campaign ? (
|
||||
<Button href={`/campaigns/${campaign.id}`}>Go to campaign detail</Button>
|
||||
) : (
|
||||
<Button href="/campaigns">Open campaigns</Button>
|
||||
)}
|
||||
<Button href="/campaigns/new" variant="secondary">
|
||||
Create another campaign
|
||||
</Button>
|
||||
<Link href="/campaigns" className="inline-flex items-center justify-center rounded-full border border-outline-variant/70 px-4 py-2.5 text-sm font-semibold font-headline text-on-surface transition hover:bg-surface-container-low">
|
||||
Back
|
||||
</Link>
|
||||
</div>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
89
app/contacts/[contactId]/edit/page.tsx
Normal file
89
app/contacts/[contactId]/edit/page.tsx
Normal file
@ -0,0 +1,89 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { Button, SectionCard } from "@/components/ui";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { updateContact } from "@/lib/admin-crud";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export default async function EditContactPage({ params }: { params: Promise<{ contactId: string }> }) {
|
||||
const { contactId } = await params;
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const contact = await prisma.contact.findFirst({
|
||||
where: { id: contactId, tenantId: session.tenantId },
|
||||
include: { contactTags: { include: { tag: true } }, channel: true }
|
||||
});
|
||||
if (!contact) {
|
||||
redirect("/contacts?error=contact_not_found");
|
||||
}
|
||||
|
||||
const channels = await prisma.channel.findMany({
|
||||
where: { tenantId: session.tenantId },
|
||||
orderBy: { channelName: "asc" }
|
||||
});
|
||||
|
||||
const tags = contact.contactTags.map((item) => item.tag.name).join(", ");
|
||||
|
||||
return (
|
||||
<ShellPage shell="admin" title="Edit Contact" description="Form update data contact.">
|
||||
<SectionCard title="Contact form">
|
||||
<form action={updateContact} className="grid gap-4 md:max-w-2xl md:grid-cols-2">
|
||||
<input type="hidden" name="contactId" value={contact.id} />
|
||||
<input required name="fullName" defaultValue={contact.fullName} className="rounded-xl border border-line px-4 py-3" />
|
||||
<input
|
||||
required
|
||||
name="phoneNumber"
|
||||
defaultValue={contact.phoneNumber}
|
||||
className="rounded-xl border border-line px-4 py-3"
|
||||
/>
|
||||
<input name="email" defaultValue={contact.email ?? ""} className="rounded-xl border border-line px-4 py-3" />
|
||||
<input
|
||||
name="countryCode"
|
||||
defaultValue={contact.countryCode ?? ""}
|
||||
className="rounded-xl border border-line px-4 py-3"
|
||||
/>
|
||||
<label className="md:col-span-2 block text-sm">
|
||||
<span className="text-on-surface-variant">Channel</span>
|
||||
<select name="channelId" defaultValue={contact.channelId || ""} className="mt-2 w-full rounded-xl border border-line px-4 py-3">
|
||||
<option value="">Tidak terkait channel</option>
|
||||
{channels.map((channel) => (
|
||||
<option key={channel.id} value={channel.id}>
|
||||
{channel.channelName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="md:col-span-2 block text-sm">
|
||||
<span className="text-on-surface-variant">Tags</span>
|
||||
<input name="tags" defaultValue={tags} className="mt-2 w-full rounded-xl border border-line px-4 py-3" />
|
||||
</label>
|
||||
<label className="md:col-span-2 block text-sm">
|
||||
<span className="text-on-surface-variant">Opt-in status</span>
|
||||
<select
|
||||
name="optInStatus"
|
||||
defaultValue={contact.optInStatus}
|
||||
className="mt-2 w-full rounded-xl border border-line px-4 py-3"
|
||||
>
|
||||
<option value="UNKNOWN">Unknown</option>
|
||||
<option value="OPTED_IN">Opted in</option>
|
||||
<option value="OPTED_OUT">Opted out</option>
|
||||
</select>
|
||||
</label>
|
||||
<div className="md:col-span-2 flex gap-3">
|
||||
<Button type="submit" className="rounded-xl">
|
||||
Save changes
|
||||
</Button>
|
||||
<Link href={`/contacts/${contact.id}`} className="text-on-surface-variant hover:underline">
|
||||
Cancel
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</SectionCard>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
73
app/contacts/[contactId]/page.tsx
Normal file
73
app/contacts/[contactId]/page.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { SectionCard } from "@/components/ui";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export default async function ContactDetailPage({ params }: { params: Promise<{ contactId: string }> }) {
|
||||
const { contactId } = await params;
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const contact = await prisma.contact.findFirst({
|
||||
where: { id: contactId, tenantId: session.tenantId },
|
||||
include: {
|
||||
contactTags: { include: { tag: true } },
|
||||
conversations: true,
|
||||
channel: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!contact) {
|
||||
redirect("/contacts?error=contact_not_found");
|
||||
}
|
||||
|
||||
return (
|
||||
<ShellPage
|
||||
shell="admin"
|
||||
title="Contact Detail"
|
||||
description="Profile, tags, conversation history, dan campaign history."
|
||||
actions={<Link href={`/contacts/${contactId}/edit`}>Edit contact</Link>}
|
||||
>
|
||||
<div className="grid gap-6 xl:grid-cols-[340px_1fr]">
|
||||
<SectionCard title="Profile card">
|
||||
<p className="text-sm text-on-surface-variant">
|
||||
<strong>Nama:</strong> {contact.fullName}
|
||||
</p>
|
||||
<p className="text-sm text-on-surface-variant">
|
||||
<strong>Phone:</strong> {contact.phoneNumber}
|
||||
</p>
|
||||
<p className="text-sm text-on-surface-variant">
|
||||
<strong>Email:</strong> {contact.email || "-"}
|
||||
</p>
|
||||
<p className="text-sm text-on-surface-variant">
|
||||
<strong>Channel:</strong> {contact.channel?.channelName || "Unset"}
|
||||
</p>
|
||||
<p className="text-sm text-on-surface-variant">
|
||||
<strong>Opt-in:</strong> {contact.optInStatus}
|
||||
</p>
|
||||
<p className="text-sm text-on-surface-variant">
|
||||
<strong>Last interaction:</strong> {contact.lastInteractionAt?.toLocaleString() || "-"}
|
||||
</p>
|
||||
<p className="mt-3 text-xs text-outline">
|
||||
<Link href="/contacts" className="text-on-surface-variant hover:underline">
|
||||
Kembali ke daftar
|
||||
</Link>
|
||||
</p>
|
||||
</SectionCard>
|
||||
<SectionCard title="History">
|
||||
<p className="text-sm text-on-surface-variant">
|
||||
Total conversations: <strong>{contact.conversations.length}</strong>
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-on-surface-variant">
|
||||
Tags: {contact.contactTags.map((item) => item.tag.name).join(", ") || "-"}
|
||||
</p>
|
||||
</SectionCard>
|
||||
</div>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
73
app/contacts/export/page.tsx
Normal file
73
app/contacts/export/page.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { Button, SectionCard } from "@/components/ui";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function ExportContactsPage() {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
if (session.role === "agent") {
|
||||
redirect("/unauthorized");
|
||||
}
|
||||
|
||||
const [segments, tags, count, lastUpdated] = await Promise.all([
|
||||
prisma.contactSegment.findMany({
|
||||
where: { tenantId: session.tenantId },
|
||||
orderBy: { name: "asc" },
|
||||
select: { id: true, name: true, _count: { select: { members: true } } }
|
||||
}),
|
||||
prisma.tag.findMany({ where: { tenantId: session.tenantId }, select: { name: true }, orderBy: { name: "asc" } }),
|
||||
prisma.contact.count({ where: { tenantId: session.tenantId } }),
|
||||
prisma.contact.findFirst({
|
||||
where: { tenantId: session.tenantId },
|
||||
orderBy: { updatedAt: "desc" },
|
||||
select: { updatedAt: true }
|
||||
})
|
||||
]);
|
||||
|
||||
const fields = ["fullName", "phoneNumber", "email", "countryCode", "optInStatus", "createdAt", "updatedAt"];
|
||||
|
||||
return (
|
||||
<ShellPage shell="admin" title="Export Contacts" description="Atur field dan filter sebelum export.">
|
||||
<SectionCard title="Export options">
|
||||
<div className="grid gap-4 md:max-w-xl">
|
||||
<p className="rounded-xl border border-line bg-surface-container p-3 text-sm text-on-surface-variant">
|
||||
Total kontak: {count} • Last updated: {lastUpdated?.updatedAt ? new Intl.DateTimeFormat("id-ID").format(lastUpdated.updatedAt) : "-"}
|
||||
</p>
|
||||
<input className="rounded-xl border border-line px-4 py-3" placeholder="Filter tags / segments" />
|
||||
<select className="rounded-xl border border-line px-4 py-3" defaultValue="">
|
||||
<option value="">Select segment (optional)</option>
|
||||
{segments.map((segment) => (
|
||||
<option key={segment.id} value={segment.id}>
|
||||
{segment.name} ({segment._count.members})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="text-sm text-on-surface-variant">
|
||||
<span>Fields to export</span>
|
||||
<select className="mt-2 w-full rounded-xl border border-line px-4 py-3">
|
||||
{fields.map((field) => (
|
||||
<option key={field} value={field}>
|
||||
{field}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<p className="text-sm text-on-surface-variant">Available tags: {tags.length ? tags.map((tag) => tag.name).join(", ") : "-"}</p>
|
||||
<div>
|
||||
<Button href="/contacts">Export</Button>
|
||||
</div>
|
||||
<div>
|
||||
{segments.length === 0 ? (
|
||||
<p className="text-xs text-warning">Tambahkan segment untuk filtering export yang lebih presisi.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
95
app/contacts/import/page.tsx
Normal file
95
app/contacts/import/page.tsx
Normal file
@ -0,0 +1,95 @@
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { SectionCard } from "@/components/ui";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
function formatDate(value: Date | null | undefined) {
|
||||
if (!value) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat("id-ID", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric"
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
export default async function ImportContactsPage() {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
if (session.role === "agent") {
|
||||
redirect("/unauthorized");
|
||||
}
|
||||
|
||||
const [channels, tags, sampleContacts] = await Promise.all([
|
||||
prisma.channel.findMany({
|
||||
where: { tenantId: session.tenantId },
|
||||
select: { id: true, channelName: true, provider: true }
|
||||
}),
|
||||
prisma.tag.findMany({
|
||||
where: { tenantId: session.tenantId },
|
||||
orderBy: { name: "asc" }
|
||||
}),
|
||||
prisma.contact.findMany({
|
||||
where: { tenantId: session.tenantId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 5,
|
||||
select: { id: true, fullName: true, phoneNumber: true, createdAt: true }
|
||||
})
|
||||
]);
|
||||
|
||||
return (
|
||||
<ShellPage shell="admin" title="Import Contacts" description="Upload CSV dan mapping ke field contact sesuai channel tenant.">
|
||||
<div className="grid gap-6 xl:grid-cols-3">
|
||||
<SectionCard title="Step 1" description="Upload CSV">
|
||||
<p className="text-sm text-on-surface-variant">
|
||||
Pilih file CSV dari browser dan pastikan header minimal: nama, no_telepon, email.
|
||||
</p>
|
||||
<div className="mt-3 rounded-xl border border-line bg-surface-container p-3">
|
||||
<input type="file" className="w-full text-sm" />
|
||||
</div>
|
||||
</SectionCard>
|
||||
<SectionCard title="Step 2" description="Field mapping">
|
||||
<div className="space-y-2 text-sm text-on-surface-variant">
|
||||
<p>Gunakan channel yang aktif:</p>
|
||||
{channels.length === 0 ? (
|
||||
<p className="text-sm text-warning">Tenant belum memiliki channel. Tambahkan channel dulu.</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{channels.map((channel) => (
|
||||
<li key={channel.id} className="rounded-xl border border-line bg-surface-container p-3">
|
||||
{channel.channelName} • {channel.provider}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
<SectionCard title="Step 3" description="Validation preview">
|
||||
<div className="space-y-2 text-sm">
|
||||
<p className="text-on-surface-variant">Baris terakhir di tenant: {sampleContacts.length} contoh terbaru.</p>
|
||||
{sampleContacts.length === 0 ? (
|
||||
<p className="text-warning text-sm">Belum ada contact sebelumnya.</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{sampleContacts.map((contact) => (
|
||||
<li key={contact.id} className="rounded-xl border border-line bg-surface-container p-3">
|
||||
<p className="font-medium text-ink">{contact.fullName}</p>
|
||||
<p className="text-xs text-outline">{contact.phoneNumber}</p>
|
||||
<p className="text-xs text-outline">Created: {formatDate(contact.createdAt)}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<p className="text-xs text-on-surface-variant">Available tags: {tags.length > 0 ? tags.map((tag) => tag.name).join(", ") : "Belum ada tag."}</p>
|
||||
</div>
|
||||
</SectionCard>
|
||||
</div>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
69
app/contacts/new/page.tsx
Normal file
69
app/contacts/new/page.tsx
Normal file
@ -0,0 +1,69 @@
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { Button, SectionCard } from "@/components/ui";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { createContact } from "@/lib/admin-crud";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export default async function NewContactPage({
|
||||
searchParams
|
||||
}: {
|
||||
searchParams?: Promise<{ error?: string }>;
|
||||
}) {
|
||||
const params = await (searchParams ?? Promise.resolve({ error: undefined }));
|
||||
const error = params?.error;
|
||||
const errorMessage = error === "missing_fields" ? "Nama dan nomor wajib diisi." : error === "invalid_channel" ? "Channel tidak valid." : null;
|
||||
|
||||
const session = await getSession();
|
||||
const channels = session
|
||||
? await prisma.channel.findMany({
|
||||
where: { tenantId: session.tenantId },
|
||||
orderBy: { channelName: "asc" }
|
||||
})
|
||||
: [];
|
||||
|
||||
return (
|
||||
<ShellPage shell="admin" title="Create Contact" description="Form tambah contact manual untuk inbox dan broadcast.">
|
||||
<SectionCard title="Contact form">
|
||||
<form action={createContact} className="grid gap-4 md:max-w-2xl md:grid-cols-2">
|
||||
{errorMessage ? (
|
||||
<div className="md:col-span-2 rounded-xl border border-warning/30 bg-warning/10 p-3 text-sm text-warning">
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
<input required name="fullName" className="rounded-xl border border-line px-4 py-3" placeholder="Full name" />
|
||||
<input required name="phoneNumber" className="rounded-xl border border-line px-4 py-3" placeholder="Phone number" />
|
||||
<input name="email" className="rounded-xl border border-line px-4 py-3" placeholder="Email" />
|
||||
<input name="countryCode" className="rounded-xl border border-line px-4 py-3" placeholder="Country code" />
|
||||
<label className="md:col-span-2 block text-sm">
|
||||
<span className="text-on-surface-variant">Channel</span>
|
||||
<select name="channelId" defaultValue="" className="mt-2 w-full rounded-xl border border-line px-4 py-3">
|
||||
<option value="">Tidak terkait channel</option>
|
||||
{channels.map((channel) => (
|
||||
<option key={channel.id} value={channel.id}>
|
||||
{channel.channelName} ({channel.displayPhoneNumber})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="md:col-span-2 block text-sm">
|
||||
<span className="text-on-surface-variant">Tags (pisah koma)</span>
|
||||
<input name="tags" className="mt-2 w-full rounded-xl border border-line px-4 py-3" placeholder="Enterprise, Hot Lead" />
|
||||
</label>
|
||||
<label className="md:col-span-2 block text-sm">
|
||||
<span className="text-on-surface-variant">Opt-in status</span>
|
||||
<select name="optInStatus" defaultValue="UNKNOWN" className="mt-2 w-full rounded-xl border border-line px-4 py-3">
|
||||
<option value="UNKNOWN">Unknown</option>
|
||||
<option value="OPTED_IN">Opted in</option>
|
||||
<option value="OPTED_OUT">Opted out</option>
|
||||
</select>
|
||||
</label>
|
||||
<div className="md:col-span-2">
|
||||
<Button type="submit" className="w-full">
|
||||
Save contact
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</SectionCard>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
60
app/contacts/page.tsx
Normal file
60
app/contacts/page.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { PlaceholderActions, ShellPage } from "@/components/page-templates";
|
||||
import { ContactSummaryCards, TablePlaceholder } from "@/components/placeholders";
|
||||
import { getContactsData } from "@/lib/platform-data";
|
||||
import { deleteContact } from "@/lib/admin-crud";
|
||||
|
||||
export default async function ContactsPage({
|
||||
searchParams
|
||||
}: {
|
||||
searchParams?: Promise<{ error?: string }>;
|
||||
}) {
|
||||
const params = await (searchParams ?? Promise.resolve({ error: undefined }));
|
||||
const contacts = await getContactsData();
|
||||
const error = params.error;
|
||||
const infoMessage = error === "contact_not_found"
|
||||
? "Contact tidak ditemukan."
|
||||
: error === "contact_has_conversations"
|
||||
? "Contact tidak bisa dihapus karena sudah punya riwayat percakapan."
|
||||
: error === "invalid_channel"
|
||||
? "Channel tidak valid."
|
||||
: null;
|
||||
|
||||
return (
|
||||
<ShellPage
|
||||
shell="admin"
|
||||
title="Contacts"
|
||||
description="Daftar contact, filter, import/export, dan akses ke detail screen."
|
||||
actions={<PlaceholderActions primaryHref="/contacts/new" primaryLabel="Add contact" secondaryHref="/contacts/import" secondaryLabel="Import CSV" />}
|
||||
>
|
||||
<ContactSummaryCards contacts={contacts} />
|
||||
{infoMessage ? <p className="rounded-xl border border-warning/30 bg-warning/10 p-3 text-sm text-warning">{infoMessage}</p> : null}
|
||||
<TablePlaceholder
|
||||
title="Contact list"
|
||||
columns={["Name", "Phone", "Tags", "Last Interaction", "Opt-in", "Actions"]}
|
||||
rows={contacts.map((contact) => [
|
||||
contact.fullName,
|
||||
contact.phone,
|
||||
contact.tags.join(", "),
|
||||
contact.lastInteraction,
|
||||
contact.optInStatus,
|
||||
<div key={contact.id} className="flex flex-wrap gap-2">
|
||||
<Link href={`/contacts/${contact.id}`} className="text-brand hover:underline">
|
||||
Detail
|
||||
</Link>
|
||||
<Link href={`/contacts/${contact.id}/edit`} className="text-brand hover:underline">
|
||||
Edit
|
||||
</Link>
|
||||
<form action={deleteContact} className="inline">
|
||||
<input type="hidden" name="contactId" value={contact.id} />
|
||||
<button type="submit" className="text-danger hover:underline">
|
||||
Hapus
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
])}
|
||||
/>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
108
app/contacts/segments/[segmentId]/page.tsx
Normal file
108
app/contacts/segments/[segmentId]/page.tsx
Normal file
@ -0,0 +1,108 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { TablePlaceholder } from "@/components/placeholders";
|
||||
import { SectionCard } from "@/components/ui";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
function summarizeRules(value: string | null) {
|
||||
if (!value) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return parsed.description ? String(parsed.description) : JSON.stringify(parsed);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(date: Date | null) {
|
||||
if (!date) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat("id-ID", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric"
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export default async function SegmentDetailPage({ params }: { params: Promise<{ segmentId: string }> }) {
|
||||
const { segmentId } = await params;
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const segment = await prisma.contactSegment.findFirst({
|
||||
where: { id: segmentId, tenantId: session.tenantId },
|
||||
include: {
|
||||
_count: {
|
||||
select: { members: true }
|
||||
},
|
||||
members: {
|
||||
include: { contact: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 20
|
||||
},
|
||||
campaigns: {
|
||||
select: { id: true, name: true, status: true, updatedAt: true },
|
||||
orderBy: { updatedAt: "desc" }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!segment) {
|
||||
redirect("/contacts/segments?error=segment_not_found");
|
||||
}
|
||||
|
||||
return (
|
||||
<ShellPage
|
||||
shell="admin"
|
||||
title="Segment Detail"
|
||||
description="Metadata segment, preview members, dan campaign yang memakai segment ini."
|
||||
actions={<Link href="/contacts/segments">Back to segments</Link>}
|
||||
>
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<SectionCard title="Segment metadata">
|
||||
<p className="text-sm text-on-surface-variant">Nama: {segment.name}</p>
|
||||
<p className="text-sm text-on-surface-variant">Rule: {summarizeRules(segment.description ?? segment.rulesJson)}</p>
|
||||
<p className="text-sm text-on-surface-variant">Members: {segment._count.members}</p>
|
||||
<p className="text-sm text-on-surface-variant">Updated: {formatDate(segment.updatedAt)}</p>
|
||||
</SectionCard>
|
||||
<SectionCard title="Campaign usage">
|
||||
{segment.campaigns.length === 0 ? (
|
||||
<p className="text-sm text-on-surface-variant">Tidak ada campaign yang memakai segment ini.</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{segment.campaigns.map((campaign) => (
|
||||
<li key={campaign.id} className="rounded-xl border border-line bg-surface-container p-3">
|
||||
<Link href={`/campaigns/${campaign.id}`} className="text-brand hover:underline">
|
||||
{campaign.name}
|
||||
</Link>
|
||||
<p className="text-xs text-outline">Status: {campaign.status} • {formatDate(campaign.updatedAt)}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</SectionCard>
|
||||
</div>
|
||||
{segment.members.length > 0 ? (
|
||||
<TablePlaceholder
|
||||
title="Member preview"
|
||||
columns={["Contact", "Phone", "Added at"]}
|
||||
rows={segment.members.map((member) => [
|
||||
member.contact.fullName,
|
||||
member.contact.phoneNumber,
|
||||
formatDate(member.createdAt)
|
||||
])}
|
||||
/>
|
||||
) : null}
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
45
app/contacts/segments/new/page.tsx
Normal file
45
app/contacts/segments/new/page.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { Button, SectionCard } from "@/components/ui";
|
||||
import { createContactSegment } from "@/lib/admin-crud";
|
||||
import { getSession } from "@/lib/auth";
|
||||
|
||||
export default async function NewSegmentPage({
|
||||
searchParams
|
||||
}: {
|
||||
searchParams?: Promise<{ error?: string }>;
|
||||
}) {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const params = await (searchParams ?? Promise.resolve<{ error?: string }>({}));
|
||||
const error = params.error;
|
||||
const errorMessage = error === "missing_fields" ? "Nama segment wajib diisi." : null;
|
||||
|
||||
return (
|
||||
<ShellPage shell="admin" title="Create Segment" description="Rule builder sederhana untuk audience segmentation.">
|
||||
<SectionCard title="Segment rule">
|
||||
<form action={createContactSegment} className="grid gap-4 md:max-w-2xl">
|
||||
{errorMessage ? (
|
||||
<p className="rounded-xl border border-warning/30 bg-warning/10 p-3 text-sm text-warning">{errorMessage}</p>
|
||||
) : null}
|
||||
<input name="name" required className="rounded-xl border border-line px-4 py-3" placeholder="Segment name" />
|
||||
<label className="text-sm text-on-surface-variant">
|
||||
<span>Rules JSON / human-readable rules</span>
|
||||
<textarea
|
||||
name="rules"
|
||||
className="mt-2 min-h-32 w-full rounded-xl border border-line px-4 py-3"
|
||||
placeholder='Contoh: {"tags":["Enterprise"]} atau tulis deskripsi aturan di sini'
|
||||
/>
|
||||
</label>
|
||||
<div>
|
||||
<Button type="submit">Save segment</Button>
|
||||
</div>
|
||||
</form>
|
||||
</SectionCard>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
77
app/contacts/segments/page.tsx
Normal file
77
app/contacts/segments/page.tsx
Normal file
@ -0,0 +1,77 @@
|
||||
import { PlaceholderActions, ShellPage } from "@/components/page-templates";
|
||||
import { TablePlaceholder } from "@/components/placeholders";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import Link from "next/link";
|
||||
|
||||
function formatDate(date: Date | null | undefined) {
|
||||
if (!date) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat("id-ID", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric"
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function summarizeRules(raw: string | null) {
|
||||
if (!raw) {
|
||||
return "No rules defined";
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { description?: string };
|
||||
if (parsed.description) {
|
||||
return parsed.description;
|
||||
}
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
|
||||
return raw;
|
||||
}
|
||||
|
||||
export default async function SegmentsPage() {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return (
|
||||
<ShellPage shell="admin" title="Segments" description="Audience segment untuk kebutuhan broadcast campaign.">
|
||||
<p className="rounded-xl border border-line bg-surface-container p-3 text-sm text-on-surface-variant">Silakan login terlebih dahulu.</p>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
|
||||
const tenantId = session.tenantId;
|
||||
const segments = await prisma.contactSegment.findMany({
|
||||
where: { tenantId },
|
||||
include: {
|
||||
_count: {
|
||||
select: { members: true }
|
||||
}
|
||||
},
|
||||
orderBy: { updatedAt: "desc" }
|
||||
});
|
||||
|
||||
return (
|
||||
<ShellPage
|
||||
shell="admin"
|
||||
title="Segments"
|
||||
description="Audience segment untuk kebutuhan broadcast campaign."
|
||||
actions={<PlaceholderActions primaryHref="/contacts/segments/new" primaryLabel="Create segment" />}
|
||||
>
|
||||
<TablePlaceholder
|
||||
title="Segments list"
|
||||
columns={["Segment", "Rule summary", "Members", "Updated"]}
|
||||
rows={segments.map((segment) => [
|
||||
<Link key={`${segment.id}-name`} href={`/contacts/segments/${segment.id}`} className="text-brand hover:underline">
|
||||
{segment.name}
|
||||
</Link>,
|
||||
summarizeRules(segment.description ?? segment.rulesJson),
|
||||
String(segment._count.members),
|
||||
formatDate(segment.updatedAt)
|
||||
])}
|
||||
/>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
18
app/dashboard/page.tsx
Normal file
18
app/dashboard/page.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import { DashboardPlaceholder } from "@/components/placeholders";
|
||||
import { PlaceholderActions, ShellPage } from "@/components/page-templates";
|
||||
import { getDashboardData } from "@/lib/platform-data";
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const data = await getDashboardData();
|
||||
|
||||
return (
|
||||
<ShellPage
|
||||
shell="admin"
|
||||
title="Dashboard"
|
||||
description="Ringkasan operasional tenant untuk open conversations, workload agent, dan campaign snapshot."
|
||||
actions={<PlaceholderActions primaryHref="/inbox" primaryLabel="Open inbox" secondaryHref="/campaigns/new" secondaryLabel="Create campaign" />}
|
||||
>
|
||||
<DashboardPlaceholder stats={data.stats} priorityQueue={data.priorityQueue} />
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
165
app/forgot-password/page.tsx
Normal file
165
app/forgot-password/page.tsx
Normal file
@ -0,0 +1,165 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { AuthTokenType, UserStatus } from "@prisma/client";
|
||||
|
||||
import { Button, PageHeader, SectionCard } from "@/components/ui";
|
||||
import { createAuthToken, makeResetUrl } from "@/lib/auth-tokens";
|
||||
import { getLocale, getTranslator } from "@/lib/i18n";
|
||||
import { getRequestAuditContext, writeAuditTrail } from "@/lib/audit";
|
||||
import { consumeRateLimit } from "@/lib/rate-limit";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { sendTransactionalNotification } from "@/lib/notification";
|
||||
|
||||
async function requestPasswordReset(formData: FormData) {
|
||||
"use server";
|
||||
|
||||
const requestContext = await getRequestAuditContext();
|
||||
const rawEmail = formData.get("email");
|
||||
const email = typeof rawEmail === "string" ? rawEmail.trim().toLowerCase() : "";
|
||||
|
||||
const rateLimit = consumeRateLimit(requestContext.ipAddress || "unknown", {
|
||||
scope: "password_reset_request",
|
||||
limit: 6,
|
||||
windowMs: 15 * 60 * 1000
|
||||
});
|
||||
if (!rateLimit.allowed) {
|
||||
redirect("/forgot-password?error=rate_limited");
|
||||
}
|
||||
|
||||
if (!email) {
|
||||
redirect("/forgot-password?error=missing_email");
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email }
|
||||
});
|
||||
|
||||
if (user && user.status === UserStatus.ACTIVE) {
|
||||
const created = await createAuthToken({
|
||||
userId: user.id,
|
||||
tenantId: user.tenantId,
|
||||
tokenType: AuthTokenType.PASSWORD_RESET
|
||||
});
|
||||
const resetUrl = makeResetUrl(created.rawToken);
|
||||
const notificationResult = await sendTransactionalNotification({
|
||||
to: user.email,
|
||||
subject: "Reset password to continue your account access",
|
||||
text: `Gunakan tautan ini untuk mengatur ulang password: ${resetUrl}`,
|
||||
html: `<p>Gunakan tautan berikut untuk mengatur ulang password: <a href="${resetUrl}">${resetUrl}</a></p>`
|
||||
});
|
||||
|
||||
if (!notificationResult.ok) {
|
||||
await writeAuditTrail({
|
||||
tenantId: user.tenantId,
|
||||
actorUserId: user.id,
|
||||
entityType: "user",
|
||||
entityId: user.id,
|
||||
action: "password_reset_notified_failed",
|
||||
metadata: {
|
||||
email,
|
||||
reason: notificationResult.error,
|
||||
source: "web",
|
||||
provider: notificationResult.provider ?? null
|
||||
},
|
||||
ipAddress: requestContext.ipAddress,
|
||||
userAgent: requestContext.userAgent
|
||||
});
|
||||
}
|
||||
|
||||
await writeAuditTrail({
|
||||
tenantId: user.tenantId,
|
||||
actorUserId: user.id,
|
||||
entityType: "user",
|
||||
entityId: user.id,
|
||||
action: "password_reset_requested",
|
||||
metadata: {
|
||||
email,
|
||||
expiresAt: created.expiresAt.toISOString(),
|
||||
source: "web",
|
||||
notifyProvider: notificationResult?.provider ?? null,
|
||||
notifyQueued: notificationResult?.ok ?? false
|
||||
},
|
||||
ipAddress: requestContext.ipAddress,
|
||||
userAgent: requestContext.userAgent
|
||||
});
|
||||
if (notificationResult.ok === true && notificationResult.provider === "console") {
|
||||
console.log(`RESET_PASSWORD_LINK=${resetUrl}`);
|
||||
}
|
||||
} else if (user) {
|
||||
await writeAuditTrail({
|
||||
tenantId: user.tenantId,
|
||||
actorUserId: user.id,
|
||||
entityType: "user",
|
||||
entityId: user.id,
|
||||
action: "password_reset_denied",
|
||||
metadata: {
|
||||
email,
|
||||
status: user.status,
|
||||
source: "web"
|
||||
},
|
||||
ipAddress: requestContext.ipAddress,
|
||||
userAgent: requestContext.userAgent
|
||||
});
|
||||
}
|
||||
|
||||
// Always respond with generic success to avoid user enumeration.
|
||||
redirect("/forgot-password?success=sent");
|
||||
}
|
||||
|
||||
export default async function ForgotPasswordPage({
|
||||
searchParams
|
||||
}: {
|
||||
searchParams?: Promise<{ error?: string; success?: string }>;
|
||||
}) {
|
||||
const t = getTranslator(await getLocale());
|
||||
const params = await (searchParams ?? Promise.resolve({ error: undefined, success: undefined }));
|
||||
const error =
|
||||
params?.error === "missing_email"
|
||||
? t("login", "missing_email")
|
||||
: params?.error === "rate_limited"
|
||||
? t("login", "error_rate_limited")
|
||||
: null;
|
||||
|
||||
const success = params?.success === "sent";
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-background px-6 py-16">
|
||||
<div className="mx-auto w-full max-w-2xl rounded-[1.5rem] bg-surface-container-lowest p-4 shadow-card md:p-8">
|
||||
<PageHeader
|
||||
title={t("pages", "forgot_password")}
|
||||
description={t("pages", "reset_password")}
|
||||
actions={<Button href="/login" variant="secondary">{t("common", "back_to_login")}</Button>}
|
||||
/>
|
||||
<div className="mt-8">
|
||||
<SectionCard title={t("pages", "reset_password")}>
|
||||
{error ? (
|
||||
<p className="mb-4 rounded-xl border border-error-container bg-error-container p-3 text-sm text-on-error-container">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
{success ? (
|
||||
<p className="mb-4 rounded-xl border border-success/30 bg-success/10 p-3 text-sm text-success">
|
||||
{t("login", "forgot_success")}
|
||||
</p>
|
||||
) : null}
|
||||
<form action={requestPasswordReset} className="grid gap-4">
|
||||
<label className="block text-sm font-medium text-on-surface-variant">
|
||||
{t("login", "email_label")}
|
||||
<input
|
||||
name="email"
|
||||
className="mt-2 w-full rounded-full border border-line bg-surface-container-high px-4 py-3 text-sm outline-none ring-1 ring-transparent transition focus:ring-primary"
|
||||
placeholder={t("login", "work_email_placeholder")}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<div />
|
||||
<div className="mt-4">
|
||||
<Button type="submit">{t("login", "forgot_action")}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</SectionCard>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
43
app/globals.css
Normal file
43
app/globals.css
Normal file
@ -0,0 +1,43 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Manrope:wght@500;700;800&display=swap");
|
||||
@import url("https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap");
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: #f7f9fc;
|
||||
color: #191c1e;
|
||||
font-family:
|
||||
"Inter", "Segoe UI", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings:
|
||||
"FILL" 0,
|
||||
"wght" 400,
|
||||
"GRAD" 0,
|
||||
"opsz" 24;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
54
app/inbox/page.tsx
Normal file
54
app/inbox/page.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
import { InboxPlaceholder } from "@/components/placeholders";
|
||||
import { PlaceholderActions, ShellPage } from "@/components/page-templates";
|
||||
import {
|
||||
addConversationNote,
|
||||
assignConversation,
|
||||
getInboxWorkspace,
|
||||
replyToConversation,
|
||||
setConversationTags,
|
||||
updateConversationStatus
|
||||
} from "@/lib/inbox-ops";
|
||||
|
||||
const allowedFilters = ["all", "open", "pending", "resolved", "unassigned"] as const;
|
||||
|
||||
export default async function InboxPage({
|
||||
searchParams
|
||||
}: {
|
||||
searchParams: Promise<{ conversationId?: string; filter?: string }>;
|
||||
}) {
|
||||
const params = await searchParams;
|
||||
const filter =
|
||||
params?.filter && allowedFilters.includes(params.filter as (typeof allowedFilters)[number])
|
||||
? (params.filter as (typeof allowedFilters)[number])
|
||||
: "all";
|
||||
|
||||
const data = await getInboxWorkspace({
|
||||
scope: "admin",
|
||||
conversationId: params?.conversationId,
|
||||
filter
|
||||
});
|
||||
|
||||
return (
|
||||
<ShellPage
|
||||
shell="admin"
|
||||
title="Shared Inbox"
|
||||
description="Split layout untuk conversation list, timeline, assignment, notes, tags, dan reply composer."
|
||||
actions={<PlaceholderActions primaryHref="/team" primaryLabel="Manage team" secondaryHref="/contacts" secondaryLabel="Open contacts" />}
|
||||
>
|
||||
<InboxPlaceholder
|
||||
conversations={data.conversations}
|
||||
selectedConversation={data.selectedConversation}
|
||||
defaultPath={data.defaultPath}
|
||||
agents={data.agents}
|
||||
role={data.role}
|
||||
filter={data.filter}
|
||||
canSelfAssign={data.canSelfAssign}
|
||||
assignConversation={assignConversation}
|
||||
updateConversationStatus={updateConversationStatus}
|
||||
replyToConversation={replyToConversation}
|
||||
addConversationNote={addConversationNote}
|
||||
setConversationTags={setConversationTags}
|
||||
/>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
147
app/invite/[token]/page.tsx
Normal file
147
app/invite/[token]/page.tsx
Normal file
@ -0,0 +1,147 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { AuthTokenType, UserStatus } from "@prisma/client";
|
||||
|
||||
import { Button, PageHeader, SectionCard } from "@/components/ui";
|
||||
import { consumeAuthToken } from "@/lib/auth-tokens";
|
||||
import { hashPassword } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getRequestAuditContext, writeAuditTrail } from "@/lib/audit";
|
||||
|
||||
async function acceptInvite(formData: FormData) {
|
||||
"use server";
|
||||
|
||||
const requestContext = await getRequestAuditContext();
|
||||
const tokenRaw = formData.get("token");
|
||||
const token = typeof tokenRaw === "string" ? tokenRaw.trim() : "";
|
||||
const fullNameRaw = formData.get("fullName");
|
||||
const fullName = typeof fullNameRaw === "string" ? fullNameRaw.trim() : "";
|
||||
const passwordRaw = formData.get("password");
|
||||
const password = typeof passwordRaw === "string" ? passwordRaw : "";
|
||||
const confirmRaw = formData.get("confirmPassword");
|
||||
const confirmPassword = typeof confirmRaw === "string" ? confirmRaw : "";
|
||||
|
||||
if (!token || !fullName || !password || !confirmPassword) {
|
||||
redirect(`/invite/${encodeURIComponent(token)}?error=missing_fields`);
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
redirect(`/invite/${encodeURIComponent(token)}?error=password_mismatch`);
|
||||
}
|
||||
|
||||
const resolvedToken = await consumeAuthToken(token, AuthTokenType.INVITE_ACCEPTANCE);
|
||||
if (!resolvedToken.valid) {
|
||||
redirect(
|
||||
`/invite/${encodeURIComponent(token)}?error=${resolvedToken.reason === "expired" ? "expired_token" : "invalid_token"}`
|
||||
);
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: resolvedToken.token.userId }
|
||||
});
|
||||
|
||||
if (!user || user.status !== UserStatus.INVITED) {
|
||||
redirect(`/invite/${encodeURIComponent(token)}?error=invalid_token`);
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
fullName,
|
||||
passwordHash: await hashPassword(password),
|
||||
status: UserStatus.ACTIVE
|
||||
}
|
||||
}),
|
||||
prisma.authToken.update({
|
||||
where: { id: resolvedToken.token.id },
|
||||
data: { consumedAt: new Date() }
|
||||
})
|
||||
]);
|
||||
|
||||
await writeAuditTrail({
|
||||
tenantId: user.tenantId,
|
||||
actorUserId: user.id,
|
||||
entityType: "user",
|
||||
entityId: user.id,
|
||||
action: "user_invite_accepted",
|
||||
metadata: {
|
||||
fullName,
|
||||
email: user.email
|
||||
},
|
||||
ipAddress: requestContext.ipAddress,
|
||||
userAgent: requestContext.userAgent
|
||||
});
|
||||
|
||||
redirect("/login?success=invite_accepted");
|
||||
}
|
||||
|
||||
export default async function InvitationPage({
|
||||
params,
|
||||
searchParams
|
||||
}: {
|
||||
params: Promise<{ token: string }>;
|
||||
searchParams?: Promise<{ error?: string }>;
|
||||
}) {
|
||||
const { token } = await params;
|
||||
const paramsData = await (searchParams ?? Promise.resolve({ error: undefined }));
|
||||
const error = paramsData?.error;
|
||||
|
||||
const resolvedToken = await consumeAuthToken(token, AuthTokenType.INVITE_ACCEPTANCE);
|
||||
const isTokenValid = resolvedToken.valid;
|
||||
|
||||
const tokenInvalidMessage = error === "expired_token" ? "Token undangan sudah kedaluwarsa." : error === "invalid_token" ? "Link undangan tidak valid." : error === "missing_fields" ? "Lengkapi data nama dan password." : error === "password_mismatch" ? "Password tidak cocok." : null;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-background px-6 py-16">
|
||||
<div className="mx-auto w-full max-w-2xl rounded-[1.5rem] bg-surface-container-lowest p-4 shadow-card md:p-8">
|
||||
<PageHeader title="Accept invitation" description="Selesaikan setup akun awal sebelum masuk ke dashboard." />
|
||||
<div className="mt-8">
|
||||
<SectionCard title="Invitation setup">
|
||||
{!isTokenValid || tokenInvalidMessage ? (
|
||||
<p className="mb-4 rounded-xl border border-error-container bg-error-container p-3 text-sm text-on-error-container">
|
||||
{tokenInvalidMessage || "Link undangan tidak valid atau sudah kedaluwarsa."}
|
||||
</p>
|
||||
) : null}
|
||||
{isTokenValid ? (
|
||||
<form action={acceptInvite} className="grid gap-4">
|
||||
<input type="hidden" name="token" value={token} />
|
||||
<label className="text-sm font-medium text-on-surface-variant">
|
||||
Full name
|
||||
<input
|
||||
name="fullName"
|
||||
required
|
||||
className="mt-2 w-full rounded-full border border-line bg-surface-container-high px-4 py-3 text-sm outline-none ring-1 ring-transparent transition focus:ring-primary"
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm font-medium text-on-surface-variant">
|
||||
Password
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
className="mt-2 w-full rounded-full border border-line bg-surface-container-high px-4 py-3 text-sm outline-none ring-1 ring-transparent transition focus:ring-primary"
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm font-medium text-on-surface-variant">
|
||||
Confirm password
|
||||
<input
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
required
|
||||
className="mt-2 w-full rounded-full border border-line bg-surface-container-high px-4 py-3 text-sm outline-none ring-1 ring-transparent transition focus:ring-primary"
|
||||
/>
|
||||
</label>
|
||||
<div className="mt-4">
|
||||
<Button type="submit">Accept invitation</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<Button href="/login">Kembali ke login</Button>
|
||||
)}
|
||||
</SectionCard>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
22
app/layout.tsx
Normal file
22
app/layout.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import { getLocale, t } from "@/lib/i18n";
|
||||
|
||||
import "./globals.css";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const locale = await getLocale();
|
||||
return {
|
||||
title: t(locale, "meta", "title"),
|
||||
description: t(locale, "meta", "description")
|
||||
};
|
||||
}
|
||||
|
||||
export default async function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
const locale = await getLocale();
|
||||
return (
|
||||
<html lang={locale}>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
28
app/locale/route.ts
Normal file
28
app/locale/route.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { DEFAULT_LOCALE, isLocale, LOCALE_COOKIE } from "@/lib/i18n";
|
||||
|
||||
function sanitizeLocale(raw: string | null) {
|
||||
if (isLocale(raw)) {
|
||||
return raw;
|
||||
}
|
||||
|
||||
return DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const rawTo = request.nextUrl.searchParams.get("to");
|
||||
const nextLocale = sanitizeLocale(rawTo);
|
||||
const back = request.headers.get("referer") || "/";
|
||||
const destination = new URL(back, request.url);
|
||||
|
||||
const response = NextResponse.redirect(destination);
|
||||
response.cookies.set(LOCALE_COOKIE, nextLocale, {
|
||||
path: "/",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
maxAge: 365 * 24 * 60 * 60,
|
||||
sameSite: "lax"
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
121
app/login/page.tsx
Normal file
121
app/login/page.tsx
Normal file
@ -0,0 +1,121 @@
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
|
||||
import { Button } from "@/components/ui";
|
||||
import { getLocale, getTranslator } from "@/lib/i18n";
|
||||
import { getSession } from "@/lib/auth";
|
||||
|
||||
export default async function LoginPage({
|
||||
searchParams
|
||||
}: {
|
||||
searchParams?: Promise<{ error?: string; next?: string }>;
|
||||
}) {
|
||||
const locale = await getLocale();
|
||||
const t = getTranslator(locale);
|
||||
|
||||
const params = await (searchParams ?? Promise.resolve({ error: undefined, next: undefined }));
|
||||
const error = params?.error;
|
||||
const next = params?.next ?? "";
|
||||
const session = await getSession();
|
||||
|
||||
const errorMessage = error === "credentials_required"
|
||||
? t("login", "error_credentials_required")
|
||||
: error === "invalid_credentials"
|
||||
? t("login", "error_invalid_credentials")
|
||||
: error === "rate_limited"
|
||||
? t("login", "error_rate_limited")
|
||||
: null;
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-background px-6 py-14">
|
||||
<div className="grid w-full max-w-5xl overflow-hidden rounded-[2rem] bg-surface-container-lowest shadow-floating">
|
||||
<section className="bg-surface-container-lowest border-b border-line px-10 py-16 text-center md:border-r md:border-b-0 md:px-14 md:py-20">
|
||||
<Image
|
||||
src="/logo_zappcare.png"
|
||||
alt="ZappCare"
|
||||
width={56}
|
||||
height={56}
|
||||
className="mx-auto h-14 w-auto rounded-full"
|
||||
priority
|
||||
/>
|
||||
<h1 className="mt-8 text-4xl font-extrabold font-headline text-on-surface">{t("login", "title")}</h1>
|
||||
<p className="mx-auto mt-3 max-w-sm text-sm text-on-surface-variant">
|
||||
{t("login", "signin_subtitle")}
|
||||
</p>
|
||||
<div className="mx-auto mt-10 flex w-full max-w-sm flex-col gap-3">
|
||||
<div className="rounded-[1.5rem] bg-surface-container-low p-4">
|
||||
<p className="text-2xl font-black text-on-surface">3</p>
|
||||
<p className="mt-1 text-sm text-on-surface-variant">Role aktif saat ini</p>
|
||||
</div>
|
||||
<div className="rounded-[1.5rem] bg-surface-container-low p-4">
|
||||
<p className="text-2xl font-black text-on-surface">10+</p>
|
||||
<p className="mt-1 text-sm text-on-surface-variant">Modul operasi aktif</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="px-8 py-10 md:px-12 md:py-16">
|
||||
<div className="mx-auto max-w-md">
|
||||
<p className="text-sm font-black uppercase tracking-[0.22em] text-primary">{t("login", "signin_label")}</p>
|
||||
<h2 className="mt-3 text-3xl font-black font-headline text-on-surface">{t("login", "signin_subtitle")}</h2>
|
||||
<p className="mt-3 text-sm text-on-surface-variant">{t("login", "signin_help")}</p>
|
||||
{session ? (
|
||||
<p className="mt-4 rounded-[1rem] border border-outline-variant bg-surface-container-high p-4 text-sm text-on-surface-variant">
|
||||
Session aktif: {session.fullName} • {session.role} • {session.tenantName}
|
||||
</p>
|
||||
) : null}
|
||||
{errorMessage ? (
|
||||
<p className="mt-4 rounded-[1rem] border border-error-container bg-error-container p-3 text-sm text-on-error-container">
|
||||
{errorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
<form action="/auth/login" method="post" className="mt-8 space-y-4">
|
||||
<input type="hidden" name="next" value={next} />
|
||||
<label className="block text-sm text-on-surface-variant">
|
||||
{t("login", "email_label")}
|
||||
<div className="mt-1.5">
|
||||
<input
|
||||
name="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
className="h-12 w-full rounded-full border-none bg-surface-container-highest px-4 text-sm outline-none ring-1 ring-outline/40 focus:ring-2 focus:ring-primary"
|
||||
placeholder={t("login", "work_email_placeholder")}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<label className="block text-sm text-on-surface-variant">
|
||||
{t("login", "password_label")}
|
||||
<div className="mt-1.5 relative">
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className="h-12 w-full rounded-full border-none bg-surface-container-highest px-4 text-sm outline-none ring-1 ring-outline/40 focus:ring-2 focus:ring-primary"
|
||||
placeholder={t("login", "password_placeholder")}
|
||||
/>
|
||||
<span className="material-symbols-outlined absolute right-4 top-1/2 -translate-y-1/2 text-outline">visibility</span>
|
||||
</div>
|
||||
</label>
|
||||
<Button className="w-full">{t("login", "sign_in_button")}</Button>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-xl border border-line bg-surface-container-low px-4 py-2 text-sm font-semibold text-on-surface transition hover:bg-surface-container-high"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[20px]">fingerprint</span>
|
||||
<span>{t("login", "sso_button")}</span>
|
||||
</button>
|
||||
<p className="text-center text-sm text-on-surface-variant">
|
||||
{t("login", "no_account_label")} <Link href="/" className="font-bold text-primary hover:text-on-primary-container">{t("login", "contact_admin")}</Link>
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-4 text-sm text-on-surface-variant">
|
||||
<Link href="/forgot-password" className="font-semibold text-primary hover:text-on-primary-container">
|
||||
{t("login", "remember_label")}
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
83
app/notifications/page.tsx
Normal file
83
app/notifications/page.tsx
Normal file
@ -0,0 +1,83 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { TablePlaceholder } from "@/components/placeholders";
|
||||
|
||||
type NotificationRow = {
|
||||
id: string;
|
||||
type: string;
|
||||
message: string;
|
||||
time: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
function toLocale(date: Date | null) {
|
||||
if (!date) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat("id-ID", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export default async function NotificationsPage() {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const tenantFilter = session.role === "super_admin" ? {} : { tenantId: session.tenantId };
|
||||
|
||||
const [audits, webhooks] = await Promise.all([
|
||||
prisma.auditLog.findMany({
|
||||
where: tenantFilter,
|
||||
include: { actorUser: true, tenant: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 8
|
||||
}),
|
||||
prisma.webhookEvent.findMany({
|
||||
where: tenantFilter,
|
||||
include: { tenant: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 8
|
||||
})
|
||||
]);
|
||||
|
||||
const rowsRaw: Array<NotificationRow & { sortAt: Date }> = [
|
||||
...audits.map((audit) => ({
|
||||
id: audit.id,
|
||||
type: "Audit",
|
||||
message: `${audit.actorUser?.fullName ?? "System"} ${audit.action} ${audit.entityType} ${audit.entityId}`,
|
||||
time: toLocale(audit.createdAt),
|
||||
status: audit.entityType ? "Info" : "Notice",
|
||||
sortAt: audit.createdAt
|
||||
})),
|
||||
...webhooks.map((event) => ({
|
||||
id: event.id,
|
||||
type: "Webhook",
|
||||
message: `${event.eventType} · ${event.tenant.name}`,
|
||||
time: toLocale(event.createdAt),
|
||||
status: event.processStatus,
|
||||
sortAt: event.createdAt
|
||||
}))
|
||||
].sort((a, b) => b.sortAt.getTime() - a.sortAt.getTime());
|
||||
|
||||
const rows = rowsRaw.slice(0, 12).map((item) => [item.type, item.message, item.time, item.status]);
|
||||
|
||||
return (
|
||||
<ShellPage shell="admin" title="Notifications" description="Feed notifikasi lintas modul.">
|
||||
<TablePlaceholder
|
||||
title="Recent notifications"
|
||||
columns={["Type", "Message", "Time", "Status"]}
|
||||
rows={rows}
|
||||
/>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
5
app/page.tsx
Normal file
5
app/page.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function HomePage() {
|
||||
redirect("/login");
|
||||
}
|
||||
50
app/profile/change-password/page.tsx
Normal file
50
app/profile/change-password/page.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { Button, SectionCard } from "@/components/ui";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { changePassword } from "@/lib/admin-crud";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function ChangePasswordPage({
|
||||
searchParams
|
||||
}: {
|
||||
searchParams?: Promise<{ error?: string; success?: string }>;
|
||||
}) {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const params = await (searchParams ?? Promise.resolve({ error: undefined, success: undefined }));
|
||||
const shell = session.role === "agent" ? "agent" : session.role === "super_admin" ? "super-admin" : "admin";
|
||||
const infoMessage =
|
||||
params.success === "updated"
|
||||
? "Password berhasil diperbarui."
|
||||
: params.error === "missing_fields"
|
||||
? "Lengkapi semua field password."
|
||||
: params.error === "password_mismatch"
|
||||
? "Password baru dan konfirmasi tidak sama."
|
||||
: params.error === "wrong_current_password"
|
||||
? "Password lama tidak sesuai."
|
||||
: null;
|
||||
|
||||
return (
|
||||
<ShellPage shell={shell} title="Change Password" description="Ubah password akun Anda.">
|
||||
<SectionCard title="Password form">
|
||||
<form action={changePassword} className="grid gap-4 md:max-w-xl">
|
||||
{infoMessage ? <p className="rounded-xl border border-success/30 bg-success/10 p-3 text-sm text-success">{infoMessage}</p> : null}
|
||||
<input type="password" name="currentPassword" className="rounded-xl border border-line px-4 py-3" placeholder="Current password" />
|
||||
<input type="password" name="newPassword" className="rounded-xl border border-line px-4 py-3" placeholder="New password" />
|
||||
<input
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
className="rounded-xl border border-line px-4 py-3"
|
||||
placeholder="Confirm new password"
|
||||
/>
|
||||
<div>
|
||||
<Button type="submit">Update password</Button>
|
||||
</div>
|
||||
</form>
|
||||
</SectionCard>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
43
app/profile/edit/page.tsx
Normal file
43
app/profile/edit/page.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { Button, SectionCard } from "@/components/ui";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { updateMyProfile } from "@/lib/admin-crud";
|
||||
|
||||
export default async function EditProfilePage({
|
||||
searchParams
|
||||
}: {
|
||||
searchParams?: Promise<{ error?: string }>;
|
||||
}) {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const params = await (searchParams ?? Promise.resolve({ error: undefined }));
|
||||
const message = params.error === "missing_fullname" ? "Nama lengkap wajib diisi." : null;
|
||||
|
||||
const shell = session.role === "agent" ? "agent" : session.role === "super_admin" ? "super-admin" : "admin";
|
||||
|
||||
return (
|
||||
<ShellPage shell={shell} title="Edit Profile" description="Edit identitas dasar pengguna.">
|
||||
<SectionCard title="Profile form">
|
||||
<form action={updateMyProfile} className="grid gap-4 md:max-w-xl">
|
||||
{message ? <p className="rounded-xl border border-warning/30 bg-warning/10 p-3 text-sm text-warning">{message}</p> : null}
|
||||
<input
|
||||
className="rounded-xl border border-line px-4 py-3"
|
||||
defaultValue={session?.fullName ?? ""}
|
||||
placeholder="Full name"
|
||||
name="fullName"
|
||||
required
|
||||
/>
|
||||
<input className="rounded-xl border border-line px-4 py-3" placeholder="Avatar URL" name="avatarUrl" />
|
||||
<div>
|
||||
<Button type="submit">Save changes</Button>
|
||||
</div>
|
||||
</form>
|
||||
</SectionCard>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
43
app/profile/page.tsx
Normal file
43
app/profile/page.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { SectionCard } from "@/components/ui";
|
||||
|
||||
function roleLabel(role: string) {
|
||||
if (role === "admin_client") {
|
||||
return "Admin Client";
|
||||
}
|
||||
|
||||
if (role === "agent") {
|
||||
return "Agent";
|
||||
}
|
||||
|
||||
return "Super Admin";
|
||||
}
|
||||
|
||||
export default async function ProfilePage() {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const shell = session.role === "agent" ? "agent" : session.role === "super_admin" ? "super-admin" : "admin";
|
||||
|
||||
return (
|
||||
<ShellPage
|
||||
shell={shell}
|
||||
title="My Profile"
|
||||
description="Informasi akun dasar yang nantinya terhubung ke session dan role."
|
||||
>
|
||||
<SectionCard title="Profile summary">
|
||||
<div className="grid gap-3 text-sm text-on-surface-variant md:grid-cols-2">
|
||||
<p>Full name: {session.fullName}</p>
|
||||
<p>Email: {session.email}</p>
|
||||
<p>Role: {roleLabel(session.role)}</p>
|
||||
<p>Tenant: {session.tenantName}</p>
|
||||
</div>
|
||||
</SectionCard>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
13
app/reports/agent-productivity/page.tsx
Normal file
13
app/reports/agent-productivity/page.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { DashboardPlaceholder } from "@/components/placeholders";
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { getDashboardData } from "@/lib/platform-data";
|
||||
|
||||
export default async function AgentProductivityReportPage() {
|
||||
const data = await getDashboardData();
|
||||
|
||||
return (
|
||||
<ShellPage shell="admin" title="Agent Productivity Report" description="Perbandingan performa agent dalam satu tenant.">
|
||||
<DashboardPlaceholder stats={data.stats} priorityQueue={data.priorityQueue} />
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
13
app/reports/campaign-analytics/page.tsx
Normal file
13
app/reports/campaign-analytics/page.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { DashboardPlaceholder } from "@/components/placeholders";
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { getDashboardData } from "@/lib/platform-data";
|
||||
|
||||
export default async function CampaignAnalyticsReportPage() {
|
||||
const data = await getDashboardData();
|
||||
|
||||
return (
|
||||
<ShellPage shell="admin" title="Campaign Analytics" description="Delivered, read, failed, dan trend broadcast.">
|
||||
<DashboardPlaceholder stats={data.stats} priorityQueue={data.priorityQueue} />
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
13
app/reports/contact-growth/page.tsx
Normal file
13
app/reports/contact-growth/page.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { DashboardPlaceholder } from "@/components/placeholders";
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { getDashboardData } from "@/lib/platform-data";
|
||||
|
||||
export default async function ContactGrowthReportPage() {
|
||||
const data = await getDashboardData();
|
||||
|
||||
return (
|
||||
<ShellPage shell="admin" title="Contact Growth Report" description="Pertumbuhan audience dan active contacts.">
|
||||
<DashboardPlaceholder stats={data.stats} priorityQueue={data.priorityQueue} />
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
25
app/reports/page.tsx
Normal file
25
app/reports/page.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { PlaceholderActions, ShellPage } from "@/components/page-templates";
|
||||
import { TablePlaceholder } from "@/components/placeholders";
|
||||
|
||||
export default function ReportsPage() {
|
||||
return (
|
||||
<ShellPage
|
||||
shell="admin"
|
||||
title="Reports Overview"
|
||||
description="Entry point ke response time, resolution, campaign analytics, dan contact growth."
|
||||
actions={<PlaceholderActions primaryHref="/reports/response-time" primaryLabel="Open response time" />}
|
||||
>
|
||||
<TablePlaceholder
|
||||
title="Available reports"
|
||||
columns={["Report", "Purpose", "Route"]}
|
||||
rows={[
|
||||
["Response Time", "SLA and responsiveness", "/reports/response-time"],
|
||||
["Resolution", "Conversation completion quality", "/reports/resolution"],
|
||||
["Agent Productivity", "Per-agent handled volume", "/reports/agent-productivity"],
|
||||
["Campaign Analytics", "Broadcast outcome", "/reports/campaign-analytics"],
|
||||
["Contact Growth", "Audience expansion", "/reports/contact-growth"]
|
||||
]}
|
||||
/>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
13
app/reports/resolution/page.tsx
Normal file
13
app/reports/resolution/page.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { DashboardPlaceholder } from "@/components/placeholders";
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { getDashboardData } from "@/lib/platform-data";
|
||||
|
||||
export default async function ResolutionReportPage() {
|
||||
const data = await getDashboardData();
|
||||
|
||||
return (
|
||||
<ShellPage shell="admin" title="Resolution Report" description="Resolution rate dan aging backlog.">
|
||||
<DashboardPlaceholder stats={data.stats} priorityQueue={data.priorityQueue} />
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
13
app/reports/response-time/page.tsx
Normal file
13
app/reports/response-time/page.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { DashboardPlaceholder } from "@/components/placeholders";
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { getDashboardData } from "@/lib/platform-data";
|
||||
|
||||
export default async function ResponseTimeReportPage() {
|
||||
const data = await getDashboardData();
|
||||
|
||||
return (
|
||||
<ShellPage shell="admin" title="Response Time Report" description="KPI, chart, dan table untuk performa response time.">
|
||||
<DashboardPlaceholder stats={data.stats} priorityQueue={data.priorityQueue} />
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
163
app/reset-password/page.tsx
Normal file
163
app/reset-password/page.tsx
Normal file
@ -0,0 +1,163 @@
|
||||
import { Button, PageHeader, SectionCard } from "@/components/ui";
|
||||
import { getLocale, getTranslator } from "@/lib/i18n";
|
||||
import { getRequestAuditContext, writeAuditTrail } from "@/lib/audit";
|
||||
import { AuthTokenType, UserStatus } from "@prisma/client";
|
||||
import { consumeAuthToken } from "@/lib/auth-tokens";
|
||||
import { hashPassword } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
async function resetPassword(formData: FormData) {
|
||||
"use server";
|
||||
|
||||
const requestContext = await getRequestAuditContext();
|
||||
|
||||
const tokenRaw = formData.get("token");
|
||||
const passwordRaw = formData.get("password");
|
||||
const confirmRaw = formData.get("confirmPassword");
|
||||
const token = typeof tokenRaw === "string" ? tokenRaw : "";
|
||||
const password = typeof passwordRaw === "string" ? passwordRaw : "";
|
||||
const confirmPassword = typeof confirmRaw === "string" ? confirmRaw : "";
|
||||
|
||||
if (!token || !password || !confirmPassword) {
|
||||
redirect(`/reset-password?token=${encodeURIComponent(token)}&error=missing_fields`);
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
redirect(`/reset-password?token=${encodeURIComponent(token)}&error=password_mismatch`);
|
||||
}
|
||||
|
||||
const resolvedToken = await consumeAuthToken(token, AuthTokenType.PASSWORD_RESET);
|
||||
if (!resolvedToken.valid) {
|
||||
const reason = resolvedToken.reason === "expired" ? "expired_token" : "invalid_token";
|
||||
redirect(`/reset-password?token=${encodeURIComponent(token)}&error=${reason}`);
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: resolvedToken.token.userId }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
await writeAuditTrail({
|
||||
tenantId: resolvedToken.token.tenantId,
|
||||
actorUserId: null,
|
||||
entityType: "user",
|
||||
entityId: resolvedToken.token.userId,
|
||||
action: "password_reset_token_used_no_user",
|
||||
metadata: { reason: "user_not_found", tokenType: AuthTokenType.PASSWORD_RESET },
|
||||
ipAddress: requestContext.ipAddress,
|
||||
userAgent: requestContext.userAgent
|
||||
});
|
||||
redirect("/reset-password?error=invalid_token");
|
||||
}
|
||||
|
||||
if (user.status !== UserStatus.ACTIVE) {
|
||||
await writeAuditTrail({
|
||||
tenantId: user.tenantId,
|
||||
actorUserId: user.id,
|
||||
entityType: "user",
|
||||
entityId: user.id,
|
||||
action: "password_reset_denied",
|
||||
metadata: { reason: "invalid_status", status: user.status },
|
||||
ipAddress: requestContext.ipAddress,
|
||||
userAgent: requestContext.userAgent
|
||||
});
|
||||
redirect("/reset-password?error=invalid_token");
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
passwordHash: await hashPassword(password)
|
||||
}
|
||||
}),
|
||||
prisma.authToken.update({
|
||||
where: { id: resolvedToken.token.id },
|
||||
data: { consumedAt: new Date() }
|
||||
})
|
||||
]);
|
||||
|
||||
await writeAuditTrail({
|
||||
tenantId: user.tenantId,
|
||||
actorUserId: user.id,
|
||||
entityType: "user",
|
||||
entityId: user.id,
|
||||
action: "password_reset_completed",
|
||||
metadata: { source: "web" },
|
||||
ipAddress: requestContext.ipAddress,
|
||||
userAgent: requestContext.userAgent
|
||||
});
|
||||
|
||||
revalidatePath("/login");
|
||||
redirect("/login?success=password_reset_done");
|
||||
}
|
||||
|
||||
export default async function ResetPasswordPage({
|
||||
searchParams
|
||||
}: {
|
||||
searchParams?: Promise<{ token?: string; error?: string }>;
|
||||
}) {
|
||||
const t = getTranslator(await getLocale());
|
||||
const params = await (
|
||||
searchParams ??
|
||||
Promise.resolve({
|
||||
token: undefined,
|
||||
error: undefined
|
||||
})
|
||||
);
|
||||
const token = typeof params.token === "string" ? params.token : "";
|
||||
|
||||
const tokenState =
|
||||
params?.error === "invalid_token"
|
||||
? t("pages", "invalid_token")
|
||||
: params?.error === "expired_token"
|
||||
? t("pages", "reset_token_expired")
|
||||
: params?.error === "missing_fields"
|
||||
? t("login", "error_credentials_required")
|
||||
: params?.error === "password_mismatch"
|
||||
? t("pages", "password_mismatch")
|
||||
: null;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-background px-6 py-16">
|
||||
<div className="mx-auto w-full max-w-2xl rounded-[1.5rem] bg-surface-container-lowest p-4 shadow-card md:p-8">
|
||||
<PageHeader title={t("pages", "reset_password")} description={t("pages", "reset_desc")} />
|
||||
<div className="mt-8">
|
||||
<SectionCard title={t("login", "password_label")}>
|
||||
{tokenState ? (
|
||||
<p className="mb-4 rounded-xl border border-error-container bg-error-container p-3 text-sm text-on-error-container">
|
||||
{tokenState}
|
||||
</p>
|
||||
) : null}
|
||||
<form action={resetPassword} className="grid gap-4">
|
||||
<input type="hidden" name="token" value={token} />
|
||||
<div className="grid gap-4">
|
||||
<label className="text-sm font-medium text-on-surface-variant">
|
||||
{t("login", "password_label")}
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
className="mt-2 w-full rounded-full border border-line bg-surface-container-high px-4 py-3 text-sm outline-none ring-1 ring-transparent transition focus:ring-primary"
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm font-medium text-on-surface-variant">
|
||||
{t("pages", "reset_password")}
|
||||
<input
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
className="mt-2 w-full rounded-full border border-line bg-surface-container-high px-4 py-3 text-sm outline-none ring-1 ring-transparent transition focus:ring-primary"
|
||||
/>
|
||||
</label>
|
||||
<div className="mt-4">
|
||||
<Button type="submit">{t("pages", "reset_password")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</SectionCard>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
132
app/search/page.tsx
Normal file
132
app/search/page.tsx
Normal file
@ -0,0 +1,132 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { TablePlaceholder } from "@/components/placeholders";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type SearchRow = [string, string, string, ReactNode];
|
||||
|
||||
function toLocale(date: Date | null) {
|
||||
if (!date) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
if (diff < 60_000) {
|
||||
return "just now";
|
||||
}
|
||||
if (diff < 3_600_000) {
|
||||
return `${Math.floor(diff / 60_000)}m ago`;
|
||||
}
|
||||
if (diff < 86_400_000) {
|
||||
return `${Math.floor(diff / 3_600_000)}h ago`;
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat("id-ID", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric"
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export default async function SearchPage({
|
||||
searchParams
|
||||
}: {
|
||||
searchParams?: Promise<{ q?: string }>;
|
||||
}) {
|
||||
const params = await (searchParams ?? Promise.resolve({ q: "" }));
|
||||
const q = params.q?.trim() ?? "";
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const tenantFilter = session.role === "super_admin" ? {} : { tenantId: session.tenantId };
|
||||
const contactScope = q
|
||||
? {
|
||||
OR: [{ fullName: { contains: q } }, { phoneNumber: { contains: q } }]
|
||||
}
|
||||
: {};
|
||||
const userScope = q
|
||||
? { OR: [{ fullName: { contains: q } }, { email: { contains: q } }] }
|
||||
: {};
|
||||
const convoScope = q
|
||||
? { OR: [{ contact: { fullName: { contains: q } } }, { subject: { contains: q } }] }
|
||||
: {};
|
||||
|
||||
const [contacts, users, conversations] = await Promise.all([
|
||||
prisma.contact.findMany({
|
||||
where: q ? { ...tenantFilter, ...contactScope } : tenantFilter,
|
||||
orderBy: { lastInteractionAt: "desc" },
|
||||
take: q ? 5 : 0
|
||||
}),
|
||||
prisma.user.findMany({
|
||||
where: q ? { ...tenantFilter, ...userScope } : tenantFilter,
|
||||
orderBy: { fullName: "asc" },
|
||||
take: q ? 5 : 0
|
||||
}),
|
||||
prisma.conversation.findMany({
|
||||
where: q ? { ...tenantFilter, ...convoScope } : tenantFilter,
|
||||
include: { contact: true },
|
||||
orderBy: { lastMessageAt: "desc" },
|
||||
take: q ? 5 : 0
|
||||
})
|
||||
]);
|
||||
|
||||
const rows: SearchRow[] = q
|
||||
? [
|
||||
...contacts.map(
|
||||
(contact) =>
|
||||
[
|
||||
"Contact",
|
||||
contact.fullName,
|
||||
`Last seen: ${toLocale(contact.lastInteractionAt)}`,
|
||||
<Link key={`contact-${contact.id}`} href={`/contacts/${contact.id}`} className="text-brand hover:underline">
|
||||
View
|
||||
</Link>
|
||||
] as SearchRow
|
||||
),
|
||||
...users.map(
|
||||
(user) =>
|
||||
[
|
||||
"User",
|
||||
user.fullName,
|
||||
`Email: ${user.email}`,
|
||||
<Link key={`user-${user.id}`} href={`/team/${user.id}`} className="text-brand hover:underline">
|
||||
View
|
||||
</Link>
|
||||
] as SearchRow
|
||||
),
|
||||
...conversations.map(
|
||||
(conversation) =>
|
||||
[
|
||||
"Conversation",
|
||||
conversation.contact.fullName,
|
||||
`Last message: ${toLocale(conversation.lastMessageAt)}`,
|
||||
<Link
|
||||
key={`conversation-${conversation.id}`}
|
||||
href={session.role === "agent" ? `/agent/inbox?conversationId=${conversation.id}` : `/inbox?conversationId=${conversation.id}`}
|
||||
className="text-brand hover:underline"
|
||||
>
|
||||
Open
|
||||
</Link>
|
||||
] as SearchRow
|
||||
)
|
||||
]
|
||||
: [];
|
||||
|
||||
const infoText = q
|
||||
? `${rows.length} hasil untuk "${q}"`
|
||||
: "Masukkan keyword di query ?q=... untuk mencari conversation, contact, atau user.";
|
||||
|
||||
return (
|
||||
<ShellPage shell="admin" title="Global Search" description="Entry point untuk search conversation, contact, dan user.">
|
||||
<p className="rounded-xl border border-line bg-surface-container p-3 text-sm text-on-surface-variant">{infoText}</p>
|
||||
<TablePlaceholder title="Search results" columns={["Type", "Name", "Context", "Action"]} rows={rows} />
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
51
app/settings/auto-assignment/page.tsx
Normal file
51
app/settings/auto-assignment/page.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { Button, SectionCard } from "@/components/ui";
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function AutoAssignmentSettingsPage() {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
if (session.role === "agent") {
|
||||
redirect("/unauthorized");
|
||||
}
|
||||
|
||||
const tenantFilter = session.role === "super_admin" ? {} : { tenantId: session.tenantId };
|
||||
|
||||
const [pendingCount, openCount, agentCount, unassignedCount] = await Promise.all([
|
||||
prisma.conversation.count({ where: { ...tenantFilter, status: "PENDING" } }),
|
||||
prisma.conversation.count({ where: { ...tenantFilter, status: "OPEN" } }),
|
||||
prisma.user.count({ where: { ...tenantFilter, role: { code: "AGENT" } } }),
|
||||
prisma.conversation.count({ where: { ...tenantFilter, assignedUserId: null, status: { in: ["OPEN", "PENDING"] } } })
|
||||
]);
|
||||
|
||||
const autoRuleSuggestion =
|
||||
agentCount > 0
|
||||
? `Round-robin aktif (${agentCount} agent): prioritas agent akan otomatis berputar saat penugasan masuk.`
|
||||
: "Tambahkan agent terlebih dahulu sebelum auto-assignment dapat berjalan penuh.";
|
||||
|
||||
return (
|
||||
<ShellPage shell="admin" title="Auto Assignment Rules" description="Pengaturan distribusi percakapan dan ringkasan antrean.">
|
||||
<SectionCard title="Auto assignment status">
|
||||
<div className="grid gap-4 md:max-w-2xl">
|
||||
<p className="text-sm text-on-surface-variant">Auto-assign belum memiliki field konfigurasi per tenant di DB saat ini.</p>
|
||||
<p className="text-sm text-on-surface">
|
||||
Open: {openCount} • Pending: {pendingCount} • Unassigned: {unassignedCount} • Agent aktif: {agentCount}
|
||||
</p>
|
||||
<p className="text-sm">{autoRuleSuggestion}</p>
|
||||
<p className="rounded-xl border border-line bg-surface-container p-3 text-sm text-on-surface-variant">
|
||||
Rekomendasi rule: utamakan penugasan ke agent dengan beban kerja paling rendah dari hitungan `open + pending`.
|
||||
</p>
|
||||
<div>
|
||||
<Button href="/settings">Back to Settings</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
47
app/settings/business-hours/page.tsx
Normal file
47
app/settings/business-hours/page.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { Button, SectionCard } from "@/components/ui";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function BusinessHoursSettingsPage() {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
if (session.role === "agent") {
|
||||
redirect("/unauthorized");
|
||||
}
|
||||
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: session.tenantId },
|
||||
select: {
|
||||
timezone: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!tenant) {
|
||||
redirect("/unauthorized");
|
||||
}
|
||||
|
||||
return (
|
||||
<ShellPage shell="admin" title="Business Hours" description="Jam operasional tenant.">
|
||||
<SectionCard title="Schedule">
|
||||
<div className="grid gap-4 md:max-w-2xl">
|
||||
<input
|
||||
className="rounded-xl border border-line px-4 py-3"
|
||||
defaultValue={`Timezone aktif: ${tenant.timezone}`}
|
||||
readOnly
|
||||
/>
|
||||
<p className="text-sm text-on-surface-variant">
|
||||
Saat ini pengaturan jam operasional belum memiliki tabel konfigurasi tersendiri di schema ini.
|
||||
</p>
|
||||
<div>
|
||||
<Button href="/settings">Back to Settings</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
46
app/settings/canned-responses/page.tsx
Normal file
46
app/settings/canned-responses/page.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { TablePlaceholder } from "@/components/placeholders";
|
||||
|
||||
function previewText(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return "-";
|
||||
}
|
||||
return value.length > 40 ? `${value.slice(0, 40)}...` : value;
|
||||
}
|
||||
|
||||
export default async function CannedResponsesPage() {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const templates = await prisma.messageTemplate.findMany({
|
||||
where: { tenantId: session.tenantId },
|
||||
orderBy: { createdAt: "desc" }
|
||||
});
|
||||
|
||||
return (
|
||||
<ShellPage shell="admin" title="Canned Responses" description="Library jawaban cepat untuk agent dan admin.">
|
||||
<TablePlaceholder
|
||||
title="Responses"
|
||||
columns={["Template", "Category", "Status", "Preview"]}
|
||||
rows={templates.map((template) => [
|
||||
template.name,
|
||||
template.category,
|
||||
template.approvalStatus,
|
||||
<div key={template.id} className="space-y-1">
|
||||
<p>{previewText(template.bodyText)}</p>
|
||||
<Link href={`/templates/${template.id}`} className="text-xs text-brand hover:underline">
|
||||
Open
|
||||
</Link>
|
||||
</div>
|
||||
])}
|
||||
/>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
84
app/settings/integrations/page.tsx
Normal file
84
app/settings/integrations/page.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { SectionCard } from "@/components/ui";
|
||||
import { headers } from "next/headers";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function IntegrationsSettingsPage() {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
if (session.role === "agent") {
|
||||
redirect("/unauthorized");
|
||||
}
|
||||
|
||||
const tenantFilter = session.role === "super_admin" ? {} : { tenantId: session.tenantId };
|
||||
|
||||
const [channels, recentWebhook] = await Promise.all([
|
||||
prisma.channel.findMany({
|
||||
where: tenantFilter,
|
||||
orderBy: { createdAt: "desc" }
|
||||
}),
|
||||
prisma.webhookEvent.findMany({
|
||||
where: tenantFilter,
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 4
|
||||
})
|
||||
]);
|
||||
|
||||
const host = (await headers()).get("host");
|
||||
const webhookBase = host ? `${process.env.NODE_ENV === "production" ? "https" : "http"}://${host}` : "";
|
||||
|
||||
const connectedCount = channels.filter((channel) => channel.status === "CONNECTED").length;
|
||||
const failedCount = channels.filter((channel) => channel.status === "ERROR").length;
|
||||
|
||||
return (
|
||||
<ShellPage
|
||||
shell="admin"
|
||||
title="Webhook / Integration Settings"
|
||||
description="Status provider, webhook URL, dan reconnection action."
|
||||
>
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<SectionCard title="Provider config">
|
||||
<p className="text-sm text-on-surface-variant">
|
||||
Webhook URL: {webhookBase ? `${webhookBase}/api/webhooks/whatsapp` : "/api/webhooks/whatsapp"}
|
||||
</p>
|
||||
<p className="text-sm text-on-surface-variant">
|
||||
Connected: {connectedCount} • Error: {failedCount} • Total channels: {channels.length}
|
||||
</p>
|
||||
<ul className="mt-3 space-y-2">
|
||||
{channels.map((channel) => (
|
||||
<li key={channel.id} className="rounded-xl border border-line bg-surface-container p-3">
|
||||
<p className="font-medium text-ink">{channel.channelName}</p>
|
||||
<p className="text-sm text-on-surface-variant">Status: {channel.status}</p>
|
||||
<p className="text-sm text-outline">Provider: {channel.provider}</p>
|
||||
<p className="text-xs text-outline">
|
||||
WABA ID: {channel.wabaId ?? "N/A"} • Phone ID: {channel.phoneNumberId ?? "N/A"}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
{channels.length === 0 ? <p className="text-sm text-on-surface-variant">Tidak ada channel terhubung.</p> : null}
|
||||
</ul>
|
||||
</SectionCard>
|
||||
<SectionCard title="Health state">
|
||||
<ul className="space-y-2">
|
||||
{recentWebhook.map((event) => (
|
||||
<li key={event.id} className="rounded-xl border border-line bg-surface-container p-3">
|
||||
<p className="text-sm text-on-surface-variant">
|
||||
{event.eventType} • {event.processStatus}
|
||||
</p>
|
||||
<p className="text-xs text-outline">
|
||||
{event.createdAt.toLocaleString("id-ID", { dateStyle: "medium", timeStyle: "short" })}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
{recentWebhook.length === 0 ? <p className="text-sm text-on-surface-variant">Belum ada event webhook terbaru.</p> : null}
|
||||
</ul>
|
||||
</SectionCard>
|
||||
</div>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
21
app/settings/page.tsx
Normal file
21
app/settings/page.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { TablePlaceholder } from "@/components/placeholders";
|
||||
|
||||
export default function SettingsPage() {
|
||||
return (
|
||||
<ShellPage shell="admin" title="Settings" description="Hub tenant profile, business hours, tags, canned responses, dan integrations.">
|
||||
<TablePlaceholder
|
||||
title="Settings modules"
|
||||
columns={["Module", "Purpose", "Route"]}
|
||||
rows={[
|
||||
["Profile", "Tenant identity", "/settings/profile"],
|
||||
["Business Hours", "Operational schedule", "/settings/business-hours"],
|
||||
["Auto Assignment", "Distribution rules", "/settings/auto-assignment"],
|
||||
["Tags", "Chat tag management", "/settings/tags"],
|
||||
["Canned Responses", "Quick reply library", "/settings/canned-responses"],
|
||||
["Integrations", "Webhook and provider setup", "/settings/integrations"]
|
||||
]}
|
||||
/>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
85
app/settings/profile/page.tsx
Normal file
85
app/settings/profile/page.tsx
Normal file
@ -0,0 +1,85 @@
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { Button, SectionCard } from "@/components/ui";
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { updateTenantProfile } from "@/lib/admin-crud";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function TenantProfileSettingsPage({
|
||||
searchParams
|
||||
}: {
|
||||
searchParams?: Promise<{ error?: string; success?: string }>;
|
||||
}) {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: session.tenantId },
|
||||
select: {
|
||||
name: true,
|
||||
slug: true,
|
||||
timezone: true,
|
||||
plan: { select: { name: true } }
|
||||
}
|
||||
});
|
||||
|
||||
if (!tenant) {
|
||||
redirect("/unauthorized");
|
||||
}
|
||||
|
||||
const params = await (searchParams ?? Promise.resolve({ error: undefined, success: undefined }));
|
||||
const infoMessage =
|
||||
params.success === "updated"
|
||||
? "Pengaturan tenant berhasil disimpan."
|
||||
: params.error === "missing_fields"
|
||||
? "Nama perusahaan, timezone, dan slug wajib diisi."
|
||||
: params.error === "tenant_slug_taken"
|
||||
? "Slug tenant sudah dipakai, pilih slug lain."
|
||||
: null;
|
||||
|
||||
return (
|
||||
<ShellPage shell="admin" title="Tenant Profile Settings" description="Identitas tenant dan informasi workspace.">
|
||||
<SectionCard title="Tenant profile">
|
||||
<form action={updateTenantProfile} className="grid gap-4 md:max-w-2xl">
|
||||
{infoMessage ? (
|
||||
<p className="rounded-xl border border-success/30 bg-success/10 p-3 text-sm text-success">{infoMessage}</p>
|
||||
) : null}
|
||||
<input
|
||||
className="rounded-xl border border-line px-4 py-3"
|
||||
defaultValue={tenant.name}
|
||||
name="companyName"
|
||||
placeholder="Company name"
|
||||
required
|
||||
/>
|
||||
<input
|
||||
className="rounded-xl border border-line px-4 py-3"
|
||||
defaultValue={tenant.timezone}
|
||||
name="timezone"
|
||||
placeholder="Timezone"
|
||||
required
|
||||
/>
|
||||
<input
|
||||
className="rounded-xl border border-line px-4 py-3"
|
||||
defaultValue={tenant.slug}
|
||||
name="slug"
|
||||
placeholder="Tenant slug"
|
||||
required
|
||||
/>
|
||||
<input
|
||||
className="rounded-xl border border-line px-4 py-3"
|
||||
defaultValue={tenant.plan.name}
|
||||
name="plan"
|
||||
placeholder="Plan"
|
||||
readOnly
|
||||
/>
|
||||
<div>
|
||||
<Button type="submit">Save settings</Button>
|
||||
</div>
|
||||
</form>
|
||||
</SectionCard>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
46
app/settings/tags/page.tsx
Normal file
46
app/settings/tags/page.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { TablePlaceholder } from "@/components/placeholders";
|
||||
|
||||
export default async function TagsSettingsPage() {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
if (session.role === "agent") {
|
||||
redirect("/unauthorized");
|
||||
}
|
||||
|
||||
const tags = await prisma.tag.findMany({
|
||||
where: { tenantId: session.tenantId },
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
conversationTags: true,
|
||||
contactTags: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: { name: "asc" }
|
||||
});
|
||||
|
||||
const rows = tags.map((tag) => [
|
||||
tag.name,
|
||||
tag.color ?? "-",
|
||||
String(tag._count.conversationTags + tag._count.contactTags)
|
||||
]);
|
||||
|
||||
return (
|
||||
<ShellPage shell="admin" title="Chat Tags Management" description="Daftar tag conversation dan contact.">
|
||||
<TablePlaceholder
|
||||
title="Tags"
|
||||
columns={["Tag", "Color", "Usage"]}
|
||||
rows={rows}
|
||||
/>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
84
app/super-admin/alerts/page.tsx
Normal file
84
app/super-admin/alerts/page.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { TablePlaceholder } from "@/components/placeholders";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
function formatTime(date: Date | null) {
|
||||
if (!date) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat("id-ID", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export default async function PlatformAlertsPage() {
|
||||
const session = await getSession();
|
||||
if (!session || session.role !== "super_admin") {
|
||||
redirect("/unauthorized");
|
||||
}
|
||||
|
||||
const [channels, webhookFailures, retryStateAlerts] = await Promise.all([
|
||||
prisma.channel.findMany({
|
||||
include: { tenant: true },
|
||||
orderBy: { updatedAt: "desc" }
|
||||
}),
|
||||
prisma.webhookEvent.findMany({
|
||||
where: { processStatus: "failed" },
|
||||
include: { tenant: true, channel: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 20
|
||||
}),
|
||||
prisma.backgroundJobState.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ lastRunStatus: "failed" },
|
||||
{ lastRunCompletedAt: null },
|
||||
{ lastRunCompletedAt: { lte: new Date(Date.now() - 60 * 60 * 1000) } }
|
||||
]
|
||||
},
|
||||
orderBy: { updatedAt: "desc" },
|
||||
take: 10
|
||||
})
|
||||
]);
|
||||
|
||||
const channelAlerts = channels
|
||||
.filter((channel) => channel.status !== "CONNECTED")
|
||||
.map((channel) => ({
|
||||
severity: channel.status === "DISCONNECTED" ? "High" : "Medium",
|
||||
tenant: channel.tenant.name,
|
||||
issue: `${channel.displayPhoneNumber || channel.channelName} disconnected`,
|
||||
triggered: formatTime(channel.lastSyncAt)
|
||||
}));
|
||||
|
||||
const webhookAlerts = webhookFailures.map((event) => ({
|
||||
severity: "Medium",
|
||||
tenant: event.tenant.name,
|
||||
issue: `${event.eventType} on ${event.providerEventId ?? event.channel?.channelName ?? "unknown"}`,
|
||||
triggered: formatTime(event.createdAt)
|
||||
}));
|
||||
const retryAlerts = retryStateAlerts.map((state) => ({
|
||||
severity: state.lastRunStatus === "failed" ? "High" : "Medium",
|
||||
tenant: "Platform",
|
||||
issue: `${state.jobName} ${state.lastRunStatus === "failed" ? "failed repeatedly" : "hasn't run recently"}`,
|
||||
triggered: formatTime(state.lastRunCompletedAt)
|
||||
}));
|
||||
|
||||
const alerts = [...channelAlerts, ...webhookAlerts, ...retryAlerts].slice(0, 30);
|
||||
|
||||
return (
|
||||
<ShellPage shell="super-admin" title="System Alerts" description="Alert platform seperti disconnected channels dan quota issues.">
|
||||
<TablePlaceholder
|
||||
title="Alerts"
|
||||
columns={["Severity", "Tenant", "Issue", "Triggered at"]}
|
||||
rows={alerts.map((alert) => [alert.severity, alert.tenant, alert.issue, alert.triggered])}
|
||||
/>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
45
app/super-admin/audit-log/page.tsx
Normal file
45
app/super-admin/audit-log/page.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { TablePlaceholder } from "@/components/placeholders";
|
||||
|
||||
function formatTime(value: Date) {
|
||||
return new Intl.DateTimeFormat("id-ID", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
export default async function SuperAdminAuditLogPage() {
|
||||
const session = await getSession();
|
||||
if (!session || session.role !== "super_admin") {
|
||||
redirect("/unauthorized");
|
||||
}
|
||||
|
||||
const events = await prisma.auditLog.findMany({
|
||||
include: { tenant: true, actorUser: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 80
|
||||
});
|
||||
|
||||
return (
|
||||
<ShellPage shell="super-admin" title="Audit Log" description="Log governance lintas tenant dan modul.">
|
||||
<TablePlaceholder
|
||||
title="Audit events"
|
||||
columns={["Time", "Tenant", "Actor", "Action", "Entity"]}
|
||||
rows={events.map((event) => [
|
||||
formatTime(event.createdAt),
|
||||
event.tenant.name,
|
||||
event.actorUser?.fullName ?? "System",
|
||||
event.action,
|
||||
event.entityId
|
||||
])}
|
||||
/>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
115
app/super-admin/billing/invoices/[invoiceId]/page.tsx
Normal file
115
app/super-admin/billing/invoices/[invoiceId]/page.tsx
Normal file
@ -0,0 +1,115 @@
|
||||
import Link from "next/link";
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { Badge, SectionCard } from "@/components/ui";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
function formatDate(value: Date | null) {
|
||||
if (!value) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat("id-ID", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function formatMoney(value: number) {
|
||||
return new Intl.NumberFormat("id-ID", {
|
||||
style: "currency",
|
||||
currency: "IDR",
|
||||
maximumFractionDigits: 0
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function statusTone(status: string) {
|
||||
if (status === "PAID") {
|
||||
return "success";
|
||||
}
|
||||
|
||||
if (status === "OVERDUE") {
|
||||
return "danger";
|
||||
}
|
||||
|
||||
return "warning";
|
||||
}
|
||||
|
||||
export default async function SuperAdminInvoiceDetailPage({
|
||||
params
|
||||
}: {
|
||||
params: Promise<{ invoiceId: string }>;
|
||||
}) {
|
||||
const session = await getSession();
|
||||
if (!session || session.role !== "super_admin") {
|
||||
redirect("/unauthorized");
|
||||
}
|
||||
|
||||
const { invoiceId } = await params;
|
||||
const invoice = await prisma.billingInvoice.findUnique({
|
||||
where: { id: invoiceId },
|
||||
include: {
|
||||
tenant: { select: { name: true, slug: true } },
|
||||
plan: { select: { name: true, code: true } }
|
||||
}
|
||||
});
|
||||
|
||||
if (!invoice) {
|
||||
redirect("/super-admin/billing/invoices?error=invoice_not_found");
|
||||
}
|
||||
|
||||
return (
|
||||
<ShellPage shell="super-admin" title="Invoice Detail" description="Invoice view untuk super admin.">
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<SectionCard title="Summary">
|
||||
<div className="space-y-2 text-sm text-on-surface-variant">
|
||||
<p>
|
||||
<strong className="text-on-surface">Invoice:</strong> {invoice.invoiceNumber}
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-on-surface">Tenant:</strong>{" "}
|
||||
<Link href={`/super-admin/tenants/${invoice.tenantId}`} className="text-brand hover:underline">
|
||||
{invoice.tenant.name} ({invoice.tenant.slug})
|
||||
</Link>
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-on-surface">Plan:</strong> {invoice.plan.name} ({invoice.plan.code})
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-on-surface">Period:</strong> {formatDate(invoice.periodStart)} - {formatDate(invoice.periodEnd)}
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-on-surface">Total amount:</strong> {formatMoney(invoice.totalAmount)}
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-on-surface">Subtotal:</strong> {formatMoney(invoice.subtotal)} | Tax: {formatMoney(invoice.taxAmount)}
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-on-surface">Status:</strong> <Badge tone={statusTone(invoice.paymentStatus)}>{invoice.paymentStatus}</Badge>
|
||||
</p>
|
||||
</div>
|
||||
</SectionCard>
|
||||
<SectionCard title="Timeline">
|
||||
<div className="space-y-2 text-sm text-on-surface-variant">
|
||||
<p>
|
||||
<strong className="text-on-surface">Issued:</strong> {formatDate(invoice.createdAt)}
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-on-surface">Due date:</strong> {formatDate(invoice.dueDate)}
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-on-surface">Paid at:</strong> {formatDate(invoice.paidAt)}
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-on-surface">Updated:</strong> {formatDate(invoice.updatedAt)}
|
||||
</p>
|
||||
</div>
|
||||
</SectionCard>
|
||||
</div>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
60
app/super-admin/billing/invoices/page.tsx
Normal file
60
app/super-admin/billing/invoices/page.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { TablePlaceholder } from "@/components/placeholders";
|
||||
import Link from "next/link";
|
||||
|
||||
function formatDate(value: Date | null) {
|
||||
if (!value) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat("id-ID", {
|
||||
month: "short",
|
||||
year: "numeric"
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function formatMoney(value: number) {
|
||||
return new Intl.NumberFormat("id-ID", {
|
||||
style: "currency",
|
||||
currency: "IDR",
|
||||
maximumFractionDigits: 0
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
export default async function SuperAdminInvoicesPage() {
|
||||
const session = await getSession();
|
||||
if (!session || session.role !== "super_admin") {
|
||||
redirect("/unauthorized");
|
||||
}
|
||||
|
||||
const invoices = await prisma.billingInvoice.findMany({
|
||||
include: { tenant: true, plan: true },
|
||||
orderBy: { createdAt: "desc" }
|
||||
});
|
||||
|
||||
return (
|
||||
<ShellPage shell="super-admin" title="Invoices" description="Invoice seluruh tenant.">
|
||||
<TablePlaceholder
|
||||
title="Invoices"
|
||||
columns={["Invoice", "Tenant", "Period", "Amount", "Status"]}
|
||||
rows={invoices.map((invoice) => [
|
||||
<Link
|
||||
key={`${invoice.id}-invoice`}
|
||||
href={`/super-admin/billing/invoices/${invoice.id}`}
|
||||
className="text-brand hover:underline"
|
||||
>
|
||||
{invoice.invoiceNumber}
|
||||
</Link>,
|
||||
invoice.tenant.name,
|
||||
`${formatDate(invoice.periodStart)} - ${formatDate(invoice.periodEnd)} (${invoice.plan.name})`,
|
||||
formatMoney(invoice.totalAmount),
|
||||
invoice.paymentStatus
|
||||
])}
|
||||
/>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
40
app/super-admin/billing/plans/page.tsx
Normal file
40
app/super-admin/billing/plans/page.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { TablePlaceholder } from "@/components/placeholders";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
function formatMoney(value: number) {
|
||||
return new Intl.NumberFormat("id-ID", {
|
||||
style: "currency",
|
||||
currency: "IDR",
|
||||
maximumFractionDigits: 0
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
export default async function SuperAdminPlanCatalogPage() {
|
||||
const session = await getSession();
|
||||
if (!session || session.role !== "super_admin") {
|
||||
redirect("/unauthorized");
|
||||
}
|
||||
|
||||
const plans = await prisma.subscriptionPlan.findMany({
|
||||
orderBy: { createdAt: "asc" }
|
||||
});
|
||||
|
||||
return (
|
||||
<ShellPage shell="super-admin" title="Plan Catalog" description="Master plan langganan untuk tenant.">
|
||||
<TablePlaceholder
|
||||
title="Plans"
|
||||
columns={["Plan", "Price", "Message quota", "Seat quota", "Broadcast quota"]}
|
||||
rows={plans.map((plan) => [
|
||||
`${plan.name} (${plan.code})`,
|
||||
formatMoney(plan.priceMonthly),
|
||||
String(plan.messageQuota),
|
||||
String(plan.seatQuota),
|
||||
String(plan.broadcastQuota)
|
||||
])}
|
||||
/>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
55
app/super-admin/billing/subscriptions/page.tsx
Normal file
55
app/super-admin/billing/subscriptions/page.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { TablePlaceholder } from "@/components/placeholders";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
function formatDate(date: Date | null | undefined) {
|
||||
if (!date) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat("id-ID", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric"
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export default async function SuperAdminSubscriptionsPage() {
|
||||
const session = await getSession();
|
||||
if (!session || session.role !== "super_admin") {
|
||||
redirect("/unauthorized");
|
||||
}
|
||||
|
||||
const tenants = await prisma.tenant.findMany({
|
||||
include: {
|
||||
plan: true,
|
||||
billingInvoices: {
|
||||
take: 1,
|
||||
orderBy: { dueDate: "desc" },
|
||||
select: { dueDate: true, paymentStatus: true }
|
||||
},
|
||||
_count: {
|
||||
select: { users: true }
|
||||
}
|
||||
},
|
||||
orderBy: { createdAt: "desc" }
|
||||
});
|
||||
|
||||
return (
|
||||
<ShellPage shell="super-admin" title="Tenant Subscriptions" description="Plan aktif, usage, dan payment status lintas tenant.">
|
||||
<TablePlaceholder
|
||||
title="Subscriptions"
|
||||
columns={["Tenant", "Plan", "Usage", "Renewal", "Payment"]}
|
||||
rows={tenants.map((tenant) => [
|
||||
tenant.name,
|
||||
tenant.plan.name,
|
||||
`${tenant._count.users}/${tenant.plan.seatQuota}`,
|
||||
formatDate(tenant.billingInvoices[0]?.dueDate),
|
||||
tenant.billingInvoices[0]?.paymentStatus ?? "No invoice"
|
||||
])}
|
||||
/>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
96
app/super-admin/channels/[channelId]/page.tsx
Normal file
96
app/super-admin/channels/[channelId]/page.tsx
Normal file
@ -0,0 +1,96 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { SectionCard } from "@/components/ui";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
function formatDate(date: Date | null) {
|
||||
if (!date) {
|
||||
return "Not synced";
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat("id-ID", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export default async function SuperAdminChannelDetailPage({
|
||||
params
|
||||
}: {
|
||||
params: Promise<{ channelId: string }>;
|
||||
}) {
|
||||
const session = await getSession();
|
||||
if (!session || session.role !== "super_admin") {
|
||||
redirect("/unauthorized");
|
||||
}
|
||||
|
||||
const { channelId } = await params;
|
||||
|
||||
const channel = await prisma.channel.findUnique({
|
||||
where: { id: channelId },
|
||||
include: {
|
||||
tenant: true,
|
||||
conversations: {
|
||||
where: {},
|
||||
take: 5,
|
||||
orderBy: { lastMessageAt: "desc" }
|
||||
},
|
||||
webhookEvents: {
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 8
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!channel) {
|
||||
redirect("/super-admin/channels?error=channel_not_found");
|
||||
}
|
||||
|
||||
const failedWebhookCount = channel.webhookEvents.filter((item) => item.processStatus === "failed").length;
|
||||
|
||||
return (
|
||||
<ShellPage shell="super-admin" title="Channel Detail" description="Phone status, webhook health, failure summary, dan reconnect action.">
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<SectionCard title="Channel info">
|
||||
<p className="text-sm text-on-surface-variant">Tenant: {channel.tenant.name}</p>
|
||||
<p className="text-sm text-on-surface-variant">Provider: {channel.provider}</p>
|
||||
<p className="text-sm text-on-surface-variant">Channel name: {channel.channelName}</p>
|
||||
<p className="text-sm text-on-surface-variant">WABA ID: {channel.wabaId ?? "-"}</p>
|
||||
<p className="text-sm text-on-surface-variant">Phone Number ID: {channel.phoneNumberId ?? "-"}</p>
|
||||
<p className="text-sm text-on-surface-variant">Display Number: {channel.displayPhoneNumber ?? "-"}</p>
|
||||
<p className="text-sm text-on-surface-variant">Status: {channel.status}</p>
|
||||
<p className="text-sm text-on-surface-variant">Webhook status: {channel.webhookStatus ?? "unknown"}</p>
|
||||
<p className="text-sm text-on-surface-variant">Last sync: {formatDate(channel.lastSyncAt)}</p>
|
||||
<div className="mt-4 flex gap-3">
|
||||
<Link href={`/super-admin/tenants/${channel.tenantId}`} className="text-brand hover:underline">
|
||||
Open tenant
|
||||
</Link>
|
||||
<Link href="/super-admin/channels" className="text-brand hover:underline">
|
||||
Back to channels
|
||||
</Link>
|
||||
</div>
|
||||
</SectionCard>
|
||||
<SectionCard title="Health">
|
||||
<p className="text-sm text-on-surface-variant">Webhook failures: {failedWebhookCount}</p>
|
||||
<p className="text-sm text-on-surface-variant">Conversations tracked: {channel.conversations.length}</p>
|
||||
<ul className="mt-2 space-y-2">
|
||||
{channel.webhookEvents.map((event) => (
|
||||
<li key={event.id} className="rounded-xl border border-line bg-surface-container p-3">
|
||||
<p className="text-xs text-outline">{event.eventType}</p>
|
||||
<p className="text-sm text-on-surface">Status: {event.processStatus}</p>
|
||||
<p className="text-xs text-outline">Created: {formatDate(event.createdAt)}</p>
|
||||
</li>
|
||||
))}
|
||||
{channel.webhookEvents.length === 0 ? <p className="text-sm text-on-surface-variant">No webhook events.</p> : null}
|
||||
</ul>
|
||||
</SectionCard>
|
||||
</div>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
52
app/super-admin/channels/page.tsx
Normal file
52
app/super-admin/channels/page.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
import { PlaceholderActions, ShellPage } from "@/components/page-templates";
|
||||
import { TablePlaceholder } from "@/components/placeholders";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
function formatDate(date: Date | null) {
|
||||
if (!date) {
|
||||
return "Not synced";
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat("id-ID", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export default async function SuperAdminChannelsPage() {
|
||||
const session = await getSession();
|
||||
if (!session || session.role !== "super_admin") {
|
||||
redirect("/unauthorized");
|
||||
}
|
||||
|
||||
const channels = await prisma.channel.findMany({
|
||||
include: { tenant: true },
|
||||
orderBy: { updatedAt: "desc" }
|
||||
});
|
||||
|
||||
return (
|
||||
<ShellPage
|
||||
shell="super-admin"
|
||||
title="Channels"
|
||||
description="Connected numbers, webhook health, dan last sync."
|
||||
actions={<PlaceholderActions primaryHref="/super-admin/tenants" primaryLabel="Tenant list" />}
|
||||
>
|
||||
<TablePlaceholder
|
||||
title="Channel list"
|
||||
columns={["Number", "Tenant", "Provider", "Status", "Webhook"]}
|
||||
rows={channels.map((channel) => [
|
||||
channel.displayPhoneNumber || "N/A",
|
||||
channel.tenant.name,
|
||||
channel.provider,
|
||||
channel.status,
|
||||
`${channel.webhookStatus || "unknown"} • ${formatDate(channel.lastSyncAt)}`
|
||||
])}
|
||||
/>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
18
app/super-admin/page.tsx
Normal file
18
app/super-admin/page.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import { DashboardPlaceholder } from "@/components/placeholders";
|
||||
import { PlaceholderActions, ShellPage } from "@/components/page-templates";
|
||||
import { getPlatformSummary } from "@/lib/platform-data";
|
||||
|
||||
export default async function SuperAdminDashboardPage() {
|
||||
const data = await getPlatformSummary();
|
||||
|
||||
return (
|
||||
<ShellPage
|
||||
shell="super-admin"
|
||||
title="Super Admin Dashboard"
|
||||
description="Global KPI, tenant health, channel failures, dan subscription overview."
|
||||
actions={<PlaceholderActions primaryHref="/super-admin/tenants/new" primaryLabel="Create tenant" secondaryHref="/super-admin/channels" secondaryLabel="View channels" />}
|
||||
>
|
||||
<DashboardPlaceholder stats={data.stats} priorityQueue={data.tenants} />
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
13
app/super-admin/reports/page.tsx
Normal file
13
app/super-admin/reports/page.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { DashboardPlaceholder } from "@/components/placeholders";
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { getPlatformSummary } from "@/lib/platform-data";
|
||||
|
||||
export default async function SuperAdminReportsPage() {
|
||||
const data = await getPlatformSummary();
|
||||
|
||||
return (
|
||||
<ShellPage shell="super-admin" title="Platform Reports" description="Global traffic, tenant growth, usage, dan failure monitoring.">
|
||||
<DashboardPlaceholder stats={data.stats} priorityQueue={data.tenants} />
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
45
app/super-admin/security-events/page.tsx
Normal file
45
app/super-admin/security-events/page.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { TablePlaceholder } from "@/components/placeholders";
|
||||
|
||||
function formatTime(date: Date) {
|
||||
return new Intl.DateTimeFormat("id-ID", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function normalizeAction(action: string) {
|
||||
return action.includes("failed") ? "Failed" : action.replace(/_/g, " ");
|
||||
}
|
||||
|
||||
export default async function SecurityEventsPage() {
|
||||
const session = await getSession();
|
||||
if (!session || session.role !== "super_admin") {
|
||||
redirect("/unauthorized");
|
||||
}
|
||||
|
||||
const events = await prisma.auditLog.findMany({
|
||||
where: {
|
||||
OR: [{ action: { contains: "failed" } }, { action: { contains: "permission" } }, { action: { contains: "security" } }]
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 30
|
||||
});
|
||||
|
||||
return (
|
||||
<ShellPage shell="super-admin" title="Security Events" description="Failed logins, suspicious access, dan permission changes.">
|
||||
<TablePlaceholder
|
||||
title="Security feed"
|
||||
columns={["Time", "Type", "Tenant ID", "Status"]}
|
||||
rows={events.map((event) => [formatTime(event.createdAt), normalizeAction(event.action), event.tenantId, "Detected"])}
|
||||
/>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
100
app/super-admin/settings/page.tsx
Normal file
100
app/super-admin/settings/page.tsx
Normal file
@ -0,0 +1,100 @@
|
||||
import Link from "next/link";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { TablePlaceholder } from "@/components/placeholders";
|
||||
|
||||
function formatDate(date: Date | null) {
|
||||
if (!date) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat("id-ID", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric"
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export default async function SuperAdminSettingsPage() {
|
||||
const session = await getSession();
|
||||
if (!session || session.role !== "super_admin") {
|
||||
return (
|
||||
<ShellPage shell="super-admin" title="Platform Settings" description="Pricing config, feature flags, dan template policy config.">
|
||||
<p className="rounded-xl border border-warning/30 bg-warning/10 p-3 text-sm text-warning">Akses super-admin diperlukan.</p>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
|
||||
const [tenantCountByStatus, planCount, lastInvoiceDate, channelStatus] = await Promise.all([
|
||||
prisma.tenant.groupBy({
|
||||
by: ["status"],
|
||||
_count: { _all: true }
|
||||
}),
|
||||
prisma.subscriptionPlan.count(),
|
||||
prisma.billingInvoice.findFirst({
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { createdAt: true }
|
||||
}),
|
||||
prisma.channel.groupBy({
|
||||
by: ["status"],
|
||||
_count: { _all: true }
|
||||
})
|
||||
]);
|
||||
|
||||
const modules = [
|
||||
{
|
||||
name: "Subscription plans",
|
||||
purpose: "Plan catalog dan metadata harga",
|
||||
route: "/super-admin/billing/plans",
|
||||
status: `${planCount} plans`
|
||||
},
|
||||
{
|
||||
name: "Tenant management",
|
||||
purpose: "Pengelolaan tenant, status, dan limit",
|
||||
route: "/super-admin/tenants",
|
||||
status: `${tenantCountByStatus.reduce((acc, item) => acc + item._count._all, 0)} tenants`
|
||||
},
|
||||
{
|
||||
name: "Channel registry",
|
||||
purpose: "Provider channel dan health status",
|
||||
route: "/super-admin/channels",
|
||||
status: `Connected: ${channelStatus.find((item) => item.status === "CONNECTED")?._count._all ?? 0}`
|
||||
},
|
||||
{
|
||||
name: "Webhook logs",
|
||||
purpose: "Monitoring event provider",
|
||||
route: "/super-admin/webhook-logs",
|
||||
status: "Realtime stream"
|
||||
},
|
||||
{
|
||||
name: "Template policy",
|
||||
purpose: "Approval dan pembatasan template",
|
||||
route: "/templates",
|
||||
status: "Review by tenant"
|
||||
},
|
||||
{
|
||||
name: "Invoice monitoring",
|
||||
purpose: "Status pembayaran tenant",
|
||||
route: "/super-admin/billing/invoices",
|
||||
status: lastInvoiceDate ? `Last: ${formatDate(lastInvoiceDate.createdAt)}` : "No invoices"
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<ShellPage shell="super-admin" title="Platform Settings" description="Overview setting-platform berdasarkan data operasional real-time.">
|
||||
<TablePlaceholder
|
||||
title="Platform settings modules"
|
||||
columns={["Module", "Purpose", "Route", "Status"]}
|
||||
rows={modules.map((module) => [
|
||||
module.name,
|
||||
module.purpose,
|
||||
<Link key={`${module.name}-route`} href={module.route} className="text-brand hover:underline">
|
||||
{module.route}
|
||||
</Link>,
|
||||
module.status
|
||||
])}
|
||||
/>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
73
app/super-admin/tenants/[tenantId]/channels/new/page.tsx
Normal file
73
app/super-admin/tenants/[tenantId]/channels/new/page.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { Button, SectionCard } from "@/components/ui";
|
||||
import { connectTenantChannel } from "@/lib/admin-crud";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { ChannelStatus } from "@prisma/client";
|
||||
|
||||
export default async function ConnectTenantChannelPage({
|
||||
params,
|
||||
searchParams
|
||||
}: {
|
||||
params: Promise<{ tenantId: string }>;
|
||||
searchParams?: Promise<{ error?: string }>;
|
||||
}) {
|
||||
const session = await getSession();
|
||||
if (!session || session.role !== "super_admin") {
|
||||
redirect("/unauthorized");
|
||||
}
|
||||
|
||||
const { tenantId } = await params;
|
||||
const [tenant, errorResult] = await Promise.all([
|
||||
prisma.tenant.findUnique({ where: { id: tenantId }, select: { id: true, name: true } }),
|
||||
searchParams ?? Promise.resolve({ error: undefined })
|
||||
]);
|
||||
|
||||
if (!tenant) {
|
||||
redirect("/super-admin/tenants?error=tenant_not_found");
|
||||
}
|
||||
|
||||
const error = errorResult.error;
|
||||
const errorMessage =
|
||||
error === "missing_fields"
|
||||
? "Semua field wajib diisi."
|
||||
: error === "tenant_not_found"
|
||||
? "Tenant tidak ditemukan."
|
||||
: error === "invalid_status"
|
||||
? "Status channel tidak valid."
|
||||
: null;
|
||||
|
||||
return (
|
||||
<ShellPage shell="super-admin" title="Connect Channel" description={`Hubungkan WABA ID, phone number ID, dan webhook config ke ${tenant.name}.`}>
|
||||
<SectionCard title="Channel form">
|
||||
<form action={connectTenantChannel} className="grid gap-4 md:max-w-3xl">
|
||||
<input type="hidden" name="tenantId" value={tenant.id} />
|
||||
{errorMessage ? <p className="rounded-xl border border-warning/30 bg-warning/10 p-3 text-sm text-warning">{errorMessage}</p> : null}
|
||||
<input name="provider" className="rounded-xl border border-line px-4 py-3" placeholder="Provider" required />
|
||||
<input name="channelName" className="rounded-xl border border-line px-4 py-3" placeholder="Channel name" required />
|
||||
<input name="wabaId" className="rounded-xl border border-line px-4 py-3" placeholder="WABA ID" required />
|
||||
<input name="phoneNumberId" className="rounded-xl border border-line px-4 py-3" placeholder="Phone Number ID" required />
|
||||
<input name="displayPhoneNumber" className="rounded-xl border border-line px-4 py-3" placeholder="Display Number" required />
|
||||
<label className="text-sm text-on-surface-variant">
|
||||
<span>Status awal</span>
|
||||
<select name="status" defaultValue={ChannelStatus.PENDING} className="mt-2 w-full rounded-xl border border-line px-4 py-3">
|
||||
{Object.values(ChannelStatus).map((status) => (
|
||||
<option key={status} value={status}>
|
||||
{status}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="flex gap-3">
|
||||
<Button type="submit">Save channel</Button>
|
||||
<Button href={`/super-admin/tenants/${tenant.id}`} variant="secondary">
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</SectionCard>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
93
app/super-admin/tenants/[tenantId]/edit/page.tsx
Normal file
93
app/super-admin/tenants/[tenantId]/edit/page.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { Button, SectionCard } from "@/components/ui";
|
||||
import { updateTenant } from "@/lib/admin-crud";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
function formatTenantLabel(tenant: { status: string }) {
|
||||
return tenant.status;
|
||||
}
|
||||
|
||||
export default async function EditTenantPage({
|
||||
params,
|
||||
searchParams
|
||||
}: {
|
||||
params: Promise<{ tenantId: string }>;
|
||||
searchParams?: Promise<{ error?: string }>;
|
||||
}) {
|
||||
const session = await getSession();
|
||||
if (!session || session.role !== "super_admin") {
|
||||
redirect("/unauthorized");
|
||||
}
|
||||
|
||||
const { tenantId } = await params;
|
||||
const [tenant, plans, query] = await Promise.all([
|
||||
prisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
include: { plan: true }
|
||||
}),
|
||||
prisma.subscriptionPlan.findMany({ orderBy: { priceMonthly: "asc" } }),
|
||||
searchParams ?? Promise.resolve({ error: undefined })
|
||||
]);
|
||||
|
||||
if (!tenant) {
|
||||
redirect("/super-admin/tenants?error=tenant_not_found");
|
||||
}
|
||||
|
||||
const error = query.error;
|
||||
const infoMessage =
|
||||
error === "missing_fields"
|
||||
? "Semua field wajib diisi."
|
||||
: error === "slug_exists"
|
||||
? "Slug sudah dipakai tenant lain."
|
||||
: error === "invalid_plan"
|
||||
? "Plan tidak valid."
|
||||
: null;
|
||||
|
||||
return (
|
||||
<ShellPage shell="super-admin" title="Edit Tenant" description="Update tenant profile dan subscription metadata.">
|
||||
<SectionCard title="Tenant form">
|
||||
<form action={updateTenant} className="grid gap-4 md:max-w-3xl">
|
||||
<input type="hidden" name="tenantId" value={tenant.id} />
|
||||
{infoMessage ? (
|
||||
<p className="rounded-xl border border-warning/30 bg-warning/10 p-3 text-sm text-warning">{infoMessage}</p>
|
||||
) : null}
|
||||
<input
|
||||
name="name"
|
||||
className="rounded-xl border border-line px-4 py-3"
|
||||
defaultValue={tenant.name}
|
||||
placeholder="Company name"
|
||||
required
|
||||
/>
|
||||
<input
|
||||
name="companyName"
|
||||
className="rounded-xl border border-line px-4 py-3"
|
||||
defaultValue={tenant.companyName}
|
||||
placeholder="Company legal name"
|
||||
required
|
||||
/>
|
||||
<input name="slug" className="rounded-xl border border-line px-4 py-3" defaultValue={tenant.slug} placeholder="Tenant slug" required />
|
||||
<select name="status" required className="rounded-xl border border-line px-4 py-3" defaultValue={formatTenantLabel(tenant)}>
|
||||
<option value="ACTIVE">Active</option>
|
||||
<option value="TRIAL">Trial</option>
|
||||
<option value="SUSPENDED">Suspended</option>
|
||||
<option value="INACTIVE">Inactive</option>
|
||||
</select>
|
||||
<select name="planId" required className="rounded-xl border border-line px-4 py-3" defaultValue={tenant.planId}>
|
||||
{plans.map((plan) => (
|
||||
<option key={plan.id} value={plan.id}>
|
||||
{plan.name} ({plan.code})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input name="timezone" className="rounded-xl border border-line px-4 py-3" placeholder="Timezone" defaultValue={tenant.timezone} required />
|
||||
<div>
|
||||
<Button type="submit">Save changes</Button>
|
||||
</div>
|
||||
</form>
|
||||
</SectionCard>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
161
app/super-admin/tenants/[tenantId]/page.tsx
Normal file
161
app/super-admin/tenants/[tenantId]/page.tsx
Normal file
@ -0,0 +1,161 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { SectionCard } from "@/components/ui";
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
function formatDate(value: Date | null | undefined) {
|
||||
if (!value) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat("id-ID", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function formatMoney(value: number) {
|
||||
return new Intl.NumberFormat("id-ID", {
|
||||
style: "currency",
|
||||
currency: "IDR",
|
||||
maximumFractionDigits: 0
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
export default async function TenantDetailPage({ params }: { params: Promise<{ tenantId: string }> }) {
|
||||
const session = await getSession();
|
||||
if (!session || session.role !== "super_admin") {
|
||||
redirect("/unauthorized");
|
||||
}
|
||||
|
||||
const { tenantId } = await params;
|
||||
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
include: {
|
||||
plan: true,
|
||||
channels: {
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: {
|
||||
_count: {
|
||||
select: { conversations: true }
|
||||
}
|
||||
}
|
||||
},
|
||||
users: {
|
||||
orderBy: { fullName: "asc" }
|
||||
},
|
||||
contacts: {
|
||||
select: { id: true }
|
||||
},
|
||||
billingInvoices: {
|
||||
include: { plan: true },
|
||||
orderBy: { dueDate: "desc" },
|
||||
take: 5
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!tenant) {
|
||||
redirect("/super-admin/tenants?error=tenant_not_found");
|
||||
}
|
||||
|
||||
const [openConversationCount, invoiceTotal, unresolvedWebhook] = await Promise.all([
|
||||
prisma.conversation.count({ where: { tenantId, status: { in: ["OPEN", "PENDING"] } } }),
|
||||
prisma.billingInvoice.aggregate({
|
||||
where: { tenantId },
|
||||
_sum: { totalAmount: true }
|
||||
}),
|
||||
prisma.webhookEvent.count({ where: { tenantId, processStatus: "failed" } })
|
||||
]);
|
||||
|
||||
return (
|
||||
<ShellPage shell="super-admin" title="Tenant Detail" description="Company profile, plan, channel status, usage summary, dan seat usage.">
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<SectionCard title="Tenant summary">
|
||||
<p className="text-sm text-on-surface-variant">Name: {tenant.name}</p>
|
||||
<p className="text-sm text-on-surface-variant">Company: {tenant.companyName}</p>
|
||||
<p className="text-sm text-on-surface-variant">Slug: {tenant.slug}</p>
|
||||
<p className="text-sm text-on-surface-variant">Status: {tenant.status}</p>
|
||||
<p className="text-sm text-on-surface-variant">Timezone: {tenant.timezone}</p>
|
||||
<p className="text-sm text-on-surface-variant">Plan: {tenant.plan.name}</p>
|
||||
<p className="text-sm text-on-surface-variant">Seats: {tenant.users.length}/{tenant.plan.seatQuota}</p>
|
||||
<p className="text-sm text-on-surface-variant">Contacts: {tenant.contacts.length}</p>
|
||||
<p className="mt-3 space-y-1 text-sm text-on-surface">
|
||||
Open/Pending conversations: {openConversationCount}
|
||||
<br />
|
||||
Unresolved webhook events: {unresolvedWebhook}
|
||||
<br />
|
||||
Outstanding invoices: {tenant.billingInvoices.filter((invoice) => invoice.paymentStatus !== "PAID").length}
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
<Link href={`/super-admin/tenants/${tenant.id}/edit`} className="text-brand hover:underline">
|
||||
Edit tenant
|
||||
</Link>
|
||||
<Link href={`/super-admin/tenants/${tenant.id}/channels/new`} className="text-brand hover:underline">
|
||||
Connect channel
|
||||
</Link>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="Channels">
|
||||
{tenant.channels.length === 0 ? <p className="text-sm text-outline">No channels connected.</p> : null}
|
||||
<ul className="space-y-2">
|
||||
{tenant.channels.map((channel) => (
|
||||
<li key={channel.id} className="rounded-xl border border-line bg-surface-container p-3">
|
||||
<p className="font-medium text-ink">{channel.channelName}</p>
|
||||
<p className="text-sm text-on-surface-variant">{channel.displayPhoneNumber || "No number"}</p>
|
||||
<p className="text-sm text-outline">Status: {channel.status}</p>
|
||||
<p className="text-xs text-outline">Conversations: {channel._count.conversations}</p>
|
||||
<Link href={`/super-admin/channels/${channel.id}`} className="text-xs text-brand hover:underline">
|
||||
Open detail
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="Recent invoices">
|
||||
{tenant.billingInvoices.length === 0 ? <p className="text-sm text-on-surface-variant">No invoices found.</p> : null}
|
||||
<ul className="space-y-2">
|
||||
{tenant.billingInvoices.map((invoice) => (
|
||||
<li key={invoice.id} className="rounded-xl border border-line bg-surface-container p-3 text-sm">
|
||||
<p className="font-medium text-ink">{invoice.invoiceNumber}</p>
|
||||
<p className="text-on-surface-variant">
|
||||
{invoice.plan.name} • {formatMoney(invoice.totalAmount)} • {invoice.paymentStatus}
|
||||
</p>
|
||||
<p className="text-xs text-outline">Due: {formatDate(invoice.dueDate)}</p>
|
||||
<Link href={`/super-admin/billing/invoices/${invoice.id}`} className="text-xs text-brand hover:underline">
|
||||
View
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="Recent team members">
|
||||
{tenant.users.length === 0 ? <p className="text-sm text-on-surface-variant">No users.</p> : null}
|
||||
<ul className="space-y-2">
|
||||
{tenant.users.map((user) => (
|
||||
<li key={user.id} className="rounded-xl border border-line bg-surface-container p-3 text-sm">
|
||||
<p className="font-medium text-ink">{user.fullName}</p>
|
||||
<p className="text-on-surface-variant">{user.email}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</SectionCard>
|
||||
</div>
|
||||
|
||||
<SectionCard title="Tenant finance totals">
|
||||
<p className="text-sm text-on-surface-variant">Total invoice amount: {formatMoney(invoiceTotal._sum.totalAmount ?? 0)}</p>
|
||||
</SectionCard>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
|
||||
66
app/super-admin/tenants/new/page.tsx
Normal file
66
app/super-admin/tenants/new/page.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { Button, SectionCard } from "@/components/ui";
|
||||
import { createTenant } from "@/lib/admin-crud";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function NewTenantPage({
|
||||
searchParams
|
||||
}: {
|
||||
searchParams?: Promise<{ error?: string }>;
|
||||
}) {
|
||||
const session = await getSession();
|
||||
if (!session || session.role !== "super_admin") {
|
||||
redirect("/unauthorized");
|
||||
}
|
||||
|
||||
const [plans, params] = await Promise.all([
|
||||
prisma.subscriptionPlan.findMany({ orderBy: { priceMonthly: "asc" } }),
|
||||
searchParams ?? Promise.resolve({ error: undefined })
|
||||
]);
|
||||
|
||||
const error = params.error;
|
||||
const errorMessage =
|
||||
error === "missing_fields"
|
||||
? "Nama perusahaan, slug, timezone, dan plan wajib diisi."
|
||||
: error === "invalid_plan"
|
||||
? "Plan tidak valid."
|
||||
: error === "slug_exists"
|
||||
? "Slug tenant sudah dipakai."
|
||||
: error === "admin_email_exists"
|
||||
? "Email admin awal sudah terpakai."
|
||||
: null;
|
||||
|
||||
return (
|
||||
<ShellPage shell="super-admin" title="Create Tenant" description="Setup tenant baru beserta plan dan admin awal.">
|
||||
<SectionCard title="Tenant form">
|
||||
<form action={createTenant} className="grid gap-4 md:max-w-3xl md:grid-cols-2">
|
||||
{errorMessage ? <p className="col-span-2 rounded-xl border border-warning/30 bg-warning/10 p-3 text-sm text-warning">{errorMessage}</p> : null}
|
||||
<input name="name" className="rounded-xl border border-line px-4 py-3" placeholder="Company name" required />
|
||||
<input name="slug" className="rounded-xl border border-line px-4 py-3" placeholder="Tenant slug" required />
|
||||
<input name="timezone" className="rounded-xl border border-line px-4 py-3" placeholder="Timezone" required />
|
||||
<select name="planId" required className="rounded-xl border border-line px-4 py-3" defaultValue="">
|
||||
<option value="">Pilih plan</option>
|
||||
{plans.map((plan) => (
|
||||
<option key={plan.id} value={plan.id}>
|
||||
{plan.name} ({plan.code}) - Rp {plan.priceMonthly.toLocaleString("id-ID")}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input name="adminFullName" className="rounded-xl border border-line px-4 py-3" placeholder="Nama admin awal" />
|
||||
<input name="adminEmail" type="email" className="rounded-xl border border-line px-4 py-3" placeholder="Initial admin email" />
|
||||
<input
|
||||
name="adminPassword"
|
||||
type="password"
|
||||
className="rounded-xl border border-line px-4 py-3 md:col-span-2"
|
||||
placeholder="Password awal admin (kosong: kirim undangan)"
|
||||
/>
|
||||
<div className="md:col-span-2">
|
||||
<Button type="submit">Create tenant</Button>
|
||||
</div>
|
||||
</form>
|
||||
</SectionCard>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
22
app/super-admin/tenants/page.tsx
Normal file
22
app/super-admin/tenants/page.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import { PlaceholderActions, ShellPage } from "@/components/page-templates";
|
||||
import { TablePlaceholder } from "@/components/placeholders";
|
||||
import { getTenantsData } from "@/lib/platform-data";
|
||||
|
||||
export default async function SuperAdminTenantsPage() {
|
||||
const tenants = await getTenantsData();
|
||||
|
||||
return (
|
||||
<ShellPage
|
||||
shell="super-admin"
|
||||
title="Tenants"
|
||||
description="Daftar tenant, plan, seat usage, dan status channel."
|
||||
actions={<PlaceholderActions primaryHref="/super-admin/tenants/new" primaryLabel="Create tenant" />}
|
||||
>
|
||||
<TablePlaceholder
|
||||
title="Tenant list"
|
||||
columns={["Tenant", "Plan", "Status", "Channels", "Seats"]}
|
||||
rows={tenants.map((tenant) => [tenant.name, tenant.plan, tenant.status, tenant.channels, tenant.seats])}
|
||||
/>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
43
app/super-admin/webhook-logs/page.tsx
Normal file
43
app/super-admin/webhook-logs/page.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { TablePlaceholder } from "@/components/placeholders";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
function formatTime(value: Date) {
|
||||
return new Intl.DateTimeFormat("id-ID", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
export default async function WebhookLogsPage() {
|
||||
const session = await getSession();
|
||||
if (!session || session.role !== "super_admin") {
|
||||
redirect("/unauthorized");
|
||||
}
|
||||
|
||||
const events = await prisma.webhookEvent.findMany({
|
||||
include: { tenant: true, channel: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 120
|
||||
});
|
||||
|
||||
return (
|
||||
<ShellPage shell="super-admin" title="Webhook Logs" description="Raw provider event logs dan process status.">
|
||||
<TablePlaceholder
|
||||
title="Webhook events"
|
||||
columns={["Event type", "Provider event ID", "Tenant", "Status"]}
|
||||
rows={events.map((event) => [
|
||||
`${event.eventType} (${event.channel?.channelName ?? "global"})`,
|
||||
event.providerEventId ?? "-",
|
||||
event.tenant.name,
|
||||
`${event.processStatus} · ${formatTime(event.createdAt)}`
|
||||
])}
|
||||
/>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
84
app/team/[userId]/edit/page.tsx
Normal file
84
app/team/[userId]/edit/page.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { RoleCode } from "@prisma/client";
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { Button, SectionCard } from "@/components/ui";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { updateTeamUser } from "@/lib/admin-crud";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export default async function EditUserPage({ params }: { params: Promise<{ userId: string }> }) {
|
||||
const { userId } = await params;
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const roles = await prisma.role.findMany({
|
||||
where: { tenantId: session.tenantId, code: { in: [RoleCode.ADMIN_CLIENT, RoleCode.AGENT] } },
|
||||
orderBy: { code: "asc" }
|
||||
});
|
||||
const fullUser = await prisma.user.findFirst({
|
||||
where: { id: userId, tenantId: session.tenantId },
|
||||
include: { role: true }
|
||||
});
|
||||
if (!fullUser) {
|
||||
redirect("/team?error=user_not_found");
|
||||
}
|
||||
|
||||
return (
|
||||
<ShellPage shell="admin" title="Edit User" description="Update role dan status user.">
|
||||
<SectionCard title="User form">
|
||||
<form action={updateTeamUser} className="grid gap-4 md:max-w-2xl">
|
||||
<input type="hidden" name="userId" value={fullUser.id} />
|
||||
<input
|
||||
name="fullName"
|
||||
required
|
||||
defaultValue={fullUser.fullName}
|
||||
className="rounded-xl border border-line px-4 py-3"
|
||||
/>
|
||||
<input
|
||||
name="email"
|
||||
required
|
||||
type="email"
|
||||
defaultValue={fullUser.email}
|
||||
className="rounded-xl border border-line px-4 py-3"
|
||||
/>
|
||||
<label className="block text-sm">
|
||||
<span className="text-on-surface-variant">Role</span>
|
||||
<select name="roleId" defaultValue={fullUser.roleId} className="mt-2 w-full rounded-xl border border-line px-4 py-3">
|
||||
{roles.map((role) => (
|
||||
<option key={role.id} value={role.id}>
|
||||
{role.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="text-on-surface-variant">Status</span>
|
||||
<select name="status" defaultValue={fullUser.status} className="mt-2 w-full rounded-xl border border-line px-4 py-3">
|
||||
<option value="INVITED">Invited</option>
|
||||
<option value="ACTIVE">Active</option>
|
||||
<option value="DISABLED">Disabled</option>
|
||||
</select>
|
||||
</label>
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="Password baru (opsional)"
|
||||
className="md:col-span-2 rounded-xl border border-line px-4 py-3"
|
||||
/>
|
||||
<div className="md:col-span-2 flex gap-3">
|
||||
<Button type="submit" className="rounded-xl">
|
||||
Save changes
|
||||
</Button>
|
||||
<Link href={`/team/${fullUser.id}`} className="text-on-surface-variant hover:underline">
|
||||
Cancel
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</SectionCard>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
58
app/team/[userId]/page.tsx
Normal file
58
app/team/[userId]/page.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { SectionCard } from "@/components/ui";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export default async function UserDetailPage({ params }: { params: Promise<{ userId: string }> }) {
|
||||
const { userId } = await params;
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: { id: userId, tenantId: session.tenantId },
|
||||
include: { role: true, assignedConversations: true }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
redirect("/team?error=user_not_found");
|
||||
}
|
||||
|
||||
return (
|
||||
<ShellPage
|
||||
shell="admin"
|
||||
title="User Detail"
|
||||
description="Role, status, assigned conversations, dan snapshot performa."
|
||||
actions={<Link href={`/team/${user.id}/edit`}>Edit user</Link>}
|
||||
>
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<SectionCard title="User profile">
|
||||
<p className="text-sm text-on-surface-variant">
|
||||
<strong>Nama:</strong> {user.fullName}
|
||||
</p>
|
||||
<p className="text-sm text-on-surface-variant">
|
||||
<strong>Email:</strong> {user.email}
|
||||
</p>
|
||||
<p className="text-sm text-on-surface-variant">
|
||||
<strong>Role:</strong> {user.role.name}
|
||||
</p>
|
||||
<p className="text-sm text-on-surface-variant">
|
||||
<strong>Status:</strong> {user.status}
|
||||
</p>
|
||||
<p className="text-sm text-on-surface-variant">
|
||||
<strong>Last login:</strong> {user.lastLoginAt?.toLocaleString() || "-"}
|
||||
</p>
|
||||
</SectionCard>
|
||||
<SectionCard title="Performance snapshot">
|
||||
<p className="text-sm text-on-surface-variant">Handled conversations: {user.assignedConversations.length}</p>
|
||||
<p className="text-sm text-on-surface-variant">Avg response time: -</p>
|
||||
<p className="text-sm text-on-surface-variant">Resolved count: -</p>
|
||||
</SectionCard>
|
||||
</div>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
66
app/team/new/page.tsx
Normal file
66
app/team/new/page.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { Button, SectionCard } from "@/components/ui";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { createTeamUser } from "@/lib/admin-crud";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export default async function NewUserPage({
|
||||
searchParams
|
||||
}: {
|
||||
searchParams?: Promise<{ error?: string }>;
|
||||
}) {
|
||||
const params = await (searchParams ?? Promise.resolve({ error: undefined }));
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const roles = await prisma.role.findMany({
|
||||
where: { tenantId: session.tenantId, code: { in: ["ADMIN_CLIENT", "AGENT"] } },
|
||||
orderBy: { code: "asc" }
|
||||
});
|
||||
const error = params?.error;
|
||||
const errorMessage = error === "missing_fields" ? "Lengkapi nama, email, dan role." : error === "invalid_role" ? "Role tidak valid." : null;
|
||||
|
||||
return (
|
||||
<ShellPage shell="admin" title="Create User" description="Tambah admin client atau agent baru.">
|
||||
<SectionCard title="User form">
|
||||
<form action={createTeamUser} className="grid gap-4 md:max-w-2xl">
|
||||
{errorMessage ? <p className="rounded-xl border border-warning/30 bg-warning/10 p-3 text-sm text-warning">{errorMessage}</p> : null}
|
||||
<input name="fullName" required placeholder="Full name" className="rounded-xl border border-line px-4 py-3" />
|
||||
<input name="email" required type="email" placeholder="Email" className="rounded-xl border border-line px-4 py-3" />
|
||||
<label className="md:col-span-2 block text-sm">
|
||||
<span className="text-on-surface-variant">Role</span>
|
||||
<select name="roleId" required className="mt-2 w-full rounded-xl border border-line px-4 py-3">
|
||||
<option value="">Pilih role</option>
|
||||
{roles.map((role) => (
|
||||
<option key={role.id} value={role.id}>
|
||||
{role.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="md:col-span-2 block text-sm">
|
||||
<span className="text-on-surface-variant">Status</span>
|
||||
<select name="status" defaultValue="INVITED" className="mt-2 w-full rounded-xl border border-line px-4 py-3">
|
||||
<option value="INVITED">Invited</option>
|
||||
<option value="ACTIVE">Active</option>
|
||||
<option value="DISABLED">Disabled</option>
|
||||
</select>
|
||||
</label>
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="Password (wajib untuk status Active)"
|
||||
className="md:col-span-2 rounded-xl border border-line px-4 py-3"
|
||||
/>
|
||||
<Button type="submit" className="md:col-span-2 w-full">
|
||||
Save user
|
||||
</Button>
|
||||
</form>
|
||||
</SectionCard>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
64
app/team/page.tsx
Normal file
64
app/team/page.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { PlaceholderActions, ShellPage } from "@/components/page-templates";
|
||||
import { TablePlaceholder, TeamSummaryCards } from "@/components/placeholders";
|
||||
import { getTeamData } from "@/lib/platform-data";
|
||||
import { deleteTeamUser } from "@/lib/admin-crud";
|
||||
|
||||
export default async function TeamPage({
|
||||
searchParams
|
||||
}: {
|
||||
searchParams?: Promise<{ error?: string }>;
|
||||
}) {
|
||||
const params = await (searchParams ?? Promise.resolve({ error: undefined }));
|
||||
const users = await getTeamData();
|
||||
const error = params.error;
|
||||
const infoMessage = error === "user_not_found"
|
||||
? "User tidak ditemukan."
|
||||
: error === "user_has_campaigns"
|
||||
? "User tidak bisa dihapus karena pernah membuat campaign."
|
||||
: error === "self_delete_not_allowed"
|
||||
? "Tidak bisa menghapus akun sendiri."
|
||||
: error === "invalid_role"
|
||||
? "Role tidak valid."
|
||||
: error === "missing_fields"
|
||||
? "Pastikan semua kolom wajib terisi."
|
||||
: null;
|
||||
|
||||
return (
|
||||
<ShellPage
|
||||
shell="admin"
|
||||
title="Team / Users"
|
||||
description="Kelola admin client dan agent di tenant."
|
||||
actions={<PlaceholderActions primaryHref="/team/new" primaryLabel="Create user" secondaryHref="/team/performance" secondaryLabel="Team performance" />}
|
||||
>
|
||||
<TeamSummaryCards users={users} />
|
||||
{infoMessage ? <p className="rounded-xl border border-warning/30 bg-warning/10 p-3 text-sm text-warning">{infoMessage}</p> : null}
|
||||
<TablePlaceholder
|
||||
title="Users list"
|
||||
columns={["Name", "Email", "Role", "Status", "Last login", "Actions"]}
|
||||
rows={users.map((user) => [
|
||||
user.fullName,
|
||||
user.email,
|
||||
user.role,
|
||||
user.status,
|
||||
user.lastLogin,
|
||||
<div key={user.id} className="flex gap-2">
|
||||
<Link href={`/team/${user.id}`} className="text-brand hover:underline">
|
||||
Detail
|
||||
</Link>
|
||||
<Link href={`/team/${user.id}/edit`} className="text-brand hover:underline">
|
||||
Edit
|
||||
</Link>
|
||||
<form action={deleteTeamUser} className="inline">
|
||||
<input type="hidden" name="userId" value={user.id} />
|
||||
<button type="submit" className="text-danger hover:underline">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
])}
|
||||
/>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
90
app/team/performance/page.tsx
Normal file
90
app/team/performance/page.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { TablePlaceholder } from "@/components/placeholders";
|
||||
|
||||
const AVERAGE_RESPONSE_DIVISOR = 1;
|
||||
|
||||
function formatDuration(ms: number | null) {
|
||||
if (!ms || ms < 0) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
const totalMinutes = Math.floor(ms / 60000);
|
||||
const minutes = totalMinutes % 60;
|
||||
const seconds = Math.floor((ms % 60000) / 1000);
|
||||
|
||||
if (totalMinutes < 60) {
|
||||
return `${totalMinutes}m ${seconds}s`;
|
||||
}
|
||||
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
|
||||
export default async function TeamPerformancePage() {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
if (session.role !== "admin_client") {
|
||||
redirect("/unauthorized");
|
||||
}
|
||||
|
||||
const agents = await prisma.user.findMany({
|
||||
where: {
|
||||
tenantId: session.tenantId,
|
||||
role: { code: "AGENT" }
|
||||
},
|
||||
include: {
|
||||
assignedConversations: {
|
||||
select: {
|
||||
status: true,
|
||||
firstMessageAt: true,
|
||||
lastOutboundAt: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: { fullName: "asc" }
|
||||
});
|
||||
|
||||
const rows = agents.map((agent) => {
|
||||
const handled = agent.assignedConversations.length;
|
||||
const resolved = agent.assignedConversations.filter((item) => item.status === "RESOLVED").length;
|
||||
const workload = agent.assignedConversations.filter((item) => item.status === "OPEN" || item.status === "PENDING").length;
|
||||
|
||||
const responseSamples = agent.assignedConversations
|
||||
.filter((item) => item.firstMessageAt && item.lastOutboundAt)
|
||||
.map((item) => {
|
||||
if (!item.firstMessageAt || !item.lastOutboundAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return item.lastOutboundAt.getTime() - item.firstMessageAt.getTime();
|
||||
})
|
||||
.filter((item): item is number => item !== null && item >= 0);
|
||||
|
||||
const avgResponse =
|
||||
responseSamples.length > 0
|
||||
? formatDuration(
|
||||
responseSamples.reduce((sum, value) => sum + value, 0) /
|
||||
Math.max(AVERAGE_RESPONSE_DIVISOR, responseSamples.length)
|
||||
)
|
||||
: "-";
|
||||
|
||||
return [agent.fullName, String(handled), avgResponse, String(resolved), `${workload} open`];
|
||||
});
|
||||
|
||||
return (
|
||||
<ShellPage shell="admin" title="Team Performance" description="Leaderboard performa agent dan workload snapshot.">
|
||||
<TablePlaceholder
|
||||
title="Performance table"
|
||||
columns={["Agent", "Handled", "Avg response", "Resolved", "Workload"]}
|
||||
rows={rows}
|
||||
/>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
70
app/templates/[templateId]/edit/page.tsx
Normal file
70
app/templates/[templateId]/edit/page.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { Button, SectionCard } from "@/components/ui";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { updateTemplate } from "@/lib/admin-crud";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export default async function EditTemplatePage({ params }: { params: Promise<{ templateId: string }> }) {
|
||||
const { templateId } = await params;
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const template = await prisma.messageTemplate.findFirst({
|
||||
where: { id: templateId, tenantId: session.tenantId }
|
||||
});
|
||||
if (!template) {
|
||||
redirect("/templates?error=template_not_found");
|
||||
}
|
||||
|
||||
const channels = await prisma.channel.findMany({
|
||||
where: { tenantId: session.tenantId },
|
||||
orderBy: { channelName: "asc" }
|
||||
});
|
||||
|
||||
return (
|
||||
<ShellPage shell="admin" title="Edit Template" description="Resubmit template yang ditolak atau update draft.">
|
||||
<SectionCard title="Template form">
|
||||
<form action={updateTemplate} className="grid gap-4 md:max-w-3xl">
|
||||
<input type="hidden" name="templateId" value={template.id} />
|
||||
<input name="name" required defaultValue={template.name} className="rounded-xl border border-line px-4 py-3" />
|
||||
<input name="category" required defaultValue={template.category} className="rounded-xl border border-line px-4 py-3" />
|
||||
<input
|
||||
name="languageCode"
|
||||
required
|
||||
defaultValue={template.languageCode}
|
||||
className="rounded-xl border border-line px-4 py-3"
|
||||
/>
|
||||
<label className="md:col-span-2 block text-sm">
|
||||
<span className="text-on-surface-variant">Channel</span>
|
||||
<select name="channelId" defaultValue={template.channelId} className="mt-2 w-full rounded-xl border border-line px-4 py-3">
|
||||
{channels.map((channel) => (
|
||||
<option key={channel.id} value={channel.id}>
|
||||
{channel.channelName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<textarea
|
||||
required
|
||||
name="bodyText"
|
||||
className="min-h-32 rounded-xl border border-line px-4 py-3"
|
||||
defaultValue={template.bodyText}
|
||||
/>
|
||||
<div className="md:col-span-2 flex gap-3">
|
||||
<Button type="submit" className="rounded-xl">
|
||||
Save template
|
||||
</Button>
|
||||
<Link href={`/templates/${template.id}`} className="text-on-surface-variant hover:underline">
|
||||
Cancel
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</SectionCard>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
54
app/templates/[templateId]/page.tsx
Normal file
54
app/templates/[templateId]/page.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { SectionCard } from "@/components/ui";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function TemplateDetailPage({ params }: { params: Promise<{ templateId: string }> }) {
|
||||
const { templateId } = await params;
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const template = await prisma.messageTemplate.findFirst({
|
||||
where: { id: templateId, tenantId: session.tenantId },
|
||||
include: { channel: true }
|
||||
});
|
||||
if (!template) {
|
||||
redirect("/templates?error=template_not_found");
|
||||
}
|
||||
|
||||
return (
|
||||
<ShellPage shell="admin" title="Template Detail" description="Preview content, provider metadata, dan approval state.">
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<SectionCard title="Template preview">
|
||||
<p className="text-sm text-on-surface-variant">
|
||||
<strong>Name:</strong> {template.name}
|
||||
</p>
|
||||
<p className="text-sm text-on-surface-variant">
|
||||
<strong>Category:</strong> {template.category}
|
||||
</p>
|
||||
<p className="text-sm text-on-surface-variant">
|
||||
<strong>Language:</strong> {template.languageCode}
|
||||
</p>
|
||||
<p className="mt-4 rounded-xl border border-line bg-surface-container p-3 text-sm text-on-surface-variant">{template.bodyText}</p>
|
||||
</SectionCard>
|
||||
<SectionCard title="Provider status">
|
||||
<p className="text-sm text-on-surface-variant">
|
||||
<strong>Approval:</strong> {template.approvalStatus}
|
||||
</p>
|
||||
<p className="text-sm text-on-surface-variant">
|
||||
<strong>Channel:</strong> {template.channel.channelName}
|
||||
</p>
|
||||
<p className="text-sm text-on-surface-variant">
|
||||
<strong>Provider id:</strong> {template.providerTemplateId || "-"}
|
||||
</p>
|
||||
<p className="text-sm text-on-surface-variant">
|
||||
<strong>Rejected reason:</strong> {template.rejectedReason || "-"}
|
||||
</p>
|
||||
</SectionCard>
|
||||
</div>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
60
app/templates/new/page.tsx
Normal file
60
app/templates/new/page.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { Button, SectionCard } from "@/components/ui";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { createTemplate } from "@/lib/admin-crud";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export default async function NewTemplatePage({
|
||||
searchParams
|
||||
}: {
|
||||
searchParams?: Promise<{ error?: string }>;
|
||||
}) {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const channels = await prisma.channel.findMany({
|
||||
where: { tenantId: session.tenantId },
|
||||
orderBy: { channelName: "asc" }
|
||||
});
|
||||
|
||||
const params = await (searchParams ?? Promise.resolve({ error: undefined }));
|
||||
const errorMessage = params.error === "missing_fields" ? "Lengkapi semua kolom wajib." : params.error === "invalid_channel" ? "Channel tidak valid." : null;
|
||||
|
||||
return (
|
||||
<ShellPage shell="admin" title="Create Template Request" description="Form request template WhatsApp.">
|
||||
<SectionCard title="Template builder">
|
||||
<form action={createTemplate} className="grid gap-4 md:max-w-3xl">
|
||||
{errorMessage ? (
|
||||
<p className="rounded-xl border border-warning/30 bg-warning/10 p-3 text-sm text-warning md:col-span-2">{errorMessage}</p>
|
||||
) : null}
|
||||
<input required name="name" className="rounded-xl border border-line px-4 py-3" placeholder="Template name" />
|
||||
<input required name="category" className="rounded-xl border border-line px-4 py-3" placeholder="Category" />
|
||||
<input required name="languageCode" className="rounded-xl border border-line px-4 py-3" placeholder="Language code (id)" />
|
||||
<label className="md:col-span-2 block text-sm">
|
||||
<span className="text-on-surface-variant">Channel</span>
|
||||
<select name="channelId" required className="mt-2 w-full rounded-xl border border-line px-4 py-3">
|
||||
<option value="">Pilih channel</option>
|
||||
{channels.map((channel) => (
|
||||
<option key={channel.id} value={channel.id}>
|
||||
{channel.channelName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="md:col-span-2 block text-sm">
|
||||
<span className="text-on-surface-variant">Template type</span>
|
||||
<input name="templateType" defaultValue="text" className="mt-2 w-full rounded-xl border border-line px-4 py-3" />
|
||||
</label>
|
||||
<textarea required name="bodyText" className="min-h-32 rounded-xl border border-line px-4 py-3" placeholder="Body text" />
|
||||
<Button type="submit" className="md:col-span-2 w-full">
|
||||
Submit template
|
||||
</Button>
|
||||
</form>
|
||||
</SectionCard>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
62
app/templates/page.tsx
Normal file
62
app/templates/page.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { PlaceholderActions, ShellPage } from "@/components/page-templates";
|
||||
import { TablePlaceholder } from "@/components/placeholders";
|
||||
import { deleteTemplate } from "@/lib/admin-crud";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export default async function TemplatesPage({
|
||||
searchParams
|
||||
}: {
|
||||
searchParams?: Promise<{ error?: string }>;
|
||||
}) {
|
||||
const params = await (searchParams ?? Promise.resolve({ error: undefined }));
|
||||
const session = await getSession();
|
||||
const templates = session
|
||||
? await prisma.messageTemplate.findMany({
|
||||
where: { tenantId: session.tenantId },
|
||||
include: { channel: true },
|
||||
orderBy: { updatedAt: "desc" }
|
||||
})
|
||||
: [];
|
||||
const error = params.error;
|
||||
const infoMessage =
|
||||
error === "template_not_found" ? "Template tidak ditemukan." : error === "template_in_use" ? "Template masih digunakan campaign." : null;
|
||||
|
||||
return (
|
||||
<ShellPage
|
||||
shell="admin"
|
||||
title="Templates"
|
||||
description="Daftar template WhatsApp, approval status, dan akses ke request form."
|
||||
actions={<PlaceholderActions primaryHref="/templates/new" primaryLabel="Create template request" />}
|
||||
>
|
||||
{infoMessage ? <p className="rounded-xl border border-warning/30 bg-warning/10 p-3 text-sm text-warning">{infoMessage}</p> : null}
|
||||
<TablePlaceholder
|
||||
title="Template list"
|
||||
columns={["Name", "Category", "Language", "Channel", "Status", "Actions"]}
|
||||
rows={templates.map((template) => [
|
||||
template.name,
|
||||
template.category,
|
||||
template.languageCode,
|
||||
template.channel.channelName,
|
||||
template.approvalStatus,
|
||||
<div key={template.id} className="flex gap-2">
|
||||
<Link href={`/templates/${template.id}`} className="text-brand hover:underline">
|
||||
Detail
|
||||
</Link>
|
||||
<Link href={`/templates/${template.id}/edit`} className="text-brand hover:underline">
|
||||
Edit
|
||||
</Link>
|
||||
<form action={deleteTemplate} className="inline">
|
||||
<input type="hidden" name="templateId" value={template.id} />
|
||||
<button type="submit" className="text-danger hover:underline">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
])}
|
||||
/>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
22
app/unauthorized/page.tsx
Normal file
22
app/unauthorized/page.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import { Button, PageHeader, SectionCard } from "@/components/ui";
|
||||
import { getLocale, getTranslator } from "@/lib/i18n";
|
||||
|
||||
export default async function UnauthorizedPage() {
|
||||
const t = getTranslator(await getLocale());
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-background px-6 py-16">
|
||||
<div className="mx-auto w-full max-w-2xl rounded-[1.5rem] bg-surface-container-lowest p-4 shadow-card md:p-8">
|
||||
<PageHeader title={t("pages", "unauthorized_title")} description={t("pages", "unauthorized_desc")} />
|
||||
<div className="mt-8">
|
||||
<SectionCard title={t("pages", "unauthorized_subtitle")}>
|
||||
<p className="text-sm text-on-surface-variant">{t("pages", "unauthorized_desc")}</p>
|
||||
<div className="mt-4">
|
||||
<Button href="/login">{t("common", "back_to_login")}</Button>
|
||||
</div>
|
||||
</SectionCard>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
177
campaign-retry-job.md
Normal file
177
campaign-retry-job.md
Normal file
@ -0,0 +1,177 @@
|
||||
# Campaign Retry Background Job (Production)
|
||||
|
||||
Endpoint:
|
||||
- `GET /api/jobs/campaign-retry` => status state
|
||||
- `POST /api/jobs/campaign-retry` => run one batch
|
||||
|
||||
## Security
|
||||
|
||||
- Set `CAMPAIGN_RETRY_JOB_TOKEN` in production.
|
||||
- Header yang diterima:
|
||||
- `Authorization: Bearer <token>`
|
||||
- `x-cron-token: <token>`
|
||||
- Saat `NODE_ENV=production`, jika token tidak di-set maka request akan ditolak.
|
||||
|
||||
## Health Check
|
||||
|
||||
- Endpoint umum: `GET /api/health`
|
||||
- Return:
|
||||
- `200` jika sehat (`status: "ok"` / `"degraded"`)
|
||||
- `503` jika ada komponen kritikal down (`status: "down"`)
|
||||
- Tanpa `HEALTHCHECK_TOKEN`, endpoint akan menampilkan status ringkas.
|
||||
- Jika ingin detail tambahan, set `HEALTHCHECK_TOKEN` di `.env` dan kirim header `Authorization: Bearer <token>` atau query `?token=<token>`.
|
||||
- Env pendukung:
|
||||
- `HEALTHCHECK_TOKEN`
|
||||
- `WEBHOOK_FAILURE_RATE_THRESHOLD_PER_HOUR` (default: `20`)
|
||||
- `RETRY_WORKER_STALE_MINUTES` (default: `30`)
|
||||
|
||||
## Runtime Env
|
||||
|
||||
- `CAMPAIGN_RETRY_BATCH_SIZE` (default: `100`)
|
||||
- `CAMPAIGN_RETRY_MAX_CAMPAIGNS` (default: `20`)
|
||||
- `CAMPAIGN_RETRY_JOB_LOCK_TTL_SECONDS` (default: `300`)
|
||||
- `CAMPAIGN_RETRY_DAEMON_INTERVAL_SECONDS` (default: `300`)
|
||||
- `CAMPAIGN_RETRY_DAEMON_TIMEOUT_MS` (default: `30000`)
|
||||
- `CAMPAIGN_RETRY_ALERT_WEBHOOK_URL` (optional, webhook/Slack endpoint)
|
||||
- `CAMPAIGN_RETRY_ALERT_ON_FAILURE` (`true`/`false`, default `true`)
|
||||
|
||||
## Menjalankan Manual
|
||||
|
||||
```bash
|
||||
npm run job:campaign-retry
|
||||
```
|
||||
|
||||
Atau:
|
||||
|
||||
```bash
|
||||
CAMPAIGN_RETRY_JOB_URL=https://app.example.com \
|
||||
CAMPAIGN_RETRY_JOB_TOKEN=token-anda \
|
||||
node scripts/campaign-retry-job.mjs
|
||||
```
|
||||
|
||||
## Jalankan Daemon (otomatis)
|
||||
|
||||
```bash
|
||||
npm run job:campaign-retry:daemon
|
||||
```
|
||||
|
||||
Opsi tambahan:
|
||||
- `--once` untuk menjalankan sekali lalu keluar
|
||||
- `--no-jitter` untuk menonaktifkan jitter interval acak
|
||||
|
||||
Opsional:
|
||||
- `CAMPAIGN_RETRY_DAEMON_INTERVAL_SECONDS`
|
||||
- `CAMPAIGN_RETRY_DAEMON_TIMEOUT_MS`
|
||||
- `CAMPAIGN_RETRY_TENANT_ID`
|
||||
- `CAMPAIGN_RETRY_CAMPAIGN_ID`
|
||||
- `CAMPAIGN_RETRY_BATCH_SIZE`
|
||||
- `CAMPAIGN_RETRY_MAX_CAMPAIGNS`
|
||||
|
||||
Contoh (daemon):
|
||||
|
||||
```bash
|
||||
CAMPAIGN_RETRY_JOB_URL=https://app.example.com \
|
||||
CAMPAIGN_RETRY_JOB_TOKEN=token-anda \
|
||||
CAMPAIGN_RETRY_DAEMON_INTERVAL_SECONDS=300 \
|
||||
npm run job:campaign-retry:daemon
|
||||
```
|
||||
|
||||
## Menjadikan otomatis (cron)
|
||||
|
||||
### Linux / VM (one-shot cron)
|
||||
|
||||
```cron
|
||||
*/5 * * * * cd /path/to/project && CAMPAIGN_RETRY_JOB_TOKEN=token-anda CAMPAIGN_RETRY_JOB_URL=https://app.example.com npm run job:campaign-retry >> /var/log/campaign-retry.log 2>&1
|
||||
```
|
||||
|
||||
Alternatif untuk host Anda: jalankan `npm run job:campaign-retry:daemon` sebagai service terpisah dan atur `CAMPAIGN_RETRY_DAEMON_INTERVAL_SECONDS` sesuai kebutuhan.
|
||||
|
||||
### Vercel Cron (opsional)
|
||||
|
||||
Tambah cron di `vercel.json` dengan query token:
|
||||
|
||||
```json
|
||||
{
|
||||
"crons": [
|
||||
{
|
||||
"path": "/api/jobs/campaign-retry?token=your-token-here",
|
||||
"schedule": "*/5 * * * *"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Pastikan token disiapkan via Environment Variable Vercel `CAMPAIGN_RETRY_JOB_TOKEN`.
|
||||
|
||||
## Ops Healthcheck
|
||||
|
||||
Jalankan pemeriksaan cepat lingkungan produksi:
|
||||
|
||||
```bash
|
||||
OPS_BASE_URL=https://app.example.com \
|
||||
HEALTHCHECK_TOKEN=your-health-token \
|
||||
CAMPAIGN_RETRY_JOB_TOKEN=your-cron-token \
|
||||
npm run ops:healthcheck
|
||||
```
|
||||
|
||||
Command ini memeriksa:
|
||||
|
||||
- halaman root aplikasi (`/`)
|
||||
- status `GET /api/health`
|
||||
- status state `GET /api/jobs/campaign-retry`
|
||||
|
||||
## Ops Readiness
|
||||
|
||||
Jalankan preflight lengkap sebelum release:
|
||||
|
||||
```bash
|
||||
npm run ops:readiness
|
||||
```
|
||||
|
||||
Perintah ini memeriksa:
|
||||
|
||||
- variabel environment penting
|
||||
- status migration Prisma
|
||||
- health endpoint
|
||||
- endpoint state campaign retry (jika token tersedia)
|
||||
|
||||
Catatan: jalankan saat aplikasi sudah aktif (dev/prod running) agar pemeriksaan endpoint tidak false positive.
|
||||
|
||||
## Operations Playbook
|
||||
|
||||
- [Runbook produksi](./ops-runbook.md)
|
||||
- [Kebijakan alert](./alert-policy.md)
|
||||
|
||||
### Linux service (daemon mode)
|
||||
|
||||
Jika ingin retry berjalan nonstop tanpa cron external, gunakan mode daemon sebagai service:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=WhatsApp campaign retry worker
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/path/to/project
|
||||
Environment=CAMPAIGN_RETRY_JOB_URL=https://app.example.com
|
||||
Environment=CAMPAIGN_RETRY_JOB_TOKEN=token-anda
|
||||
Environment=CAMPAIGN_RETRY_DAEMON_INTERVAL_SECONDS=300
|
||||
Environment=CAMPAIGN_RETRY_DAEMON_TIMEOUT_MS=30000
|
||||
Environment=CAMPAIGN_RETRY_BATCH_SIZE=100
|
||||
Environment=CAMPAIGN_RETRY_MAX_CAMPAIGNS=20
|
||||
ExecStart=/usr/bin/npm run job:campaign-retry:daemon
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Jalankan service:
|
||||
|
||||
```bash
|
||||
sudo systemctl start whatsapp-campaign-retry
|
||||
sudo systemctl enable whatsapp-campaign-retry
|
||||
sudo journalctl -u whatsapp-campaign-retry -f
|
||||
```
|
||||
176
components/app-shell.tsx
Normal file
176
components/app-shell.tsx
Normal file
@ -0,0 +1,176 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
import { getLocale, getTranslator, type NavKey } from "@/lib/i18n";
|
||||
import type { NavItem } from "@/lib/mock-data";
|
||||
|
||||
type ShellContext = {
|
||||
userName: string;
|
||||
roleLabel: string;
|
||||
tenantName: string;
|
||||
};
|
||||
|
||||
const navIconByKey: Record<NavKey, string> = {
|
||||
dashboard: "dashboard",
|
||||
shared_inbox: "inbox",
|
||||
inbox: "inbox",
|
||||
contacts: "contacts",
|
||||
broadcast: "campaign",
|
||||
templates: "article",
|
||||
team: "group",
|
||||
reports: "bar_chart",
|
||||
settings: "settings",
|
||||
billing: "payments",
|
||||
audit_log: "history",
|
||||
tenants: "domain",
|
||||
channels: "settings_input_antenna",
|
||||
security_events: "notifications_active",
|
||||
webhook_logs: "webhook",
|
||||
alerts: "warning",
|
||||
quick_tools: "auto_fix_high",
|
||||
performance: "trending_up",
|
||||
new_chat: "chat",
|
||||
search: "search",
|
||||
logout: "logout",
|
||||
global_search: "search",
|
||||
campaign: "campaign"
|
||||
};
|
||||
|
||||
function initials(name: string) {
|
||||
return name
|
||||
.split(" ")
|
||||
.filter(Boolean)
|
||||
.map((part) => part[0]?.toUpperCase())
|
||||
.slice(0, 2)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function LocaleSwitcher({ locale }: { locale: "id" | "en" }) {
|
||||
const nextLocale = locale === "id" ? "en" : "id";
|
||||
return (
|
||||
<Link
|
||||
href={`/locale?to=${nextLocale}`}
|
||||
className="rounded-full border border-line bg-surface-container px-3 py-1 text-xs font-semibold text-on-surface"
|
||||
>
|
||||
{nextLocale.toUpperCase()}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export async function AppShell({
|
||||
title,
|
||||
subtitle,
|
||||
nav,
|
||||
context,
|
||||
children
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
nav: NavItem[];
|
||||
context: ShellContext;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const locale = await getLocale();
|
||||
const t = getTranslator(locale);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-on-surface">
|
||||
<div className="mx-auto flex min-h-screen max-w-[1700px]">
|
||||
<aside className="hidden w-[268px] shrink-0 flex-col space-y-4 border-r border-line bg-surface-container-low px-4 py-6 lg:flex">
|
||||
<div className="mb-6 rounded-[1.25rem] bg-surface-container-lowest px-3 py-3 shadow-sm">
|
||||
<div className="flex items-center gap-3 px-2">
|
||||
<Image
|
||||
src="/logo_zappcare.png"
|
||||
alt="ZappCare"
|
||||
width={36}
|
||||
height={36}
|
||||
className="h-9 w-auto rounded-full"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-xs font-extrabold uppercase tracking-[0.24em] text-outline">{t("common", "zappcare")}</p>
|
||||
<p className="text-lg font-black leading-tight font-headline text-on-surface">{t("common", "business_suite")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-2">
|
||||
<button className="flex w-full items-center justify-center gap-2 rounded-full bg-gradient-to-br from-primary to-primary-container px-4 py-3 text-sm font-bold text-white">
|
||||
<span className="material-symbols-outlined text-sm">add_comment</span>
|
||||
<span>{t("nav", "new_chat")}</span>
|
||||
</button>
|
||||
</div>
|
||||
<nav className="space-y-1">
|
||||
{nav.map((item) => {
|
||||
const label = t("nav", item.labelKey);
|
||||
const isDashboard = item.labelKey === "dashboard";
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`flex items-center gap-3 rounded-xl px-4 py-2.5 text-sm font-semibold font-headline transition-all ${
|
||||
isDashboard
|
||||
? "bg-primary-container/40 text-on-primary-container"
|
||||
: "text-on-surface-variant hover:bg-surface-container-high hover:text-on-surface"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="material-symbols-outlined text-sm"
|
||||
style={{ fontVariationSettings: "'FILL' 1", fontSize: "20px" }}
|
||||
>
|
||||
{navIconByKey[item.labelKey]}
|
||||
</span>
|
||||
<span>{label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div className="mt-auto border-t border-line px-2 pt-5">
|
||||
<Link
|
||||
href="/auth/logout"
|
||||
className="flex items-center gap-3 rounded-xl px-4 py-2.5 text-sm font-semibold text-on-surface-variant transition hover:text-on-surface"
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">logout</span>
|
||||
<span>{t("nav", "logout")}</span>
|
||||
</Link>
|
||||
</div>
|
||||
</aside>
|
||||
<div className="flex min-h-screen flex-1 flex-col">
|
||||
<header className="border-b border-line bg-surface-container-lowest/85 backdrop-blur">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 px-5 py-4 md:px-7">
|
||||
<div>
|
||||
<p className="text-xs font-bold uppercase tracking-[0.2em] text-outline">{subtitle}</p>
|
||||
<h1 className="text-2xl font-black font-headline text-on-surface">{title}</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative hidden w-72 rounded-full border border-line bg-surface-container-low px-4 py-2 md:block">
|
||||
<span className="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-outline text-sm">search</span>
|
||||
<input
|
||||
placeholder={t("common", "search_placeholder")}
|
||||
className="w-full border-none bg-transparent pl-8 pr-2 text-sm outline-none placeholder:text-outline-variant"
|
||||
/>
|
||||
</div>
|
||||
<LocaleSwitcher locale={locale} />
|
||||
<button className="flex h-9 w-9 items-center justify-center rounded-full text-outline transition hover:bg-surface-container-low">
|
||||
<span className="material-symbols-outlined text-sm">notifications</span>
|
||||
</button>
|
||||
<button className="flex h-9 w-9 items-center justify-center rounded-full text-outline transition hover:bg-surface-container-low">
|
||||
<span className="material-symbols-outlined text-sm">help_outline</span>
|
||||
</button>
|
||||
<button className="flex h-9 w-9 items-center justify-center rounded-full text-outline transition hover:bg-surface-container-low">
|
||||
<span className="material-symbols-outlined text-sm">app_shortcut</span>
|
||||
</button>
|
||||
<button className="ml-2 flex h-9 items-center justify-center rounded-full border border-line bg-surface-container px-3 text-xs font-bold text-on-surface-variant">
|
||||
{initials(context.userName)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-7 pb-4 text-sm text-on-surface-variant">
|
||||
{context.userName} • {context.roleLabel} • {context.tenantName}
|
||||
</div>
|
||||
</header>
|
||||
<main className="min-h-0 flex-1 px-6 py-6 md:px-8">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
93
components/page-templates.tsx
Normal file
93
components/page-templates.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
import { Button, PageHeader } from "@/components/ui";
|
||||
import { getLocale, getTranslator } from "@/lib/i18n";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { adminNav, agentNav, superAdminNav } from "@/lib/mock-data";
|
||||
|
||||
type ShellType = "admin" | "agent" | "super-admin";
|
||||
|
||||
const shellMap = {
|
||||
admin: {
|
||||
nav: adminNav,
|
||||
titleKey: "admin_title" as const,
|
||||
subtitleKey: "admin_subtitle" as const
|
||||
},
|
||||
agent: {
|
||||
nav: agentNav,
|
||||
titleKey: "agent_title" as const,
|
||||
subtitleKey: "agent_subtitle" as const
|
||||
},
|
||||
"super-admin": {
|
||||
nav: superAdminNav,
|
||||
titleKey: "super_admin_title" as const,
|
||||
subtitleKey: "super_admin_subtitle" as const
|
||||
}
|
||||
} as const;
|
||||
|
||||
export async function ShellPage({
|
||||
shell,
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
children
|
||||
}: {
|
||||
shell: ShellType;
|
||||
title: string;
|
||||
description: string;
|
||||
actions?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const locale = await getLocale();
|
||||
const t = getTranslator(locale);
|
||||
|
||||
const config = shellMap[shell];
|
||||
const session = await getSession();
|
||||
const roleLabel =
|
||||
session?.role === "super_admin" ? t("roles", "super_admin") : session?.role === "agent" ? t("roles", "agent") : t("roles", "admin_client");
|
||||
const tenantName = session?.tenantName ?? "Inbox Suite";
|
||||
const shellTitle = t("shell", config.titleKey);
|
||||
const shellSubtitle = t("shell", config.subtitleKey);
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
title={shellTitle}
|
||||
subtitle={shellSubtitle}
|
||||
nav={config.nav}
|
||||
context={{
|
||||
userName: session?.fullName ?? "Guest User",
|
||||
roleLabel,
|
||||
tenantName
|
||||
}}
|
||||
>
|
||||
<div className="space-y-6 pb-8">
|
||||
<PageHeader title={title} description={description} actions={actions} />
|
||||
{children}
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlaceholderActions({
|
||||
primaryHref,
|
||||
primaryLabel,
|
||||
secondaryHref,
|
||||
secondaryLabel
|
||||
}: {
|
||||
primaryHref?: string;
|
||||
primaryLabel?: string;
|
||||
secondaryHref?: string;
|
||||
secondaryLabel?: string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{secondaryHref && secondaryLabel ? (
|
||||
<Button href={secondaryHref} variant="secondary">
|
||||
{secondaryLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
{primaryHref && primaryLabel ? <Button href={primaryHref}>{primaryLabel}</Button> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
428
components/placeholders.tsx
Normal file
428
components/placeholders.tsx
Normal file
@ -0,0 +1,428 @@
|
||||
import Link from "next/link";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
import { Badge, SectionCard } from "@/components/ui";
|
||||
import { getLocale, getTranslator } from "@/lib/i18n";
|
||||
import type { CampaignRecord, ContactRecord, ConversationRecord, DashboardStats, TenantRecord, UserRecord } from "@/lib/demo-data";
|
||||
import type {
|
||||
ConversationMessage,
|
||||
ConversationNote,
|
||||
ConversationSummary,
|
||||
InboxConversationDetail
|
||||
} from "@/lib/inbox-ops";
|
||||
|
||||
export async function DashboardPlaceholder({
|
||||
stats,
|
||||
priorityQueue
|
||||
}: {
|
||||
stats: DashboardStats[];
|
||||
priorityQueue: Array<ConversationRecord | CampaignRecord | TenantRecord>;
|
||||
}) {
|
||||
const t = getTranslator(await getLocale());
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{stats.map((item) => (
|
||||
<SectionCard key={item.label} title={item.label}>
|
||||
<div className="flex items-end justify-between">
|
||||
<span className="text-3xl font-extrabold text-on-surface">{item.value}</span>
|
||||
<Badge tone="success">{item.delta}</Badge>
|
||||
</div>
|
||||
</SectionCard>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid gap-6 xl:grid-cols-[1.35fr_1fr]">
|
||||
<SectionCard title={t("placeholders", "operational_overview_title")} description={t("placeholders", "operational_overview_desc")}>
|
||||
<div className="h-72 rounded-[1.25rem] border border-dashed border-line bg-gradient-to-br from-primary/5 to-surface-container-high p-6 text-sm text-on-surface-variant">
|
||||
{t("placeholders", "operation_chart_note")}
|
||||
</div>
|
||||
</SectionCard>
|
||||
<SectionCard title={t("placeholders", "priority_queue_title")} description={t("placeholders", "priority_queue_desc")}>
|
||||
<div className="space-y-3">
|
||||
{priorityQueue.map((item) => (
|
||||
<div key={item.id} className="rounded-xl border border-line bg-surface-container-low p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="font-medium text-ink">{item.name}</p>
|
||||
<Badge tone={item.status === "Completed" || item.status === "Resolved" || item.status === "Active" ? "success" : "warning"}>
|
||||
{item.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-on-surface-variant">
|
||||
{"snippet" in item
|
||||
? item.snippet
|
||||
: "plan" in item
|
||||
? `${item.plan} • ${item.channels}`
|
||||
: `${item.audience} • ${item.channel}`}
|
||||
</p>
|
||||
<p className="mt-3 text-xs text-outline">
|
||||
{"time" in item ? `${item.time} • ${item.assignee}` : "seats" in item ? item.seats : item.scheduledAt}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export async function InboxPlaceholder({
|
||||
conversations,
|
||||
selectedConversation,
|
||||
defaultPath,
|
||||
agents = [],
|
||||
filter = "all",
|
||||
role = "admin",
|
||||
canSelfAssign = false,
|
||||
assignConversation,
|
||||
updateConversationStatus,
|
||||
replyToConversation,
|
||||
addConversationNote,
|
||||
setConversationTags
|
||||
}: {
|
||||
conversations: ConversationSummary[];
|
||||
selectedConversation: InboxConversationDetail | null;
|
||||
defaultPath: string;
|
||||
agents?: Array<{ id: string; name: string }>;
|
||||
filter?: "all" | "unassigned" | "resolved" | "open" | "pending";
|
||||
role?: "admin" | "agent";
|
||||
canSelfAssign?: boolean;
|
||||
assignConversation?: (formData: FormData) => Promise<void>;
|
||||
updateConversationStatus?: (formData: FormData) => Promise<void>;
|
||||
replyToConversation?: (formData: FormData) => Promise<void>;
|
||||
addConversationNote?: (formData: FormData) => Promise<void>;
|
||||
setConversationTags?: (formData: FormData) => Promise<void>;
|
||||
}) {
|
||||
const t = getTranslator(await getLocale());
|
||||
|
||||
const currentPath = defaultPath || "/inbox";
|
||||
const selectedId = selectedConversation?.id;
|
||||
|
||||
const statusTone = (status: string) => {
|
||||
if (status === "Resolved") {
|
||||
return "success";
|
||||
}
|
||||
|
||||
if (status === "Pending") {
|
||||
return "warning";
|
||||
}
|
||||
|
||||
return "default";
|
||||
};
|
||||
|
||||
const statusValue = (status: string) => {
|
||||
switch (status) {
|
||||
case "Open":
|
||||
return "OPEN";
|
||||
case "Pending":
|
||||
return "PENDING";
|
||||
case "Resolved":
|
||||
return "RESOLVED";
|
||||
default:
|
||||
return "OPEN";
|
||||
}
|
||||
};
|
||||
|
||||
const tagsValue = selectedConversation?.tagJson
|
||||
? JSON.parse(selectedConversation.tagJson).filter((tag: string) => Boolean(tag.trim())).join(", ")
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 xl:grid-cols-[360px_1fr_320px]">
|
||||
<SectionCard title={t("placeholders", "conversation_list_title")} description={t("placeholders", "conversation_list_desc")}>
|
||||
<div className="space-y-3">
|
||||
{conversations.map((item) => (
|
||||
<Link
|
||||
key={item.id}
|
||||
href={`${currentPath}?conversationId=${item.id}&filter=${filter}`}
|
||||
className={`block rounded-xl border p-4 transition ${
|
||||
item.id === selectedId
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-line hover:border-primary/50 hover:bg-surface-container-low"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="font-medium text-ink">{item.name}</p>
|
||||
<span className="text-xs text-outline">{item.time}</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-on-surface-variant">{item.snippet}</p>
|
||||
<div className="mt-3 flex items-center justify-between text-xs text-outline">
|
||||
<span>{item.assignee}</span>
|
||||
<Badge tone={statusTone(item.status)}>{item.status}</Badge>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
{!conversations.length ? <p className="text-sm text-outline">{t("placeholders", "no_conversation_found")}</p> : null}
|
||||
</div>
|
||||
</SectionCard>
|
||||
<SectionCard title={t("placeholders", "conversation_detail_title")} description={t("placeholders", "conversation_detail_desc")}>
|
||||
{!selectedConversation ? (
|
||||
<p className="text-sm text-on-surface-variant">{t("placeholders", "select_to_reply")}</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl border border-line bg-surface-container p-4 text-sm text-on-surface-variant">
|
||||
<p className="font-medium text-ink">{selectedConversation.name} • {selectedConversation.phone}</p>
|
||||
<p>{selectedConversation.channel}</p>
|
||||
<p>{selectedConversation.assignee}</p>
|
||||
<p className="mt-2 text-outline">
|
||||
{localeStatus(t, "status", selectedConversation.status)}{" "}
|
||||
<Badge tone={statusTone(selectedConversation.status)}>{selectedConversation.status}</Badge>
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-line bg-surface-container p-4 text-sm text-on-surface-variant">
|
||||
{selectedConversation.messages.length === 0 ? t("placeholders", "no_messages") : null}
|
||||
<div className="space-y-2">
|
||||
{selectedConversation.messages.map((message: ConversationMessage) => (
|
||||
<MessageBubble
|
||||
key={message.id}
|
||||
align={message.direction === "INBOUND" ? "left" : "right"}
|
||||
meta={`${message.from} • ${message.at}`}
|
||||
>
|
||||
{message.body}
|
||||
</MessageBubble>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<form action={replyToConversation} className="rounded-2xl border border-line bg-surface-container p-4">
|
||||
<p className="mb-2 text-sm font-semibold text-on-surface">{t("placeholders", "reply_label")}</p>
|
||||
<input type="hidden" name="conversationId" value={selectedConversation.id} />
|
||||
<input type="hidden" name="nextPath" value={currentPath} />
|
||||
<textarea
|
||||
name="content"
|
||||
required
|
||||
rows={3}
|
||||
placeholder={t("placeholders", "reply_placeholder")}
|
||||
className="h-auto min-h-[110px] w-full rounded-lg border border-line bg-surface-container-low px-3 py-2 text-sm outline-none"
|
||||
/>
|
||||
<button className="mt-2 rounded-full bg-primary px-4 py-2 text-xs text-white" type="submit">
|
||||
{t("placeholders", "send_reply")}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</SectionCard>
|
||||
<SectionCard title={t("placeholders", "context_panel_title")} description={t("placeholders", "context_panel_desc")}>
|
||||
{!selectedConversation ? (
|
||||
<p className="text-sm text-on-surface-variant">{t("placeholders", "select_context")}</p>
|
||||
) : (
|
||||
<div className="space-y-3 text-sm text-on-surface-variant">
|
||||
<ul className="space-y-2">
|
||||
<li className="rounded-xl bg-surface-container p-3">
|
||||
<p className="font-medium text-ink">{t("placeholders", "contact_label")}</p>
|
||||
<p>{selectedConversation.name}</p>
|
||||
<p>{selectedConversation.phone}</p>
|
||||
</li>
|
||||
<li className="rounded-xl bg-surface-container p-3">
|
||||
<p className="font-medium text-ink">{t("placeholders", "tags_label")}</p>
|
||||
<p>{tagsValue || "-"}</p>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<form action={assignConversation} className="rounded-xl bg-surface-container p-3 space-y-2">
|
||||
<p className="font-semibold text-ink">{t("placeholders", "assign_label")}</p>
|
||||
<input type="hidden" name="conversationId" value={selectedConversation.id} />
|
||||
<input type="hidden" name="nextPath" value={currentPath} />
|
||||
{role === "admin" ? (
|
||||
<select name="assigneeId" className="w-full rounded border border-line bg-surface-container-low px-3 py-2">
|
||||
<option value="">{t("placeholders", "unassign_label")}</option>
|
||||
{agents.map((agent) => (
|
||||
<option key={agent.id} value={agent.id}>
|
||||
{agent.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : null}
|
||||
{role === "agent" && canSelfAssign ? <input type="hidden" name="assigneeId" value={""} /> : null}
|
||||
<button className="rounded-full bg-surface-container-lowest px-4 py-2 text-xs text-on-surface" type="submit">
|
||||
{role === "admin"
|
||||
? t("placeholders", "assign_label")
|
||||
: selectedConversation.assignee === "Unassigned"
|
||||
? t("placeholders", "take_assignment")
|
||||
: t("placeholders", "reassign")}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form action={updateConversationStatus} className="rounded-xl bg-surface-container p-3 space-y-2">
|
||||
<p className="font-semibold text-ink">{t("placeholders", "status_label")}</p>
|
||||
<input type="hidden" name="conversationId" value={selectedConversation.id} />
|
||||
<input type="hidden" name="nextPath" value={currentPath} />
|
||||
<select name="status" defaultValue={statusValue(selectedConversation.status)} className="w-full rounded border border-line bg-surface-container-low px-3 py-2">
|
||||
<option value="OPEN">{t("placeholders", "status_open")}</option>
|
||||
<option value="PENDING">{t("placeholders", "status_pending")}</option>
|
||||
<option value="RESOLVED">{t("placeholders", "status_resolved")}</option>
|
||||
<option value="ARCHIVED">{t("placeholders", "status_archived")}</option>
|
||||
<option value="SPAM">{t("placeholders", "status_spam")}</option>
|
||||
</select>
|
||||
<button className="rounded-full bg-surface-container-lowest px-4 py-2 text-xs text-on-surface" type="submit">
|
||||
{t("placeholders", "update_status")}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form action={addConversationNote} className="rounded-xl bg-surface-container p-3 space-y-2">
|
||||
<p className="font-semibold text-ink">{t("placeholders", "add_note_label")}</p>
|
||||
<input type="hidden" name="conversationId" value={selectedConversation.id} />
|
||||
<input type="hidden" name="nextPath" value={currentPath} />
|
||||
<input
|
||||
name="note"
|
||||
required
|
||||
placeholder={t("placeholders", "add_note_placeholder")}
|
||||
className="w-full rounded border border-line bg-surface-container-low px-3 py-2"
|
||||
/>
|
||||
<button className="rounded-full bg-surface-container-lowest px-4 py-2 text-xs text-on-surface" type="submit">
|
||||
{t("placeholders", "save_note")}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form action={setConversationTags} className="rounded-xl bg-surface-container p-3 space-y-2">
|
||||
<p className="font-semibold text-ink">{t("placeholders", "tags_label")}</p>
|
||||
<input type="hidden" name="conversationId" value={selectedConversation.id} />
|
||||
<input type="hidden" name="nextPath" value={currentPath} />
|
||||
<input
|
||||
name="tags"
|
||||
defaultValue={tagsValue}
|
||||
placeholder={t("placeholders", "tags_placeholder")}
|
||||
className="w-full rounded border border-line bg-surface-container-low px-3 py-2"
|
||||
/>
|
||||
<button className="rounded-full bg-surface-container-lowest px-4 py-2 text-xs text-on-surface" type="submit">
|
||||
{t("placeholders", "save_tags")}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="rounded-xl bg-surface-container p-3">
|
||||
<p className="font-semibold text-ink">{t("placeholders", "notes_label")}</p>
|
||||
{selectedConversation.notes.length === 0 ? <p className="text-xs text-outline">{t("placeholders", "no_notes")}</p> : null}
|
||||
<ul className="mt-2 space-y-2">
|
||||
{selectedConversation.notes.map((item: ConversationNote) => (
|
||||
<li key={item.id} className="rounded bg-surface-container-low p-2">
|
||||
<p className="text-xs text-outline">
|
||||
{item.by} • {item.at}
|
||||
</p>
|
||||
<p className="text-sm text-on-surface">{item.content}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SectionCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageBubble({
|
||||
align,
|
||||
children,
|
||||
meta
|
||||
}: {
|
||||
align: "left" | "right";
|
||||
children: ReactNode;
|
||||
meta?: string;
|
||||
}) {
|
||||
const className =
|
||||
align === "left"
|
||||
? "mr-auto max-w-xl rounded-2xl rounded-bl-md bg-surface-container-low"
|
||||
: "ml-auto max-w-xl rounded-2xl rounded-br-md bg-primary text-white";
|
||||
|
||||
return (
|
||||
<div className={`border border-line px-4 py-3 text-sm shadow-sm ${className}`}>
|
||||
{children}
|
||||
{meta ? <p className="mt-2 text-xs opacity-70">{meta}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TablePlaceholder({
|
||||
columns,
|
||||
rows,
|
||||
title,
|
||||
description
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
columns: string[];
|
||||
rows: Array<(string | ReactNode)[]>;
|
||||
}) {
|
||||
return (
|
||||
<SectionCard title={title} description={description}>
|
||||
<div className="overflow-hidden rounded-[1.25rem] border border-line">
|
||||
<table className="min-w-full divide-y divide-line text-left text-sm">
|
||||
<thead className="bg-surface-container">
|
||||
<tr>
|
||||
{columns.map((column) => (
|
||||
<th key={column} className="px-4 py-3 font-semibold text-on-surface-variant">
|
||||
{column}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line bg-surface-container-lowest">
|
||||
{rows.map((row, rowIndex) => (
|
||||
<tr key={`${title}-${rowIndex}`}>
|
||||
{row.map((cell, cellIndex) => (
|
||||
<td key={`${rowIndex}-${cellIndex}`} className="px-4 py-3 text-on-surface-variant">
|
||||
{cell}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
export async function ContactSummaryCards({ contacts }: { contacts: ContactRecord[] }) {
|
||||
const t = getTranslator(await getLocale());
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<SectionCard title={t("tables", "total_contacts")}>
|
||||
<p className="text-3xl font-semibold text-ink">{contacts.length}</p>
|
||||
</SectionCard>
|
||||
<SectionCard title={t("tables", "opted_in")}>
|
||||
<p className="text-3xl font-semibold text-ink">
|
||||
{contacts.filter((contact) => contact.optInStatus === "Opted in").length}
|
||||
</p>
|
||||
</SectionCard>
|
||||
<SectionCard title={t("tables", "tagged_contacts")}>
|
||||
<p className="text-3xl font-semibold text-ink">
|
||||
{contacts.filter((contact) => contact.tags.length > 0).length}
|
||||
</p>
|
||||
</SectionCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export async function TeamSummaryCards({ users }: { users: UserRecord[] }) {
|
||||
const t = getTranslator(await getLocale());
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<SectionCard title={t("tables", "total_users")}>
|
||||
<p className="text-3xl font-semibold text-ink">{users.length}</p>
|
||||
</SectionCard>
|
||||
<SectionCard title={t("tables", "agents")}>
|
||||
<p className="text-3xl font-semibold text-ink">{users.filter((user) => user.role === "Agent").length}</p>
|
||||
</SectionCard>
|
||||
<SectionCard title={t("tables", "invited")}>
|
||||
<p className="text-3xl font-semibold text-ink">{users.filter((user) => user.status === "Invited").length}</p>
|
||||
</SectionCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function localeStatus(t: ReturnType<typeof getTranslator>, section: "status", key: string) {
|
||||
if (key === "Open") {
|
||||
return t("placeholders", "status_open");
|
||||
}
|
||||
|
||||
if (key === "Pending") {
|
||||
return t("placeholders", "status_pending");
|
||||
}
|
||||
|
||||
if (key === "Resolved") {
|
||||
return t("placeholders", "status_resolved");
|
||||
}
|
||||
|
||||
return key;
|
||||
}
|
||||
109
components/ui.tsx
Normal file
109
components/ui.tsx
Normal file
@ -0,0 +1,109 @@
|
||||
import Link from "next/link";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export function Button({
|
||||
href,
|
||||
children,
|
||||
variant = "primary",
|
||||
className,
|
||||
type = "button"
|
||||
}: {
|
||||
href?: string;
|
||||
children: ReactNode;
|
||||
variant?: "primary" | "secondary" | "ghost";
|
||||
className?: string;
|
||||
type?: "button" | "submit" | "reset";
|
||||
}) {
|
||||
const styleClass =
|
||||
variant === "primary"
|
||||
? "bg-gradient-to-br from-primary to-primary-container text-white rounded-full hover:brightness-105 shadow-sm shadow-primary/30"
|
||||
: variant === "secondary"
|
||||
? "bg-surface-container-low text-on-surface border border-outline-variant/70 rounded-full hover:bg-surface-container-high"
|
||||
: "text-on-surface-variant hover:text-on-surface hover:bg-surface-container-low";
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={`inline-flex items-center justify-center rounded-full px-4 py-2.5 text-sm font-semibold font-headline transition ${styleClass} ${
|
||||
className ?? ""
|
||||
} ${className?.includes("w-full") ? "" : "min-w-24"}`}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
className={`inline-flex items-center justify-center rounded-full px-4 py-2.5 text-sm font-semibold font-headline transition ${styleClass} ${
|
||||
className ?? ""
|
||||
} ${className?.includes("w-full") ? "" : "min-w-24"}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function SectionCard({
|
||||
title,
|
||||
description,
|
||||
children
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="rounded-[1.25rem] border border-line bg-surface-container-lowest p-5 shadow-card">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-base font-bold font-headline text-on-surface">{title}</h3>
|
||||
{description ? <p className="mt-1 text-sm text-on-surface-variant">{description}</p> : null}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function Badge({
|
||||
children,
|
||||
tone = "default"
|
||||
}: {
|
||||
children: ReactNode;
|
||||
tone?: "default" | "success" | "warning" | "danger";
|
||||
}) {
|
||||
const tones = {
|
||||
default: "bg-surface-container-high text-on-surface-variant",
|
||||
success: "bg-success/10 text-success",
|
||||
warning: "bg-warning/10 text-warning",
|
||||
danger: "bg-error/10 text-danger"
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`inline-flex rounded-full px-2.5 py-1 text-xs font-medium ${tones[tone]}`}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function PageHeader({
|
||||
title,
|
||||
description,
|
||||
actions
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 border-b border-line pb-6 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-bold uppercase tracking-[0.16em] text-on-surface-variant">ZappCare</p>
|
||||
<h1 className="mt-2 text-3xl font-extrabold font-headline text-on-surface">{title}</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm text-on-surface-variant">{description}</p>
|
||||
</div>
|
||||
{actions ? <div className="flex flex-wrap gap-3">{actions}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
517
komponen-ui-checklist-whatsapp-inbox.md
Normal file
517
komponen-ui-checklist-whatsapp-inbox.md
Normal file
@ -0,0 +1,517 @@
|
||||
# Komponen UI Checklist WhatsApp Inbox MVP
|
||||
|
||||
Dokumen ini merangkum komponen UI yang perlu disiapkan untuk semua screen. Checklist ini berguna untuk:
|
||||
- tim desain membuat design system minimum
|
||||
- tim frontend memecah komponen reusable
|
||||
- menjaga konsistensi antar screen
|
||||
|
||||
Format checklist:
|
||||
- `Global`
|
||||
- `Navigation`
|
||||
- `Data Display`
|
||||
- `Forms`
|
||||
- `Inbox`
|
||||
- `Contacts`
|
||||
- `Campaigns`
|
||||
- `Reports`
|
||||
- `Billing`
|
||||
- `Feedback States`
|
||||
|
||||
## 1. Foundations
|
||||
|
||||
### 1.1 Design Tokens
|
||||
- color palette primary, success, warning, error, neutral
|
||||
- typography scale
|
||||
- spacing scale
|
||||
- border radius scale
|
||||
- shadows
|
||||
- z-index layers
|
||||
- icon size scale
|
||||
|
||||
### 1.2 Responsive Rules
|
||||
- desktop main layout
|
||||
- tablet fallback
|
||||
- mobile stacking rules
|
||||
- sidebar collapse behavior
|
||||
|
||||
### 1.3 Content Rules
|
||||
- date time format
|
||||
- currency format
|
||||
- phone number format
|
||||
- status label copy
|
||||
- empty state copy
|
||||
|
||||
## 2. Global Navigation
|
||||
|
||||
### 2.1 Sidebar
|
||||
- role-based sidebar container
|
||||
- sidebar section label
|
||||
- nav item
|
||||
- nav item with badge
|
||||
- collapsed sidebar state
|
||||
|
||||
### 2.2 Topbar
|
||||
- page title block
|
||||
- breadcrumb
|
||||
- tenant/workspace indicator
|
||||
- channel status indicator
|
||||
- search trigger
|
||||
- notifications trigger
|
||||
- profile dropdown
|
||||
|
||||
### 2.3 Search and Notifications
|
||||
- global search modal/overlay
|
||||
- notification center panel
|
||||
- notification item
|
||||
|
||||
## 3. Core Layout Components
|
||||
|
||||
### 3.1 Page Structure
|
||||
- page container
|
||||
- page header
|
||||
- page subtitle
|
||||
- action bar
|
||||
- filter bar
|
||||
- split view layout
|
||||
- right-side inspector panel
|
||||
- sticky section header
|
||||
|
||||
### 3.2 Panels and Containers
|
||||
- card
|
||||
- stat card
|
||||
- info panel
|
||||
- section container
|
||||
- tab container
|
||||
- accordion
|
||||
- drawer
|
||||
- modal
|
||||
|
||||
## 4. Buttons and Actions
|
||||
|
||||
### 4.1 Buttons
|
||||
- primary button
|
||||
- secondary button
|
||||
- tertiary / ghost button
|
||||
- danger button
|
||||
- icon button
|
||||
- loading button
|
||||
|
||||
### 4.2 Action Patterns
|
||||
- dropdown action menu
|
||||
- inline action link
|
||||
- confirmation action
|
||||
- bulk action bar
|
||||
|
||||
## 5. Forms
|
||||
|
||||
### 5.1 Inputs
|
||||
- text input
|
||||
- email input
|
||||
- password input
|
||||
- phone number input
|
||||
- textarea
|
||||
- search input
|
||||
- number input
|
||||
|
||||
### 5.2 Selectors
|
||||
- single select
|
||||
- multi select
|
||||
- async search select
|
||||
- combobox
|
||||
- radio group
|
||||
- checkbox
|
||||
- switch/toggle
|
||||
- date picker
|
||||
- date range picker
|
||||
- time picker
|
||||
|
||||
### 5.3 Upload
|
||||
- file upload zone
|
||||
- CSV upload component
|
||||
- avatar uploader
|
||||
|
||||
### 5.4 Form Feedback
|
||||
- inline validation error
|
||||
- helper text
|
||||
- success state
|
||||
- disabled state
|
||||
- submit progress
|
||||
|
||||
## 6. Data Display
|
||||
|
||||
### 6.1 Tables and Lists
|
||||
- data table
|
||||
- table toolbar
|
||||
- sortable column header
|
||||
- paginated table
|
||||
- empty table state
|
||||
- list item row
|
||||
- selectable row
|
||||
|
||||
### 6.2 Status and Metadata
|
||||
- badge
|
||||
- status pill
|
||||
- tag chip
|
||||
- avatar
|
||||
- avatar group
|
||||
- timestamp label
|
||||
- metric delta indicator
|
||||
|
||||
### 6.3 Charts and KPI
|
||||
- KPI card
|
||||
- line chart wrapper
|
||||
- bar chart wrapper
|
||||
- donut/pie summary
|
||||
- legend
|
||||
- metric summary row
|
||||
|
||||
## 7. Auth Components
|
||||
|
||||
### 7.1 Login / Password
|
||||
- auth card layout
|
||||
- login form
|
||||
- forgot password form
|
||||
- reset password form
|
||||
- password rules hint
|
||||
|
||||
### 7.2 Invitation
|
||||
- invitation accept form
|
||||
- invite success message
|
||||
|
||||
### 7.3 Auth Feedback
|
||||
- invalid credentials alert
|
||||
- account disabled alert
|
||||
- token expired state
|
||||
|
||||
## 8. Inbox Components
|
||||
|
||||
Inbox adalah area terpenting. Komponen di sini harus diprioritaskan.
|
||||
|
||||
### 8.1 Inbox Layout
|
||||
- inbox filter tabs
|
||||
- inbox filter toolbar
|
||||
- conversation list container
|
||||
- conversation row item
|
||||
- unread badge
|
||||
- assignment indicator
|
||||
- priority indicator
|
||||
|
||||
### 8.2 Conversation Detail
|
||||
- conversation header
|
||||
- status selector
|
||||
- priority selector
|
||||
- assignment selector
|
||||
- conversation metadata row
|
||||
- message timeline
|
||||
- message group
|
||||
- message bubble inbound
|
||||
- message bubble outbound
|
||||
- delivery status marker
|
||||
- attachment preview
|
||||
- template message block
|
||||
- composer toolbar
|
||||
- reply composer
|
||||
- send button
|
||||
|
||||
### 8.3 Context Panels
|
||||
- customer summary card
|
||||
- tag manager
|
||||
- note list
|
||||
- add note input
|
||||
- activity log list
|
||||
- activity event row
|
||||
|
||||
### 8.4 Productivity Tools
|
||||
- canned response picker
|
||||
- template picker
|
||||
- assign/reassign modal
|
||||
- follow-up indicator
|
||||
|
||||
### 8.5 Inbox Feedback States
|
||||
- no conversation selected
|
||||
- empty inbox
|
||||
- loading timeline
|
||||
- send failed alert
|
||||
- disconnected channel alert
|
||||
|
||||
## 9. Contact Components
|
||||
|
||||
### 9.1 Contact List
|
||||
- contact row
|
||||
- contact table
|
||||
- contact search toolbar
|
||||
- opt-in badge
|
||||
|
||||
### 9.2 Contact Form
|
||||
- contact form section
|
||||
- phone validation hint
|
||||
- duplicate warning
|
||||
|
||||
### 9.3 Contact Detail
|
||||
- contact profile card
|
||||
- custom field block
|
||||
- conversation history list
|
||||
- campaign history list
|
||||
- segment membership list
|
||||
|
||||
### 9.4 Import / Export
|
||||
- import stepper
|
||||
- field mapping table
|
||||
- validation preview table
|
||||
- import result summary
|
||||
- export options form
|
||||
|
||||
### 9.5 Segments
|
||||
- segment row
|
||||
- segment rule builder simple
|
||||
- member count badge
|
||||
|
||||
## 10. Template Components
|
||||
|
||||
### 10.1 Template List
|
||||
- template row
|
||||
- approval status badge
|
||||
- category badge
|
||||
|
||||
### 10.2 Template Form
|
||||
- template builder form
|
||||
- header selector
|
||||
- body editor
|
||||
- footer input
|
||||
- button config block
|
||||
- template preview panel
|
||||
|
||||
### 10.3 Template Detail
|
||||
- provider info block
|
||||
- rejected reason alert
|
||||
- resubmit action group
|
||||
|
||||
## 11. Campaign Components
|
||||
|
||||
### 11.1 Campaign List
|
||||
- campaign row
|
||||
- campaign KPI strip
|
||||
- campaign status badge
|
||||
|
||||
### 11.2 Create Campaign Flow
|
||||
- stepper
|
||||
- campaign metadata form
|
||||
- audience source selector
|
||||
- segment picker
|
||||
- schedule picker
|
||||
- recipient estimate block
|
||||
- review summary block
|
||||
- warnings panel
|
||||
|
||||
### 11.3 Campaign Detail
|
||||
- campaign summary header
|
||||
- delivery stats cards
|
||||
- send timeline
|
||||
- delivery trend chart
|
||||
- error breakdown list
|
||||
- recipient list table
|
||||
|
||||
### 11.4 Campaign Recipient States
|
||||
- queued badge
|
||||
- sent badge
|
||||
- delivered badge
|
||||
- read badge
|
||||
- failed badge
|
||||
- skipped badge
|
||||
|
||||
## 12. Team / User Components
|
||||
|
||||
### 12.1 Users List
|
||||
- user row
|
||||
- role badge
|
||||
- status badge
|
||||
- last login field
|
||||
|
||||
### 12.2 User Form
|
||||
- role selector
|
||||
- user status toggle
|
||||
|
||||
### 12.3 User Detail
|
||||
- user profile panel
|
||||
- assigned workload stat
|
||||
- personal performance widget
|
||||
|
||||
### 12.4 Team Performance
|
||||
- leaderboard table
|
||||
- workload summary
|
||||
- response time comparison
|
||||
|
||||
## 13. Reports Components
|
||||
|
||||
### 13.1 Reports Overview
|
||||
- report card
|
||||
- report group section
|
||||
|
||||
### 13.2 Report Detail
|
||||
- report filter bar
|
||||
- KPI row
|
||||
- chart card
|
||||
- breakdown table
|
||||
- export action
|
||||
|
||||
### 13.3 Specific Metrics
|
||||
- response time distribution widget
|
||||
- resolution rate summary
|
||||
- agent productivity leaderboard
|
||||
- campaign performance chart
|
||||
- contact growth chart
|
||||
|
||||
## 14. Billing Components
|
||||
|
||||
### 14.1 Billing Overview
|
||||
- current plan card
|
||||
- quota usage cards
|
||||
- renewal summary
|
||||
- upgrade CTA
|
||||
|
||||
### 14.2 Invoices
|
||||
- invoice table
|
||||
- payment status badge
|
||||
- invoice summary panel
|
||||
|
||||
### 14.3 Usage
|
||||
- usage progress bar
|
||||
- quota warning alert
|
||||
|
||||
## 15. Super Admin Components
|
||||
|
||||
### 15.1 Tenant Management
|
||||
- tenant row
|
||||
- tenant status badge
|
||||
- plan badge
|
||||
- usage summary cell
|
||||
|
||||
### 15.2 Tenant Detail
|
||||
- tenant summary cards
|
||||
- seat usage widget
|
||||
- recent activity panel
|
||||
|
||||
### 15.3 Channel Management
|
||||
- channel row
|
||||
- provider badge
|
||||
- webhook status badge
|
||||
- connection validation result
|
||||
- reconnect action group
|
||||
|
||||
### 15.4 Platform Monitoring
|
||||
- global KPI cards
|
||||
- alert feed
|
||||
- platform health chart
|
||||
- failure monitoring table
|
||||
|
||||
### 15.5 Governance
|
||||
- audit log table
|
||||
- log metadata drawer
|
||||
- webhook log payload viewer
|
||||
- security event list
|
||||
|
||||
### 15.6 Platform Settings
|
||||
- feature flag toggle row
|
||||
- pricing config form
|
||||
- settings section header
|
||||
|
||||
## 16. Shared Settings Components
|
||||
|
||||
### 16.1 Tenant Settings
|
||||
- settings side nav
|
||||
- business hours editor
|
||||
- auto assignment rules form
|
||||
- chat tags manager
|
||||
- canned responses manager
|
||||
- webhook config form
|
||||
|
||||
### 16.2 Profile Settings
|
||||
- profile summary card
|
||||
- edit profile form
|
||||
- change password form
|
||||
|
||||
## 17. Feedback and State Components
|
||||
|
||||
### 17.1 Feedback
|
||||
- success toast
|
||||
- error toast
|
||||
- warning toast
|
||||
- info toast
|
||||
- inline alert
|
||||
|
||||
### 17.2 Empty States
|
||||
- no conversations
|
||||
- no contacts
|
||||
- no campaigns
|
||||
- no templates
|
||||
- no reports data
|
||||
- no tenants
|
||||
- no invoices
|
||||
|
||||
### 17.3 Loading States
|
||||
- page skeleton
|
||||
- card skeleton
|
||||
- list row skeleton
|
||||
- timeline skeleton
|
||||
- form loading state
|
||||
|
||||
### 17.4 Error States
|
||||
- generic error
|
||||
- permission denied
|
||||
- disconnected integration
|
||||
- failed delivery
|
||||
- failed upload
|
||||
|
||||
### 17.5 Confirmation States
|
||||
- suspend tenant confirmation
|
||||
- deactivate user confirmation
|
||||
- disconnect channel confirmation
|
||||
- send campaign confirmation
|
||||
- mark spam confirmation
|
||||
|
||||
## 18. Prioritas Implementasi Komponen
|
||||
|
||||
Kalau harus dibangun bertahap, urutan paling masuk akal:
|
||||
|
||||
1. foundations
|
||||
2. navigation
|
||||
3. page layout
|
||||
4. buttons dan form primitives
|
||||
5. data table, badge, card
|
||||
6. inbox components
|
||||
7. contact components
|
||||
8. campaign components
|
||||
9. user/team components
|
||||
10. report components
|
||||
11. billing components
|
||||
12. super admin components
|
||||
13. settings components
|
||||
14. feedback states
|
||||
|
||||
## 19. Minimum Reusable Component Set
|
||||
|
||||
Kalau tim ingin mulai dari set minimum:
|
||||
- app shell
|
||||
- sidebar
|
||||
- topbar
|
||||
- page header
|
||||
- card
|
||||
- stat card
|
||||
- data table
|
||||
- badge
|
||||
- modal
|
||||
- drawer
|
||||
- text input
|
||||
- select
|
||||
- date picker
|
||||
- tabs
|
||||
- split layout
|
||||
- conversation row
|
||||
- message bubble
|
||||
- reply composer
|
||||
- note list
|
||||
- activity list
|
||||
- chart wrapper
|
||||
- toast/alert
|
||||
|
||||
Set ini sudah cukup untuk membangun mayoritas screen MVP.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user