Compare commits
4 commits
33f0cc2e13
...
158fb32b8a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
158fb32b8a | ||
|
|
8a237a820d | ||
|
|
14480df9f3 | ||
|
|
47ccdc51a7 |
8 changed files with 881 additions and 43 deletions
|
|
@ -168,7 +168,40 @@ export const startSession = mutation({
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
})
|
})
|
||||||
|
|
||||||
return { sessionId, isNew: true }
|
// Iniciar timer automaticamente se nao houver sessao de trabalho ativa
|
||||||
|
// O chat ao vivo eh considerado trabalho EXTERNAL (interacao com cliente)
|
||||||
|
let workSessionId: Id<"ticketWorkSessions"> | null = null
|
||||||
|
if (!ticket.activeSessionId && ticket.assigneeId) {
|
||||||
|
workSessionId = await ctx.db.insert("ticketWorkSessions", {
|
||||||
|
ticketId,
|
||||||
|
agentId: ticket.assigneeId,
|
||||||
|
workType: "EXTERNAL",
|
||||||
|
startedAt: now,
|
||||||
|
})
|
||||||
|
|
||||||
|
await ctx.db.patch(ticketId, {
|
||||||
|
working: true,
|
||||||
|
activeSessionId: workSessionId,
|
||||||
|
status: "AWAITING_ATTENDANCE",
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
|
||||||
|
await ctx.db.insert("ticketEvents", {
|
||||||
|
ticketId,
|
||||||
|
type: "WORK_STARTED",
|
||||||
|
payload: {
|
||||||
|
actorId,
|
||||||
|
actorName: agent.name,
|
||||||
|
actorAvatar: agent.avatarUrl,
|
||||||
|
sessionId: workSessionId,
|
||||||
|
workType: "EXTERNAL",
|
||||||
|
source: "live_chat_auto",
|
||||||
|
},
|
||||||
|
createdAt: now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sessionId, isNew: true, workSessionStarted: workSessionId !== null }
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -225,7 +258,60 @@ export const endSession = mutation({
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
})
|
})
|
||||||
|
|
||||||
return { ok: true }
|
// Pausar timer automaticamente se houver sessao de trabalho ativa
|
||||||
|
let workSessionPaused = false
|
||||||
|
const ticket = await ctx.db.get(session.ticketId)
|
||||||
|
if (ticket?.activeSessionId) {
|
||||||
|
const workSession = await ctx.db.get(ticket.activeSessionId)
|
||||||
|
if (workSession && !workSession.stoppedAt) {
|
||||||
|
const workDurationMs = now - workSession.startedAt
|
||||||
|
const sessionType = (workSession.workType ?? "INTERNAL").toUpperCase()
|
||||||
|
const deltaInternal = sessionType === "INTERNAL" ? workDurationMs : 0
|
||||||
|
const deltaExternal = sessionType === "EXTERNAL" ? workDurationMs : 0
|
||||||
|
|
||||||
|
// Encerrar sessao de trabalho
|
||||||
|
await ctx.db.patch(ticket.activeSessionId, {
|
||||||
|
stoppedAt: now,
|
||||||
|
durationMs: workDurationMs,
|
||||||
|
pauseReason: "END_LIVE_CHAT",
|
||||||
|
pauseNote: "Pausa automática ao encerrar chat ao vivo",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Atualizar ticket
|
||||||
|
await ctx.db.patch(session.ticketId, {
|
||||||
|
working: false,
|
||||||
|
activeSessionId: undefined,
|
||||||
|
status: "PAUSED",
|
||||||
|
totalWorkedMs: (ticket.totalWorkedMs ?? 0) + workDurationMs,
|
||||||
|
internalWorkedMs: (ticket.internalWorkedMs ?? 0) + deltaInternal,
|
||||||
|
externalWorkedMs: (ticket.externalWorkedMs ?? 0) + deltaExternal,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Registrar evento de pausa
|
||||||
|
await ctx.db.insert("ticketEvents", {
|
||||||
|
ticketId: session.ticketId,
|
||||||
|
type: "WORK_PAUSED",
|
||||||
|
payload: {
|
||||||
|
actorId,
|
||||||
|
actorName: actor.name,
|
||||||
|
actorAvatar: actor.avatarUrl,
|
||||||
|
sessionId: workSession._id,
|
||||||
|
sessionDurationMs: workDurationMs,
|
||||||
|
workType: sessionType,
|
||||||
|
pauseReason: "END_LIVE_CHAT",
|
||||||
|
pauseReasonLabel: "Chat ao vivo encerrado",
|
||||||
|
pauseNote: "Pausa automática ao encerrar chat ao vivo",
|
||||||
|
source: "live_chat_auto",
|
||||||
|
},
|
||||||
|
createdAt: now,
|
||||||
|
})
|
||||||
|
|
||||||
|
workSessionPaused = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, workSessionPaused }
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2406,19 +2406,21 @@ export const companyOverview = query({
|
||||||
args: {
|
args: {
|
||||||
tenantId: v.string(),
|
tenantId: v.string(),
|
||||||
viewerId: v.id("users"),
|
viewerId: v.id("users"),
|
||||||
companyId: v.id("companies"),
|
companyId: v.optional(v.id("companies")),
|
||||||
range: v.optional(v.string()),
|
range: v.optional(v.string()),
|
||||||
},
|
},
|
||||||
handler: async (ctx, { tenantId, viewerId, companyId, range }) => {
|
handler: async (ctx, { tenantId, viewerId, companyId, range }) => {
|
||||||
const viewer = await requireStaff(ctx, viewerId, tenantId);
|
const viewer = await requireStaff(ctx, viewerId, tenantId);
|
||||||
if (viewer.role === "MANAGER" && viewer.user.companyId && viewer.user.companyId !== companyId) {
|
const scopedCompanyId = resolveScopedCompanyId(viewer, companyId);
|
||||||
throw new ConvexError("Gestores só podem consultar relatórios da própria empresa");
|
|
||||||
}
|
|
||||||
|
|
||||||
const company = await ctx.db.get(companyId);
|
// Buscar dados da empresa selecionada (se houver)
|
||||||
|
let company: Doc<"companies"> | null = null;
|
||||||
|
if (scopedCompanyId) {
|
||||||
|
company = await ctx.db.get(scopedCompanyId);
|
||||||
if (!company || company.tenantId !== tenantId) {
|
if (!company || company.tenantId !== tenantId) {
|
||||||
throw new ConvexError("Empresa não encontrada");
|
throw new ConvexError("Empresa não encontrada");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const normalizedRange = (range ?? "30d").toLowerCase();
|
const normalizedRange = (range ?? "30d").toLowerCase();
|
||||||
const rangeDays = normalizedRange === "90d" ? 90 : normalizedRange === "7d" ? 7 : 30;
|
const rangeDays = normalizedRange === "90d" ? 90 : normalizedRange === "7d" ? 7 : 30;
|
||||||
|
|
@ -2426,19 +2428,34 @@ export const companyOverview = query({
|
||||||
const startMs = now - rangeDays * ONE_DAY_MS;
|
const startMs = now - rangeDays * ONE_DAY_MS;
|
||||||
|
|
||||||
// Limita consultas para evitar OOM em empresas muito grandes
|
// Limita consultas para evitar OOM em empresas muito grandes
|
||||||
const tickets = await ctx.db
|
const tickets = scopedCompanyId
|
||||||
|
? await ctx.db
|
||||||
.query("tickets")
|
.query("tickets")
|
||||||
.withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId))
|
.withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", scopedCompanyId))
|
||||||
|
.take(2000)
|
||||||
|
: await ctx.db
|
||||||
|
.query("tickets")
|
||||||
|
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
||||||
.take(2000);
|
.take(2000);
|
||||||
|
|
||||||
const machines = await ctx.db
|
const machines = scopedCompanyId
|
||||||
|
? await ctx.db
|
||||||
.query("machines")
|
.query("machines")
|
||||||
.withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId))
|
.withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", scopedCompanyId))
|
||||||
|
.take(1000)
|
||||||
|
: await ctx.db
|
||||||
|
.query("machines")
|
||||||
|
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
||||||
.take(1000);
|
.take(1000);
|
||||||
|
|
||||||
const users = await ctx.db
|
const users = scopedCompanyId
|
||||||
|
? await ctx.db
|
||||||
.query("users")
|
.query("users")
|
||||||
.withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId))
|
.withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", scopedCompanyId))
|
||||||
|
.take(500)
|
||||||
|
: await ctx.db
|
||||||
|
.query("users")
|
||||||
|
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
||||||
.take(500);
|
.take(500);
|
||||||
|
|
||||||
const statusCounts = {} as Record<string, number>;
|
const statusCounts = {} as Record<string, number>;
|
||||||
|
|
@ -2534,11 +2551,13 @@ export const companyOverview = query({
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
company: {
|
company: company
|
||||||
|
? {
|
||||||
id: company._id,
|
id: company._id,
|
||||||
name: company.name,
|
name: company.name,
|
||||||
isAvulso: company.isAvulso ?? false,
|
isAvulso: company.isAvulso ?? false,
|
||||||
},
|
}
|
||||||
|
: null,
|
||||||
rangeDays,
|
rangeDays,
|
||||||
generatedAt: now,
|
generatedAt: now,
|
||||||
tickets: {
|
tickets: {
|
||||||
|
|
|
||||||
|
|
@ -535,10 +535,27 @@ export const sendAutomationEmail = action({
|
||||||
ctaUrl: emailProps.ctaUrl,
|
ctaUrl: emailProps.ctaUrl,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const results: Array<{ recipient: string; sent: boolean; error?: string }> = []
|
||||||
|
|
||||||
for (const recipient of recipients) {
|
for (const recipient of recipients) {
|
||||||
|
try {
|
||||||
await sendSmtpMail(smtp, recipient, subject, html)
|
await sendSmtpMail(smtp, recipient, subject, html)
|
||||||
|
results.push({ recipient, sent: true })
|
||||||
|
console.log(`[automation-email] Enviado para ${recipient}`)
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||||
|
results.push({ recipient, sent: false, error: errorMessage })
|
||||||
|
console.error(`[automation-email] Falha ao enviar para ${recipient}: ${errorMessage}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ok: true, sent: recipients.length }
|
const sent = results.filter((r) => r.sent).length
|
||||||
|
const failed = results.filter((r) => !r.sent).length
|
||||||
|
|
||||||
|
if (failed > 0) {
|
||||||
|
console.error(`[automation-email] Resumo: ${sent}/${recipients.length} enviados, ${failed} falhas`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: sent > 0, sent, failed, results }
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ const PAUSE_REASON_LABELS: Record<string, string> = {
|
||||||
NO_CONTACT: "Falta de contato",
|
NO_CONTACT: "Falta de contato",
|
||||||
WAITING_THIRD_PARTY: "Aguardando terceiro",
|
WAITING_THIRD_PARTY: "Aguardando terceiro",
|
||||||
IN_PROCEDURE: "Em procedimento",
|
IN_PROCEDURE: "Em procedimento",
|
||||||
|
END_LIVE_CHAT: "Chat ao vivo encerrado",
|
||||||
[LUNCH_BREAK_REASON]: LUNCH_BREAK_PAUSE_LABEL,
|
[LUNCH_BREAK_REASON]: LUNCH_BREAK_PAUSE_LABEL,
|
||||||
};
|
};
|
||||||
const DATE_ONLY_REGEX = /^\d{4}-\d{2}-\d{2}$/;
|
const DATE_ONLY_REGEX = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
|
|
||||||
473
src/components/admin/slas/company-sla-drawer.tsx
Normal file
473
src/components/admin/slas/company-sla-drawer.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
238
src/components/admin/slas/company-sla-manager.tsx
Normal file
238
src/components/admin/slas/company-sla-manager.tsx
Normal 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 "Adicionar empresa" 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 "{searchQuery}"
|
||||||
|
</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)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,7 @@ import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||||
|
|
||||||
import { CategorySlaManager } from "./category-sla-manager"
|
import { CategorySlaManager } from "./category-sla-manager"
|
||||||
|
import { CompanySlaManager } from "./company-sla-manager"
|
||||||
|
|
||||||
type SlaPolicy = {
|
type SlaPolicy = {
|
||||||
id: string
|
id: string
|
||||||
|
|
@ -331,6 +332,8 @@ export function SlasManager() {
|
||||||
|
|
||||||
<CategorySlaManager />
|
<CategorySlaManager />
|
||||||
|
|
||||||
|
<CompanySlaManager />
|
||||||
|
|
||||||
<Dialog open={Boolean(editingSla)} onOpenChange={(value) => (!value ? setEditingSla(null) : null)}>
|
<Dialog open={Boolean(editingSla)} onOpenChange={(value) => (!value ? setEditingSla(null) : null)}>
|
||||||
<DialogContent className="max-w-2xl">
|
<DialogContent className="max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useQuery } from "convex/react"
|
import { useQuery } from "convex/react"
|
||||||
import { api } from "@/convex/_generated/api"
|
import { api } from "@/convex/_generated/api"
|
||||||
import type { Id } from "@/convex/_generated/dataModel"
|
import type { Id } from "@/convex/_generated/dataModel"
|
||||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||||
import { useAuth } from "@/lib/auth-client"
|
import { useAuth } from "@/lib/auth-client"
|
||||||
|
import { usePersistentCompanyFilter } from "@/lib/use-company-filter"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
|
|
@ -59,7 +60,7 @@ const MACHINE_STATUS_CONFIG = {
|
||||||
export function CompanyReport() {
|
export function CompanyReport() {
|
||||||
const { session, convexUserId, isStaff } = useAuth()
|
const { session, convexUserId, isStaff } = useAuth()
|
||||||
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
||||||
const [selectedCompany, setSelectedCompany] = useState<string>("")
|
const [selectedCompany, setSelectedCompany] = usePersistentCompanyFilter("all")
|
||||||
const [timeRange, setTimeRange] = useState<"7d" | "30d" | "90d">("30d")
|
const [timeRange, setTimeRange] = useState<"7d" | "30d" | "90d">("30d")
|
||||||
|
|
||||||
const companies = useQuery(
|
const companies = useQuery(
|
||||||
|
|
@ -67,28 +68,28 @@ export function CompanyReport() {
|
||||||
isStaff && convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
|
isStaff && convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
|
||||||
) as CompanyRecord[] | undefined
|
) as CompanyRecord[] | undefined
|
||||||
|
|
||||||
const companyOptions = useMemo<SearchableComboboxOption[]>(
|
const companyOptions = useMemo<SearchableComboboxOption[]>(() => {
|
||||||
() =>
|
const base: SearchableComboboxOption[] = [{ value: "all", label: "Todas as empresas" }]
|
||||||
(companies ?? []).map((company) => ({
|
if (!companies || companies.length === 0) {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
const sorted = [...companies].sort((a, b) => a.name.localeCompare(b.name, "pt-BR"))
|
||||||
|
return [
|
||||||
|
base[0],
|
||||||
|
...sorted.map((company) => ({
|
||||||
value: company.id as string,
|
value: company.id as string,
|
||||||
label: company.name,
|
label: company.name,
|
||||||
})),
|
})),
|
||||||
[companies]
|
]
|
||||||
)
|
}, [companies])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedCompany && companyOptions.length > 0) {
|
|
||||||
setSelectedCompany(companyOptions[0]?.value ?? "")
|
|
||||||
}
|
|
||||||
}, [companyOptions, selectedCompany])
|
|
||||||
|
|
||||||
const report = useQuery(
|
const report = useQuery(
|
||||||
api.reports.companyOverview,
|
api.reports.companyOverview,
|
||||||
selectedCompany && convexUserId && isStaff
|
convexUserId && isStaff
|
||||||
? {
|
? {
|
||||||
tenantId,
|
tenantId,
|
||||||
viewerId: convexUserId as Id<"users">,
|
viewerId: convexUserId as Id<"users">,
|
||||||
companyId: selectedCompany as Id<"companies">,
|
companyId: selectedCompany === "all" ? undefined : (selectedCompany as Id<"companies">),
|
||||||
range: timeRange,
|
range: timeRange,
|
||||||
}
|
}
|
||||||
: "skip"
|
: "skip"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue