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
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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue