')
@@ -2225,7 +2328,7 @@ function MachinesGrid({ machines, companyNameBySlug }: { machines: MachinesQuery
function MachineCard({ machine, companyName }: { machine: MachinesQueryItem; companyName?: string | null }) {
const effectiveStatus = resolveMachineStatus(machine)
- const { className } = getStatusVariant(effectiveStatus)
+ const isActive = machine.isActive
const lastHeartbeat = machine.lastHeartbeatAt ? new Date(machine.lastHeartbeatAt) : null
type AgentMetrics = {
memoryUsedBytes?: number
@@ -2264,19 +2367,23 @@ function MachineCard({ machine, companyName }: { machine: MachinesQueryItem; com
return (
-
+
{effectiveStatus === "online" ? (
@@ -2288,6 +2395,11 @@ function MachineCard({ machine, companyName }: { machine: MachinesQueryItem; com
{machine.hostname}
{machine.authEmail ?? "—"}
+ {!isActive ? (
+
+ Desativada
+
+ ) : null}
@@ -2351,52 +2463,243 @@ function DetailLine({ label, value, classNameValue }: DetailLineProps) {
)
}
-function MetricsGrid({ metrics }: { metrics: MachineMetrics }) {
+function InfoChip({ label, value, icon, tone = "default" }: { label: string; value: string; icon?: ReactNode; tone?: "default" | "warning" | "muted" }) {
+ const toneClasses =
+ tone === "warning"
+ ? "border-amber-200 bg-amber-50 text-amber-700"
+ : tone === "muted"
+ ? "border-slate-200 bg-slate-50 text-neutral-600"
+ : "border-slate-200 bg-white text-neutral-800"
+
+ return (
+
+ {icon ?
{icon} : null}
+
+
+ )
+}
+
+function clampPercent(raw: number): number {
+ if (!Number.isFinite(raw)) return 0
+ const normalized = raw > 1 && raw <= 100 ? raw : raw <= 1 ? raw * 100 : raw
+ return Math.max(0, Math.min(100, normalized))
+}
+
+function deriveUsageMetrics({
+ metrics,
+ hardware,
+ disks,
+}: {
+ metrics: MachineMetrics
+ hardware?: MachineInventory["hardware"]
+ disks?: MachineInventory["disks"]
+}) {
const data = (metrics ?? {}) as Record
- // Compat: aceitar chaves do agente desktop (cpuUsagePercent, memoryUsedBytes, memoryTotalBytes)
- const cpu = (() => {
- const v = Number(
- data.cpuUsage ?? data.cpu ?? data.cpu_percent ?? data.cpuUsagePercent ?? NaN
- )
- return v
- })()
- const memory = (() => {
- // valor absoluto em bytes, se disponível
- const memBytes = Number(
- data.memoryBytes ?? data.memory ?? data.memory_used ?? data.memoryUsedBytes ?? NaN
- )
- if (Number.isFinite(memBytes)) return memBytes
- // tentar derivar a partir de percentuais do agente
- const usedPct = Number(data.memoryUsedPercent ?? NaN)
- const totalBytes = Number(data.memoryTotalBytes ?? NaN)
- if (Number.isFinite(usedPct) && Number.isFinite(totalBytes)) {
- return Math.max(0, Math.min(1, usedPct > 1 ? usedPct / 100 : usedPct)) * totalBytes
+
+ const cpuRaw = Number(
+ data.cpuUsagePercent ?? data.cpuUsage ?? data.cpu_percent ?? data.cpu ?? NaN
+ )
+ const cpuPercent = Number.isFinite(cpuRaw) ? clampPercent(cpuRaw) : null
+
+ const totalCandidates = [
+ data.memoryTotalBytes,
+ data.memory_total,
+ data.memoryTotal,
+ hardware?.memoryBytes,
+ hardware?.memory,
+ ]
+ let memoryTotalBytes: number | null = null
+ for (const candidate of totalCandidates) {
+ const parsed = parseBytesLike(candidate)
+ if (parsed && Number.isFinite(parsed) && parsed > 0) {
+ memoryTotalBytes = parsed
+ break
}
- return NaN
- })()
- const disk = Number(data.diskUsage ?? data.disk ?? NaN)
- const gpuUsage = Number(
- data.gpuUsage ?? data.gpu ?? data.gpuUsagePercent ?? data.gpu_percent ?? NaN
+ const numeric = Number(candidate)
+ if (Number.isFinite(numeric) && numeric > 0) {
+ memoryTotalBytes = numeric
+ break
+ }
+ }
+
+ const usedCandidates = [
+ data.memoryUsedBytes,
+ data.memoryBytes,
+ data.memory_used,
+ data.memory,
+ ]
+ let memoryUsedBytes: number | null = null
+ for (const candidate of usedCandidates) {
+ const parsed = parseBytesLike(candidate)
+ if (parsed !== undefined && Number.isFinite(parsed)) {
+ memoryUsedBytes = parsed
+ break
+ }
+ const numeric = Number(candidate)
+ if (Number.isFinite(numeric)) {
+ memoryUsedBytes = numeric
+ break
+ }
+ }
+
+ const memoryPercentRaw = Number(data.memoryUsedPercent ?? data.memory_percent ?? NaN)
+ let memoryPercent = Number.isFinite(memoryPercentRaw) ? clampPercent(memoryPercentRaw) : null
+ if (memoryTotalBytes && memoryUsedBytes === null && memoryPercent !== null) {
+ memoryUsedBytes = (memoryPercent / 100) * memoryTotalBytes
+ } else if (memoryTotalBytes && memoryUsedBytes !== null) {
+ memoryPercent = clampPercent((memoryUsedBytes / memoryTotalBytes) * 100)
+ }
+
+ let diskTotalBytes: number | null = null
+ let diskUsedBytes: number | null = null
+ let diskPercent: number | null = null
+ if (Array.isArray(disks) && disks.length > 0) {
+ let total = 0
+ let available = 0
+ disks.forEach((disk) => {
+ const totalParsed = parseBytesLike(disk?.totalBytes)
+ if (typeof totalParsed === "number" && Number.isFinite(totalParsed) && totalParsed > 0) {
+ total += totalParsed
+ }
+ const availableParsed = parseBytesLike(disk?.availableBytes)
+ if (typeof availableParsed === "number" && Number.isFinite(availableParsed) && availableParsed >= 0) {
+ available += availableParsed
+ }
+ })
+ if (total > 0) {
+ diskTotalBytes = total
+ const used = Math.max(0, total - available)
+ diskUsedBytes = used
+ diskPercent = clampPercent((used / total) * 100)
+ }
+ }
+ if (diskPercent === null) {
+ const diskMetric = Number(
+ data.diskUsage ?? data.disk ?? data.diskUsedPercent ?? data.storageUsedPercent ?? NaN
+ )
+ if (Number.isFinite(diskMetric)) {
+ diskPercent = clampPercent(diskMetric)
+ }
+ }
+
+ const gpuMetric = Number(
+ data.gpuUsagePercent ?? data.gpuUsage ?? data.gpu_percent ?? data.gpu ?? NaN
+ )
+ const gpuPercent = Number.isFinite(gpuMetric) ? clampPercent(gpuMetric) : null
+
+ return {
+ cpuPercent,
+ memoryUsedBytes,
+ memoryTotalBytes,
+ memoryPercent,
+ diskPercent,
+ diskUsedBytes,
+ diskTotalBytes,
+ gpuPercent,
+ }
+}
+
+function MetricsGrid({ metrics, hardware, disks }: { metrics: MachineMetrics; hardware?: MachineInventory["hardware"]; disks?: MachineInventory["disks"] }) {
+ const derived = useMemo(
+ () => deriveUsageMetrics({ metrics, hardware, disks }),
+ [metrics, hardware, disks]
)
- const cards: Array<{ label: string; value: string }> = [
- { label: "CPU", value: formatPercent(cpu) },
- { label: "Memória", value: formatBytes(memory) },
- { label: "Disco", value: Number.isNaN(disk) ? "—" : formatPercent(disk) },
- ]
+ const cards = [
+ {
+ key: "cpu",
+ label: "CPU",
+ percent: derived.cpuPercent,
+ primaryText: derived.cpuPercent !== null ? formatPercent(derived.cpuPercent) : "Sem dados",
+ secondaryText: derived.cpuPercent !== null ? "Uso instantâneo" : "Sem leituras recentes",
+ icon: ,
+ color: "var(--chart-1)",
+ },
+ {
+ key: "memory",
+ label: "Memória",
+ percent: derived.memoryPercent,
+ primaryText:
+ derived.memoryUsedBytes !== null && derived.memoryTotalBytes !== null
+ ? `${formatBytes(derived.memoryUsedBytes)} / ${formatBytes(derived.memoryTotalBytes)}`
+ : derived.memoryPercent !== null
+ ? formatPercent(derived.memoryPercent)
+ : "Sem dados",
+ secondaryText: derived.memoryPercent !== null ? `${Math.round(derived.memoryPercent)}% em uso` : null,
+ icon: ,
+ color: "var(--chart-2)",
+ },
+ {
+ key: "disk",
+ label: "Disco",
+ percent: derived.diskPercent,
+ primaryText:
+ derived.diskUsedBytes !== null && derived.diskTotalBytes !== null
+ ? `${formatBytes(derived.diskUsedBytes)} / ${formatBytes(derived.diskTotalBytes)}`
+ : derived.diskPercent !== null
+ ? formatPercent(derived.diskPercent)
+ : "Sem dados",
+ secondaryText: derived.diskPercent !== null ? `${Math.round(derived.diskPercent)}% utilizado` : null,
+ icon: ,
+ color: "var(--chart-3)",
+ },
+ ] as Array<{ key: string; label: string; percent: number | null; primaryText: string; secondaryText?: string | null; icon: ReactNode; color: string }>
- if (!Number.isNaN(gpuUsage)) {
- cards.push({ label: "GPU", value: formatPercent(gpuUsage) })
+ if (derived.gpuPercent !== null) {
+ cards.push({
+ key: "gpu",
+ label: "GPU",
+ percent: derived.gpuPercent,
+ primaryText: formatPercent(derived.gpuPercent),
+ secondaryText: null,
+ icon: ,
+ color: "var(--chart-4)",
+ })
}
return (
-
- {cards.map((card) => (
-
-
{card.label}
-
{card.value}
-
- ))}
+
+ {cards.map((card) => {
+ const percentValue = Number.isFinite(card.percent ?? NaN) ? Math.max(0, Math.min(100, card.percent ?? 0)) : 0
+ const percentLabel = card.percent !== null ? `${Math.round(card.percent)}%` : "—"
+ return (
+
+
+
+
+
+
+
+
+
+ {percentLabel}
+
+
+
+
+ {card.icon}
+ {card.label}
+
+
{card.primaryText}
+ {card.secondaryText ? (
+
{card.secondaryText}
+ ) : null}
+
+
+ )
+ })}
)
}
diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx
index a4b4b0d..fdb0b0e 100644
--- a/src/components/app-sidebar.tsx
+++ b/src/components/app-sidebar.tsx
@@ -15,11 +15,11 @@ import {
Clock4,
Timer,
MonitorCog,
- Layers3,
UserPlus,
BellRing,
ChevronDown,
ShieldCheck,
+ Users,
} from "lucide-react"
import { usePathname } from "next/navigation"
import Link from "next/link"
@@ -86,7 +86,7 @@ const navigation: NavigationGroup[] = [
{ title: "SLA e produtividade", url: "/reports/sla", icon: TrendingUp, requiredRole: "staff" },
{ title: "Qualidade (CSAT)", url: "/reports/csat", icon: LifeBuoy, requiredRole: "staff" },
{ title: "Backlog", url: "/reports/backlog", icon: BarChart3, requiredRole: "staff" },
- { title: "Horas por cliente", url: "/reports/hours", icon: Clock4, requiredRole: "staff" },
+ { title: "Horas", url: "/reports/hours", icon: Clock4, requiredRole: "staff" },
],
},
{
@@ -102,9 +102,14 @@ const navigation: NavigationGroup[] = [
},
{ title: "Canais & roteamento", url: "/admin/channels", icon: Waypoints, requiredRole: "admin" },
{ title: "Times & papéis", url: "/admin/teams", icon: UserCog, requiredRole: "admin" },
- { title: "Empresas & clientes", url: "/admin/companies", icon: Building2, requiredRole: "admin" },
+ {
+ title: "Empresas",
+ url: "/admin/companies",
+ icon: Building2,
+ requiredRole: "admin",
+ children: [{ title: "Clientes", url: "/admin/clients", icon: Users, requiredRole: "admin" }],
+ },
{ title: "Máquinas", url: "/admin/machines", icon: MonitorCog, requiredRole: "admin" },
- { title: "Campos personalizados", url: "/admin/fields", icon: Layers3, requiredRole: "admin" },
{ title: "SLAs", url: "/admin/slas", icon: Timer, requiredRole: "admin" },
{ title: "Alertas enviados", url: "/admin/alerts", icon: BellRing, requiredRole: "admin" },
],
diff --git a/src/components/charts/chart-opened-resolved.tsx b/src/components/charts/chart-opened-resolved.tsx
index 4975112..278e56a 100644
--- a/src/components/charts/chart-opened-resolved.tsx
+++ b/src/components/charts/chart-opened-resolved.tsx
@@ -86,12 +86,12 @@ export function ChartOpenedResolved() {
{data.series.length === 0 ? (
-
+
Sem dados suficientes no período selecionado.
) : (
-
-
+
+
}
/>
-
-
+
+
)}
diff --git a/src/components/portal/portal-ticket-detail.tsx b/src/components/portal/portal-ticket-detail.tsx
index 7a00520..ef93070 100644
--- a/src/components/portal/portal-ticket-detail.tsx
+++ b/src/components/portal/portal-ticket-detail.tsx
@@ -15,6 +15,7 @@ import { useAuth } from "@/lib/auth-client"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Dropzone } from "@/components/ui/dropzone"
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
import { Skeleton } from "@/components/ui/skeleton"
@@ -60,13 +61,26 @@ type ClientTimelineEntry = {
}
export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
- const { convexUserId, session, isCustomer } = useAuth()
+ const { convexUserId, session, isCustomer, machineContext } = useAuth()
const addComment = useMutation(api.tickets.addComment)
const getFileUrl = useAction(api.files.getUrl)
const [comment, setComment] = useState("")
const [attachments, setAttachments] = useState<
Array<{ storageId: string; name: string; size?: number; type?: string; previewUrl?: string }>
>([])
+ const attachmentsTotalBytes = useMemo(
+ () => attachments.reduce((acc, item) => acc + (item.size ?? 0), 0),
+ [attachments]
+ )
+ const [previewAttachment, setPreviewAttachment] = useState<{ url: string; name: string; type?: string } | null>(null)
+ const isPreviewImage = useMemo(() => {
+ if (!previewAttachment) return false
+ const type = previewAttachment.type ?? ""
+ if (type.startsWith("image/")) return true
+ const name = previewAttachment.name ?? ""
+ return /\.(png|jpe?g|gif|webp|svg)$/i.test(name)
+ }, [previewAttachment])
+ const machineInactive = machineContext?.isActive === false
const ticketRaw = useQuery(
api.tickets.getById,
@@ -225,6 +239,10 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
const updatedAgo = formatDistanceToNow(ticket.updatedAt, { addSuffix: true, locale: ptBR })
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
+ if (machineInactive) {
+ toast.error("Esta máquina está desativada. Reative-a para enviar novas mensagens.")
+ return
+ }
if (!convexUserId || !comment.trim() || !ticket) return
const toastId = "portal-add-comment"
toast.loading("Enviando comentário...", { id: toastId })
@@ -303,8 +321,13 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
+ {machineInactive ? (
+
+ Esta máquina está desativada. Ative-a novamente para enviar novas mensagens.
+
+ ) : null}
+ { if (!open) setPreviewAttachment(null) }}>
+
+
+
+ {previewAttachment?.name ?? "Visualização do anexo"}
+
+
+
+
+
+ {previewAttachment ? (
+ isPreviewImage ? (
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
+
+ ) : (
+
+
Não é possível visualizar este tipo de arquivo aqui. Abra em uma nova aba para conferi-lo.
+
+ Abrir em nova aba
+
+
+ )
+ ) : null}
+
+
)
}
@@ -488,7 +547,15 @@ function DetailItem({ label, value, subtitle }: DetailItemProps) {
type CommentAttachment = TicketWithDetails["comments"][number]["attachments"][number]
type GetFileUrlAction = (args: { storageId: Id<"_storage"> }) => Promise
-function PortalCommentAttachmentCard({ attachment, getFileUrl }: { attachment: CommentAttachment; getFileUrl: GetFileUrlAction }) {
+function PortalCommentAttachmentCard({
+ attachment,
+ getFileUrl,
+ onOpenPreview,
+}: {
+ attachment: CommentAttachment
+ getFileUrl: GetFileUrlAction
+ onOpenPreview: (payload: { url: string; name: string; type?: string }) => void
+}) {
const [url, setUrl] = useState(attachment.url ?? null)
const [loading, setLoading] = useState(false)
const [errored, setErrored] = useState(false)
@@ -532,10 +599,13 @@ function PortalCommentAttachmentCard({ attachment, getFileUrl }: { attachment: C
const handlePreview = useCallback(async () => {
const target = await ensureUrl()
- if (target) {
- window.open(target, "_blank", "noopener,noreferrer")
+ if (!target) return
+ if (isImageType) {
+ onOpenPreview({ url: target, name: attachment.name ?? "Anexo", type: attachment.type ?? undefined })
+ return
}
- }, [ensureUrl])
+ window.open(target, "_blank", "noopener,noreferrer")
+ }, [attachment.name, attachment.type, ensureUrl, isImageType, onOpenPreview])
const handleDownload = useCallback(async () => {
const target = await ensureUrl()
diff --git a/src/components/portal/portal-ticket-list.tsx b/src/components/portal/portal-ticket-list.tsx
index 6c3fbc6..3ae286c 100644
--- a/src/components/portal/portal-ticket-list.tsx
+++ b/src/components/portal/portal-ticket-list.tsx
@@ -41,12 +41,16 @@ export function PortalTicketList() {
if (isLoading) {
return (
-
-
- Carregando chamados...
-
-
- Estamos buscando seus chamados mais recentes. Isso deve levar apenas alguns instantes.
+
+
+
+
+
+
Carregando chamados...
+
+ Estamos buscando seus chamados mais recentes. Isso deve levar apenas alguns instantes.
+
+
)
diff --git a/src/components/reports/hours-report.tsx b/src/components/reports/hours-report.tsx
index adf954d..50ed6f8 100644
--- a/src/components/reports/hours-report.tsx
+++ b/src/components/reports/hours-report.tsx
@@ -14,11 +14,7 @@ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { usePersistentCompanyFilter } from "@/lib/use-company-filter"
-
-function formatHours(ms: number) {
- const hours = ms / 3600000
- return hours.toFixed(2)
-}
+import { Progress } from "@/components/ui/progress"
type HoursItem = {
companyId: string
@@ -52,16 +48,58 @@ export function HoursReport() {
return list
}, [data?.items, query, companyId])
+ const totals = useMemo(() => {
+ return filtered.reduce(
+ (acc, item) => {
+ acc.internal += item.internalMs / 3600000
+ acc.external += item.externalMs / 3600000
+ acc.total += item.totalMs / 3600000
+ return acc
+ },
+ { internal: 0, external: 0, total: 0 }
+ )
+ }, [filtered])
+
+ const numberFormatter = useMemo(
+ () =>
+ new Intl.NumberFormat("pt-BR", {
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ }),
+ []
+ )
+
+ const filteredWithComputed = useMemo(
+ () =>
+ filtered.map((row) => {
+ const internal = row.internalMs / 3600000
+ const external = row.externalMs / 3600000
+ const total = row.totalMs / 3600000
+ const contracted = row.contractedHoursPerMonth ?? null
+ const usagePercent =
+ contracted && contracted > 0 ? Math.min(100, Math.round((total / contracted) * 100)) : null
+ return {
+ ...row,
+ internal,
+ external,
+ total,
+ contracted,
+ usagePercent,
+ }
+ }),
+ [filtered]
+ )
+
return (
- Horas por cliente
- Horas internas e externas registradas por empresa.
+ Horas
+ Visualize o esforço interno e externo por empresa e acompanhe o consumo contratado.
-
-
-
-
- Cliente
- Avulso
- Horas internas
- Horas externas
- Total
- Contratadas/mês
- Uso
-
-
-
- {filtered.map((row) => {
- const totalH = Number(formatHours(row.totalMs))
- const contracted = row.contractedHoursPerMonth ?? null
- const pct = contracted ? Math.round((totalH / contracted) * 100) : null
- const pctBadgeVariant: "secondary" | "destructive" = pct !== null && pct >= 90 ? "destructive" : "secondary"
- return (
-
- {row.name}
- {row.isAvulso ? "Sim" : "Não"}
- {formatHours(row.internalMs)}
- {formatHours(row.externalMs)}
- {formatHours(row.totalMs)}
- {contracted ?? "—"}
-
- {pct !== null ? (
-
- {pct}%
-
- ) : (
- —
- )}
-
-
- )
- })}
-
-
+
+ {[
+ { key: "internal", label: "Horas internas", value: numberFormatter.format(totals.internal) },
+ { key: "external", label: "Horas externas", value: numberFormatter.format(totals.external) },
+ { key: "total", label: "Total acumulado", value: numberFormatter.format(totals.total) },
+ ].map((item) => (
+
+
{item.label}
+
{item.value} h
+
+ ))}
+
+ {filteredWithComputed.length === 0 ? (
+
+ Nenhuma empresa encontrada para o filtro selecionado.
+
+ ) : (
+
+ {filteredWithComputed.map((row) => (
+
+
+
+
{row.name}
+
ID {row.companyId}
+
+
+ {row.isAvulso ? "Cliente avulso" : "Recorrente"}
+
+
+
+
+ Horas internas
+ {numberFormatter.format(row.internal)} h
+
+
+ Horas externas
+ {numberFormatter.format(row.external)} h
+
+
+ Total
+ {numberFormatter.format(row.total)} h
+
+
+
+
+ Contratadas/mês
+
+ {row.contracted ? `${numberFormatter.format(row.contracted)} h` : "—"}
+
+
+
+
+ Uso
+
+ {row.usagePercent !== null ? `${row.usagePercent}%` : "—"}
+
+
+ {row.usagePercent !== null ? (
+
+ ) : (
+
+ Defina horas contratadas para acompanhar o uso
+
+ )}
+
+
+
+ ))}
+
+ )}
diff --git a/src/components/section-cards.tsx b/src/components/section-cards.tsx
index 4d3b339..808ef67 100644
--- a/src/components/section-cards.tsx
+++ b/src/components/section-cards.tsx
@@ -2,11 +2,12 @@
import { useMemo } from "react"
import { useQuery } from "convex/react"
-import { IconClockHour4, IconMessages, IconTrendingDown, IconTrendingUp } from "@tabler/icons-react"
+import { IconClockHour4, IconTrendingDown, IconTrendingUp } from "@tabler/icons-react"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { useAuth } from "@/lib/auth-client"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
+import { cn } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import {
Card,
@@ -27,11 +28,6 @@ function formatMinutes(value: number | null) {
return `${hours}h ${minutes}min`
}
-function formatScore(value: number | null) {
- if (value === null) return "—"
- return value.toFixed(2)
-}
-
export function SectionCards() {
const { session, convexUserId, isStaff } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
@@ -65,6 +61,29 @@ export function SectionCards() {
const TrendIcon = trendInfo.icon
+ const resolutionInfo = useMemo(() => {
+ if (!dashboard?.resolution) {
+ return {
+ positive: true,
+ badgeLabel: "Sem histórico",
+ rateLabel: "Taxa indisponível",
+ }
+ }
+ const current = dashboard.resolution.resolvedLast7d ?? 0
+ const previous = dashboard.resolution.previousResolved ?? 0
+ const deltaPercentage = dashboard.resolution.deltaPercentage ?? null
+ const positive = deltaPercentage !== null ? deltaPercentage >= 0 : current >= previous
+ const badgeLabel = deltaPercentage !== null
+ ? `${deltaPercentage >= 0 ? "+" : ""}${deltaPercentage.toFixed(1)}%`
+ : previous > 0
+ ? `${current - previous >= 0 ? "+" : ""}${current - previous}`
+ : "Sem histórico"
+ const rateLabel = dashboard.resolution.rate !== null
+ ? `${dashboard.resolution.rate.toFixed(1)}% dos tickets foram resolvidos`
+ : "Taxa indisponível"
+ return { positive, badgeLabel, rateLabel }
+ }, [dashboard])
+
return (
@@ -150,20 +169,30 @@ export function SectionCards() {
- CSAT recente
+ Tickets resolvidos (7 dias)
- {dashboard ? formatScore(dashboard.csat.averageScore) : }
+ {dashboard ? dashboard.resolution.resolvedLast7d : }
-
-
- {dashboard ? `${dashboard.csat.totalSurveys} pesquisas` : "—"}
+
+ {resolutionInfo.positive ? (
+
+ ) : (
+
+ )}
+ {resolutionInfo.badgeLabel}
- Notas de satisfação recebidas nos últimos períodos.
- Escala de 1 a 5 pontos.
+ {resolutionInfo.rateLabel}
+ Comparação com os 7 dias anteriores.
diff --git a/src/components/site-header.tsx b/src/components/site-header.tsx
index f48b205..554f65f 100644
--- a/src/components/site-header.tsx
+++ b/src/components/site-header.tsx
@@ -5,19 +5,26 @@ import { Separator } from "@/components/ui/separator"
import { SidebarTrigger } from "@/components/ui/sidebar"
import { cn } from "@/lib/utils"
-interface SiteHeaderProps {
- title: string
- lead?: string
- primaryAction?: ReactNode
- secondaryAction?: ReactNode
-}
-
+interface SiteHeaderProps {
+ title: string
+ lead?: string
+ primaryAction?: ReactNode
+ secondaryAction?: ReactNode
+ primaryAlignment?: "right" | "center"
+}
+
export function SiteHeader({
title,
lead,
primaryAction,
secondaryAction,
+ primaryAlignment = "right",
}: SiteHeaderProps) {
+ const actionsClassName =
+ primaryAlignment === "center" && !secondaryAction
+ ? "flex w-full flex-col items-stretch gap-2 sm:w-full sm:flex-row sm:items-center sm:justify-center"
+ : "flex w-full flex-col items-stretch gap-2 sm:w-auto sm:flex-row sm:items-center"
+
return (
@@ -26,7 +33,7 @@ export function SiteHeader({
{lead ? {lead} : null}
{title}
-
+
{secondaryAction}
{primaryAction}
diff --git a/src/components/tickets/new-ticket-dialog.client.tsx b/src/components/tickets/new-ticket-dialog.client.tsx
index df315bb..f4f07ee 100644
--- a/src/components/tickets/new-ticket-dialog.client.tsx
+++ b/src/components/tickets/new-ticket-dialog.client.tsx
@@ -3,10 +3,11 @@
import { useEffect, useState } from "react"
import { Button } from "@/components/ui/button"
+import { cn } from "@/lib/utils"
import { NewTicketDialog } from "./new-ticket-dialog"
-export function NewTicketDialogDeferred() {
+export function NewTicketDialogDeferred({ triggerClassName }: { triggerClassName?: string } = {}) {
const [mounted, setMounted] = useState(false)
useEffect(() => {
@@ -17,7 +18,10 @@ export function NewTicketDialogDeferred() {
return (
@@ -26,5 +30,5 @@ export function NewTicketDialogDeferred() {
)
}
- return
+ return
}
diff --git a/src/components/tickets/new-ticket-dialog.tsx b/src/components/tickets/new-ticket-dialog.tsx
index c17e2d3..45bcd12 100644
--- a/src/components/tickets/new-ticket-dialog.tsx
+++ b/src/components/tickets/new-ticket-dialog.tsx
@@ -25,6 +25,7 @@ import {
} from "@/components/tickets/priority-select"
import { CategorySelectFields } from "@/components/tickets/category-select"
import { useDefaultQueues } from "@/hooks/use-default-queues"
+import { cn } from "@/lib/utils"
const schema = z.object({
subject: z.string().default(""),
@@ -38,7 +39,7 @@ const schema = z.object({
subcategoryId: z.string().min(1, "Selecione uma categoria secundária"),
})
-export function NewTicketDialog() {
+export function NewTicketDialog({ triggerClassName }: { triggerClassName?: string } = {}) {
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
const form = useForm>({
@@ -76,6 +77,10 @@ export function NewTicketDialog() {
[staffRaw]
)
const [attachments, setAttachments] = useState>([])
+ const attachmentsTotalBytes = useMemo(
+ () => attachments.reduce((acc, item) => acc + (item.size ?? 0), 0),
+ [attachments]
+ )
const priorityValue = form.watch("priority") as TicketPriority
const channelValue = form.watch("channel")
const queueValue = form.watch("queueName") ?? "NONE"
@@ -200,7 +205,10 @@ export function NewTicketDialog() {
Novo ticket
@@ -279,6 +287,8 @@ export function NewTicketDialog() {
setAttachments((prev) => [...prev, ...files])}
className="space-y-1.5 [&>div:first-child]:rounded-2xl [&>div:first-child]:p-4 [&>div:first-child]:pb-5 [&>div:first-child]:shadow-sm"
+ currentFileCount={attachments.length}
+ currentTotalBytes={attachmentsTotalBytes}
/>
Formatos comuns de imagem e documentos são aceitos.
diff --git a/src/components/tickets/recent-tickets-panel.tsx b/src/components/tickets/recent-tickets-panel.tsx
index db5f2ce..b35a033 100644
--- a/src/components/tickets/recent-tickets-panel.tsx
+++ b/src/components/tickets/recent-tickets-panel.tsx
@@ -49,7 +49,7 @@ function TicketRow({ ticket, entering }: { ticket: Ticket; entering: boolean })
#{ticket.reference}
{queueLabel}
-
+
diff --git a/src/components/tickets/ticket-comments.rich.tsx b/src/components/tickets/ticket-comments.rich.tsx
index b725175..9d6fbbb 100644
--- a/src/components/tickets/ticket-comments.rich.tsx
+++ b/src/components/tickets/ticket-comments.rich.tsx
@@ -41,6 +41,10 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
const updateComment = useMutation(api.tickets.updateComment)
const [body, setBody] = useState("")
const [attachmentsToSend, setAttachmentsToSend] = useState
>([])
+ const attachmentsToSendTotalBytes = useMemo(
+ () => attachmentsToSend.reduce((acc, item) => acc + (item.size ?? 0), 0),
+ [attachmentsToSend]
+ )
const [preview, setPreview] = useState(null)
const [pending, setPending] = useState[]>([])
const [visibility, setVisibility] = useState<"PUBLIC" | "INTERNAL">("INTERNAL")
@@ -358,7 +362,11 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
)}