feat(devices): implementa tabela separada para softwares instalados

- Cria tabela machineSoftware no schema com indices otimizados
- Adiciona mutations para sincronizar softwares do heartbeat
- Atualiza heartbeat para processar e salvar softwares
- Cria componente DeviceSoftwareList com pesquisa e paginacao
- Integra lista de softwares no drawer de detalhes do dispositivo

feat(sla): transforma formulario em modal completo

- Substitui formulario inline por modal guiado
- Adiciona badge "Global" para indicar escopo da politica
- Adiciona seletor de unidade de tempo (minutos, horas, dias)
- Melhora textos e adiciona dica sobre hierarquia de SLAs

fix(reports): ajusta altura do SearchableCombobox

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rever-tecnologia 2025-12-18 08:00:40 -03:00
parent ef2545221d
commit 23fe67e7d3
7 changed files with 741 additions and 205 deletions

View file

@ -3,17 +3,20 @@
import { useMemo, useState } from "react"
import { useMutation, useQuery } from "convex/react"
import { toast } from "sonner"
import { Globe, Plus } from "lucide-react"
import { IconAlarm, IconBolt, IconTargetArrow } from "@tabler/icons-react"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { useAuth } from "@/lib/auth-client"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { CategorySlaManager } from "./category-sla-manager"
import { CompanySlaManager } from "./company-sla-manager"
@ -26,6 +29,14 @@ type SlaPolicy = {
timeToResolution: number | null
}
type TimeUnit = "minutes" | "hours" | "days"
const TIME_UNITS: Array<{ value: TimeUnit; label: string; factor: number }> = [
{ value: "minutes", label: "Minutos", factor: 1 },
{ value: "hours", label: "Horas", factor: 60 },
{ value: "days", label: "Dias", factor: 1440 },
]
function formatMinutes(value: number | null) {
if (value === null) return "—"
if (value < 60) return `${Math.round(value)} min`
@ -35,6 +46,23 @@ function formatMinutes(value: number | null) {
return `${hours}h ${minutes}min`
}
function minutesToForm(input: number | null): { amount: string; unit: TimeUnit } {
if (!input || input <= 0) return { amount: "", unit: "hours" }
for (const option of [...TIME_UNITS].reverse()) {
if (input % option.factor === 0) {
return { amount: String(Math.round(input / option.factor)), unit: option.value }
}
}
return { amount: String(input), unit: "minutes" }
}
function convertToMinutes(amount: string, unit: TimeUnit): number | undefined {
const numeric = Number(amount)
if (!Number.isFinite(numeric) || numeric <= 0) return undefined
const factor = TIME_UNITS.find((item) => item.value === unit)?.factor ?? 1
return Math.round(numeric * factor)
}
export function SlasManager() {
const { session, convexUserId } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
@ -48,12 +76,15 @@ export function SlasManager() {
const updateSla = useMutation(api.slas.update)
const removeSla = useMutation(api.slas.remove)
const [dialogOpen, setDialogOpen] = useState(false)
const [editingSla, setEditingSla] = useState<SlaPolicy | null>(null)
const [name, setName] = useState("")
const [description, setDescription] = useState("")
const [firstResponse, setFirstResponse] = useState<string>("")
const [resolution, setResolution] = useState<string>("")
const [responseAmount, setResponseAmount] = useState("")
const [responseUnit, setResponseUnit] = useState<TimeUnit>("hours")
const [resolutionAmount, setResolutionAmount] = useState("")
const [resolutionUnit, setResolutionUnit] = useState<TimeUnit>("hours")
const [saving, setSaving] = useState(false)
const [editingSla, setEditingSla] = useState<SlaPolicy | null>(null)
const { bestFirstResponse, bestResolution } = useMemo(() => {
if (!slas) return { bestFirstResponse: null, bestResolution: null }
@ -74,82 +105,81 @@ export function SlasManager() {
const resetForm = () => {
setName("")
setDescription("")
setFirstResponse("")
setResolution("")
setResponseAmount("")
setResponseUnit("hours")
setResolutionAmount("")
setResolutionUnit("hours")
}
const parseNumber = (value: string) => {
const parsed = Number(value)
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined
const openCreateDialog = () => {
resetForm()
setEditingSla(null)
setDialogOpen(true)
}
const handleCreate = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (!name.trim()) {
toast.error("Informe um nome para a política")
return
}
if (!convexUserId) {
toast.error("Sessão não sincronizada com o Convex")
return
}
setSaving(true)
toast.loading("Criando SLA...", { id: "sla" })
try {
await createSla({
tenantId,
actorId: convexUserId as Id<"users">,
name: name.trim(),
description: description.trim() || undefined,
timeToFirstResponse: parseNumber(firstResponse),
timeToResolution: parseNumber(resolution),
})
toast.success("Política criada", { id: "sla" })
resetForm()
} catch (error) {
console.error(error)
toast.error("Não foi possível criar a política", { id: "sla" })
} finally {
setSaving(false)
}
}
const openEdit = (policy: SlaPolicy) => {
const openEditDialog = (policy: SlaPolicy) => {
const response = minutesToForm(policy.timeToFirstResponse)
const resolution = minutesToForm(policy.timeToResolution)
setEditingSla(policy)
setName(policy.name)
setDescription(policy.description)
setFirstResponse(policy.timeToFirstResponse ? String(policy.timeToFirstResponse) : "")
setResolution(policy.timeToResolution ? String(policy.timeToResolution) : "")
setResponseAmount(response.amount)
setResponseUnit(response.unit)
setResolutionAmount(resolution.amount)
setResolutionUnit(resolution.unit)
setDialogOpen(true)
}
const handleUpdate = async () => {
if (!editingSla) return
const closeDialog = () => {
setDialogOpen(false)
setEditingSla(null)
resetForm()
}
const handleSave = async () => {
if (!name.trim()) {
toast.error("Informe um nome para a política")
toast.error("Informe um nome para a politica")
return
}
if (!convexUserId) {
toast.error("Sessão não sincronizada com o Convex")
toast.error("Sessao nao sincronizada com o Convex")
return
}
const timeToFirstResponse = convertToMinutes(responseAmount, responseUnit)
const timeToResolution = convertToMinutes(resolutionAmount, resolutionUnit)
setSaving(true)
toast.loading("Salvando alterações...", { id: "sla-edit" })
const toastId = editingSla ? "sla-edit" : "sla-create"
toast.loading(editingSla ? "Salvando alteracoes..." : "Criando politica...", { id: toastId })
try {
await updateSla({
tenantId,
policyId: editingSla.id as Id<"slaPolicies">,
actorId: convexUserId as Id<"users">,
name: name.trim(),
description: description.trim() || undefined,
timeToFirstResponse: parseNumber(firstResponse),
timeToResolution: parseNumber(resolution),
})
toast.success("Política atualizada", { id: "sla-edit" })
setEditingSla(null)
resetForm()
if (editingSla) {
await updateSla({
tenantId,
policyId: editingSla.id as Id<"slaPolicies">,
actorId: convexUserId as Id<"users">,
name: name.trim(),
description: description.trim() || undefined,
timeToFirstResponse,
timeToResolution,
})
toast.success("Politica atualizada", { id: toastId })
} else {
await createSla({
tenantId,
actorId: convexUserId as Id<"users">,
name: name.trim(),
description: description.trim() || undefined,
timeToFirstResponse,
timeToResolution,
})
toast.success("Politica criada", { id: toastId })
}
closeDialog()
} catch (error) {
console.error(error)
toast.error("Não foi possível atualizar a política", { id: "sla-edit" })
toast.error(editingSla ? "Nao foi possivel atualizar a politica" : "Nao foi possivel criar a politica", { id: toastId })
} finally {
setSaving(false)
}
@ -178,13 +208,14 @@ export function SlasManager() {
return (
<div className="space-y-8">
{/* Cards de resumo */}
<div className="grid gap-4 md:grid-cols-3">
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600">
<IconTargetArrow className="size-4" /> Políticas criadas
<IconTargetArrow className="size-4" /> Politicas globais
</CardTitle>
<CardDescription>Regras aplicadas às filas e tickets.</CardDescription>
<CardDescription>Regras que valem para todas as empresas.</CardDescription>
</CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900">
{slas ? slas.length : <Skeleton className="h-8 w-16" />}
@ -193,9 +224,9 @@ export function SlasManager() {
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600">
<IconAlarm className="size-4" /> Resposta (média)
<IconAlarm className="size-4" /> Melhor resposta
</CardTitle>
<CardDescription>Tempo mínimo para primeira resposta.</CardDescription>
<CardDescription>Menor meta de primeira resposta.</CardDescription>
</CardHeader>
<CardContent className="text-xl font-semibold text-neutral-900">
{slas ? formatMinutes(bestFirstResponse ?? null) : <Skeleton className="h-8 w-24" />}
@ -204,9 +235,9 @@ export function SlasManager() {
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600">
<IconBolt className="size-4" /> Resolução (média)
<IconBolt className="size-4" /> Melhor resolucao
</CardTitle>
<CardDescription>Alvo para encerrar chamados.</CardDescription>
<CardDescription>Menor meta para encerrar chamados.</CardDescription>
</CardHeader>
<CardContent className="text-xl font-semibold text-neutral-900">
{slas ? formatMinutes(bestResolution ?? null) : <Skeleton className="h-8 w-24" />}
@ -214,179 +245,184 @@ export function SlasManager() {
</Card>
</div>
{/* Politicas globais de SLA */}
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="text-lg font-semibold text-neutral-900">Nova política de SLA</CardTitle>
<CardDescription>Defina metas de resposta e resolução para garantir previsibilidade no atendimento.</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleCreate} className="grid gap-4 md:grid-cols-[minmax(0,320px)_minmax(0,1fr)]">
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="sla-name">Nome da política</Label>
<Input
id="sla-name"
placeholder="Ex.: Resposta prioritária"
value={name}
onChange={(event) => setName(event.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="sla-first-response">Primeira resposta (minutos)</Label>
<Input
id="sla-first-response"
type="number"
min={1}
value={firstResponse}
onChange={(event) => setFirstResponse(event.target.value)}
placeholder="Opcional"
/>
</div>
<div className="space-y-2">
<Label htmlFor="sla-resolution">Resolução (minutos)</Label>
<Input
id="sla-resolution"
type="number"
min={1}
value={resolution}
onChange={(event) => setResolution(event.target.value)}
placeholder="Opcional"
/>
</div>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="sla-description">Descrição</Label>
<textarea
id="sla-description"
className="min-h-[120px] w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-neutral-700 shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-900/10"
placeholder="Como esta política será aplicada"
value={description}
onChange={(event) => setDescription(event.target.value)}
/>
</div>
<div className="flex justify-end">
<Button type="submit" disabled={saving}>
Criar política
</Button>
</div>
</div>
</form>
</CardContent>
</Card>
<div className="space-y-4">
{slas === undefined ? (
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, index) => (
<Skeleton key={index} className="h-32 rounded-2xl" />
))}
</div>
) : slas.length === 0 ? (
<Card className="border-dashed border-slate-300 bg-slate-50/80">
<CardHeader>
<CardTitle className="text-lg font-semibold text-neutral-900">Nenhuma política cadastrada</CardTitle>
<CardDescription className="text-neutral-600">
Crie SLAs para monitorar o tempo de resposta e resolução dos seus chamados.
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<CardTitle className="flex items-center gap-2 text-lg font-semibold text-neutral-900">
<Globe className="size-5" />
Politicas globais de SLA
</CardTitle>
<CardDescription className="mt-1">
Estas regras valem para todas as empresas e categorias. Sao sobrescritas por regras mais especificas
(por empresa ou por categoria).
</CardDescription>
</CardHeader>
</Card>
) : (
slas.map((policy) => (
<Card key={policy.id} className="border-slate-200">
<CardHeader>
<div className="flex items-start justify-between gap-4">
<div className="space-y-2">
<CardTitle className="text-xl font-semibold text-neutral-900">{policy.name}</CardTitle>
{policy.description ? (
<CardDescription className="text-neutral-600">{policy.description}</CardDescription>
) : null}
</div>
<Button size="sm" onClick={openCreateDialog} disabled={!convexUserId} className="gap-2 shrink-0">
<Plus className="size-4" />
Nova politica
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{slas === undefined ? (
<div className="space-y-3">
{Array.from({ length: 2 }).map((_, index) => (
<Skeleton key={`sla-skeleton-${index}`} className="h-20 rounded-xl" />
))}
</div>
) : slas.length === 0 ? (
<div className="rounded-xl border border-dashed border-slate-300 bg-slate-50/80 p-6 text-center">
<Globe className="mx-auto size-8 text-slate-400" />
<p className="mt-2 text-sm font-medium text-neutral-700">Nenhuma politica global cadastrada</p>
<p className="mt-1 text-xs text-neutral-500">
Crie politicas de SLA para definir metas de resposta e resolucao para os chamados.
</p>
</div>
) : (
<div className="space-y-3">
{slas.map((policy) => (
<div
key={policy.id}
className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-slate-200 bg-white px-4 py-3"
>
<div className="space-y-1">
<div className="flex items-center gap-2">
<p className="text-sm font-semibold text-neutral-900">{policy.name}</p>
<Badge variant="secondary" className="rounded-full text-xs">
<Globe className="mr-1 size-3" />
Global
</Badge>
</div>
{policy.description && (
<p className="text-xs text-neutral-500">{policy.description}</p>
)}
<div className="flex gap-4 text-xs text-neutral-600">
<span>Resposta: {formatMinutes(policy.timeToFirstResponse)}</span>
<span>Resolucao: {formatMinutes(policy.timeToResolution)}</span>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => openEdit(policy)}>
<div className="flex items-center gap-2">
<Button size="sm" variant="secondary" onClick={() => openEditDialog(policy)} disabled={!convexUserId}>
Editar
</Button>
<Button variant="destructive" size="sm" onClick={() => handleRemove(policy)}>
<Button size="sm" variant="destructive" onClick={() => handleRemove(policy)} disabled={!convexUserId}>
Excluir
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<dl className="grid gap-4 md:grid-cols-2">
<div>
<dt className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Primeira resposta</dt>
<dd className="text-lg font-semibold text-neutral-900">{formatMinutes(policy.timeToFirstResponse)}</dd>
</div>
<div>
<dt className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Resolução</dt>
<dd className="text-lg font-semibold text-neutral-900">{formatMinutes(policy.timeToResolution)}</dd>
</div>
</dl>
</CardContent>
</Card>
))
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* SLA por empresa */}
<CompanySlaManager />
{/* SLA por categoria */}
<CategorySlaManager />
<Dialog open={Boolean(editingSla)} onOpenChange={(value) => (!value ? setEditingSla(null) : null)}>
{/* Modal unificado de criar/editar */}
<Dialog open={dialogOpen} onOpenChange={(open) => !open && closeDialog()}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Editar política de SLA</DialogTitle>
<DialogTitle>{editingSla ? "Editar politica de SLA" : "Nova politica de SLA"}</DialogTitle>
<DialogDescription>
{editingSla
? "Altere os dados da politica. Ela continua valendo para todas as empresas e categorias."
: "Crie uma politica global que vale para todas as empresas e categorias. Voce pode criar regras mais especificas depois."}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label htmlFor="edit-sla-name">Nome</Label>
<Label htmlFor="sla-name">Nome da politica</Label>
<Input
id="edit-sla-name"
id="sla-name"
placeholder="Ex.: Atendimento padrao, Premium, Urgente..."
value={name}
onChange={(event) => setName(event.target.value)}
autoFocus
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="edit-sla-first">Primeira resposta (minutos)</Label>
<Input
id="edit-sla-first"
type="number"
min={1}
value={firstResponse}
onChange={(event) => setFirstResponse(event.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-sla-resolution">Resolução (minutos)</Label>
<Input
id="edit-sla-resolution"
type="number"
min={1}
value={resolution}
onChange={(event) => setResolution(event.target.value)}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="edit-sla-description">Descrição</Label>
<Label htmlFor="sla-description">Descricao (opcional)</Label>
<textarea
id="edit-sla-description"
className="min-h-[120px] w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-neutral-700 shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-900/10"
id="sla-description"
className="min-h-[80px] w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-neutral-700 shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-900/10"
placeholder="Quando esta politica deve ser usada, para quais tipos de chamado..."
value={description}
onChange={(event) => setDescription(event.target.value)}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Tempo para primeira resposta</Label>
<p className="text-xs text-neutral-500">Quanto tempo a equipe tem para dar a primeira resposta ao cliente.</p>
<div className="flex gap-2">
<Input
type="number"
min={1}
placeholder="0"
value={responseAmount}
onChange={(event) => setResponseAmount(event.target.value)}
className="flex-1"
/>
<Select value={responseUnit} onValueChange={(value) => setResponseUnit(value as TimeUnit)}>
<SelectTrigger className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TIME_UNITS.map((unit) => (
<SelectItem key={unit.value} value={unit.value}>
{unit.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label>Tempo para resolucao</Label>
<p className="text-xs text-neutral-500">Quanto tempo a equipe tem para resolver o chamado por completo.</p>
<div className="flex gap-2">
<Input
type="number"
min={1}
placeholder="0"
value={resolutionAmount}
onChange={(event) => setResolutionAmount(event.target.value)}
className="flex-1"
/>
<Select value={resolutionUnit} onValueChange={(value) => setResolutionUnit(value as TimeUnit)}>
<SelectTrigger className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TIME_UNITS.map((unit) => (
<SelectItem key={unit.value} value={unit.value}>
{unit.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
<div className="rounded-lg border border-blue-200 bg-blue-50 p-3">
<p className="text-xs text-blue-800">
<strong>Dica:</strong> Esta politica e global e sera aplicada a todos os chamados que nao tiverem uma
regra mais especifica (por empresa ou categoria). Use as secoes abaixo para criar regras personalizadas.
</p>
</div>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => setEditingSla(null)}>
<Button variant="outline" onClick={closeDialog}>
Cancelar
</Button>
<Button onClick={handleUpdate} disabled={saving}>
Salvar alterações
<Button onClick={handleSave} disabled={saving || !name.trim()}>
{saving ? "Salvando..." : editingSla ? "Salvar alteracoes" : "Criar politica"}
</Button>
</DialogFooter>
</DialogContent>