feat: adiciona informacoes de reinicio e melhora SLA global
All checks were successful
All checks were successful
- Agente Rust: captura LastBootTime, uptime e contagem de boots - Backend: extrai campos do extended (bootInfo, discos, RAM, etc) antes de salvar - Frontend /devices: exibe secao de ultimo reinicio - SLA global: adiciona campos de modo, threshold de alerta e status de pausa - Corrige acento em "destinatario" -> "destinatario" em automations 🤖 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
d32b94c22d
commit
f39bd46c2b
7 changed files with 338 additions and 19 deletions
|
|
@ -1099,6 +1099,41 @@ fn collect_windows_extended() -> serde_json::Value {
|
|||
}
|
||||
"#).unwrap_or_else(|| json!({}));
|
||||
|
||||
// Último reinício e contagem de boots
|
||||
let boot_info = ps(r#"
|
||||
$os = Get-CimInstance Win32_OperatingSystem | Select-Object LastBootUpTime
|
||||
$lastBoot = $os.LastBootUpTime
|
||||
|
||||
# Calcula uptime
|
||||
$uptime = if ($lastBoot) { (New-TimeSpan -Start $lastBoot -End (Get-Date)).TotalSeconds } else { 0 }
|
||||
|
||||
# Conta eventos de boot (ID 6005) - últimos 30 dias para performance
|
||||
$startDate = (Get-Date).AddDays(-30)
|
||||
$bootEvents = @()
|
||||
$bootCount = 0
|
||||
try {
|
||||
$events = Get-WinEvent -FilterHashtable @{
|
||||
LogName = 'System'
|
||||
ID = 6005
|
||||
StartTime = $startDate
|
||||
} -MaxEvents 50 -ErrorAction SilentlyContinue
|
||||
$bootCount = @($events).Count
|
||||
$bootEvents = @($events | Select-Object -First 10 | ForEach-Object {
|
||||
@{
|
||||
TimeCreated = $_.TimeCreated.ToString('o')
|
||||
Computer = $_.MachineName
|
||||
}
|
||||
})
|
||||
} catch {}
|
||||
|
||||
[PSCustomObject]@{
|
||||
LastBootTime = if ($lastBoot) { $lastBoot.ToString('o') } else { $null }
|
||||
UptimeSeconds = [math]::Round($uptime)
|
||||
BootCountLast30Days = $bootCount
|
||||
RecentBoots = $bootEvents
|
||||
}
|
||||
"#).unwrap_or_else(|| json!({ "LastBootTime": null, "UptimeSeconds": 0, "BootCountLast30Days": 0, "RecentBoots": [] }));
|
||||
|
||||
json!({
|
||||
"windows": {
|
||||
"software": software,
|
||||
|
|
@ -1125,6 +1160,7 @@ fn collect_windows_extended() -> serde_json::Value {
|
|||
"networkAdapters": network_adapters,
|
||||
"monitors": monitors,
|
||||
"chassis": power_supply,
|
||||
"bootInfo": boot_info,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -331,9 +331,59 @@ async function getMachineLastHeartbeat(
|
|||
return hb?.lastHeartbeatAt ?? fallback ?? null
|
||||
}
|
||||
|
||||
// Campos do inventory que sao muito grandes e nao devem ser persistidos
|
||||
// para evitar OOM no Convex (documentos de ~100KB cada)
|
||||
const INVENTORY_BLOCKLIST = new Set(["software", "extended"])
|
||||
// Campo software é muito grande e é tratado separadamente via machineSoftware
|
||||
|
||||
// Extrai campos importantes do extended antes de bloqueá-lo
|
||||
function extractFromExtended(extended: unknown): JsonRecord {
|
||||
const result: JsonRecord = {}
|
||||
const sanitizedExtended = sanitizeRecord(extended)
|
||||
if (!sanitizedExtended) return result
|
||||
|
||||
// Extrair dados do Windows
|
||||
const windows = sanitizeRecord(sanitizedExtended["windows"])
|
||||
if (windows) {
|
||||
const windowsFields: JsonRecord = {}
|
||||
// bootInfo - informacoes de reinicio
|
||||
if (windows["bootInfo"]) {
|
||||
windowsFields["bootInfo"] = windows["bootInfo"] as JsonValue
|
||||
}
|
||||
// osInfo - informacoes do sistema operacional
|
||||
if (windows["osInfo"]) {
|
||||
windowsFields["osInfo"] = windows["osInfo"] as JsonValue
|
||||
}
|
||||
// cpu, baseboard, bios, memoryModules, videoControllers, disks
|
||||
for (const key of ["cpu", "baseboard", "bios", "memoryModules", "videoControllers", "disks", "bitLocker", "tpm", "secureBoot", "deviceGuard", "firewallProfiles", "windowsUpdate", "computerSystem", "azureAdStatus", "battery", "thermal", "networkAdapters", "monitors", "chassis", "defender", "hotfix"]) {
|
||||
if (windows[key]) {
|
||||
windowsFields[key] = windows[key] as JsonValue
|
||||
}
|
||||
}
|
||||
if (Object.keys(windowsFields).length > 0) {
|
||||
result["windows"] = windowsFields
|
||||
}
|
||||
}
|
||||
|
||||
// Extrair dados do Linux
|
||||
const linux = sanitizeRecord(sanitizedExtended["linux"])
|
||||
if (linux) {
|
||||
const linuxFields: JsonRecord = {}
|
||||
for (const key of ["lsblk", "smart", "lspci", "lsusb", "dmidecode"]) {
|
||||
if (linux[key]) {
|
||||
linuxFields[key] = linux[key] as JsonValue
|
||||
}
|
||||
}
|
||||
if (Object.keys(linuxFields).length > 0) {
|
||||
result["linux"] = linuxFields
|
||||
}
|
||||
}
|
||||
|
||||
// Extrair dados do macOS
|
||||
const macos = sanitizeRecord(sanitizedExtended["macos"])
|
||||
if (macos) {
|
||||
result["macos"] = macos as JsonValue
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function mergeInventory(current: JsonRecord | null | undefined, patch: Record<string, unknown>): JsonRecord {
|
||||
const sanitizedPatch = sanitizeRecord(patch)
|
||||
|
|
@ -341,9 +391,10 @@ function mergeInventory(current: JsonRecord | null | undefined, patch: Record<st
|
|||
return current ? { ...current } : {}
|
||||
}
|
||||
const base: JsonRecord = current ? { ...current } : {}
|
||||
|
||||
for (const [key, value] of Object.entries(sanitizedPatch)) {
|
||||
// Filtrar campos volumosos que causam OOM
|
||||
if (INVENTORY_BLOCKLIST.has(key)) continue
|
||||
// Filtrar software (extended já foi processado em sanitizeInventoryPayload)
|
||||
if (key === "software") continue
|
||||
if (value === undefined) continue
|
||||
if (isObject(value) && isObject(base[key])) {
|
||||
base[key] = mergeInventory(base[key] as JsonRecord, value as Record<string, unknown>)
|
||||
|
|
@ -393,9 +444,20 @@ function ensureString(value: unknown): string | null {
|
|||
function sanitizeInventoryPayload(value: unknown): JsonRecord | null {
|
||||
const record = sanitizeRecord(value)
|
||||
if (!record) return null
|
||||
for (const blocked of INVENTORY_BLOCKLIST) {
|
||||
delete record[blocked]
|
||||
|
||||
// Extrair campos importantes do extended antes de deletá-lo
|
||||
if (record["extended"]) {
|
||||
const extractedExtended = extractFromExtended(record["extended"])
|
||||
if (Object.keys(extractedExtended).length > 0) {
|
||||
record["extended"] = extractedExtended
|
||||
} else {
|
||||
delete record["extended"]
|
||||
}
|
||||
}
|
||||
|
||||
// Deletar apenas software (extended já foi processado acima)
|
||||
delete record["software"]
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -200,7 +200,11 @@ export default defineSchema({
|
|||
name: v.string(),
|
||||
description: v.optional(v.string()),
|
||||
timeToFirstResponse: v.optional(v.number()), // minutes
|
||||
responseMode: v.optional(v.string()), // "business" | "calendar"
|
||||
timeToResolution: v.optional(v.number()), // minutes
|
||||
solutionMode: v.optional(v.string()), // "business" | "calendar"
|
||||
alertThreshold: v.optional(v.number()), // 0.1 a 0.95
|
||||
pauseStatuses: v.optional(v.array(v.string())), // Status que pausam SLA
|
||||
}).index("by_tenant_name", ["tenantId", "name"]),
|
||||
|
||||
tickets: defineTable({
|
||||
|
|
|
|||
|
|
@ -9,6 +9,26 @@ function normalizeName(value: string) {
|
|||
return value.trim();
|
||||
}
|
||||
|
||||
function normalizeMode(value?: string): "business" | "calendar" {
|
||||
if (value === "business") return "business";
|
||||
return "calendar";
|
||||
}
|
||||
|
||||
function normalizeThreshold(value?: number): number {
|
||||
if (value === undefined || value === null) return 0.8;
|
||||
if (value < 0.1) return 0.1;
|
||||
if (value > 0.95) return 0.95;
|
||||
return value;
|
||||
}
|
||||
|
||||
const VALID_PAUSE_STATUSES = ["PAUSED", "PENDING", "AWAITING_ATTENDANCE"] as const;
|
||||
|
||||
function normalizePauseStatuses(statuses?: string[]): string[] {
|
||||
if (!statuses || statuses.length === 0) return ["PAUSED"];
|
||||
const filtered = statuses.filter((s) => VALID_PAUSE_STATUSES.includes(s as typeof VALID_PAUSE_STATUSES[number]));
|
||||
return filtered.length > 0 ? filtered : ["PAUSED"];
|
||||
}
|
||||
|
||||
type AnyCtx = QueryCtx | MutationCtx;
|
||||
|
||||
async function ensureUniqueName(ctx: AnyCtx, tenantId: string, name: string, excludeId?: Id<"slaPolicies">) {
|
||||
|
|
@ -35,7 +55,11 @@ export const list = query({
|
|||
name: policy.name,
|
||||
description: policy.description ?? "",
|
||||
timeToFirstResponse: policy.timeToFirstResponse ?? null,
|
||||
responseMode: policy.responseMode ?? "calendar",
|
||||
timeToResolution: policy.timeToResolution ?? null,
|
||||
solutionMode: policy.solutionMode ?? "calendar",
|
||||
alertThreshold: policy.alertThreshold ?? 0.8,
|
||||
pauseStatuses: policy.pauseStatuses ?? ["PAUSED"],
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
|
@ -47,9 +71,14 @@ export const create = mutation({
|
|||
name: v.string(),
|
||||
description: v.optional(v.string()),
|
||||
timeToFirstResponse: v.optional(v.number()),
|
||||
responseMode: v.optional(v.string()),
|
||||
timeToResolution: v.optional(v.number()),
|
||||
solutionMode: v.optional(v.string()),
|
||||
alertThreshold: v.optional(v.number()),
|
||||
pauseStatuses: v.optional(v.array(v.string())),
|
||||
},
|
||||
handler: async (ctx, { tenantId, actorId, name, description, timeToFirstResponse, timeToResolution }) => {
|
||||
handler: async (ctx, args) => {
|
||||
const { tenantId, actorId, name, description, timeToFirstResponse, responseMode, timeToResolution, solutionMode, alertThreshold, pauseStatuses } = args;
|
||||
await requireAdmin(ctx, actorId, tenantId);
|
||||
const trimmed = normalizeName(name);
|
||||
if (trimmed.length < 2) {
|
||||
|
|
@ -68,7 +97,11 @@ export const create = mutation({
|
|||
name: trimmed,
|
||||
description,
|
||||
timeToFirstResponse,
|
||||
responseMode: normalizeMode(responseMode),
|
||||
timeToResolution,
|
||||
solutionMode: normalizeMode(solutionMode),
|
||||
alertThreshold: normalizeThreshold(alertThreshold),
|
||||
pauseStatuses: normalizePauseStatuses(pauseStatuses),
|
||||
});
|
||||
return id;
|
||||
},
|
||||
|
|
@ -82,9 +115,14 @@ export const update = mutation({
|
|||
name: v.string(),
|
||||
description: v.optional(v.string()),
|
||||
timeToFirstResponse: v.optional(v.number()),
|
||||
responseMode: v.optional(v.string()),
|
||||
timeToResolution: v.optional(v.number()),
|
||||
solutionMode: v.optional(v.string()),
|
||||
alertThreshold: v.optional(v.number()),
|
||||
pauseStatuses: v.optional(v.array(v.string())),
|
||||
},
|
||||
handler: async (ctx, { policyId, tenantId, actorId, name, description, timeToFirstResponse, timeToResolution }) => {
|
||||
handler: async (ctx, args) => {
|
||||
const { policyId, tenantId, actorId, name, description, timeToFirstResponse, responseMode, timeToResolution, solutionMode, alertThreshold, pauseStatuses } = args;
|
||||
await requireAdmin(ctx, actorId, tenantId);
|
||||
const policy = await ctx.db.get(policyId);
|
||||
if (!policy || policy.tenantId !== tenantId) {
|
||||
|
|
@ -106,7 +144,11 @@ export const update = mutation({
|
|||
name: trimmed,
|
||||
description,
|
||||
timeToFirstResponse,
|
||||
responseMode: normalizeMode(responseMode),
|
||||
timeToResolution,
|
||||
solutionMode: normalizeMode(solutionMode),
|
||||
alertThreshold: normalizeThreshold(alertThreshold),
|
||||
pauseStatuses: normalizePauseStatuses(pauseStatuses),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -329,6 +329,16 @@ type WindowsChassis = {
|
|||
SMBIOSAssetTag?: string
|
||||
}
|
||||
|
||||
type WindowsBootInfo = {
|
||||
LastBootTime?: string
|
||||
UptimeSeconds?: number
|
||||
BootCountLast30Days?: number
|
||||
RecentBoots?: Array<{
|
||||
TimeCreated?: string
|
||||
Computer?: string
|
||||
}>
|
||||
}
|
||||
|
||||
type WindowsExtended = {
|
||||
software?: DeviceSoftware[]
|
||||
services?: Array<{ name?: string; status?: string; displayName?: string }>
|
||||
|
|
@ -355,6 +365,7 @@ type WindowsExtended = {
|
|||
networkAdapters?: WindowsNetworkAdapter[]
|
||||
monitors?: WindowsMonitor[]
|
||||
chassis?: WindowsChassis
|
||||
bootInfo?: WindowsBootInfo
|
||||
}
|
||||
|
||||
type MacExtended = {
|
||||
|
|
@ -3093,6 +3104,7 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
|
|||
return []
|
||||
}, [windowsExt?.monitors])
|
||||
const windowsChassisInfo = windowsExt?.chassis ?? null
|
||||
const windowsBootInfo = windowsExt?.bootInfo ?? null
|
||||
const osNameDisplay = useMemo(() => {
|
||||
const base = device?.osName?.trim()
|
||||
const edition = windowsEditionLabel?.trim()
|
||||
|
|
@ -4302,11 +4314,6 @@ 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">
|
||||
|
|
@ -4346,6 +4353,11 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
|
|||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Softwares instalados */}
|
||||
<div className="space-y-3 border-t border-slate-100 pt-5">
|
||||
<DeviceSoftwareList machineId={device.id as Id<"machines">} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3 border-t border-slate-100 pt-6">
|
||||
|
|
@ -5721,6 +5733,33 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Último reinício */}
|
||||
{windowsBootInfo ? (
|
||||
<div className="rounded-md border border-slate-200 bg-slate-50/60 p-3">
|
||||
<p className="text-xs font-semibold uppercase text-slate-500">Último reinício</p>
|
||||
<div className="mt-2 grid gap-1 text-sm text-muted-foreground">
|
||||
<DetailLine
|
||||
label="Data/Hora"
|
||||
value={windowsBootInfo.LastBootTime
|
||||
? format(new Date(windowsBootInfo.LastBootTime), "dd/MM/yyyy 'às' HH:mm", { locale: ptBR })
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
<DetailLine
|
||||
label="Tempo ligado"
|
||||
value={windowsBootInfo.UptimeSeconds
|
||||
? formatDistanceToNowStrict(Date.now() - (windowsBootInfo.UptimeSeconds * 1000), { locale: ptBR, addSuffix: false })
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
<DetailLine
|
||||
label="Reinícios (30 dias)"
|
||||
value={windowsBootInfo.BootCountLast30Days?.toString() ?? "—"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ 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, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
import { CategorySlaManager } from "./category-sla-manager"
|
||||
import { CompanySlaManager } from "./company-sla-manager"
|
||||
|
|
@ -26,10 +27,15 @@ type SlaPolicy = {
|
|||
name: string
|
||||
description: string
|
||||
timeToFirstResponse: number | null
|
||||
responseMode: "business" | "calendar"
|
||||
timeToResolution: number | null
|
||||
solutionMode: "business" | "calendar"
|
||||
alertThreshold: number
|
||||
pauseStatuses: string[]
|
||||
}
|
||||
|
||||
type TimeUnit = "minutes" | "hours" | "days"
|
||||
type TimeMode = "business" | "calendar"
|
||||
|
||||
const TIME_UNITS: Array<{ value: TimeUnit; label: string; factor: number }> = [
|
||||
{ value: "minutes", label: "Minutos", factor: 1 },
|
||||
|
|
@ -37,6 +43,17 @@ const TIME_UNITS: Array<{ value: TimeUnit; label: string; factor: number }> = [
|
|||
{ value: "days", label: "Dias", factor: 1440 },
|
||||
]
|
||||
|
||||
const MODE_OPTIONS: Array<{ value: TimeMode; label: string }> = [
|
||||
{ value: "calendar", label: "Horas corridas" },
|
||||
{ value: "business", label: "Horas úteis" },
|
||||
]
|
||||
|
||||
const PAUSE_STATUS_OPTIONS = [
|
||||
{ value: "PENDING", label: "Pendente" },
|
||||
{ value: "AWAITING_ATTENDANCE", label: "Em atendimento" },
|
||||
{ value: "PAUSED", label: "Pausado" },
|
||||
] as const
|
||||
|
||||
function formatMinutes(value: number | null) {
|
||||
if (value === null) return "—"
|
||||
if (value < 60) return `${Math.round(value)} min`
|
||||
|
|
@ -82,8 +99,12 @@ export function SlasManager() {
|
|||
const [description, setDescription] = useState("")
|
||||
const [responseAmount, setResponseAmount] = useState("")
|
||||
const [responseUnit, setResponseUnit] = useState<TimeUnit>("hours")
|
||||
const [responseMode, setResponseMode] = useState<TimeMode>("calendar")
|
||||
const [resolutionAmount, setResolutionAmount] = useState("")
|
||||
const [resolutionUnit, setResolutionUnit] = useState<TimeUnit>("hours")
|
||||
const [solutionMode, setSolutionMode] = useState<TimeMode>("calendar")
|
||||
const [alertThreshold, setAlertThreshold] = useState(80)
|
||||
const [pauseStatuses, setPauseStatuses] = useState<string[]>(["PAUSED"])
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const { bestFirstResponse, bestResolution } = useMemo(() => {
|
||||
|
|
@ -107,8 +128,12 @@ export function SlasManager() {
|
|||
setDescription("")
|
||||
setResponseAmount("")
|
||||
setResponseUnit("hours")
|
||||
setResponseMode("calendar")
|
||||
setResolutionAmount("")
|
||||
setResolutionUnit("hours")
|
||||
setSolutionMode("calendar")
|
||||
setAlertThreshold(80)
|
||||
setPauseStatuses(["PAUSED"])
|
||||
}
|
||||
|
||||
const openCreateDialog = () => {
|
||||
|
|
@ -125,11 +150,30 @@ export function SlasManager() {
|
|||
setDescription(policy.description)
|
||||
setResponseAmount(response.amount)
|
||||
setResponseUnit(response.unit)
|
||||
setResponseMode(policy.responseMode ?? "calendar")
|
||||
setResolutionAmount(resolution.amount)
|
||||
setResolutionUnit(resolution.unit)
|
||||
setSolutionMode(policy.solutionMode ?? "calendar")
|
||||
setAlertThreshold(Math.round((policy.alertThreshold ?? 0.8) * 100))
|
||||
setPauseStatuses(policy.pauseStatuses ?? ["PAUSED"])
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const togglePauseStatus = (status: string) => {
|
||||
setPauseStatuses((current) => {
|
||||
const selected = new Set(current)
|
||||
if (selected.has(status)) {
|
||||
selected.delete(status)
|
||||
} else {
|
||||
selected.add(status)
|
||||
}
|
||||
if (selected.size === 0) {
|
||||
selected.add("PAUSED")
|
||||
}
|
||||
return Array.from(selected)
|
||||
})
|
||||
}
|
||||
|
||||
const closeDialog = () => {
|
||||
setDialogOpen(false)
|
||||
setEditingSla(null)
|
||||
|
|
@ -153,6 +197,8 @@ export function SlasManager() {
|
|||
const toastId = editingSla ? "sla-edit" : "sla-create"
|
||||
toast.loading(editingSla ? "Salvando alterações..." : "Criando política...", { id: toastId })
|
||||
|
||||
const normalizedThreshold = Math.min(Math.max(alertThreshold, 10), 95) / 100
|
||||
|
||||
try {
|
||||
if (editingSla) {
|
||||
await updateSla({
|
||||
|
|
@ -162,7 +208,11 @@ export function SlasManager() {
|
|||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
timeToFirstResponse,
|
||||
responseMode,
|
||||
timeToResolution,
|
||||
solutionMode,
|
||||
alertThreshold: normalizedThreshold,
|
||||
pauseStatuses,
|
||||
})
|
||||
toast.success("Política atualizada", { id: toastId })
|
||||
} else {
|
||||
|
|
@ -172,7 +222,11 @@ export function SlasManager() {
|
|||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
timeToFirstResponse,
|
||||
responseMode,
|
||||
timeToResolution,
|
||||
solutionMode,
|
||||
alertThreshold: normalizedThreshold,
|
||||
pauseStatuses,
|
||||
})
|
||||
toast.success("Política criada", { id: toastId })
|
||||
}
|
||||
|
|
@ -235,7 +289,7 @@ 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" /> Melhor resolucao
|
||||
<IconBolt className="size-4" /> Melhor resolução
|
||||
</CardTitle>
|
||||
<CardDescription>Menor meta para encerrar chamados.</CardDescription>
|
||||
</CardHeader>
|
||||
|
|
@ -298,9 +352,24 @@ export function SlasManager() {
|
|||
{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>Resolução: {formatMinutes(policy.timeToResolution)}</span>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-neutral-600">
|
||||
<span>
|
||||
Resposta: {formatMinutes(policy.timeToFirstResponse)}
|
||||
{policy.timeToFirstResponse !== null && (
|
||||
<span className="ml-1 text-neutral-400">
|
||||
({policy.responseMode === "business" ? "horas úteis" : "corrido"})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span>
|
||||
Resolução: {formatMinutes(policy.timeToResolution)}
|
||||
{policy.timeToResolution !== null && (
|
||||
<span className="ml-1 text-neutral-400">
|
||||
({policy.solutionMode === "business" ? "horas úteis" : "corrido"})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span>Alerta: {Math.round(policy.alertThreshold * 100)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -382,6 +451,18 @@ export function SlasManager() {
|
|||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Select value={responseMode} onValueChange={(value) => setResponseMode(value as TimeMode)}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MODE_OPTIONS.map((mode) => (
|
||||
<SelectItem key={mode.value} value={mode.value}>
|
||||
{mode.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Tempo para resolução</Label>
|
||||
|
|
@ -408,6 +489,61 @@ export function SlasManager() {
|
|||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Select value={solutionMode} onValueChange={(value) => setSolutionMode(value as TimeMode)}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MODE_OPTIONS.map((mode) => (
|
||||
<SelectItem key={mode.value} value={mode.value}>
|
||||
{mode.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Alerta de proximidade</Label>
|
||||
<p className="text-xs text-neutral-500">
|
||||
Percentual do tempo em que o sistema emitirá alerta antes do vencimento.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={10}
|
||||
max={95}
|
||||
value={alertThreshold}
|
||||
onChange={(event) => setAlertThreshold(Number(event.target.value))}
|
||||
className="w-20"
|
||||
/>
|
||||
<span className="text-sm text-neutral-500">%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Status que pausam o SLA</Label>
|
||||
<p className="text-xs text-neutral-500">
|
||||
Quando o chamado estiver em um desses status, o cronômetro do SLA será pausado.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{PAUSE_STATUS_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => togglePauseStatus(option.value)}
|
||||
className={cn(
|
||||
"rounded-full px-3 py-1 text-xs font-medium transition-colors",
|
||||
pauseStatuses.includes(option.value)
|
||||
? "bg-neutral-900 text-white"
|
||||
: "bg-slate-100 text-neutral-600 hover:bg-slate-200"
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-3">
|
||||
|
|
|
|||
|
|
@ -598,7 +598,7 @@ export function EmailActionConfig({ action, onChange, onRemove, agents }: EmailA
|
|||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl">
|
||||
<SelectItem value="AUTO">Auto (detecta pelo destinatario)</SelectItem>
|
||||
<SelectItem value="AUTO">Auto (detecta pelo destinatário)</SelectItem>
|
||||
<SelectItem value="PORTAL">Portal (cliente)</SelectItem>
|
||||
<SelectItem value="STAFF">Painel (agente)</SelectItem>
|
||||
</SelectContent>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue