Ajusta placeholders, formulários e widgets

This commit is contained in:
Esdras Renan 2025-11-06 23:13:41 -03:00
parent 343f0c8c64
commit b94cea2f9a
33 changed files with 2122 additions and 462 deletions

View file

@ -1,5 +1,6 @@
import { CategoriesManager } from "@/components/admin/categories/categories-manager"
import { FieldsManager } from "@/components/admin/fields/fields-manager"
import { TicketFormTemplatesManager } from "@/components/admin/fields/ticket-form-templates-manager"
import { AppShell } from "@/components/app-shell"
import { SiteHeader } from "@/components/site-header"
@ -17,6 +18,7 @@ export default function AdminFieldsPage() {
>
<div className="mx-auto w-full max-w-6xl space-y-8 px-6 lg:px-8">
<CategoriesManager />
<TicketFormTemplatesManager />
<FieldsManager />
</div>
</AppShell>

View file

@ -1,5 +1,5 @@
import { AppShell } from "@/components/app-shell"
import { SiteHeader, SiteHeaderPrimaryButton, SiteHeaderSecondaryButton } from "@/components/site-header"
import { SiteHeader, SiteHeaderPrimaryButton } from "@/components/site-header"
import { PlayNextTicketCard } from "@/components/tickets/play-next-ticket-card"
import { TicketQueueSummaryCards } from "@/components/tickets/ticket-queue-summary"
import { requireAuthenticatedSession } from "@/lib/auth-server"
@ -12,7 +12,6 @@ export default async function PlayPage() {
<SiteHeader
title="Modo play"
lead="Distribua tickets automaticamente conforme prioridade"
secondaryAction={<SiteHeaderSecondaryButton>Pausar notificações</SiteHeaderSecondaryButton>}
primaryAction={<SiteHeaderPrimaryButton>Iniciar sessão</SiteHeaderPrimaryButton>}
/>
}

View file

@ -0,0 +1,25 @@
import { AppShell } from "@/components/app-shell"
import { CategoryReport } from "@/components/reports/category-report"
import { SiteHeader } from "@/components/site-header"
import { requireAuthenticatedSession } from "@/lib/auth-server"
export const dynamic = "force-dynamic"
export default async function ReportsCategoriesPage() {
await requireAuthenticatedSession()
return (
<AppShell
header={
<SiteHeader
title="Categorias e agentes"
lead="Acompanhe os temas mais atendidos e descubra quais agentes concentram cada tipo de solicitação."
/>
}
>
<div className="mx-auto w-full max-w-6xl px-4 pb-12 lg:px-6">
<CategoryReport />
</div>
</AppShell>
)
}

View file

@ -36,7 +36,6 @@ export function TicketsPageClient() {
<SiteHeader
title="Tickets"
lead="Visão consolidada de filas e SLAs"
primaryAction={<SiteHeader.SecondaryButton>Exportar CSV</SiteHeader.SecondaryButton>}
secondaryAction={<NewTicketDialog />}
/>
}

View file

@ -1,6 +1,6 @@
"use client"
import { useCallback, useEffect, useMemo, useState, useTransition } from "react"
import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react"
import { Controller, FormProvider, useFieldArray, useForm, type UseFormReturn } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import {
@ -80,6 +80,7 @@ import { Textarea } from "@/components/ui/textarea"
import { TimePicker } from "@/components/ui/time-picker"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Skeleton } from "@/components/ui/skeleton"
import { useQuery, useMutation } from "convex/react"
import { useAuth } from "@/lib/auth-client"
import type { Id } from "@/convex/_generated/dataModel"
@ -1691,6 +1692,7 @@ function CompanySheet({ tenantId, editor, onClose, onCreated, onUpdated }: Compa
<AccordionTrigger className="py-3 font-semibold">Tipos de solicitação</AccordionTrigger>
<AccordionContent className="pb-5">
<CompanyRequestTypesControls tenantId={tenantId} companyId={editor?.mode === "edit" ? editor.company.id : null} />
<CompanyExportTemplateSelector tenantId={tenantId} companyId={editor?.mode === "edit" ? editor.company.id : null} />
</AccordionContent>
</AccordionItem>
@ -2187,13 +2189,28 @@ type CompanyRequestTypesControlsProps = { tenantId?: string | null; companyId: s
function CompanyRequestTypesControls({ tenantId, companyId }: CompanyRequestTypesControlsProps) {
const { convexUserId } = useAuth()
const canLoad = Boolean(tenantId && convexUserId)
const ensureDefaults = useMutation(api.tickets.ensureTicketFormDefaults)
const hasEnsuredRef = useRef(false)
const settings = useQuery(
api.ticketFormSettings.list,
canLoad ? { tenantId: tenantId as string, viewerId: convexUserId as Id<"users"> } : "skip"
) as Array<{ template: string; scope: string; companyId?: string | null; enabled: boolean; updatedAt: number }> | undefined
const templates = useQuery(
api.ticketFormTemplates.listActive,
canLoad ? { tenantId: tenantId as string, viewerId: convexUserId as Id<"users"> } : "skip"
) as Array<{ key: string; label: string }> | undefined
const upsert = useMutation(api.ticketFormSettings.upsert)
const resolveEnabled = (template: "admissao" | "desligamento") => {
useEffect(() => {
if (!tenantId || !convexUserId || hasEnsuredRef.current) return
hasEnsuredRef.current = true
ensureDefaults({ tenantId, actorId: convexUserId as Id<"users"> }).catch((error) => {
console.error("Falha ao garantir formulários padrão", error)
hasEnsuredRef.current = false
})
}, [ensureDefaults, tenantId, convexUserId])
const resolveEnabled = (template: string) => {
const scoped = (settings ?? []).filter((s) => s.template === template)
const base = true
if (!companyId) return base
@ -2203,10 +2220,7 @@ function CompanyRequestTypesControls({ tenantId, companyId }: CompanyRequestType
return typeof latest?.enabled === "boolean" ? latest.enabled : base
}
const admissaoEnabled = resolveEnabled("admissao")
const desligamentoEnabled = resolveEnabled("desligamento")
const handleToggle = async (template: "admissao" | "desligamento", enabled: boolean) => {
const handleToggle = async (template: string, enabled: boolean) => {
if (!tenantId || !convexUserId || !companyId) return
try {
await upsert({
@ -2227,24 +2241,113 @@ function CompanyRequestTypesControls({ tenantId, companyId }: CompanyRequestType
return (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">Defina quais tipos de solicitação estão disponíveis para colaboradores/gestores desta empresa. Administradores e agentes sempre veem todas as opções.</p>
<div className="grid gap-3 sm:grid-cols-2">
<label className="flex items-center gap-2 text-sm text-foreground">
<Checkbox
checked={admissaoEnabled}
onCheckedChange={(v) => handleToggle("admissao", Boolean(v))}
disabled={!companyId}
/>
<span>Admissão de colaborador</span>
</label>
<label className="flex items-center gap-2 text-sm text-foreground">
<Checkbox
checked={desligamentoEnabled}
onCheckedChange={(v) => handleToggle("desligamento", Boolean(v))}
disabled={!companyId}
/>
<span>Desligamento de colaborador</span>
</label>
</div>
{!templates ? (
<div className="space-y-2">
<Skeleton className="h-10 w-full rounded-lg" />
<Skeleton className="h-10 w-full rounded-lg" />
</div>
) : templates.length === 0 ? (
<p className="text-sm text-neutral-500">Nenhum formulário disponível.</p>
) : (
<div className="grid gap-3 sm:grid-cols-2">
{templates.map((template) => {
const enabled = resolveEnabled(template.key)
return (
<label key={template.key} className="flex items-center gap-2 text-sm text-foreground">
<Checkbox
checked={enabled}
onCheckedChange={(v) => handleToggle(template.key, Boolean(v))}
disabled={!companyId}
/>
<span>{template.label}</span>
</label>
)
})}
</div>
)}
</div>
)
}
type CompanyExportTemplateSelectorProps = { tenantId?: string | null; companyId: string | null }
function CompanyExportTemplateSelector({ tenantId, companyId }: CompanyExportTemplateSelectorProps) {
const { convexUserId } = useAuth()
const canLoad = Boolean(tenantId && convexUserId)
const templates = useQuery(
api.deviceExportTemplates.list,
canLoad
? {
tenantId: tenantId as string,
viewerId: convexUserId as Id<"users">,
companyId: companyId ? (companyId as unknown as Id<"companies">) : undefined,
includeInactive: true,
}
: "skip"
) as Array<{ id: string; name: string; companyId: string | null; isDefault: boolean; description?: string }> | undefined
const setDefaultTemplate = useMutation(api.deviceExportTemplates.setDefault)
const clearDefaultTemplate = useMutation(api.deviceExportTemplates.clearCompanyDefault)
const companyTemplates = useMemo(() => {
if (!templates || !companyId) return []
return templates.filter((tpl) => String(tpl.companyId ?? "") === String(companyId))
}, [templates, companyId])
const companyDefault = useMemo(() => companyTemplates.find((tpl) => tpl.isDefault) ?? null, [companyTemplates])
const handleChange = async (value: string) => {
if (!tenantId || !convexUserId || !companyId) return
try {
if (value === "inherit") {
await clearDefaultTemplate({
tenantId,
actorId: convexUserId as Id<"users">,
companyId: companyId as unknown as Id<"companies">,
})
toast.success("Template desta empresa voltou a herdar o padrão global.")
} else {
await setDefaultTemplate({
tenantId,
actorId: convexUserId as Id<"users">,
templateId: value as Id<"deviceExportTemplates">,
})
toast.success("Template aplicado para esta empresa.")
}
} catch (error) {
console.error("Falha ao definir template de exportação", error)
toast.error("Não foi possível atualizar o template.")
}
}
const selectValue = companyDefault ? companyDefault.id : "inherit"
return (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Defina o template padrão das exportações de inventário para esta empresa. Ao herdar, o template global será utilizado.
</p>
{!companyId ? (
<p className="text-xs text-neutral-500">Salve a empresa antes de configurar o template.</p>
) : !templates ? (
<Skeleton className="h-10 w-full rounded-md" />
) : companyTemplates.length === 0 ? (
<p className="text-xs text-neutral-500">
Nenhum template específico para esta empresa. Crie um template em <span className="font-semibold">Dispositivos &gt; Exportações</span> e associe a esta empresa para habilitar aqui.
</p>
) : (
<Select value={selectValue} onValueChange={handleChange} disabled={!companyId}>
<SelectTrigger>
<SelectValue placeholder="Herdar template global" />
</SelectTrigger>
<SelectContent>
<SelectItem value="inherit">Herdar template global</SelectItem>
{companyTemplates.map((tpl) => (
<SelectItem key={tpl.id} value={tpl.id}>
{tpl.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
)
}

View file

@ -2441,6 +2441,9 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
const effectiveStatus = device ? resolveDeviceStatus(device) : "unknown"
const [isActiveLocal, setIsActiveLocal] = useState<boolean>(device?.isActive ?? true)
const isDeactivated = !isActiveLocal || effectiveStatus === "deactivated"
const isManualMobile =
(device?.managementMode ?? "").toLowerCase() === "manual" &&
(device?.deviceType ?? "").toLowerCase() === "mobile"
const alertsHistory = useQuery(
api.devices.listAlerts,
device ? { machineId: device.id as Id<"machines">, limit: 50 } : "skip"
@ -3578,6 +3581,11 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
<h1 className="break-words text-2xl font-semibold text-neutral-900">
{device.displayName ?? device.hostname ?? "Dispositivo"}
</h1>
{isManualMobile ? (
<span className="rounded-full border border-slate-200 bg-slate-50 px-2 py-0.5 text-xs font-semibold uppercase tracking-wide text-neutral-600">
Identificação interna
</span>
) : null}
<Button
size="icon"
variant="ghost"
@ -3723,29 +3731,33 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
<ShieldCheck className="size-4" />
Ajustar acesso
</Button>
<Button
size="sm"
variant="outline"
className="gap-2 border-dashed border-amber-300 text-amber-700 hover:border-amber-400 hover:text-amber-800"
onClick={handleResetAgent}
disabled={isResettingAgent}
>
<RefreshCcw className={cn("size-4", isResettingAgent && "animate-spin")} />
{isResettingAgent ? "Resetando agente..." : "Resetar agente"}
</Button>
<Button
size="sm"
variant={isActiveLocal ? "outline" : "default"}
className={cn(
"gap-2 border-dashed",
!isActiveLocal && "bg-emerald-600 text-white hover:bg-emerald-600/90"
)}
onClick={handleToggleActive}
disabled={togglingActive}
>
{isActiveLocal ? <Power className="size-4" /> : <PlayCircle className="size-4" />}
{isActiveLocal ? (togglingActive ? "Desativando..." : "Desativar") : togglingActive ? "Reativando..." : "Reativar"}
</Button>
{!isManualMobile ? (
<>
<Button
size="sm"
variant="outline"
className="gap-2 border-dashed border-amber-300 text-amber-700 hover:border-amber-400 hover:text-amber-800"
onClick={handleResetAgent}
disabled={isResettingAgent}
>
<RefreshCcw className={cn("size-4", isResettingAgent && "animate-spin")} />
{isResettingAgent ? "Resetando agente..." : "Resetar agente"}
</Button>
<Button
size="sm"
variant={isActiveLocal ? "outline" : "default"}
className={cn(
"gap-2 border-dashed",
!isActiveLocal && "bg-emerald-600 text-white hover:bg-emerald-600/90"
)}
onClick={handleToggleActive}
disabled={togglingActive}
>
{isActiveLocal ? <Power className="size-4" /> : <PlayCircle className="size-4" />}
{isActiveLocal ? (togglingActive ? "Desativando..." : "Desativar") : togglingActive ? "Reativando..." : "Reativar"}
</Button>
</>
) : null}
{device.registeredBy ? (
<span
className={cn(
@ -4157,55 +4169,59 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
</DialogContent>
</Dialog>
<section className="space-y-2">
<h4 className="text-sm font-semibold">Sincronização</h4>
<div className="grid gap-2 text-sm text-muted-foreground">
<div className="flex justify-between gap-4">
<span>Último heartbeat</span>
<span className="text-right font-medium text-foreground">
{formatRelativeTime(lastHeartbeatDate)}
</span>
{!isManualMobile ? (
<section className="space-y-2">
<h4 className="text-sm font-semibold">Sincronização</h4>
<div className="grid gap-2 text-sm text-muted-foreground">
<div className="flex justify-between gap-4">
<span>Último heartbeat</span>
<span className="text-right font-medium text-foreground">
{formatRelativeTime(lastHeartbeatDate)}
</span>
</div>
<div className="flex justify-between gap-4">
<span>Criada em</span>
<span className="text-right font-medium text-foreground">{formatDate(new Date(device.createdAt))}</span>
</div>
<div className="flex justify-between gap-4">
<span>Atualizada em</span>
<span className="text-right font-medium text-foreground">{formatDate(new Date(device.updatedAt))}</span>
</div>
<div className="flex justify-between gap-4">
<span>Token expira</span>
<span className="text-right font-medium text-foreground">
{tokenExpiry ? formatRelativeTime(tokenExpiry) : "—"}
</span>
</div>
<div className="flex justify-between gap-4">
<span>Token usado por último</span>
<span className="text-right font-medium text-foreground">
{tokenLastUsed ? formatRelativeTime(tokenLastUsed) : "—"}
</span>
</div>
<div className="flex justify-between gap-4">
<span>Uso do token</span>
<span className="text-right font-medium text-foreground">{device.token?.usageCount ?? 0} trocas</span>
</div>
</div>
<div className="flex justify-between gap-4">
<span>Criada em</span>
<span className="text-right font-medium text-foreground">{formatDate(new Date(device.createdAt))}</span>
</div>
<div className="flex justify-between gap-4">
<span>Atualizada em</span>
<span className="text-right font-medium text-foreground">{formatDate(new Date(device.updatedAt))}</span>
</div>
<div className="flex justify-between gap-4">
<span>Token expira</span>
<span className="text-right font-medium text-foreground">
{tokenExpiry ? formatRelativeTime(tokenExpiry) : "—"}
</span>
</div>
<div className="flex justify-between gap-4">
<span>Token usado por último</span>
<span className="text-right font-medium text-foreground">
{tokenLastUsed ? formatRelativeTime(tokenLastUsed) : "—"}
</span>
</div>
<div className="flex justify-between gap-4">
<span>Uso do token</span>
<span className="text-right font-medium text-foreground">{device.token?.usageCount ?? 0} trocas</span>
</div>
</div>
</section>
</section>
) : null}
<section className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<h4 className="text-sm font-semibold">Métricas recentes</h4>
{lastUpdateRelative ? (
<span className="text-xs font-medium text-muted-foreground">
Última atualização {lastUpdateRelative}
</span>
) : null}
</div>
<MetricsGrid metrics={metrics} hardware={hardware} disks={disks} />
</section>
{!isManualMobile ? (
<section className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<h4 className="text-sm font-semibold">Métricas recentes</h4>
{lastUpdateRelative ? (
<span className="text-xs font-medium text-muted-foreground">
Última atualização {lastUpdateRelative}
</span>
) : null}
</div>
<MetricsGrid metrics={metrics} hardware={hardware} disks={disks} />
</section>
) : null}
{hardware || network || (labels && labels.length > 0) ? (
{!isManualMobile && (hardware || network || (labels && labels.length > 0)) ? (
<section className="space-y-3">
<div>
<h4 className="text-sm font-semibold">Inventário</h4>
@ -5145,39 +5161,41 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
</section>
) : null}
<section className="space-y-2">
<div className="flex items-center justify-between">
<h4 className="text-sm font-semibold">Histórico de alertas</h4>
{deviceAlertsHistory.length > 0 ? (
<span className="text-xs text-muted-foreground">
Últimos {deviceAlertsHistory.length} {deviceAlertsHistory.length === 1 ? "evento" : "eventos"}
</span>
) : null}
</div>
{deviceAlertsHistory.length > 0 ? (
<div className="relative max-h-64 overflow-y-auto pr-2">
<div className="absolute left-3 top-3 bottom-3 w-px bg-slate-200" />
<ol className="space-y-3 pl-6">
{deviceAlertsHistory.map((alert) => {
const date = new Date(alert.createdAt)
return (
<li key={alert.id} className="relative rounded-md border border-slate-200/80 bg-white px-3 py-2 text-xs shadow-sm">
<span className="absolute -left-5 top-3 inline-flex size-3 items-center justify-center rounded-full border border-white bg-slate-200 ring-2 ring-white" />
<div className={cn("flex items-center justify-between", postureSeverityClass(alert.severity))}>
<span className="text-xs font-medium uppercase tracking-wide text-slate-600">{formatPostureAlertKind(alert.kind)}</span>
<span className="text-xs text-slate-500">{formatRelativeTime(date)}</span>
</div>
<p className="mt-1 text-sm text-foreground">{alert.message ?? formatPostureAlertKind(alert.kind)}</p>
<p className="mt-1 text-[11px] text-muted-foreground">{format(date, "dd/MM/yyyy HH:mm:ss")}</p>
</li>
)
})}
</ol>
{!isManualMobile ? (
<section className="space-y-2">
<div className="flex items-center justify-between">
<h4 className="text-sm font-semibold">Histórico de alertas</h4>
{deviceAlertsHistory.length > 0 ? (
<span className="text-xs text-muted-foreground">
Últimos {deviceAlertsHistory.length} {deviceAlertsHistory.length === 1 ? "evento" : "eventos"}
</span>
) : null}
</div>
) : (
<p className="text-xs text-muted-foreground">Nenhum alerta registrado para este dispositivo.</p>
)}
</section>
{deviceAlertsHistory.length > 0 ? (
<div className="relative max-h-64 overflow-y-auto pr-2">
<div className="absolute left-3 top-3 bottom-3 w-px bg-slate-200" />
<ol className="space-y-3 pl-6">
{deviceAlertsHistory.map((alert) => {
const date = new Date(alert.createdAt)
return (
<li key={alert.id} className="relative rounded-md border border-slate-200/80 bg-white px-3 py-2 text-xs shadow-sm">
<span className="absolute -left-5 top-3 inline-flex size-3 items-center justify-center rounded-full border border-white bg-slate-200 ring-2 ring-white" />
<div className={cn("flex items-center justify-between", postureSeverityClass(alert.severity))}>
<span className="text-xs font-medium uppercase tracking-wide text-slate-600">{formatPostureAlertKind(alert.kind)}</span>
<span className="text-xs text-slate-500">{formatRelativeTime(date)}</span>
</div>
<p className="mt-1 text-sm text-foreground">{alert.message ?? formatPostureAlertKind(alert.kind)}</p>
<p className="mt-1 text-[11px] text-muted-foreground">{format(date, "dd/MM/yyyy HH:mm:ss")}</p>
</li>
)
})}
</ol>
</div>
) : (
<p className="text-xs text-muted-foreground">Nenhum alerta registrado para este dispositivo.</p>
)}
</section>
) : null}
<div className="flex flex-wrap gap-2 pt-2">
{Array.isArray(software) && software.length > 0 ? (

View file

@ -29,6 +29,7 @@ type Field = {
required: boolean
options: FieldOption[]
order: number
scope: string
}
const TYPE_LABELS: Record<Field["type"], string> = {
@ -48,6 +49,25 @@ export function FieldsManager() {
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
) as Field[] | undefined
const templates = useQuery(
api.ticketFormTemplates.listActive,
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
) as Array<{ id: string; key: string; label: string }> | undefined
const scopeOptions = useMemo(
() => [
{ value: "all", label: "Todos os formulários" },
...((templates ?? []).map((tpl) => ({ value: tpl.key, label: tpl.label })) ?? []),
],
[templates]
)
const templateLabelByKey = useMemo(() => {
const map = new Map<string, string>()
templates?.forEach((tpl) => map.set(tpl.key, tpl.label))
return map
}, [templates])
const createField = useMutation(api.fields.create)
const updateField = useMutation(api.fields.update)
const removeField = useMutation(api.fields.remove)
@ -58,8 +78,10 @@ export function FieldsManager() {
const [type, setType] = useState<Field["type"]>("text")
const [required, setRequired] = useState(false)
const [options, setOptions] = useState<FieldOption[]>([])
const [scopeSelection, setScopeSelection] = useState<string>("all")
const [saving, setSaving] = useState(false)
const [editingField, setEditingField] = useState<Field | null>(null)
const [editingScope, setEditingScope] = useState<string>("all")
const totals = useMemo(() => {
if (!fields) return { total: 0, required: 0, select: 0 }
@ -76,6 +98,7 @@ export function FieldsManager() {
setType("text")
setRequired(false)
setOptions([])
setScopeSelection("all")
}
const normalizeOptions = (source: FieldOption[]) =>
@ -97,6 +120,7 @@ export function FieldsManager() {
return
}
const preparedOptions = type === "select" ? normalizeOptions(options) : undefined
const scopeValue = scopeSelection === "all" ? undefined : scopeSelection
setSaving(true)
toast.loading("Criando campo...", { id: "field" })
try {
@ -108,6 +132,7 @@ export function FieldsManager() {
type,
required,
options: preparedOptions,
scope: scopeValue,
})
toast.success("Campo criado", { id: "field" })
resetForm()
@ -147,6 +172,7 @@ export function FieldsManager() {
setType(field.type)
setRequired(field.required)
setOptions(field.options)
setEditingScope(field.scope ?? "all")
}
const handleUpdate = async () => {
@ -160,6 +186,7 @@ export function FieldsManager() {
return
}
const preparedOptions = type === "select" ? normalizeOptions(options) : undefined
const scopeValue = editingScope === "all" ? undefined : editingScope
setSaving(true)
toast.loading("Atualizando campo...", { id: "field-edit" })
try {
@ -172,6 +199,7 @@ export function FieldsManager() {
type,
required,
options: preparedOptions,
scope: scopeValue,
})
toast.success("Campo atualizado", { id: "field-edit" })
setEditingField(null)
@ -304,6 +332,21 @@ export function FieldsManager() {
Campo obrigatório na abertura
</Label>
</div>
<div className="space-y-2">
<Label>Aplicar em</Label>
<Select value={scopeSelection} onValueChange={setScopeSelection}>
<SelectTrigger>
<SelectValue placeholder="Todos os formulários" />
</SelectTrigger>
<SelectContent>
{scopeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-4">
<div className="space-y-2">
@ -378,7 +421,12 @@ export function FieldsManager() {
</CardHeader>
</Card>
) : (
fields.map((field, index) => (
fields.map((field, index) => {
const scopeLabel =
field.scope === "all"
? "Todos os formulários"
: templateLabelByKey.get(field.scope) ?? `Formulário: ${field.scope}`
return (
<Card key={field.id} className="border-slate-200">
<CardHeader>
<div className="flex items-start justify-between gap-4">
@ -395,6 +443,9 @@ export function FieldsManager() {
) : null}
</div>
<CardDescription className="text-neutral-600">Identificador: {field.key}</CardDescription>
<Badge variant="secondary" className="rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-semibold text-neutral-700">
{scopeLabel}
</Badge>
{field.description ? (
<p className="text-sm text-neutral-600">{field.description}</p>
) : null}
@ -446,7 +497,8 @@ export function FieldsManager() {
</CardContent>
) : null}
</Card>
))
)
})
)}
</div>
@ -487,6 +539,21 @@ export function FieldsManager() {
Campo obrigatório na abertura
</Label>
</div>
<div className="space-y-2">
<Label>Aplicar em</Label>
<Select value={editingScope} onValueChange={setEditingScope}>
<SelectTrigger>
<SelectValue placeholder="Todos os formulários" />
</SelectTrigger>
<SelectContent>
{scopeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-4">
<div className="space-y-2">

View file

@ -0,0 +1,345 @@
"use client"
import { useEffect, useMemo, useRef, useState } from "react"
import { useMutation, useQuery } from "convex/react"
import { toast } from "sonner"
import { Plus, MoreHorizontal, Archive, RefreshCcw } from "lucide-react"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { useAuth } from "@/lib/auth-client"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Badge } from "@/components/ui/badge"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { Checkbox } from "@/components/ui/checkbox"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
type Template = {
id: string
key: string
label: string
description: string
defaultEnabled: boolean
baseTemplateKey: string | null
isSystem: boolean
isArchived: boolean
order: number
}
export function TicketFormTemplatesManager() {
const { session, convexUserId } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
const viewerId = convexUserId as Id<"users"> | null
const ensureDefaults = useMutation(api.tickets.ensureTicketFormDefaults)
const createTemplate = useMutation(api.ticketFormTemplates.create)
const updateTemplate = useMutation(api.ticketFormTemplates.update)
const archiveTemplate = useMutation(api.ticketFormTemplates.archive)
const hasEnsuredRef = useRef(false)
useEffect(() => {
if (!viewerId || hasEnsuredRef.current) return
hasEnsuredRef.current = true
ensureDefaults({ tenantId, actorId: viewerId }).catch((error) => {
console.error("[ticket-templates] ensure defaults failed", error)
hasEnsuredRef.current = false
})
}, [ensureDefaults, tenantId, viewerId])
const templates = useQuery(
api.ticketFormTemplates.list,
viewerId ? { tenantId, viewerId } : "skip"
) as Template[] | undefined
const [createDialogOpen, setCreateDialogOpen] = useState(false)
const [newLabel, setNewLabel] = useState("")
const [newDescription, setNewDescription] = useState("")
const [baseTemplate, setBaseTemplate] = useState<string>("")
const [cloneFields, setCloneFields] = useState(true)
const [creating, setCreating] = useState(false)
const [editingTemplate, setEditingTemplate] = useState<Template | null>(null)
const [editLabel, setEditLabel] = useState("")
const [editDescription, setEditDescription] = useState("")
const [savingEdit, setSavingEdit] = useState(false)
const activeTemplates = useMemo(() => {
if (!templates) return []
return templates.filter((tpl) => !tpl.isArchived).sort((a, b) => a.order - b.order)
}, [templates])
const archivedTemplates = useMemo(() => {
if (!templates) return []
return templates.filter((tpl) => tpl.isArchived).sort((a, b) => a.label.localeCompare(b.label, "pt-BR"))
}, [templates])
const baseOptions = useMemo(() => {
return (templates ?? []).filter((tpl) => !tpl.isArchived)
}, [templates])
const handleCreate = async () => {
if (!viewerId) return
const label = newLabel.trim()
if (label.length < 3) {
toast.error("Informe um nome com pelo menos 3 caracteres")
return
}
setCreating(true)
try {
await createTemplate({
tenantId,
actorId: viewerId,
label,
description: newDescription.trim() || undefined,
baseTemplateKey: baseTemplate || undefined,
cloneFields,
})
toast.success("Formulário criado com sucesso.")
setCreateDialogOpen(false)
setNewLabel("")
setNewDescription("")
setBaseTemplate("")
setCloneFields(true)
} catch (error) {
console.error("[ticket-templates] create failed", error)
toast.error("Não foi possível criar o formulário.")
} finally {
setCreating(false)
}
}
const handleSaveEdit = async () => {
if (!viewerId || !editingTemplate) return
const label = editLabel.trim()
if (label.length < 3) {
toast.error("Informe um nome com pelo menos 3 caracteres")
return
}
setSavingEdit(true)
try {
await updateTemplate({
tenantId,
actorId: viewerId,
templateId: editingTemplate.id as Id<"ticketFormTemplates">,
label,
description: editDescription.trim() || undefined,
})
toast.success("Formulário atualizado.")
setEditingTemplate(null)
} catch (error) {
console.error("[ticket-templates] update failed", error)
toast.error("Não foi possível atualizar o formulário.")
} finally {
setSavingEdit(false)
}
}
const handleToggleArchive = async (template: Template, archived: boolean) => {
if (!viewerId) return
try {
await archiveTemplate({
tenantId,
actorId: viewerId,
templateId: template.id as Id<"ticketFormTemplates">,
archived,
})
toast.success(archived ? "Formulário arquivado." : "Formulário reativado.")
} catch (error) {
console.error("[ticket-templates] toggle archive failed", error)
toast.error("Não foi possível atualizar o formulário.")
}
}
const renderTemplateCard = (template: Template) => (
<Card key={template.id} className="border-slate-200">
<CardHeader className="flex flex-row items-start justify-between gap-3">
<div className="space-y-1">
<CardTitle className="text-base font-semibold text-neutral-900">{template.label}</CardTitle>
<CardDescription>{template.description || "Sem descrição"}</CardDescription>
<div className="flex flex-wrap gap-2 pt-1">
<Badge variant="outline" className="rounded-full px-2 py-0.5 text-xs font-semibold">
{template.isSystem ? "Padrão do sistema" : "Personalizado"}
</Badge>
{!template.defaultEnabled ? (
<Badge variant="secondary" className="rounded-full px-2 py-0.5 text-xs font-semibold">
Desabilitado por padrão
</Badge>
) : null}
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={() => {
setEditingTemplate(template)
setEditLabel(template.label)
setEditDescription(template.description)
}}
>
Renomear
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => handleToggleArchive(template, !template.isArchived)}>
{template.isArchived ? (
<span className="flex items-center gap-2 text-emerald-600">
<RefreshCcw className="size-3.5" />
Reativar
</span>
) : (
<span className="flex items-center gap-2 text-rose-600">
<Archive className="size-3.5" />
Arquivar
</span>
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</CardHeader>
</Card>
)
return (
<Card className="border-slate-200">
<CardHeader className="flex flex-row items-center justify-between gap-3">
<div>
<CardTitle className="text-lg font-semibold text-neutral-900">Modelos de formulário</CardTitle>
<CardDescription>Controle quais formulários especiais ficam disponíveis na abertura de tickets.</CardDescription>
</div>
<Button size="sm" className="gap-2" onClick={() => setCreateDialogOpen(true)}>
<Plus className="size-4" />
Novo formulário
</Button>
</CardHeader>
<CardContent className="space-y-4">
{!templates ? (
<div className="space-y-3">
{Array.from({ length: 2 }).map((_, index) => (
<Skeleton key={index} className="h-28 w-full rounded-2xl" />
))}
</div>
) : (
<>
{activeTemplates.length === 0 ? (
<div className="rounded-lg border border-dashed border-slate-300 bg-slate-50/70 p-6 text-center text-sm text-muted-foreground">
Nenhum formulário personalizado. Clique em &quot;Novo formulário&quot; para começar.
</div>
) : (
<div className="grid gap-3 md:grid-cols-2">
{activeTemplates.map(renderTemplateCard)}
</div>
)}
{archivedTemplates.length > 0 ? (
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Arquivados</p>
<div className="grid gap-3 md:grid-cols-2">
{archivedTemplates.map(renderTemplateCard)}
</div>
</div>
) : null}
</>
)}
</CardContent>
<Dialog open={createDialogOpen} onOpenChange={(open) => !creating && setCreateDialogOpen(open)}>
<DialogContent className="max-w-lg space-y-4">
<DialogHeader>
<DialogTitle>Novo formulário</DialogTitle>
<DialogDescription>Crie formulários específicos para fluxos como admissões, desligamentos ou demandas especiais.</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium text-neutral-700">Nome</label>
<Input
value={newLabel}
onChange={(event) => setNewLabel(event.target.value)}
placeholder="Ex.: Troca de equipamento"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-neutral-700">Descrição</label>
<Textarea
value={newDescription}
onChange={(event) => setNewDescription(event.target.value)}
placeholder="Explique quando este formulário deve ser usado"
rows={3}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-neutral-700">Basear em</label>
<Select value={baseTemplate} onValueChange={setBaseTemplate}>
<SelectTrigger>
<SelectValue placeholder="Em branco" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Começar do zero</SelectItem>
{baseOptions.map((tpl) => (
<SelectItem key={tpl.key} value={tpl.key}>
{tpl.label}
</SelectItem>
))}
</SelectContent>
</Select>
{baseTemplate ? (
<label className="flex items-center gap-2 text-sm text-neutral-700">
<Checkbox
checked={cloneFields}
onCheckedChange={(value) => setCloneFields(Boolean(value))}
/>
<span>Copiar campos do formulário base</span>
</label>
) : null}
</div>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => !creating && setCreateDialogOpen(false)}>
Cancelar
</Button>
<Button onClick={handleCreate} disabled={creating}>
{creating ? "Criando..." : "Criar formulário"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={Boolean(editingTemplate)} onOpenChange={(open) => !savingEdit && !open && setEditingTemplate(null)}>
<DialogContent className="max-w-lg space-y-4">
<DialogHeader>
<DialogTitle>Editar formulário</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-2">
<label className="text-sm font-medium text-neutral-700">Nome</label>
<Input value={editLabel} onChange={(event) => setEditLabel(event.target.value)} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-neutral-700">Descrição</label>
<Textarea
value={editDescription}
onChange={(event) => setEditDescription(event.target.value)}
rows={3}
/>
</div>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => !savingEdit && setEditingTemplate(null)}>
Cancelar
</Button>
<Button onClick={handleSaveEdit} disabled={savingEdit}>
{savingEdit ? "Salvando..." : "Salvar alterações"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
)
}

View file

@ -1,6 +1,6 @@
"use client"
import { useCallback, useEffect, useMemo, useState, useTransition } from "react"
import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react"
import { format } from "date-fns"
import { ptBR } from "date-fns/locale"
import { Controller, FormProvider, useFieldArray, useForm } from "react-hook-form"
@ -70,6 +70,7 @@ import { Textarea } from "@/components/ui/textarea"
import { Badge } from "@/components/ui/badge"
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
import { EmptyIndicator } from "@/components/ui/empty-indicator"
import { Skeleton } from "@/components/ui/skeleton"
export type AdminAccount = {
id: string
@ -265,9 +266,24 @@ function AccountsTable({
api.ticketFormSettings.list,
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
) as Array<{ template: string; scope: string; userId?: string | null; enabled: boolean; updatedAt: number }> | undefined
const templates = useQuery(
api.ticketFormTemplates.listActive,
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
) as Array<{ key: string; label: string }> | undefined
const upsertFormSetting = useMutation(api.ticketFormSettings.upsert)
const ensureTicketForms = useMutation(api.tickets.ensureTicketFormDefaults)
const ensuredRef = useRef(false)
const resolveUserFormEnabled = useCallback((template: "admissao" | "desligamento") => {
useEffect(() => {
if (!convexUserId || ensuredRef.current) return
ensuredRef.current = true
ensureTicketForms({ tenantId, actorId: convexUserId as Id<"users"> }).catch((error) => {
console.error("Falha ao garantir formulários padrão", error)
ensuredRef.current = false
})
}, [convexUserId, ensureTicketForms, tenantId])
const resolveUserFormEnabled = useCallback((template: string) => {
if (!editAccount) return true
const scoped = (formSettings ?? []).filter((s) => s.template === template)
const latest = scoped
@ -276,7 +292,7 @@ function AccountsTable({
return typeof latest?.enabled === "boolean" ? latest.enabled : true
}, [formSettings, editAccount])
const handleToggleUserForm = useCallback(async (template: "admissao" | "desligamento", enabled: boolean) => {
const handleToggleUserForm = useCallback(async (template: string, enabled: boolean) => {
if (!convexUserId || !editAccount) return
try {
await upsertFormSetting({
@ -1019,24 +1035,27 @@ function AccountsTable({
<div className="rounded-lg border border-border/60 bg-muted/20 p-4">
<p className="text-sm font-semibold text-foreground">Tipos de solicitação</p>
<p className="mb-2 text-xs text-muted-foreground">Disponíveis para este colaborador/gestor no portal. Administradores e agentes sempre veem todas as opções.</p>
<div className="grid gap-2 sm:grid-cols-2">
<label className="flex items-center gap-2 text-sm text-foreground">
<Checkbox
checked={resolveUserFormEnabled("admissao")}
onCheckedChange={(v) => handleToggleUserForm("admissao", Boolean(v))}
disabled={!editAccount || isSavingAccount}
/>
<span>Admissão de colaborador</span>
</label>
<label className="flex items-center gap-2 text-sm text-foreground">
<Checkbox
checked={resolveUserFormEnabled("desligamento")}
onCheckedChange={(v) => handleToggleUserForm("desligamento", Boolean(v))}
disabled={!editAccount || isSavingAccount}
/>
<span>Desligamento de colaborador</span>
</label>
</div>
{!templates ? (
<div className="space-y-2">
<Skeleton className="h-8 w-full rounded-md" />
<Skeleton className="h-8 w-full rounded-md" />
</div>
) : templates.length === 0 ? (
<p className="text-xs text-neutral-500">Nenhum formulário configurado.</p>
) : (
<div className="grid gap-2 sm:grid-cols-2">
{templates.map((template) => (
<label key={template.key} className="flex items-center gap-2 text-sm text-foreground">
<Checkbox
checked={resolveUserFormEnabled(template.key)}
onCheckedChange={(v) => handleToggleUserForm(template.key, Boolean(v))}
disabled={!editAccount || isSavingAccount}
/>
<span>{template.label}</span>
</label>
))}
</div>
)}
</div>
</div>

View file

@ -21,6 +21,7 @@ import {
ChevronDown,
ShieldCheck,
Users,
Layers3,
} from "lucide-react"
import { usePathname } from "next/navigation"
import Link from "next/link"
@ -90,6 +91,7 @@ const navigation: NavigationGroup[] = [
{ title: "Qualidade (CSAT)", url: "/reports/csat", icon: LifeBuoy, requiredRole: "staff" },
{ title: "Backlog", url: "/reports/backlog", icon: BarChart3, requiredRole: "staff" },
{ title: "Empresas", url: "/reports/company", icon: Building2, requiredRole: "staff" },
{ title: "Categorias", url: "/reports/categories", icon: Layers3, requiredRole: "staff" },
{ title: "Horas", url: "/reports/hours", icon: Clock4, requiredRole: "staff" },
],
},

View file

@ -34,6 +34,7 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
import { Textarea } from "@/components/ui/textarea"
import {
Dialog,
@ -88,6 +89,7 @@ import {
PauseCircle,
PlayCircle,
Plus,
Share2,
Sparkles,
Table2,
Trash2,
@ -569,6 +571,7 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" }
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
const [isDeletingDashboard, setIsDeletingDashboard] = useState(false)
const fullscreenContainerRef = useRef<HTMLDivElement | null>(null)
const autoFullscreenRef = useRef(false)
const previousSidebarStateRef = useRef<{ open: boolean; openMobile: boolean } | null>(null)
const ensureQueueSummaryRequestedRef = useRef(false)
@ -709,6 +712,28 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" }
}
}, [isMobile, open, openMobile, setOpen, setOpenMobile])
useEffect(() => {
if (typeof document === "undefined") return
if (enforceTv) {
if (!document.fullscreenElement) {
handleToggleFullscreen()
.then(() => {
autoFullscreenRef.current = true
})
.catch(() => {
autoFullscreenRef.current = false
})
} else {
autoFullscreenRef.current = true
}
} else if (autoFullscreenRef.current && document.fullscreenElement) {
document.exitFullscreen?.().catch(() => null)
autoFullscreenRef.current = false
} else {
autoFullscreenRef.current = false
}
}, [enforceTv, handleToggleFullscreen])
const packedLayout = useMemo(() => packLayout(layoutState, GRID_COLUMNS), [layoutState])
const metricOptions = useMemo(() => getMetricOptionsForRole(userRole), [userRole])
@ -1133,7 +1158,7 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" }
className={cn(
"flex flex-1 flex-col gap-6",
isFullscreen &&
"min-h-screen bg-gradient-to-br from-background via-background to-primary/5 pb-10 pt-16",
"min-h-screen bg-gradient-to-b from-white via-slate-50 to-slate-100 pb-10 pt-16",
isFullscreen && (enforceTv ? "px-0" : "px-4 md:px-8 lg:px-12"),
)}
>
@ -1183,27 +1208,59 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" }
) : null}
{visibleCount === 0 ? (
<Card className="border-dashed border-muted-foreground/40 bg-muted/10 py-12 text-center">
<CardHeader>
<CardTitle className="flex items-center justify-center gap-2 text-lg font-semibold">
<Sparkles className="size-4 text-primary" />
Comece adicionando widgets
</CardTitle>
<CardDescription>
KPIs, gráficos ou tabelas podem ser combinados para contar histórias relevantes para a operação.
</CardDescription>
</CardHeader>
<CardContent className="flex justify-center">
<section className="rounded-2xl border border-dashed border-slate-200 bg-white/90 p-6 shadow-sm">
<Empty className="border-none bg-transparent p-0">
<EmptyMedia variant="icon" className="bg-slate-100 text-slate-600">
<Sparkles className="size-5" />
</EmptyMedia>
<EmptyHeader>
<EmptyTitle className="text-xl">Canvas em branco</EmptyTitle>
<EmptyDescription>
Adicione cards de KPI, gráficos ou texto para montar a visão diária da operação.
</EmptyDescription>
</EmptyHeader>
{canEdit ? (
<Button onClick={() => handleAddWidget("kpi")} disabled={isAddingWidget}>
<Plus className="mr-2 size-4" />
Adicionar primeiro widget
</Button>
<EmptyContent>
<Button onClick={() => handleAddWidget("kpi")} disabled={isAddingWidget} className="gap-2">
<Plus className="size-4" />
Adicionar bloco
</Button>
<Button
type="button"
variant="ghost"
className="gap-2 text-sm text-muted-foreground"
onClick={() => handleAddWidget("text")}
disabled={isAddingWidget}
>
<LayoutTemplate className="size-4" />
Ver biblioteca
</Button>
</EmptyContent>
) : (
<p className="text-sm text-muted-foreground">Nenhum widget visível para esta seção.</p>
<p className="text-sm text-neutral-500">Nenhum widget disponível para esta seção.</p>
)}
</CardContent>
</Card>
</Empty>
<div className="mt-6 grid gap-3 text-left text-sm text-neutral-600 md:grid-cols-3">
<div className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
<div className="mb-2 inline-flex size-8 items-center justify-center rounded-lg bg-slate-100 text-slate-600">
<LayoutTemplate className="size-4" />
</div>
<p>Distribua widgets em slides e ajuste o grid para focar cada indicador.</p>
</div>
<div className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
<div className="mb-2 inline-flex size-8 items-center justify-center rounded-lg bg-slate-100 text-slate-600">
<Share2 className="size-4" />
</div>
<p>Salve filtros padrão, replique layouts e gere PDFs/PNGs.</p>
</div>
<div className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
<div className="mb-2 inline-flex size-8 items-center justify-center rounded-lg bg-slate-100 text-slate-600">
<MonitorPlay className="size-4" />
</div>
<p>Ative o modo apresentação/TV para loops automáticos em tela cheia.</p>
</div>
</div>
</section>
) : null}
{enforceTv ? (
@ -1341,6 +1398,9 @@ function BuilderHeader({
? dashboard.theme
: null
const metricBadgeClass =
"inline-flex h-9 items-center gap-2 rounded-full border border-slate-200 bg-white px-4 text-sm font-medium text-neutral-800"
const handleStartEditHeader = () => {
setDraftName(name)
setDraftDescription(description ?? "")
@ -1430,26 +1490,17 @@ function BuilderHeader({
</div>
)}
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<Badge
variant="outline"
className="inline-flex items-center gap-2 rounded-full border-slate-200 bg-white px-4 py-1.5 text-xs font-semibold text-neutral-700 shadow-sm"
>
<Badge variant="outline" className={metricBadgeClass}>
<span className="h-2 w-2 rounded-full bg-neutral-400" />
Formato {dashboard.aspectRatio ?? "16:9"}
</Badge>
{themeLabel ? (
<Badge
variant="outline"
className="inline-flex items-center gap-2 rounded-full border-slate-200 bg-white px-4 py-1.5 text-xs font-semibold text-neutral-700 shadow-sm"
>
<Badge variant="outline" className={metricBadgeClass}>
<span className="h-2 w-2 rounded-full bg-neutral-400" />
Tema {themeLabel}
</Badge>
) : null}
<Badge
variant="outline"
className="inline-flex items-center gap-2 rounded-full border-slate-200 bg-white px-4 py-1.5 text-xs font-semibold text-neutral-700 shadow-sm"
>
<Badge variant="outline" className={metricBadgeClass}>
<span className="h-2 w-2 rounded-full bg-neutral-400" />
{totalWidgets} bloco{totalWidgets === 1 ? "" : "s"}
</Badge>
@ -1483,12 +1534,17 @@ function BuilderHeader({
</Button>
</div>
) : null}
<div className="flex flex-wrap items-center gap-2">
{canEdit ? <WidgetPicker onSelect={onAddWidget} disabled={isAddingWidget} /> : null}
<div className="flex flex-1 flex-col gap-3">
{canEdit ? (
<div className="flex flex-wrap items-center justify-end gap-2">
<WidgetPicker onSelect={onAddWidget} disabled={isAddingWidget} />
</div>
) : null}
<div className="flex flex-wrap items-center gap-2 rounded-2xl border border-slate-200 bg-white/80 px-2 py-1.5">
<Button
variant="outline"
variant="ghost"
size="sm"
className="gap-2"
className="gap-2 rounded-full border border-slate-200 px-3 font-medium text-neutral-700 hover:border-slate-300 hover:bg-white"
onClick={onToggleFullscreen}
>
{isFullscreen ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
@ -1497,18 +1553,21 @@ function BuilderHeader({
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant={isTvMode ? "secondary" : "default"}
variant={isTvMode ? "secondary" : "ghost"}
size="sm"
className="gap-2"
className={cn(
"gap-2 rounded-full border px-3",
isTvMode ? "border-slate-200 bg-slate-900 text-white" : "border-slate-200 text-neutral-700",
)}
>
{isTvMode ? <PauseCircle className="size-4" /> : <PlayCircle className="size-4" />}
{isTvMode ? "Modo apresentação ativo" : "Modo apresentação"}
Modo apresentação
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-60">
<DropdownMenuLabel>Modo apresentação</DropdownMenuLabel>
<DropdownMenuItem onSelect={onToggleTvMode}>
{isTvMode ? "Encerrar modo apresentação" : "Iniciar modo apresentação"}
{isTvMode ? "Encerrar" : "Iniciar"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>Tempo por seção</DropdownMenuLabel>
@ -1527,42 +1586,41 @@ function BuilderHeader({
</DropdownMenuItem>
))}
{canEdit ? null : (
<DropdownMenuItem disabled>
Apenas edição permite ajustar o tempo
</DropdownMenuItem>
<DropdownMenuItem disabled>Apenas edição permite ajustar o tempo</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<Download className="size-4" />
Exportar
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="gap-2 rounded-full border border-slate-200 px-3 font-medium text-neutral-700">
<Download className="size-4" />
Exportar
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Exportar dashboard</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem disabled={isExporting} onSelect={() => onExport("pdf")}>
Exportar como PDF
</DropdownMenuItem>
<DropdownMenuItem disabled={isExporting} onSelect={() => onExport("png")}>
Exportar como PNG
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{canEdit ? (
<Button
variant="ghost"
size="sm"
className="gap-2 rounded-full border border-rose-200 px-3 font-medium text-rose-600 hover:bg-rose-50"
onClick={onDeleteRequest}
>
<Trash2 className="size-4" />
Excluir
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Exportar dashboard</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem disabled={isExporting} onSelect={() => onExport("pdf")}>
Exportar como PDF
</DropdownMenuItem>
<DropdownMenuItem disabled={isExporting} onSelect={() => onExport("png")}>
Exportar como PNG
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{canEdit ? (
<Button
variant="outline"
size="sm"
className="gap-2 border border-rose-300 bg-white text-rose-600 transition hover:bg-rose-50 focus-visible:ring-rose-200 [&>svg]:transition [&>svg]:text-rose-600 hover:[&>svg]:text-rose-700 hover:text-rose-700"
onClick={onDeleteRequest}
>
<Trash2 className="size-4" />
Excluir
</Button>
) : null}
) : null}
</div>
</div>
</div>
</div>
</CardContent>
@ -1749,7 +1807,7 @@ function TvCanvas({ items, isFullscreen }: { items: CanvasRenderableItem[]; isFu
<div
key={item.key}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-2xl border border-border/40 bg-gradient-to-br from-white via-white to-slate-100 shadow-lg",
"flex h-full w-full flex-col overflow-visible rounded-2xl border border-border/40 bg-gradient-to-br from-white via-white to-slate-100 shadow-lg",
isSingle ? "max-w-4xl justify-self-center" : "",
)}
>

View file

@ -6,7 +6,7 @@ import { useRouter } from "next/navigation"
import { useMutation, useQuery } from "convex/react"
import { formatDistanceToNow } from "date-fns"
import { ptBR } from "date-fns/locale"
import { Plus, Sparkles, Trash2 } from "lucide-react"
import { LayoutTemplate, MonitorPlay, Plus, Share2, Sparkles, Trash2 } from "lucide-react"
import type { Id } from "@/convex/_generated/dataModel"
import { api } from "@/convex/_generated/api"
@ -239,23 +239,42 @@ export function DashboardListView() {
</div>
{activeDashboards.length === 0 ? (
<Card className="border-dashed border-muted-foreground/40 bg-muted/10">
<CardHeader className="flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<Sparkles className="size-4 text-primary" />
Crie o seu primeiro dashboard
</CardTitle>
<CardDescription>
Monte painéis por cliente, fila ou operação e compartilhe com a equipe.
</CardDescription>
<Card className="overflow-hidden border-dashed border-slate-200 bg-gradient-to-br from-white via-white to-slate-50 shadow-sm">
<CardContent className="flex flex-col gap-6 p-6 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-4">
<div className="flex items-center gap-3">
<div className="inline-flex size-12 items-center justify-center rounded-full bg-sky-50 text-sky-700">
<Sparkles className="size-5" />
</div>
<div>
<h3 className="text-xl font-semibold text-neutral-900">Nenhum dashboard ainda</h3>
<p className="text-sm text-muted-foreground">Use KPIs, filas e texto para contar a história da operação.</p>
</div>
</div>
<ul className="space-y-2 text-sm text-neutral-600">
<li className="flex items-start gap-2">
<LayoutTemplate className="mt-0.5 size-4 text-slate-500" />
<span>Escolha widgets arrastando no canvas e organize por seções.</span>
</li>
<li className="flex items-start gap-2">
<Share2 className="mt-0.5 size-4 text-slate-500" />
<span>Compartilhe com a equipe, salve filtros padrão e gere PDFs/PNGs.</span>
</li>
<li className="flex items-start gap-2">
<MonitorPlay className="mt-0.5 size-4 text-slate-500" />
<span>Entre no modo apresentação/TV para um loop automático em tela cheia.</span>
</li>
</ul>
</div>
<div className="flex w-full flex-col gap-3 rounded-2xl border border-slate-200 bg-white/90 p-4 lg:w-auto lg:min-w-[220px]">
{renderCreateButton()}
<Button variant="outline" className="gap-2" asChild>
<Link href="/views">
<LayoutTemplate className="size-4" />
Ver exemplos
</Link>
</Button>
</div>
{renderCreateButton()}
</CardHeader>
<CardContent className="space-y-2 text-sm text-muted-foreground">
<p> Arraste e redimensione widgets livremente no canvas.</p>
<p> Salve filtros padrão por dashboard e gere exportações em PDF/PNG.</p>
<p> Ative o modo TV ou compartilhe via link público com token rotativo.</p>
</CardContent>
</Card>
) : (

View file

@ -67,7 +67,8 @@ const percentFormatter = new Intl.NumberFormat("pt-BR", { style: "percent", maxi
const CHART_COLORS = ["var(--chart-1)", "var(--chart-2)", "var(--chart-3)", "var(--chart-4)", "var(--chart-5)"]
const DEFAULT_CHART_HEIGHT = 320
const PRESENTATION_CHART_HEIGHT = 420
// Em modo apresentação o card já define a altura disponível; evitar valores fixos previne cortes.
const PRESENTATION_CHART_HEIGHT = 0
export type DashboardFilters = {
range?: "7d" | "30d" | "90d" | "custom"
@ -415,7 +416,7 @@ function WidgetCard({ title, description, children, isLoading }: WidgetCardProps
<CardTitle className="text-base font-semibold">{title}</CardTitle>
{description ? <CardDescription>{description}</CardDescription> : null}
</CardHeader>
<CardContent className="flex-1 pb-4 pt-0">
<CardContent className="flex-1 overflow-visible pb-4 pt-0">
{isLoading ? (
<Skeleton className="h-full min-h-[240px] w-full rounded-xl animate-pulse" />
) : (
@ -514,6 +515,7 @@ function renderBarChart({
const allowDecimals = valueFormatter === "percent"
const isPresentation = mode === "tv" || mode === "print"
const minHeight = isPresentation ? PRESENTATION_CHART_HEIGHT : DEFAULT_CHART_HEIGHT
const legendPadClass = isPresentation ? "pb-8" : "pb-6"
return (
<WidgetCard title={title} description={description} isLoading={metric.isLoading}>
@ -522,8 +524,13 @@ function renderBarChart({
) : (
<ChartContainer
config={chartConfig as ChartConfig}
className="group/chart h-full w-full px-2 pb-4 [&_.recharts-legend-wrapper]:px-2 [&_.recharts-legend-wrapper]:pb-1 [&_.recharts-default-legend]:flex [&_.recharts-default-legend]:flex-wrap [&_.recharts-default-legend]:justify-center"
style={{ minHeight, height: "100%" }}
className={cn(
"group/chart h-full w-full px-2",
legendPadClass,
"[&_.recharts-legend-wrapper]:px-2 [&_.recharts-legend-wrapper]:pb-1",
"[&_.recharts-default-legend]:flex [&_.recharts-default-legend]:flex-wrap [&_.recharts-default-legend]:justify-center",
)}
style={{ minHeight, height: "100%", overflow: "visible" }}
>
<BarChart data={chartData} accessibilityLayer>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
@ -594,6 +601,7 @@ function renderLineChart({
valueFormatter === "percent" ? (value: number) => percentFormatter.format(value) : undefined
const isPresentation = mode === "tv" || mode === "print"
const minHeight = isPresentation ? PRESENTATION_CHART_HEIGHT : DEFAULT_CHART_HEIGHT
const legendPadClass = isPresentation ? "pb-8" : "pb-6"
return (
<WidgetCard title={title} description={description} isLoading={metric.isLoading}>
@ -602,8 +610,13 @@ function renderLineChart({
) : (
<ChartContainer
config={chartConfig as ChartConfig}
className="group/chart h-full w-full px-2 pb-4 [&_.recharts-legend-wrapper]:px-2 [&_.recharts-legend-wrapper]:pb-1 [&_.recharts-default-legend]:flex [&_.recharts-default-legend]:flex-wrap [&_.recharts-default-legend]:justify-center"
style={{ minHeight, height: "100%" }}
className={cn(
"group/chart h-full w-full px-2",
legendPadClass,
"[&_.recharts-legend-wrapper]:px-2 [&_.recharts-legend-wrapper]:pb-1",
"[&_.recharts-default-legend]:flex [&_.recharts-default-legend]:flex-wrap [&_.recharts-default-legend]:justify-center",
)}
style={{ minHeight, height: "100%", overflow: "visible" }}
>
<LineChart data={chartData} accessibilityLayer>
<CartesianGrid strokeDasharray="3 3" />
@ -669,6 +682,7 @@ function renderAreaChart({
valueFormatter === "percent" ? (value: number) => percentFormatter.format(value) : undefined
const isPresentation = mode === "tv" || mode === "print"
const minHeight = isPresentation ? PRESENTATION_CHART_HEIGHT : DEFAULT_CHART_HEIGHT
const legendPadClass = isPresentation ? "pb-8" : "pb-6"
return (
<WidgetCard title={title} description={description} isLoading={metric.isLoading}>
@ -677,8 +691,13 @@ function renderAreaChart({
) : (
<ChartContainer
config={chartConfig as ChartConfig}
className="group/chart h-full w-full px-2 pb-4 [&_.recharts-legend-wrapper]:px-2 [&_.recharts-legend-wrapper]:pb-1 [&_.recharts-default-legend]:flex [&_.recharts-default-legend]:flex-wrap [&_.recharts-default-legend]:justify-center"
style={{ minHeight, height: "100%" }}
className={cn(
"group/chart h-full w-full px-2",
legendPadClass,
"[&_.recharts-legend-wrapper]:px-2 [&_.recharts-legend-wrapper]:pb-1",
"[&_.recharts-default-legend]:flex [&_.recharts-default-legend]:flex-wrap [&_.recharts-default-legend]:justify-center",
)}
style={{ minHeight, height: "100%", overflow: "visible" }}
>
<AreaChart data={chartData} accessibilityLayer>
<defs>
@ -742,6 +761,7 @@ function renderPieChart({
const tooltipValueFormatter = (value: unknown) => formatMetricValue(value, valueFormatter)
const isPresentation = mode === "tv" || mode === "print"
const minHeight = isPresentation ? PRESENTATION_CHART_HEIGHT : DEFAULT_CHART_HEIGHT
const legendPadClass = isPresentation ? "pb-8" : "pb-6"
return (
<WidgetCard title={title} description={description} isLoading={metric.isLoading}>
{chartData.length === 0 ? (
@ -753,8 +773,13 @@ function renderPieChart({
acc[key] = { label: key, color: CHART_COLORS[index % CHART_COLORS.length] }
return acc
}, {}) as ChartConfig}
className="group/chart flex h-full w-full items-center justify-center px-2 pb-4 [&_.recharts-legend-wrapper]:px-2 [&_.recharts-legend-wrapper]:pb-1 [&_.recharts-default-legend]:flex [&_.recharts-default-legend]:flex-wrap [&_.recharts-default-legend]:justify-center"
style={{ minHeight, height: "100%" }}
className={cn(
"group/chart flex h-full w-full items-center justify-center px-2",
legendPadClass,
"[&_.recharts-legend-wrapper]:px-2 [&_.recharts-legend-wrapper]:pb-1",
"[&_.recharts-default-legend]:flex [&_.recharts-default-legend]:flex-wrap [&_.recharts-default-legend]:justify-center",
)}
style={{ minHeight, height: "100%", overflow: "visible" }}
>
<PieChart>
{showTooltip ? (
@ -807,6 +832,7 @@ function renderRadarChart({
const tooltipValueFormatter = (value: unknown) => formatMetricValue(value, valueFormatter)
const isPresentation = mode === "tv" || mode === "print"
const minHeight = isPresentation ? PRESENTATION_CHART_HEIGHT : DEFAULT_CHART_HEIGHT
const legendPadClass = isPresentation ? "pb-8" : "pb-6"
return (
<WidgetCard title={title} description={description} isLoading={metric.isLoading}>
{chartData.length === 0 ? (
@ -814,8 +840,13 @@ function renderRadarChart({
) : (
<ChartContainer
config={{ [radiusKey]: { label: radiusKey, color: "var(--chart-1)" } }}
className="group/chart flex h-full w-full items-center justify-center px-2 pb-4 [&_.recharts-legend-wrapper]:px-2 [&_.recharts-legend-wrapper]:pb-1 [&_.recharts-default-legend]:flex [&_.recharts-default-legend]:flex-wrap [&_.recharts-default-legend]:justify-center"
style={{ minHeight, height: "100%" }}
className={cn(
"group/chart flex h-full w-full items-center justify-center px-2",
legendPadClass,
"[&_.recharts-legend-wrapper]:px-2 [&_.recharts-legend-wrapper]:pb-1",
"[&_.recharts-default-legend]:flex [&_.recharts-default-legend]:flex-wrap [&_.recharts-default-legend]:justify-center",
)}
style={{ minHeight, height: "100%", overflow: "visible" }}
>
<RadarChart data={chartData} accessibilityLayer>
<PolarGrid />
@ -863,12 +894,18 @@ function renderGauge({
const display = Math.max(0, Math.min(1, value))
const isPresentation = mode === "tv" || mode === "print"
const minHeight = isPresentation ? PRESENTATION_CHART_HEIGHT : DEFAULT_CHART_HEIGHT
const legendPadClass = isPresentation ? "pb-8" : "pb-6"
return (
<WidgetCard title={title} description={description} isLoading={metric.isLoading}>
<ChartContainer
config={{ value: { label: "SLA", color: "var(--chart-1)" } }}
className="group/chart flex h-full w-full items-center justify-center px-2 pb-4 [&_.recharts-legend-wrapper]:px-2 [&_.recharts-legend-wrapper]:pb-1 [&_.recharts-default-legend]:flex [&_.recharts-default-legend]:flex-wrap [&_.recharts-default-legend]:justify-center"
style={{ minHeight, height: "100%" }}
className={cn(
"group/chart flex h-full w-full items-center justify-center px-2",
legendPadClass,
"[&_.recharts-legend-wrapper]:px-2 [&_.recharts-legend-wrapper]:pb-1",
"[&_.recharts-default-legend]:flex [&_.recharts-default-legend]:flex-wrap [&_.recharts-default-legend]:justify-center",
)}
style={{ minHeight, height: "100%", overflow: "visible" }}
>
<RadialBarChart
startAngle={180}

View file

@ -0,0 +1,300 @@
"use client"
import { useMemo, useState } from "react"
import { useQuery } from "convex/react"
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts"
import { Layers3, PieChart as PieChartIcon, Award } from "lucide-react"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { useAuth } from "@/lib/auth-client"
import { usePersistentCompanyFilter } from "@/lib/use-company-filter"
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
import { Skeleton } from "@/components/ui/skeleton"
import { Badge } from "@/components/ui/badge"
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
type CategoryInsightsResponse = {
rangeDays: number
totalTickets: number
categories: Array<{
id: string | null
name: string
total: number
resolved: number
topAgent: { id: string | null; name: string | null; total: number } | null
agents: Array<{ id: string | null; name: string | null; total: number }>
}>
spotlight: {
categoryId: string | null
categoryName: string
agentId: string | null
agentName: string | null
tickets: number
} | null
}
const numberFormatter = new Intl.NumberFormat("pt-BR")
const percentFormatter = new Intl.NumberFormat("pt-BR", { maximumFractionDigits: 1 })
const chartConfig = {
tickets: {
label: "Tickets",
color: "hsl(var(--chart-1))",
},
} as const
export function CategoryReport() {
const { session, convexUserId, isStaff } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
const [timeRange, setTimeRange] = useState("90d")
const [companyId, setCompanyId] = usePersistentCompanyFilter("all")
const enabled = Boolean(isStaff && convexUserId)
const companyFilter = companyId !== "all" ? (companyId as Id<"companies">) : undefined
const data = useQuery(
api.reports.categoryInsights,
enabled
? {
tenantId,
viewerId: convexUserId as Id<"users">,
range: timeRange,
companyId: companyFilter,
}
: "skip",
) as CategoryInsightsResponse | undefined
const companies = useQuery(
api.companies.list,
enabled ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip",
) as Array<{ id: Id<"companies">; name: string }> | undefined
const companyOptions = useMemo<SearchableComboboxOption[]>(() => {
const base: SearchableComboboxOption[] = [{ value: "all", label: "Todas as empresas" }]
if (!companies || companies.length === 0) return base
const sorted = [...companies].sort((a, b) => a.name.localeCompare(b.name, "pt-BR"))
return [
base[0],
...sorted.map((company) => ({
value: company.id,
label: company.name,
})),
]
}, [companies])
const categories = data?.categories ?? []
const leadingCategory = categories[0] ?? null
const spotlight = data?.spotlight ?? null
const chartData = categories.length
? categories.slice(0, 8).map((category) => ({
name: category.name,
tickets: category.total,
topAgent: category.topAgent?.name ?? "—",
agentTickets: category.topAgent?.total ?? 0,
}))
: []
const tableData = categories.slice(0, 10)
const totalTickets = data?.totalTickets ?? 0
const chartHeight = Math.max(240, chartData.length * 48)
const summarySkeleton = (
<div className="grid gap-4 md:grid-cols-3">
{Array.from({ length: 3 }).map((_, index) => (
<Card key={index} className="border-slate-200">
<CardContent className="space-y-3 p-6">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-8 w-24" />
</CardContent>
</Card>
))}
</div>
)
return (
<div className="space-y-8">
{!data ? (
summarySkeleton
) : (
<div className="grid gap-4 md:grid-cols-3">
<Card className="border-slate-200">
<CardContent className="space-y-3 p-6">
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
<Layers3 className="size-4" /> Tickets analisados
</div>
<p className="text-3xl font-semibold text-neutral-900">{numberFormatter.format(totalTickets)}</p>
<p className="text-xs text-neutral-500">Últimos {data.rangeDays} dias</p>
</CardContent>
</Card>
<Card className="border-slate-200">
<CardContent className="space-y-3 p-6">
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
<PieChartIcon className="size-4" /> Categoria líder
</div>
<p className="text-lg font-semibold text-neutral-900">{leadingCategory ? leadingCategory.name : "—"}</p>
<p className="text-sm text-neutral-600">
{leadingCategory
? `${numberFormatter.format(leadingCategory.total)} chamados (${percentFormatter.format(
totalTickets > 0 ? (leadingCategory.total / totalTickets) * 100 : 0,
)}%)`
: "Sem registros no período."}
</p>
</CardContent>
</Card>
<Card className="border-slate-200">
<CardContent className="space-y-3 p-6">
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
<Award className="size-4" /> Agente destaque
</div>
<p className="text-lg font-semibold text-neutral-900">{spotlight?.agentName ?? "—"}</p>
<p className="text-sm text-neutral-600">
{spotlight
? `${spotlight.tickets} chamados em ${spotlight.categoryName}`
: "Nenhum agente se destacou neste período."}
</p>
</CardContent>
</Card>
</div>
)}
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="text-lg font-semibold text-neutral-900">Categorias mais atendidas</CardTitle>
<CardDescription className="text-neutral-600">
Compare o volume de solicitações por categoria e identifique quais agentes concentram o atendimento de cada tema.
</CardDescription>
<CardAction>
<div className="flex flex-wrap items-center justify-end gap-2">
<SearchableCombobox
value={companyId}
onValueChange={(next) => setCompanyId(next ?? "all")}
options={companyOptions}
placeholder="Todas as empresas"
className="w-full min-w-56 md:w-64"
/>
<ToggleGroup
type="single"
value={timeRange}
onValueChange={(value) => value && setTimeRange(value)}
variant="outline"
className="hidden md:flex"
>
<ToggleGroupItem value="90d">90 dias</ToggleGroupItem>
<ToggleGroupItem value="30d">30 dias</ToggleGroupItem>
<ToggleGroupItem value="7d">7 dias</ToggleGroupItem>
</ToggleGroup>
</div>
</CardAction>
</CardHeader>
<CardContent className="space-y-6">
{!data ? (
<div className="space-y-4">
<Skeleton className="h-[260px] w-full rounded-2xl" />
<Skeleton className="h-48 w-full rounded-2xl" />
</div>
) : data.categories.length === 0 ? (
<div className="rounded-2xl border border-dashed border-slate-200 bg-slate-50/70 p-8 text-center text-sm text-neutral-500">
Nenhum ticket encontrado para o período selecionado.
</div>
) : (
<>
<div className="grid gap-6 lg:grid-cols-[1.2fr_1fr]">
<ChartContainer config={chartConfig} className="h-full w-full" style={{ minHeight: chartHeight }}>
<BarChart data={chartData} layout="vertical" margin={{ right: 16, left: 0 }}>
<CartesianGrid strokeDasharray="3 3" horizontal={false} />
<XAxis type="number" hide domain={[0, "dataMax"]} />
<YAxis dataKey="name" type="category" tickLine={false} axisLine={false} width={160} />
<ChartTooltip
cursor={{ fill: "hsl(var(--muted))" }}
content={
<ChartTooltipContent
formatter={(value, name, item) => (
<div className="flex w-full flex-col gap-1">
<span className="text-xs text-muted-foreground">{item?.payload?.topAgent ?? "Sem responsável"}</span>
<span className="font-semibold text-foreground">
{numberFormatter.format(Number(value))} tickets
</span>
</div>
)}
/>
}
/>
<Bar dataKey="tickets" fill="var(--color-tickets)" radius={[0, 6, 6, 0]} barSize={20} />
</BarChart>
</ChartContainer>
<div className="space-y-3">
<h4 className="text-sm font-semibold text-neutral-900">Agentes por categoria</h4>
<div className="space-y-3">
{tableData.slice(0, 4).map((category) => {
const share = totalTickets > 0 ? (category.total / totalTickets) * 100 : 0
const agent = category.topAgent
return (
<div key={`${category.id ?? "uncategorized"}-card`} className="rounded-2xl border border-slate-200 bg-slate-50/70 p-4">
<div className="flex items-center justify-between gap-2">
<div>
<p className="text-sm font-semibold text-neutral-900">{category.name}</p>
<p className="text-xs text-neutral-500">{numberFormatter.format(category.total)} tickets · {percentFormatter.format(share)}%</p>
</div>
<Badge variant="outline" className="rounded-full px-3 py-1 text-xs font-semibold">
{agent?.name ?? "Sem responsável"}
</Badge>
</div>
<p className="mt-2 text-xs text-neutral-500">
{agent ? `${numberFormatter.format(agent.total)} chamados atribuídos a ${agent.name ?? "—"}` : "Sem agentes atribuídos."}
</p>
</div>
)
})}
</div>
</div>
</div>
<div className="rounded-2xl border border-slate-200">
<Table>
<TableHeader>
<TableRow>
<TableHead>Categoria</TableHead>
<TableHead className="text-right">Tickets</TableHead>
<TableHead className="text-right">Participação</TableHead>
<TableHead className="text-right">Agente destaque</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tableData.map((category) => {
const share = totalTickets > 0 ? (category.total / totalTickets) * 100 : 0
return (
<TableRow key={category.id ?? `sem-categoria-${category.name}`}>
<TableCell className="font-medium text-neutral-900">{category.name}</TableCell>
<TableCell className="text-right font-mono text-sm">{numberFormatter.format(category.total)}</TableCell>
<TableCell className="text-right text-sm text-neutral-600">{percentFormatter.format(share)}%</TableCell>
<TableCell className="text-right text-sm text-neutral-700">
{category.topAgent ? (
<div className="flex flex-col items-end">
<span className="font-semibold text-neutral-900">{category.topAgent.name ?? "—"}</span>
<span className="text-xs text-neutral-500">{category.topAgent.total} chamados</span>
</div>
) : (
<span className="text-neutral-400">Sem responsável</span>
)}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</>
)}
</CardContent>
</Card>
</div>
)
}

View file

@ -174,10 +174,9 @@ export function TicketCustomFieldsList({ record, emptyMessage, className }: Tick
type TicketCustomFieldsSectionProps = {
ticket: TicketWithDetails
hidePreview?: boolean
}
export function TicketCustomFieldsSection({ ticket, hidePreview = false }: TicketCustomFieldsSectionProps) {
export function TicketCustomFieldsSection({ ticket }: TicketCustomFieldsSectionProps) {
const { convexUserId, role } = useAuth()
const canEdit = Boolean(convexUserId && (role === "admin" || role === "agent"))
@ -319,14 +318,10 @@ export function TicketCustomFieldsSection({ ticket, hidePreview = false }: Ticke
</Button>
) : null}
</div>
{hidePreview ? (
<p className="text-xs text-neutral-500">Visualize os valores no resumo principal.</p>
) : (
<TicketCustomFieldsList
record={ticket.customFields}
emptyMessage="Nenhum campo adicional preenchido neste chamado."
/>
)}
<TicketCustomFieldsList
record={ticket.customFields}
emptyMessage="Nenhum campo adicional preenchido neste chamado."
/>
<Dialog open={editorOpen} onOpenChange={setEditorOpen}>
<DialogContent className="max-w-3xl gap-4">

View file

@ -5,9 +5,9 @@ import { ptBR } from "date-fns/locale"
import type { TicketWithDetails } from "@/lib/schemas/ticket"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { getTicketStatusLabel, getTicketStatusSummaryTone } from "@/lib/ticket-status-style"
import { TicketCustomFieldsSection } from "@/components/tickets/ticket-custom-fields"
import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
import { TicketCustomFieldsSection } from "@/components/tickets/ticket-custom-fields"
interface TicketDetailsPanelProps {
ticket: TicketWithDetails
@ -53,19 +53,19 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
const isAvulso = Boolean(ticket.company?.isAvulso)
const companyLabel = ticket.company?.name ?? (isAvulso ? "Cliente avulso" : "Sem empresa vinculada")
const summaryChips = useMemo(
() => [
const summaryChips = useMemo(() => {
const chips: Array<{ key: string; label: string; value: string; tone: SummaryTone }> = [
{
key: "queue",
label: "Fila",
value: ticket.queue ?? "Sem fila",
tone: ticket.queue ? ("default" as SummaryTone) : ("muted" as SummaryTone),
tone: ticket.queue ? "default" : "muted",
},
{
key: "company",
label: "Empresa",
value: companyLabel,
tone: isAvulso ? ("warning" as SummaryTone) : ("default" as SummaryTone),
tone: isAvulso ? "warning" : "default",
},
{
key: "status",
@ -83,11 +83,19 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
key: "assignee",
label: "Responsável",
value: ticket.assignee?.name ?? "Não atribuído",
tone: ticket.assignee ? ("default" as SummaryTone) : ("muted" as SummaryTone),
tone: ticket.assignee ? "default" : "muted",
},
],
[companyLabel, isAvulso, ticket.assignee, ticket.priority, ticket.queue, ticket.status]
)
]
if (ticket.formTemplateLabel) {
chips.push({
key: "formTemplate",
label: "Tipo de solicitação",
value: ticket.formTemplateLabel,
tone: "info",
})
}
return chips
}, [companyLabel, isAvulso, ticket.assignee, ticket.formTemplateLabel, ticket.priority, ticket.queue, ticket.status])
const agentTotals = useMemo(() => {
const totals = ticket.workSummary?.perAgentTotals ?? []
@ -129,8 +137,6 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
</div>
</section>
<TicketCustomFieldsSection ticket={ticket} hidePreview />
<section className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-neutral-900">SLA & métricas</h3>
@ -184,6 +190,8 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
</div>
</section>
<TicketCustomFieldsSection ticket={ticket} />
<section className="space-y-3">
<h3 className="text-sm font-semibold text-neutral-900">Tempo por agente</h3>
{agentTotals.length > 0 ? (

View file

@ -27,7 +27,6 @@ import { Textarea } from "@/components/ui/textarea"
import { Spinner } from "@/components/ui/spinner"
import { useTicketCategories } from "@/hooks/use-ticket-categories"
import { useDefaultQueues } from "@/hooks/use-default-queues"
import { mapTicketCustomFields } from "@/lib/ticket-custom-fields"
import {
DropdownMenu,
DropdownMenuContent,
@ -214,7 +213,6 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
queuesEnabled ? { tenantId: ticket.tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
)
const queues: TicketQueueSummary[] = Array.isArray(queuesResult) ? queuesResult : []
const customFieldEntries = useMemo(() => mapTicketCustomFields(ticket.customFields), [ticket.customFields])
const { categories, isLoading: categoriesLoading } = useTicketCategories(ticket.tenantId)
const workSummaryRemote = useQuery(
api.tickets.workSummary,
@ -1300,13 +1298,9 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<h1 className="break-words text-2xl font-semibold text-neutral-900">{subject}</h1>
{ticket.formTemplate ? (
{ticket.formTemplateLabel || ticket.formTemplate ? (
<span className="inline-flex items-center rounded-full border border-sky-200 bg-sky-50 px-2.5 py-0.5 text-xs font-semibold text-sky-700">
{ticket.formTemplate === "admissao"
? "Admissão"
: ticket.formTemplate === "desligamento"
? "Desligamento"
: "Chamado"}
{ticket.formTemplateLabel ?? ticket.formTemplate}
</span>
) : null}
</div>
@ -1585,24 +1579,6 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
</div>
) : null}
</div>
<div className="mt-6 space-y-2">
<span className={sectionLabelClass}>Informações adicionais</span>
{customFieldEntries.length > 0 ? (
<div className="grid gap-3 sm:grid-cols-2">
{customFieldEntries.map((entry) => (
<div
key={entry.key}
className="rounded-2xl border border-slate-200 bg-white px-4 py-3 shadow-sm"
>
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">{entry.label}</p>
<p className="mt-1 text-sm font-semibold text-neutral-900">{entry.formattedValue}</p>
</div>
))}
</div>
) : (
<p className="text-xs text-neutral-500">Nenhum campo adicional preenchido para este chamado.</p>
)}
</div>
<Dialog open={pauseDialogOpen} onOpenChange={setPauseDialogOpen}>
<DialogContent>
<DialogHeader>

View file

@ -221,14 +221,10 @@ export function TicketsTable({ tickets, enteringIds }: TicketsTableProps) {
<span className="text-sm text-neutral-600 line-clamp-1 break-words">
{ticket.summary ?? "Sem resumo"}
</span>
{ticket.formTemplate ? (
{ticket.formTemplateLabel || ticket.formTemplate ? (
<div className="flex items-center gap-2">
<Badge className="rounded-full border border-sky-200 bg-sky-50 px-2.5 py-0.5 text-[11px] font-semibold text-sky-700">
{ticket.formTemplate === "admissao"
? "Admissão"
: ticket.formTemplate === "desligamento"
? "Desligamento"
: "Chamado"}
{ticket.formTemplateLabel ?? ticket.formTemplate}
</Badge>
</div>
) : null}

View file

@ -50,6 +50,7 @@ const serverTicketSchema = z.object({
channel: z.string(),
queue: z.string().nullable(),
formTemplate: z.string().nullable().optional(),
formTemplateLabel: z.string().nullable().optional(),
requester: serverUserSchema,
assignee: serverUserSchema.nullable(),
company: z
@ -198,6 +199,7 @@ export function mapTicketFromServer(input: unknown) {
csatComment: typeof csatComment === "string" && csatComment.trim().length > 0 ? csatComment.trim() : null,
csatRatedAt: csatRatedAt ? new Date(csatRatedAt) : null,
csatRatedBy: csatRatedBy ?? null,
formTemplateLabel: base.formTemplateLabel ?? null,
workSummary: s.workSummary
? {
totalWorkedMs: s.workSummary.totalWorkedMs,

View file

@ -116,9 +116,9 @@ export const ticketCustomFieldValueSchema = z.object({
export type TicketCustomFieldValue = z.infer<typeof ticketCustomFieldValueSchema>
export const ticketSchema = z.object({
id: z.string(),
reference: z.number(),
tenantId: z.string(),
id: z.string(),
reference: z.number(),
tenantId: z.string(),
subject: z.string(),
summary: z.string().optional(),
status: ticketStatusSchema,
@ -129,7 +129,7 @@ export const ticketSchema = z.object({
assignee: userSummarySchema.nullable(),
company: ticketCompanySummarySchema.optional().nullable(),
machine: ticketMachineSummarySchema.nullable().optional(),
slaPolicy: z
slaPolicy: z
.object({
id: z.string(),
name: z.string(),
@ -158,6 +158,7 @@ export const ticketSchema = z.object({
reopenedBy: z.string().nullable().optional(),
chatEnabled: z.boolean().optional(),
formTemplate: z.string().nullable().optional(),
formTemplateLabel: z.string().nullable().optional(),
csatScore: z.number().nullable().optional(),
csatMaxScore: z.number().nullable().optional(),
csatComment: z.string().nullable().optional(),