chore: initial project import
Some checks failed
CI - Production Readiness / Verify (push) Has been cancelled
Some checks failed
CI - Production Readiness / Verify (push) Has been cancelled
This commit is contained in:
84
app/super-admin/alerts/page.tsx
Normal file
84
app/super-admin/alerts/page.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { TablePlaceholder } from "@/components/placeholders";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
function formatTime(date: Date | null) {
|
||||
if (!date) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat("id-ID", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export default async function PlatformAlertsPage() {
|
||||
const session = await getSession();
|
||||
if (!session || session.role !== "super_admin") {
|
||||
redirect("/unauthorized");
|
||||
}
|
||||
|
||||
const [channels, webhookFailures, retryStateAlerts] = await Promise.all([
|
||||
prisma.channel.findMany({
|
||||
include: { tenant: true },
|
||||
orderBy: { updatedAt: "desc" }
|
||||
}),
|
||||
prisma.webhookEvent.findMany({
|
||||
where: { processStatus: "failed" },
|
||||
include: { tenant: true, channel: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 20
|
||||
}),
|
||||
prisma.backgroundJobState.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ lastRunStatus: "failed" },
|
||||
{ lastRunCompletedAt: null },
|
||||
{ lastRunCompletedAt: { lte: new Date(Date.now() - 60 * 60 * 1000) } }
|
||||
]
|
||||
},
|
||||
orderBy: { updatedAt: "desc" },
|
||||
take: 10
|
||||
})
|
||||
]);
|
||||
|
||||
const channelAlerts = channels
|
||||
.filter((channel) => channel.status !== "CONNECTED")
|
||||
.map((channel) => ({
|
||||
severity: channel.status === "DISCONNECTED" ? "High" : "Medium",
|
||||
tenant: channel.tenant.name,
|
||||
issue: `${channel.displayPhoneNumber || channel.channelName} disconnected`,
|
||||
triggered: formatTime(channel.lastSyncAt)
|
||||
}));
|
||||
|
||||
const webhookAlerts = webhookFailures.map((event) => ({
|
||||
severity: "Medium",
|
||||
tenant: event.tenant.name,
|
||||
issue: `${event.eventType} on ${event.providerEventId ?? event.channel?.channelName ?? "unknown"}`,
|
||||
triggered: formatTime(event.createdAt)
|
||||
}));
|
||||
const retryAlerts = retryStateAlerts.map((state) => ({
|
||||
severity: state.lastRunStatus === "failed" ? "High" : "Medium",
|
||||
tenant: "Platform",
|
||||
issue: `${state.jobName} ${state.lastRunStatus === "failed" ? "failed repeatedly" : "hasn't run recently"}`,
|
||||
triggered: formatTime(state.lastRunCompletedAt)
|
||||
}));
|
||||
|
||||
const alerts = [...channelAlerts, ...webhookAlerts, ...retryAlerts].slice(0, 30);
|
||||
|
||||
return (
|
||||
<ShellPage shell="super-admin" title="System Alerts" description="Alert platform seperti disconnected channels dan quota issues.">
|
||||
<TablePlaceholder
|
||||
title="Alerts"
|
||||
columns={["Severity", "Tenant", "Issue", "Triggered at"]}
|
||||
rows={alerts.map((alert) => [alert.severity, alert.tenant, alert.issue, alert.triggered])}
|
||||
/>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
45
app/super-admin/audit-log/page.tsx
Normal file
45
app/super-admin/audit-log/page.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { TablePlaceholder } from "@/components/placeholders";
|
||||
|
||||
function formatTime(value: Date) {
|
||||
return new Intl.DateTimeFormat("id-ID", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
export default async function SuperAdminAuditLogPage() {
|
||||
const session = await getSession();
|
||||
if (!session || session.role !== "super_admin") {
|
||||
redirect("/unauthorized");
|
||||
}
|
||||
|
||||
const events = await prisma.auditLog.findMany({
|
||||
include: { tenant: true, actorUser: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 80
|
||||
});
|
||||
|
||||
return (
|
||||
<ShellPage shell="super-admin" title="Audit Log" description="Log governance lintas tenant dan modul.">
|
||||
<TablePlaceholder
|
||||
title="Audit events"
|
||||
columns={["Time", "Tenant", "Actor", "Action", "Entity"]}
|
||||
rows={events.map((event) => [
|
||||
formatTime(event.createdAt),
|
||||
event.tenant.name,
|
||||
event.actorUser?.fullName ?? "System",
|
||||
event.action,
|
||||
event.entityId
|
||||
])}
|
||||
/>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
115
app/super-admin/billing/invoices/[invoiceId]/page.tsx
Normal file
115
app/super-admin/billing/invoices/[invoiceId]/page.tsx
Normal file
@ -0,0 +1,115 @@
|
||||
import Link from "next/link";
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { Badge, SectionCard } from "@/components/ui";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
function formatDate(value: Date | null) {
|
||||
if (!value) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat("id-ID", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function formatMoney(value: number) {
|
||||
return new Intl.NumberFormat("id-ID", {
|
||||
style: "currency",
|
||||
currency: "IDR",
|
||||
maximumFractionDigits: 0
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function statusTone(status: string) {
|
||||
if (status === "PAID") {
|
||||
return "success";
|
||||
}
|
||||
|
||||
if (status === "OVERDUE") {
|
||||
return "danger";
|
||||
}
|
||||
|
||||
return "warning";
|
||||
}
|
||||
|
||||
export default async function SuperAdminInvoiceDetailPage({
|
||||
params
|
||||
}: {
|
||||
params: Promise<{ invoiceId: string }>;
|
||||
}) {
|
||||
const session = await getSession();
|
||||
if (!session || session.role !== "super_admin") {
|
||||
redirect("/unauthorized");
|
||||
}
|
||||
|
||||
const { invoiceId } = await params;
|
||||
const invoice = await prisma.billingInvoice.findUnique({
|
||||
where: { id: invoiceId },
|
||||
include: {
|
||||
tenant: { select: { name: true, slug: true } },
|
||||
plan: { select: { name: true, code: true } }
|
||||
}
|
||||
});
|
||||
|
||||
if (!invoice) {
|
||||
redirect("/super-admin/billing/invoices?error=invoice_not_found");
|
||||
}
|
||||
|
||||
return (
|
||||
<ShellPage shell="super-admin" title="Invoice Detail" description="Invoice view untuk super admin.">
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<SectionCard title="Summary">
|
||||
<div className="space-y-2 text-sm text-on-surface-variant">
|
||||
<p>
|
||||
<strong className="text-on-surface">Invoice:</strong> {invoice.invoiceNumber}
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-on-surface">Tenant:</strong>{" "}
|
||||
<Link href={`/super-admin/tenants/${invoice.tenantId}`} className="text-brand hover:underline">
|
||||
{invoice.tenant.name} ({invoice.tenant.slug})
|
||||
</Link>
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-on-surface">Plan:</strong> {invoice.plan.name} ({invoice.plan.code})
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-on-surface">Period:</strong> {formatDate(invoice.periodStart)} - {formatDate(invoice.periodEnd)}
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-on-surface">Total amount:</strong> {formatMoney(invoice.totalAmount)}
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-on-surface">Subtotal:</strong> {formatMoney(invoice.subtotal)} | Tax: {formatMoney(invoice.taxAmount)}
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-on-surface">Status:</strong> <Badge tone={statusTone(invoice.paymentStatus)}>{invoice.paymentStatus}</Badge>
|
||||
</p>
|
||||
</div>
|
||||
</SectionCard>
|
||||
<SectionCard title="Timeline">
|
||||
<div className="space-y-2 text-sm text-on-surface-variant">
|
||||
<p>
|
||||
<strong className="text-on-surface">Issued:</strong> {formatDate(invoice.createdAt)}
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-on-surface">Due date:</strong> {formatDate(invoice.dueDate)}
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-on-surface">Paid at:</strong> {formatDate(invoice.paidAt)}
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-on-surface">Updated:</strong> {formatDate(invoice.updatedAt)}
|
||||
</p>
|
||||
</div>
|
||||
</SectionCard>
|
||||
</div>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
60
app/super-admin/billing/invoices/page.tsx
Normal file
60
app/super-admin/billing/invoices/page.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { TablePlaceholder } from "@/components/placeholders";
|
||||
import Link from "next/link";
|
||||
|
||||
function formatDate(value: Date | null) {
|
||||
if (!value) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat("id-ID", {
|
||||
month: "short",
|
||||
year: "numeric"
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function formatMoney(value: number) {
|
||||
return new Intl.NumberFormat("id-ID", {
|
||||
style: "currency",
|
||||
currency: "IDR",
|
||||
maximumFractionDigits: 0
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
export default async function SuperAdminInvoicesPage() {
|
||||
const session = await getSession();
|
||||
if (!session || session.role !== "super_admin") {
|
||||
redirect("/unauthorized");
|
||||
}
|
||||
|
||||
const invoices = await prisma.billingInvoice.findMany({
|
||||
include: { tenant: true, plan: true },
|
||||
orderBy: { createdAt: "desc" }
|
||||
});
|
||||
|
||||
return (
|
||||
<ShellPage shell="super-admin" title="Invoices" description="Invoice seluruh tenant.">
|
||||
<TablePlaceholder
|
||||
title="Invoices"
|
||||
columns={["Invoice", "Tenant", "Period", "Amount", "Status"]}
|
||||
rows={invoices.map((invoice) => [
|
||||
<Link
|
||||
key={`${invoice.id}-invoice`}
|
||||
href={`/super-admin/billing/invoices/${invoice.id}`}
|
||||
className="text-brand hover:underline"
|
||||
>
|
||||
{invoice.invoiceNumber}
|
||||
</Link>,
|
||||
invoice.tenant.name,
|
||||
`${formatDate(invoice.periodStart)} - ${formatDate(invoice.periodEnd)} (${invoice.plan.name})`,
|
||||
formatMoney(invoice.totalAmount),
|
||||
invoice.paymentStatus
|
||||
])}
|
||||
/>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
40
app/super-admin/billing/plans/page.tsx
Normal file
40
app/super-admin/billing/plans/page.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { TablePlaceholder } from "@/components/placeholders";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
function formatMoney(value: number) {
|
||||
return new Intl.NumberFormat("id-ID", {
|
||||
style: "currency",
|
||||
currency: "IDR",
|
||||
maximumFractionDigits: 0
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
export default async function SuperAdminPlanCatalogPage() {
|
||||
const session = await getSession();
|
||||
if (!session || session.role !== "super_admin") {
|
||||
redirect("/unauthorized");
|
||||
}
|
||||
|
||||
const plans = await prisma.subscriptionPlan.findMany({
|
||||
orderBy: { createdAt: "asc" }
|
||||
});
|
||||
|
||||
return (
|
||||
<ShellPage shell="super-admin" title="Plan Catalog" description="Master plan langganan untuk tenant.">
|
||||
<TablePlaceholder
|
||||
title="Plans"
|
||||
columns={["Plan", "Price", "Message quota", "Seat quota", "Broadcast quota"]}
|
||||
rows={plans.map((plan) => [
|
||||
`${plan.name} (${plan.code})`,
|
||||
formatMoney(plan.priceMonthly),
|
||||
String(plan.messageQuota),
|
||||
String(plan.seatQuota),
|
||||
String(plan.broadcastQuota)
|
||||
])}
|
||||
/>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
55
app/super-admin/billing/subscriptions/page.tsx
Normal file
55
app/super-admin/billing/subscriptions/page.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { TablePlaceholder } from "@/components/placeholders";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
function formatDate(date: Date | null | undefined) {
|
||||
if (!date) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat("id-ID", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric"
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export default async function SuperAdminSubscriptionsPage() {
|
||||
const session = await getSession();
|
||||
if (!session || session.role !== "super_admin") {
|
||||
redirect("/unauthorized");
|
||||
}
|
||||
|
||||
const tenants = await prisma.tenant.findMany({
|
||||
include: {
|
||||
plan: true,
|
||||
billingInvoices: {
|
||||
take: 1,
|
||||
orderBy: { dueDate: "desc" },
|
||||
select: { dueDate: true, paymentStatus: true }
|
||||
},
|
||||
_count: {
|
||||
select: { users: true }
|
||||
}
|
||||
},
|
||||
orderBy: { createdAt: "desc" }
|
||||
});
|
||||
|
||||
return (
|
||||
<ShellPage shell="super-admin" title="Tenant Subscriptions" description="Plan aktif, usage, dan payment status lintas tenant.">
|
||||
<TablePlaceholder
|
||||
title="Subscriptions"
|
||||
columns={["Tenant", "Plan", "Usage", "Renewal", "Payment"]}
|
||||
rows={tenants.map((tenant) => [
|
||||
tenant.name,
|
||||
tenant.plan.name,
|
||||
`${tenant._count.users}/${tenant.plan.seatQuota}`,
|
||||
formatDate(tenant.billingInvoices[0]?.dueDate),
|
||||
tenant.billingInvoices[0]?.paymentStatus ?? "No invoice"
|
||||
])}
|
||||
/>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
96
app/super-admin/channels/[channelId]/page.tsx
Normal file
96
app/super-admin/channels/[channelId]/page.tsx
Normal file
@ -0,0 +1,96 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { SectionCard } from "@/components/ui";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
function formatDate(date: Date | null) {
|
||||
if (!date) {
|
||||
return "Not synced";
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat("id-ID", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export default async function SuperAdminChannelDetailPage({
|
||||
params
|
||||
}: {
|
||||
params: Promise<{ channelId: string }>;
|
||||
}) {
|
||||
const session = await getSession();
|
||||
if (!session || session.role !== "super_admin") {
|
||||
redirect("/unauthorized");
|
||||
}
|
||||
|
||||
const { channelId } = await params;
|
||||
|
||||
const channel = await prisma.channel.findUnique({
|
||||
where: { id: channelId },
|
||||
include: {
|
||||
tenant: true,
|
||||
conversations: {
|
||||
where: {},
|
||||
take: 5,
|
||||
orderBy: { lastMessageAt: "desc" }
|
||||
},
|
||||
webhookEvents: {
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 8
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!channel) {
|
||||
redirect("/super-admin/channels?error=channel_not_found");
|
||||
}
|
||||
|
||||
const failedWebhookCount = channel.webhookEvents.filter((item) => item.processStatus === "failed").length;
|
||||
|
||||
return (
|
||||
<ShellPage shell="super-admin" title="Channel Detail" description="Phone status, webhook health, failure summary, dan reconnect action.">
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<SectionCard title="Channel info">
|
||||
<p className="text-sm text-on-surface-variant">Tenant: {channel.tenant.name}</p>
|
||||
<p className="text-sm text-on-surface-variant">Provider: {channel.provider}</p>
|
||||
<p className="text-sm text-on-surface-variant">Channel name: {channel.channelName}</p>
|
||||
<p className="text-sm text-on-surface-variant">WABA ID: {channel.wabaId ?? "-"}</p>
|
||||
<p className="text-sm text-on-surface-variant">Phone Number ID: {channel.phoneNumberId ?? "-"}</p>
|
||||
<p className="text-sm text-on-surface-variant">Display Number: {channel.displayPhoneNumber ?? "-"}</p>
|
||||
<p className="text-sm text-on-surface-variant">Status: {channel.status}</p>
|
||||
<p className="text-sm text-on-surface-variant">Webhook status: {channel.webhookStatus ?? "unknown"}</p>
|
||||
<p className="text-sm text-on-surface-variant">Last sync: {formatDate(channel.lastSyncAt)}</p>
|
||||
<div className="mt-4 flex gap-3">
|
||||
<Link href={`/super-admin/tenants/${channel.tenantId}`} className="text-brand hover:underline">
|
||||
Open tenant
|
||||
</Link>
|
||||
<Link href="/super-admin/channels" className="text-brand hover:underline">
|
||||
Back to channels
|
||||
</Link>
|
||||
</div>
|
||||
</SectionCard>
|
||||
<SectionCard title="Health">
|
||||
<p className="text-sm text-on-surface-variant">Webhook failures: {failedWebhookCount}</p>
|
||||
<p className="text-sm text-on-surface-variant">Conversations tracked: {channel.conversations.length}</p>
|
||||
<ul className="mt-2 space-y-2">
|
||||
{channel.webhookEvents.map((event) => (
|
||||
<li key={event.id} className="rounded-xl border border-line bg-surface-container p-3">
|
||||
<p className="text-xs text-outline">{event.eventType}</p>
|
||||
<p className="text-sm text-on-surface">Status: {event.processStatus}</p>
|
||||
<p className="text-xs text-outline">Created: {formatDate(event.createdAt)}</p>
|
||||
</li>
|
||||
))}
|
||||
{channel.webhookEvents.length === 0 ? <p className="text-sm text-on-surface-variant">No webhook events.</p> : null}
|
||||
</ul>
|
||||
</SectionCard>
|
||||
</div>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
52
app/super-admin/channels/page.tsx
Normal file
52
app/super-admin/channels/page.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
import { PlaceholderActions, ShellPage } from "@/components/page-templates";
|
||||
import { TablePlaceholder } from "@/components/placeholders";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
function formatDate(date: Date | null) {
|
||||
if (!date) {
|
||||
return "Not synced";
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat("id-ID", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export default async function SuperAdminChannelsPage() {
|
||||
const session = await getSession();
|
||||
if (!session || session.role !== "super_admin") {
|
||||
redirect("/unauthorized");
|
||||
}
|
||||
|
||||
const channels = await prisma.channel.findMany({
|
||||
include: { tenant: true },
|
||||
orderBy: { updatedAt: "desc" }
|
||||
});
|
||||
|
||||
return (
|
||||
<ShellPage
|
||||
shell="super-admin"
|
||||
title="Channels"
|
||||
description="Connected numbers, webhook health, dan last sync."
|
||||
actions={<PlaceholderActions primaryHref="/super-admin/tenants" primaryLabel="Tenant list" />}
|
||||
>
|
||||
<TablePlaceholder
|
||||
title="Channel list"
|
||||
columns={["Number", "Tenant", "Provider", "Status", "Webhook"]}
|
||||
rows={channels.map((channel) => [
|
||||
channel.displayPhoneNumber || "N/A",
|
||||
channel.tenant.name,
|
||||
channel.provider,
|
||||
channel.status,
|
||||
`${channel.webhookStatus || "unknown"} • ${formatDate(channel.lastSyncAt)}`
|
||||
])}
|
||||
/>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
18
app/super-admin/page.tsx
Normal file
18
app/super-admin/page.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import { DashboardPlaceholder } from "@/components/placeholders";
|
||||
import { PlaceholderActions, ShellPage } from "@/components/page-templates";
|
||||
import { getPlatformSummary } from "@/lib/platform-data";
|
||||
|
||||
export default async function SuperAdminDashboardPage() {
|
||||
const data = await getPlatformSummary();
|
||||
|
||||
return (
|
||||
<ShellPage
|
||||
shell="super-admin"
|
||||
title="Super Admin Dashboard"
|
||||
description="Global KPI, tenant health, channel failures, dan subscription overview."
|
||||
actions={<PlaceholderActions primaryHref="/super-admin/tenants/new" primaryLabel="Create tenant" secondaryHref="/super-admin/channels" secondaryLabel="View channels" />}
|
||||
>
|
||||
<DashboardPlaceholder stats={data.stats} priorityQueue={data.tenants} />
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
13
app/super-admin/reports/page.tsx
Normal file
13
app/super-admin/reports/page.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { DashboardPlaceholder } from "@/components/placeholders";
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { getPlatformSummary } from "@/lib/platform-data";
|
||||
|
||||
export default async function SuperAdminReportsPage() {
|
||||
const data = await getPlatformSummary();
|
||||
|
||||
return (
|
||||
<ShellPage shell="super-admin" title="Platform Reports" description="Global traffic, tenant growth, usage, dan failure monitoring.">
|
||||
<DashboardPlaceholder stats={data.stats} priorityQueue={data.tenants} />
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
45
app/super-admin/security-events/page.tsx
Normal file
45
app/super-admin/security-events/page.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { TablePlaceholder } from "@/components/placeholders";
|
||||
|
||||
function formatTime(date: Date) {
|
||||
return new Intl.DateTimeFormat("id-ID", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function normalizeAction(action: string) {
|
||||
return action.includes("failed") ? "Failed" : action.replace(/_/g, " ");
|
||||
}
|
||||
|
||||
export default async function SecurityEventsPage() {
|
||||
const session = await getSession();
|
||||
if (!session || session.role !== "super_admin") {
|
||||
redirect("/unauthorized");
|
||||
}
|
||||
|
||||
const events = await prisma.auditLog.findMany({
|
||||
where: {
|
||||
OR: [{ action: { contains: "failed" } }, { action: { contains: "permission" } }, { action: { contains: "security" } }]
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 30
|
||||
});
|
||||
|
||||
return (
|
||||
<ShellPage shell="super-admin" title="Security Events" description="Failed logins, suspicious access, dan permission changes.">
|
||||
<TablePlaceholder
|
||||
title="Security feed"
|
||||
columns={["Time", "Type", "Tenant ID", "Status"]}
|
||||
rows={events.map((event) => [formatTime(event.createdAt), normalizeAction(event.action), event.tenantId, "Detected"])}
|
||||
/>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
100
app/super-admin/settings/page.tsx
Normal file
100
app/super-admin/settings/page.tsx
Normal file
@ -0,0 +1,100 @@
|
||||
import Link from "next/link";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { TablePlaceholder } from "@/components/placeholders";
|
||||
|
||||
function formatDate(date: Date | null) {
|
||||
if (!date) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat("id-ID", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric"
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export default async function SuperAdminSettingsPage() {
|
||||
const session = await getSession();
|
||||
if (!session || session.role !== "super_admin") {
|
||||
return (
|
||||
<ShellPage shell="super-admin" title="Platform Settings" description="Pricing config, feature flags, dan template policy config.">
|
||||
<p className="rounded-xl border border-warning/30 bg-warning/10 p-3 text-sm text-warning">Akses super-admin diperlukan.</p>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
|
||||
const [tenantCountByStatus, planCount, lastInvoiceDate, channelStatus] = await Promise.all([
|
||||
prisma.tenant.groupBy({
|
||||
by: ["status"],
|
||||
_count: { _all: true }
|
||||
}),
|
||||
prisma.subscriptionPlan.count(),
|
||||
prisma.billingInvoice.findFirst({
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { createdAt: true }
|
||||
}),
|
||||
prisma.channel.groupBy({
|
||||
by: ["status"],
|
||||
_count: { _all: true }
|
||||
})
|
||||
]);
|
||||
|
||||
const modules = [
|
||||
{
|
||||
name: "Subscription plans",
|
||||
purpose: "Plan catalog dan metadata harga",
|
||||
route: "/super-admin/billing/plans",
|
||||
status: `${planCount} plans`
|
||||
},
|
||||
{
|
||||
name: "Tenant management",
|
||||
purpose: "Pengelolaan tenant, status, dan limit",
|
||||
route: "/super-admin/tenants",
|
||||
status: `${tenantCountByStatus.reduce((acc, item) => acc + item._count._all, 0)} tenants`
|
||||
},
|
||||
{
|
||||
name: "Channel registry",
|
||||
purpose: "Provider channel dan health status",
|
||||
route: "/super-admin/channels",
|
||||
status: `Connected: ${channelStatus.find((item) => item.status === "CONNECTED")?._count._all ?? 0}`
|
||||
},
|
||||
{
|
||||
name: "Webhook logs",
|
||||
purpose: "Monitoring event provider",
|
||||
route: "/super-admin/webhook-logs",
|
||||
status: "Realtime stream"
|
||||
},
|
||||
{
|
||||
name: "Template policy",
|
||||
purpose: "Approval dan pembatasan template",
|
||||
route: "/templates",
|
||||
status: "Review by tenant"
|
||||
},
|
||||
{
|
||||
name: "Invoice monitoring",
|
||||
purpose: "Status pembayaran tenant",
|
||||
route: "/super-admin/billing/invoices",
|
||||
status: lastInvoiceDate ? `Last: ${formatDate(lastInvoiceDate.createdAt)}` : "No invoices"
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<ShellPage shell="super-admin" title="Platform Settings" description="Overview setting-platform berdasarkan data operasional real-time.">
|
||||
<TablePlaceholder
|
||||
title="Platform settings modules"
|
||||
columns={["Module", "Purpose", "Route", "Status"]}
|
||||
rows={modules.map((module) => [
|
||||
module.name,
|
||||
module.purpose,
|
||||
<Link key={`${module.name}-route`} href={module.route} className="text-brand hover:underline">
|
||||
{module.route}
|
||||
</Link>,
|
||||
module.status
|
||||
])}
|
||||
/>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
73
app/super-admin/tenants/[tenantId]/channels/new/page.tsx
Normal file
73
app/super-admin/tenants/[tenantId]/channels/new/page.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { Button, SectionCard } from "@/components/ui";
|
||||
import { connectTenantChannel } from "@/lib/admin-crud";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { ChannelStatus } from "@prisma/client";
|
||||
|
||||
export default async function ConnectTenantChannelPage({
|
||||
params,
|
||||
searchParams
|
||||
}: {
|
||||
params: Promise<{ tenantId: string }>;
|
||||
searchParams?: Promise<{ error?: string }>;
|
||||
}) {
|
||||
const session = await getSession();
|
||||
if (!session || session.role !== "super_admin") {
|
||||
redirect("/unauthorized");
|
||||
}
|
||||
|
||||
const { tenantId } = await params;
|
||||
const [tenant, errorResult] = await Promise.all([
|
||||
prisma.tenant.findUnique({ where: { id: tenantId }, select: { id: true, name: true } }),
|
||||
searchParams ?? Promise.resolve({ error: undefined })
|
||||
]);
|
||||
|
||||
if (!tenant) {
|
||||
redirect("/super-admin/tenants?error=tenant_not_found");
|
||||
}
|
||||
|
||||
const error = errorResult.error;
|
||||
const errorMessage =
|
||||
error === "missing_fields"
|
||||
? "Semua field wajib diisi."
|
||||
: error === "tenant_not_found"
|
||||
? "Tenant tidak ditemukan."
|
||||
: error === "invalid_status"
|
||||
? "Status channel tidak valid."
|
||||
: null;
|
||||
|
||||
return (
|
||||
<ShellPage shell="super-admin" title="Connect Channel" description={`Hubungkan WABA ID, phone number ID, dan webhook config ke ${tenant.name}.`}>
|
||||
<SectionCard title="Channel form">
|
||||
<form action={connectTenantChannel} className="grid gap-4 md:max-w-3xl">
|
||||
<input type="hidden" name="tenantId" value={tenant.id} />
|
||||
{errorMessage ? <p className="rounded-xl border border-warning/30 bg-warning/10 p-3 text-sm text-warning">{errorMessage}</p> : null}
|
||||
<input name="provider" className="rounded-xl border border-line px-4 py-3" placeholder="Provider" required />
|
||||
<input name="channelName" className="rounded-xl border border-line px-4 py-3" placeholder="Channel name" required />
|
||||
<input name="wabaId" className="rounded-xl border border-line px-4 py-3" placeholder="WABA ID" required />
|
||||
<input name="phoneNumberId" className="rounded-xl border border-line px-4 py-3" placeholder="Phone Number ID" required />
|
||||
<input name="displayPhoneNumber" className="rounded-xl border border-line px-4 py-3" placeholder="Display Number" required />
|
||||
<label className="text-sm text-on-surface-variant">
|
||||
<span>Status awal</span>
|
||||
<select name="status" defaultValue={ChannelStatus.PENDING} className="mt-2 w-full rounded-xl border border-line px-4 py-3">
|
||||
{Object.values(ChannelStatus).map((status) => (
|
||||
<option key={status} value={status}>
|
||||
{status}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="flex gap-3">
|
||||
<Button type="submit">Save channel</Button>
|
||||
<Button href={`/super-admin/tenants/${tenant.id}`} variant="secondary">
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</SectionCard>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
93
app/super-admin/tenants/[tenantId]/edit/page.tsx
Normal file
93
app/super-admin/tenants/[tenantId]/edit/page.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { Button, SectionCard } from "@/components/ui";
|
||||
import { updateTenant } from "@/lib/admin-crud";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
function formatTenantLabel(tenant: { status: string }) {
|
||||
return tenant.status;
|
||||
}
|
||||
|
||||
export default async function EditTenantPage({
|
||||
params,
|
||||
searchParams
|
||||
}: {
|
||||
params: Promise<{ tenantId: string }>;
|
||||
searchParams?: Promise<{ error?: string }>;
|
||||
}) {
|
||||
const session = await getSession();
|
||||
if (!session || session.role !== "super_admin") {
|
||||
redirect("/unauthorized");
|
||||
}
|
||||
|
||||
const { tenantId } = await params;
|
||||
const [tenant, plans, query] = await Promise.all([
|
||||
prisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
include: { plan: true }
|
||||
}),
|
||||
prisma.subscriptionPlan.findMany({ orderBy: { priceMonthly: "asc" } }),
|
||||
searchParams ?? Promise.resolve({ error: undefined })
|
||||
]);
|
||||
|
||||
if (!tenant) {
|
||||
redirect("/super-admin/tenants?error=tenant_not_found");
|
||||
}
|
||||
|
||||
const error = query.error;
|
||||
const infoMessage =
|
||||
error === "missing_fields"
|
||||
? "Semua field wajib diisi."
|
||||
: error === "slug_exists"
|
||||
? "Slug sudah dipakai tenant lain."
|
||||
: error === "invalid_plan"
|
||||
? "Plan tidak valid."
|
||||
: null;
|
||||
|
||||
return (
|
||||
<ShellPage shell="super-admin" title="Edit Tenant" description="Update tenant profile dan subscription metadata.">
|
||||
<SectionCard title="Tenant form">
|
||||
<form action={updateTenant} className="grid gap-4 md:max-w-3xl">
|
||||
<input type="hidden" name="tenantId" value={tenant.id} />
|
||||
{infoMessage ? (
|
||||
<p className="rounded-xl border border-warning/30 bg-warning/10 p-3 text-sm text-warning">{infoMessage}</p>
|
||||
) : null}
|
||||
<input
|
||||
name="name"
|
||||
className="rounded-xl border border-line px-4 py-3"
|
||||
defaultValue={tenant.name}
|
||||
placeholder="Company name"
|
||||
required
|
||||
/>
|
||||
<input
|
||||
name="companyName"
|
||||
className="rounded-xl border border-line px-4 py-3"
|
||||
defaultValue={tenant.companyName}
|
||||
placeholder="Company legal name"
|
||||
required
|
||||
/>
|
||||
<input name="slug" className="rounded-xl border border-line px-4 py-3" defaultValue={tenant.slug} placeholder="Tenant slug" required />
|
||||
<select name="status" required className="rounded-xl border border-line px-4 py-3" defaultValue={formatTenantLabel(tenant)}>
|
||||
<option value="ACTIVE">Active</option>
|
||||
<option value="TRIAL">Trial</option>
|
||||
<option value="SUSPENDED">Suspended</option>
|
||||
<option value="INACTIVE">Inactive</option>
|
||||
</select>
|
||||
<select name="planId" required className="rounded-xl border border-line px-4 py-3" defaultValue={tenant.planId}>
|
||||
{plans.map((plan) => (
|
||||
<option key={plan.id} value={plan.id}>
|
||||
{plan.name} ({plan.code})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input name="timezone" className="rounded-xl border border-line px-4 py-3" placeholder="Timezone" defaultValue={tenant.timezone} required />
|
||||
<div>
|
||||
<Button type="submit">Save changes</Button>
|
||||
</div>
|
||||
</form>
|
||||
</SectionCard>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
161
app/super-admin/tenants/[tenantId]/page.tsx
Normal file
161
app/super-admin/tenants/[tenantId]/page.tsx
Normal file
@ -0,0 +1,161 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { SectionCard } from "@/components/ui";
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
function formatDate(value: Date | null | undefined) {
|
||||
if (!value) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat("id-ID", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function formatMoney(value: number) {
|
||||
return new Intl.NumberFormat("id-ID", {
|
||||
style: "currency",
|
||||
currency: "IDR",
|
||||
maximumFractionDigits: 0
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
export default async function TenantDetailPage({ params }: { params: Promise<{ tenantId: string }> }) {
|
||||
const session = await getSession();
|
||||
if (!session || session.role !== "super_admin") {
|
||||
redirect("/unauthorized");
|
||||
}
|
||||
|
||||
const { tenantId } = await params;
|
||||
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
include: {
|
||||
plan: true,
|
||||
channels: {
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: {
|
||||
_count: {
|
||||
select: { conversations: true }
|
||||
}
|
||||
}
|
||||
},
|
||||
users: {
|
||||
orderBy: { fullName: "asc" }
|
||||
},
|
||||
contacts: {
|
||||
select: { id: true }
|
||||
},
|
||||
billingInvoices: {
|
||||
include: { plan: true },
|
||||
orderBy: { dueDate: "desc" },
|
||||
take: 5
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!tenant) {
|
||||
redirect("/super-admin/tenants?error=tenant_not_found");
|
||||
}
|
||||
|
||||
const [openConversationCount, invoiceTotal, unresolvedWebhook] = await Promise.all([
|
||||
prisma.conversation.count({ where: { tenantId, status: { in: ["OPEN", "PENDING"] } } }),
|
||||
prisma.billingInvoice.aggregate({
|
||||
where: { tenantId },
|
||||
_sum: { totalAmount: true }
|
||||
}),
|
||||
prisma.webhookEvent.count({ where: { tenantId, processStatus: "failed" } })
|
||||
]);
|
||||
|
||||
return (
|
||||
<ShellPage shell="super-admin" title="Tenant Detail" description="Company profile, plan, channel status, usage summary, dan seat usage.">
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<SectionCard title="Tenant summary">
|
||||
<p className="text-sm text-on-surface-variant">Name: {tenant.name}</p>
|
||||
<p className="text-sm text-on-surface-variant">Company: {tenant.companyName}</p>
|
||||
<p className="text-sm text-on-surface-variant">Slug: {tenant.slug}</p>
|
||||
<p className="text-sm text-on-surface-variant">Status: {tenant.status}</p>
|
||||
<p className="text-sm text-on-surface-variant">Timezone: {tenant.timezone}</p>
|
||||
<p className="text-sm text-on-surface-variant">Plan: {tenant.plan.name}</p>
|
||||
<p className="text-sm text-on-surface-variant">Seats: {tenant.users.length}/{tenant.plan.seatQuota}</p>
|
||||
<p className="text-sm text-on-surface-variant">Contacts: {tenant.contacts.length}</p>
|
||||
<p className="mt-3 space-y-1 text-sm text-on-surface">
|
||||
Open/Pending conversations: {openConversationCount}
|
||||
<br />
|
||||
Unresolved webhook events: {unresolvedWebhook}
|
||||
<br />
|
||||
Outstanding invoices: {tenant.billingInvoices.filter((invoice) => invoice.paymentStatus !== "PAID").length}
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
<Link href={`/super-admin/tenants/${tenant.id}/edit`} className="text-brand hover:underline">
|
||||
Edit tenant
|
||||
</Link>
|
||||
<Link href={`/super-admin/tenants/${tenant.id}/channels/new`} className="text-brand hover:underline">
|
||||
Connect channel
|
||||
</Link>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="Channels">
|
||||
{tenant.channels.length === 0 ? <p className="text-sm text-outline">No channels connected.</p> : null}
|
||||
<ul className="space-y-2">
|
||||
{tenant.channels.map((channel) => (
|
||||
<li key={channel.id} className="rounded-xl border border-line bg-surface-container p-3">
|
||||
<p className="font-medium text-ink">{channel.channelName}</p>
|
||||
<p className="text-sm text-on-surface-variant">{channel.displayPhoneNumber || "No number"}</p>
|
||||
<p className="text-sm text-outline">Status: {channel.status}</p>
|
||||
<p className="text-xs text-outline">Conversations: {channel._count.conversations}</p>
|
||||
<Link href={`/super-admin/channels/${channel.id}`} className="text-xs text-brand hover:underline">
|
||||
Open detail
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="Recent invoices">
|
||||
{tenant.billingInvoices.length === 0 ? <p className="text-sm text-on-surface-variant">No invoices found.</p> : null}
|
||||
<ul className="space-y-2">
|
||||
{tenant.billingInvoices.map((invoice) => (
|
||||
<li key={invoice.id} className="rounded-xl border border-line bg-surface-container p-3 text-sm">
|
||||
<p className="font-medium text-ink">{invoice.invoiceNumber}</p>
|
||||
<p className="text-on-surface-variant">
|
||||
{invoice.plan.name} • {formatMoney(invoice.totalAmount)} • {invoice.paymentStatus}
|
||||
</p>
|
||||
<p className="text-xs text-outline">Due: {formatDate(invoice.dueDate)}</p>
|
||||
<Link href={`/super-admin/billing/invoices/${invoice.id}`} className="text-xs text-brand hover:underline">
|
||||
View
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="Recent team members">
|
||||
{tenant.users.length === 0 ? <p className="text-sm text-on-surface-variant">No users.</p> : null}
|
||||
<ul className="space-y-2">
|
||||
{tenant.users.map((user) => (
|
||||
<li key={user.id} className="rounded-xl border border-line bg-surface-container p-3 text-sm">
|
||||
<p className="font-medium text-ink">{user.fullName}</p>
|
||||
<p className="text-on-surface-variant">{user.email}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</SectionCard>
|
||||
</div>
|
||||
|
||||
<SectionCard title="Tenant finance totals">
|
||||
<p className="text-sm text-on-surface-variant">Total invoice amount: {formatMoney(invoiceTotal._sum.totalAmount ?? 0)}</p>
|
||||
</SectionCard>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
|
||||
66
app/super-admin/tenants/new/page.tsx
Normal file
66
app/super-admin/tenants/new/page.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { Button, SectionCard } from "@/components/ui";
|
||||
import { createTenant } from "@/lib/admin-crud";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function NewTenantPage({
|
||||
searchParams
|
||||
}: {
|
||||
searchParams?: Promise<{ error?: string }>;
|
||||
}) {
|
||||
const session = await getSession();
|
||||
if (!session || session.role !== "super_admin") {
|
||||
redirect("/unauthorized");
|
||||
}
|
||||
|
||||
const [plans, params] = await Promise.all([
|
||||
prisma.subscriptionPlan.findMany({ orderBy: { priceMonthly: "asc" } }),
|
||||
searchParams ?? Promise.resolve({ error: undefined })
|
||||
]);
|
||||
|
||||
const error = params.error;
|
||||
const errorMessage =
|
||||
error === "missing_fields"
|
||||
? "Nama perusahaan, slug, timezone, dan plan wajib diisi."
|
||||
: error === "invalid_plan"
|
||||
? "Plan tidak valid."
|
||||
: error === "slug_exists"
|
||||
? "Slug tenant sudah dipakai."
|
||||
: error === "admin_email_exists"
|
||||
? "Email admin awal sudah terpakai."
|
||||
: null;
|
||||
|
||||
return (
|
||||
<ShellPage shell="super-admin" title="Create Tenant" description="Setup tenant baru beserta plan dan admin awal.">
|
||||
<SectionCard title="Tenant form">
|
||||
<form action={createTenant} className="grid gap-4 md:max-w-3xl md:grid-cols-2">
|
||||
{errorMessage ? <p className="col-span-2 rounded-xl border border-warning/30 bg-warning/10 p-3 text-sm text-warning">{errorMessage}</p> : null}
|
||||
<input name="name" className="rounded-xl border border-line px-4 py-3" placeholder="Company name" required />
|
||||
<input name="slug" className="rounded-xl border border-line px-4 py-3" placeholder="Tenant slug" required />
|
||||
<input name="timezone" className="rounded-xl border border-line px-4 py-3" placeholder="Timezone" required />
|
||||
<select name="planId" required className="rounded-xl border border-line px-4 py-3" defaultValue="">
|
||||
<option value="">Pilih plan</option>
|
||||
{plans.map((plan) => (
|
||||
<option key={plan.id} value={plan.id}>
|
||||
{plan.name} ({plan.code}) - Rp {plan.priceMonthly.toLocaleString("id-ID")}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input name="adminFullName" className="rounded-xl border border-line px-4 py-3" placeholder="Nama admin awal" />
|
||||
<input name="adminEmail" type="email" className="rounded-xl border border-line px-4 py-3" placeholder="Initial admin email" />
|
||||
<input
|
||||
name="adminPassword"
|
||||
type="password"
|
||||
className="rounded-xl border border-line px-4 py-3 md:col-span-2"
|
||||
placeholder="Password awal admin (kosong: kirim undangan)"
|
||||
/>
|
||||
<div className="md:col-span-2">
|
||||
<Button type="submit">Create tenant</Button>
|
||||
</div>
|
||||
</form>
|
||||
</SectionCard>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
22
app/super-admin/tenants/page.tsx
Normal file
22
app/super-admin/tenants/page.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import { PlaceholderActions, ShellPage } from "@/components/page-templates";
|
||||
import { TablePlaceholder } from "@/components/placeholders";
|
||||
import { getTenantsData } from "@/lib/platform-data";
|
||||
|
||||
export default async function SuperAdminTenantsPage() {
|
||||
const tenants = await getTenantsData();
|
||||
|
||||
return (
|
||||
<ShellPage
|
||||
shell="super-admin"
|
||||
title="Tenants"
|
||||
description="Daftar tenant, plan, seat usage, dan status channel."
|
||||
actions={<PlaceholderActions primaryHref="/super-admin/tenants/new" primaryLabel="Create tenant" />}
|
||||
>
|
||||
<TablePlaceholder
|
||||
title="Tenant list"
|
||||
columns={["Tenant", "Plan", "Status", "Channels", "Seats"]}
|
||||
rows={tenants.map((tenant) => [tenant.name, tenant.plan, tenant.status, tenant.channels, tenant.seats])}
|
||||
/>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
43
app/super-admin/webhook-logs/page.tsx
Normal file
43
app/super-admin/webhook-logs/page.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { TablePlaceholder } from "@/components/placeholders";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
function formatTime(value: Date) {
|
||||
return new Intl.DateTimeFormat("id-ID", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
export default async function WebhookLogsPage() {
|
||||
const session = await getSession();
|
||||
if (!session || session.role !== "super_admin") {
|
||||
redirect("/unauthorized");
|
||||
}
|
||||
|
||||
const events = await prisma.webhookEvent.findMany({
|
||||
include: { tenant: true, channel: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 120
|
||||
});
|
||||
|
||||
return (
|
||||
<ShellPage shell="super-admin" title="Webhook Logs" description="Raw provider event logs dan process status.">
|
||||
<TablePlaceholder
|
||||
title="Webhook events"
|
||||
columns={["Event type", "Provider event ID", "Tenant", "Status"]}
|
||||
rows={events.map((event) => [
|
||||
`${event.eventType} (${event.channel?.channelName ?? "global"})`,
|
||||
event.providerEventId ?? "-",
|
||||
event.tenant.name,
|
||||
`${event.processStatus} · ${formatTime(event.createdAt)}`
|
||||
])}
|
||||
/>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user