feat(devices): implementa tabela separada para softwares instalados
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
ef2545221d
commit
23fe67e7d3
7 changed files with 741 additions and 205 deletions
276
convex/machineSoftware.ts
Normal file
276
convex/machineSoftware.ts
Normal file
|
|
@ -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<string>()
|
||||
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,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
@ -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<string, unknown>)["software"]
|
||||
if (Array.isArray(softwareArray) && softwareArray.length > 0) {
|
||||
const validSoftware = softwareArray
|
||||
.filter((item): item is Record<string, unknown> => 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,
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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) {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Softwares instalados */}
|
||||
<div className="space-y-3 border-t border-slate-100 pt-5">
|
||||
<DeviceSoftwareList machineId={device.id as Id<"machines">} />
|
||||
</div>
|
||||
|
||||
{/* Campos personalizados */}
|
||||
<div className="space-y-3 border-t border-slate-100 pt-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
|
|
|
|||
170
src/components/admin/devices/device-software-list.tsx
Normal file
170
src/components/admin/devices/device-software-list.tsx
Normal file
|
|
@ -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<string | null>(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 (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="size-5 text-neutral-600" />
|
||||
<h3 className="text-sm font-semibold text-neutral-900">Softwares instalados</h3>
|
||||
{count?.count !== undefined && (
|
||||
<Badge variant="secondary" className="rounded-full">
|
||||
{count.count}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-neutral-400" />
|
||||
<Input
|
||||
placeholder="Buscar por nome, versao ou fabricante..."
|
||||
value={search}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{result === undefined ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<Skeleton key={`software-skeleton-${index}`} className="h-14 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : result.items.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-slate-300 bg-slate-50/80 p-6 text-center">
|
||||
<Package className="mx-auto size-8 text-slate-400" />
|
||||
<p className="mt-2 text-sm font-medium text-neutral-700">
|
||||
{search ? "Nenhum software encontrado" : "Nenhum software detectado"}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-neutral-500">
|
||||
{search
|
||||
? `Nenhum resultado para "${search}".`
|
||||
: "O agente ainda nao enviou dados de softwares instalados."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="divide-y divide-slate-100 rounded-xl border border-slate-200 bg-white">
|
||||
{result.items.map((software) => (
|
||||
<div
|
||||
key={software.id}
|
||||
className="flex items-center justify-between gap-3 px-4 py-3 first:rounded-t-xl last:rounded-b-xl hover:bg-slate-50"
|
||||
>
|
||||
<div className="min-w-0 flex-1 space-y-0.5">
|
||||
<p className="truncate text-sm font-medium text-neutral-900">{software.name}</p>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-neutral-500">
|
||||
{software.version && <span>v{software.version}</span>}
|
||||
{software.publisher && (
|
||||
<>
|
||||
<span className="text-neutral-300">|</span>
|
||||
<span>{software.publisher}</span>
|
||||
</>
|
||||
)}
|
||||
{software.source && (
|
||||
<Badge variant="outline" className="rounded text-[10px] px-1.5 py-0">
|
||||
{software.source}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
<p className="text-xs text-neutral-400">
|
||||
Visto {formatDistanceToNow(software.lastSeenAt, { addSuffix: true, locale: ptBR })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-neutral-500">
|
||||
<span>
|
||||
Mostrando {result.items.length} de {result.total} softwares
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handlePrevPage}
|
||||
disabled={!cursor}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleNextPage}
|
||||
disabled={!result.nextCursor}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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<SlaPolicy | null>(null)
|
||||
const [name, setName] = useState("")
|
||||
const [description, setDescription] = useState("")
|
||||
const [firstResponse, setFirstResponse] = useState<string>("")
|
||||
const [resolution, setResolution] = useState<string>("")
|
||||
const [responseAmount, setResponseAmount] = useState("")
|
||||
const [responseUnit, setResponseUnit] = useState<TimeUnit>("hours")
|
||||
const [resolutionAmount, setResolutionAmount] = useState("")
|
||||
const [resolutionUnit, setResolutionUnit] = useState<TimeUnit>("hours")
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [editingSla, setEditingSla] = useState<SlaPolicy | null>(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<HTMLFormElement>) => {
|
||||
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 (
|
||||
<div className="space-y-8">
|
||||
{/* Cards de resumo */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card className="border-slate-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600">
|
||||
<IconTargetArrow className="size-4" /> Políticas criadas
|
||||
<IconTargetArrow className="size-4" /> Politicas globais
|
||||
</CardTitle>
|
||||
<CardDescription>Regras aplicadas às filas e tickets.</CardDescription>
|
||||
<CardDescription>Regras que valem para todas as empresas.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-3xl font-semibold text-neutral-900">
|
||||
{slas ? slas.length : <Skeleton className="h-8 w-16" />}
|
||||
|
|
@ -193,9 +224,9 @@ export function SlasManager() {
|
|||
<Card className="border-slate-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600">
|
||||
<IconAlarm className="size-4" /> Resposta (média)
|
||||
<IconAlarm className="size-4" /> Melhor resposta
|
||||
</CardTitle>
|
||||
<CardDescription>Tempo mínimo para primeira resposta.</CardDescription>
|
||||
<CardDescription>Menor meta de primeira resposta.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-xl font-semibold text-neutral-900">
|
||||
{slas ? formatMinutes(bestFirstResponse ?? null) : <Skeleton className="h-8 w-24" />}
|
||||
|
|
@ -204,9 +235,9 @@ export function SlasManager() {
|
|||
<Card className="border-slate-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600">
|
||||
<IconBolt className="size-4" /> Resolução (média)
|
||||
<IconBolt className="size-4" /> Melhor resolucao
|
||||
</CardTitle>
|
||||
<CardDescription>Alvo para encerrar chamados.</CardDescription>
|
||||
<CardDescription>Menor meta para encerrar chamados.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-xl font-semibold text-neutral-900">
|
||||
{slas ? formatMinutes(bestResolution ?? null) : <Skeleton className="h-8 w-24" />}
|
||||
|
|
@ -214,179 +245,184 @@ export function SlasManager() {
|
|||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Politicas globais de SLA */}
|
||||
<Card className="border-slate-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-semibold text-neutral-900">Nova política de SLA</CardTitle>
|
||||
<CardDescription>Defina metas de resposta e resolução para garantir previsibilidade no atendimento.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleCreate} className="grid gap-4 md:grid-cols-[minmax(0,320px)_minmax(0,1fr)]">
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sla-name">Nome da política</Label>
|
||||
<Input
|
||||
id="sla-name"
|
||||
placeholder="Ex.: Resposta prioritária"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sla-first-response">Primeira resposta (minutos)</Label>
|
||||
<Input
|
||||
id="sla-first-response"
|
||||
type="number"
|
||||
min={1}
|
||||
value={firstResponse}
|
||||
onChange={(event) => setFirstResponse(event.target.value)}
|
||||
placeholder="Opcional"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sla-resolution">Resolução (minutos)</Label>
|
||||
<Input
|
||||
id="sla-resolution"
|
||||
type="number"
|
||||
min={1}
|
||||
value={resolution}
|
||||
onChange={(event) => setResolution(event.target.value)}
|
||||
placeholder="Opcional"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sla-description">Descrição</Label>
|
||||
<textarea
|
||||
id="sla-description"
|
||||
className="min-h-[120px] w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-neutral-700 shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-900/10"
|
||||
placeholder="Como esta política será aplicada"
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={saving}>
|
||||
Criar política
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-4">
|
||||
{slas === undefined ? (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<Skeleton key={index} className="h-32 rounded-2xl" />
|
||||
))}
|
||||
</div>
|
||||
) : slas.length === 0 ? (
|
||||
<Card className="border-dashed border-slate-300 bg-slate-50/80">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-semibold text-neutral-900">Nenhuma política cadastrada</CardTitle>
|
||||
<CardDescription className="text-neutral-600">
|
||||
Crie SLAs para monitorar o tempo de resposta e resolução dos seus chamados.
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2 text-lg font-semibold text-neutral-900">
|
||||
<Globe className="size-5" />
|
||||
Politicas globais de SLA
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1">
|
||||
Estas regras valem para todas as empresas e categorias. Sao sobrescritas por regras mais especificas
|
||||
(por empresa ou por categoria).
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
) : (
|
||||
slas.map((policy) => (
|
||||
<Card key={policy.id} className="border-slate-200">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<CardTitle className="text-xl font-semibold text-neutral-900">{policy.name}</CardTitle>
|
||||
{policy.description ? (
|
||||
<CardDescription className="text-neutral-600">{policy.description}</CardDescription>
|
||||
) : null}
|
||||
</div>
|
||||
<Button size="sm" onClick={openCreateDialog} disabled={!convexUserId} className="gap-2 shrink-0">
|
||||
<Plus className="size-4" />
|
||||
Nova politica
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{slas === undefined ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 2 }).map((_, index) => (
|
||||
<Skeleton key={`sla-skeleton-${index}`} className="h-20 rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
) : slas.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-slate-300 bg-slate-50/80 p-6 text-center">
|
||||
<Globe className="mx-auto size-8 text-slate-400" />
|
||||
<p className="mt-2 text-sm font-medium text-neutral-700">Nenhuma politica global cadastrada</p>
|
||||
<p className="mt-1 text-xs text-neutral-500">
|
||||
Crie politicas de SLA para definir metas de resposta e resolucao para os chamados.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{slas.map((policy) => (
|
||||
<div
|
||||
key={policy.id}
|
||||
className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-slate-200 bg-white px-4 py-3"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-semibold text-neutral-900">{policy.name}</p>
|
||||
<Badge variant="secondary" className="rounded-full text-xs">
|
||||
<Globe className="mr-1 size-3" />
|
||||
Global
|
||||
</Badge>
|
||||
</div>
|
||||
{policy.description && (
|
||||
<p className="text-xs text-neutral-500">{policy.description}</p>
|
||||
)}
|
||||
<div className="flex gap-4 text-xs text-neutral-600">
|
||||
<span>Resposta: {formatMinutes(policy.timeToFirstResponse)}</span>
|
||||
<span>Resolucao: {formatMinutes(policy.timeToResolution)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => openEdit(policy)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={() => openEditDialog(policy)} disabled={!convexUserId}>
|
||||
Editar
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={() => handleRemove(policy)}>
|
||||
<Button size="sm" variant="destructive" onClick={() => handleRemove(policy)} disabled={!convexUserId}>
|
||||
Excluir
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Primeira resposta</dt>
|
||||
<dd className="text-lg font-semibold text-neutral-900">{formatMinutes(policy.timeToFirstResponse)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Resolução</dt>
|
||||
<dd className="text-lg font-semibold text-neutral-900">{formatMinutes(policy.timeToResolution)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* SLA por empresa */}
|
||||
<CompanySlaManager />
|
||||
|
||||
{/* SLA por categoria */}
|
||||
<CategorySlaManager />
|
||||
|
||||
<Dialog open={Boolean(editingSla)} onOpenChange={(value) => (!value ? setEditingSla(null) : null)}>
|
||||
{/* Modal unificado de criar/editar */}
|
||||
<Dialog open={dialogOpen} onOpenChange={(open) => !open && closeDialog()}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Editar política de SLA</DialogTitle>
|
||||
<DialogTitle>{editingSla ? "Editar politica de SLA" : "Nova politica de SLA"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingSla
|
||||
? "Altere os dados da politica. Ela continua valendo para todas as empresas e categorias."
|
||||
: "Crie uma politica global que vale para todas as empresas e categorias. Voce pode criar regras mais especificas depois."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-sla-name">Nome</Label>
|
||||
<Label htmlFor="sla-name">Nome da politica</Label>
|
||||
<Input
|
||||
id="edit-sla-name"
|
||||
id="sla-name"
|
||||
placeholder="Ex.: Atendimento padrao, Premium, Urgente..."
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-sla-first">Primeira resposta (minutos)</Label>
|
||||
<Input
|
||||
id="edit-sla-first"
|
||||
type="number"
|
||||
min={1}
|
||||
value={firstResponse}
|
||||
onChange={(event) => setFirstResponse(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-sla-resolution">Resolução (minutos)</Label>
|
||||
<Input
|
||||
id="edit-sla-resolution"
|
||||
type="number"
|
||||
min={1}
|
||||
value={resolution}
|
||||
onChange={(event) => setResolution(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-sla-description">Descrição</Label>
|
||||
<Label htmlFor="sla-description">Descricao (opcional)</Label>
|
||||
<textarea
|
||||
id="edit-sla-description"
|
||||
className="min-h-[120px] w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-neutral-700 shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-900/10"
|
||||
id="sla-description"
|
||||
className="min-h-[80px] w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-neutral-700 shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-900/10"
|
||||
placeholder="Quando esta politica deve ser usada, para quais tipos de chamado..."
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Tempo para primeira resposta</Label>
|
||||
<p className="text-xs text-neutral-500">Quanto tempo a equipe tem para dar a primeira resposta ao cliente.</p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder="0"
|
||||
value={responseAmount}
|
||||
onChange={(event) => setResponseAmount(event.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Select value={responseUnit} onValueChange={(value) => setResponseUnit(value as TimeUnit)}>
|
||||
<SelectTrigger className="w-28">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TIME_UNITS.map((unit) => (
|
||||
<SelectItem key={unit.value} value={unit.value}>
|
||||
{unit.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Tempo para resolucao</Label>
|
||||
<p className="text-xs text-neutral-500">Quanto tempo a equipe tem para resolver o chamado por completo.</p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder="0"
|
||||
value={resolutionAmount}
|
||||
onChange={(event) => setResolutionAmount(event.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Select value={resolutionUnit} onValueChange={(value) => setResolutionUnit(value as TimeUnit)}>
|
||||
<SelectTrigger className="w-28">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TIME_UNITS.map((unit) => (
|
||||
<SelectItem key={unit.value} value={unit.value}>
|
||||
{unit.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-3">
|
||||
<p className="text-xs text-blue-800">
|
||||
<strong>Dica:</strong> Esta politica e global e sera aplicada a todos os chamados que nao tiverem uma
|
||||
regra mais especifica (por empresa ou categoria). Use as secoes abaixo para criar regras personalizadas.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="outline" onClick={() => setEditingSla(null)}>
|
||||
<Button variant="outline" onClick={closeDialog}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={handleUpdate} disabled={saving}>
|
||||
Salvar alterações
|
||||
<Button onClick={handleSave} disabled={saving || !name.trim()}>
|
||||
{saving ? "Salvando..." : editingSla ? "Salvar alteracoes" : "Criar politica"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -163,6 +163,7 @@ export function CompanyReport() {
|
|||
searchPlaceholder="Buscar empresa..."
|
||||
emptyText="Nenhuma empresa encontrada."
|
||||
disabled={!companyOptions.length}
|
||||
triggerClassName="h-10 rounded-xl border-slate-200"
|
||||
/>
|
||||
</div>
|
||||
<Select value={timeRange} onValueChange={(value) => setTimeRange(value as typeof timeRange)}>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue