Compare commits

..

2 commits

Author SHA1 Message Date
rever-tecnologia
f0c2bdc283 feat(agent): adiciona captura de bateria, sensores termicos, rede e monitores
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 7s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 4m11s
Quality Checks / Lint, Test and Build (push) Successful in 4m22s
CI/CD Web + Desktop / Deploy Convex functions (push) Successful in 1m28s
- Captura info de bateria (Win32_Battery) com status traduzido
- Captura sensores termicos via WMI ThermalZone e OpenHardwareMonitor
- Captura adaptadores de rede fisicos com status de conexao
- Captura monitores conectados (fabricante, serial, modelo)
- Captura info de chassis/gabinete com tipo traduzido

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 08:04:43 -03:00
rever-tecnologia
23fe67e7d3 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>
2025-12-18 08:00:40 -03:00
8 changed files with 874 additions and 205 deletions

View file

@ -971,6 +971,134 @@ fn collect_windows_extended() -> serde_json::Value {
"#).unwrap_or_else(|| json!([])); "#).unwrap_or_else(|| json!([]));
let disks = ps("@(Get-CimInstance Win32_DiskDrive | Select-Object Model,SerialNumber,Size,InterfaceType,MediaType)").unwrap_or_else(|| json!([])); let disks = ps("@(Get-CimInstance Win32_DiskDrive | Select-Object Model,SerialNumber,Size,InterfaceType,MediaType)").unwrap_or_else(|| json!([]));
// Bateria (notebooks/laptops)
let battery = ps(r#"
$batteries = @(Get-CimInstance Win32_Battery | Select-Object Name,DeviceID,Status,BatteryStatus,EstimatedChargeRemaining,EstimatedRunTime,DesignCapacity,FullChargeCapacity,DesignVoltage,Chemistry,BatteryRechargeTime)
if ($batteries.Count -eq 0) {
[PSCustomObject]@{ Present = $false; Batteries = @() }
} else {
# Mapeia status numérico para texto
$statusMap = @{
1 = 'Discharging'
2 = 'AC Power'
3 = 'Fully Charged'
4 = 'Low'
5 = 'Critical'
6 = 'Charging'
7 = 'Charging High'
8 = 'Charging Low'
9 = 'Charging Critical'
10 = 'Undefined'
11 = 'Partially Charged'
}
foreach ($b in $batteries) {
if ($b.BatteryStatus) {
$b | Add-Member -NotePropertyName 'BatteryStatusText' -NotePropertyValue ($statusMap[[int]$b.BatteryStatus] ?? 'Unknown') -Force
}
}
[PSCustomObject]@{ Present = $true; Batteries = $batteries }
}
"#).unwrap_or_else(|| json!({ "Present": false, "Batteries": [] }));
// Sensores térmicos (temperatura CPU/GPU quando disponível)
let thermal = ps(r#"
$temps = @()
# Tenta WMI thermal zone (requer admin em alguns sistemas)
try {
$zones = Get-CimInstance -Namespace 'root/WMI' -ClassName MSAcpi_ThermalZoneTemperature -ErrorAction SilentlyContinue
foreach ($z in $zones) {
if ($z.CurrentTemperature) {
$celsius = [math]::Round(($z.CurrentTemperature - 2732) / 10, 1)
$temps += [PSCustomObject]@{
Source = 'ThermalZone'
Name = $z.InstanceName
TemperatureCelsius = $celsius
CriticalTripPoint = if ($z.CriticalTripPoint) { [math]::Round(($z.CriticalTripPoint - 2732) / 10, 1) } else { $null }
}
}
}
} catch {}
# CPU temp via Open Hardware Monitor WMI (se instalado)
try {
$ohm = Get-CimInstance -Namespace 'root/OpenHardwareMonitor' -ClassName Sensor -ErrorAction SilentlyContinue | Where-Object { $_.SensorType -eq 'Temperature' }
foreach ($s in $ohm) {
$temps += [PSCustomObject]@{
Source = 'OpenHardwareMonitor'
Name = $s.Name
TemperatureCelsius = $s.Value
Parent = $s.Parent
}
}
} catch {}
@($temps)
"#).unwrap_or_else(|| json!([]));
// Adaptadores de rede (físicos e virtuais)
let network_adapters = ps(r#"
@(Get-CimInstance Win32_NetworkAdapter | Where-Object { $_.PhysicalAdapter -eq $true -or $_.NetConnectionStatus -ne $null } | Select-Object Name,Description,MACAddress,Speed,NetConnectionStatus,AdapterType,Manufacturer,NetConnectionID,PNPDeviceID | ForEach-Object {
$statusMap = @{
0 = 'Disconnected'
1 = 'Connecting'
2 = 'Connected'
3 = 'Disconnecting'
4 = 'Hardware not present'
5 = 'Hardware disabled'
6 = 'Hardware malfunction'
7 = 'Media disconnected'
8 = 'Authenticating'
9 = 'Authentication succeeded'
10 = 'Authentication failed'
11 = 'Invalid address'
12 = 'Credentials required'
}
$_ | Add-Member -NotePropertyName 'StatusText' -NotePropertyValue ($statusMap[[int]$_.NetConnectionStatus] ?? 'Unknown') -Force
$_
})
"#).unwrap_or_else(|| json!([]));
// Monitores conectados
let monitors = ps(r#"
@(Get-CimInstance WmiMonitorID -Namespace root/wmi -ErrorAction SilentlyContinue | ForEach-Object {
$decode = { param($arr) if ($arr) { -join ($arr | Where-Object { $_ -ne 0 } | ForEach-Object { [char]$_ }) } else { $null } }
[PSCustomObject]@{
ManufacturerName = & $decode $_.ManufacturerName
ProductCodeID = & $decode $_.ProductCodeID
SerialNumberID = & $decode $_.SerialNumberID
UserFriendlyName = & $decode $_.UserFriendlyName
YearOfManufacture = $_.YearOfManufacture
WeekOfManufacture = $_.WeekOfManufacture
}
})
"#).unwrap_or_else(|| json!([]));
// Fonte de alimentação / chassis
let power_supply = ps(r#"
$chassis = Get-CimInstance Win32_SystemEnclosure | Select-Object ChassisTypes,Manufacturer,SerialNumber,SMBIOSAssetTag
$chassisTypeMap = @{
1 = 'Other'; 2 = 'Unknown'; 3 = 'Desktop'; 4 = 'Low Profile Desktop'
5 = 'Pizza Box'; 6 = 'Mini Tower'; 7 = 'Tower'; 8 = 'Portable'
9 = 'Laptop'; 10 = 'Notebook'; 11 = 'Hand Held'; 12 = 'Docking Station'
13 = 'All in One'; 14 = 'Sub Notebook'; 15 = 'Space-Saving'; 16 = 'Lunch Box'
17 = 'Main Server Chassis'; 18 = 'Expansion Chassis'; 19 = 'SubChassis'
20 = 'Bus Expansion Chassis'; 21 = 'Peripheral Chassis'; 22 = 'RAID Chassis'
23 = 'Rack Mount Chassis'; 24 = 'Sealed-case PC'; 25 = 'Multi-system chassis'
30 = 'Tablet'; 31 = 'Convertible'; 32 = 'Detachable'
}
$types = @()
if ($chassis.ChassisTypes) {
foreach ($t in $chassis.ChassisTypes) {
$types += $chassisTypeMap[[int]$t] ?? "Type$t"
}
}
[PSCustomObject]@{
ChassisTypes = $chassis.ChassisTypes
ChassisTypesText = $types
Manufacturer = $chassis.Manufacturer
SerialNumber = $chassis.SerialNumber
SMBIOSAssetTag = $chassis.SMBIOSAssetTag
}
"#).unwrap_or_else(|| json!({}));
json!({ json!({
"windows": { "windows": {
"software": software, "software": software,
@ -992,6 +1120,11 @@ fn collect_windows_extended() -> serde_json::Value {
"windowsUpdate": windows_update, "windowsUpdate": windows_update,
"computerSystem": computer_system, "computerSystem": computer_system,
"azureAdStatus": device_join, "azureAdStatus": device_join,
"battery": battery,
"thermal": thermal,
"networkAdapters": network_adapters,
"monitors": monitors,
"chassis": power_supply,
} }
}) })
} }

276
convex/machineSoftware.ts Normal file
View 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,
}
},
})

View file

@ -1,6 +1,6 @@
// ci: trigger convex functions deploy (no-op) // ci: trigger convex functions deploy (no-op)
import { mutation, query } from "./_generated/server" import { mutation, query } from "./_generated/server"
import { api } from "./_generated/api" import { internal, api } from "./_generated/api"
import { paginationOptsValidator } from "convex/server" import { paginationOptsValidator } from "convex/server"
import { ConvexError, v, Infer } from "convex/values" import { ConvexError, v, Infer } from "convex/values"
import { sha256 } from "@noble/hashes/sha2.js" import { sha256 } from "@noble/hashes/sha2.js"
@ -1010,6 +1010,34 @@ export const heartbeat = mutation({
await upsertRemoteAccessSnapshotFromHeartbeat(ctx, machine, remoteAccessSnapshot, now) 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, { await ctx.db.patch(token._id, {
lastUsedAt: now, lastUsedAt: now,
usageCount: (token.usageCount ?? 0) + 1, usageCount: (token.usageCount ?? 0) + 1,

View file

@ -821,6 +821,25 @@ export default defineSchema({
}) })
.index("by_machine", ["machineId"]), .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({ machineTokens: defineTable({
tenantId: v.string(), tenantId: v.string(),
machineId: v.id("machines"), machineId: v.id("machines"),

View file

@ -77,6 +77,7 @@ import type { Id } from "@/convex/_generated/dataModel"
import { TicketStatusBadge } from "@/components/tickets/status-badge" import { TicketStatusBadge } from "@/components/tickets/status-badge"
import type { TicketPriority, TicketStatus } from "@/lib/schemas/ticket" import type { TicketPriority, TicketStatus } from "@/lib/schemas/ticket"
import { DeviceCustomFieldManager } from "@/components/admin/devices/device-custom-field-manager" 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 { UsbPolicyControl } from "@/components/admin/devices/usb-policy-control"
import { DatePicker } from "@/components/ui/date-picker" import { DatePicker } from "@/components/ui/date-picker"
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox" import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
@ -4223,6 +4224,11 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
)} )}
</div> </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 */} {/* Campos personalizados */}
<div className="space-y-3 border-t border-slate-100 pt-5"> <div className="space-y-3 border-t border-slate-100 pt-5">
<div className="flex flex-wrap items-start justify-between gap-3"> <div className="flex flex-wrap items-start justify-between gap-3">

View 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>
)
}

View file

@ -3,17 +3,20 @@
import { useMemo, useState } from "react" import { useMemo, useState } from "react"
import { useMutation, useQuery } from "convex/react" import { useMutation, useQuery } from "convex/react"
import { toast } from "sonner" import { toast } from "sonner"
import { Globe, Plus } from "lucide-react"
import { IconAlarm, IconBolt, IconTargetArrow } from "@tabler/icons-react" import { IconAlarm, IconBolt, IconTargetArrow } from "@tabler/icons-react"
import { api } from "@/convex/_generated/api" import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel" import type { Id } from "@/convex/_generated/dataModel"
import { useAuth } from "@/lib/auth-client" import { useAuth } from "@/lib/auth-client"
import { DEFAULT_TENANT_ID } from "@/lib/constants" import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton" 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 { CategorySlaManager } from "./category-sla-manager"
import { CompanySlaManager } from "./company-sla-manager" import { CompanySlaManager } from "./company-sla-manager"
@ -26,6 +29,14 @@ type SlaPolicy = {
timeToResolution: number | null 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) { function formatMinutes(value: number | null) {
if (value === null) return "—" if (value === null) return "—"
if (value < 60) return `${Math.round(value)} min` if (value < 60) return `${Math.round(value)} min`
@ -35,6 +46,23 @@ function formatMinutes(value: number | null) {
return `${hours}h ${minutes}min` 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() { export function SlasManager() {
const { session, convexUserId } = useAuth() const { session, convexUserId } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
@ -48,12 +76,15 @@ export function SlasManager() {
const updateSla = useMutation(api.slas.update) const updateSla = useMutation(api.slas.update)
const removeSla = useMutation(api.slas.remove) const removeSla = useMutation(api.slas.remove)
const [dialogOpen, setDialogOpen] = useState(false)
const [editingSla, setEditingSla] = useState<SlaPolicy | null>(null)
const [name, setName] = useState("") const [name, setName] = useState("")
const [description, setDescription] = useState("") const [description, setDescription] = useState("")
const [firstResponse, setFirstResponse] = useState<string>("") const [responseAmount, setResponseAmount] = useState("")
const [resolution, setResolution] = useState<string>("") const [responseUnit, setResponseUnit] = useState<TimeUnit>("hours")
const [resolutionAmount, setResolutionAmount] = useState("")
const [resolutionUnit, setResolutionUnit] = useState<TimeUnit>("hours")
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [editingSla, setEditingSla] = useState<SlaPolicy | null>(null)
const { bestFirstResponse, bestResolution } = useMemo(() => { const { bestFirstResponse, bestResolution } = useMemo(() => {
if (!slas) return { bestFirstResponse: null, bestResolution: null } if (!slas) return { bestFirstResponse: null, bestResolution: null }
@ -74,82 +105,81 @@ export function SlasManager() {
const resetForm = () => { const resetForm = () => {
setName("") setName("")
setDescription("") setDescription("")
setFirstResponse("") setResponseAmount("")
setResolution("") setResponseUnit("hours")
setResolutionAmount("")
setResolutionUnit("hours")
} }
const parseNumber = (value: string) => { const openCreateDialog = () => {
const parsed = Number(value) resetForm()
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined setEditingSla(null)
setDialogOpen(true)
} }
const handleCreate = async (event: React.FormEvent<HTMLFormElement>) => { const openEditDialog = (policy: SlaPolicy) => {
event.preventDefault() const response = minutesToForm(policy.timeToFirstResponse)
if (!name.trim()) { const resolution = minutesToForm(policy.timeToResolution)
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) => {
setEditingSla(policy) setEditingSla(policy)
setName(policy.name) setName(policy.name)
setDescription(policy.description) setDescription(policy.description)
setFirstResponse(policy.timeToFirstResponse ? String(policy.timeToFirstResponse) : "") setResponseAmount(response.amount)
setResolution(policy.timeToResolution ? String(policy.timeToResolution) : "") setResponseUnit(response.unit)
setResolutionAmount(resolution.amount)
setResolutionUnit(resolution.unit)
setDialogOpen(true)
} }
const handleUpdate = async () => { const closeDialog = () => {
if (!editingSla) return setDialogOpen(false)
setEditingSla(null)
resetForm()
}
const handleSave = async () => {
if (!name.trim()) { if (!name.trim()) {
toast.error("Informe um nome para a política") toast.error("Informe um nome para a politica")
return return
} }
if (!convexUserId) { if (!convexUserId) {
toast.error("Sessão não sincronizada com o Convex") toast.error("Sessao nao sincronizada com o Convex")
return return
} }
const timeToFirstResponse = convertToMinutes(responseAmount, responseUnit)
const timeToResolution = convertToMinutes(resolutionAmount, resolutionUnit)
setSaving(true) 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 { try {
await updateSla({ if (editingSla) {
tenantId, await updateSla({
policyId: editingSla.id as Id<"slaPolicies">, tenantId,
actorId: convexUserId as Id<"users">, policyId: editingSla.id as Id<"slaPolicies">,
name: name.trim(), actorId: convexUserId as Id<"users">,
description: description.trim() || undefined, name: name.trim(),
timeToFirstResponse: parseNumber(firstResponse), description: description.trim() || undefined,
timeToResolution: parseNumber(resolution), timeToFirstResponse,
}) timeToResolution,
toast.success("Política atualizada", { id: "sla-edit" }) })
setEditingSla(null) toast.success("Politica atualizada", { id: toastId })
resetForm() } 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) { } catch (error) {
console.error(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 { } finally {
setSaving(false) setSaving(false)
} }
@ -178,13 +208,14 @@ export function SlasManager() {
return ( return (
<div className="space-y-8"> <div className="space-y-8">
{/* Cards de resumo */}
<div className="grid gap-4 md:grid-cols-3"> <div className="grid gap-4 md:grid-cols-3">
<Card className="border-slate-200"> <Card className="border-slate-200">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600"> <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> </CardTitle>
<CardDescription>Regras aplicadas às filas e tickets.</CardDescription> <CardDescription>Regras que valem para todas as empresas.</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900"> <CardContent className="text-3xl font-semibold text-neutral-900">
{slas ? slas.length : <Skeleton className="h-8 w-16" />} {slas ? slas.length : <Skeleton className="h-8 w-16" />}
@ -193,9 +224,9 @@ export function SlasManager() {
<Card className="border-slate-200"> <Card className="border-slate-200">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600"> <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> </CardTitle>
<CardDescription>Tempo mínimo para primeira resposta.</CardDescription> <CardDescription>Menor meta de primeira resposta.</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="text-xl font-semibold text-neutral-900"> <CardContent className="text-xl font-semibold text-neutral-900">
{slas ? formatMinutes(bestFirstResponse ?? null) : <Skeleton className="h-8 w-24" />} {slas ? formatMinutes(bestFirstResponse ?? null) : <Skeleton className="h-8 w-24" />}
@ -204,9 +235,9 @@ export function SlasManager() {
<Card className="border-slate-200"> <Card className="border-slate-200">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600"> <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> </CardTitle>
<CardDescription>Alvo para encerrar chamados.</CardDescription> <CardDescription>Menor meta para encerrar chamados.</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="text-xl font-semibold text-neutral-900"> <CardContent className="text-xl font-semibold text-neutral-900">
{slas ? formatMinutes(bestResolution ?? null) : <Skeleton className="h-8 w-24" />} {slas ? formatMinutes(bestResolution ?? null) : <Skeleton className="h-8 w-24" />}
@ -214,179 +245,184 @@ export function SlasManager() {
</Card> </Card>
</div> </div>
{/* Politicas globais de SLA */}
<Card className="border-slate-200"> <Card className="border-slate-200">
<CardHeader> <CardHeader>
<CardTitle className="text-lg font-semibold text-neutral-900">Nova política de SLA</CardTitle> <div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<CardDescription>Defina metas de resposta e resolução para garantir previsibilidade no atendimento.</CardDescription> <div>
</CardHeader> <CardTitle className="flex items-center gap-2 text-lg font-semibold text-neutral-900">
<CardContent> <Globe className="size-5" />
<form onSubmit={handleCreate} className="grid gap-4 md:grid-cols-[minmax(0,320px)_minmax(0,1fr)]"> Politicas globais de SLA
<div className="space-y-3"> </CardTitle>
<div className="space-y-2"> <CardDescription className="mt-1">
<Label htmlFor="sla-name">Nome da política</Label> Estas regras valem para todas as empresas e categorias. Sao sobrescritas por regras mais especificas
<Input (por empresa ou por categoria).
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.
</CardDescription> </CardDescription>
</CardHeader> </div>
</Card> <Button size="sm" onClick={openCreateDialog} disabled={!convexUserId} className="gap-2 shrink-0">
) : ( <Plus className="size-4" />
slas.map((policy) => ( Nova politica
<Card key={policy.id} className="border-slate-200"> </Button>
<CardHeader> </div>
<div className="flex items-start justify-between gap-4"> </CardHeader>
<div className="space-y-2"> <CardContent className="space-y-4">
<CardTitle className="text-xl font-semibold text-neutral-900">{policy.name}</CardTitle> {slas === undefined ? (
{policy.description ? ( <div className="space-y-3">
<CardDescription className="text-neutral-600">{policy.description}</CardDescription> {Array.from({ length: 2 }).map((_, index) => (
) : null} <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>
<div className="flex gap-2"> <div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => openEdit(policy)}> <Button size="sm" variant="secondary" onClick={() => openEditDialog(policy)} disabled={!convexUserId}>
Editar Editar
</Button> </Button>
<Button variant="destructive" size="sm" onClick={() => handleRemove(policy)}> <Button size="sm" variant="destructive" onClick={() => handleRemove(policy)} disabled={!convexUserId}>
Excluir Excluir
</Button> </Button>
</div> </div>
</div> </div>
</CardHeader> ))}
<CardContent> </div>
<dl className="grid gap-4 md:grid-cols-2"> )}
<div> </CardContent>
<dt className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Primeira resposta</dt> </Card>
<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>
{/* SLA por empresa */}
<CompanySlaManager /> <CompanySlaManager />
{/* SLA por categoria */}
<CategorySlaManager /> <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"> <DialogContent className="max-w-2xl">
<DialogHeader> <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> </DialogHeader>
<div className="space-y-4 py-2"> <div className="space-y-4 py-2">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="edit-sla-name">Nome</Label> <Label htmlFor="sla-name">Nome da politica</Label>
<Input <Input
id="edit-sla-name" id="sla-name"
placeholder="Ex.: Atendimento padrao, Premium, Urgente..."
value={name} value={name}
onChange={(event) => setName(event.target.value)} onChange={(event) => setName(event.target.value)}
autoFocus autoFocus
/> />
</div> </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"> <div className="space-y-2">
<Label htmlFor="edit-sla-description">Descrição</Label> <Label htmlFor="sla-description">Descricao (opcional)</Label>
<textarea <textarea
id="edit-sla-description" 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" 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} value={description}
onChange={(event) => setDescription(event.target.value)} onChange={(event) => setDescription(event.target.value)}
/> />
</div> </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> </div>
<DialogFooter className="gap-2"> <DialogFooter className="gap-2">
<Button variant="outline" onClick={() => setEditingSla(null)}> <Button variant="outline" onClick={closeDialog}>
Cancelar Cancelar
</Button> </Button>
<Button onClick={handleUpdate} disabled={saving}> <Button onClick={handleSave} disabled={saving || !name.trim()}>
Salvar alterações {saving ? "Salvando..." : editingSla ? "Salvar alteracoes" : "Criar politica"}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View file

@ -163,6 +163,7 @@ export function CompanyReport() {
searchPlaceholder="Buscar empresa..." searchPlaceholder="Buscar empresa..."
emptyText="Nenhuma empresa encontrada." emptyText="Nenhuma empresa encontrada."
disabled={!companyOptions.length} disabled={!companyOptions.length}
triggerClassName="h-10 rounded-xl border-slate-200"
/> />
</div> </div>
<Select value={timeRange} onValueChange={(value) => setTimeRange(value as typeof timeRange)}> <Select value={timeRange} onValueChange={(value) => setTimeRange(value as typeof timeRange)}>