From 47ccdc51a7ca838b08727e7045b6a2eb2b8ed88b Mon Sep 17 00:00:00 2001 From: rever-tecnologia Date: Wed, 17 Dec 2025 18:53:32 -0300 Subject: [PATCH] =?UTF-8?q?feat(sla):=20adiciona=20interface=20de=20admini?= =?UTF-8?q?stra=C3=A7=C3=A3o=20de=20SLA=20por=20empresa?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../admin/slas/company-sla-drawer.tsx | 473 ++++++++++++++++++ .../admin/slas/company-sla-manager.tsx | 238 +++++++++ src/components/admin/slas/slas-manager.tsx | 3 + 3 files changed, 714 insertions(+) create mode 100644 src/components/admin/slas/company-sla-drawer.tsx create mode 100644 src/components/admin/slas/company-sla-manager.tsx diff --git a/src/components/admin/slas/company-sla-drawer.tsx b/src/components/admin/slas/company-sla-drawer.tsx new file mode 100644 index 0000000..e2b7bad --- /dev/null +++ b/src/components/admin/slas/company-sla-drawer.tsx @@ -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>(() => 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) => { + 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 ( + <> + { + if (!open) { + onClose() + } + }} + > + + + Configurar SLA - {company?.name ?? ""} + + Defina metas de resposta e resolucao para cada prioridade. Estas regras tem prioridade sobre as regras + por categoria. + + +
+ {PRIORITY_ROWS.map((row) => { + const form = rules[row.value] + return ( +
+
+
+

{row.label}

+

+ {row.value === "DEFAULT" + ? "Aplicado quando o ticket nao tem prioridade definida." + : "Aplica-se aos tickets desta prioridade."} +

+
+
+
+ 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"] }) + } + /> + 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"] }) + } + /> +
+
+
+

Alertar quando

+
+ handleChange(row.value, { alertThreshold: Number(event.target.value) || 0 })} + /> + % do tempo for consumido. +
+
+
+

Estados que pausam

+
+ {PAUSE_STATUS_OPTIONS.map((option) => { + const selected = form.pauseStatuses.includes(option.value) + return ( + + ) + })} +
+
+
+
+ ) + })} +
+ +
+ {hasExistingRules && ( + + )} +
+
+ + +
+
+
+
+ + {/* Confirmacao de exclusao */} + + + + + + Remover SLA da empresa + + + Esta acao ira remover todas as regras de SLA personalizadas da empresa{" "} + {company?.name}. Os tickets passarao a usar as regras por + categoria. + + +
+ + +
+
+
+ + ) +} + +function buildDefaultRuleState() { + return PRIORITY_ROWS.reduce>((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 ( +
+

{title}

+
+ onAmountChange(event.target.value)} + placeholder="0" + /> + +
+ +
+ ) +} diff --git a/src/components/admin/slas/company-sla-manager.tsx b/src/components/admin/slas/company-sla-manager.tsx new file mode 100644 index 0000000..bdd1543 --- /dev/null +++ b/src/components/admin/slas/company-sla-manager.tsx @@ -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 ( + <> + + +
+
+ + + SLA por empresa + + + Configure políticas de SLA específicas por cliente. Estas regras têm prioridade sobre as regras por categoria. + +
+ +
+
+ + {companiesWithSla === undefined ? ( +
+ {Array.from({ length: 3 }).map((_, index) => ( + + ))} +
+ ) : companiesWithSla.length === 0 ? ( +
+ +

Nenhuma empresa com SLA personalizado

+

+ Clique em "Adicionar empresa" para configurar SLA específico para um cliente. +

+
+ ) : ( + <> + {companiesWithSla.length > 5 && ( +
+ + setSearchQuery(e.target.value)} + className="pl-9" + /> +
+ )} +
+ {filteredCompaniesWithSla.map((company) => ( +
+
+

{company.companyName}

+

/{company.companySlug}

+
+
+ + {company.rulesCount} {company.rulesCount === 1 ? "regra" : "regras"} + + +
+
+ ))} + {filteredCompaniesWithSla.length === 0 && searchQuery && ( +

+ Nenhuma empresa encontrada para "{searchQuery}" +

+ )} +
+ + )} +
+
+ + {/* Dialog para adicionar nova empresa */} + { + setAddDialogOpen(open) + if (!open) setDialogSearchQuery("") + }}> + + + Adicionar SLA por empresa + + Selecione uma empresa para configurar políticas de SLA personalizadas. + + +
+
+ + setDialogSearchQuery(e.target.value)} + className="pl-9" + /> +
+ + {filteredAvailableCompanies.length === 0 ? ( +
+

Nenhuma empresa disponível.

+
+ ) : ( +
+ {filteredAvailableCompanies.map((company) => ( + + ))} +
+ )} +
+
+
+
+ + setSelectedCompany(null)} + /> + + ) +} diff --git a/src/components/admin/slas/slas-manager.tsx b/src/components/admin/slas/slas-manager.tsx index 25bbfb3..3cba73d 100644 --- a/src/components/admin/slas/slas-manager.tsx +++ b/src/components/admin/slas/slas-manager.tsx @@ -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() { + + (!value ? setEditingSla(null) : null)}>