feat(sla): adiciona interface de administração de SLA por empresa

- Adiciona CompanySlaManager para gerenciar empresas com SLA customizado
- Adiciona CompanySlaDrawer para configurar regras de SLA por empresa
- Integra componentes no SlasManager existente

🤖 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-17 18:53:32 -03:00
parent 33f0cc2e13
commit 47ccdc51a7
3 changed files with 714 additions and 0 deletions

View file

@ -0,0 +1,473 @@
"use client"
import { useEffect, useState } from "react"
import { useMutation, useQuery } from "convex/react"
import { toast } from "sonner"
import { AlertTriangle, Trash2 } from "lucide-react"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
const PRIORITY_ROWS = [
{ value: "URGENT", label: "Critico" },
{ value: "HIGH", label: "Alta" },
{ value: "MEDIUM", label: "Media" },
{ value: "LOW", label: "Baixa" },
{ value: "DEFAULT", label: "Sem prioridade" },
] as const
const TIME_UNITS: Array<{ value: RuleFormState["responseUnit"]; label: string; factor: number }> = [
{ value: "minutes", label: "Minutos", factor: 1 },
{ value: "hours", label: "Horas", factor: 60 },
{ value: "days", label: "Dias", factor: 1440 },
]
const MODE_OPTIONS: Array<{ value: RuleFormState["responseMode"]; label: string }> = [
{ value: "calendar", label: "Horas corridas" },
{ value: "business", label: "Horas uteis" },
]
const PAUSE_STATUS_OPTIONS = [
{ value: "PENDING", label: "Pendente" },
{ value: "AWAITING_ATTENDANCE", label: "Em atendimento" },
{ value: "PAUSED", label: "Pausado" },
] as const
const DEFAULT_RULE_STATE: RuleFormState = {
responseValue: "",
responseUnit: "hours",
responseMode: "calendar",
solutionValue: "",
solutionUnit: "hours",
solutionMode: "calendar",
alertThreshold: 80,
pauseStatuses: ["PAUSED"],
}
type RuleFormState = {
responseValue: string
responseUnit: "minutes" | "hours" | "days"
responseMode: "calendar" | "business"
solutionValue: string
solutionUnit: "minutes" | "hours" | "days"
solutionMode: "calendar" | "business"
alertThreshold: number
pauseStatuses: string[]
}
export type CompanySlaDrawerProps = {
company: { id: string; name: string } | null
tenantId: string
viewerId: Id<"users"> | null
onClose: () => void
}
export function CompanySlaDrawer({ company, tenantId, viewerId, onClose }: CompanySlaDrawerProps) {
const [rules, setRules] = useState<Record<string, RuleFormState>>(() => buildDefaultRuleState())
const [saving, setSaving] = useState(false)
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
const [deleting, setDeleting] = useState(false)
const drawerOpen = Boolean(company)
const canLoad = Boolean(company && viewerId)
const existing = useQuery(
api.companySlas.get,
canLoad
? {
tenantId,
viewerId: viewerId as Id<"users">,
companyId: company!.id as Id<"companies">,
}
: "skip"
) as {
companyId: string
companyName: string
rules: Array<{
priority: string
categoryId: string | null
categoryName: string | null
responseTargetMinutes: number | null
responseMode?: string | null
solutionTargetMinutes: number | null
solutionMode?: string | null
alertThreshold?: number | null
pauseStatuses?: string[] | null
}>
} | undefined
const saveSla = useMutation(api.companySlas.save)
const removeSla = useMutation(api.companySlas.remove)
useEffect(() => {
if (!existing?.rules) {
setRules(buildDefaultRuleState())
return
}
const next = buildDefaultRuleState()
// Filtrar apenas regras globais (sem categoryId) por enquanto
for (const rule of existing.rules.filter((r) => !r.categoryId)) {
const priority = rule.priority?.toUpperCase() ?? "DEFAULT"
next[priority] = convertRuleToForm(rule)
}
setRules(next)
}, [existing, company?.id])
const handleChange = (priority: string, patch: Partial<RuleFormState>) => {
setRules((current) => ({
...current,
[priority]: {
...current[priority],
...patch,
},
}))
}
const togglePause = (priority: string, status: string) => {
setRules((current) => {
const selected = new Set(current[priority].pauseStatuses)
if (selected.has(status)) {
selected.delete(status)
} else {
selected.add(status)
}
if (selected.size === 0) {
selected.add("PAUSED")
}
return {
...current,
[priority]: {
...current[priority],
pauseStatuses: Array.from(selected),
},
}
})
}
const handleSave = async () => {
if (!company || !viewerId) return
setSaving(true)
toast.loading("Salvando SLA...", { id: "company-sla" })
try {
const payload = PRIORITY_ROWS.map((row) => {
const form = rules[row.value]
return {
priority: row.value,
categoryId: null, // Regras globais da empresa
responseTargetMinutes: convertToMinutes(form.responseValue, form.responseUnit),
responseMode: form.responseMode,
solutionTargetMinutes: convertToMinutes(form.solutionValue, form.solutionUnit),
solutionMode: form.solutionMode,
alertThreshold: Math.min(Math.max(form.alertThreshold, 5), 95) / 100,
pauseStatuses: form.pauseStatuses,
}
})
await saveSla({
tenantId,
actorId: viewerId,
companyId: company.id as Id<"companies">,
rules: payload,
})
toast.success("SLA atualizado", { id: "company-sla" })
onClose()
} catch (error) {
console.error(error)
toast.error("Nao foi possivel salvar as regras de SLA.", { id: "company-sla" })
} finally {
setSaving(false)
}
}
const handleDelete = async () => {
if (!company || !viewerId) return
setDeleting(true)
toast.loading("Removendo SLA...", { id: "company-sla-delete" })
try {
await removeSla({
tenantId,
actorId: viewerId,
companyId: company.id as Id<"companies">,
})
toast.success("SLA removido", { id: "company-sla-delete" })
setDeleteConfirmOpen(false)
onClose()
} catch (error) {
console.error(error)
toast.error("Nao foi possivel remover o SLA.", { id: "company-sla-delete" })
} finally {
setDeleting(false)
}
}
const hasExistingRules = existing?.rules && existing.rules.length > 0
return (
<>
<Dialog
open={drawerOpen}
onOpenChange={(open) => {
if (!open) {
onClose()
}
}}
>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Configurar SLA - {company?.name ?? ""}</DialogTitle>
<DialogDescription>
Defina metas de resposta e resolucao para cada prioridade. Estas regras tem prioridade sobre as regras
por categoria.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{PRIORITY_ROWS.map((row) => {
const form = rules[row.value]
return (
<div key={row.value} className="space-y-3 rounded-2xl border border-slate-200 bg-white/80 p-4 shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<p className="text-sm font-semibold text-neutral-900">{row.label}</p>
<p className="text-xs text-neutral-500">
{row.value === "DEFAULT"
? "Aplicado quando o ticket nao tem prioridade definida."
: "Aplica-se aos tickets desta prioridade."}
</p>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<SlaInputGroup
title="Tempo de resposta"
amount={form.responseValue}
unit={form.responseUnit}
mode={form.responseMode}
onAmountChange={(value) => handleChange(row.value, { responseValue: value })}
onUnitChange={(value) =>
handleChange(row.value, { responseUnit: value as RuleFormState["responseUnit"] })
}
onModeChange={(value) =>
handleChange(row.value, { responseMode: value as RuleFormState["responseMode"] })
}
/>
<SlaInputGroup
title="Tempo de solucao"
amount={form.solutionValue}
unit={form.solutionUnit}
mode={form.solutionMode}
onAmountChange={(value) => handleChange(row.value, { solutionValue: value })}
onUnitChange={(value) =>
handleChange(row.value, { solutionUnit: value as RuleFormState["solutionUnit"] })
}
onModeChange={(value) =>
handleChange(row.value, { solutionMode: value as RuleFormState["solutionMode"] })
}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Alertar quando</p>
<div className="mt-2 flex items-center gap-2">
<Input
type="number"
min={10}
max={95}
step={5}
value={form.alertThreshold}
onChange={(event) => handleChange(row.value, { alertThreshold: Number(event.target.value) || 0 })}
/>
<span className="text-xs text-neutral-500">% do tempo for consumido.</span>
</div>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Estados que pausam</p>
<div className="mt-2 flex flex-wrap gap-2">
{PAUSE_STATUS_OPTIONS.map((option) => {
const selected = form.pauseStatuses.includes(option.value)
return (
<button
key={option.value}
type="button"
onClick={() => togglePause(row.value, option.value)}
className={cn(
"rounded-full border px-3 py-1 text-xs font-semibold transition",
selected
? "border-primary bg-primary text-primary-foreground"
: "border-slate-200 bg-white text-neutral-600"
)}
>
{option.label}
</button>
)
})}
</div>
</div>
</div>
</div>
)
})}
</div>
<DialogFooter className="flex-col gap-2 sm:flex-row sm:justify-between">
<div>
{hasExistingRules && (
<Button
variant="outline"
onClick={() => setDeleteConfirmOpen(true)}
className="gap-2 text-destructive hover:text-destructive"
>
<Trash2 className="size-4" />
Remover SLA
</Button>
)}
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={onClose}>
Cancelar
</Button>
<Button onClick={handleSave} disabled={saving || !viewerId}>
{saving ? "Salvando..." : "Salvar"}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Confirmacao de exclusao */}
<Dialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-destructive">
<AlertTriangle className="size-5" />
Remover SLA da empresa
</DialogTitle>
<DialogDescription>
Esta acao ira remover todas as regras de SLA personalizadas da empresa{" "}
<span className="font-semibold">{company?.name}</span>. Os tickets passarao a usar as regras por
categoria.
</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => setDeleteConfirmOpen(false)} disabled={deleting}>
Cancelar
</Button>
<Button variant="destructive" onClick={handleDelete} disabled={deleting} className="gap-2">
{deleting ? "Removendo..." : (<><Trash2 className="size-4" /> Remover</>)}
</Button>
</div>
</DialogContent>
</Dialog>
</>
)
}
function buildDefaultRuleState() {
return PRIORITY_ROWS.reduce<Record<string, RuleFormState>>((acc, row) => {
acc[row.value] = { ...DEFAULT_RULE_STATE }
return acc
}, {})
}
function convertRuleToForm(rule: {
priority: string
responseTargetMinutes: number | null
responseMode?: string | null
solutionTargetMinutes: number | null
solutionMode?: string | null
alertThreshold?: number | null
pauseStatuses?: string[] | null
}): RuleFormState {
const response = minutesToForm(rule.responseTargetMinutes)
const solution = minutesToForm(rule.solutionTargetMinutes)
return {
responseValue: response.amount,
responseUnit: response.unit,
responseMode: (rule.responseMode ?? "calendar") as RuleFormState["responseMode"],
solutionValue: solution.amount,
solutionUnit: solution.unit,
solutionMode: (rule.solutionMode ?? "calendar") as RuleFormState["solutionMode"],
alertThreshold: Math.round((rule.alertThreshold ?? 0.8) * 100),
pauseStatuses: rule.pauseStatuses && rule.pauseStatuses.length > 0 ? rule.pauseStatuses : ["PAUSED"],
}
}
function minutesToForm(input?: number | null) {
if (!input || input <= 0) {
return { amount: "", unit: "hours" as RuleFormState["responseUnit"] }
}
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" as RuleFormState["responseUnit"] }
}
function convertToMinutes(value: string, unit: RuleFormState["responseUnit"]) {
const numeric = Number(value)
if (!Number.isFinite(numeric) || numeric <= 0) {
return undefined
}
const factor = TIME_UNITS.find((item) => item.value === unit)?.factor ?? 1
return Math.round(numeric * factor)
}
type SlaInputGroupProps = {
title: string
amount: string
unit: RuleFormState["responseUnit"]
mode: RuleFormState["responseMode"]
onAmountChange: (value: string) => void
onUnitChange: (value: string) => void
onModeChange: (value: string) => void
}
function SlaInputGroup({ title, amount, unit, mode, onAmountChange, onUnitChange, onModeChange }: SlaInputGroupProps) {
return (
<div className="space-y-2 rounded-xl border border-slate-200 bg-white px-3 py-3">
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">{title}</p>
<div className="flex flex-col gap-2 md:flex-row">
<Input
type="number"
min={0}
step={1}
value={amount}
onChange={(event) => onAmountChange(event.target.value)}
placeholder="0"
/>
<Select value={unit} onValueChange={onUnitChange}>
<SelectTrigger>
<SelectValue placeholder="Unidade" />
</SelectTrigger>
<SelectContent>
{TIME_UNITS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Select value={mode} onValueChange={onModeChange}>
<SelectTrigger>
<SelectValue placeholder="Tipo de contagem" />
</SelectTrigger>
<SelectContent>
{MODE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}

View file

@ -0,0 +1,238 @@
"use client"
import { useMemo, useState } from "react"
import { useQuery } from "convex/react"
import { Building2, Plus, Search } from "lucide-react"
import { api } from "@/convex/_generated/api"
import { useAuth } from "@/lib/auth-client"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import type { Id } from "@/convex/_generated/dataModel"
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 { Skeleton } from "@/components/ui/skeleton"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { ScrollArea } from "@/components/ui/scroll-area"
import { CompanySlaDrawer } from "./company-sla-drawer"
type CompanyWithSla = {
companyId: string
companyName: string
companySlug: string
rulesCount: number
}
type Company = {
_id: string
name: string
slug: string
tenantId: string
}
export function CompanySlaManager() {
const { session, convexUserId } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
const viewerId = convexUserId ? (convexUserId as Id<"users">) : null
const [searchQuery, setSearchQuery] = useState("")
const [dialogSearchQuery, setDialogSearchQuery] = useState("")
const [selectedCompany, setSelectedCompany] = useState<{ id: string; name: string } | null>(null)
const [addDialogOpen, setAddDialogOpen] = useState(false)
// Empresas que já possuem SLA customizado
const companiesWithSla = useQuery(
api.companySlas.listCompaniesWithCustomSla,
viewerId ? { tenantId, viewerId } : "skip"
) as CompanyWithSla[] | undefined
// Todas as empresas (para o dialog de adicionar)
const allCompanies = useQuery(api.companies.list, { tenantId }) as Company[] | undefined
const filteredCompaniesWithSla = useMemo(() => {
if (!companiesWithSla) return []
if (!searchQuery.trim()) return companiesWithSla
const query = searchQuery.toLowerCase()
return companiesWithSla.filter(
(c) => c.companyName.toLowerCase().includes(query) || c.companySlug.toLowerCase().includes(query)
)
}, [companiesWithSla, searchQuery])
// Empresas disponíveis para adicionar (que ainda não têm SLA customizado)
const availableCompanies = useMemo(() => {
if (!allCompanies || !companiesWithSla) return []
const existingIds = new Set(companiesWithSla.map((c) => c.companyId))
return allCompanies.filter((c) => !existingIds.has(c._id))
}, [allCompanies, companiesWithSla])
// Filtra empresas disponíveis pelo termo de busca no dialog
const filteredAvailableCompanies = useMemo(() => {
if (!dialogSearchQuery.trim()) return availableCompanies
const query = dialogSearchQuery.toLowerCase()
return availableCompanies.filter(
(c) => c.name.toLowerCase().includes(query) || c.slug.toLowerCase().includes(query)
)
}, [availableCompanies, dialogSearchQuery])
const handleSelectCompanyToAdd = (company: Company) => {
setAddDialogOpen(false)
setDialogSearchQuery("")
setSelectedCompany({ id: company._id, name: company.name })
}
return (
<>
<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">
<Building2 className="size-5" />
SLA por empresa
</CardTitle>
<CardDescription className="mt-1">
Configure políticas de SLA específicas por cliente. Estas regras têm prioridade sobre as regras por categoria.
</CardDescription>
</div>
<Button
size="sm"
onClick={() => setAddDialogOpen(true)}
disabled={!viewerId || availableCompanies.length === 0}
className="gap-2 shrink-0"
>
<Plus className="size-4" />
Adicionar empresa
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{companiesWithSla === undefined ? (
<div className="space-y-2">
{Array.from({ length: 3 }).map((_, index) => (
<Skeleton key={`company-sla-skeleton-${index}`} className="h-16 rounded-xl" />
))}
</div>
) : companiesWithSla.length === 0 ? (
<div className="rounded-xl border border-dashed border-slate-300 bg-slate-50/80 p-6 text-center">
<Building2 className="mx-auto size-8 text-slate-400" />
<p className="mt-2 text-sm font-medium text-neutral-700">Nenhuma empresa com SLA personalizado</p>
<p className="mt-1 text-xs text-neutral-500">
Clique em &quot;Adicionar empresa&quot; para configurar SLA específico para um cliente.
</p>
</div>
) : (
<>
{companiesWithSla.length > 5 && (
<div className="relative">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-neutral-400" />
<Input
placeholder="Buscar empresa..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
)}
<div className="space-y-3">
{filteredCompaniesWithSla.map((company) => (
<div
key={company.companyId}
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">
<p className="text-sm font-semibold text-neutral-900">{company.companyName}</p>
<p className="text-xs text-neutral-500">/{company.companySlug}</p>
</div>
<div className="flex items-center gap-3">
<Badge variant="secondary" className="rounded-full">
{company.rulesCount} {company.rulesCount === 1 ? "regra" : "regras"}
</Badge>
<Button
size="sm"
variant="secondary"
onClick={() => setSelectedCompany({ id: company.companyId, name: company.companyName })}
disabled={!viewerId}
>
Editar SLA
</Button>
</div>
</div>
))}
{filteredCompaniesWithSla.length === 0 && searchQuery && (
<p className="py-4 text-center text-sm text-neutral-500">
Nenhuma empresa encontrada para &quot;{searchQuery}&quot;
</p>
)}
</div>
</>
)}
</CardContent>
</Card>
{/* Dialog para adicionar nova empresa */}
<Dialog open={addDialogOpen} onOpenChange={(open) => {
setAddDialogOpen(open)
if (!open) setDialogSearchQuery("")
}}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Adicionar SLA por empresa</DialogTitle>
<DialogDescription>
Selecione uma empresa para configurar políticas de SLA personalizadas.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="relative">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-neutral-400" />
<Input
placeholder="Buscar empresa..."
value={dialogSearchQuery}
onChange={(e) => setDialogSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<ScrollArea className="h-[280px] rounded-lg border border-slate-200">
{filteredAvailableCompanies.length === 0 ? (
<div className="flex h-full items-center justify-center p-6">
<p className="text-sm text-neutral-500">Nenhuma empresa disponível.</p>
</div>
) : (
<div className="p-1">
{filteredAvailableCompanies.map((company) => (
<button
key={company._id}
type="button"
onClick={() => handleSelectCompanyToAdd(company)}
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left transition-colors hover:bg-slate-100"
>
<Building2 className="size-4 shrink-0 text-neutral-500" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-neutral-900">{company.name}</p>
<p className="truncate text-xs text-neutral-500">/{company.slug}</p>
</div>
</button>
))}
</div>
)}
</ScrollArea>
</div>
</DialogContent>
</Dialog>
<CompanySlaDrawer
company={selectedCompany}
tenantId={tenantId}
viewerId={viewerId}
onClose={() => setSelectedCompany(null)}
/>
</>
)
}

View file

@ -16,6 +16,7 @@ import { Skeleton } from "@/components/ui/skeleton"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { CategorySlaManager } from "./category-sla-manager"
import { CompanySlaManager } from "./company-sla-manager"
type SlaPolicy = {
id: string
@ -331,6 +332,8 @@ export function SlasManager() {
<CategorySlaManager />
<CompanySlaManager />
<Dialog open={Boolean(editingSla)} onOpenChange={(value) => (!value ? setEditingSla(null) : null)}>
<DialogContent className="max-w-2xl">
<DialogHeader>