From 23fe67e7d3999ee1a400b110ff39e6cd3f3f5aea Mon Sep 17 00:00:00 2001 From: rever-tecnologia Date: Thu, 18 Dec 2025 08:00:40 -0300 Subject: [PATCH 1/2] feat(devices): implementa tabela separada para softwares instalados MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cria tabela machineSoftware no schema com indices otimizados - Adiciona mutations para sincronizar softwares do heartbeat - Atualiza heartbeat para processar e salvar softwares - Cria componente DeviceSoftwareList com pesquisa e paginacao - Integra lista de softwares no drawer de detalhes do dispositivo feat(sla): transforma formulario em modal completo - Substitui formulario inline por modal guiado - Adiciona badge "Global" para indicar escopo da politica - Adiciona seletor de unidade de tempo (minutos, horas, dias) - Melhora textos e adiciona dica sobre hierarquia de SLAs fix(reports): ajusta altura do SearchableCombobox 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- convex/machineSoftware.ts | 276 +++++++++++ convex/machines.ts | 30 +- convex/schema.ts | 19 + .../admin/devices/admin-devices-overview.tsx | 6 + .../admin/devices/device-software-list.tsx | 170 +++++++ src/components/admin/slas/slas-manager.tsx | 444 ++++++++++-------- src/components/reports/company-report.tsx | 1 + 7 files changed, 741 insertions(+), 205 deletions(-) create mode 100644 convex/machineSoftware.ts create mode 100644 src/components/admin/devices/device-software-list.tsx diff --git a/convex/machineSoftware.ts b/convex/machineSoftware.ts new file mode 100644 index 0000000..1615391 --- /dev/null +++ b/convex/machineSoftware.ts @@ -0,0 +1,276 @@ +import { mutation, query, internalMutation } from "./_generated/server" +import { v } from "convex/values" +import type { Id } from "./_generated/dataModel" + +// Tipo para software recebido do agente +type SoftwareInput = { + name: string + version?: string + publisher?: string + source?: string +} + +// Upsert de softwares de uma maquina (chamado pelo heartbeat) +export const syncFromHeartbeat = internalMutation({ + args: { + tenantId: v.string(), + machineId: v.id("machines"), + software: v.array( + v.object({ + name: v.string(), + version: v.optional(v.string()), + publisher: v.optional(v.string()), + source: v.optional(v.string()), + }) + ), + }, + handler: async (ctx, { tenantId, machineId, software }) => { + const now = Date.now() + + // Busca softwares existentes da maquina + const existing = await ctx.db + .query("machineSoftware") + .withIndex("by_machine", (q) => q.eq("machineId", machineId)) + .collect() + + const existingMap = new Map(existing.map((s) => [`${s.nameLower}|${s.version ?? ""}`, s])) + + // Processa cada software recebido + const seenKeys = new Set() + for (const item of software) { + if (!item.name || item.name.trim().length === 0) continue + + const nameLower = item.name.toLowerCase().trim() + const key = `${nameLower}|${item.version ?? ""}` + seenKeys.add(key) + + const existingDoc = existingMap.get(key) + if (existingDoc) { + // Atualiza lastSeenAt se ja existe + await ctx.db.patch(existingDoc._id, { + lastSeenAt: now, + publisher: item.publisher || existingDoc.publisher, + source: item.source || existingDoc.source, + }) + } else { + // Cria novo registro + await ctx.db.insert("machineSoftware", { + tenantId, + machineId, + name: item.name.trim(), + nameLower, + version: item.version?.trim() || undefined, + publisher: item.publisher?.trim() || undefined, + source: item.source?.trim() || undefined, + detectedAt: now, + lastSeenAt: now, + }) + } + } + + // Remove softwares que nao foram vistos (desinstalados) + // So remove se o software nao foi visto nas ultimas 24 horas + const staleThreshold = now - 24 * 60 * 60 * 1000 + for (const doc of existing) { + const key = `${doc.nameLower}|${doc.version ?? ""}` + if (!seenKeys.has(key) && doc.lastSeenAt < staleThreshold) { + await ctx.db.delete(doc._id) + } + } + + return { processed: software.length } + }, +}) + +// Lista softwares de uma maquina com paginacao e filtros +export const listByMachine = query({ + args: { + tenantId: v.string(), + viewerId: v.id("users"), + machineId: v.id("machines"), + search: v.optional(v.string()), + limit: v.optional(v.number()), + cursor: v.optional(v.string()), + }, + handler: async (ctx, { machineId, search, limit = 50, cursor }) => { + const pageLimit = Math.min(limit, 100) + + let query = ctx.db + .query("machineSoftware") + .withIndex("by_machine", (q) => q.eq("machineId", machineId)) + + // Coleta todos e filtra em memoria (Convex nao suporta LIKE) + const all = await query.collect() + + // Filtra por search se fornecido + let filtered = all + if (search && search.trim().length > 0) { + const searchLower = search.toLowerCase().trim() + filtered = all.filter( + (s) => + s.nameLower.includes(searchLower) || + (s.publisher && s.publisher.toLowerCase().includes(searchLower)) || + (s.version && s.version.toLowerCase().includes(searchLower)) + ) + } + + // Ordena por nome + filtered.sort((a, b) => a.nameLower.localeCompare(b.nameLower)) + + // Paginacao manual + let startIndex = 0 + if (cursor) { + const cursorIndex = filtered.findIndex((s) => s._id === cursor) + if (cursorIndex >= 0) { + startIndex = cursorIndex + 1 + } + } + + const page = filtered.slice(startIndex, startIndex + pageLimit) + const nextCursor = page.length === pageLimit ? page[page.length - 1]._id : null + + return { + items: page.map((s) => ({ + id: s._id, + name: s.name, + version: s.version ?? null, + publisher: s.publisher ?? null, + source: s.source ?? null, + detectedAt: s.detectedAt, + lastSeenAt: s.lastSeenAt, + })), + total: filtered.length, + nextCursor, + } + }, +}) + +// Lista softwares de todas as maquinas de um tenant (para admin) +export const listByTenant = query({ + args: { + tenantId: v.string(), + viewerId: v.id("users"), + search: v.optional(v.string()), + machineId: v.optional(v.id("machines")), + limit: v.optional(v.number()), + cursor: v.optional(v.string()), + }, + handler: async (ctx, { tenantId, search, machineId, limit = 50, cursor }) => { + const pageLimit = Math.min(limit, 100) + + // Busca por tenant ou por maquina especifica + let all: Array<{ + _id: Id<"machineSoftware"> + tenantId: string + machineId: Id<"machines"> + name: string + nameLower: string + version?: string + publisher?: string + source?: string + detectedAt: number + lastSeenAt: number + }> + + if (machineId) { + all = await ctx.db + .query("machineSoftware") + .withIndex("by_tenant_machine", (q) => q.eq("tenantId", tenantId).eq("machineId", machineId)) + .collect() + } else { + // Busca por tenant - pode ser grande, limita + all = await ctx.db + .query("machineSoftware") + .withIndex("by_tenant_name", (q) => q.eq("tenantId", tenantId)) + .take(5000) + } + + // Filtra por search + let filtered = all + if (search && search.trim().length > 0) { + const searchLower = search.toLowerCase().trim() + filtered = all.filter( + (s) => + s.nameLower.includes(searchLower) || + (s.publisher && s.publisher.toLowerCase().includes(searchLower)) || + (s.version && s.version.toLowerCase().includes(searchLower)) + ) + } + + // Ordena por nome + filtered.sort((a, b) => a.nameLower.localeCompare(b.nameLower)) + + // Paginacao + let startIndex = 0 + if (cursor) { + const cursorIndex = filtered.findIndex((s) => s._id === cursor) + if (cursorIndex >= 0) { + startIndex = cursorIndex + 1 + } + } + + const page = filtered.slice(startIndex, startIndex + pageLimit) + const nextCursor = page.length === pageLimit ? page[page.length - 1]._id : null + + // Busca nomes das maquinas + const machineIds = [...new Set(page.map((s) => s.machineId))] + const machines = await Promise.all(machineIds.map((id) => ctx.db.get(id))) + const machineNames = new Map( + machines.filter(Boolean).map((m) => [m!._id, m!.displayName || m!.hostname]) + ) + + return { + items: page.map((s) => ({ + id: s._id, + machineId: s.machineId, + machineName: machineNames.get(s.machineId) ?? "Desconhecido", + name: s.name, + version: s.version ?? null, + publisher: s.publisher ?? null, + source: s.source ?? null, + detectedAt: s.detectedAt, + lastSeenAt: s.lastSeenAt, + })), + total: filtered.length, + nextCursor, + } + }, +}) + +// Conta softwares de uma maquina +export const countByMachine = query({ + args: { + machineId: v.id("machines"), + }, + handler: async (ctx, { machineId }) => { + const software = await ctx.db + .query("machineSoftware") + .withIndex("by_machine", (q) => q.eq("machineId", machineId)) + .collect() + + return { count: software.length } + }, +}) + +// Conta softwares unicos por tenant (para relatorios) +export const stats = query({ + args: { + tenantId: v.string(), + viewerId: v.id("users"), + }, + handler: async (ctx, { tenantId }) => { + const software = await ctx.db + .query("machineSoftware") + .withIndex("by_tenant_name", (q) => q.eq("tenantId", tenantId)) + .take(10000) + + const uniqueNames = new Set(software.map((s) => s.nameLower)) + const machineIds = new Set(software.map((s) => s.machineId)) + + return { + totalInstances: software.length, + uniqueSoftware: uniqueNames.size, + machinesWithSoftware: machineIds.size, + } + }, +}) diff --git a/convex/machines.ts b/convex/machines.ts index ab21fcf..69c8b61 100644 --- a/convex/machines.ts +++ b/convex/machines.ts @@ -1,6 +1,6 @@ // ci: trigger convex functions deploy (no-op) import { mutation, query } from "./_generated/server" -import { api } from "./_generated/api" +import { internal, api } from "./_generated/api" import { paginationOptsValidator } from "convex/server" import { ConvexError, v, Infer } from "convex/values" import { sha256 } from "@noble/hashes/sha2.js" @@ -1010,6 +1010,34 @@ export const heartbeat = mutation({ await upsertRemoteAccessSnapshotFromHeartbeat(ctx, machine, remoteAccessSnapshot, now) } + // Processar softwares instalados (armazenados em tabela separada) + // Os dados de software sao extraidos ANTES de sanitizar o inventory + const rawInventory = args.inventory ?? args.metadata?.inventory + if (rawInventory && typeof rawInventory === "object") { + const softwareArray = (rawInventory as Record)["software"] + if (Array.isArray(softwareArray) && softwareArray.length > 0) { + const validSoftware = softwareArray + .filter((item): item is Record => item !== null && typeof item === "object") + .map((item) => ({ + name: typeof item.name === "string" ? item.name : "", + version: typeof item.version === "string" ? item.version : undefined, + publisher: typeof item.publisher === "string" || typeof item.source === "string" + ? (item.publisher as string) || (item.source as string) + : undefined, + source: typeof item.source === "string" ? item.source : undefined, + })) + .filter((item) => item.name.length > 0) + + if (validSoftware.length > 0) { + await ctx.runMutation(internal.machineSoftware.syncFromHeartbeat, { + tenantId: machine.tenantId, + machineId: machine._id, + software: validSoftware, + }) + } + } + } + await ctx.db.patch(token._id, { lastUsedAt: now, usageCount: (token.usageCount ?? 0) + 1, diff --git a/convex/schema.ts b/convex/schema.ts index ac9ae2d..c8f9099 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -821,6 +821,25 @@ export default defineSchema({ }) .index("by_machine", ["machineId"]), + // Tabela separada para softwares instalados - permite filtros, pesquisa e paginacao + // Os dados sao enviados pelo agente desktop e armazenados aqui de forma normalizada + machineSoftware: defineTable({ + tenantId: v.string(), + machineId: v.id("machines"), + name: v.string(), + nameLower: v.string(), // Para busca case-insensitive + version: v.optional(v.string()), + publisher: v.optional(v.string()), + source: v.optional(v.string()), // dpkg, rpm, windows, macos, etc + installedAt: v.optional(v.number()), // Data de instalacao (se disponivel) + detectedAt: v.number(), // Quando foi detectado pelo agente + lastSeenAt: v.number(), // Ultima vez que foi visto no heartbeat + }) + .index("by_machine", ["machineId"]) + .index("by_machine_name", ["machineId", "nameLower"]) + .index("by_tenant_name", ["tenantId", "nameLower"]) + .index("by_tenant_machine", ["tenantId", "machineId"]), + machineTokens: defineTable({ tenantId: v.string(), machineId: v.id("machines"), diff --git a/src/components/admin/devices/admin-devices-overview.tsx b/src/components/admin/devices/admin-devices-overview.tsx index 41670c3..3b0edc4 100644 --- a/src/components/admin/devices/admin-devices-overview.tsx +++ b/src/components/admin/devices/admin-devices-overview.tsx @@ -77,6 +77,7 @@ import type { Id } from "@/convex/_generated/dataModel" import { TicketStatusBadge } from "@/components/tickets/status-badge" import type { TicketPriority, TicketStatus } from "@/lib/schemas/ticket" import { DeviceCustomFieldManager } from "@/components/admin/devices/device-custom-field-manager" +import { DeviceSoftwareList } from "@/components/admin/devices/device-software-list" import { UsbPolicyControl } from "@/components/admin/devices/usb-policy-control" import { DatePicker } from "@/components/ui/date-picker" import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox" @@ -4223,6 +4224,11 @@ export function DeviceDetails({ device }: DeviceDetailsProps) { )} + {/* Softwares instalados */} +
+ } /> +
+ {/* Campos personalizados */}
diff --git a/src/components/admin/devices/device-software-list.tsx b/src/components/admin/devices/device-software-list.tsx new file mode 100644 index 0000000..3b4a29b --- /dev/null +++ b/src/components/admin/devices/device-software-list.tsx @@ -0,0 +1,170 @@ +"use client" + +import { useState } from "react" +import { useQuery } from "convex/react" +import { formatDistanceToNow } from "date-fns" +import { ptBR } from "date-fns/locale" +import { Package, Search, ChevronLeft, ChevronRight } from "lucide-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 { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Skeleton } from "@/components/ui/skeleton" + +type DeviceSoftwareListProps = { + machineId: Id<"machines"> +} + +export function DeviceSoftwareList({ machineId }: DeviceSoftwareListProps) { + const { session, convexUserId } = useAuth() + const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID + const viewerId = convexUserId as Id<"users"> | undefined + + const [search, setSearch] = useState("") + const [cursor, setCursor] = useState(null) + + const result = useQuery( + api.machineSoftware.listByMachine, + viewerId + ? { + tenantId, + viewerId, + machineId, + search: search.trim() || undefined, + limit: 30, + cursor: cursor ?? undefined, + } + : "skip" + ) + + const count = useQuery(api.machineSoftware.countByMachine, { machineId }) + + const handleSearch = (value: string) => { + setSearch(value) + setCursor(null) + } + + const handleNextPage = () => { + if (result?.nextCursor) { + setCursor(result.nextCursor) + } + } + + const handlePrevPage = () => { + setCursor(null) + } + + if (!viewerId) { + return null + } + + return ( +
+
+
+ +

Softwares instalados

+ {count?.count !== undefined && ( + + {count.count} + + )} +
+
+ +
+ + handleSearch(e.target.value)} + className="pl-9" + /> +
+ + {result === undefined ? ( +
+ {Array.from({ length: 5 }).map((_, index) => ( + + ))} +
+ ) : result.items.length === 0 ? ( +
+ +

+ {search ? "Nenhum software encontrado" : "Nenhum software detectado"} +

+

+ {search + ? `Nenhum resultado para "${search}".` + : "O agente ainda nao enviou dados de softwares instalados."} +

+
+ ) : ( + <> +
+ {result.items.map((software) => ( +
+
+

{software.name}

+
+ {software.version && v{software.version}} + {software.publisher && ( + <> + | + {software.publisher} + + )} + {software.source && ( + + {software.source} + + )} +
+
+
+

+ Visto {formatDistanceToNow(software.lastSeenAt, { addSuffix: true, locale: ptBR })} +

+
+
+ ))} +
+ +
+ + Mostrando {result.items.length} de {result.total} softwares + +
+ + +
+
+ + )} +
+ ) +} diff --git a/src/components/admin/slas/slas-manager.tsx b/src/components/admin/slas/slas-manager.tsx index e29ea00..657a1f6 100644 --- a/src/components/admin/slas/slas-manager.tsx +++ b/src/components/admin/slas/slas-manager.tsx @@ -3,17 +3,20 @@ import { useMemo, useState } from "react" import { useMutation, useQuery } from "convex/react" import { toast } from "sonner" +import { Globe, Plus } from "lucide-react" import { IconAlarm, IconBolt, IconTargetArrow } 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 { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Skeleton } from "@/components/ui/skeleton" -import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { CategorySlaManager } from "./category-sla-manager" import { CompanySlaManager } from "./company-sla-manager" @@ -26,6 +29,14 @@ type SlaPolicy = { timeToResolution: number | null } +type TimeUnit = "minutes" | "hours" | "days" + +const TIME_UNITS: Array<{ value: TimeUnit; label: string; factor: number }> = [ + { value: "minutes", label: "Minutos", factor: 1 }, + { value: "hours", label: "Horas", factor: 60 }, + { value: "days", label: "Dias", factor: 1440 }, +] + function formatMinutes(value: number | null) { if (value === null) return "—" if (value < 60) return `${Math.round(value)} min` @@ -35,6 +46,23 @@ function formatMinutes(value: number | null) { return `${hours}h ${minutes}min` } +function minutesToForm(input: number | null): { amount: string; unit: TimeUnit } { + if (!input || input <= 0) return { amount: "", unit: "hours" } + for (const option of [...TIME_UNITS].reverse()) { + if (input % option.factor === 0) { + return { amount: String(Math.round(input / option.factor)), unit: option.value } + } + } + return { amount: String(input), unit: "minutes" } +} + +function convertToMinutes(amount: string, unit: TimeUnit): number | undefined { + const numeric = Number(amount) + if (!Number.isFinite(numeric) || numeric <= 0) return undefined + const factor = TIME_UNITS.find((item) => item.value === unit)?.factor ?? 1 + return Math.round(numeric * factor) +} + export function SlasManager() { const { session, convexUserId } = useAuth() const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID @@ -48,12 +76,15 @@ export function SlasManager() { const updateSla = useMutation(api.slas.update) const removeSla = useMutation(api.slas.remove) + const [dialogOpen, setDialogOpen] = useState(false) + const [editingSla, setEditingSla] = useState(null) const [name, setName] = useState("") const [description, setDescription] = useState("") - const [firstResponse, setFirstResponse] = useState("") - const [resolution, setResolution] = useState("") + const [responseAmount, setResponseAmount] = useState("") + const [responseUnit, setResponseUnit] = useState("hours") + const [resolutionAmount, setResolutionAmount] = useState("") + const [resolutionUnit, setResolutionUnit] = useState("hours") const [saving, setSaving] = useState(false) - const [editingSla, setEditingSla] = useState(null) const { bestFirstResponse, bestResolution } = useMemo(() => { if (!slas) return { bestFirstResponse: null, bestResolution: null } @@ -74,82 +105,81 @@ export function SlasManager() { const resetForm = () => { setName("") setDescription("") - setFirstResponse("") - setResolution("") + setResponseAmount("") + setResponseUnit("hours") + setResolutionAmount("") + setResolutionUnit("hours") } - const parseNumber = (value: string) => { - const parsed = Number(value) - return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined + const openCreateDialog = () => { + resetForm() + setEditingSla(null) + setDialogOpen(true) } - const handleCreate = async (event: React.FormEvent) => { - event.preventDefault() - if (!name.trim()) { - toast.error("Informe um nome para a política") - return - } - if (!convexUserId) { - toast.error("Sessão não sincronizada com o Convex") - return - } - setSaving(true) - toast.loading("Criando SLA...", { id: "sla" }) - try { - await createSla({ - tenantId, - actorId: convexUserId as Id<"users">, - name: name.trim(), - description: description.trim() || undefined, - timeToFirstResponse: parseNumber(firstResponse), - timeToResolution: parseNumber(resolution), - }) - toast.success("Política criada", { id: "sla" }) - resetForm() - } catch (error) { - console.error(error) - toast.error("Não foi possível criar a política", { id: "sla" }) - } finally { - setSaving(false) - } - } - - const openEdit = (policy: SlaPolicy) => { + const openEditDialog = (policy: SlaPolicy) => { + const response = minutesToForm(policy.timeToFirstResponse) + const resolution = minutesToForm(policy.timeToResolution) setEditingSla(policy) setName(policy.name) setDescription(policy.description) - setFirstResponse(policy.timeToFirstResponse ? String(policy.timeToFirstResponse) : "") - setResolution(policy.timeToResolution ? String(policy.timeToResolution) : "") + setResponseAmount(response.amount) + setResponseUnit(response.unit) + setResolutionAmount(resolution.amount) + setResolutionUnit(resolution.unit) + setDialogOpen(true) } - const handleUpdate = async () => { - if (!editingSla) return + const closeDialog = () => { + setDialogOpen(false) + setEditingSla(null) + resetForm() + } + + const handleSave = async () => { if (!name.trim()) { - toast.error("Informe um nome para a política") + toast.error("Informe um nome para a politica") return } if (!convexUserId) { - toast.error("Sessão não sincronizada com o Convex") + toast.error("Sessao nao sincronizada com o Convex") return } + + const timeToFirstResponse = convertToMinutes(responseAmount, responseUnit) + const timeToResolution = convertToMinutes(resolutionAmount, resolutionUnit) + setSaving(true) - toast.loading("Salvando alterações...", { id: "sla-edit" }) + const toastId = editingSla ? "sla-edit" : "sla-create" + toast.loading(editingSla ? "Salvando alteracoes..." : "Criando politica...", { id: toastId }) + try { - await updateSla({ - tenantId, - policyId: editingSla.id as Id<"slaPolicies">, - actorId: convexUserId as Id<"users">, - name: name.trim(), - description: description.trim() || undefined, - timeToFirstResponse: parseNumber(firstResponse), - timeToResolution: parseNumber(resolution), - }) - toast.success("Política atualizada", { id: "sla-edit" }) - setEditingSla(null) - resetForm() + if (editingSla) { + await updateSla({ + tenantId, + policyId: editingSla.id as Id<"slaPolicies">, + actorId: convexUserId as Id<"users">, + name: name.trim(), + description: description.trim() || undefined, + timeToFirstResponse, + timeToResolution, + }) + toast.success("Politica atualizada", { id: toastId }) + } else { + await createSla({ + tenantId, + actorId: convexUserId as Id<"users">, + name: name.trim(), + description: description.trim() || undefined, + timeToFirstResponse, + timeToResolution, + }) + toast.success("Politica criada", { id: toastId }) + } + closeDialog() } catch (error) { console.error(error) - toast.error("Não foi possível atualizar a política", { id: "sla-edit" }) + toast.error(editingSla ? "Nao foi possivel atualizar a politica" : "Nao foi possivel criar a politica", { id: toastId }) } finally { setSaving(false) } @@ -178,13 +208,14 @@ export function SlasManager() { return (
+ {/* Cards de resumo */}
- Políticas criadas + Politicas globais - Regras aplicadas às filas e tickets. + Regras que valem para todas as empresas. {slas ? slas.length : } @@ -193,9 +224,9 @@ export function SlasManager() { - Resposta (média) + Melhor resposta - Tempo mínimo para primeira resposta. + Menor meta de primeira resposta. {slas ? formatMinutes(bestFirstResponse ?? null) : } @@ -204,9 +235,9 @@ export function SlasManager() { - Resolução (média) + Melhor resolucao - Alvo para encerrar chamados. + Menor meta para encerrar chamados. {slas ? formatMinutes(bestResolution ?? null) : } @@ -214,179 +245,184 @@ export function SlasManager() {
+ {/* Politicas globais de SLA */} - Nova política de SLA - Defina metas de resposta e resolução para garantir previsibilidade no atendimento. - - -
-
-
- - setName(event.target.value)} - required - /> -
-
- - setFirstResponse(event.target.value)} - placeholder="Opcional" - /> -
-
- - setResolution(event.target.value)} - placeholder="Opcional" - /> -
-
-
-
- -