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