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:
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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user