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:
51
app/settings/auto-assignment/page.tsx
Normal file
51
app/settings/auto-assignment/page.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { Button, SectionCard } from "@/components/ui";
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function AutoAssignmentSettingsPage() {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
if (session.role === "agent") {
|
||||
redirect("/unauthorized");
|
||||
}
|
||||
|
||||
const tenantFilter = session.role === "super_admin" ? {} : { tenantId: session.tenantId };
|
||||
|
||||
const [pendingCount, openCount, agentCount, unassignedCount] = await Promise.all([
|
||||
prisma.conversation.count({ where: { ...tenantFilter, status: "PENDING" } }),
|
||||
prisma.conversation.count({ where: { ...tenantFilter, status: "OPEN" } }),
|
||||
prisma.user.count({ where: { ...tenantFilter, role: { code: "AGENT" } } }),
|
||||
prisma.conversation.count({ where: { ...tenantFilter, assignedUserId: null, status: { in: ["OPEN", "PENDING"] } } })
|
||||
]);
|
||||
|
||||
const autoRuleSuggestion =
|
||||
agentCount > 0
|
||||
? `Round-robin aktif (${agentCount} agent): prioritas agent akan otomatis berputar saat penugasan masuk.`
|
||||
: "Tambahkan agent terlebih dahulu sebelum auto-assignment dapat berjalan penuh.";
|
||||
|
||||
return (
|
||||
<ShellPage shell="admin" title="Auto Assignment Rules" description="Pengaturan distribusi percakapan dan ringkasan antrean.">
|
||||
<SectionCard title="Auto assignment status">
|
||||
<div className="grid gap-4 md:max-w-2xl">
|
||||
<p className="text-sm text-on-surface-variant">Auto-assign belum memiliki field konfigurasi per tenant di DB saat ini.</p>
|
||||
<p className="text-sm text-on-surface">
|
||||
Open: {openCount} • Pending: {pendingCount} • Unassigned: {unassignedCount} • Agent aktif: {agentCount}
|
||||
</p>
|
||||
<p className="text-sm">{autoRuleSuggestion}</p>
|
||||
<p className="rounded-xl border border-line bg-surface-container p-3 text-sm text-on-surface-variant">
|
||||
Rekomendasi rule: utamakan penugasan ke agent dengan beban kerja paling rendah dari hitungan `open + pending`.
|
||||
</p>
|
||||
<div>
|
||||
<Button href="/settings">Back to Settings</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
47
app/settings/business-hours/page.tsx
Normal file
47
app/settings/business-hours/page.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { Button, SectionCard } from "@/components/ui";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function BusinessHoursSettingsPage() {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
if (session.role === "agent") {
|
||||
redirect("/unauthorized");
|
||||
}
|
||||
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: session.tenantId },
|
||||
select: {
|
||||
timezone: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!tenant) {
|
||||
redirect("/unauthorized");
|
||||
}
|
||||
|
||||
return (
|
||||
<ShellPage shell="admin" title="Business Hours" description="Jam operasional tenant.">
|
||||
<SectionCard title="Schedule">
|
||||
<div className="grid gap-4 md:max-w-2xl">
|
||||
<input
|
||||
className="rounded-xl border border-line px-4 py-3"
|
||||
defaultValue={`Timezone aktif: ${tenant.timezone}`}
|
||||
readOnly
|
||||
/>
|
||||
<p className="text-sm text-on-surface-variant">
|
||||
Saat ini pengaturan jam operasional belum memiliki tabel konfigurasi tersendiri di schema ini.
|
||||
</p>
|
||||
<div>
|
||||
<Button href="/settings">Back to Settings</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
46
app/settings/canned-responses/page.tsx
Normal file
46
app/settings/canned-responses/page.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { TablePlaceholder } from "@/components/placeholders";
|
||||
|
||||
function previewText(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return "-";
|
||||
}
|
||||
return value.length > 40 ? `${value.slice(0, 40)}...` : value;
|
||||
}
|
||||
|
||||
export default async function CannedResponsesPage() {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const templates = await prisma.messageTemplate.findMany({
|
||||
where: { tenantId: session.tenantId },
|
||||
orderBy: { createdAt: "desc" }
|
||||
});
|
||||
|
||||
return (
|
||||
<ShellPage shell="admin" title="Canned Responses" description="Library jawaban cepat untuk agent dan admin.">
|
||||
<TablePlaceholder
|
||||
title="Responses"
|
||||
columns={["Template", "Category", "Status", "Preview"]}
|
||||
rows={templates.map((template) => [
|
||||
template.name,
|
||||
template.category,
|
||||
template.approvalStatus,
|
||||
<div key={template.id} className="space-y-1">
|
||||
<p>{previewText(template.bodyText)}</p>
|
||||
<Link href={`/templates/${template.id}`} className="text-xs text-brand hover:underline">
|
||||
Open
|
||||
</Link>
|
||||
</div>
|
||||
])}
|
||||
/>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
84
app/settings/integrations/page.tsx
Normal file
84
app/settings/integrations/page.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { SectionCard } from "@/components/ui";
|
||||
import { headers } from "next/headers";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function IntegrationsSettingsPage() {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
if (session.role === "agent") {
|
||||
redirect("/unauthorized");
|
||||
}
|
||||
|
||||
const tenantFilter = session.role === "super_admin" ? {} : { tenantId: session.tenantId };
|
||||
|
||||
const [channels, recentWebhook] = await Promise.all([
|
||||
prisma.channel.findMany({
|
||||
where: tenantFilter,
|
||||
orderBy: { createdAt: "desc" }
|
||||
}),
|
||||
prisma.webhookEvent.findMany({
|
||||
where: tenantFilter,
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 4
|
||||
})
|
||||
]);
|
||||
|
||||
const host = (await headers()).get("host");
|
||||
const webhookBase = host ? `${process.env.NODE_ENV === "production" ? "https" : "http"}://${host}` : "";
|
||||
|
||||
const connectedCount = channels.filter((channel) => channel.status === "CONNECTED").length;
|
||||
const failedCount = channels.filter((channel) => channel.status === "ERROR").length;
|
||||
|
||||
return (
|
||||
<ShellPage
|
||||
shell="admin"
|
||||
title="Webhook / Integration Settings"
|
||||
description="Status provider, webhook URL, dan reconnection action."
|
||||
>
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<SectionCard title="Provider config">
|
||||
<p className="text-sm text-on-surface-variant">
|
||||
Webhook URL: {webhookBase ? `${webhookBase}/api/webhooks/whatsapp` : "/api/webhooks/whatsapp"}
|
||||
</p>
|
||||
<p className="text-sm text-on-surface-variant">
|
||||
Connected: {connectedCount} • Error: {failedCount} • Total channels: {channels.length}
|
||||
</p>
|
||||
<ul className="mt-3 space-y-2">
|
||||
{channels.map((channel) => (
|
||||
<li key={channel.id} className="rounded-xl border border-line bg-surface-container p-3">
|
||||
<p className="font-medium text-ink">{channel.channelName}</p>
|
||||
<p className="text-sm text-on-surface-variant">Status: {channel.status}</p>
|
||||
<p className="text-sm text-outline">Provider: {channel.provider}</p>
|
||||
<p className="text-xs text-outline">
|
||||
WABA ID: {channel.wabaId ?? "N/A"} • Phone ID: {channel.phoneNumberId ?? "N/A"}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
{channels.length === 0 ? <p className="text-sm text-on-surface-variant">Tidak ada channel terhubung.</p> : null}
|
||||
</ul>
|
||||
</SectionCard>
|
||||
<SectionCard title="Health state">
|
||||
<ul className="space-y-2">
|
||||
{recentWebhook.map((event) => (
|
||||
<li key={event.id} className="rounded-xl border border-line bg-surface-container p-3">
|
||||
<p className="text-sm text-on-surface-variant">
|
||||
{event.eventType} • {event.processStatus}
|
||||
</p>
|
||||
<p className="text-xs text-outline">
|
||||
{event.createdAt.toLocaleString("id-ID", { dateStyle: "medium", timeStyle: "short" })}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
{recentWebhook.length === 0 ? <p className="text-sm text-on-surface-variant">Belum ada event webhook terbaru.</p> : null}
|
||||
</ul>
|
||||
</SectionCard>
|
||||
</div>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
21
app/settings/page.tsx
Normal file
21
app/settings/page.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { TablePlaceholder } from "@/components/placeholders";
|
||||
|
||||
export default function SettingsPage() {
|
||||
return (
|
||||
<ShellPage shell="admin" title="Settings" description="Hub tenant profile, business hours, tags, canned responses, dan integrations.">
|
||||
<TablePlaceholder
|
||||
title="Settings modules"
|
||||
columns={["Module", "Purpose", "Route"]}
|
||||
rows={[
|
||||
["Profile", "Tenant identity", "/settings/profile"],
|
||||
["Business Hours", "Operational schedule", "/settings/business-hours"],
|
||||
["Auto Assignment", "Distribution rules", "/settings/auto-assignment"],
|
||||
["Tags", "Chat tag management", "/settings/tags"],
|
||||
["Canned Responses", "Quick reply library", "/settings/canned-responses"],
|
||||
["Integrations", "Webhook and provider setup", "/settings/integrations"]
|
||||
]}
|
||||
/>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
85
app/settings/profile/page.tsx
Normal file
85
app/settings/profile/page.tsx
Normal file
@ -0,0 +1,85 @@
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { Button, SectionCard } from "@/components/ui";
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { updateTenantProfile } from "@/lib/admin-crud";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function TenantProfileSettingsPage({
|
||||
searchParams
|
||||
}: {
|
||||
searchParams?: Promise<{ error?: string; success?: string }>;
|
||||
}) {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: session.tenantId },
|
||||
select: {
|
||||
name: true,
|
||||
slug: true,
|
||||
timezone: true,
|
||||
plan: { select: { name: true } }
|
||||
}
|
||||
});
|
||||
|
||||
if (!tenant) {
|
||||
redirect("/unauthorized");
|
||||
}
|
||||
|
||||
const params = await (searchParams ?? Promise.resolve({ error: undefined, success: undefined }));
|
||||
const infoMessage =
|
||||
params.success === "updated"
|
||||
? "Pengaturan tenant berhasil disimpan."
|
||||
: params.error === "missing_fields"
|
||||
? "Nama perusahaan, timezone, dan slug wajib diisi."
|
||||
: params.error === "tenant_slug_taken"
|
||||
? "Slug tenant sudah dipakai, pilih slug lain."
|
||||
: null;
|
||||
|
||||
return (
|
||||
<ShellPage shell="admin" title="Tenant Profile Settings" description="Identitas tenant dan informasi workspace.">
|
||||
<SectionCard title="Tenant profile">
|
||||
<form action={updateTenantProfile} className="grid gap-4 md:max-w-2xl">
|
||||
{infoMessage ? (
|
||||
<p className="rounded-xl border border-success/30 bg-success/10 p-3 text-sm text-success">{infoMessage}</p>
|
||||
) : null}
|
||||
<input
|
||||
className="rounded-xl border border-line px-4 py-3"
|
||||
defaultValue={tenant.name}
|
||||
name="companyName"
|
||||
placeholder="Company name"
|
||||
required
|
||||
/>
|
||||
<input
|
||||
className="rounded-xl border border-line px-4 py-3"
|
||||
defaultValue={tenant.timezone}
|
||||
name="timezone"
|
||||
placeholder="Timezone"
|
||||
required
|
||||
/>
|
||||
<input
|
||||
className="rounded-xl border border-line px-4 py-3"
|
||||
defaultValue={tenant.slug}
|
||||
name="slug"
|
||||
placeholder="Tenant slug"
|
||||
required
|
||||
/>
|
||||
<input
|
||||
className="rounded-xl border border-line px-4 py-3"
|
||||
defaultValue={tenant.plan.name}
|
||||
name="plan"
|
||||
placeholder="Plan"
|
||||
readOnly
|
||||
/>
|
||||
<div>
|
||||
<Button type="submit">Save settings</Button>
|
||||
</div>
|
||||
</form>
|
||||
</SectionCard>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
46
app/settings/tags/page.tsx
Normal file
46
app/settings/tags/page.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { ShellPage } from "@/components/page-templates";
|
||||
import { TablePlaceholder } from "@/components/placeholders";
|
||||
|
||||
export default async function TagsSettingsPage() {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
if (session.role === "agent") {
|
||||
redirect("/unauthorized");
|
||||
}
|
||||
|
||||
const tags = await prisma.tag.findMany({
|
||||
where: { tenantId: session.tenantId },
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
conversationTags: true,
|
||||
contactTags: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: { name: "asc" }
|
||||
});
|
||||
|
||||
const rows = tags.map((tag) => [
|
||||
tag.name,
|
||||
tag.color ?? "-",
|
||||
String(tag._count.conversationTags + tag._count.contactTags)
|
||||
]);
|
||||
|
||||
return (
|
||||
<ShellPage shell="admin" title="Chat Tags Management" description="Daftar tag conversation dan contact.">
|
||||
<TablePlaceholder
|
||||
title="Tags"
|
||||
columns={["Tag", "Color", "Usage"]}
|
||||
rows={rows}
|
||||
/>
|
||||
</ShellPage>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user