302 lines
11 KiB
TypeScript

"use client";
import { FormEvent, useEffect, useState } from "react";
import { api } from "@/lib/api";
import { Modal, RegistryTable, fieldClass } from "@/components/registry-table";
type Tenant = {
id: string;
name: string;
slug: string;
status: string;
};
type Created = Tenant & {
access: { email: string; password: string; path: string };
};
type TenantUser = {
id: string;
name: string;
email: string;
role: "ADMIN" | "MANAGER" | "OPERATOR";
password: string | null;
};
const roleLabel = {
ADMIN: "Administrador",
MANAGER: "Manager",
OPERATOR: "Operador",
} as const;
function slugFromName(value: string) {
return value
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 30);
}
export default function PlatformPage() {
const [tenants, setTenants] = useState<Tenant[]>([]);
const [chatCounts, setChatCounts] = useState<Record<string, number>>({});
const [error, setError] = useState("");
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const [slug, setSlug] = useState("");
const [created, setCreated] = useState<Created | null>(null);
const [saving, setSaving] = useState(false);
const [usersOf, setUsersOf] = useState<Tenant | null>(null);
const [users, setUsers] = useState<TenantUser[]>([]);
const [usersError, setUsersError] = useState("");
const [issuing, setIssuing] = useState<string | null>(null);
async function load() {
const [list, counts] = await Promise.all([
api<Tenant[]>("/api/platform/tenants"),
api<Record<string, number>>("/api/chat/counts").catch(() => ({})),
]);
setTenants(list);
setChatCounts(counts);
}
useEffect(() => {
load().catch((err: unknown) => setError(err instanceof Error ? err.message : "Erro"));
}, []);
function closeModal() {
setOpen(false);
setCreated(null);
setName("");
setSlug("");
setError("");
}
async function openUsers(tenant: Tenant) {
setUsersError("");
setUsersOf(tenant);
setUsers([]);
try {
const result = await api<{ users: TenantUser[] }>(`/api/platform/tenants/${tenant.id}/users`);
setUsers(result.users);
} catch (err) {
setUsersError(err instanceof Error ? err.message : "Não foi possível listar os usuários.");
}
}
function closeUsers() {
setUsersOf(null);
setUsers([]);
setUsersError("");
setIssuing(null);
}
async function issuePassword(userId: string) {
if (!usersOf) {
return;
}
setUsersError("");
setIssuing(userId);
try {
const result = await api<{ id: string; password: string }>(
`/api/platform/tenants/${usersOf.id}/users/${userId}/password`,
{ method: "POST" },
);
setUsers((current) =>
current.map((user) => (user.id === userId ? { ...user, password: result.password } : user)),
);
} catch (err) {
setUsersError(err instanceof Error ? err.message : "Não foi possível gerar a senha.");
} finally {
setIssuing(null);
}
}
async function onCreate(event: FormEvent) {
event.preventDefault();
setError("");
setSaving(true);
try {
const result = await api<Created>("/api/platform/tenants", {
method: "POST",
body: JSON.stringify({ name, slug }),
});
setCreated(result);
await load();
} catch (err) {
setError(err instanceof Error ? err.message : "Não foi possível criar o cliente.");
} finally {
setSaving(false);
}
}
return (
<section>
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-[#82C85C]">Admin</p>
<h1 className="font-display mt-2 text-4xl font-extrabold uppercase">Clientes</h1>
<p className="mt-4 max-w-xl text-[#E8F4E5]/65">
Quem contrata a Sinka. Cada cliente tem um endereço próprio para o time entrar.
</p>
{error && !open ? <p className="mt-4 text-sm text-red-300">{error}</p> : null}
<RegistryTable
countLabel={`${tenants.length} cliente${tenants.length === 1 ? "" : "s"}`}
columns={["Cliente", "Endereço", "Situação", "Acesso"]}
isEmpty={tenants.length === 0}
empty="Nenhum cliente ainda."
action={
<button className="btn-sinka px-4 py-2 text-xs" type="button" onClick={() => setOpen(true)}>
Adicionar
</button>
}
>
{tenants.map((tenant) => (
<tr key={tenant.id} className="border-t border-white/10">
<td className="px-5 py-3 font-medium">{tenant.name}</td>
<td className="px-5 py-3 text-[#E8F4E5]/70">/{tenant.slug}</td>
<td className="px-5 py-3 text-[#E8F4E5]/70">{tenant.status === "ACTIVE" ? "Ativo" : "Suspenso"}</td>
<td className="px-5 py-3">
<div className="flex flex-wrap items-center gap-2">
<button
className="btn-ghost cursor-pointer px-3 py-1.5 text-[11px]"
type="button"
onClick={() => void openUsers(tenant)}
>
Usuários
</button>
<a
href={`/admin/chat/${tenant.slug}`}
className="relative btn-ghost cursor-pointer px-3 py-1.5 text-[11px]"
>
Chat
{chatCounts[tenant.slug] ? (
<span className="absolute -right-1 -top-1 inline-flex min-w-[1.15rem] items-center justify-center rounded-full bg-[#82C85C] px-1 text-[10px] font-bold leading-4 text-black">
{chatCounts[tenant.slug] > 99 ? "99+" : chatCounts[tenant.slug]}
</span>
) : null}
</a>
<a
href={`/${tenant.slug}/login`}
className="btn-sinka cursor-pointer px-3 py-1.5 text-[11px]"
target="_blank"
rel="noopener noreferrer"
>
Entrar
</a>
</div>
</td>
</tr>
))}
</RegistryTable>
{open ? (
<Modal onClose={closeModal}>
{created ? (
<div>
<h2 className="font-display text-2xl font-bold uppercase">Cliente criado</h2>
<p className="mt-3 text-sm text-[#E8F4E5]/65">Guarde o acesso do administrador:</p>
<p className="mt-4 text-sm">
Endereço:{" "}
<a className="text-[#82C85C]" href={created.access.path} target="_blank" rel="noopener noreferrer">
{created.access.path}
</a>
</p>
<p className="mt-2 text-sm">E-mail: {created.access.email}</p>
<p className="mt-2 text-sm">Senha: {created.access.password}</p>
<button className="btn-sinka mt-6" type="button" onClick={closeModal}>
Fechar
</button>
</div>
) : (
<form className="grid gap-3" onSubmit={onCreate}>
<h2 className="font-display text-2xl font-bold uppercase">Novo cliente</h2>
{error ? <p className="text-sm text-red-300">{error}</p> : null}
<input
className={fieldClass}
placeholder="Nome"
value={name}
onChange={(event) => {
setName(event.target.value);
setSlug(slugFromName(event.target.value));
}}
required
/>
<input
className={fieldClass}
placeholder="Endereço (ex: demo)"
value={slug}
onChange={(event) => setSlug(event.target.value.toLowerCase())}
required
/>
<div className="mt-2 flex gap-2">
<button className="btn-sinka" disabled={saving} type="submit">
{saving ? "Criando…" : "Adicionar"}
</button>
<button className="btn-ghost" type="button" onClick={closeModal}>
Cancelar
</button>
</div>
</form>
)}
</Modal>
) : null}
{usersOf ? (
<Modal wide onClose={closeUsers}>
<h2 className="font-display text-2xl font-bold uppercase">Usuários · {usersOf.name}</h2>
<p className="mt-2 text-sm text-[#E8F4E5]/60">
Contas de {usersOf.name}. Se a senha estiver vazia, gere uma nova.
</p>
{usersError ? <p className="mt-3 text-sm text-red-300">{usersError}</p> : null}
<div className="mt-5 overflow-x-auto">
<table className="w-full min-w-[36rem] text-left text-sm">
<thead className="text-xs uppercase tracking-wider text-[#E8F4E5]/50">
<tr>
<th className="pb-3 font-medium">Nome</th>
<th className="pb-3 font-medium">Usuário</th>
<th className="pb-3 font-medium">Senha</th>
<th className="pb-3 font-medium">Papel</th>
<th className="pb-3 font-medium" />
</tr>
</thead>
<tbody>
{users.length === 0 ? (
<tr>
<td className="py-6 text-[#E8F4E5]/50" colSpan={5}>
Nenhum usuário neste cliente.
</td>
</tr>
) : (
users.map((user) => (
<tr key={user.id} className="border-t border-white/10">
<td className="py-3 pr-4 font-medium">{user.name}</td>
<td className="py-3 pr-4 text-[#E8F4E5]/80">{user.email}</td>
<td className="py-3 pr-4 font-mono text-[#82C85C]">{user.password || "-"}</td>
<td className="py-3 pr-4 text-[#E8F4E5]/70">{roleLabel[user.role]}</td>
<td className="py-3 text-right">
<button
className="text-xs text-[#E8F4E5]/70"
type="button"
disabled={issuing === user.id}
onClick={() => void issuePassword(user.id)}
>
{issuing === user.id ? "Gerando…" : "Gerar nova senha"}
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
<button className="btn-sinka mt-6" type="button" onClick={closeUsers}>
Fechar
</button>
</Modal>
) : null}
</section>
);
}