feat: adiciona SLA por empresa e modal de exclusao de automacoes
Some checks failed
Some checks failed
## SLA por Empresa - Adiciona tabela companySlaSettings no schema - Cria convex/companySlas.ts com queries e mutations - Modifica resolveTicketSlaSnapshot para verificar SLA da empresa primeiro - Fallback: empresa > categoria > padrao ## Modal de Exclusao de Automacoes - Substitui confirm() nativo por Dialog gracioso - Segue padrao do delete-ticket-dialog 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
b3fcbcc682
commit
33f0cc2e13
4 changed files with 404 additions and 22 deletions
272
convex/companySlas.ts
Normal file
272
convex/companySlas.ts
Normal file
|
|
@ -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<string>()
|
||||
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<string, ReturnType<typeof buildRule>> = 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,
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue