chore: initial project import
Some checks failed
CI - Production Readiness / Verify (push) Has been cancelled

This commit is contained in:
Wira Basalamah
2026-04-21 09:29:29 +07:00
commit adde003fba
222 changed files with 37657 additions and 0 deletions

37
.env.example Normal file
View 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
View File

@ -0,0 +1,3 @@
{
"extends": ["next/core-web-vitals", "next/typescript"]
}

View 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
View File

@ -0,0 +1,4 @@
.next
node_modules
dev.db
dev.db-journal

View 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
View 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

View 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>
);
}

View 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>
);
}

View 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
View 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>
);
}

View 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>
);
}

View 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
View 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>
);
}

View 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>
);
}

View 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
View 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 });
}

View 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 });
}
}

View 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
View 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
View 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
View 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;
}

View 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>
);
}

View 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
View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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
View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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>
);
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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>
);
}

View 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
View File

@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function HomePage() {
redirect("/login");
}

View 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
View 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
View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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>
);
}

View 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>
);
}

View 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
View 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
View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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
View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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
View 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
View 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
View 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>
);
}

View 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
View 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
View 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>
);
}

View 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