feat: dispositivos e ajustes de csat e relatórios

This commit is contained in:
codex-bot 2025-11-03 19:29:50 -03:00
parent 25d2a9b062
commit e0ef66555d
86 changed files with 5811 additions and 992 deletions

View file

@ -102,7 +102,7 @@ const ROLE_LABELS: Record<string, string> = {
manager: "Gestor",
agent: "Agente",
collaborator: "Colaborador",
machine: "Agente de máquina",
machine: "Agente de dispositivo",
}
function formatRole(role: string) {
@ -305,7 +305,7 @@ export function AdminUsersManager({
() => [
{ value: "all", label: "Todos" },
{ value: "people", label: "Pessoas" },
{ value: "machines", label: "Máquinas" },
{ value: "machines", label: "Dispositivos" },
],
[],
)
@ -327,8 +327,8 @@ export function AdminUsersManager({
const [teamSelection, setTeamSelection] = useState<Set<string>>(new Set())
const [isBulkDeletingTeam, setIsBulkDeletingTeam] = useState(false)
const [bulkDeleteTeamOpen, setBulkDeleteTeamOpen] = useState(false)
// Removidos filtros antigos de Pessoas/Máquinas (agora unificado)
// Unificado (pessoas + máquinas)
// Removidos filtros antigos de Pessoas/Dispositivos (agora unificado)
// Unificado (pessoas + dispositivos)
const [usersSearch, setUsersSearch] = useState("")
const [usersTypeFilter, setUsersTypeFilter] = useState<"all" | "people" | "machines">("people")
const [usersCompanyFilter, setUsersCompanyFilter] = useState<string>("all")
@ -366,7 +366,7 @@ export function AdminUsersManager({
const cleanupPreview = useMemo(() => Array.from(buildKeepEmailSet()).join(", "), [buildKeepEmailSet])
// Máquinas (para listar vínculos por usuário)
// Dispositivos (para listar vínculos por usuário)
type MachinesListItem = {
id: string
hostname?: string
@ -375,7 +375,7 @@ export function AdminUsersManager({
linkedUsers?: Array<{ id: string; email: string; name: string }>
}
const machinesList = useQuery(
api.machines.listByTenant,
api.devices.listByTenant,
convexUserId ? { tenantId: defaultTenantId, includeMetadata: true } : "skip"
) as MachinesListItem[] | undefined
@ -907,7 +907,7 @@ export function AdminUsersManager({
// Removido: seleção específica de Pessoas (uso substituído pelo unificado)
// Removido: seleção específica de Máquinas (uso substituído pelo unificado)
// Removido: seleção específica de Dispositivos (uso substituído pelo unificado)
const [inviteSelection, setInviteSelection] = useState<Set<string>>(new Set())
const selectedInvites = useMemo(() => invites.filter((i) => inviteSelection.has(i.id)), [invites, inviteSelection])
@ -951,7 +951,7 @@ export function AdminUsersManager({
if (!user) return
const machineId = extractMachineId(user.email)
if (!machineId) return
const response = await fetch(`/api/admin/machines/delete`, {
const response = await fetch(`/api/admin/devices/delete`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
@ -1098,9 +1098,9 @@ async function handleDeleteUser() {
if (isMachine) {
const machineId = extractMachineId(deleteTarget.email)
if (!machineId) {
throw new Error("Não foi possível identificar a máquina associada.")
throw new Error("Não foi possível identificar a dispositivo associada.")
}
const response = await fetch("/api/admin/machines/delete", {
const response = await fetch("/api/admin/devices/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ machineId }),
@ -1108,9 +1108,9 @@ async function handleDeleteUser() {
})
if (!response.ok) {
const data = await response.json().catch(() => ({}))
throw new Error(data.error ?? "Falha ao remover agente de máquina")
throw new Error(data.error ?? "Falha ao remover agente de dispositivo")
}
toast.success("Agente de máquina removido")
toast.success("Agente de dispositivo removido")
} else {
const response = await fetch(`/api/admin/users/${deleteTarget.id}`, {
method: "DELETE",
@ -1457,7 +1457,7 @@ async function handleDeleteUser() {
<Input
value={usersSearch}
onChange={(event) => setUsersSearch(event.target.value)}
placeholder="Buscar por nome, e-mail, empresa ou máquina..."
placeholder="Buscar por nome, e-mail, empresa ou dispositivo..."
className="h-9 pl-9"
/>
</div>
@ -1513,7 +1513,7 @@ async function handleDeleteUser() {
<Card>
<CardHeader>
<CardTitle>Usuários</CardTitle>
<CardDescription>Pessoas e máquinas com acesso ao sistema.</CardDescription>
<CardDescription>Pessoas e dispositivos com acesso ao sistema.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="w-full overflow-x-auto">
@ -1564,11 +1564,11 @@ async function handleDeleteUser() {
</div>
</TableCell>
<TableCell className="px-4 font-medium text-neutral-800">
{user.name || (user.role === "machine" ? "Máquina" : "—")}
{user.name || (user.role === "machine" ? "Dispositivo" : "—")}
</TableCell>
<TableCell className="px-4 text-neutral-600">{user.email}</TableCell>
<TableCell className="px-4 text-neutral-600">
{user.role === "machine" ? "Máquina" : "Pessoa"}
{user.role === "machine" ? "Dispositivo" : "Pessoa"}
</TableCell>
<TableCell className="px-4 text-neutral-600">
{user.role === "machine" ? (
@ -1606,11 +1606,11 @@ async function handleDeleteUser() {
<Link
href={
extractMachineId(user.email)
? `/admin/machines/${extractMachineId(user.email)}`
: "/admin/machines"
? `/admin/devices/${extractMachineId(user.email)}`
: "/admin/devices"
}
>
Detalhes da máquina
Detalhes da dispositivo
</Link>
</Button>
) : null}
@ -2113,7 +2113,7 @@ async function handleDeleteUser() {
<DialogContent>
<DialogHeader>
<DialogTitle>Remover usuários selecionados</DialogTitle>
<DialogDescription>Pessoas perderão o acesso e máquinas serão desconectadas.</DialogDescription>
<DialogDescription>Pessoas perderão o acesso e dispositivos serão desconectadas.</DialogDescription>
</DialogHeader>
<div className="max-h-64 space-y-2 overflow-auto">
{Array.from(usersSelection).slice(0, 5).map((id) => {
@ -2240,27 +2240,27 @@ async function handleDeleteUser() {
if (r === 'admin' || r === 'agent') return null
return (
<div className="grid gap-2">
<Label>Máquinas vinculadas</Label>
<Label>Dispositivos vinculadas</Label>
{linkedMachinesForEditUser.length > 0 ? (
<ul className="divide-y divide-slate-200 rounded-md border border-slate-200 bg-slate-50/60">
{linkedMachinesForEditUser.map((m) => (
<li key={`linked-m-${m.id}`} className="flex items-center justify-between px-3 py-2 text-sm">
<span className="truncate">{m.hostname || m.id}</span>
<Button asChild size="sm" variant="ghost">
<Link href={`/admin/machines/${m.id}`}>Abrir</Link>
<Link href={`/admin/devices/${m.id}`}>Abrir</Link>
</Button>
</li>
))}
</ul>
) : (
<p className="text-xs text-neutral-500">Nenhuma máquina vinculada a este usuário.</p>
<p className="text-xs text-neutral-500">Nenhuma dispositivo vinculada a este usuário.</p>
)}
</div>
)
})()}
{isMachineEditing ? (
<div className="rounded-lg border border-dashed border-slate-300 bg-slate-50 p-3 text-sm text-neutral-600">
Os ajustes detalhados de agentes de máquina são feitos em <Link href="/admin/machines" className="underline underline-offset-4">Admin Máquinas</Link>.
Os ajustes detalhados de agentes de dispositivo são feitos em <Link href="/admin/devices" className="underline underline-offset-4">Admin Dispositivos</Link>.
</div>
) : (
<div className="rounded-lg border border-slate-200 bg-slate-50 p-4 text-sm text-neutral-700">
@ -2314,7 +2314,7 @@ async function handleDeleteUser() {
<DialogContent>
<DialogHeader>
<DialogTitle>
{deleteTarget?.role === "machine" ? "Remover agente de máquina" : "Remover colaborador"}
{deleteTarget?.role === "machine" ? "Remover agente de dispositivo" : "Remover colaborador"}
</DialogTitle>
<DialogDescription>
{deleteTarget?.role === "machine"
@ -2328,7 +2328,7 @@ async function handleDeleteUser() {
</p>
{deleteTarget?.role === "machine" ? (
<p>
A máquina correspondente perderá imediatamente o token ativo e voltará para a tela de provisionamento.
A dispositivo correspondente perderá imediatamente o token ativo e voltará para a tela de provisionamento.
</p>
) : (
<p>Esse usuário não poderá mais acessar o painel até receber um novo convite.</p>

View file

@ -83,7 +83,7 @@ import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/
import { useQuery } from "convex/react"
import { api } from "@/convex/_generated/api"
import { MultiValueInput } from "@/components/ui/multi-value-input"
import { AdminMachinesOverview } from "@/components/admin/machines/admin-machines-overview"
import { AdminDevicesOverview } from "@/components/admin/devices/admin-devices-overview"
type LastAlertInfo = { createdAt: number; usagePct: number; threshold: number } | null
@ -283,8 +283,8 @@ export function AdminCompaniesManager({ initialCompanies, tenantId }: Props) {
const effectiveTenantId = tenantId ?? companies[0]?.tenantId ?? DEFAULT_TENANT_ID
// Máquinas por empresa para contagem rápida
const machines = useQuery(api.machines.listByTenant, {
// Dispositivos por empresa para contagem rápida
const machines = useQuery(api.devices.listByTenant, {
tenantId: effectiveTenantId,
includeMetadata: false,
}) as unknown[] | undefined
@ -513,10 +513,10 @@ export function AdminCompaniesManager({ initialCompanies, tenantId }: Props) {
type="button"
className="inline-flex items-center gap-1 text-muted-foreground transition hover:text-foreground"
onClick={() => {
window.location.href = `/admin/machines?company=${company.slug}`
window.location.href = `/admin/devices?company=${company.slug}`
}}
>
<IconDeviceDesktop className="size-3.5" /> Máquinas
<IconDeviceDesktop className="size-3.5" /> Dispositivos
</button>
<button
type="button"
@ -754,7 +754,7 @@ function TableView({ companies, machineCountsBySlug, onEdit, onDelete }: TableVi
<TableHead>Empresa</TableHead>
<TableHead>Contratos ativos</TableHead>
<TableHead>Contatos</TableHead>
<TableHead>Máquinas</TableHead>
<TableHead>Dispositivos</TableHead>
<TableHead className="text-right">Ações</TableHead>
</TableRow>
</TableHeader>
@ -864,9 +864,9 @@ function TableView({ companies, machineCountsBySlug, onEdit, onDelete }: TableVi
<IconCopy className="mr-2 size-3.5" />
Código
</Button>
<Button size="sm" variant="outline" className="whitespace-nowrap" onClick={() => { window.location.href = `/admin/machines?company=${company.slug}` }}>
<Button size="sm" variant="outline" className="whitespace-nowrap" onClick={() => { window.location.href = `/admin/devices?company=${company.slug}` }}>
<IconDeviceDesktop className="mr-2 size-3.5" />
Máquinas
Dispositivos
</Button>
<Button
size="icon"
@ -1687,10 +1687,10 @@ function CompanySheet({ tenantId, editor, onClose, onCreated, onUpdated }: Compa
{editor?.mode === "edit" ? (
<AccordionItem value="machines" className="rounded-lg border border-border/60 bg-muted/20 px-4">
<AccordionTrigger className="py-3 font-semibold">Máquinas vinculadas</AccordionTrigger>
<AccordionTrigger className="py-3 font-semibold">Dispositivos vinculadas</AccordionTrigger>
<AccordionContent className="pb-5">
<div className="rounded-lg border border-border/60 bg-background p-3">
<AdminMachinesOverview tenantId={tenantId} initialCompanyFilterSlug={editor.company.slug} />
<AdminDevicesOverview tenantId={tenantId} initialCompanyFilterSlug={editor.company.slug} />
</div>
</AccordionContent>
</AccordionItem>

View file

@ -5,28 +5,28 @@ import { useQuery } from "convex/react"
import { useParams, useRouter } from "next/navigation"
import { api } from "@/convex/_generated/api"
import {
MachineDetails,
normalizeMachineItem,
type MachinesQueryItem,
} from "@/components/admin/machines/admin-machines-overview"
DeviceDetails,
normalizeDeviceItem,
type DevicesQueryItem,
} from "@/components/admin/devices/admin-devices-overview"
import { Card, CardContent } from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton"
import { Button } from "@/components/ui/button"
import type { Id } from "@/convex/_generated/dataModel"
import { ConvexHttpClient } from "convex/browser"
export function AdminMachineDetailsClient({ tenantId: _tenantId, machineId }: { tenantId: string; machineId?: string }) {
export function AdminDeviceDetailsClient({ tenantId: _tenantId, deviceId }: { tenantId: string; deviceId?: string }) {
const router = useRouter()
const params = useParams<{ id?: string | string[] }>()
const routeMachineId = Array.isArray(params?.id) ? params?.id[0] : params?.id
const effectiveMachineId = machineId ?? routeMachineId ?? ""
const routeDeviceId = Array.isArray(params?.id) ? params?.id[0] : params?.id
const effectiveDeviceId = deviceId ?? routeDeviceId ?? ""
const canLoadMachine = Boolean(effectiveMachineId)
const canLoadDevice = Boolean(effectiveDeviceId)
const single = useQuery(
api.machines.getById,
canLoadMachine
? ({ id: effectiveMachineId as Id<"machines">, includeMetadata: true } as const)
api.devices.getById,
canLoadDevice
? ({ id: effectiveDeviceId as Id<"machines">, includeMetadata: true } as const)
: "skip"
)
@ -34,7 +34,7 @@ export function AdminMachineDetailsClient({ tenantId: _tenantId, machineId }: {
const [fallback, setFallback] = useState<Record<string, unknown> | null | undefined>(undefined)
const [loadError, setLoadError] = useState<string | null>(null)
const [retryTick, setRetryTick] = useState(0)
const shouldLoad = fallback === undefined && Boolean(effectiveMachineId)
const shouldLoad = fallback === undefined && Boolean(effectiveDeviceId)
const [isHydrated, setIsHydrated] = useState(false)
useEffect(() => {
@ -51,8 +51,8 @@ export function AdminMachineDetailsClient({ tenantId: _tenantId, machineId }: {
if (convexUrl) {
try {
const http = new ConvexHttpClient(convexUrl)
const data = (await http.query(api.machines.getById, {
id: effectiveMachineId as Id<"machines">,
const data = (await http.query(api.devices.getById, {
id: effectiveDeviceId as Id<"machines">,
includeMetadata: true,
})) as Record<string, unknown> | null
@ -75,7 +75,7 @@ export function AdminMachineDetailsClient({ tenantId: _tenantId, machineId }: {
}
try {
const res = await fetch(`/api/admin/machines/${effectiveMachineId}/details`, {
const res = await fetch(`/api/admin/devices/${effectiveDeviceId}/details`, {
credentials: "include",
cache: "no-store",
})
@ -108,29 +108,29 @@ export function AdminMachineDetailsClient({ tenantId: _tenantId, machineId }: {
}
} catch (err) {
if (!cancelled) {
console.error("[admin-machine-details] API fallback fetch failed", err)
setLoadError("Erro de rede ao carregar os dados da máquina.")
console.error("[admin-device-details] API fallback fetch failed", err)
setLoadError("Erro de rede ao carregar os dados da dispositivo.")
}
}
} catch (err) {
if (!cancelled) {
console.error("[admin-machine-details] Unexpected probe failure", err)
setLoadError("Erro de rede ao carregar os dados da máquina.")
console.error("[admin-device-details] Unexpected probe failure", err)
setLoadError("Erro de rede ao carregar os dados da dispositivo.")
}
}
}
probe().catch((err) => {
if (!cancelled) {
console.error("[admin-machine-details] Probe promise rejected", err)
setLoadError("Erro de rede ao carregar os dados da máquina.")
console.error("[admin-device-details] Probe promise rejected", err)
setLoadError("Erro de rede ao carregar os dados da dispositivo.")
}
})
return () => {
cancelled = true
}
}, [shouldLoad, effectiveMachineId, retryTick])
}, [shouldLoad, effectiveDeviceId, retryTick])
// Timeout de proteção: se depois de X segundos ainda estiver carregando e sem fallback, mostra erro claro
useEffect(() => {
@ -141,12 +141,12 @@ export function AdminMachineDetailsClient({ tenantId: _tenantId, machineId }: {
)
}, 10_000)
return () => clearTimeout(timeout)
}, [shouldLoad, effectiveMachineId, retryTick])
}, [shouldLoad, effectiveDeviceId, retryTick])
const machine: MachinesQueryItem | null = useMemo(() => {
const device: DevicesQueryItem | null = useMemo(() => {
const source = single ?? (fallback === undefined ? undefined : fallback)
if (source === undefined || source === null) return source as null
return normalizeMachineItem(source)
return normalizeDeviceItem(source)
}, [single, fallback])
const isLoading = single === undefined && fallback === undefined && !loadError
const isNotFound = (single === null || fallback === null) && !loadError
@ -174,11 +174,11 @@ export function AdminMachineDetailsClient({ tenantId: _tenantId, machineId }: {
// ignore
}
}
if (loadError && !machine) {
if (loadError && !device) {
return (
<Card>
<CardContent className="space-y-3 p-6">
<p className="text-sm font-medium text-red-600">Falha ao carregar os dados da máquina</p>
<p className="text-sm font-medium text-red-600">Falha ao carregar os dados da dispositivo</p>
<p className="text-sm text-muted-foreground">{loadError}</p>
<div className="pt-2 flex items-center gap-2">
<Button size="sm" onClick={onRetry}>Tentar novamente</Button>
@ -204,7 +204,7 @@ export function AdminMachineDetailsClient({ tenantId: _tenantId, machineId }: {
return (
<Card>
<CardContent className="space-y-3 p-6">
<p className="text-sm font-medium text-red-600">Máquina não encontrada</p>
<p className="text-sm font-medium text-red-600">Dispositivo não encontrada</p>
<p className="text-sm text-muted-foreground">Verifique o identificador e tente novamente.</p>
<div className="pt-2 flex items-center gap-2">
<Button size="sm" onClick={onRetry}>Recarregar</Button>
@ -214,5 +214,5 @@ export function AdminMachineDetailsClient({ tenantId: _tenantId, machineId }: {
)
}
return <MachineDetails machine={machine} />
return <DeviceDetails device={device} />
}

View file

@ -12,33 +12,33 @@ type BreadcrumbSegment = {
href?: string | null
}
type MachineBreadcrumbsProps = {
type DeviceBreadcrumbsProps = {
tenantId: string
machineId: string
machineHref?: string | null
deviceId: string
deviceHref?: string | null
extra?: BreadcrumbSegment[]
}
export function MachineBreadcrumbs({ tenantId: _tenantId, machineId, machineHref, extra }: MachineBreadcrumbsProps) {
export function DeviceBreadcrumbs({ tenantId: _tenantId, deviceId, deviceHref, extra }: DeviceBreadcrumbsProps) {
const { convexUserId } = useAuth()
const canLoadMachine = Boolean(machineId && convexUserId)
const canLoadDevice = Boolean(deviceId && convexUserId)
const item = useQuery(
api.machines.getById,
canLoadMachine
? ({ id: machineId as Id<"machines">, includeMetadata: false } as const)
api.devices.getById,
canLoadDevice
? ({ id: deviceId as Id<"machines">, includeMetadata: false } as const)
: "skip"
)
const hostname = useMemo(() => item?.hostname ?? "Detalhe", [item])
const segments = useMemo(() => {
const trail: BreadcrumbSegment[] = [
{ label: "Máquinas", href: "/admin/machines" },
{ label: hostname, href: machineHref ?? undefined },
{ label: "Dispositivos", href: "/admin/devices" },
{ label: hostname, href: deviceHref ?? undefined },
]
if (Array.isArray(extra) && extra.length > 0) {
trail.push(...extra.filter((segment): segment is BreadcrumbSegment => Boolean(segment?.label)))
}
return trail
}, [hostname, machineHref, extra])
}, [hostname, deviceHref, extra])
return (
<nav className="mb-4 text-sm text-neutral-600">

View file

@ -27,7 +27,7 @@ import { TicketStatusBadge } from "@/components/tickets/status-badge"
import type { TicketPriority, TicketStatus } from "@/lib/schemas/ticket"
import { EmptyIndicator } from "@/components/ui/empty-indicator"
type MachineTicketHistoryItem = {
type DeviceTicketHistoryItem = {
id: string
reference: number
subject: string
@ -40,7 +40,7 @@ type MachineTicketHistoryItem = {
assignee: { name: string | null; email: string | null } | null
}
type MachineTicketsHistoryArgs = {
type DeviceTicketsHistoryArgs = {
machineId: Id<"machines">
status?: "open" | "resolved"
priority?: string
@ -49,7 +49,7 @@ type MachineTicketsHistoryArgs = {
to?: number
}
type MachineTicketsHistoryStats = {
type DeviceTicketsHistoryStats = {
total: number
openCount: number
resolvedCount: number
@ -142,7 +142,7 @@ function getPriorityMeta(priority: TicketPriority | string | null | undefined) {
}
}
export function MachineTicketsHistoryClient({ tenantId: _tenantId, machineId }: { tenantId: string; machineId: string }) {
export function DeviceTicketsHistoryClient({ tenantId: _tenantId, deviceId }: { tenantId: string; deviceId: string }) {
const [statusFilter, setStatusFilter] = useState<"all" | "open" | "resolved">("all")
const [priorityFilter, setPriorityFilter] = useState<string>("ALL")
const [periodPreset, setPeriodPreset] = useState<PeriodPreset>("90d")
@ -168,8 +168,8 @@ export function MachineTicketsHistoryClient({ tenantId: _tenantId, machineId }:
const range = useMemo(() => computeRange(periodPreset, customFrom, customTo), [periodPreset, customFrom, customTo])
const queryArgs = useMemo(() => {
const args: MachineTicketsHistoryArgs = {
machineId: machineId as Id<"machines">,
const args: DeviceTicketsHistoryArgs = {
machineId: deviceId as Id<"machines">,
}
if (statusFilter !== "all") {
args.status = statusFilter
@ -187,15 +187,15 @@ export function MachineTicketsHistoryClient({ tenantId: _tenantId, machineId }:
args.to = range.to
}
return args
}, [debouncedSearch, machineId, priorityFilter, range.from, range.to, statusFilter])
}, [debouncedSearch, deviceId, priorityFilter, range.from, range.to, statusFilter])
const { results: tickets, status: paginationStatus, loadMore } = usePaginatedQuery(
api.machines.listTicketsHistory,
api.devices.listTicketsHistory,
queryArgs,
{ initialNumItems: 25 }
)
const stats = useQuery(api.machines.getTicketsHistoryStats, queryArgs) as MachineTicketsHistoryStats | undefined
const stats = useQuery(api.devices.getTicketsHistoryStats, queryArgs) as DeviceTicketsHistoryStats | undefined
const totalTickets = stats?.total ?? 0
const openTickets = stats?.openCount ?? 0
const resolvedTickets = stats?.resolvedCount ?? 0
@ -321,7 +321,7 @@ export function MachineTicketsHistoryClient({ tenantId: _tenantId, machineId }:
<EmptyHeader>
<EmptyTitle>Nenhum chamado encontrado</EmptyTitle>
<EmptyDescription>
Ajuste os filtros ou expanda o período para visualizar o histórico de chamados desta máquina.
Ajuste os filtros ou expanda o período para visualizar o histórico de chamados desta dispositivo.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>

View file

@ -109,7 +109,7 @@ const navigation: NavigationGroup[] = [
requiredRole: "admin",
},
{ title: "Usuários", url: "/admin/users", icon: Users, requiredRole: "admin" },
{ title: "Máquinas", url: "/admin/machines", icon: MonitorCog, requiredRole: "admin" },
{ title: "Dispositivos", url: "/admin/devices", icon: MonitorCog, requiredRole: "admin" },
{ title: "SLAs", url: "/admin/slas", icon: Timer, requiredRole: "admin" },
],
},

View file

@ -230,7 +230,7 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
if (machineInactive) {
toast.error("Esta máquina está desativada. Reative-a para enviar novas mensagens.")
toast.error("Esta dispositivo está desativada. Reative-a para enviar novas mensagens.")
return
}
if (!convexUserId || !ticket) return
@ -328,7 +328,7 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
<CardContent className="space-y-6 px-5 pb-6">
{machineInactive ? (
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700">
Esta máquina está desativada. Ative-a novamente para enviar novas mensagens.
Esta dispositivo está desativada. Ative-a novamente para enviar novas mensagens.
</div>
) : null}
<form onSubmit={handleSubmit} className="space-y-4">
@ -687,10 +687,10 @@ function PortalCommentAttachmentCard({
</div>
)
}

View file

@ -64,13 +64,13 @@ export function PortalTicketForm() {
event.preventDefault()
if (isSubmitting || !isFormValid) return
if (machineInactive) {
toast.error("Esta máquina está desativada no momento. Reative-a para abrir novos chamados.", { id: "portal-new-ticket" })
toast.error("Esta dispositivo está desativada no momento. Reative-a para abrir novos chamados.", { id: "portal-new-ticket" })
return
}
if (!viewerId) {
const detail = viewerErrorMessage ? ` Detalhes: ${viewerErrorMessage}` : ""
toast.error(
`Não foi possível identificar o colaborador vinculado a esta máquina. Tente abrir novamente o portal ou contate o suporte.${detail}`,
`Não foi possível identificar o colaborador vinculado a esta dispositivo. Tente abrir novamente o portal ou contate o suporte.${detail}`,
{ id: "portal-new-ticket" }
)
return
@ -145,12 +145,12 @@ export function PortalTicketForm() {
<CardContent className="space-y-6 px-5 pb-6">
{machineInactive ? (
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700">
Esta máquina foi desativada pelos administradores e não pode abrir novos chamados até ser reativada.
Esta dispositivo foi desativada pelos administradores e não pode abrir novos chamados até ser reativada.
</div>
) : null}
{!isViewerReady ? (
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700">
Vincule esta máquina a um colaborador na aplicação desktop para enviar chamados em nome dele.
Vincule esta dispositivo a um colaborador na aplicação desktop para enviar chamados em nome dele.
{machineContextLoading ? (
<p className="mt-2 text-xs text-amber-600">Carregando informa<EFBFBD><EFBFBD>es da m<EFBFBD>quina...</p>
) : null}

View file

@ -188,7 +188,7 @@ export function CompanyReport() {
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Máquinas monitoradas</CardTitle>
<CardTitle className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Dispositivos monitoradas</CardTitle>
<CardDescription className="text-neutral-600">Inventário registrado nesta empresa.</CardDescription>
</CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900">{report.machines.total}</CardContent>
@ -291,7 +291,7 @@ export function CompanyReport() {
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="text-lg font-semibold text-neutral-900">Sistemas operacionais</CardTitle>
<CardDescription className="text-neutral-600">Inventário das máquinas desta empresa.</CardDescription>
<CardDescription className="text-neutral-600">Inventário das dispositivos desta empresa.</CardDescription>
</CardHeader>
<CardContent className="pb-6">
<ChartContainer config={MACHINE_STATUS_CONFIG} className="mx-auto aspect-square max-h-[240px]">

View file

@ -1,7 +1,7 @@
"use client"
import { useQuery } from "convex/react"
import { IconMoodSmile, IconStars, IconMessageCircle2 } from "@tabler/icons-react"
import { IconMoodSmile, IconStars, IconMessageCircle2, IconTarget } from "@tabler/icons-react"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { useAuth } from "@/lib/auth-client"
@ -13,7 +13,7 @@ import { useState } from "react"
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts"
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
import { Button } from "@/components/ui/button"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { usePersistentCompanyFilter } from "@/lib/use-company-filter"
@ -49,53 +49,84 @@ export function CsatReport() {
)
}
const companyOptions = (companies ?? []).map<SearchableComboboxOption>((company) => ({
value: company.id,
label: company.name,
}))
const comboboxOptions: SearchableComboboxOption[] = [
{ value: "all", label: "Todas as empresas" },
...companyOptions,
]
const handleCompanyChange = (value: string | null) => {
setCompanyId(value ?? "all")
}
const selectedCompany = companyId === "all" ? "all" : companyId
const averageScore = typeof data.averageScore === "number" ? data.averageScore : null
const positiveRate = typeof data.positiveRate === "number" ? data.positiveRate : null
const agentStats = Array.isArray(data.byAgent) ? data.byAgent : []
const topAgent = agentStats[0] ?? null
const agentChartData = agentStats.map((agent: { agentName: string; averageScore: number | null; totalResponses: number; positiveRate: number | null }) => ({
agent: agent.agentName ?? "Sem responsável",
average: agent.averageScore ?? 0,
total: agent.totalResponses ?? 0,
positive: agent.positiveRate ? Math.round(agent.positiveRate * 1000) / 10 : 0,
}))
return (
<div className="space-y-8">
<div className="grid gap-4 md:grid-cols-3">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">
<h2 className="text-xl font-semibold text-neutral-900">CSAT Satisfação dos chamados</h2>
<p className="text-sm text-neutral-600">
Avalie a experiência dos usuários e acompanhe o desempenho da equipe de atendimento.
</p>
</div>
<div className="flex flex-wrap items-center gap-2 md:justify-end">
<SearchableCombobox
value={selectedCompany}
onValueChange={handleCompanyChange}
options={comboboxOptions}
className="w-56"
placeholder="Filtrar por empresa"
/>
<ToggleGroup
type="single"
value={timeRange}
onValueChange={(value) => {
if (value) setTimeRange(value)
}}
variant="outline"
className="hidden *:data-[slot=toggle-group-item]:!px-4 md:flex"
>
<ToggleGroupItem value="90d">90 dias</ToggleGroupItem>
<ToggleGroupItem value="30d">30 dias</ToggleGroupItem>
<ToggleGroupItem value="7d">7 dias</ToggleGroupItem>
</ToggleGroup>
<Button asChild size="sm" variant="outline">
<a href={`/api/reports/csat.xlsx?range=${timeRange}${companyId !== "all" ? `&companyId=${companyId}` : ""}`} download>
Exportar XLSX
</a>
</Button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
<IconMoodSmile className="size-4 text-teal-500" /> CSAT médio
</CardTitle>
<CardDescription className="text-neutral-600">Média das respostas recebidas.</CardDescription>
<CardAction>
<div className="flex flex-wrap items-center justify-end gap-2 md:gap-3">
<Select value={companyId} onValueChange={setCompanyId}>
<SelectTrigger className="w-56">
<SelectValue placeholder="Todas as empresas" />
</SelectTrigger>
<SelectContent className="rounded-xl">
<SelectItem value="all">Todas as empresas</SelectItem>
{(companies ?? []).map((c) => (
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
<ToggleGroup
type="single"
value={timeRange}
onValueChange={setTimeRange}
variant="outline"
className="hidden *:data-[slot=toggle-group-item]:!px-4 md:flex"
>
<ToggleGroupItem value="90d">90 dias</ToggleGroupItem>
<ToggleGroupItem value="30d">30 dias</ToggleGroupItem>
<ToggleGroupItem value="7d">7 dias</ToggleGroupItem>
</ToggleGroup>
<Button asChild size="sm" variant="outline">
<a href={`/api/reports/csat.xlsx?range=${timeRange}${companyId !== "all" ? `&companyId=${companyId}` : ""}`} download>
Exportar XLSX
</a>
</Button>
</div>
</CardAction>
<CardTitle className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
<IconMoodSmile className="size-4 text-teal-500" /> CSAT médio
</CardTitle>
<CardDescription className="text-neutral-600">Média das respostas recebidas.</CardDescription>
</CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900">
{formatScore(data.averageScore)}
{formatScore(averageScore)} <span className="text-base font-normal text-neutral-500">/ 5</span>
</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
@ -105,30 +136,104 @@ export function CsatReport() {
</CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900">{data.totalSurveys}</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
<IconMessageCircle2 className="size-4 text-sky-500" /> Últimas avaliações
<IconTarget className="size-4 text-amber-500" /> Avaliações positivas
</CardTitle>
<CardDescription className="text-neutral-600">Até 10 registros mais recentes.</CardDescription>
<CardDescription className="text-neutral-600">Notas iguais ou superiores a 4.</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
{data.recent.length === 0 ? (
<p className="text-sm text-neutral-500">Ainda não coletamos nenhuma avaliação.</p>
<CardContent className="text-3xl font-semibold text-neutral-900">
{positiveRate === null ? "—" : `${(positiveRate * 100).toFixed(1).replace(".0", "")}%`}
</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
<IconMessageCircle2 className="size-4 text-sky-500" /> Destaque do período
</CardTitle>
<CardDescription className="text-neutral-600">Agente com melhor média no recorte selecionado.</CardDescription>
</CardHeader>
<CardContent className="space-y-1">
{topAgent ? (
<>
<p className="text-base font-semibold text-neutral-900">{topAgent.agentName}</p>
<p className="text-sm text-neutral-600">
{topAgent.averageScore ? `${topAgent.averageScore.toFixed(2)} / 5` : "Sem notas suficientes"}
</p>
<p className="text-xs text-neutral-500">{topAgent.totalResponses} avaliação{topAgent.totalResponses === 1 ? "" : "s"}</p>
</>
) : (
data.recent.map((item: { ticketId: string; reference: number; score: number; receivedAt: number }) => (
<div key={`${item.ticketId}-${item.receivedAt}`} className="flex items-center justify-between rounded-lg border border-slate-200 px-3 py-2 text-sm">
<span>#{item.reference}</span>
<Badge variant="outline" className="rounded-full border-neutral-300 text-neutral-600">
Nota {item.score}
</Badge>
</div>
))
<p className="text-sm text-neutral-500">Ainda não avaliações suficientes.</p>
)}
</CardContent>
</Card>
</div>
<Card className="border-slate-200">
<CardHeader>
<div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
<div>
<CardTitle className="text-lg font-semibold text-neutral-900">Últimas avaliações</CardTitle>
<CardDescription className="text-neutral-600">
Até 10 registros mais recentes enviados pelos usuários.
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-2">
{data.recent.length === 0 ? (
<p className="rounded-lg border border-dashed border-slate-200 p-6 text-sm text-neutral-500">
Ainda não coletamos nenhuma avaliação no período selecionado.
</p>
) : (
data.recent.map(
(item: {
ticketId: string
reference: number
score: number
maxScore?: number | null
comment?: string | null
receivedAt: number
assigneeName?: string | null
}) => {
const normalized =
item.maxScore && item.maxScore > 0
? Math.round(((item.score / item.maxScore) * 5) * 10) / 10
: item.score
const badgeLabel =
item.maxScore && item.maxScore !== 5
? `${item.score}/${item.maxScore}`
: normalized.toFixed(1).replace(/\.0$/, "")
return (
<div
key={`${item.ticketId}-${item.receivedAt}`}
className="flex items-center justify-between gap-3 rounded-lg border border-slate-200 px-3 py-2 text-sm"
>
<div className="flex flex-col gap-1">
<span className="font-semibold text-neutral-800">#{item.reference}</span>
{item.assigneeName ? (
<Badge variant="outline" className="w-fit rounded-full border-neutral-200 text-xs text-neutral-600">
{item.assigneeName}
</Badge>
) : null}
{item.comment ? (
<span className="text-xs text-neutral-500 line-clamp-2">{item.comment}</span>
) : null}
</div>
<Badge variant="outline" className="rounded-full border-neutral-300 text-neutral-600">
Nota {badgeLabel}
</Badge>
</div>
)
}
)
)}
</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="text-lg font-semibold text-neutral-900">Distribuição das notas</CardTitle>
@ -140,17 +245,80 @@ export function CsatReport() {
{data.totalSurveys === 0 ? (
<p className="rounded-lg border border-dashed border-slate-200 p-6 text-sm text-neutral-500">Sem respostas no período.</p>
) : (
<ChartContainer config={{}} className="aspect-auto h-[260px] w-full">
<ChartContainer config={{ total: { label: "Respostas" } }} className="aspect-auto h-[260px] w-full">
<BarChart data={data.distribution.map((d: { score: number; total: number }) => ({ score: `Nota ${d.score}`, total: d.total }))}>
<CartesianGrid vertical={false} />
<XAxis dataKey="score" tickLine={false} axisLine={false} tickMargin={8} />
<Bar dataKey="total" fill="var(--chart-3)" radius={[4, 4, 0, 0]} />
<ChartTooltip content={<ChartTooltipContent className="w-[180px]" nameKey="Respostas" />} />
<ChartTooltip content={<ChartTooltipContent className="w-[180px]" nameKey="total" />} />
</BarChart>
</ChartContainer>
)}
</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="text-lg font-semibold text-neutral-900">Desempenho por agente</CardTitle>
<CardDescription className="text-neutral-600">
Média ponderada (1 a 5) e volume de avaliações recebidas por integrante da equipe.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-6 lg:grid-cols-[3fr_2fr]">
{agentChartData.length === 0 ? (
<p className="rounded-lg border border-dashed border-slate-200 p-6 text-sm text-neutral-500">
Ainda não avaliações atreladas a agentes no período selecionado.
</p>
) : (
<>
<ChartContainer
config={{
average: { label: "Média (1-5)", color: "hsl(var(--chart-1))" },
}}
className="aspect-auto h-[280px] w-full"
>
<BarChart data={agentChartData}>
<CartesianGrid vertical={false} />
<XAxis dataKey="agent" tickLine={false} axisLine={false} tickMargin={8} />
<Bar dataKey="average" fill="var(--chart-1)" radius={[4, 4, 0, 0]} />
<ChartTooltip
content={
<ChartTooltipContent
className="w-[220px]"
nameKey="average"
labelFormatter={(label) => `Agente: ${label}`}
valueFormatter={(value) => `${Number(value).toFixed(2)} / 5`}
/>
}
/>
</BarChart>
</ChartContainer>
<div className="space-y-3">
{agentStats.slice(0, 6).map((agent: { agentName: string; averageScore: number | null; totalResponses: number; positiveRate: number | null }) => (
<div
key={`${agent.agentName}-${agent.totalResponses}`}
className="flex items-center justify-between rounded-xl border border-slate-200 bg-white px-3 py-2"
>
<div className="flex flex-col">
<span className="font-semibold text-neutral-900">{agent.agentName}</span>
<span className="text-xs text-neutral-500">
{agent.totalResponses} avaliação{agent.totalResponses === 1 ? "" : "s"}
</span>
</div>
<div className="text-right text-sm font-semibold text-neutral-900">
{agent.averageScore ? `${agent.averageScore.toFixed(2)} / 5` : "—"}
<p className="text-xs font-normal text-neutral-500">
{agent.positiveRate === null ? "—" : `${(agent.positiveRate * 100).toFixed(0)}% positivas`}
</p>
</div>
</div>
))}
</div>
</>
)}
</CardContent>
</Card>
</div>
)
}

View file

@ -13,6 +13,7 @@ import { Checkbox } from "@/components/ui/checkbox"
import { Label } from "@/components/ui/label"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
type ClosingTemplate = { id: string; title: string; body: string }
@ -45,7 +46,7 @@ const DEFAULT_CLOSING_TEMPLATES: ClosingTemplate[] = [
body: sanitizeTemplate(`
<p>Olá {{cliente}},</p>
<p>A equipe da ${DEFAULT_COMPANY_NAME} agradece o contato. Este ticket está sendo encerrado.</p>
<p>Se surgirem novas questões, você pode reabrir o ticket em até 7 dias ou nos contatar pelo número <strong>${DEFAULT_PHONE_NUMBER}</strong>. Obrigado.</p>
<p>Se surgirem novas questões, você pode reabrir o ticket em até 14 dias ou nos contatar pelo número <strong>${DEFAULT_PHONE_NUMBER}</strong>. Obrigado.</p>
<p>{{agente}} · ${DEFAULT_COMPANY_NAME}</p>
`),
},
@ -67,7 +68,7 @@ const DEFAULT_CLOSING_TEMPLATES: ClosingTemplate[] = [
body: sanitizeTemplate(`
<p>Prezado(a) {{cliente}},</p>
<p>Esse ticket está sendo encerrado pois realizamos 3 tentativas sem retorno.</p>
<p>Você pode reabrir este ticket em até 7 dias ou entrar em contato pelo telefone <strong>${DEFAULT_PHONE_NUMBER}</strong> quando preferir.</p>
<p>Você pode reabrir este ticket em até 14 dias ou entrar em contato pelo telefone <strong>${DEFAULT_PHONE_NUMBER}</strong> quando preferir.</p>
<p>{{agente}} · ${DEFAULT_COMPANY_NAME}</p>
`),
},
@ -105,6 +106,7 @@ export function CloseTicketDialog({
ticketId,
tenantId,
actorId,
ticketReference,
requesterName,
agentName,
onSuccess,
@ -117,6 +119,7 @@ export function CloseTicketDialog({
ticketId: string
tenantId: string
actorId: Id<"users"> | null
ticketReference?: number | null
requesterName?: string | null
agentName?: string | null
onSuccess: () => void
@ -128,7 +131,7 @@ export function CloseTicketDialog({
onWorkSummaryAdjusted?: (result: AdjustWorkSummaryResult) => void
canAdjustTime?: boolean
}) {
const updateStatus = useMutation(api.tickets.updateStatus)
const resolveTicketMutation = useMutation(api.tickets.resolveTicket)
const addComment = useMutation(api.tickets.addComment)
const adjustWorkSummary = useMutation(api.tickets.adjustWorkSummary)
@ -160,6 +163,24 @@ export function CloseTicketDialog({
const [externalMinutes, setExternalMinutes] = useState<string>("0")
const [adjustReason, setAdjustReason] = useState<string>("")
const enableAdjustment = Boolean(canAdjustTime && workSummary)
const [linkedReference, setLinkedReference] = useState<string>("")
const [reopenWindowDays, setReopenWindowDays] = useState<string>("14")
const normalizedReference = useMemo(() => {
const digits = linkedReference.replace(/[^0-9]/g, "").trim()
if (!digits) return null
const parsed = Number(digits)
if (!Number.isFinite(parsed) || parsed <= 0) return null
if (ticketReference && parsed === ticketReference) return null
return parsed
}, [linkedReference, ticketReference])
const linkedTicket = useQuery(
api.tickets.findByReference,
actorId && normalizedReference ? { tenantId, viewerId: actorId, reference: normalizedReference } : "skip"
) as { id: Id<"tickets">; reference: number; subject: string; status: string } | null | undefined
const isLinkLoading = Boolean(actorId && normalizedReference && linkedTicket === undefined)
const linkNotFound = Boolean(normalizedReference && linkedTicket === null && !isLinkLoading)
const hydrateTemplateBody = useCallback((templateHtml: string) => {
const withPlaceholders = applyTemplatePlaceholders(templateHtml, requesterName, agentName)
@ -269,6 +290,21 @@ export function CloseTicketDialog({
setIsSubmitting(true)
toast.loading(applyAdjustment ? "Ajustando tempo e encerrando ticket..." : "Encerrando ticket...", { id: "close-ticket" })
try {
if (linkedReference.trim().length > 0) {
if (isLinkLoading) {
toast.error("Aguarde carregar o ticket vinculado antes de encerrar.", { id: "close-ticket" })
setIsSubmitting(false)
return
}
if (linkNotFound || !linkedTicket) {
toast.error("Não encontramos o ticket informado para vincular. Verifique o número e tente novamente.", {
id: "close-ticket",
})
setIsSubmitting(false)
return
}
}
if (applyAdjustment) {
const result = (await adjustWorkSummary({
ticketId: ticketId as unknown as Id<"tickets">,
@ -280,7 +316,13 @@ export function CloseTicketDialog({
onWorkSummaryAdjusted?.(result)
}
await updateStatus({ ticketId: ticketId as unknown as Id<"tickets">, status: "RESOLVED", actorId })
const reopenDaysNumber = Number(reopenWindowDays)
await resolveTicketMutation({
ticketId: ticketId as unknown as Id<"tickets">,
actorId,
resolvedWithTicketId: linkedTicket ? (linkedTicket.id as Id<"tickets">) : undefined,
reopenWindowDays: Number.isFinite(reopenDaysNumber) ? reopenDaysNumber : undefined,
})
const withPlaceholders = applyTemplatePlaceholders(message, requesterName, agentName)
const sanitized = stripLeadingEmptyParagraphs(sanitizeEditorHtml(withPlaceholders))
const hasContent = sanitized.replace(/<[^>]*>/g, "").trim().length > 0
@ -351,6 +393,50 @@ export function CloseTicketDialog({
/>
<p className="text-xs text-neutral-500">Você pode editar o conteúdo antes de enviar. Deixe em branco para encerrar sem comentário adicional.</p>
</div>
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-4">
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_220px]">
<div className="space-y-2">
<Label htmlFor="linked-reference" className="text-sm font-medium text-neutral-800">
Ticket relacionado (opcional)
</Label>
<Input
id="linked-reference"
value={linkedReference}
onChange={(event) => setLinkedReference(event.target.value)}
placeholder="Número do ticket relacionado (ex.: 12345)"
disabled={isSubmitting}
/>
{linkedReference.trim().length === 0 ? (
<p className="text-xs text-neutral-500">Informe o número de outro ticket quando o atendimento estiver relacionado.</p>
) : isLinkLoading ? (
<p className="flex items-center gap-2 text-xs text-neutral-500">
<Spinner className="size-3" /> Procurando ticket #{normalizedReference}...
</p>
) : linkNotFound ? (
<p className="text-xs text-red-500">Ticket não encontrado ou sem acesso permitido. Verifique o número informado.</p>
) : linkedTicket ? (
<p className="text-xs text-emerald-600">
Será registrado vínculo com o ticket #{linkedTicket.reference} {linkedTicket.subject ?? "Sem assunto"}
</p>
) : null}
</div>
<div className="space-y-2">
<Label htmlFor="reopen-window" className="text-sm font-medium text-neutral-800">
Reabertura permitida
</Label>
<Select value={reopenWindowDays} onValueChange={setReopenWindowDays} disabled={isSubmitting}>
<SelectTrigger id="reopen-window">
<SelectValue placeholder="Escolha o prazo" />
</SelectTrigger>
<SelectContent>
<SelectItem value="7">7 dias</SelectItem>
<SelectItem value="14">14 dias</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-neutral-500">Após esse período o ticket não poderá ser reaberto automaticamente.</p>
</div>
</div>
</div>
</div>
{enableAdjustment ? (
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-4">

View file

@ -90,6 +90,23 @@ function RequesterPreview({ customer, company }: RequesterPreviewProps) {
const NO_COMPANY_VALUE = "__no_company__"
type TicketFormFieldDefinition = {
id: string
key: string
label: string
type: string
required: boolean
description: string
options: Array<{ value: string; label: string }>
}
type TicketFormDefinition = {
key: string
label: string
description: string
fields: TicketFormFieldDefinition[]
}
const schema = z.object({
subject: z.string().default(""),
summary: z.string().optional(),
@ -158,6 +175,41 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
[companiesRemote]
)
const formsRemote = useQuery(
api.tickets.listTicketForms,
convexUserId ? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users"> } : "skip"
) as TicketFormDefinition[] | undefined
const forms = useMemo<TicketFormDefinition[]>(() => {
const base: TicketFormDefinition = {
key: "default",
label: "Chamado padrão",
description: "Formulário básico para abertura de chamados gerais.",
fields: [],
}
if (Array.isArray(formsRemote) && formsRemote.length > 0) {
return [base, ...formsRemote]
}
return [base]
}, [formsRemote])
const [selectedFormKey, setSelectedFormKey] = useState<string>("default")
const [customFieldValues, setCustomFieldValues] = useState<Record<string, unknown>>({})
const selectedForm = useMemo(() => forms.find((formDef) => formDef.key === selectedFormKey) ?? forms[0], [forms, selectedFormKey])
const handleFormSelection = (key: string) => {
setSelectedFormKey(key)
setCustomFieldValues({})
}
const handleCustomFieldChange = (field: TicketFormFieldDefinition, value: unknown) => {
setCustomFieldValues((prev) => ({
...prev,
[field.id]: value,
}))
}
const customersRemote = useQuery(
api.users.listCustomers,
directoryQueryEnabled ? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users"> } : "skip"
@ -395,6 +447,52 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
return
}
let customFieldsPayload: Array<{ fieldId: Id<"ticketFields">; value: unknown }> = []
if (selectedFormKey !== "default" && selectedForm?.fields?.length) {
for (const field of selectedForm.fields) {
const raw = customFieldValues[field.id]
const isBooleanField = field.type === "boolean"
const isEmpty =
raw === undefined ||
raw === null ||
(typeof raw === "string" && raw.trim().length === 0)
if (isBooleanField) {
const boolValue = Boolean(raw)
customFieldsPayload.push({ fieldId: field.id as Id<"ticketFields">, value: boolValue })
continue
}
if (field.required && isEmpty) {
toast.error(`Preencha o campo "${field.label}".`, { id: "new-ticket" })
setLoading(false)
return
}
if (isEmpty) {
continue
}
let value: unknown = raw
if (field.type === "number") {
const parsed = typeof raw === "number" ? raw : Number(raw)
if (!Number.isFinite(parsed)) {
toast.error(`Informe um valor numérico válido para "${field.label}".`, { id: "new-ticket" })
setLoading(false)
return
}
value = parsed
} else if (field.type === "boolean") {
value = Boolean(raw)
} else if (field.type === "date") {
value = String(raw)
} else {
value = String(raw)
}
customFieldsPayload.push({ fieldId: field.id as Id<"ticketFields">, value })
}
}
setLoading(true)
toast.loading("Criando ticket…", { id: "new-ticket" })
try {
@ -413,6 +511,8 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
assigneeId: selectedAssignee ? (selectedAssignee as Id<"users">) : undefined,
categoryId: values.categoryId as Id<"ticketCategories">,
subcategoryId: values.subcategoryId as Id<"ticketSubcategories">,
formTemplate: selectedFormKey !== "default" ? selectedFormKey : undefined,
customFields: customFieldsPayload.length > 0 ? customFieldsPayload : undefined,
})
const summaryFallback = values.summary?.trim() ?? ""
const bodyHtml = plainDescription.length > 0 ? sanitizedDescription : summaryFallback
@ -446,6 +546,8 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
subcategoryId: "",
})
form.clearErrors()
setSelectedFormKey("default")
setCustomFieldValues({})
setAssigneeInitialized(false)
setAttachments([])
// Navegar para o ticket recém-criado
@ -497,6 +599,28 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
</Button>
</div>
</div>
{forms.length > 1 ? (
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-4">
<p className="text-sm font-semibold text-neutral-800">Modelo de ticket</p>
<div className="mt-2 flex flex-wrap gap-2">
{forms.map((formDef) => (
<Button
key={formDef.key}
type="button"
variant={selectedFormKey === formDef.key ? "default" : "outline"}
size="sm"
onClick={() => handleFormSelection(formDef.key)}
>
{formDef.label}
</Button>
))}
</div>
{selectedForm?.description ? (
<p className="mt-2 text-xs text-neutral-500">{selectedForm.description}</p>
) : null}
</div>
) : null}
<FieldSet>
<FieldGroup className="lg:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)]">
<div className="space-y-4">
@ -811,6 +935,118 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
</Select>
</Field>
</div>
{selectedFormKey !== "default" && selectedForm.fields.length > 0 ? (
<div className="space-y-4 rounded-xl border border-slate-200 bg-white px-4 py-4">
<p className="text-sm font-semibold text-neutral-800">Informações adicionais</p>
{selectedForm.fields.map((field) => {
const value = customFieldValues[field.id]
const fieldId = `custom-field-${field.id}`
const labelSuffix = field.required ? <span className="text-destructive">*</span> : null
const helpText = field.description ? (
<p className="text-xs text-neutral-500">{field.description}</p>
) : null
if (field.type === "boolean") {
return (
<div
key={field.id}
className="flex items-center gap-3 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2"
>
<input
id={fieldId}
type="checkbox"
className="size-4 rounded border border-slate-300 text-[#00d6eb] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#00d6eb]/40"
checked={Boolean(value)}
onChange={(event) => handleCustomFieldChange(field, event.target.checked)}
/>
<div className="flex flex-col">
<label htmlFor={fieldId} className="text-sm font-medium text-neutral-800">
{field.label} {labelSuffix}
</label>
{helpText}
</div>
</div>
)
}
if (field.type === "select") {
return (
<Field key={field.id}>
<FieldLabel className="flex items-center gap-1">
{field.label} {labelSuffix}
</FieldLabel>
<Select
value={typeof value === "string" ? value : ""}
onValueChange={(selected) => handleCustomFieldChange(field, selected)}
>
<SelectTrigger className={selectTriggerClass}>
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent className="rounded-xl border border-slate-200 bg-white text-neutral-800 shadow-md">
{field.options.map((option) => (
<SelectItem key={option.value} value={option.value} className={selectItemClass}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{helpText}
</Field>
)
}
if (field.type === "number") {
return (
<Field key={field.id}>
<FieldLabel className="flex items-center gap-1">
{field.label} {labelSuffix}
</FieldLabel>
<Input
id={fieldId}
type="number"
inputMode="decimal"
value={typeof value === "number" || typeof value === "string" ? String(value) : ""}
onChange={(event) => handleCustomFieldChange(field, event.target.value)}
/>
{helpText}
</Field>
)
}
if (field.type === "date") {
return (
<Field key={field.id}>
<FieldLabel className="flex items-center gap-1">
{field.label} {labelSuffix}
</FieldLabel>
<Input
id={fieldId}
type="date"
value={typeof value === "string" ? value : ""}
onChange={(event) => handleCustomFieldChange(field, event.target.value)}
/>
{helpText}
</Field>
)
}
return (
<Field key={field.id}>
<FieldLabel className="flex items-center gap-1">
{field.label} {labelSuffix}
</FieldLabel>
<Input
id={fieldId}
value={typeof value === "string" ? value : ""}
onChange={(event) => handleCustomFieldChange(field, event.target.value)}
/>
{helpText}
</Field>
)
})}
</div>
) : null}
</div>
</FieldGroup>
</FieldSet>

View file

@ -32,6 +32,7 @@ export function StatusSelect({
value,
tenantId,
requesterName,
ticketReference,
showCloseButton = true,
onStatusChange,
}: {
@ -39,6 +40,7 @@ export function StatusSelect({
value: TicketStatus
tenantId: string
requesterName?: string | null
ticketReference?: number | null
showCloseButton?: boolean
onStatusChange?: (next: TicketStatus) => void
}) {
@ -94,6 +96,7 @@ export function StatusSelect({
ticketId={ticketId}
tenantId={tenantId}
actorId={actorId}
ticketReference={ticketReference ?? null}
requesterName={requesterName}
agentName={agentName}
onSuccess={() => {

View file

@ -0,0 +1,203 @@
"use client"
import { useEffect, useMemo, useRef, useState } from "react"
import { useMutation, useQuery } from "convex/react"
import type { Id } from "@/convex/_generated/dataModel"
import { api } from "@/convex/_generated/api"
import { useAuth } from "@/lib/auth-client"
import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Spinner } from "@/components/ui/spinner"
import { cn } from "@/lib/utils"
import { toast } from "sonner"
import { formatDistanceToNowStrict } from "date-fns"
import { ptBR } from "date-fns/locale"
const MAX_MESSAGE_LENGTH = 4000
function formatRelative(timestamp: number) {
try {
return formatDistanceToNowStrict(timestamp, { locale: ptBR, addSuffix: true })
} catch {
return new Date(timestamp).toLocaleString("pt-BR")
}
}
type TicketChatPanelProps = {
ticketId: string
}
export function TicketChatPanel({ ticketId }: TicketChatPanelProps) {
const { convexUserId } = useAuth()
const viewerId = convexUserId ?? null
const chat = useQuery(
api.tickets.listChatMessages,
viewerId ? { ticketId: ticketId as Id<"tickets">, viewerId: viewerId as Id<"users"> } : "skip"
) as
| {
ticketId: string
chatEnabled: boolean
status: string
canPost: boolean
reopenDeadline: number | null
messages: Array<{
id: Id<"ticketChatMessages">
body: string
createdAt: number
updatedAt: number
authorId: string
authorName: string | null
authorEmail: string | null
attachments: Array<{ storageId: Id<"_storage">; name: string; size: number | null; type: string | null }>
readBy: Array<{ userId: string; readAt: number }>
}>
}
| null
| undefined
const markChatRead = useMutation(api.tickets.markChatRead)
const postChatMessage = useMutation(api.tickets.postChatMessage)
const messagesEndRef = useRef<HTMLDivElement | null>(null)
const [draft, setDraft] = useState("")
const [isSending, setIsSending] = useState(false)
const messages = chat?.messages ?? []
const canPost = Boolean(chat?.canPost && viewerId)
const chatEnabled = Boolean(chat?.chatEnabled)
useEffect(() => {
if (!viewerId || !chat || !Array.isArray(chat.messages) || chat.messages.length === 0) return
const unreadIds = chat.messages
.filter((message) => {
const alreadyRead = (message.readBy ?? []).some((entry) => entry.userId === viewerId)
return !alreadyRead
})
.map((message) => message.id)
if (unreadIds.length === 0) return
void markChatRead({
ticketId: ticketId as Id<"tickets">,
actorId: viewerId as Id<"users">,
messageIds: unreadIds,
}).catch((error) => {
console.error("Failed to mark chat messages as read", error)
})
}, [markChatRead, chat, ticketId, viewerId])
useEffect(() => {
if (messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
}
}, [messages.length])
const disabledReason = useMemo(() => {
if (!chatEnabled) return "Chat desativado para este ticket"
if (!canPost) return "Você não tem permissão para enviar mensagens"
return null
}, [canPost, chatEnabled])
const handleSend = async () => {
if (!viewerId || !canPost || draft.trim().length === 0) return
if (draft.length > MAX_MESSAGE_LENGTH) {
toast.error(`Mensagem muito longa (máx. ${MAX_MESSAGE_LENGTH} caracteres).`)
return
}
setIsSending(true)
toast.dismiss("ticket-chat")
toast.loading("Enviando mensagem...", { id: "ticket-chat" })
try {
await postChatMessage({
ticketId: ticketId as Id<"tickets">,
actorId: viewerId as Id<"users">,
body: draft,
})
setDraft("")
toast.success("Mensagem enviada!", { id: "ticket-chat" })
} catch (error) {
console.error(error)
toast.error("Não foi possível enviar a mensagem.", { id: "ticket-chat" })
} finally {
setIsSending(false)
}
}
if (!viewerId) {
return null
}
return (
<Card className="border-slate-200">
<CardHeader className="flex flex-row items-center justify-between gap-2">
<CardTitle className="text-base font-semibold text-neutral-800">Chat do atendimento</CardTitle>
{!chatEnabled ? (
<span className="text-xs font-medium text-neutral-500">Chat desativado</span>
) : null}
</CardHeader>
<CardContent className="space-y-4">
{chat === undefined ? (
<div className="flex items-center justify-center gap-2 rounded-lg border border-dashed border-slate-200 py-6 text-sm text-neutral-500">
<Spinner className="size-4" /> Carregando mensagens...
</div>
) : messages.length === 0 ? (
<div className="rounded-lg border border-dashed border-slate-200 bg-slate-50 px-4 py-6 text-center text-sm text-neutral-500">
Nenhuma mensagem registrada no chat até o momento.
</div>
) : (
<div className="max-h-72 space-y-3 overflow-y-auto pr-2">
{messages.map((message) => {
const isOwn = String(message.authorId) === String(viewerId)
return (
<div
key={message.id}
className={cn(
"flex flex-col gap-1 rounded-lg border px-3 py-2 text-sm",
isOwn ? "border-slate-300 bg-slate-50" : "border-slate-200 bg-white"
)}
>
<div className="flex items-center justify-between gap-3">
<span className="font-semibold text-neutral-800">{message.authorName ?? "Usuário"}</span>
<span className="text-xs text-neutral-500">{formatRelative(message.createdAt)}</span>
</div>
<div
className="prose prose-sm max-w-none text-neutral-700"
dangerouslySetInnerHTML={{ __html: message.body }}
/>
</div>
)
})}
<div ref={messagesEndRef} />
</div>
)}
<div className="space-y-2">
<Textarea
value={draft}
onChange={(event) => setDraft(event.target.value)}
placeholder={disabledReason ?? "Digite uma mensagem"}
rows={3}
disabled={isSending || !canPost || !chatEnabled}
/>
<div className="flex items-center justify-between text-xs text-neutral-500">
<span>{draft.length}/{MAX_MESSAGE_LENGTH}</span>
<div className="inline-flex items-center gap-2">
{!chatEnabled ? (
<span className="text-neutral-500">Chat indisponível</span>
) : null}
<Button
type="button"
size="sm"
onClick={handleSend}
disabled={isSending || !canPost || !chatEnabled || draft.trim().length === 0}
>
{isSending ? <Spinner className="mr-2 size-4" /> : null}
Enviar
</Button>
</div>
</div>
{disabledReason && chatEnabled ? (
<p className="text-xs text-neutral-500">{disabledReason}</p>
) : null}
</div>
</CardContent>
</Card>
)
}

View file

@ -0,0 +1,234 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { useMutation } from "convex/react"
import { useRouter } from "next/navigation"
import { Star } from "lucide-react"
import { formatDistanceToNowStrict } from "date-fns"
import { ptBR } from "date-fns/locale"
import { toast } from "sonner"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { useAuth } from "@/lib/auth-client"
import type { TicketWithDetails } from "@/lib/schemas/ticket"
import { cn } from "@/lib/utils"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
type TicketCsatCardProps = {
ticket: TicketWithDetails
}
function formatRelative(timestamp: Date | null | undefined) {
if (!timestamp) return null
try {
return formatDistanceToNowStrict(timestamp, { locale: ptBR, addSuffix: true })
} catch {
return null
}
}
export function TicketCsatCard({ ticket }: TicketCsatCardProps) {
const router = useRouter()
const { session, convexUserId, role: authRole } = useAuth()
const submitCsat = useMutation(api.tickets.submitCsat)
const viewerRole = (authRole ?? session?.user.role ?? "").toUpperCase()
const viewerEmail = session?.user.email?.trim().toLowerCase() ?? ""
const viewerId = convexUserId as Id<"users"> | undefined
const requesterEmail = ticket.requester.email.trim().toLowerCase()
const isRequesterById = viewerId ? ticket.requester.id === viewerId : false
const isRequesterByEmail = viewerEmail && requesterEmail ? viewerEmail === requesterEmail : false
const isRequester = isRequesterById || isRequesterByEmail
const isResolved = ticket.status === "RESOLVED"
const initialScore = typeof ticket.csatScore === "number" ? ticket.csatScore : 0
const initialComment = ticket.csatComment ?? ""
const maxScore = typeof ticket.csatMaxScore === "number" && ticket.csatMaxScore > 0 ? ticket.csatMaxScore : 5
const [score, setScore] = useState<number>(initialScore)
const [comment, setComment] = useState<string>(initialComment)
const [hasSubmitted, setHasSubmitted] = useState<boolean>(initialScore > 0)
const [ratedAt, setRatedAt] = useState<Date | null>(ticket.csatRatedAt ?? null)
const [hoverScore, setHoverScore] = useState<number | null>(null)
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
setScore(initialScore)
setComment(initialComment)
setRatedAt(ticket.csatRatedAt ?? null)
setHasSubmitted(initialScore > 0)
}, [initialScore, initialComment, ticket.csatRatedAt])
const effectiveScore = hasSubmitted ? score : hoverScore ?? score
const viewerIsStaff = viewerRole === "ADMIN" || viewerRole === "AGENT" || viewerRole === "MANAGER"
const staffCanInspect = viewerIsStaff && ticket.status !== "PENDING"
const canSubmit =
Boolean(viewerId && viewerRole === "COLLABORATOR" && isRequester && isResolved && !hasSubmitted)
const hasRating = hasSubmitted
const showCard = staffCanInspect || isRequester || hasSubmitted
const ratedAtRelative = useMemo(() => formatRelative(ratedAt), [ratedAt])
if (!showCard) {
return null
}
const handleSubmit = async () => {
if (!viewerId) {
toast.error("Sessão não autenticada.")
return
}
if (!canSubmit) {
toast.error("Você não pode avaliar este chamado.")
return
}
if (score < 1) {
toast.error("Selecione uma nota de 1 a 5 estrelas.")
return
}
if (comment.length > 2000) {
toast.error("Reduza o comentário para no máximo 2000 caracteres.")
return
}
try {
setSubmitting(true)
const result = await submitCsat({
ticketId: ticket.id as Id<"tickets">,
actorId: viewerId,
score,
maxScore,
comment: comment.trim() ? comment.trim() : undefined,
})
if (result?.score) {
setScore(result.score)
}
if (typeof result?.comment === "string") {
setComment(result.comment)
}
if (result?.ratedAt) {
const ratedAtDate = new Date(result.ratedAt)
if (!Number.isNaN(ratedAtDate.getTime())) {
setRatedAt(ratedAtDate)
}
}
setHasSubmitted(true)
toast.success("Avaliação registrada. Obrigado pelo feedback!")
router.refresh()
} catch (error) {
console.error("Failed to submit CSAT", error)
toast.error("Não foi possível registrar a avaliação. Tente novamente.")
} finally {
setSubmitting(false)
setHoverScore(null)
}
}
const stars = Array.from({ length: maxScore }, (_, index) => index + 1)
return (
<Card className="rounded-2xl border border-slate-200 bg-gradient-to-br from-white via-white to-slate-50 shadow-sm">
<CardHeader className="px-4 pt-5 pb-3">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<CardTitle className="text-lg font-semibold text-neutral-900">Avaliação do atendimento</CardTitle>
<CardDescription className="text-sm text-neutral-600">
Conte como foi sua experiência com este chamado.
</CardDescription>
</div>
{hasRating ? (
<div className="flex items-center gap-1 rounded-full bg-emerald-50 px-3 py-1 text-xs font-medium text-emerald-700">
Obrigado pelo feedback!
</div>
) : null}
</div>
</CardHeader>
<CardContent className="space-y-4 px-4 pb-2">
<div className="flex flex-wrap items-center gap-2">
{stars.map((value) => {
const filled = value <= effectiveScore
return (
<button
key={value}
type="button"
className={cn(
"flex size-10 items-center justify-center rounded-full border transition",
canSubmit
? filled
? "border-amber-300 bg-amber-50 text-amber-500 hover:border-amber-400 hover:bg-amber-100"
: "border-slate-200 bg-white text-slate-300 hover:border-amber-200 hover:bg-amber-50 hover:text-amber-400"
: filled
? "border-amber-200 bg-amber-50 text-amber-500"
: "border-slate-200 bg-white text-slate-300"
)}
onMouseEnter={() => (canSubmit ? setHoverScore(value) : undefined)}
onMouseLeave={() => (canSubmit ? setHoverScore(null) : undefined)}
onClick={() => (canSubmit ? setScore(value) : undefined)}
disabled={!canSubmit}
aria-label={`${value} estrela${value > 1 ? "s" : ""}`}
>
<Star
className="size-5"
strokeWidth={1.5}
fill={(canSubmit && value <= (hoverScore ?? score)) || (!canSubmit && value <= score) ? "currentColor" : "none"}
/>
</button>
)
})}
</div>
{hasRating ? (
<p className="text-sm text-neutral-600">
Nota final:{" "}
<span className="font-semibold text-neutral-900">
{score}/{maxScore}
</span>
{ratedAtRelative ? `${ratedAtRelative}` : null}
</p>
) : null}
{canSubmit ? (
<div className="space-y-2">
<label htmlFor="csat-comment" className="text-sm font-medium text-neutral-800">
Deixe um comentário (opcional)
</label>
<Textarea
id="csat-comment"
placeholder="O que funcionou bem? Algo poderia ser melhor?"
value={comment}
onChange={(event) => setComment(event.target.value)}
maxLength={2000}
className="min-h-[90px] resize-y"
/>
<div className="flex justify-end text-xs text-neutral-500">{comment.length}/2000</div>
</div>
) : hasRating && comment ? (
<div className="rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-neutral-700">
<p className="whitespace-pre-line">{comment}</p>
</div>
) : null}
{viewerIsStaff && !hasRating ? (
<p className="rounded-lg border border-dashed border-slate-200 bg-slate-50 px-3 py-2 text-sm text-neutral-500">
Nenhuma avaliação registrada para este chamado até o momento.
</p>
) : null}
{!isResolved && viewerRole === "COLLABORATOR" && isRequester && !hasSubmitted ? (
<p className="rounded-lg border border-dashed border-slate-200 bg-slate-50 px-3 py-2 text-sm text-neutral-500">
Assim que o chamado for encerrado, você poderá registrar sua avaliação aqui.
</p>
) : null}
</CardContent>
{canSubmit ? (
<CardFooter className="flex flex-col gap-2 px-4 pb-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs text-neutral-500">
Sua avaliação ajuda a equipe a melhorar continuamente o atendimento.
</p>
<Button type="button" onClick={handleSubmit} disabled={submitting || score < 1}>
{submitting ? "Enviando..." : "Enviar avaliação"}
</Button>
</CardFooter>
) : null}
</Card>
)
}

View file

@ -12,6 +12,8 @@ import { TicketComments } from "@/components/tickets/ticket-comments.rich";
import { TicketDetailsPanel } from "@/components/tickets/ticket-details-panel";
import { TicketSummaryHeader } from "@/components/tickets/ticket-summary-header";
import { TicketTimeline } from "@/components/tickets/ticket-timeline";
import { TicketCsatCard } from "@/components/tickets/ticket-csat-card";
import { TicketChatPanel } from "@/components/tickets/ticket-chat-panel";
import { useAuth } from "@/lib/auth-client";
export function TicketDetailView({ id }: { id: string }) {
@ -90,9 +92,11 @@ export function TicketDetailView({ id }: { id: string }) {
return (
<div className="flex flex-col gap-6 px-4 lg:px-6">
<TicketSummaryHeader ticket={ticket} />
<TicketCsatCard ticket={ticket} />
<div className="grid gap-6 lg:grid-cols-[2fr_1fr]">
<div className="space-y-6">
<TicketComments ticket={ticket} />
<TicketChatPanel ticketId={ticket.id as string} />
<TicketTimeline ticket={ticket} />
</div>
<TicketDetailsPanel ticket={ticket} />

View file

@ -142,6 +142,22 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
: machineAssignedName && machineAssignedName.length > 0
? machineAssignedName
: null
const viewerId = convexUserId ?? null
const viewerRole = (role ?? "").toLowerCase()
const [status, setStatus] = useState<TicketStatus>(ticket.status)
const reopenDeadline = ticket.reopenDeadline ?? null
const isRequester = Boolean(ticket.requester?.id && viewerId && ticket.requester.id === viewerId)
const reopenWindowActive = reopenDeadline ? reopenDeadline > Date.now() : false
const canReopenTicket =
status === "RESOLVED" && reopenWindowActive && (isStaff || viewerRole === "manager" || isRequester)
const reopenDeadlineLabel = useMemo(() => {
if (!reopenDeadline) return null
try {
return new Date(reopenDeadline).toLocaleString("pt-BR")
} catch {
return null
}
}, [reopenDeadline])
const viewerEmail = session?.user?.email ?? machineContext?.assignedUserEmail ?? null
const viewerAvatar = session?.user?.avatarUrl ?? null
const viewerAgentMeta = useMemo(
@ -165,6 +181,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const startWork = useMutation(api.tickets.startWork)
const pauseWork = useMutation(api.tickets.pauseWork)
const updateCategories = useMutation(api.tickets.updateCategories)
const reopenTicket = useMutation(api.tickets.reopenTicket)
const agents = (useQuery(api.users.listAgents, { tenantId: ticket.tenantId }) as Doc<"users">[] | undefined) ?? []
const queuesEnabled = Boolean(isStaff && convexUserId)
const companiesRemote = useQuery(
@ -227,7 +244,6 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
| null
| undefined
const [status, setStatus] = useState<TicketStatus>(ticket.status)
const [assigneeState, setAssigneeState] = useState(ticket.assignee ?? null)
const [editing, setEditing] = useState(false)
const [subject, setSubject] = useState(ticket.subject)
@ -242,6 +258,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const [assigneeSelection, setAssigneeSelection] = useState(currentAssigneeId)
const [saving, setSaving] = useState(false)
const [pauseDialogOpen, setPauseDialogOpen] = useState(false)
const [isReopening, setIsReopening] = useState(false)
const [pauseReason, setPauseReason] = useState<string>(PAUSE_REASONS[0]?.value ?? "NO_CONTACT")
const [pauseNote, setPauseNote] = useState("")
const [pausing, setPausing] = useState(false)
@ -326,8 +343,8 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const assigneeDirty = useMemo(() => assigneeSelection !== currentAssigneeId, [assigneeSelection, currentAssigneeId])
const requesterDirty = useMemo(() => requesterSelection !== ticket.requester.id, [requesterSelection, ticket.requester.id])
const formDirty = dirty || categoryDirty || queueDirty || assigneeDirty || requesterDirty
const assigneeReasonRequired = assigneeDirty && !isManager
const assigneeReasonValid = !assigneeReasonRequired || assigneeChangeReason.trim().length >= 5
const normalizedAssigneeReason = assigneeChangeReason.trim()
const assigneeReasonValid = normalizedAssigneeReason.length === 0 || normalizedAssigneeReason.length >= 5
const saveDisabled = !formDirty || saving || !assigneeReasonValid
const companyLabel = useMemo(() => {
if (ticket.company?.name) return ticket.company.name
@ -488,9 +505,9 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
throw new Error("assignee-not-allowed")
}
const reasonValue = assigneeChangeReason.trim()
if (reasonValue.length < 5) {
setAssigneeReasonError("Descreva o motivo com pelo menos 5 caracteres.")
toast.error("Informe um motivo para registrar a troca do responsável.", { id: "assignee" })
if (reasonValue.length > 0 && reasonValue.length < 5) {
setAssigneeReasonError("Descreva o motivo com pelo menos 5 caracteres ou deixe em branco.")
toast.error("Informe ao menos 5 caracteres no motivo ou deixe o campo vazio.", { id: "assignee" })
return
}
if (reasonValue.length > 1000) {
@ -505,7 +522,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
ticketId: ticket.id as Id<"tickets">,
assigneeId: assigneeSelection as Id<"users">,
actorId: convexUserId as Id<"users">,
reason: reasonValue,
reason: reasonValue.length > 0 ? reasonValue : undefined,
})
toast.success("Responsável atualizado!", { id: "assignee" })
if (assigneeSelection) {
@ -1008,6 +1025,26 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
}
}, [ticket.id, ticket.reference])
const handleReopenTicket = useCallback(async () => {
if (!viewerId) {
toast.error("Não foi possível identificar o usuário atual.")
return
}
toast.dismiss("ticket-reopen")
setIsReopening(true)
toast.loading("Reabrindo ticket...", { id: "ticket-reopen" })
try {
await reopenTicket({ ticketId: ticket.id as Id<"tickets">, actorId: viewerId as Id<"users"> })
toast.success("Ticket reaberto com sucesso!", { id: "ticket-reopen" })
setStatus("AWAITING_ATTENDANCE")
} catch (error) {
console.error(error)
toast.error("Não foi possível reabrir o ticket.", { id: "ticket-reopen" })
} finally {
setIsReopening(false)
}
}, [reopenTicket, ticket.id, viewerId])
return (
<div className={cardClass}>
<div className="absolute right-6 top-6 flex items-center gap-3">
@ -1065,6 +1102,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
ticketId={ticket.id as unknown as string}
tenantId={ticket.tenantId}
actorId={convexUserId as Id<"users"> | null}
ticketReference={ticket.reference ?? null}
requesterName={ticket.requester?.name ?? ticket.requester?.email ?? null}
agentName={agentName}
workSummary={
@ -1095,9 +1133,26 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
value={status}
tenantId={ticket.tenantId}
requesterName={ticket.requester?.name ?? ticket.requester?.email ?? null}
ticketReference={ticket.reference ?? null}
showCloseButton={false}
onStatusChange={setStatus}
/>
{canReopenTicket ? (
<Button
type="button"
variant="outline"
size="sm"
className="inline-flex items-center gap-2 rounded-lg border border-slate-200 bg-white text-sm font-semibold text-neutral-700 hover:bg-slate-50"
onClick={handleReopenTicket}
disabled={isReopening}
>
{isReopening ? <Spinner className="size-4 text-neutral-600" /> : null}
Reabrir
</Button>
) : null}
{canReopenTicket && reopenDeadlineLabel ? (
<p className="text-xs text-neutral-500">Prazo para reabrir: {reopenDeadlineLabel}</p>
) : null}
{isPlaying ? (
<Tooltip>
<TooltipTrigger asChild>
@ -1427,8 +1482,8 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
</p>
{assigneeReasonError ? (
<p className="text-xs font-semibold text-rose-600">{assigneeReasonError}</p>
) : assigneeReasonRequired && assigneeChangeReason.trim().length < 5 ? (
<p className="text-xs font-semibold text-rose-600">Descreva o motivo com pelo menos 5 caracteres.</p>
) : normalizedAssigneeReason.length > 0 && normalizedAssigneeReason.length < 5 ? (
<p className="text-xs font-semibold text-rose-600">Descreva o motivo com pelo menos 5 caracteres ou deixe em branco.</p>
) : null}
</div>
) : null}

View file

@ -351,17 +351,55 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
message = "CSAT recebido"
}
if (entry.type === "CSAT_RATED") {
const score = typeof payload.score === "number" ? payload.score : payload.rating
const maxScore =
typeof payload.maxScore === "number"
? payload.maxScore
: typeof payload.max === "number"
? payload.max
const rawScoreSource = (payload as { score?: unknown; rating?: unknown }) ?? {}
const rawScore =
typeof rawScoreSource.score === "number"
? rawScoreSource.score
: typeof rawScoreSource.rating === "number"
? rawScoreSource.rating
: null
const rawMaxSource = (payload as { maxScore?: unknown; max?: unknown }) ?? {}
const rawMax =
typeof rawMaxSource.maxScore === "number"
? rawMaxSource.maxScore
: typeof rawMaxSource.max === "number"
? rawMaxSource.max
: undefined
message =
typeof score === "number"
? `CSAT avaliado: ${score}${typeof maxScore === "number" ? `/${maxScore}` : ""}`
: "CSAT avaliado"
const safeMax = rawMax && Number.isFinite(rawMax) && rawMax > 0 ? Math.round(rawMax) : 5
const safeScore =
typeof rawScore === "number" && Number.isFinite(rawScore)
? Math.max(1, Math.min(safeMax, Math.round(rawScore)))
: null
const rawComment = (payload as { comment?: unknown })?.comment
const comment =
typeof rawComment === "string" && rawComment.trim().length > 0
? rawComment.trim()
: null
message = (
<div className="space-y-1">
<span>
CSAT avaliado:{" "}
<span className="font-semibold text-neutral-900">
{safeScore ?? "—"}/{safeMax}
</span>
</span>
<div className="flex items-center gap-1 text-amber-500">
{Array.from({ length: safeMax }).map((_, index) => (
<IconStar
key={index}
className="size-3.5"
strokeWidth={1.5}
fill={safeScore !== null && index < safeScore ? "currentColor" : "none"}
/>
))}
</div>
{comment ? (
<span className="block rounded-lg bg-slate-100 px-3 py-1 text-xs text-neutral-600">
{comment}
</span>
) : null}
</div>
)
}
if (!message) return null

View file

@ -104,28 +104,30 @@ ${colorConfig
const ChartTooltip = RechartsPrimitive.Tooltip
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}) {
label,
labelFormatter,
labelClassName,
formatter,
valueFormatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
valueFormatter?: (value: unknown, name?: string, item?: unknown) => React.ReactNode
}) {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
@ -234,11 +236,23 @@ function ChartTooltipContent({
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
{item.value !== undefined && item.value !== null && (
<span className="text-foreground font-mono font-medium tabular-nums">
{valueFormatter
? valueFormatter(
item.value,
item.name !== undefined && item.name !== null
? String(item.name)
: item.dataKey !== undefined && item.dataKey !== null
? String(item.dataKey)
: undefined,
item
)
: typeof item.value === "number"
? item.value.toLocaleString()
: String(item.value)}
</span>
)}
</div>
</>
)}