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

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