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>
|
||||
)
|
||||
}
|
||||
555
src/components/admin/categories/categories-manager.tsx
Normal file
555
src/components/admin/categories/categories-manager.tsx
Normal 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 há 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>
|
||||
)
|
||||
}
|
||||
551
src/components/admin/fields/fields-manager.tsx
Normal file
551
src/components/admin/fields/fields-manager.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
332
src/components/admin/queues/queues-manager.tsx
Normal file
332
src/components/admin/queues/queues-manager.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
390
src/components/admin/slas/slas-manager.tsx
Normal file
390
src/components/admin/slas/slas-manager.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
428
src/components/admin/teams/teams-manager.tsx
Normal file
428
src/components/admin/teams/teams-manager.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue