chore: reorganize project structure and ensure default queues
This commit is contained in:
parent
854887f499
commit
1cccb852a5
201 changed files with 417 additions and 838 deletions
430
src/components/admin/admin-users-manager.tsx
Normal file
430
src/components/admin/admin-users-manager.tsx
Normal file
|
|
@ -0,0 +1,430 @@
|
|||
"use client"
|
||||
|
||||
import { useMemo, useState, useTransition } from "react"
|
||||
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { ROLE_OPTIONS, type RoleOption } from "@/lib/authz"
|
||||
|
||||
type AdminUser = {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
role: RoleOption
|
||||
tenantId: string
|
||||
createdAt: string
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
type AdminInvite = {
|
||||
id: string
|
||||
email: string
|
||||
name: string | null
|
||||
role: RoleOption
|
||||
tenantId: string
|
||||
status: "pending" | "accepted" | "revoked" | "expired"
|
||||
token: string
|
||||
inviteUrl: string
|
||||
expiresAt: string
|
||||
createdAt: string
|
||||
createdById: string | null
|
||||
acceptedAt: string | null
|
||||
acceptedById: string | null
|
||||
revokedAt: string | null
|
||||
revokedById: string | null
|
||||
revokedReason: string | null
|
||||
}
|
||||
|
||||
type Props = {
|
||||
initialUsers: AdminUser[]
|
||||
initialInvites: AdminInvite[]
|
||||
roleOptions: readonly RoleOption[]
|
||||
defaultTenantId: string
|
||||
}
|
||||
|
||||
function formatDate(dateIso: string) {
|
||||
const date = new Date(dateIso)
|
||||
return new Intl.DateTimeFormat("pt-BR", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
function formatStatus(status: AdminInvite["status"]) {
|
||||
switch (status) {
|
||||
case "pending":
|
||||
return "Pendente"
|
||||
case "accepted":
|
||||
return "Aceito"
|
||||
case "revoked":
|
||||
return "Revogado"
|
||||
case "expired":
|
||||
return "Expirado"
|
||||
default:
|
||||
return status
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeInvite(invite: AdminInvite & { events?: unknown }): AdminInvite {
|
||||
const { events: unusedEvents, ...rest } = invite
|
||||
void unusedEvents
|
||||
return rest
|
||||
}
|
||||
|
||||
export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, defaultTenantId }: Props) {
|
||||
const [users] = useState<AdminUser[]>(initialUsers)
|
||||
const [invites, setInvites] = useState<AdminInvite[]>(initialInvites)
|
||||
const [email, setEmail] = useState("")
|
||||
const [name, setName] = useState("")
|
||||
const [role, setRole] = useState<RoleOption>("agent")
|
||||
const [tenantId, setTenantId] = useState(defaultTenantId)
|
||||
const [expiresInDays, setExpiresInDays] = useState<string>("7")
|
||||
const [lastInviteLink, setLastInviteLink] = useState<string | null>(null)
|
||||
const [revokingId, setRevokingId] = useState<string | null>(null)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
const normalizedRoles = useMemo(() => roleOptions ?? ROLE_OPTIONS, [roleOptions])
|
||||
|
||||
async function handleInviteSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
if (!email || !email.includes("@")) {
|
||||
toast.error("Informe um e-mail válido")
|
||||
return
|
||||
}
|
||||
|
||||
const payload = {
|
||||
email,
|
||||
name,
|
||||
role,
|
||||
tenantId,
|
||||
expiresInDays: Number.parseInt(expiresInDays, 10),
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const response = await fetch("/api/admin/invites", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}))
|
||||
throw new Error(data.error ?? "Não foi possível gerar o convite")
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { invite: AdminInvite }
|
||||
const nextInvite = sanitizeInvite(data.invite)
|
||||
setInvites((previous) => [nextInvite, ...previous.filter((item) => item.id !== nextInvite.id)])
|
||||
setEmail("")
|
||||
setName("")
|
||||
setRole("agent")
|
||||
setTenantId(defaultTenantId)
|
||||
setExpiresInDays("7")
|
||||
setLastInviteLink(nextInvite.inviteUrl)
|
||||
toast.success("Convite criado com sucesso")
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Falha ao criar convite"
|
||||
toast.error(message)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handleCopy(link: string) {
|
||||
navigator.clipboard
|
||||
.writeText(link)
|
||||
.then(() => toast.success("Link de convite copiado"))
|
||||
.catch(() => toast.error("Não foi possível copiar o link"))
|
||||
}
|
||||
|
||||
async function handleRevoke(inviteId: string) {
|
||||
const invite = invites.find((item) => item.id === inviteId)
|
||||
if (!invite || invite.status !== "pending") {
|
||||
return
|
||||
}
|
||||
|
||||
const confirmed = window.confirm("Deseja revogar este convite?")
|
||||
if (!confirmed) return
|
||||
|
||||
setRevokingId(inviteId)
|
||||
try {
|
||||
const response = await fetch(`/api/admin/invites/${inviteId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ reason: "Revogado manualmente" }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}))
|
||||
throw new Error(data.error ?? "Falha ao revogar convite")
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { invite: AdminInvite }
|
||||
const updated = sanitizeInvite(data.invite)
|
||||
setInvites((previous) => previous.map((item) => (item.id === updated.id ? updated : item)))
|
||||
toast.success("Convite revogado")
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Erro ao revogar convite"
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setRevokingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="invites" className="w-full">
|
||||
<TabsList className="h-12 w-full justify-start rounded-xl bg-slate-100 p-1">
|
||||
<TabsTrigger value="invites" className="rounded-lg">Convites</TabsTrigger>
|
||||
<TabsTrigger value="users" className="rounded-lg">Usuários</TabsTrigger>
|
||||
<TabsTrigger value="queues" className="rounded-lg">Filas</TabsTrigger>
|
||||
<TabsTrigger value="categories" className="rounded-lg">Categorias</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="invites" className="mt-6 space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Gerar convite</CardTitle>
|
||||
<CardDescription>
|
||||
Envie convites personalizados com validade controlada e acompanhe o status em tempo real.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form
|
||||
onSubmit={handleInviteSubmit}
|
||||
className="grid gap-4 lg:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)_160px_160px_160px_auto]"
|
||||
>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="invite-email">E-mail corporativo</Label>
|
||||
<Input
|
||||
id="invite-email"
|
||||
type="email"
|
||||
inputMode="email"
|
||||
placeholder="nome@suaempresa.com"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
required
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="invite-name">Nome</Label>
|
||||
<Input
|
||||
id="invite-name"
|
||||
placeholder="Nome completo"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Papel</Label>
|
||||
<Select value={role} onValueChange={(value) => setRole(value as RoleOption)}>
|
||||
<SelectTrigger id="invite-role">
|
||||
<SelectValue placeholder="Selecione" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{normalizedRoles.map((item) => (
|
||||
<SelectItem key={item} value={item}>
|
||||
{item === "customer"
|
||||
? "Cliente"
|
||||
: item === "admin"
|
||||
? "Administrador"
|
||||
: item === "manager"
|
||||
? "Gestor"
|
||||
: item === "agent"
|
||||
? "Agente"
|
||||
: "Colaborador"}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="invite-tenant">Tenant</Label>
|
||||
<Input
|
||||
id="invite-tenant"
|
||||
value={tenantId}
|
||||
onChange={(event) => setTenantId(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Expira em</Label>
|
||||
<Select value={expiresInDays} onValueChange={setExpiresInDays}>
|
||||
<SelectTrigger id="invite-expiration">
|
||||
<SelectValue placeholder="7 dias" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="7">7 dias</SelectItem>
|
||||
<SelectItem value="14">14 dias</SelectItem>
|
||||
<SelectItem value="30">30 dias</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<Button type="submit" disabled={isPending} className="w-full">
|
||||
{isPending ? "Gerando..." : "Gerar convite"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
{lastInviteLink ? (
|
||||
<div className="mt-4 flex flex-col gap-2 rounded-lg border border-slate-200 bg-slate-50 p-4 text-sm text-neutral-700 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-neutral-900">Link de convite pronto</p>
|
||||
<p className="text-neutral-600">Compartilhe com o convidado. O link expira automaticamente no prazo definido.</p>
|
||||
<p className="mt-2 truncate font-mono text-xs text-neutral-500">{lastInviteLink}</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" onClick={() => handleCopy(lastInviteLink)}>
|
||||
Copiar link
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Convites emitidos</CardTitle>
|
||||
<CardDescription>Histórico e status atual de todos os convites enviados para o workspace.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-x-auto">
|
||||
<table className="min-w-full table-fixed divide-y divide-slate-200 text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs uppercase tracking-wide text-neutral-500">
|
||||
<th className="py-3 pr-4 font-medium">Colaborador</th>
|
||||
<th className="py-3 pr-4 font-medium">Papel</th>
|
||||
<th className="py-3 pr-4 font-medium">Tenant</th>
|
||||
<th className="py-3 pr-4 font-medium">Expira em</th>
|
||||
<th className="py-3 pr-4 font-medium">Status</th>
|
||||
<th className="py-3 font-medium">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{invites.map((invite) => (
|
||||
<tr key={invite.id} className="hover:bg-slate-50">
|
||||
<td className="py-3 pr-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-neutral-800">{invite.name || invite.email}</span>
|
||||
<span className="text-xs text-neutral-500">{invite.email}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 pr-4 uppercase text-neutral-600">{invite.role}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{invite.tenantId}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{formatDate(invite.expiresAt)}</td>
|
||||
<td className="py-3 pr-4">
|
||||
<Badge
|
||||
variant={invite.status === "pending" ? "secondary" : invite.status === "accepted" ? "default" : invite.status === "expired" ? "outline" : "destructive"}
|
||||
className="rounded-full px-3 py-1 text-[11px] uppercase tracking-wide"
|
||||
>
|
||||
{formatStatus(invite.status)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => handleCopy(invite.inviteUrl)}>
|
||||
Copiar link
|
||||
</Button>
|
||||
{invite.status === "pending" ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-red-600 hover:bg-red-50"
|
||||
onClick={() => handleRevoke(invite.id)}
|
||||
disabled={revokingId === invite.id}
|
||||
>
|
||||
{revokingId === invite.id ? "Revogando..." : "Revogar"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{invites.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="py-6 text-center text-neutral-500">
|
||||
Nenhum convite emitido até o momento.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="users" className="mt-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Equipe cadastrada</CardTitle>
|
||||
<CardDescription>Usuários ativos e provisionados via convites aceitos.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-x-auto">
|
||||
<table className="min-w-full table-fixed divide-y divide-slate-200 text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs uppercase tracking-wide text-neutral-500">
|
||||
<th className="py-3 pr-4 font-medium">Nome</th>
|
||||
<th className="py-3 pr-4 font-medium">E-mail</th>
|
||||
<th className="py-3 pr-4 font-medium">Papel</th>
|
||||
<th className="py-3 pr-4 font-medium">Tenant</th>
|
||||
<th className="py-3 font-medium">Criado em</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{users.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-slate-50">
|
||||
<td className="py-3 pr-4 font-medium text-neutral-800">{user.name || "—"}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{user.email}</td>
|
||||
<td className="py-3 pr-4 uppercase text-neutral-600">{user.role}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{user.tenantId}</td>
|
||||
<td className="py-3 text-neutral-500">{formatDate(user.createdAt)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{users.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="py-6 text-center text-neutral-500">
|
||||
Nenhum usuário cadastrado até o momento.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="queues" className="mt-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Gestão de filas</CardTitle>
|
||||
<CardDescription>
|
||||
Em breve será possível criar e reordenar as filas utilizadas na triagem dos tickets.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="categories" className="mt-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Gestão de categorias</CardTitle>
|
||||
<CardDescription>
|
||||
Estamos preparando o painel completo para organizar categorias e subcategorias do catálogo.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue