- 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>
432 lines
18 KiB
TypeScript
432 lines
18 KiB
TypeScript
"use client"
|
|
|
|
import { useMemo, useState } from "react"
|
|
import { useMutation, useQuery } from "convex/react"
|
|
import { toast } from "sonner"
|
|
import { Globe, Plus } from "lucide-react"
|
|
import { IconAlarm, IconBolt, IconTargetArrow } from "@tabler/icons-react"
|
|
import { api } from "@/convex/_generated/api"
|
|
import type { Id } from "@/convex/_generated/dataModel"
|
|
import { useAuth } from "@/lib/auth-client"
|
|
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
|
import { Badge } from "@/components/ui/badge"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Label } from "@/components/ui/label"
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
|
import { Skeleton } from "@/components/ui/skeleton"
|
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
|
|
|
import { CategorySlaManager } from "./category-sla-manager"
|
|
import { CompanySlaManager } from "./company-sla-manager"
|
|
|
|
type SlaPolicy = {
|
|
id: string
|
|
name: string
|
|
description: string
|
|
timeToFirstResponse: 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) {
|
|
if (value === null) return "—"
|
|
if (value < 60) return `${Math.round(value)} min`
|
|
const hours = Math.floor(value / 60)
|
|
const minutes = Math.round(value % 60)
|
|
if (minutes === 0) return `${hours}h`
|
|
return `${hours}h ${minutes}min`
|
|
}
|
|
|
|
function minutesToForm(input: number | null): { amount: string; unit: TimeUnit } {
|
|
if (!input || input <= 0) return { amount: "", unit: "hours" }
|
|
for (const option of [...TIME_UNITS].reverse()) {
|
|
if (input % option.factor === 0) {
|
|
return { amount: String(Math.round(input / option.factor)), unit: option.value }
|
|
}
|
|
}
|
|
return { amount: String(input), unit: "minutes" }
|
|
}
|
|
|
|
function convertToMinutes(amount: string, unit: TimeUnit): number | undefined {
|
|
const numeric = Number(amount)
|
|
if (!Number.isFinite(numeric) || numeric <= 0) return undefined
|
|
const factor = TIME_UNITS.find((item) => item.value === unit)?.factor ?? 1
|
|
return Math.round(numeric * factor)
|
|
}
|
|
|
|
export function SlasManager() {
|
|
const { session, convexUserId } = useAuth()
|
|
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
|
|
|
const slas = useQuery(
|
|
api.slas.list,
|
|
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
|
|
) as SlaPolicy[] | undefined
|
|
|
|
const createSla = useMutation(api.slas.create)
|
|
const updateSla = useMutation(api.slas.update)
|
|
const removeSla = useMutation(api.slas.remove)
|
|
|
|
const [dialogOpen, setDialogOpen] = useState(false)
|
|
const [editingSla, setEditingSla] = useState<SlaPolicy | null>(null)
|
|
const [name, setName] = useState("")
|
|
const [description, setDescription] = useState("")
|
|
const [responseAmount, setResponseAmount] = useState("")
|
|
const [responseUnit, setResponseUnit] = useState<TimeUnit>("hours")
|
|
const [resolutionAmount, setResolutionAmount] = useState("")
|
|
const [resolutionUnit, setResolutionUnit] = useState<TimeUnit>("hours")
|
|
const [saving, setSaving] = useState(false)
|
|
|
|
const { bestFirstResponse, bestResolution } = useMemo(() => {
|
|
if (!slas) return { bestFirstResponse: null, bestResolution: null }
|
|
|
|
const response = slas.reduce<number | null>((acc, sla) => {
|
|
if (sla.timeToFirstResponse === null) return acc
|
|
return acc === null ? sla.timeToFirstResponse : Math.min(acc, sla.timeToFirstResponse)
|
|
}, null)
|
|
|
|
const resolution = slas.reduce<number | null>((acc, sla) => {
|
|
if (sla.timeToResolution === null) return acc
|
|
return acc === null ? sla.timeToResolution : Math.min(acc, sla.timeToResolution)
|
|
}, null)
|
|
|
|
return { bestFirstResponse: response, bestResolution: resolution }
|
|
}, [slas])
|
|
|
|
const resetForm = () => {
|
|
setName("")
|
|
setDescription("")
|
|
setResponseAmount("")
|
|
setResponseUnit("hours")
|
|
setResolutionAmount("")
|
|
setResolutionUnit("hours")
|
|
}
|
|
|
|
const openCreateDialog = () => {
|
|
resetForm()
|
|
setEditingSla(null)
|
|
setDialogOpen(true)
|
|
}
|
|
|
|
const openEditDialog = (policy: SlaPolicy) => {
|
|
const response = minutesToForm(policy.timeToFirstResponse)
|
|
const resolution = minutesToForm(policy.timeToResolution)
|
|
setEditingSla(policy)
|
|
setName(policy.name)
|
|
setDescription(policy.description)
|
|
setResponseAmount(response.amount)
|
|
setResponseUnit(response.unit)
|
|
setResolutionAmount(resolution.amount)
|
|
setResolutionUnit(resolution.unit)
|
|
setDialogOpen(true)
|
|
}
|
|
|
|
const closeDialog = () => {
|
|
setDialogOpen(false)
|
|
setEditingSla(null)
|
|
resetForm()
|
|
}
|
|
|
|
const handleSave = async () => {
|
|
if (!name.trim()) {
|
|
toast.error("Informe um nome para a politica")
|
|
return
|
|
}
|
|
if (!convexUserId) {
|
|
toast.error("Sessao nao sincronizada com o Convex")
|
|
return
|
|
}
|
|
|
|
const timeToFirstResponse = convertToMinutes(responseAmount, responseUnit)
|
|
const timeToResolution = convertToMinutes(resolutionAmount, resolutionUnit)
|
|
|
|
setSaving(true)
|
|
const toastId = editingSla ? "sla-edit" : "sla-create"
|
|
toast.loading(editingSla ? "Salvando alteracoes..." : "Criando politica...", { id: toastId })
|
|
|
|
try {
|
|
if (editingSla) {
|
|
await updateSla({
|
|
tenantId,
|
|
policyId: editingSla.id as Id<"slaPolicies">,
|
|
actorId: convexUserId as Id<"users">,
|
|
name: name.trim(),
|
|
description: description.trim() || undefined,
|
|
timeToFirstResponse,
|
|
timeToResolution,
|
|
})
|
|
toast.success("Politica atualizada", { id: toastId })
|
|
} else {
|
|
await createSla({
|
|
tenantId,
|
|
actorId: convexUserId as Id<"users">,
|
|
name: name.trim(),
|
|
description: description.trim() || undefined,
|
|
timeToFirstResponse,
|
|
timeToResolution,
|
|
})
|
|
toast.success("Politica criada", { id: toastId })
|
|
}
|
|
closeDialog()
|
|
} catch (error) {
|
|
console.error(error)
|
|
toast.error(editingSla ? "Nao foi possivel atualizar a politica" : "Nao foi possivel criar a politica", { id: toastId })
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
const handleRemove = async (policy: SlaPolicy) => {
|
|
const confirmed = window.confirm(`Excluir a política ${policy.name}?`)
|
|
if (!confirmed) return
|
|
if (!convexUserId) {
|
|
toast.error("Sessão não sincronizada com o Convex")
|
|
return
|
|
}
|
|
toast.loading("Removendo política...", { id: `sla-remove-${policy.id}` })
|
|
try {
|
|
await removeSla({
|
|
tenantId,
|
|
policyId: policy.id as Id<"slaPolicies">,
|
|
actorId: convexUserId as Id<"users">,
|
|
})
|
|
toast.success("Política removida", { id: `sla-remove-${policy.id}` })
|
|
} catch (error) {
|
|
console.error(error)
|
|
toast.error("Não foi possível remover a política", { id: `sla-remove-${policy.id}` })
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
{/* Cards de resumo */}
|
|
<div className="grid gap-4 md:grid-cols-3">
|
|
<Card className="border-slate-200">
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600">
|
|
<IconTargetArrow className="size-4" /> Politicas globais
|
|
</CardTitle>
|
|
<CardDescription>Regras que valem para todas as empresas.</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="text-3xl font-semibold text-neutral-900">
|
|
{slas ? slas.length : <Skeleton className="h-8 w-16" />}
|
|
</CardContent>
|
|
</Card>
|
|
<Card className="border-slate-200">
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600">
|
|
<IconAlarm className="size-4" /> Melhor resposta
|
|
</CardTitle>
|
|
<CardDescription>Menor meta de primeira resposta.</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="text-xl font-semibold text-neutral-900">
|
|
{slas ? formatMinutes(bestFirstResponse ?? null) : <Skeleton className="h-8 w-24" />}
|
|
</CardContent>
|
|
</Card>
|
|
<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
|
|
</CardTitle>
|
|
<CardDescription>Menor meta para encerrar chamados.</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="text-xl font-semibold text-neutral-900">
|
|
{slas ? formatMinutes(bestResolution ?? null) : <Skeleton className="h-8 w-24" />}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Politicas globais de SLA */}
|
|
<Card className="border-slate-200">
|
|
<CardHeader>
|
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
|
<div>
|
|
<CardTitle className="flex items-center gap-2 text-lg font-semibold text-neutral-900">
|
|
<Globe className="size-5" />
|
|
Politicas globais de SLA
|
|
</CardTitle>
|
|
<CardDescription className="mt-1">
|
|
Estas regras valem para todas as empresas e categorias. Sao sobrescritas por regras mais especificas
|
|
(por empresa ou por categoria).
|
|
</CardDescription>
|
|
</div>
|
|
<Button size="sm" onClick={openCreateDialog} disabled={!convexUserId} className="gap-2 shrink-0">
|
|
<Plus className="size-4" />
|
|
Nova politica
|
|
</Button>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{slas === undefined ? (
|
|
<div className="space-y-3">
|
|
{Array.from({ length: 2 }).map((_, index) => (
|
|
<Skeleton key={`sla-skeleton-${index}`} className="h-20 rounded-xl" />
|
|
))}
|
|
</div>
|
|
) : slas.length === 0 ? (
|
|
<div className="rounded-xl border border-dashed border-slate-300 bg-slate-50/80 p-6 text-center">
|
|
<Globe className="mx-auto size-8 text-slate-400" />
|
|
<p className="mt-2 text-sm font-medium text-neutral-700">Nenhuma politica global cadastrada</p>
|
|
<p className="mt-1 text-xs text-neutral-500">
|
|
Crie politicas de SLA para definir metas de resposta e resolucao para os chamados.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{slas.map((policy) => (
|
|
<div
|
|
key={policy.id}
|
|
className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-slate-200 bg-white px-4 py-3"
|
|
>
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-2">
|
|
<p className="text-sm font-semibold text-neutral-900">{policy.name}</p>
|
|
<Badge variant="secondary" className="rounded-full text-xs">
|
|
<Globe className="mr-1 size-3" />
|
|
Global
|
|
</Badge>
|
|
</div>
|
|
{policy.description && (
|
|
<p className="text-xs text-neutral-500">{policy.description}</p>
|
|
)}
|
|
<div className="flex gap-4 text-xs text-neutral-600">
|
|
<span>Resposta: {formatMinutes(policy.timeToFirstResponse)}</span>
|
|
<span>Resolucao: {formatMinutes(policy.timeToResolution)}</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button size="sm" variant="secondary" onClick={() => openEditDialog(policy)} disabled={!convexUserId}>
|
|
Editar
|
|
</Button>
|
|
<Button size="sm" variant="destructive" onClick={() => handleRemove(policy)} disabled={!convexUserId}>
|
|
Excluir
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* SLA por empresa */}
|
|
<CompanySlaManager />
|
|
|
|
{/* SLA por categoria */}
|
|
<CategorySlaManager />
|
|
|
|
{/* Modal unificado de criar/editar */}
|
|
<Dialog open={dialogOpen} onOpenChange={(open) => !open && closeDialog()}>
|
|
<DialogContent className="max-w-2xl">
|
|
<DialogHeader>
|
|
<DialogTitle>{editingSla ? "Editar politica de SLA" : "Nova politica de SLA"}</DialogTitle>
|
|
<DialogDescription>
|
|
{editingSla
|
|
? "Altere os dados da politica. Ela continua valendo para todas as empresas e categorias."
|
|
: "Crie uma politica global que vale para todas as empresas e categorias. Voce pode criar regras mais especificas depois."}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-4 py-2">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="sla-name">Nome da politica</Label>
|
|
<Input
|
|
id="sla-name"
|
|
placeholder="Ex.: Atendimento padrao, Premium, Urgente..."
|
|
value={name}
|
|
onChange={(event) => setName(event.target.value)}
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="sla-description">Descricao (opcional)</Label>
|
|
<textarea
|
|
id="sla-description"
|
|
className="min-h-[80px] w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-neutral-700 shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-900/10"
|
|
placeholder="Quando esta politica deve ser usada, para quais tipos de chamado..."
|
|
value={description}
|
|
onChange={(event) => setDescription(event.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<Label>Tempo para primeira resposta</Label>
|
|
<p className="text-xs text-neutral-500">Quanto tempo a equipe tem para dar a primeira resposta ao cliente.</p>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
type="number"
|
|
min={1}
|
|
placeholder="0"
|
|
value={responseAmount}
|
|
onChange={(event) => setResponseAmount(event.target.value)}
|
|
className="flex-1"
|
|
/>
|
|
<Select value={responseUnit} onValueChange={(value) => setResponseUnit(value as TimeUnit)}>
|
|
<SelectTrigger className="w-28">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{TIME_UNITS.map((unit) => (
|
|
<SelectItem key={unit.value} value={unit.value}>
|
|
{unit.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Tempo para resolucao</Label>
|
|
<p className="text-xs text-neutral-500">Quanto tempo a equipe tem para resolver o chamado por completo.</p>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
type="number"
|
|
min={1}
|
|
placeholder="0"
|
|
value={resolutionAmount}
|
|
onChange={(event) => setResolutionAmount(event.target.value)}
|
|
className="flex-1"
|
|
/>
|
|
<Select value={resolutionUnit} onValueChange={(value) => setResolutionUnit(value as TimeUnit)}>
|
|
<SelectTrigger className="w-28">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{TIME_UNITS.map((unit) => (
|
|
<SelectItem key={unit.value} value={unit.value}>
|
|
{unit.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="rounded-lg border border-blue-200 bg-blue-50 p-3">
|
|
<p className="text-xs text-blue-800">
|
|
<strong>Dica:</strong> Esta politica e global e sera aplicada a todos os chamados que nao tiverem uma
|
|
regra mais especifica (por empresa ou categoria). Use as secoes abaixo para criar regras personalizadas.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<DialogFooter className="gap-2">
|
|
<Button variant="outline" onClick={closeDialog}>
|
|
Cancelar
|
|
</Button>
|
|
<Button onClick={handleSave} disabled={saving || !name.trim()}>
|
|
{saving ? "Salvando..." : editingSla ? "Salvar alteracoes" : "Criar politica"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
)
|
|
}
|