400 lines
14 KiB
TypeScript
400 lines
14 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, useState } from "react"
|
|
import { useMutation, useQuery } from "convex/react"
|
|
import { toast } from "sonner"
|
|
|
|
import { api } from "@/convex/_generated/api"
|
|
import type { Id } from "@/convex/_generated/dataModel"
|
|
import type { TicketCategory } from "@/lib/schemas/category"
|
|
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: "Crítico" },
|
|
{ value: "HIGH", label: "Alta" },
|
|
{ value: "MEDIUM", label: "Média" },
|
|
{ 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 úteis" },
|
|
]
|
|
|
|
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 CategorySlaDrawerProps = {
|
|
category: TicketCategory | null
|
|
tenantId: string
|
|
viewerId: Id<"users"> | null
|
|
onClose: () => void
|
|
}
|
|
|
|
export function CategorySlaDrawer({ category, tenantId, viewerId, onClose }: CategorySlaDrawerProps) {
|
|
const [rules, setRules] = useState<Record<string, RuleFormState>>(() => buildDefaultRuleState())
|
|
const [saving, setSaving] = useState(false)
|
|
const drawerOpen = Boolean(category)
|
|
|
|
const canLoad = Boolean(category && viewerId)
|
|
const existing = useQuery(
|
|
api.categorySlas.get,
|
|
canLoad
|
|
? {
|
|
tenantId,
|
|
viewerId: viewerId as Id<"users">,
|
|
categoryId: category!.id as Id<"ticketCategories">,
|
|
}
|
|
: "skip"
|
|
) as {
|
|
rules: Array<{
|
|
priority: string
|
|
responseTargetMinutes: number | null
|
|
responseMode?: string | null
|
|
solutionTargetMinutes: number | null
|
|
solutionMode?: string | null
|
|
alertThreshold?: number | null
|
|
pauseStatuses?: string[] | null
|
|
}>
|
|
} | undefined
|
|
|
|
const saveSla = useMutation(api.categorySlas.save)
|
|
|
|
useEffect(() => {
|
|
if (!existing?.rules) {
|
|
setRules(buildDefaultRuleState())
|
|
return
|
|
}
|
|
const next = buildDefaultRuleState()
|
|
for (const rule of existing.rules) {
|
|
const priority = rule.priority?.toUpperCase() ?? "DEFAULT"
|
|
next[priority] = convertRuleToForm(rule)
|
|
}
|
|
setRules(next)
|
|
}, [existing, category?.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 (!category || !viewerId) return
|
|
setSaving(true)
|
|
toast.loading("Salvando SLA...", { id: "category-sla" })
|
|
try {
|
|
const payload = PRIORITY_ROWS.map((row) => {
|
|
const form = rules[row.value]
|
|
return {
|
|
priority: row.value,
|
|
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,
|
|
categoryId: category.id as Id<"ticketCategories">,
|
|
rules: payload,
|
|
})
|
|
toast.success("SLA atualizado", { id: "category-sla" })
|
|
onClose()
|
|
} catch (error) {
|
|
console.error(error)
|
|
toast.error("Não foi possível salvar as regras de SLA.", { id: "category-sla" })
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Dialog
|
|
open={drawerOpen}
|
|
onOpenChange={(open) => {
|
|
if (!open) {
|
|
onClose()
|
|
}
|
|
}}
|
|
>
|
|
<DialogContent className="max-w-4xl overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>Configurar SLA — {category?.name ?? ""}</DialogTitle>
|
|
<DialogDescription>
|
|
Defina metas de resposta e resolução para cada prioridade. Os prazos em horas úteis consideram apenas
|
|
segunda a sexta, das 8h às 18h.
|
|
</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 não 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 solução"
|
|
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="gap-2">
|
|
<Button variant="outline" onClick={onClose}>
|
|
Cancelar
|
|
</Button>
|
|
<Button onClick={handleSave} disabled={saving || !viewerId}>
|
|
{saving ? "Salvando..." : "Salvar"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</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>
|
|
)
|
|
}
|