302 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|