diff --git a/convex/liveChat.ts b/convex/liveChat.ts index 3a5ceec..0c700ae 100644 --- a/convex/liveChat.ts +++ b/convex/liveChat.ts @@ -168,7 +168,40 @@ export const startSession = mutation({ 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, }) - 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 } }, }) diff --git a/convex/reports.ts b/convex/reports.ts index 5c18044..2f16dbe 100644 --- a/convex/reports.ts +++ b/convex/reports.ts @@ -2406,18 +2406,20 @@ export const companyOverview = query({ args: { tenantId: v.string(), viewerId: v.id("users"), - companyId: v.id("companies"), + companyId: v.optional(v.id("companies")), range: v.optional(v.string()), }, handler: async (ctx, { tenantId, viewerId, companyId, range }) => { const viewer = await requireStaff(ctx, viewerId, tenantId); - if (viewer.role === "MANAGER" && viewer.user.companyId && viewer.user.companyId !== companyId) { - throw new ConvexError("Gestores só podem consultar relatórios da própria empresa"); - } + const scopedCompanyId = resolveScopedCompanyId(viewer, companyId); - const company = await ctx.db.get(companyId); - if (!company || company.tenantId !== tenantId) { - throw new ConvexError("Empresa não encontrada"); + // 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) { + throw new ConvexError("Empresa não encontrada"); + } } const normalizedRange = (range ?? "30d").toLowerCase(); @@ -2426,20 +2428,35 @@ export const companyOverview = query({ const startMs = now - rangeDays * ONE_DAY_MS; // Limita consultas para evitar OOM em empresas muito grandes - const tickets = await ctx.db - .query("tickets") - .withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId)) - .take(2000); + const tickets = scopedCompanyId + ? await ctx.db + .query("tickets") + .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); - const machines = await ctx.db - .query("machines") - .withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId)) - .take(1000); + const machines = scopedCompanyId + ? await ctx.db + .query("machines") + .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); - const users = await ctx.db - .query("users") - .withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId)) - .take(500); + const users = scopedCompanyId + ? await ctx.db + .query("users") + .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); const statusCounts = {} as Record; const priorityCounts = {} as Record; @@ -2534,11 +2551,13 @@ export const companyOverview = query({ }); return { - company: { - id: company._id, - name: company.name, - isAvulso: company.isAvulso ?? false, - }, + company: company + ? { + id: company._id, + name: company.name, + isAvulso: company.isAvulso ?? false, + } + : null, rangeDays, generatedAt: now, tickets: { diff --git a/convex/ticketNotifications.ts b/convex/ticketNotifications.ts index 5b234fd..50f2246 100644 --- a/convex/ticketNotifications.ts +++ b/convex/ticketNotifications.ts @@ -535,10 +535,27 @@ export const sendAutomationEmail = action({ ctaUrl: emailProps.ctaUrl, }) + const results: Array<{ recipient: string; sent: boolean; error?: string }> = [] + for (const recipient of recipients) { - await sendSmtpMail(smtp, recipient, subject, html) + try { + 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 } }, }) diff --git a/convex/tickets.ts b/convex/tickets.ts index 8ab5ebe..27922cc 100644 --- a/convex/tickets.ts +++ b/convex/tickets.ts @@ -38,6 +38,7 @@ const PAUSE_REASON_LABELS: Record = { NO_CONTACT: "Falta de contato", WAITING_THIRD_PARTY: "Aguardando terceiro", IN_PROCEDURE: "Em procedimento", + END_LIVE_CHAT: "Chat ao vivo encerrado", [LUNCH_BREAK_REASON]: LUNCH_BREAK_PAUSE_LABEL, }; const DATE_ONLY_REGEX = /^\d{4}-\d{2}-\d{2}$/; 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)}> diff --git a/src/components/reports/company-report.tsx b/src/components/reports/company-report.tsx index 6480e51..abef3a0 100644 --- a/src/components/reports/company-report.tsx +++ b/src/components/reports/company-report.tsx @@ -1,12 +1,13 @@ "use client" -import { useEffect, useMemo, useState } from "react" +import { useMemo, useState } from "react" import Link from "next/link" import { useQuery } from "convex/react" import { api } from "@/convex/_generated/api" import type { Id } from "@/convex/_generated/dataModel" import { DEFAULT_TENANT_ID } from "@/lib/constants" import { useAuth } from "@/lib/auth-client" +import { usePersistentCompanyFilter } from "@/lib/use-company-filter" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Skeleton } from "@/components/ui/skeleton" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" @@ -59,7 +60,7 @@ const MACHINE_STATUS_CONFIG = { export function CompanyReport() { const { session, convexUserId, isStaff } = useAuth() const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID - const [selectedCompany, setSelectedCompany] = useState("") + const [selectedCompany, setSelectedCompany] = usePersistentCompanyFilter("all") const [timeRange, setTimeRange] = useState<"7d" | "30d" | "90d">("30d") const companies = useQuery( @@ -67,28 +68,28 @@ export function CompanyReport() { isStaff && convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip" ) as CompanyRecord[] | undefined - const companyOptions = useMemo( - () => - (companies ?? []).map((company) => ({ + const companyOptions = useMemo(() => { + const base: SearchableComboboxOption[] = [{ value: "all", label: "Todas as empresas" }] + 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, label: company.name, })), - [companies] - ) - - useEffect(() => { - if (!selectedCompany && companyOptions.length > 0) { - setSelectedCompany(companyOptions[0]?.value ?? "") - } - }, [companyOptions, selectedCompany]) + ] + }, [companies]) const report = useQuery( api.reports.companyOverview, - selectedCompany && convexUserId && isStaff + convexUserId && isStaff ? { tenantId, viewerId: convexUserId as Id<"users">, - companyId: selectedCompany as Id<"companies">, + companyId: selectedCompany === "all" ? undefined : (selectedCompany as Id<"companies">), range: timeRange, } : "skip"