Ajusta placeholders, formulários e widgets
This commit is contained in:
parent
343f0c8c64
commit
b94cea2f9a
33 changed files with 2122 additions and 462 deletions
|
|
@ -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 > 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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
345
src/components/admin/fields/ticket-form-templates-manager.tsx
Normal file
345
src/components/admin/fields/ticket-form-templates-manager.tsx
Normal 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 "Novo formulário" 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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" : "",
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
300
src/components/reports/category-report.tsx
Normal file
300
src/components/reports/category-report.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue