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!({}));
|
"#).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!({
|
json!({
|
||||||
"windows": {
|
"windows": {
|
||||||
"software": software,
|
"software": software,
|
||||||
|
|
@ -1125,6 +1160,7 @@ fn collect_windows_extended() -> serde_json::Value {
|
||||||
"networkAdapters": network_adapters,
|
"networkAdapters": network_adapters,
|
||||||
"monitors": monitors,
|
"monitors": monitors,
|
||||||
"chassis": power_supply,
|
"chassis": power_supply,
|
||||||
|
"bootInfo": boot_info,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -331,9 +331,59 @@ async function getMachineLastHeartbeat(
|
||||||
return hb?.lastHeartbeatAt ?? fallback ?? null
|
return hb?.lastHeartbeatAt ?? fallback ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Campos do inventory que sao muito grandes e nao devem ser persistidos
|
// Campo software é muito grande e é tratado separadamente via machineSoftware
|
||||||
// para evitar OOM no Convex (documentos de ~100KB cada)
|
|
||||||
const INVENTORY_BLOCKLIST = new Set(["software", "extended"])
|
// 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 {
|
function mergeInventory(current: JsonRecord | null | undefined, patch: Record<string, unknown>): JsonRecord {
|
||||||
const sanitizedPatch = sanitizeRecord(patch)
|
const sanitizedPatch = sanitizeRecord(patch)
|
||||||
|
|
@ -341,9 +391,10 @@ function mergeInventory(current: JsonRecord | null | undefined, patch: Record<st
|
||||||
return current ? { ...current } : {}
|
return current ? { ...current } : {}
|
||||||
}
|
}
|
||||||
const base: JsonRecord = current ? { ...current } : {}
|
const base: JsonRecord = current ? { ...current } : {}
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(sanitizedPatch)) {
|
for (const [key, value] of Object.entries(sanitizedPatch)) {
|
||||||
// Filtrar campos volumosos que causam OOM
|
// Filtrar software (extended já foi processado em sanitizeInventoryPayload)
|
||||||
if (INVENTORY_BLOCKLIST.has(key)) continue
|
if (key === "software") continue
|
||||||
if (value === undefined) continue
|
if (value === undefined) continue
|
||||||
if (isObject(value) && isObject(base[key])) {
|
if (isObject(value) && isObject(base[key])) {
|
||||||
base[key] = mergeInventory(base[key] as JsonRecord, value as Record<string, unknown>)
|
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 {
|
function sanitizeInventoryPayload(value: unknown): JsonRecord | null {
|
||||||
const record = sanitizeRecord(value)
|
const record = sanitizeRecord(value)
|
||||||
if (!record) return null
|
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
|
return record
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -200,7 +200,11 @@ export default defineSchema({
|
||||||
name: v.string(),
|
name: v.string(),
|
||||||
description: v.optional(v.string()),
|
description: v.optional(v.string()),
|
||||||
timeToFirstResponse: v.optional(v.number()), // minutes
|
timeToFirstResponse: v.optional(v.number()), // minutes
|
||||||
|
responseMode: v.optional(v.string()), // "business" | "calendar"
|
||||||
timeToResolution: v.optional(v.number()), // minutes
|
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"]),
|
}).index("by_tenant_name", ["tenantId", "name"]),
|
||||||
|
|
||||||
tickets: defineTable({
|
tickets: defineTable({
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,26 @@ function normalizeName(value: string) {
|
||||||
return value.trim();
|
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;
|
type AnyCtx = QueryCtx | MutationCtx;
|
||||||
|
|
||||||
async function ensureUniqueName(ctx: AnyCtx, tenantId: string, name: string, excludeId?: Id<"slaPolicies">) {
|
async function ensureUniqueName(ctx: AnyCtx, tenantId: string, name: string, excludeId?: Id<"slaPolicies">) {
|
||||||
|
|
@ -35,7 +55,11 @@ export const list = query({
|
||||||
name: policy.name,
|
name: policy.name,
|
||||||
description: policy.description ?? "",
|
description: policy.description ?? "",
|
||||||
timeToFirstResponse: policy.timeToFirstResponse ?? null,
|
timeToFirstResponse: policy.timeToFirstResponse ?? null,
|
||||||
|
responseMode: policy.responseMode ?? "calendar",
|
||||||
timeToResolution: policy.timeToResolution ?? null,
|
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(),
|
name: v.string(),
|
||||||
description: v.optional(v.string()),
|
description: v.optional(v.string()),
|
||||||
timeToFirstResponse: v.optional(v.number()),
|
timeToFirstResponse: v.optional(v.number()),
|
||||||
|
responseMode: v.optional(v.string()),
|
||||||
timeToResolution: v.optional(v.number()),
|
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);
|
await requireAdmin(ctx, actorId, tenantId);
|
||||||
const trimmed = normalizeName(name);
|
const trimmed = normalizeName(name);
|
||||||
if (trimmed.length < 2) {
|
if (trimmed.length < 2) {
|
||||||
|
|
@ -68,7 +97,11 @@ export const create = mutation({
|
||||||
name: trimmed,
|
name: trimmed,
|
||||||
description,
|
description,
|
||||||
timeToFirstResponse,
|
timeToFirstResponse,
|
||||||
|
responseMode: normalizeMode(responseMode),
|
||||||
timeToResolution,
|
timeToResolution,
|
||||||
|
solutionMode: normalizeMode(solutionMode),
|
||||||
|
alertThreshold: normalizeThreshold(alertThreshold),
|
||||||
|
pauseStatuses: normalizePauseStatuses(pauseStatuses),
|
||||||
});
|
});
|
||||||
return id;
|
return id;
|
||||||
},
|
},
|
||||||
|
|
@ -82,9 +115,14 @@ export const update = mutation({
|
||||||
name: v.string(),
|
name: v.string(),
|
||||||
description: v.optional(v.string()),
|
description: v.optional(v.string()),
|
||||||
timeToFirstResponse: v.optional(v.number()),
|
timeToFirstResponse: v.optional(v.number()),
|
||||||
|
responseMode: v.optional(v.string()),
|
||||||
timeToResolution: v.optional(v.number()),
|
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);
|
await requireAdmin(ctx, actorId, tenantId);
|
||||||
const policy = await ctx.db.get(policyId);
|
const policy = await ctx.db.get(policyId);
|
||||||
if (!policy || policy.tenantId !== tenantId) {
|
if (!policy || policy.tenantId !== tenantId) {
|
||||||
|
|
@ -106,7 +144,11 @@ export const update = mutation({
|
||||||
name: trimmed,
|
name: trimmed,
|
||||||
description,
|
description,
|
||||||
timeToFirstResponse,
|
timeToFirstResponse,
|
||||||
|
responseMode: normalizeMode(responseMode),
|
||||||
timeToResolution,
|
timeToResolution,
|
||||||
|
solutionMode: normalizeMode(solutionMode),
|
||||||
|
alertThreshold: normalizeThreshold(alertThreshold),
|
||||||
|
pauseStatuses: normalizePauseStatuses(pauseStatuses),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -329,6 +329,16 @@ type WindowsChassis = {
|
||||||
SMBIOSAssetTag?: string
|
SMBIOSAssetTag?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WindowsBootInfo = {
|
||||||
|
LastBootTime?: string
|
||||||
|
UptimeSeconds?: number
|
||||||
|
BootCountLast30Days?: number
|
||||||
|
RecentBoots?: Array<{
|
||||||
|
TimeCreated?: string
|
||||||
|
Computer?: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
type WindowsExtended = {
|
type WindowsExtended = {
|
||||||
software?: DeviceSoftware[]
|
software?: DeviceSoftware[]
|
||||||
services?: Array<{ name?: string; status?: string; displayName?: string }>
|
services?: Array<{ name?: string; status?: string; displayName?: string }>
|
||||||
|
|
@ -355,6 +365,7 @@ type WindowsExtended = {
|
||||||
networkAdapters?: WindowsNetworkAdapter[]
|
networkAdapters?: WindowsNetworkAdapter[]
|
||||||
monitors?: WindowsMonitor[]
|
monitors?: WindowsMonitor[]
|
||||||
chassis?: WindowsChassis
|
chassis?: WindowsChassis
|
||||||
|
bootInfo?: WindowsBootInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
type MacExtended = {
|
type MacExtended = {
|
||||||
|
|
@ -3093,6 +3104,7 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
|
||||||
return []
|
return []
|
||||||
}, [windowsExt?.monitors])
|
}, [windowsExt?.monitors])
|
||||||
const windowsChassisInfo = windowsExt?.chassis ?? null
|
const windowsChassisInfo = windowsExt?.chassis ?? null
|
||||||
|
const windowsBootInfo = windowsExt?.bootInfo ?? null
|
||||||
const osNameDisplay = useMemo(() => {
|
const osNameDisplay = useMemo(() => {
|
||||||
const base = device?.osName?.trim()
|
const base = device?.osName?.trim()
|
||||||
const edition = windowsEditionLabel?.trim()
|
const edition = windowsEditionLabel?.trim()
|
||||||
|
|
@ -4302,11 +4314,6 @@ 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">
|
||||||
|
|
@ -4346,6 +4353,11 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<section className="space-y-3 border-t border-slate-100 pt-6">
|
<section className="space-y-3 border-t border-slate-100 pt-6">
|
||||||
|
|
@ -5721,6 +5733,33 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import { Label } from "@/components/ui/label"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
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, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
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,10 +27,15 @@ type SlaPolicy = {
|
||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
timeToFirstResponse: number | null
|
timeToFirstResponse: number | null
|
||||||
|
responseMode: "business" | "calendar"
|
||||||
timeToResolution: number | null
|
timeToResolution: number | null
|
||||||
|
solutionMode: "business" | "calendar"
|
||||||
|
alertThreshold: number
|
||||||
|
pauseStatuses: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type TimeUnit = "minutes" | "hours" | "days"
|
type TimeUnit = "minutes" | "hours" | "days"
|
||||||
|
type TimeMode = "business" | "calendar"
|
||||||
|
|
||||||
const TIME_UNITS: Array<{ value: TimeUnit; label: string; factor: number }> = [
|
const TIME_UNITS: Array<{ value: TimeUnit; label: string; factor: number }> = [
|
||||||
{ value: "minutes", label: "Minutos", factor: 1 },
|
{ 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 },
|
{ 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) {
|
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`
|
||||||
|
|
@ -82,8 +99,12 @@ export function SlasManager() {
|
||||||
const [description, setDescription] = useState("")
|
const [description, setDescription] = useState("")
|
||||||
const [responseAmount, setResponseAmount] = useState("")
|
const [responseAmount, setResponseAmount] = useState("")
|
||||||
const [responseUnit, setResponseUnit] = useState<TimeUnit>("hours")
|
const [responseUnit, setResponseUnit] = useState<TimeUnit>("hours")
|
||||||
|
const [responseMode, setResponseMode] = useState<TimeMode>("calendar")
|
||||||
const [resolutionAmount, setResolutionAmount] = useState("")
|
const [resolutionAmount, setResolutionAmount] = useState("")
|
||||||
const [resolutionUnit, setResolutionUnit] = useState<TimeUnit>("hours")
|
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 [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
const { bestFirstResponse, bestResolution } = useMemo(() => {
|
const { bestFirstResponse, bestResolution } = useMemo(() => {
|
||||||
|
|
@ -107,8 +128,12 @@ export function SlasManager() {
|
||||||
setDescription("")
|
setDescription("")
|
||||||
setResponseAmount("")
|
setResponseAmount("")
|
||||||
setResponseUnit("hours")
|
setResponseUnit("hours")
|
||||||
|
setResponseMode("calendar")
|
||||||
setResolutionAmount("")
|
setResolutionAmount("")
|
||||||
setResolutionUnit("hours")
|
setResolutionUnit("hours")
|
||||||
|
setSolutionMode("calendar")
|
||||||
|
setAlertThreshold(80)
|
||||||
|
setPauseStatuses(["PAUSED"])
|
||||||
}
|
}
|
||||||
|
|
||||||
const openCreateDialog = () => {
|
const openCreateDialog = () => {
|
||||||
|
|
@ -125,11 +150,30 @@ export function SlasManager() {
|
||||||
setDescription(policy.description)
|
setDescription(policy.description)
|
||||||
setResponseAmount(response.amount)
|
setResponseAmount(response.amount)
|
||||||
setResponseUnit(response.unit)
|
setResponseUnit(response.unit)
|
||||||
|
setResponseMode(policy.responseMode ?? "calendar")
|
||||||
setResolutionAmount(resolution.amount)
|
setResolutionAmount(resolution.amount)
|
||||||
setResolutionUnit(resolution.unit)
|
setResolutionUnit(resolution.unit)
|
||||||
|
setSolutionMode(policy.solutionMode ?? "calendar")
|
||||||
|
setAlertThreshold(Math.round((policy.alertThreshold ?? 0.8) * 100))
|
||||||
|
setPauseStatuses(policy.pauseStatuses ?? ["PAUSED"])
|
||||||
setDialogOpen(true)
|
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 = () => {
|
const closeDialog = () => {
|
||||||
setDialogOpen(false)
|
setDialogOpen(false)
|
||||||
setEditingSla(null)
|
setEditingSla(null)
|
||||||
|
|
@ -153,6 +197,8 @@ export function SlasManager() {
|
||||||
const toastId = editingSla ? "sla-edit" : "sla-create"
|
const toastId = editingSla ? "sla-edit" : "sla-create"
|
||||||
toast.loading(editingSla ? "Salvando alterações..." : "Criando política...", { id: toastId })
|
toast.loading(editingSla ? "Salvando alterações..." : "Criando política...", { id: toastId })
|
||||||
|
|
||||||
|
const normalizedThreshold = Math.min(Math.max(alertThreshold, 10), 95) / 100
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (editingSla) {
|
if (editingSla) {
|
||||||
await updateSla({
|
await updateSla({
|
||||||
|
|
@ -162,7 +208,11 @@ export function SlasManager() {
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
description: description.trim() || undefined,
|
description: description.trim() || undefined,
|
||||||
timeToFirstResponse,
|
timeToFirstResponse,
|
||||||
|
responseMode,
|
||||||
timeToResolution,
|
timeToResolution,
|
||||||
|
solutionMode,
|
||||||
|
alertThreshold: normalizedThreshold,
|
||||||
|
pauseStatuses,
|
||||||
})
|
})
|
||||||
toast.success("Política atualizada", { id: toastId })
|
toast.success("Política atualizada", { id: toastId })
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -172,7 +222,11 @@ export function SlasManager() {
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
description: description.trim() || undefined,
|
description: description.trim() || undefined,
|
||||||
timeToFirstResponse,
|
timeToFirstResponse,
|
||||||
|
responseMode,
|
||||||
timeToResolution,
|
timeToResolution,
|
||||||
|
solutionMode,
|
||||||
|
alertThreshold: normalizedThreshold,
|
||||||
|
pauseStatuses,
|
||||||
})
|
})
|
||||||
toast.success("Política criada", { id: toastId })
|
toast.success("Política criada", { id: toastId })
|
||||||
}
|
}
|
||||||
|
|
@ -235,7 +289,7 @@ 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" /> Melhor resolucao
|
<IconBolt className="size-4" /> Melhor resolução
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>Menor meta para encerrar chamados.</CardDescription>
|
<CardDescription>Menor meta para encerrar chamados.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
@ -298,9 +352,24 @@ export function SlasManager() {
|
||||||
{policy.description && (
|
{policy.description && (
|
||||||
<p className="text-xs text-neutral-500">{policy.description}</p>
|
<p className="text-xs text-neutral-500">{policy.description}</p>
|
||||||
)}
|
)}
|
||||||
<div className="flex gap-4 text-xs text-neutral-600">
|
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-neutral-600">
|
||||||
<span>Resposta: {formatMinutes(policy.timeToFirstResponse)}</span>
|
<span>
|
||||||
<span>Resolução: {formatMinutes(policy.timeToResolution)}</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>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
@ -382,6 +451,18 @@ export function SlasManager() {
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</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>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Tempo para resolução</Label>
|
<Label>Tempo para resolução</Label>
|
||||||
|
|
@ -408,6 +489,61 @@ export function SlasManager() {
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-3">
|
<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 />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="rounded-xl">
|
<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="PORTAL">Portal (cliente)</SelectItem>
|
||||||
<SelectItem value="STAFF">Painel (agente)</SelectItem>
|
<SelectItem value="STAFF">Painel (agente)</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue