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