chore: reorganize project structure and ensure default queues

This commit is contained in:
Esdras Renan 2025-10-06 22:59:35 -03:00
parent 854887f499
commit 1cccb852a5
201 changed files with 417 additions and 838 deletions

View 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>
)
}

View file

@ -0,0 +1,555 @@
"use client"
import { useMemo, useState } from "react"
import { useMutation, useQuery } from "convex/react"
import { toast } from "sonner"
// @ts-expect-error Convex runtime API lacks generated types
import { api } from "@/convex/_generated/api"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { useAuth } from "@/lib/auth-client"
import type { TicketCategory, TicketSubcategory } from "@/lib/schemas/category"
import type { Id } from "@/convex/_generated/dataModel"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
type DeleteState<T extends "category" | "subcategory"> =
| { type: T; targetId: string; reason: string }
| null
export function CategoriesManager() {
const { session, convexUserId } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
const categories = useQuery(api.categories.list, { tenantId }) as TicketCategory[] | undefined
const [categoryName, setCategoryName] = useState("")
const [categoryDescription, setCategoryDescription] = useState("")
const [subcategoryDraft, setSubcategoryDraft] = useState("")
const [subcategoryList, setSubcategoryList] = useState<string[]>([])
const [deleteState, setDeleteState] = useState<DeleteState<"category" | "subcategory">>(null)
const createCategory = useMutation(api.categories.createCategory)
const deleteCategory = useMutation(api.categories.deleteCategory)
const updateCategory = useMutation(api.categories.updateCategory)
const createSubcategory = useMutation(api.categories.createSubcategory)
const updateSubcategory = useMutation(api.categories.updateSubcategory)
const deleteSubcategory = useMutation(api.categories.deleteSubcategory)
const isCreatingCategory = useMemo(
() => categoryName.trim().length < 2,
[categoryName]
)
function addSubcategory() {
const value = subcategoryDraft.trim()
if (value.length < 2) {
toast.error("Informe um nome válido para a subcategoria")
return
}
const normalized = value.toLowerCase()
const exists = subcategoryList.some((item) => item.toLowerCase() === normalized)
if (exists) {
toast.error("Essa subcategoria já foi adicionada")
return
}
setSubcategoryList((items) => [...items, value])
setSubcategoryDraft("")
}
function removeSubcategory(target: string) {
setSubcategoryList((items) => items.filter((item) => item !== target))
}
async function handleCreateCategory() {
if (!convexUserId) return
const name = categoryName.trim()
if (name.length < 2) {
toast.error("Informe um nome válido para a categoria")
return
}
toast.loading("Criando categoria...", { id: "category:create" })
try {
await createCategory({
tenantId,
actorId: convexUserId as Id<"users">,
name,
description: categoryDescription.trim() || undefined,
secondary: subcategoryList,
})
toast.success("Categoria criada!", { id: "category:create" })
setCategoryName("")
setCategoryDescription("")
setSubcategoryDraft("")
setSubcategoryList([])
} catch (error) {
console.error(error)
toast.error("Não foi possível criar a categoria", { id: "category:create" })
}
}
async function handleDeleteCategory(id: string) {
if (!convexUserId) return
toast.loading("Removendo categoria...", { id: "category:delete" })
try {
await deleteCategory({
tenantId,
actorId: convexUserId as Id<"users">,
categoryId: id as Id<"ticketCategories">,
})
toast.success("Categoria removida", { id: "category:delete" })
} catch (error) {
console.error(error)
toast.error("Não foi possível remover a categoria", { id: "category:delete" })
}
}
async function handleUpdateCategory(target: TicketCategory, next: { name: string; description: string }) {
if (!convexUserId) return
const name = next.name.trim()
if (name.length < 2) {
toast.error("Informe um nome válido")
return
}
toast.loading("Atualizando categoria...", { id: `category:update:${target.id}` })
try {
await updateCategory({
tenantId,
actorId: convexUserId as Id<"users">,
categoryId: target.id as Id<"ticketCategories">,
name,
description: next.description.trim() || undefined,
})
toast.success("Categoria atualizada", { id: `category:update:${target.id}` })
} catch (error) {
console.error(error)
toast.error("Não foi possível atualizar a categoria", { id: `category:update:${target.id}` })
}
}
async function handleCreateSubcategory(categoryId: string, payload: { name: string }) {
if (!convexUserId) return
const name = payload.name.trim()
if (name.length < 2) {
toast.error("Informe um nome válido para a subcategoria")
return
}
toast.loading("Adicionando subcategoria...", { id: `subcategory:create:${categoryId}` })
try {
await createSubcategory({
tenantId,
actorId: convexUserId as Id<"users">,
categoryId: categoryId as Id<"ticketCategories">,
name,
})
toast.success("Subcategoria criada", { id: `subcategory:create:${categoryId}` })
} catch (error) {
console.error(error)
toast.error("Não foi possível criar a subcategoria", { id: `subcategory:create:${categoryId}` })
}
}
async function handleUpdateSubcategory(target: TicketSubcategory, name: string) {
if (!convexUserId) return
const trimmed = name.trim()
if (trimmed.length < 2) {
toast.error("Informe um nome válido")
return
}
toast.loading("Atualizando subcategoria...", { id: `subcategory:update:${target.id}` })
try {
await updateSubcategory({
tenantId,
actorId: convexUserId as Id<"users">,
subcategoryId: target.id as Id<"ticketSubcategories">,
name: trimmed,
})
toast.success("Subcategoria atualizada", { id: `subcategory:update:${target.id}` })
} catch (error) {
console.error(error)
toast.error("Não foi possível atualizar a subcategoria", { id: `subcategory:update:${target.id}` })
}
}
async function handleDeleteSubcategory(id: string) {
if (!convexUserId) return
toast.loading("Removendo subcategoria...", { id: `subcategory:delete:${id}` })
try {
await deleteSubcategory({
tenantId,
actorId: convexUserId as Id<"users">,
subcategoryId: id as Id<"ticketSubcategories">,
})
toast.success("Subcategoria removida", { id: `subcategory:delete:${id}` })
} catch (error) {
console.error(error)
toast.error("Não foi possível remover a subcategoria", { id: `subcategory:delete:${id}` })
}
}
const pendingDelete = deleteState
const isDisabled = !convexUserId
return (
<div className="space-y-6">
<section className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<div className="space-y-1">
<h3 className="text-lg font-semibold text-neutral-900">Categorias</h3>
<p className="text-sm text-neutral-600">
Organize a classificação primária e secundária utilizada nos tickets. Todas as alterações entram em vigor
imediatamente para novos atendimentos.
</p>
</div>
<div className="mt-4 grid gap-4 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<div className="space-y-3 rounded-xl border border-dashed border-slate-200 bg-white/80 p-4">
<div className="space-y-2">
<Label htmlFor="category-name">Nome da categoria</Label>
<Input
id="category-name"
value={categoryName}
onChange={(event) => setCategoryName(event.target.value)}
placeholder="Ex.: Incidentes"
disabled={isDisabled}
/>
</div>
<div className="space-y-2">
<Label htmlFor="category-description">Descrição (opcional)</Label>
<Textarea
id="category-description"
value={categoryDescription}
onChange={(event) => setCategoryDescription(event.target.value)}
placeholder="Contextualize quando usar esta categoria"
rows={3}
disabled={isDisabled}
/>
</div>
<div className="space-y-2">
<Label htmlFor="subcategory-name">Subcategorias (opcional)</Label>
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-2 sm:flex-row">
<Input
id="subcategory-name"
value={subcategoryDraft}
onChange={(event) => setSubcategoryDraft(event.target.value)}
placeholder="Ex.: Lentidão"
disabled={isDisabled}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault()
addSubcategory()
}
}}
/>
<Button
type="button"
variant="outline"
onClick={addSubcategory}
disabled={isDisabled || subcategoryDraft.trim().length < 2}
className="shrink-0"
>
Adicionar subcategoria
</Button>
</div>
{subcategoryList.length ? (
<div className="flex flex-wrap gap-2">
{subcategoryList.map((item) => (
<div
key={item}
className="group inline-flex items-center gap-2 rounded-full border border-slate-200 bg-slate-100 px-3 py-1 text-xs text-neutral-700"
>
<span>{item}</span>
<button
type="button"
onClick={() => removeSubcategory(item)}
className="rounded-full p-1 text-neutral-500 transition hover:bg-white hover:text-neutral-900"
disabled={isDisabled}
>
×
</button>
</div>
))}
</div>
) : null}
</div>
</div>
<Button
onClick={handleCreateCategory}
disabled={isDisabled || isCreatingCategory}
className="w-full sm:w-auto"
>
Adicionar categoria
</Button>
</div>
<div className="space-y-3 rounded-xl border border-slate-200 bg-slate-50/60 p-4 text-sm text-neutral-600">
<p className="font-medium text-neutral-800">Boas práticas</p>
<ul className="list-disc space-y-1 pl-4">
<li>Mantenha nomes concisos e fáceis de entender.</li>
<li>Use a descrição para orientar a equipe sobre quando aplicar cada categoria.</li>
<li>Subcategorias devem ser específicas e mutuamente exclusivas.</li>
</ul>
</div>
</div>
</section>
<div className="space-y-4">
{categories?.length ? (
categories.map((category) => (
<CategoryItem
key={category.id}
category={category}
onUpdate={handleUpdateCategory}
onDelete={() => setDeleteState({ type: "category", targetId: category.id, reason: "" })}
onCreateSubcategory={handleCreateSubcategory}
onUpdateSubcategory={handleUpdateSubcategory}
onDeleteSubcategory={(subcategoryId) =>
setDeleteState({ type: "subcategory", targetId: subcategoryId, reason: "" })
}
disabled={isDisabled}
/>
))
) : (
<div className="rounded-xl border border-dashed border-slate-200 bg-white/60 p-6 text-center text-sm text-neutral-600">
Nenhuma categoria cadastrada ainda.
</div>
)}
</div>
<Dialog
open={Boolean(pendingDelete)}
onOpenChange={(open) => {
if (!open) setDeleteState(null)
}}
>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Confirmar remoção</DialogTitle>
<DialogDescription>
A remoção é permanente. Certifique-se de que não tickets em aberto associados.
</DialogDescription>
</DialogHeader>
<div className="space-y-3 pt-2">
<Label htmlFor="delete-reason">Motivo (opcional)</Label>
<Textarea
id="delete-reason"
rows={3}
placeholder="Descreva o motivo da remoção"
value={pendingDelete?.reason ?? ""}
onChange={(event) =>
setDeleteState((current) =>
current ? { ...current, reason: event.target.value } : current
)
}
/>
<p className="text-xs text-neutral-500">
Caso existam tickets vinculados, será necessário mover para outra categoria antes de continuar.
</p>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => setDeleteState(null)}>
Cancelar
</Button>
<Button
variant="destructive"
onClick={async () => {
const target = pendingDelete
if (!target) return
if (target.type === "category") {
await handleDeleteCategory(target.targetId)
} else {
await handleDeleteSubcategory(target.targetId)
}
setDeleteState(null)
}}
>
Remover
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
interface CategoryItemProps {
category: TicketCategory
disabled?: boolean
onUpdate: (category: TicketCategory, next: { name: string; description: string }) => Promise<void>
onDelete: () => void
onCreateSubcategory: (categoryId: string, payload: { name: string }) => Promise<void>
onUpdateSubcategory: (subcategory: TicketSubcategory, name: string) => Promise<void>
onDeleteSubcategory: (subcategoryId: string) => void
}
function CategoryItem({
category,
disabled,
onUpdate,
onDelete,
onCreateSubcategory,
onUpdateSubcategory,
onDeleteSubcategory,
}: CategoryItemProps) {
const [isEditing, setIsEditing] = useState(false)
const [name, setName] = useState(category.name)
const [description, setDescription] = useState(category.description ?? "")
const [subcategoryDraft, setSubcategoryDraft] = useState("")
const hasSubcategories = category.secondary.length > 0
async function handleSave() {
await onUpdate(category, { name, description })
setIsEditing(false)
}
return (
<div className="space-y-4 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
{isEditing ? (
<div className="flex flex-col gap-2">
<Input value={name} onChange={(event) => setName(event.target.value)} disabled={disabled} />
<Textarea
value={description}
onChange={(event) => setDescription(event.target.value)}
rows={2}
placeholder="Descrição"
disabled={disabled}
/>
</div>
) : (
<>
<h4 className="text-base font-semibold text-neutral-900">{category.name}</h4>
{category.description ? (
<p className="text-sm text-neutral-600">{category.description}</p>
) : null}
</>
)}
</div>
<div className="flex items-center gap-2">
{hasSubcategories ? (
<Badge variant="outline" className="rounded-full border-slate-200 px-3 py-1 text-xs text-neutral-600">
{category.secondary.length} subcategorias
</Badge>
) : null}
{isEditing ? (
<div className="flex items-center gap-2">
<Button size="sm" onClick={handleSave} disabled={disabled}>
Salvar
</Button>
<Button size="sm" variant="outline" onClick={() => setIsEditing(false)}>
Cancelar
</Button>
</div>
) : (
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={() => setIsEditing(true)} disabled={disabled}>
Editar
</Button>
<Button size="sm" variant="destructive" onClick={onDelete} disabled={disabled}>
Remover
</Button>
</div>
)}
</div>
</div>
<div className="space-y-3">
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Subcategorias</p>
<div className="space-y-2">
{category.secondary.length ? (
category.secondary.map((subcategory) => (
<SubcategoryItem
key={subcategory.id}
subcategory={subcategory}
disabled={disabled}
onUpdate={(value) => onUpdateSubcategory(subcategory, value)}
onDelete={() => onDeleteSubcategory(subcategory.id)}
/>
))
) : (
<div className="rounded-lg border border-dashed border-slate-200 bg-slate-50/60 p-3 text-sm text-neutral-600">
Nenhuma subcategoria cadastrada.
</div>
)}
</div>
<div className="flex flex-col gap-2 rounded-lg border border-dashed border-slate-200 bg-white p-3">
<Label className="text-xs uppercase tracking-wide text-neutral-500">Nova subcategoria</Label>
<div className="flex flex-col gap-2 sm:flex-row">
<Input
value={subcategoryDraft}
onChange={(event) => setSubcategoryDraft(event.target.value)}
placeholder="Ex.: Configuração"
disabled={disabled}
/>
<Button
size="sm"
className="sm:w-auto"
onClick={async () => {
if (!subcategoryDraft.trim()) return
await onCreateSubcategory(category.id, { name: subcategoryDraft })
setSubcategoryDraft("")
}}
disabled={disabled || subcategoryDraft.trim().length < 2}
>
Adicionar
</Button>
</div>
</div>
</div>
</div>
)
}
interface SubcategoryItemProps {
subcategory: TicketSubcategory
disabled?: boolean
onUpdate: (nextValue: string) => Promise<void>
onDelete: () => void
}
function SubcategoryItem({ subcategory, disabled, onUpdate, onDelete }: SubcategoryItemProps) {
const [isEditing, setIsEditing] = useState(false)
const [name, setName] = useState(subcategory.name)
async function handleSave() {
await onUpdate(name)
setIsEditing(false)
}
return (
<div className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-slate-200 bg-white px-3 py-2 shadow-sm">
{isEditing ? (
<Input value={name} onChange={(event) => setName(event.target.value)} disabled={disabled} className="max-w-sm" />
) : (
<span className="text-sm font-medium text-neutral-800">{subcategory.name}</span>
)}
<div className="flex items-center gap-2">
{isEditing ? (
<>
<Button size="sm" onClick={handleSave} disabled={disabled}>
Salvar
</Button>
<Button size="sm" variant="outline" onClick={() => setIsEditing(false)}>
Cancelar
</Button>
</>
) : (
<>
<Button size="sm" variant="outline" onClick={() => setIsEditing(true)} disabled={disabled}>
Renomear
</Button>
<Button size="sm" variant="destructive" onClick={onDelete} disabled={disabled}>
Remover
</Button>
</>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,551 @@
"use client"
import { useMemo, useState } from "react"
import { useMutation, useQuery } from "convex/react"
import { toast } from "sonner"
import { IconAdjustments, IconForms, IconListDetails, IconTypography } from "@tabler/icons-react"
// @ts-expect-error Convex runtime API lacks TypeScript declarations
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { useAuth } from "@/lib/auth-client"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
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 { Checkbox } from "@/components/ui/checkbox"
import { Badge } from "@/components/ui/badge"
import { Skeleton } from "@/components/ui/skeleton"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
type FieldOption = { value: string; label: string }
type Field = {
id: string
key: string
label: string
description: string
type: "text" | "number" | "select" | "date" | "boolean"
required: boolean
options: FieldOption[]
order: number
}
const TYPE_LABELS: Record<Field["type"], string> = {
text: "Texto",
number: "Número",
select: "Seleção",
date: "Data",
boolean: "Verdadeiro/Falso",
}
export function FieldsManager() {
const { session, convexUserId } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
const fields = useQuery(
api.fields.list,
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
) as Field[] | undefined
const createField = useMutation(api.fields.create)
const updateField = useMutation(api.fields.update)
const removeField = useMutation(api.fields.remove)
const reorderFields = useMutation(api.fields.reorder)
const [label, setLabel] = useState("")
const [description, setDescription] = useState("")
const [type, setType] = useState<Field["type"]>("text")
const [required, setRequired] = useState(false)
const [options, setOptions] = useState<FieldOption[]>([])
const [saving, setSaving] = useState(false)
const [editingField, setEditingField] = useState<Field | null>(null)
const totals = useMemo(() => {
if (!fields) return { total: 0, required: 0, select: 0 }
return {
total: fields.length,
required: fields.filter((field) => field.required).length,
select: fields.filter((field) => field.type === "select").length,
}
}, [fields])
const resetForm = () => {
setLabel("")
setDescription("")
setType("text")
setRequired(false)
setOptions([])
}
const normalizeOptions = (source: FieldOption[]) =>
source
.map((option) => ({
label: option.label.trim(),
value: option.value.trim() || option.label.trim().toLowerCase().replace(/\s+/g, "_"),
}))
.filter((option) => option.label.length > 0)
const handleCreate = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (!label.trim()) {
toast.error("Informe o rótulo do campo")
return
}
if (!convexUserId) {
toast.error("Sessão não sincronizada com o Convex")
return
}
const preparedOptions = type === "select" ? normalizeOptions(options) : undefined
setSaving(true)
toast.loading("Criando campo...", { id: "field" })
try {
await createField({
tenantId,
actorId: convexUserId as Id<"users">,
label: label.trim(),
description: description.trim() || undefined,
type,
required,
options: preparedOptions,
})
toast.success("Campo criado", { id: "field" })
resetForm()
} catch (error) {
console.error(error)
toast.error("Não foi possível criar o campo", { id: "field" })
} finally {
setSaving(false)
}
}
const handleRemove = async (field: Field) => {
const confirmed = window.confirm(`Excluir o campo ${field.label}?`)
if (!confirmed) return
if (!convexUserId) {
toast.error("Sessão não sincronizada com o Convex")
return
}
toast.loading("Removendo campo...", { id: `field-remove-${field.id}` })
try {
await removeField({
tenantId,
fieldId: field.id as Id<"ticketFields">,
actorId: convexUserId as Id<"users">,
})
toast.success("Campo removido", { id: `field-remove-${field.id}` })
} catch (error) {
console.error(error)
toast.error("Não foi possível remover o campo", { id: `field-remove-${field.id}` })
}
}
const openEdit = (field: Field) => {
setEditingField(field)
setLabel(field.label)
setDescription(field.description)
setType(field.type)
setRequired(field.required)
setOptions(field.options)
}
const handleUpdate = async () => {
if (!editingField) return
if (!label.trim()) {
toast.error("Informe o rótulo do campo")
return
}
if (!convexUserId) {
toast.error("Sessão não sincronizada com o Convex")
return
}
const preparedOptions = type === "select" ? normalizeOptions(options) : undefined
setSaving(true)
toast.loading("Atualizando campo...", { id: "field-edit" })
try {
await updateField({
tenantId,
fieldId: editingField.id as Id<"ticketFields">,
actorId: convexUserId as Id<"users">,
label: label.trim(),
description: description.trim() || undefined,
type,
required,
options: preparedOptions,
})
toast.success("Campo atualizado", { id: "field-edit" })
setEditingField(null)
resetForm()
} catch (error) {
console.error(error)
toast.error("Não foi possível atualizar o campo", { id: "field-edit" })
} finally {
setSaving(false)
}
}
const moveField = async (field: Field, direction: "up" | "down") => {
if (!fields) return
if (!convexUserId) {
toast.error("Sessão não sincronizada com o Convex")
return
}
const index = fields.findIndex((item) => item.id === field.id)
const targetIndex = direction === "up" ? index - 1 : index + 1
if (targetIndex < 0 || targetIndex >= fields.length) return
const reordered = [...fields]
const [removed] = reordered.splice(index, 1)
reordered.splice(targetIndex, 0, removed)
try {
await reorderFields({
tenantId,
actorId: convexUserId as Id<"users">,
orderedIds: reordered.map((item) => item.id as Id<"ticketFields">),
})
toast.success("Ordem atualizada")
} catch (error) {
console.error(error)
toast.error("Não foi possível reordenar os campos")
}
}
const addOption = () => {
setOptions((current) => [...current, { label: "", value: "" }])
}
const updateOption = (index: number, key: keyof FieldOption, value: string) => {
setOptions((current) => {
const copy = [...current]
copy[index] = { ...copy[index], [key]: value }
return copy
})
}
const removeOption = (index: number) => {
setOptions((current) => current.filter((_, optIndex) => optIndex !== index))
}
return (
<div className="space-y-8">
<div className="grid gap-4 md:grid-cols-3">
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600">
<IconForms className="size-4" /> Campos personalizados
</CardTitle>
<CardDescription>Metadados adicionais disponíveis nos tickets.</CardDescription>
</CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900">
{fields ? totals.total : <Skeleton className="h-8 w-16" />}
</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600">
<IconTypography className="size-4" /> Campos obrigatórios
</CardTitle>
<CardDescription>Informações exigidas na abertura.</CardDescription>
</CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900">
{fields ? totals.required : <Skeleton className="h-8 w-16" />}
</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600">
<IconListDetails className="size-4" /> Campos de seleção
</CardTitle>
<CardDescription>Usados para listas e múltipla escolha.</CardDescription>
</CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900">
{fields ? totals.select : <Skeleton className="h-8 w-16" />}
</CardContent>
</Card>
</div>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg font-semibold text-neutral-900">
<IconAdjustments className="size-5 text-neutral-500" /> Novo campo
</CardTitle>
<CardDescription>Capture informações específicas do seu fluxo de atendimento.</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleCreate} className="grid gap-4 lg:grid-cols-[minmax(0,280px)_minmax(0,1fr)]">
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="field-label">Rótulo</Label>
<Input
id="field-label"
placeholder="Ex.: Número do contrato"
value={label}
onChange={(event) => setLabel(event.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label>Tipo de dado</Label>
<Select value={type} onValueChange={(value) => setType(value as Field["type"])}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="text">Texto curto</SelectItem>
<SelectItem value="number">Número</SelectItem>
<SelectItem value="select">Seleção</SelectItem>
<SelectItem value="date">Data</SelectItem>
<SelectItem value="boolean">Verdadeiro/Falso</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Checkbox id="field-required" checked={required} onCheckedChange={(value) => setRequired(Boolean(value))} />
<Label htmlFor="field-required" className="text-sm font-normal text-neutral-600">
Campo obrigatório na abertura
</Label>
</div>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="field-description">Descrição</Label>
<textarea
id="field-description"
className="min-h-[96px] w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-neutral-700 shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-900/10"
placeholder="Como este campo será utilizado"
value={description}
onChange={(event) => setDescription(event.target.value)}
/>
</div>
{type === "select" ? (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label>Opções</Label>
<Button type="button" variant="outline" size="sm" onClick={addOption}>
Adicionar opção
</Button>
</div>
{options.length === 0 ? (
<p className="rounded-lg border border-dashed border-slate-200 p-4 text-sm text-neutral-500">
Adicione pelo menos uma opção para este campo de seleção.
</p>
) : (
<div className="space-y-3">
{options.map((option, index) => (
<div key={index} className="grid gap-3 rounded-lg border border-slate-200 p-3 md:grid-cols-[minmax(0,1fr)_minmax(0,200px)_auto]">
<Input
placeholder="Rótulo"
value={option.label}
onChange={(event) => updateOption(index, "label", event.target.value)}
/>
<Input
placeholder="Valor"
value={option.value}
onChange={(event) => updateOption(index, "value", event.target.value)}
/>
<Button variant="ghost" type="button" onClick={() => removeOption(index)}>
Remover
</Button>
</div>
))}
</div>
)}
</div>
) : null}
<div className="flex justify-end">
<Button type="submit" disabled={saving}>
Criar campo
</Button>
</div>
</div>
</form>
</CardContent>
</Card>
<div className="space-y-4">
{fields === undefined ? (
<div className="space-y-4">
{Array.from({ length: 4 }).map((_, index) => (
<Skeleton key={index} className="h-28 rounded-2xl" />
))}
</div>
) : fields.length === 0 ? (
<Card className="border-dashed border-slate-300 bg-slate-50/80">
<CardHeader>
<CardTitle className="text-lg font-semibold text-neutral-900">Nenhum campo cadastrado</CardTitle>
<CardDescription className="text-neutral-600">
Crie campos personalizados para enriquecer os tickets com informações importantes.
</CardDescription>
</CardHeader>
</Card>
) : (
fields.map((field, index) => (
<Card key={field.id} className="border-slate-200">
<CardHeader>
<div className="flex items-start justify-between gap-4">
<div className="space-y-2">
<div className="flex items-center gap-3">
<CardTitle className="text-xl font-semibold text-neutral-900">{field.label}</CardTitle>
<Badge variant="outline" className="rounded-full border-neutral-300 text-neutral-600">
{TYPE_LABELS[field.type]}
</Badge>
{field.required ? (
<Badge variant="outline" className="rounded-full border-neutral-300 text-neutral-600">
obrigatório
</Badge>
) : null}
</div>
<CardDescription className="text-neutral-600">Identificador: {field.key}</CardDescription>
{field.description ? (
<p className="text-sm text-neutral-600">{field.description}</p>
) : null}
</div>
<div className="flex flex-col items-end gap-2">
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => openEdit(field)}>
Editar
</Button>
<Button variant="destructive" size="sm" onClick={() => handleRemove(field)}>
Excluir
</Button>
</div>
<div className="flex gap-2 text-xs text-neutral-500">
<Button
type="button"
size="sm"
variant="ghost"
className="px-2"
disabled={index === 0}
onClick={() => moveField(field, "up")}
>
Subir
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="px-2"
disabled={index === fields.length - 1}
onClick={() => moveField(field, "down")}
>
Descer
</Button>
</div>
</div>
</div>
</CardHeader>
{field.type === "select" && field.options.length > 0 ? (
<CardContent>
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">Opções cadastradas</p>
<div className="flex flex-wrap gap-2">
{field.options.map((option) => (
<Badge key={option.value} variant="outline" className="rounded-full border-neutral-300 text-neutral-600">
{option.label} ({option.value})
</Badge>
))}
</div>
</CardContent>
) : null}
</Card>
))
)}
</div>
<Dialog open={Boolean(editingField)} onOpenChange={(value) => (!value ? setEditingField(null) : null)}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>Editar campo</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-2 lg:grid-cols-[minmax(0,260px)_minmax(0,1fr)]">
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="edit-field-label">Rótulo</Label>
<Input
id="edit-field-label"
value={label}
onChange={(event) => setLabel(event.target.value)}
autoFocus
/>
</div>
<div className="space-y-2">
<Label>Tipo de dado</Label>
<Select value={type} onValueChange={(value) => setType(value as Field["type"])}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="text">Texto curto</SelectItem>
<SelectItem value="number">Número</SelectItem>
<SelectItem value="select">Seleção</SelectItem>
<SelectItem value="date">Data</SelectItem>
<SelectItem value="boolean">Verdadeiro/Falso</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Checkbox id="edit-field-required" checked={required} onCheckedChange={(value) => setRequired(Boolean(value))} />
<Label htmlFor="edit-field-required" className="text-sm font-normal text-neutral-600">
Campo obrigatório na abertura
</Label>
</div>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="edit-field-description">Descrição</Label>
<textarea
id="edit-field-description"
className="min-h-[96px] w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-neutral-700 shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-900/10"
value={description}
onChange={(event) => setDescription(event.target.value)}
/>
</div>
{type === "select" ? (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label>Opções</Label>
<Button type="button" variant="outline" size="sm" onClick={addOption}>
Adicionar opção
</Button>
</div>
{options.length === 0 ? (
<p className="rounded-lg border border-dashed border-slate-200 p-4 text-sm text-neutral-500">
Inclua ao menos uma opção para salvar este campo.
</p>
) : (
<div className="space-y-3">
{options.map((option, index) => (
<div key={index} className="grid gap-3 rounded-lg border border-slate-200 p-3 md:grid-cols-[minmax(0,1fr)_minmax(0,200px)_auto]">
<Input
placeholder="Rótulo"
value={option.label}
onChange={(event) => updateOption(index, "label", event.target.value)}
/>
<Input
placeholder="Valor"
value={option.value}
onChange={(event) => updateOption(index, "value", event.target.value)}
/>
<Button variant="ghost" type="button" onClick={() => removeOption(index)}>
Remover
</Button>
</div>
))}
</div>
)}
</div>
) : null}
</div>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => setEditingField(null)}>
Cancelar
</Button>
<Button onClick={handleUpdate} disabled={saving}>
Salvar alterações
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View file

@ -0,0 +1,332 @@
"use client"
import { useMemo, useState } from "react"
import { useMutation, useQuery } from "convex/react"
import { toast } from "sonner"
import { IconInbox, IconHierarchy2, IconLink, IconPlus } from "@tabler/icons-react"
// @ts-expect-error Convex runtime API lacks TypeScript declarations
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { useAuth } from "@/lib/auth-client"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
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 { Badge } from "@/components/ui/badge"
import { Skeleton } from "@/components/ui/skeleton"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { useDefaultQueues } from "@/hooks/use-default-queues"
type Queue = {
id: string
name: string
slug: string
team: { id: string; name: string } | null
}
type TeamOption = {
id: string
name: string
}
export function QueuesManager() {
const { session, convexUserId } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
useDefaultQueues(tenantId)
const NO_TEAM_VALUE = "__none__"
const queues = useQuery(
api.queues.list,
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
) as Queue[] | undefined
const teams = useQuery(
api.teams.list,
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
) as TeamOption[] | undefined
const createQueue = useMutation(api.queues.create)
const updateQueue = useMutation(api.queues.update)
const removeQueue = useMutation(api.queues.remove)
const [name, setName] = useState("")
const [teamId, setTeamId] = useState<string | undefined>()
const [editingQueue, setEditingQueue] = useState<Queue | null>(null)
const [saving, setSaving] = useState(false)
const totalQueues = queues?.length ?? 0
const withoutTeam = useMemo(() => {
if (!queues) return 0
return queues.filter((queue) => !queue.team).length
}, [queues])
const handleCreate = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (!name.trim()) {
toast.error("Informe o nome da fila")
return
}
if (!convexUserId) {
toast.error("Sessão não sincronizada com o Convex")
return
}
setSaving(true)
toast.loading("Criando fila...", { id: "queue" })
try {
await createQueue({
tenantId,
actorId: convexUserId as Id<"users">,
name: name.trim(),
teamId: teamId as Id<"teams"> | undefined,
})
setName("")
setTeamId(undefined)
toast.success("Fila criada", { id: "queue" })
} catch (error) {
console.error(error)
toast.error("Não foi possível criar a fila", { id: "queue" })
} finally {
setSaving(false)
}
}
const openEdit = (queue: Queue) => {
setEditingQueue(queue)
setName(queue.name)
setTeamId(queue.team?.id)
}
const handleUpdate = async () => {
if (!editingQueue) return
if (!name.trim()) {
toast.error("Informe o nome da fila")
return
}
if (!convexUserId) {
toast.error("Sessão não sincronizada com o Convex")
return
}
setSaving(true)
toast.loading("Salvando alterações...", { id: "queue-edit" })
try {
await updateQueue({
queueId: editingQueue.id as Id<"queues">,
tenantId,
actorId: convexUserId as Id<"users">,
name: name.trim(),
teamId: (teamId ?? undefined) as Id<"teams"> | undefined,
})
toast.success("Fila atualizada", { id: "queue-edit" })
setEditingQueue(null)
setName("")
setTeamId(undefined)
} catch (error) {
console.error(error)
toast.error("Não foi possível atualizar a fila", { id: "queue-edit" })
} finally {
setSaving(false)
}
}
const handleRemove = async (queue: Queue) => {
const confirmed = window.confirm(`Remover a fila ${queue.name}?`)
if (!confirmed) return
if (!convexUserId) {
toast.error("Sessão não sincronizada com o Convex")
return
}
toast.loading("Removendo fila...", { id: `queue-remove-${queue.id}` })
try {
await removeQueue({ tenantId, queueId: queue.id as Id<"queues">, actorId: convexUserId as Id<"users"> })
toast.success("Fila removida", { id: `queue-remove-${queue.id}` })
} catch (error) {
console.error(error)
toast.error("Não foi possível remover a fila", { id: `queue-remove-${queue.id}` })
}
}
return (
<div className="space-y-8">
<div className="grid gap-4 md:grid-cols-3">
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600">
<IconInbox className="size-4" /> Filas criadas
</CardTitle>
<CardDescription>Rotas que recebem tickets dos canais conectados.</CardDescription>
</CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900">
{queues ? totalQueues : <Skeleton className="h-8 w-16" />}
</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600">
<IconHierarchy2 className="size-4" /> Com time definido
</CardTitle>
<CardDescription>Filas com time responsável atribuído.</CardDescription>
</CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900">
{queues ? totalQueues - withoutTeam : <Skeleton className="h-8 w-16" />}
</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600">
<IconLink className="size-4" /> Sem vinculação
</CardTitle>
<CardDescription>Filas aguardando responsáveis.</CardDescription>
</CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900">
{queues ? withoutTeam : <Skeleton className="h-8 w-16" />}
</CardContent>
</Card>
</div>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg font-semibold text-neutral-900">
<IconPlus className="size-5 text-neutral-500" /> Nova fila
</CardTitle>
<CardDescription>Defina as filas de atendimento, conectando-as aos times responsáveis.</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleCreate} className="grid gap-4 md:grid-cols-[minmax(0,300px)_minmax(0,300px)_auto]">
<div className="space-y-2">
<Label htmlFor="queue-name">Nome da fila</Label>
<Input
id="queue-name"
placeholder="Ex.: Suporte N1"
value={name}
onChange={(event) => setName(event.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label>Time responsável</Label>
<Select
value={teamId ?? NO_TEAM_VALUE}
onValueChange={(value) => setTeamId(value === NO_TEAM_VALUE ? undefined : value)}
>
<SelectTrigger>
<SelectValue placeholder="Selecione um time" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_TEAM_VALUE}>Sem time</SelectItem>
{teams?.map((team) => (
<SelectItem key={team.id} value={team.id}>
{team.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-end">
<Button type="submit" className="w-full" disabled={saving}>
Criar fila
</Button>
</div>
</form>
</CardContent>
</Card>
<div className="grid gap-4 lg:grid-cols-2">
{queues === undefined ? (
Array.from({ length: 4 }).map((_, index) => <Skeleton key={index} className="h-40 rounded-2xl" />)
) : queues.length === 0 ? (
<Card className="border-dashed border-slate-300 bg-slate-50/80">
<CardHeader>
<CardTitle className="text-lg font-semibold text-neutral-900">Nenhuma fila cadastrada</CardTitle>
<CardDescription className="text-neutral-600">
Crie filas para segmentar os atendimentos por canal ou especialidade.
</CardDescription>
</CardHeader>
</Card>
) : (
queues.map((queue) => (
<Card key={queue.id} className="border-slate-200">
<CardHeader>
<div className="flex items-start justify-between gap-4">
<div>
<CardTitle className="text-xl font-semibold text-neutral-900">{queue.name}</CardTitle>
<CardDescription className="mt-2 text-xs uppercase tracking-wide text-neutral-500">
Slug: {queue.slug}
</CardDescription>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => openEdit(queue)}>
Editar
</Button>
<Button variant="destructive" size="sm" onClick={() => handleRemove(queue)}>
Excluir
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="flex items-center gap-3 text-sm text-neutral-600">
<span className="font-medium text-neutral-500">Time:</span>
{queue.team ? (
<Badge variant="outline" className="rounded-full border-neutral-300 text-neutral-700">
{queue.team.name}
</Badge>
) : (
<span className="text-neutral-500">Sem time vinculado</span>
)}
</div>
</CardContent>
</Card>
))
)}
</div>
<Dialog open={Boolean(editingQueue)} onOpenChange={(value) => (!value ? setEditingQueue(null) : null)}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Editar fila</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label htmlFor="edit-queue-name">Nome</Label>
<Input
id="edit-queue-name"
value={name}
onChange={(event) => setName(event.target.value)}
autoFocus
/>
</div>
<div className="space-y-2">
<Label>Time responsável</Label>
<Select
value={teamId ?? NO_TEAM_VALUE}
onValueChange={(value) => setTeamId(value === NO_TEAM_VALUE ? undefined : value)}
>
<SelectTrigger>
<SelectValue placeholder="Selecione um time" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_TEAM_VALUE}>Sem time</SelectItem>
{teams?.map((team) => (
<SelectItem key={team.id} value={team.id}>
{team.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => setEditingQueue(null)}>
Cancelar
</Button>
<Button onClick={handleUpdate} disabled={saving}>
Salvar alterações
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View file

@ -0,0 +1,390 @@
"use client"
import { useMemo, useState } from "react"
import { useMutation, useQuery } from "convex/react"
import { toast } from "sonner"
import { IconAlarm, IconBolt, IconTargetArrow } from "@tabler/icons-react"
// @ts-expect-error Convex runtime API lacks TypeScript declarations
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { useAuth } from "@/lib/auth-client"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
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 { Skeleton } from "@/components/ui/skeleton"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
type SlaPolicy = {
id: string
name: string
description: string
timeToFirstResponse: number | null
timeToResolution: number | null
}
function formatMinutes(value: number | null) {
if (value === null) return "—"
if (value < 60) return `${Math.round(value)} min`
const hours = Math.floor(value / 60)
const minutes = Math.round(value % 60)
if (minutes === 0) return `${hours}h`
return `${hours}h ${minutes}min`
}
export function SlasManager() {
const { session, convexUserId } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
const slas = useQuery(
api.slas.list,
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
) as SlaPolicy[] | undefined
const createSla = useMutation(api.slas.create)
const updateSla = useMutation(api.slas.update)
const removeSla = useMutation(api.slas.remove)
const [name, setName] = useState("")
const [description, setDescription] = useState("")
const [firstResponse, setFirstResponse] = useState<string>("")
const [resolution, setResolution] = useState<string>("")
const [saving, setSaving] = useState(false)
const [editingSla, setEditingSla] = useState<SlaPolicy | null>(null)
const { bestFirstResponse, bestResolution } = useMemo(() => {
if (!slas) return { bestFirstResponse: null, bestResolution: null }
const response = slas.reduce<number | null>((acc, sla) => {
if (sla.timeToFirstResponse === null) return acc
return acc === null ? sla.timeToFirstResponse : Math.min(acc, sla.timeToFirstResponse)
}, null)
const resolution = slas.reduce<number | null>((acc, sla) => {
if (sla.timeToResolution === null) return acc
return acc === null ? sla.timeToResolution : Math.min(acc, sla.timeToResolution)
}, null)
return { bestFirstResponse: response, bestResolution: resolution }
}, [slas])
const resetForm = () => {
setName("")
setDescription("")
setFirstResponse("")
setResolution("")
}
const parseNumber = (value: string) => {
const parsed = Number(value)
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined
}
const handleCreate = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (!name.trim()) {
toast.error("Informe um nome para a política")
return
}
if (!convexUserId) {
toast.error("Sessão não sincronizada com o Convex")
return
}
setSaving(true)
toast.loading("Criando SLA...", { id: "sla" })
try {
await createSla({
tenantId,
actorId: convexUserId as Id<"users">,
name: name.trim(),
description: description.trim() || undefined,
timeToFirstResponse: parseNumber(firstResponse),
timeToResolution: parseNumber(resolution),
})
toast.success("Política criada", { id: "sla" })
resetForm()
} catch (error) {
console.error(error)
toast.error("Não foi possível criar a política", { id: "sla" })
} finally {
setSaving(false)
}
}
const openEdit = (policy: SlaPolicy) => {
setEditingSla(policy)
setName(policy.name)
setDescription(policy.description)
setFirstResponse(policy.timeToFirstResponse ? String(policy.timeToFirstResponse) : "")
setResolution(policy.timeToResolution ? String(policy.timeToResolution) : "")
}
const handleUpdate = async () => {
if (!editingSla) return
if (!name.trim()) {
toast.error("Informe um nome para a política")
return
}
if (!convexUserId) {
toast.error("Sessão não sincronizada com o Convex")
return
}
setSaving(true)
toast.loading("Salvando alterações...", { id: "sla-edit" })
try {
await updateSla({
tenantId,
policyId: editingSla.id as Id<"slaPolicies">,
actorId: convexUserId as Id<"users">,
name: name.trim(),
description: description.trim() || undefined,
timeToFirstResponse: parseNumber(firstResponse),
timeToResolution: parseNumber(resolution),
})
toast.success("Política atualizada", { id: "sla-edit" })
setEditingSla(null)
resetForm()
} catch (error) {
console.error(error)
toast.error("Não foi possível atualizar a política", { id: "sla-edit" })
} finally {
setSaving(false)
}
}
const handleRemove = async (policy: SlaPolicy) => {
const confirmed = window.confirm(`Excluir a política ${policy.name}?`)
if (!confirmed) return
if (!convexUserId) {
toast.error("Sessão não sincronizada com o Convex")
return
}
toast.loading("Removendo política...", { id: `sla-remove-${policy.id}` })
try {
await removeSla({
tenantId,
policyId: policy.id as Id<"slaPolicies">,
actorId: convexUserId as Id<"users">,
})
toast.success("Política removida", { id: `sla-remove-${policy.id}` })
} catch (error) {
console.error(error)
toast.error("Não foi possível remover a política", { id: `sla-remove-${policy.id}` })
}
}
return (
<div className="space-y-8">
<div className="grid gap-4 md:grid-cols-3">
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600">
<IconTargetArrow className="size-4" /> Políticas criadas
</CardTitle>
<CardDescription>Regras aplicadas às filas e tickets.</CardDescription>
</CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900">
{slas ? slas.length : <Skeleton className="h-8 w-16" />}
</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600">
<IconAlarm className="size-4" /> Resposta (média)
</CardTitle>
<CardDescription>Tempo mínimo para primeira resposta.</CardDescription>
</CardHeader>
<CardContent className="text-xl font-semibold text-neutral-900">
{slas ? formatMinutes(bestFirstResponse ?? null) : <Skeleton className="h-8 w-24" />}
</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600">
<IconBolt className="size-4" /> Resolução (média)
</CardTitle>
<CardDescription>Alvo para encerrar chamados.</CardDescription>
</CardHeader>
<CardContent className="text-xl font-semibold text-neutral-900">
{slas ? formatMinutes(bestResolution ?? null) : <Skeleton className="h-8 w-24" />}
</CardContent>
</Card>
</div>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="text-lg font-semibold text-neutral-900">Nova política de SLA</CardTitle>
<CardDescription>Defina metas de resposta e resolução para garantir previsibilidade no atendimento.</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleCreate} className="grid gap-4 md:grid-cols-[minmax(0,320px)_minmax(0,1fr)]">
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="sla-name">Nome da política</Label>
<Input
id="sla-name"
placeholder="Ex.: Resposta prioritária"
value={name}
onChange={(event) => setName(event.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="sla-first-response">Primeira resposta (minutos)</Label>
<Input
id="sla-first-response"
type="number"
min={1}
value={firstResponse}
onChange={(event) => setFirstResponse(event.target.value)}
placeholder="Opcional"
/>
</div>
<div className="space-y-2">
<Label htmlFor="sla-resolution">Resolução (minutos)</Label>
<Input
id="sla-resolution"
type="number"
min={1}
value={resolution}
onChange={(event) => setResolution(event.target.value)}
placeholder="Opcional"
/>
</div>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="sla-description">Descrição</Label>
<textarea
id="sla-description"
className="min-h-[120px] w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-neutral-700 shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-900/10"
placeholder="Como esta política será aplicada"
value={description}
onChange={(event) => setDescription(event.target.value)}
/>
</div>
<div className="flex justify-end">
<Button type="submit" disabled={saving}>
Criar política
</Button>
</div>
</div>
</form>
</CardContent>
</Card>
<div className="space-y-4">
{slas === undefined ? (
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, index) => (
<Skeleton key={index} className="h-32 rounded-2xl" />
))}
</div>
) : slas.length === 0 ? (
<Card className="border-dashed border-slate-300 bg-slate-50/80">
<CardHeader>
<CardTitle className="text-lg font-semibold text-neutral-900">Nenhuma política cadastrada</CardTitle>
<CardDescription className="text-neutral-600">
Crie SLAs para monitorar o tempo de resposta e resolução dos seus chamados.
</CardDescription>
</CardHeader>
</Card>
) : (
slas.map((policy) => (
<Card key={policy.id} className="border-slate-200">
<CardHeader>
<div className="flex items-start justify-between gap-4">
<div className="space-y-2">
<CardTitle className="text-xl font-semibold text-neutral-900">{policy.name}</CardTitle>
{policy.description ? (
<CardDescription className="text-neutral-600">{policy.description}</CardDescription>
) : null}
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => openEdit(policy)}>
Editar
</Button>
<Button variant="destructive" size="sm" onClick={() => handleRemove(policy)}>
Excluir
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<dl className="grid gap-4 md:grid-cols-2">
<div>
<dt className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Primeira resposta</dt>
<dd className="text-lg font-semibold text-neutral-900">{formatMinutes(policy.timeToFirstResponse)}</dd>
</div>
<div>
<dt className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Resolução</dt>
<dd className="text-lg font-semibold text-neutral-900">{formatMinutes(policy.timeToResolution)}</dd>
</div>
</dl>
</CardContent>
</Card>
))
)}
</div>
<Dialog open={Boolean(editingSla)} onOpenChange={(value) => (!value ? setEditingSla(null) : null)}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Editar política de SLA</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label htmlFor="edit-sla-name">Nome</Label>
<Input
id="edit-sla-name"
value={name}
onChange={(event) => setName(event.target.value)}
autoFocus
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="edit-sla-first">Primeira resposta (minutos)</Label>
<Input
id="edit-sla-first"
type="number"
min={1}
value={firstResponse}
onChange={(event) => setFirstResponse(event.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-sla-resolution">Resolução (minutos)</Label>
<Input
id="edit-sla-resolution"
type="number"
min={1}
value={resolution}
onChange={(event) => setResolution(event.target.value)}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="edit-sla-description">Descrição</Label>
<textarea
id="edit-sla-description"
className="min-h-[120px] w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-neutral-700 shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-900/10"
value={description}
onChange={(event) => setDescription(event.target.value)}
/>
</div>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => setEditingSla(null)}>
Cancelar
</Button>
<Button onClick={handleUpdate} disabled={saving}>
Salvar alterações
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View file

@ -0,0 +1,428 @@
"use client"
import { useMemo, useState } from "react"
import { useMutation, useQuery } from "convex/react"
import { toast } from "sonner"
import { IconUsersGroup, IconCalendarClock, IconSettings, IconUserPlus } from "@tabler/icons-react"
// @ts-expect-error Convex runtime API lacks TypeScript declarations
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { useAuth } from "@/lib/auth-client"
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 { Badge } from "@/components/ui/badge"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Checkbox } from "@/components/ui/checkbox"
import { Skeleton } from "@/components/ui/skeleton"
type Team = {
id: string
name: string
description: string
members: { id: string; name: string; email: string; role: string }[]
queueCount: number
createdAt: number
}
type DirectoryUser = {
id: string
name: string
email: string
role: string
teams: string[]
}
export function TeamsManager() {
const { session, convexUserId } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
const teams = useQuery(
api.teams.list,
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
) as Team[] | undefined
const directory = useQuery(
api.teams.directory,
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
) as DirectoryUser[] | undefined
const createTeam = useMutation(api.teams.create)
const updateTeam = useMutation(api.teams.update)
const removeTeam = useMutation(api.teams.remove)
const setMembers = useMutation(api.teams.setMembers)
const [name, setName] = useState("")
const [description, setDescription] = useState("")
const [editingTeam, setEditingTeam] = useState<Team | null>(null)
const [membershipTeam, setMembershipTeam] = useState<Team | null>(null)
const [selectedMembers, setSelectedMembers] = useState<Set<string>>(new Set())
const [saving, setSaving] = useState(false)
const totalMembers = useMemo(() => {
if (!teams) return 0
return teams.reduce((acc, team) => acc + team.members.length, 0)
}, [teams])
const totalQueues = useMemo(() => {
if (!teams) return 0
return teams.reduce((acc, team) => acc + team.queueCount, 0)
}, [teams])
const handleCreate = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (!name.trim()) {
toast.error("Informe um nome para o time")
return
}
if (!convexUserId) {
toast.error("Sessão não sincronizada com o Convex")
return
}
setSaving(true)
toast.loading("Criando time...", { id: "team" })
try {
await createTeam({
tenantId,
actorId: convexUserId as Id<"users">,
name: name.trim(),
description: description.trim() || undefined,
})
setName("")
setDescription("")
toast.success("Time criado com sucesso", { id: "team" })
} catch (error) {
console.error(error)
toast.error("Não foi possível criar o time", { id: "team" })
} finally {
setSaving(false)
}
}
const openEdit = (team: Team) => {
setEditingTeam(team)
setName(team.name)
setDescription(team.description)
}
const handleUpdate = async () => {
if (!editingTeam) return
if (!name.trim()) {
toast.error("Informe um nome para o time")
return
}
if (!convexUserId) {
toast.error("Sessão não sincronizada com o Convex")
return
}
setSaving(true)
toast.loading("Salvando alterações...", { id: "team-edit" })
try {
await updateTeam({
tenantId,
teamId: editingTeam.id as Id<"teams">,
actorId: convexUserId as Id<"users">,
name: name.trim(),
description: description.trim() || undefined,
})
toast.success("Time atualizado", { id: "team-edit" })
setEditingTeam(null)
setName("")
setDescription("")
} catch (error) {
console.error(error)
toast.error("Não foi possível atualizar o time", { id: "team-edit" })
} finally {
setSaving(false)
}
}
const handleRemove = async (team: Team) => {
const confirmed = window.confirm(`Excluir o time ${team.name}?`)
if (!confirmed) return
if (!convexUserId) {
toast.error("Sessão não sincronizada com o Convex")
return
}
toast.loading("Removendo time...", { id: `team-remove-${team.id}` })
try {
await removeTeam({ tenantId, teamId: team.id as Id<"teams">, actorId: convexUserId as Id<"users"> })
toast.success("Time removido", { id: `team-remove-${team.id}` })
} catch (error) {
console.error(error)
toast.error("Não foi possível remover o time", { id: `team-remove-${team.id}` })
}
}
const openMembership = (team: Team) => {
setMembershipTeam(team)
setSelectedMembers(new Set(team.members.map((member) => member.id)))
}
const toggleMember = (userId: string) => {
setSelectedMembers((current) => {
const next = new Set(current)
if (next.has(userId)) {
next.delete(userId)
} else {
next.add(userId)
}
return next
})
}
const handleMembershipSave = async () => {
if (!membershipTeam) return
if (!convexUserId) {
toast.error("Sessão não sincronizada com o Convex")
return
}
setSaving(true)
toast.loading("Atualizando membros...", { id: "team-members" })
try {
await setMembers({
tenantId,
teamId: membershipTeam.id as Id<"teams">,
actorId: convexUserId as Id<"users">,
memberIds: Array.from(selectedMembers) as Id<"users">[],
})
toast.success("Membros atualizados", { id: "team-members" })
setMembershipTeam(null)
setSelectedMembers(new Set())
} catch (error) {
console.error(error)
toast.error("Não foi possível atualizar os membros", { id: "team-members" })
} finally {
setSaving(false)
}
}
return (
<div className="space-y-8">
<div className="grid gap-4 md:grid-cols-3">
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600">
<IconUsersGroup className="size-4" /> Times cadastrados
</CardTitle>
<CardDescription>Organize a operação em células de atendimento.</CardDescription>
</CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900">
{teams ? teams.length : <Skeleton className="h-8 w-16" />}
</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600">
<IconUserPlus className="size-4" /> Pessoas alocadas
</CardTitle>
<CardDescription>Soma de integrantes em cada time.</CardDescription>
</CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900">
{teams ? totalMembers : <Skeleton className="h-8 w-16" />}
</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600">
<IconCalendarClock className="size-4" /> Filas vinculadas
</CardTitle>
<CardDescription>Total de canais ligados aos times.</CardDescription>
</CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900">
{teams ? totalQueues : <Skeleton className="h-8 w-16" />}
</CardContent>
</Card>
</div>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg font-semibold text-neutral-900">
<IconSettings className="size-5 text-neutral-500" /> Novo time
</CardTitle>
<CardDescription>Defina a que squad, célula ou capítulo cada chamado poderá ser roteado.</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleCreate} className="grid gap-4 md:grid-cols-[minmax(0,300px)_minmax(0,1fr)_auto]">
<div className="space-y-2">
<Label htmlFor="team-name">Nome do time</Label>
<Input
id="team-name"
placeholder="Ex.: Suporte N1"
value={name}
onChange={(event) => setName(event.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="team-description">Descrição</Label>
<Input
id="team-description"
placeholder="Contextualize a responsabilidade do time"
value={description}
onChange={(event) => setDescription(event.target.value)}
/>
</div>
<div className="flex items-end">
<Button type="submit" className="w-full" disabled={saving}>
{saving && !editingTeam ? "Salvando..." : "Adicionar"}
</Button>
</div>
</form>
</CardContent>
</Card>
<div className="grid gap-4 lg:grid-cols-2">
{teams === undefined ? (
Array.from({ length: 4 }).map((_, index) => <Skeleton key={index} className="h-48 rounded-2xl" />)
) : teams.length === 0 ? (
<Card className="border-dashed border-slate-300 bg-slate-50/80">
<CardHeader>
<CardTitle className="text-lg font-semibold text-neutral-900">Nenhum time cadastrado</CardTitle>
<CardDescription className="text-neutral-600">
Crie o primeiro time para organizar a distribuição das filas e agentes.
</CardDescription>
</CardHeader>
</Card>
) : (
teams.map((team) => (
<Card key={team.id} className="flex flex-col justify-between border-slate-200">
<CardHeader>
<div className="flex items-start justify-between gap-4">
<div>
<CardTitle className="text-xl font-semibold text-neutral-900">{team.name}</CardTitle>
{team.description ? (
<CardDescription className="mt-1 text-neutral-600">{team.description}</CardDescription>
) : null}
<div className="mt-4 flex flex-wrap gap-2 text-xs text-neutral-500">
<Badge variant="outline" className="rounded-full border-neutral-300 text-neutral-600">
{team.members.length} membro{team.members.length === 1 ? "" : "s"}
</Badge>
<Badge variant="outline" className="rounded-full border-neutral-300 text-neutral-600">
{team.queueCount} fila{team.queueCount === 1 ? "" : "s"}
</Badge>
</div>
</div>
<div className="flex flex-col gap-2">
<Button variant="outline" size="sm" onClick={() => openMembership(team)}>
Gerenciar membros
</Button>
<Button variant="secondary" size="sm" onClick={() => openEdit(team)}>
Renomear
</Button>
<Button variant="destructive" size="sm" onClick={() => handleRemove(team)}>
Excluir
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Integrantes</p>
{team.members.length === 0 ? (
<p className="mt-2 text-sm text-neutral-600">Nenhum membro atribuído.</p>
) : (
<ul className="mt-2 space-y-2">
{team.members.map((member) => (
<li key={member.id} className="flex items-center justify-between rounded-lg border border-slate-200 px-3 py-2 text-sm">
<div>
<p className="font-medium text-neutral-800">{member.name || member.email}</p>
<p className="text-xs text-neutral-500">{member.email}</p>
</div>
<Badge variant="outline" className="rounded-full border-neutral-200 text-neutral-600">
{member.role.toLowerCase()}
</Badge>
</li>
))}
</ul>
)}
</CardContent>
</Card>
))
)}
</div>
<Dialog open={Boolean(editingTeam)} onOpenChange={(value) => (!value ? setEditingTeam(null) : null)}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Editar time</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label htmlFor="edit-team-name">Nome</Label>
<Input
id="edit-team-name"
value={name}
onChange={(event) => setName(event.target.value)}
autoFocus
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-team-description">Descrição</Label>
<Input
id="edit-team-description"
value={description}
onChange={(event) => setDescription(event.target.value)}
/>
</div>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => setEditingTeam(null)}>
Cancelar
</Button>
<Button onClick={handleUpdate} disabled={saving}>
Salvar alterações
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={Boolean(membershipTeam)} onOpenChange={(value) => (!value ? setMembershipTeam(null) : null)}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Gerenciar membros</DialogTitle>
</DialogHeader>
<div className="max-h-[420px] overflow-y-auto rounded-lg border border-slate-200">
{directory === undefined ? (
<div className="space-y-2 p-4">
{Array.from({ length: 6 }).map((_, index) => (
<Skeleton key={index} className="h-10 rounded" />
))}
</div>
) : directory.length === 0 ? (
<p className="p-4 text-sm text-neutral-600">Nenhum usuário sincronizado para este tenant.</p>
) : (
<ul className="divide-y divide-slate-100">
{directory.map((user) => {
const checked = selectedMembers.has(user.id)
return (
<li key={user.id} className="flex items-center justify-between gap-3 px-4 py-3">
<div>
<p className="text-sm font-medium text-neutral-900">{user.name || user.email}</p>
<p className="text-xs text-neutral-500">{user.email}</p>
</div>
<div className="flex items-center gap-3">
<Badge variant="outline" className="rounded-full border-neutral-200 text-neutral-600">
{user.role.toLowerCase()}
</Badge>
<Checkbox checked={checked} onCheckedChange={() => toggleMember(user.id)} />
</div>
</li>
)
})}
</ul>
)}
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => setMembershipTeam(null)}>
Cancelar
</Button>
<Button onClick={handleMembershipSave} disabled={saving}>
Salvar
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}