diff --git a/convex/companySlas.ts b/convex/companySlas.ts new file mode 100644 index 0000000..6253e0f --- /dev/null +++ b/convex/companySlas.ts @@ -0,0 +1,272 @@ +import { mutation, query } from "./_generated/server" +import { ConvexError, v } from "convex/values" + +import { requireAdmin } from "./rbac" + +const PRIORITY_VALUES = ["URGENT", "HIGH", "MEDIUM", "LOW", "DEFAULT"] as const +const VALID_STATUSES = ["PENDING", "AWAITING_ATTENDANCE", "PAUSED", "RESOLVED"] as const +const VALID_TIME_MODES = ["business", "calendar"] as const + +type CompanySlaRuleInput = { + priority: string + categoryId?: string | null + responseTargetMinutes?: number | null + responseMode?: string | null + solutionTargetMinutes?: number | null + solutionMode?: string | null + alertThreshold?: number | null + pauseStatuses?: string[] | null + calendarType?: string | null +} + +const ruleInput = v.object({ + priority: v.string(), + categoryId: v.optional(v.union(v.id("ticketCategories"), v.null())), + responseTargetMinutes: v.optional(v.number()), + responseMode: v.optional(v.string()), + solutionTargetMinutes: v.optional(v.number()), + solutionMode: v.optional(v.string()), + alertThreshold: v.optional(v.number()), + pauseStatuses: v.optional(v.array(v.string())), + calendarType: v.optional(v.string()), +}) + +function normalizePriority(value: string) { + const upper = value.trim().toUpperCase() + return PRIORITY_VALUES.includes(upper as (typeof PRIORITY_VALUES)[number]) ? upper : "DEFAULT" +} + +function sanitizeTime(value?: number | null) { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return undefined + return Math.round(value) +} + +function normalizeMode(value?: string | null) { + if (!value) return "calendar" + const normalized = value.toLowerCase() + return VALID_TIME_MODES.includes(normalized as (typeof VALID_TIME_MODES)[number]) ? normalized : "calendar" +} + +function normalizeThreshold(value?: number | null) { + if (typeof value !== "number" || Number.isNaN(value)) { + return 0.8 + } + const clamped = Math.min(Math.max(value, 0.1), 0.95) + return Math.round(clamped * 100) / 100 +} + +function normalizePauseStatuses(value?: string[] | null) { + if (!Array.isArray(value)) return ["PAUSED"] + const normalized = new Set() + for (const status of value) { + if (typeof status !== "string") continue + const upper = status.trim().toUpperCase() + if (VALID_STATUSES.includes(upper as (typeof VALID_STATUSES)[number])) { + normalized.add(upper) + } + } + if (normalized.size === 0) { + normalized.add("PAUSED") + } + return Array.from(normalized) +} + +// Lista todas as empresas que possuem SLA customizado +export const listCompaniesWithCustomSla = query({ + args: { + tenantId: v.string(), + viewerId: v.id("users"), + }, + handler: async (ctx, { tenantId, viewerId }) => { + await requireAdmin(ctx, viewerId, tenantId) + + // Busca todas as configurações de SLA por empresa + const allSettings = await ctx.db + .query("companySlaSettings") + .withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId)) + .take(1000) + + // Agrupa por companyId para evitar duplicatas + const companyIds = [...new Set(allSettings.map((s) => s.companyId))] + + // Busca dados das empresas + const companies = await Promise.all( + companyIds.map(async (companyId) => { + const company = await ctx.db.get(companyId) + if (!company) return null + const rulesCount = allSettings.filter((s) => s.companyId === companyId).length + return { + companyId, + companyName: company.name, + companySlug: company.slug, + rulesCount, + } + }) + ) + + return companies.filter(Boolean) + }, +}) + +// Busca as regras de SLA de uma empresa específica +export const get = query({ + args: { + tenantId: v.string(), + viewerId: v.id("users"), + companyId: v.id("companies"), + }, + handler: async (ctx, { tenantId, viewerId, companyId }) => { + await requireAdmin(ctx, viewerId, tenantId) + + const company = await ctx.db.get(companyId) + if (!company || company.tenantId !== tenantId) { + throw new ConvexError("Empresa não encontrada") + } + + const records = await ctx.db + .query("companySlaSettings") + .withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId)) + .take(100) + + // Busca nomes das categorias referenciadas + const categoryIds = [...new Set(records.filter((r) => r.categoryId).map((r) => r.categoryId!))] + const categories = await Promise.all(categoryIds.map((id) => ctx.db.get(id))) + const categoryNames = new Map( + categories.filter(Boolean).map((c) => [c!._id, c!.name]) + ) + + return { + companyId, + companyName: company.name, + rules: records.map((record) => ({ + priority: record.priority, + categoryId: record.categoryId ?? null, + categoryName: record.categoryId ? categoryNames.get(record.categoryId) ?? null : null, + responseTargetMinutes: record.responseTargetMinutes ?? null, + responseMode: record.responseMode ?? "calendar", + solutionTargetMinutes: record.solutionTargetMinutes ?? null, + solutionMode: record.solutionMode ?? "calendar", + alertThreshold: record.alertThreshold ?? 0.8, + pauseStatuses: record.pauseStatuses ?? ["PAUSED"], + })), + } + }, +}) + +// Salva as regras de SLA de uma empresa +export const save = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + companyId: v.id("companies"), + rules: v.array(ruleInput), + }, + handler: async (ctx, { tenantId, actorId, companyId, rules }) => { + await requireAdmin(ctx, actorId, tenantId) + + const company = await ctx.db.get(companyId) + if (!company || company.tenantId !== tenantId) { + throw new ConvexError("Empresa não encontrada") + } + + // Valida categorias referenciadas + for (const rule of rules) { + if (rule.categoryId) { + const category = await ctx.db.get(rule.categoryId) + if (!category || category.tenantId !== tenantId) { + throw new ConvexError(`Categoria inválida: ${rule.categoryId}`) + } + } + } + + const sanitized = sanitizeRules(rules) + + // Remove regras existentes da empresa + const existing = await ctx.db + .query("companySlaSettings") + .withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId)) + .take(100) + + await Promise.all(existing.map((record) => ctx.db.delete(record._id))) + + // Insere novas regras + const now = Date.now() + for (const rule of sanitized) { + await ctx.db.insert("companySlaSettings", { + tenantId, + companyId, + categoryId: rule.categoryId ?? undefined, + priority: rule.priority, + responseTargetMinutes: rule.responseTargetMinutes, + responseMode: rule.responseMode, + solutionTargetMinutes: rule.solutionTargetMinutes, + solutionMode: rule.solutionMode, + alertThreshold: rule.alertThreshold, + pauseStatuses: rule.pauseStatuses, + calendarType: rule.calendarType ?? undefined, + createdAt: now, + updatedAt: now, + actorId, + }) + } + + return { ok: true } + }, +}) + +// Remove todas as regras de SLA de uma empresa +export const remove = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + companyId: v.id("companies"), + }, + handler: async (ctx, { tenantId, actorId, companyId }) => { + await requireAdmin(ctx, actorId, tenantId) + + const company = await ctx.db.get(companyId) + if (!company || company.tenantId !== tenantId) { + throw new ConvexError("Empresa não encontrada") + } + + const existing = await ctx.db + .query("companySlaSettings") + .withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId)) + .take(100) + + await Promise.all(existing.map((record) => ctx.db.delete(record._id))) + + return { ok: true } + }, +}) + +function sanitizeRules(rules: CompanySlaRuleInput[]) { + // Chave única: categoryId + priority + const normalized: Map> = new Map() + + for (const rule of rules) { + const built = buildRule(rule) + const key = `${built.categoryId ?? "ALL"}-${built.priority}` + normalized.set(key, built) + } + + return Array.from(normalized.values()) +} + +function buildRule(rule: CompanySlaRuleInput) { + const priority = normalizePriority(rule.priority) + const responseTargetMinutes = sanitizeTime(rule.responseTargetMinutes) + const solutionTargetMinutes = sanitizeTime(rule.solutionTargetMinutes) + + return { + priority, + categoryId: rule.categoryId ?? null, + responseTargetMinutes, + responseMode: normalizeMode(rule.responseMode), + solutionTargetMinutes, + solutionMode: normalizeMode(rule.solutionMode), + alertThreshold: normalizeThreshold(rule.alertThreshold), + pauseStatuses: normalizePauseStatuses(rule.pauseStatuses), + calendarType: rule.calendarType ?? null, + } +} diff --git a/convex/schema.ts b/convex/schema.ts index a6beda2..ac9ae2d 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -594,6 +594,29 @@ export default defineSchema({ .index("by_tenant_category_priority", ["tenantId", "categoryId", "priority"]) .index("by_tenant_category", ["tenantId", "categoryId"]), + // SLA por empresa - permite configurar políticas de SLA específicas por cliente + // Quando um ticket é criado, o sistema busca primeiro aqui antes de usar categorySlaSettings + companySlaSettings: defineTable({ + tenantId: v.string(), + companyId: v.id("companies"), + // Se categoryId for null, aplica-se a todas as categorias da empresa + categoryId: v.optional(v.id("ticketCategories")), + priority: v.string(), // URGENT, HIGH, MEDIUM, LOW, DEFAULT + responseTargetMinutes: v.optional(v.number()), + responseMode: v.optional(v.string()), // "business" | "calendar" + solutionTargetMinutes: v.optional(v.number()), + solutionMode: v.optional(v.string()), // "business" | "calendar" + alertThreshold: v.optional(v.number()), // 0.1 a 0.95 (ex: 0.8 = 80%) + pauseStatuses: v.optional(v.array(v.string())), + calendarType: v.optional(v.string()), + createdAt: v.number(), + updatedAt: v.number(), + actorId: v.optional(v.id("users")), + }) + .index("by_tenant_company", ["tenantId", "companyId"]) + .index("by_tenant_company_category", ["tenantId", "companyId", "categoryId"]) + .index("by_tenant_company_category_priority", ["tenantId", "companyId", "categoryId", "priority"]), + ticketFields: defineTable({ tenantId: v.string(), key: v.string(), diff --git a/convex/tickets.ts b/convex/tickets.ts index 5fb123d..8ab5ebe 100644 --- a/convex/tickets.ts +++ b/convex/tickets.ts @@ -272,25 +272,74 @@ async function resolveTicketSlaSnapshot( ctx: AnyCtx, tenantId: string, category: Doc<"ticketCategories"> | null, - priority: string + priority: string, + companyId?: Id<"companies"> | null ): Promise { if (!category) { return null; } const normalizedPriority = priority.trim().toUpperCase(); - const rule = - (await ctx.db - .query("categorySlaSettings") - .withIndex("by_tenant_category_priority", (q) => - q.eq("tenantId", tenantId).eq("categoryId", category._id).eq("priority", normalizedPriority) + + // 1. Primeiro, tenta buscar SLA específico da empresa (se companyId foi informado) + let rule: { + responseTargetMinutes?: number; + responseMode?: string; + solutionTargetMinutes?: number; + solutionMode?: string; + alertThreshold?: number; + pauseStatuses?: string[]; + } | null = null; + + if (companyId) { + // Tenta: empresa + categoria + prioridade + rule = await ctx.db + .query("companySlaSettings") + .withIndex("by_tenant_company_category_priority", (q) => + q.eq("tenantId", tenantId).eq("companyId", companyId).eq("categoryId", category._id).eq("priority", normalizedPriority) ) - .first()) ?? - (await ctx.db - .query("categorySlaSettings") - .withIndex("by_tenant_category_priority", (q) => - q.eq("tenantId", tenantId).eq("categoryId", category._id).eq("priority", "DEFAULT") - ) - .first()); + .first(); + + // Fallback: empresa + categoria + DEFAULT + if (!rule) { + rule = await ctx.db + .query("companySlaSettings") + .withIndex("by_tenant_company_category_priority", (q) => + q.eq("tenantId", tenantId).eq("companyId", companyId).eq("categoryId", category._id).eq("priority", "DEFAULT") + ) + .first(); + } + + // Fallback: empresa + todas categorias (categoryId null) + prioridade + if (!rule) { + const allCategoriesRules = await ctx.db + .query("companySlaSettings") + .withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId)) + .filter((q) => q.eq(q.field("categoryId"), undefined)) + .take(10); + + rule = allCategoriesRules.find((r) => r.priority === normalizedPriority) ?? + allCategoriesRules.find((r) => r.priority === "DEFAULT") ?? + null; + } + } + + // 2. Se não encontrou SLA da empresa, usa SLA da categoria (comportamento padrão) + if (!rule) { + rule = + (await ctx.db + .query("categorySlaSettings") + .withIndex("by_tenant_category_priority", (q) => + q.eq("tenantId", tenantId).eq("categoryId", category._id).eq("priority", normalizedPriority) + ) + .first()) ?? + (await ctx.db + .query("categorySlaSettings") + .withIndex("by_tenant_category_priority", (q) => + q.eq("tenantId", tenantId).eq("categoryId", category._id).eq("priority", "DEFAULT") + ) + .first()); + } + if (!rule) { return null; } @@ -2325,7 +2374,7 @@ export const create = mutation({ avatarUrl: requester.avatarUrl ?? undefined, teams: requester.teams ?? undefined, } - const slaSnapshot = await resolveTicketSlaSnapshot(ctx, args.tenantId, category as Doc<"ticketCategories"> | null, args.priority) + // Resolve a empresa primeiro para poder verificar SLA específico let companyDoc = requester.companyId ? (await ctx.db.get(requester.companyId)) : null if (!companyDoc && machineDoc?.companyId) { const candidateCompany = await ctx.db.get(machineDoc.companyId) @@ -2337,6 +2386,8 @@ export const create = mutation({ ? { name: companyDoc.name, slug: companyDoc.slug, isAvulso: companyDoc.isAvulso ?? undefined } : undefined const resolvedCompanyId = companyDoc?._id ?? requester.companyId ?? undefined + // Resolve SLA passando companyId para verificar regras específicas da empresa + const slaSnapshot = await resolveTicketSlaSnapshot(ctx, args.tenantId, category as Doc<"ticketCategories"> | null, args.priority, resolvedCompanyId) let checklist = manualChecklist for (const templateId of args.checklistTemplateIds ?? []) { diff --git a/src/components/automations/automations-manager.tsx b/src/components/automations/automations-manager.tsx index 9806681..86f08a7 100644 --- a/src/components/automations/automations-manager.tsx +++ b/src/components/automations/automations-manager.tsx @@ -2,7 +2,7 @@ import { useMemo, useState } from "react" import { useMutation, useQuery } from "convex/react" -import { History, MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react" +import { AlertTriangle, History, MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react" import { toast } from "sonner" import { api } from "@/convex/_generated/api" @@ -12,7 +12,7 @@ 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 { Dialog, DialogTrigger } from "@/components/ui/dialog" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" import { Input } from "@/components/ui/input" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" @@ -81,6 +81,9 @@ export function AutomationsManager() { const [editing, setEditing] = useState(null) const [runsOpen, setRunsOpen] = useState(false) const [runsAutomation, setRunsAutomation] = useState(null) + const [deleteOpen, setDeleteOpen] = useState(false) + const [deletingAutomation, setDeletingAutomation] = useState(null) + const [deleteLoading, setDeleteLoading] = useState(false) const list = useQuery( api.automations.list, @@ -134,19 +137,27 @@ export function AutomationsManager() { } } - const handleDelete = async (row: AutomationRow) => { - if (!convexUserId) return - const ok = confirm(`Excluir a automação "${row.name}"?`) - if (!ok) return + const handleDeleteClick = (row: AutomationRow) => { + setDeletingAutomation(row) + setDeleteOpen(true) + } + + const handleDeleteConfirm = async () => { + if (!convexUserId || !deletingAutomation) return + setDeleteLoading(true) try { await removeAutomation({ tenantId, viewerId: convexUserId as Id<"users">, - automationId: row.id, + automationId: deletingAutomation.id, }) toast.success("Automação excluída") + setDeleteOpen(false) + setDeletingAutomation(null) } catch (error) { toast.error(error instanceof Error ? error.message : "Falha ao excluir automação") + } finally { + setDeleteLoading(false) } } @@ -340,7 +351,7 @@ export function AutomationsManager() { Editar - handleDelete(row)} className="gap-2"> + handleDeleteClick(row)} className="gap-2"> Excluir @@ -355,6 +366,31 @@ export function AutomationsManager() { )} + + {/* Modal de confirmação de exclusão */} + { + setDeleteOpen(open) + if (!open) setDeletingAutomation(null) + }}> + + + + Excluir automação + + + Esta ação é permanente e removerá a automação "{deletingAutomation?.name}" e todo o seu histórico de execuções. + + +
+ + +
+
+
) }