From e4d0c9579124299d81ba37381b7b5508a081ca2a Mon Sep 17 00:00:00 2001 From: esdrasrenan Date: Sat, 13 Dec 2025 11:26:42 -0300 Subject: [PATCH] =?UTF-8?q?fix:=20automa=C3=A7=C3=B5es=20(gatilhos,=20hist?= =?UTF-8?q?=C3=B3rico)=20e=20chat=20desktop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/desktop/src/chat/ChatWidget.tsx | 55 +++- convex/automations.ts | 234 ++++++++++++++++- convex/automationsEngine.ts | 9 +- convex/tickets.ts | 12 + .../automations/automation-editor-dialog.tsx | 17 +- .../automations/automation-runs-dialog.tsx | 241 ++++++++++++++++++ .../automations/automations-manager.tsx | 155 ++++++++--- 7 files changed, 670 insertions(+), 53 deletions(-) create mode 100644 src/components/automations/automation-runs-dialog.tsx diff --git a/apps/desktop/src/chat/ChatWidget.tsx b/apps/desktop/src/chat/ChatWidget.tsx index 334da67..e4964fc 100644 --- a/apps/desktop/src/chat/ChatWidget.tsx +++ b/apps/desktop/src/chat/ChatWidget.tsx @@ -4,7 +4,15 @@ import { openUrl as openExternal } from "@tauri-apps/plugin-opener" import { invoke } from "@tauri-apps/api/core" import { listen } from "@tauri-apps/api/event" import { Send, X, Loader2, MessageCircle, Paperclip, FileText, Image as ImageIcon, File, User, ChevronUp, Minimize2, Eye, Download, Check } from "lucide-react" -import type { ChatAttachment, ChatMessage, ChatMessagesResponse, NewMessageEvent, SessionEndedEvent, UnreadUpdateEvent } from "./types" +import type { + ChatAttachment, + ChatMessage, + ChatMessagesResponse, + NewMessageEvent, + SessionEndedEvent, + SessionStartedEvent, + UnreadUpdateEvent, +} from "./types" import { getMachineStoreConfig } from "./machineStore" const MAX_MESSAGES_IN_MEMORY = 200 // Limite de mensagens para evitar memory leak @@ -243,6 +251,7 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { const messagesContainerRef = useRef(null) const messageElementsRef = useRef>(new Map()) const prevHasSessionRef = useRef(false) + const retryDelayMsRef = useRef(1_000) const [isAtBottom, setIsAtBottom] = useState(true) const isAtBottomRef = useRef(true) @@ -361,6 +370,7 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { } setError(null) + retryDelayMsRef.current = 1_000 } catch (err) { const message = err instanceof Error ? err.message : String(err) setError(message || "Erro ao carregar mensagens.") @@ -369,6 +379,22 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { } }, [ensureConfig, ticketId, ticketRef]) + // Auto-retry leve quando houver erro (evita ficar "morto" após falha transiente). + useEffect(() => { + if (!error) return + + const delayMs = retryDelayMsRef.current + const timeout = window.setTimeout(() => { + loadMessages() + }, delayMs) + + retryDelayMsRef.current = Math.min(retryDelayMsRef.current * 2, 30_000) + + return () => { + window.clearTimeout(timeout) + } + }, [error, loadMessages]) + const markUnreadMessagesRead = useCallback(async () => { if (unreadCount <= 0) return const ids = getUnreadAgentMessageIds(messages, unreadCount) @@ -420,6 +446,24 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { } }, [ticketId, loadMessages]) + // Recarregar quando uma nova sessão iniciar (usuário pode estar com o chat aberto em "Offline") + useEffect(() => { + let unlisten: (() => void) | null = null + listen("raven://chat/session-started", (event) => { + if (event.payload?.session?.ticketId === ticketId) { + loadMessages() + } + }) + .then((u) => { + unlisten = u + }) + .catch((err) => console.error("Falha ao registrar listener session-started:", err)) + + return () => { + unlisten?.() + } + }, [ticketId, loadMessages]) + // Atualizar contador em tempo real (inclui decremento quando a máquina marca como lida) useEffect(() => { let unlisten: (() => void) | null = null @@ -697,10 +741,15 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { // Mostrar chip compacto de erro (compativel com janela minimizada) return (
-
+
+
) } diff --git a/convex/automations.ts b/convex/automations.ts index e85de17..d971d85 100644 --- a/convex/automations.ts +++ b/convex/automations.ts @@ -1,4 +1,5 @@ import { ConvexError, v } from "convex/values" +import { paginationOptsValidator } from "convex/server" import { api } from "./_generated/api" import type { Doc, Id } from "./_generated/dataModel" @@ -22,7 +23,18 @@ type AutomationAction = | { type: "SET_CHAT_ENABLED"; enabled: boolean } | { type: "ADD_INTERNAL_COMMENT"; body: string } -const TRIGGERS: AutomationTrigger[] = ["TICKET_CREATED", "STATUS_CHANGED", "COMMENT_ADDED", "TICKET_RESOLVED"] +type AutomationRunStatus = "SUCCESS" | "SKIPPED" | "ERROR" + +type AppliedAction = { type: string; details?: Record } + +const TRIGGERS: AutomationTrigger[] = [ + "TICKET_CREATED", + "STATUS_CHANGED", + "PRIORITY_CHANGED", + "QUEUE_CHANGED", + "COMMENT_ADDED", + "TICKET_RESOLVED", +] const TIMINGS = ["IMMEDIATE", "DELAYED"] as const const VISIT_QUEUE_KEYWORDS = ["visita", "visitas", "in loco", "laboratório", "laboratorio", "lab"] @@ -62,6 +74,58 @@ function parseConditions(raw: unknown): AutomationConditionGroup | null { } } +function parseRecord(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null + return value as Record +} + +function parseAction(value: unknown): AutomationAction | null { + const record = parseRecord(value) + if (!record) return null + const type = typeof record.type === "string" ? record.type.trim().toUpperCase() : "" + + if (type === "SET_PRIORITY") { + const priority = typeof record.priority === "string" ? record.priority.trim().toUpperCase() : "" + if (!priority) return null + return { type: "SET_PRIORITY", priority } + } + + if (type === "MOVE_QUEUE") { + const queueId = typeof record.queueId === "string" ? record.queueId : "" + if (!queueId) return null + return { type: "MOVE_QUEUE", queueId: queueId as Id<"queues"> } + } + + if (type === "ASSIGN_TO") { + const assigneeId = typeof record.assigneeId === "string" ? record.assigneeId : "" + if (!assigneeId) return null + return { type: "ASSIGN_TO", assigneeId: assigneeId as Id<"users"> } + } + + if (type === "SET_FORM_TEMPLATE") { + const formTemplateRaw = record.formTemplate + if (formTemplateRaw === null || formTemplateRaw === undefined) { + return { type: "SET_FORM_TEMPLATE", formTemplate: null } + } + if (typeof formTemplateRaw !== "string") return null + const formTemplate = formTemplateRaw.trim() + return { type: "SET_FORM_TEMPLATE", formTemplate: formTemplate.length > 0 ? formTemplate : null } + } + + if (type === "SET_CHAT_ENABLED") { + if (typeof record.enabled !== "boolean") return null + return { type: "SET_CHAT_ENABLED", enabled: record.enabled } + } + + if (type === "ADD_INTERNAL_COMMENT") { + const body = typeof record.body === "string" ? record.body : "" + if (!body.trim()) return null + return { type: "ADD_INTERNAL_COMMENT", body } + } + + return null +} + function parseActions(raw: unknown): AutomationAction[] { if (!Array.isArray(raw)) { throw new ConvexError("Ações inválidas") @@ -69,7 +133,11 @@ function parseActions(raw: unknown): AutomationAction[] { if (raw.length === 0) { throw new ConvexError("Adicione pelo menos uma ação") } - return raw as AutomationAction[] + const parsed = raw.map(parseAction).filter((item): item is AutomationAction => Boolean(item)) + if (parsed.length === 0) { + throw new ConvexError("Ações inválidas") + } + return parsed } function mapAutomation(doc: Doc<"ticketAutomations">) { @@ -89,6 +157,30 @@ function mapAutomation(doc: Doc<"ticketAutomations">) { } } +function normalizeRunStatus(value: unknown): AutomationRunStatus { + const status = typeof value === "string" ? value.toUpperCase() : "" + if (status === "SUCCESS" || status === "SKIPPED" || status === "ERROR") return status + return "ERROR" +} + +function normalizeAppliedActions(value: unknown): AppliedAction[] | null { + if (!Array.isArray(value)) return null + const parsed: AppliedAction[] = [] + for (const entry of value) { + const record = parseRecord(entry) + if (!record) continue + const type = typeof record.type === "string" ? record.type : null + if (!type) continue + const detailsRaw = record.details + const details = + detailsRaw && typeof detailsRaw === "object" && !Array.isArray(detailsRaw) + ? (detailsRaw as Record) + : undefined + parsed.push({ type, details }) + } + return parsed.length > 0 ? parsed : [] +} + export const list = query({ args: { tenantId: v.string(), viewerId: v.id("users") }, handler: async (ctx, { tenantId, viewerId }) => { @@ -101,6 +193,104 @@ export const list = query({ }, }) +export const listRunsPaginated = query({ + args: { + tenantId: v.string(), + viewerId: v.id("users"), + automationId: v.optional(v.id("ticketAutomations")), + status: v.optional(v.string()), + paginationOpts: paginationOptsValidator, + }, + handler: async (ctx, args) => { + await requireInternal(ctx, args.viewerId, args.tenantId) + + const statusFilter = args.status ? args.status.trim().toUpperCase() : null + const automationId = args.automationId ?? null + + let baseQuery + if (automationId) { + const automation = await ctx.db.get(automationId) + if (!automation || automation.tenantId !== args.tenantId) { + throw new ConvexError("Automação não encontrada") + } + baseQuery = ctx.db + .query("ticketAutomationRuns") + .withIndex("by_automation_created", (q) => q.eq("automationId", automationId)) + } else { + baseQuery = ctx.db + .query("ticketAutomationRuns") + .withIndex("by_tenant_created", (q) => q.eq("tenantId", args.tenantId)) + } + + const filteredQuery = + statusFilter && statusFilter.length > 0 + ? baseQuery.filter((q) => q.eq(q.field("status"), statusFilter)) + : baseQuery + + const paginationResult = await filteredQuery.order("desc").paginate(args.paginationOpts) + + const ticketIds = new Set() + const automationIds = new Set() + for (const run of paginationResult.page) { + // Hardening: em caso de drift, garante que só expomos o tenant correto. + if (run.tenantId !== args.tenantId) continue + ticketIds.add(String(run.ticketId)) + automationIds.add(String(run.automationId)) + } + + const ticketDocs = new Map | null>() + const automationDocs = new Map | null>() + + await Promise.all( + Array.from(ticketIds).map(async (id) => { + const doc = (await ctx.db.get(id as Id<"tickets">)) as Doc<"tickets"> | null + ticketDocs.set(id, doc) + }) + ) + await Promise.all( + Array.from(automationIds).map(async (id) => { + const doc = (await ctx.db.get(id as Id<"ticketAutomations">)) as Doc<"ticketAutomations"> | null + automationDocs.set(id, doc) + }) + ) + + const page = paginationResult.page + .filter((run) => run.tenantId === args.tenantId) + .map((run) => { + const ticket = ticketDocs.get(String(run.ticketId)) ?? null + const automation = automationDocs.get(String(run.automationId)) ?? null + return { + id: run._id, + createdAt: run.createdAt, + status: normalizeRunStatus(run.status), + matched: Boolean(run.matched), + eventType: run.eventType, + error: run.error ?? null, + actionsApplied: normalizeAppliedActions(run.actionsApplied ?? null), + ticket: ticket + ? { + id: ticket._id, + reference: ticket.reference ?? 0, + subject: ticket.subject ?? "", + } + : null, + automation: automation + ? { + id: automation._id, + name: automation.name, + } + : null, + } + }) + + return { + page, + isDone: paginationResult.isDone, + continueCursor: paginationResult.continueCursor, + } + }, +}) + export const getById = query({ args: { tenantId: v.string(), viewerId: v.id("users"), automationId: v.id("ticketAutomations") }, handler: async (ctx, { tenantId, viewerId, automationId }) => { @@ -318,6 +508,7 @@ async function executeAutomationInternal( matched: false, createdAt: now, }) + await enforceAutomationRunsRetention(ctx, automation._id, now) return } @@ -338,6 +529,7 @@ async function executeAutomationInternal( actionsApplied: applied, createdAt: now, }) + await enforceAutomationRunsRetention(ctx, automation._id, now) } catch (error) { const message = error instanceof Error ? error.message : String(error) await ctx.db.insert("ticketAutomationRuns", { @@ -350,6 +542,44 @@ async function executeAutomationInternal( error: message, createdAt: now, }) + await enforceAutomationRunsRetention(ctx, automation._id, now) + } +} + +const RUNS_RETENTION_MAX_PER_AUTOMATION = 2000 +const RUNS_RETENTION_MAX_AGE_DAYS = 90 +const RUNS_RETENTION_DELETE_BATCH = 250 + +async function enforceAutomationRunsRetention(ctx: MutationCtx, automationId: Id<"ticketAutomations">, now: number) { + const cutoff = now - RUNS_RETENTION_MAX_AGE_DAYS * 24 * 60 * 60 * 1000 + + const runs = await ctx.db + .query("ticketAutomationRuns") + .withIndex("by_automation_created", (q) => q.eq("automationId", automationId)) + .order("desc") + .take(RUNS_RETENTION_MAX_PER_AUTOMATION + RUNS_RETENTION_DELETE_BATCH) + + if (runs.length <= RUNS_RETENTION_MAX_PER_AUTOMATION) { + // Mesmo com baixo volume, ainda remove registros antigos. + const expired = runs.filter((run) => (run.createdAt ?? 0) < cutoff).slice(0, RUNS_RETENTION_DELETE_BATCH) + for (const run of expired) { + await ctx.db.delete(run._id) + } + return + } + + const candidates = runs + .slice(RUNS_RETENTION_MAX_PER_AUTOMATION) + .concat(runs.filter((run) => (run.createdAt ?? 0) < cutoff)) + + const unique = new Map>() + for (const run of candidates) { + unique.set(String(run._id), run._id) + if (unique.size >= RUNS_RETENTION_DELETE_BATCH) break + } + + for (const id of unique.values()) { + await ctx.db.delete(id) } } diff --git a/convex/automationsEngine.ts b/convex/automationsEngine.ts index 9b1d6cf..1648dcb 100644 --- a/convex/automationsEngine.ts +++ b/convex/automationsEngine.ts @@ -1,6 +1,12 @@ import type { Doc } from "./_generated/dataModel" -export type AutomationTrigger = "TICKET_CREATED" | "STATUS_CHANGED" | "COMMENT_ADDED" | "TICKET_RESOLVED" +export type AutomationTrigger = + | "TICKET_CREATED" + | "STATUS_CHANGED" + | "PRIORITY_CHANGED" + | "QUEUE_CHANGED" + | "COMMENT_ADDED" + | "TICKET_RESOLVED" export type AutomationConditionOperator = "AND" | "OR" @@ -130,4 +136,3 @@ export function evaluateAutomationConditions( return op === "OR" ? results.some(Boolean) : results.every(Boolean) } - diff --git a/convex/tickets.ts b/convex/tickets.ts index af52c82..93865f9 100644 --- a/convex/tickets.ts +++ b/convex/tickets.ts @@ -3930,6 +3930,12 @@ export const changeQueue = mutation({ payload: { queueId, queueName, actorId }, createdAt: now, }) + + await runTicketAutomationsForEvent(ctx, { + tenantId: ticketDoc.tenantId, + ticketId, + eventType: "QUEUE_CHANGED", + }) }, }); @@ -4081,6 +4087,12 @@ export const updatePriority = mutation({ payload: { to: priority, toLabel: pt[priority] ?? priority, actorId }, createdAt: now, }); + + await runTicketAutomationsForEvent(ctx, { + tenantId: ticket.tenantId, + ticketId, + eventType: "PRIORITY_CHANGED", + }) }, }); diff --git a/src/components/automations/automation-editor-dialog.tsx b/src/components/automations/automation-editor-dialog.tsx index 9d18ca4..b6f82bc 100644 --- a/src/components/automations/automation-editor-dialog.tsx +++ b/src/components/automations/automation-editor-dialog.tsx @@ -90,6 +90,8 @@ const CHANNELS = [ const TRIGGERS = [ { value: "TICKET_CREATED", label: "Abertura" }, { value: "STATUS_CHANGED", label: "Alteração de status" }, + { value: "PRIORITY_CHANGED", label: "Alteração de prioridade" }, + { value: "QUEUE_CHANGED", label: "Alteração de fila" }, { value: "COMMENT_ADDED", label: "Inclusão de comentário" }, { value: "TICKET_RESOLVED", label: "Finalização" }, ] @@ -320,12 +322,16 @@ export function AutomationEditorDialog({ return ( - +
{automation ? "Editar automação" : "Nova automação"}
- Ativa - + Ativa +
@@ -654,7 +660,7 @@ export function AutomationEditorDialog({ variant="ghost" size="icon" onClick={() => handleRemoveCondition(c.id)} - className="mt-6 h-8 w-8 text-slate-500 hover:bg-white" + className="mt-6 h-8 w-8 text-slate-500 hover:bg-red-50 hover:text-red-700" title="Remover" > @@ -799,6 +805,7 @@ export function AutomationEditorDialog({ onCheckedChange={(checked) => setActions((prev) => prev.map((item) => (item.id === a.id ? { ...item, enabled: checked } : item))) } + className="data-[state=checked]:bg-black data-[state=unchecked]:bg-slate-300" />
) : ( @@ -818,7 +825,7 @@ export function AutomationEditorDialog({ variant="ghost" size="icon" onClick={() => handleRemoveAction(a.id)} - className="mt-6 h-8 w-8 text-slate-500 hover:bg-slate-50" + className="mt-6 h-8 w-8 text-slate-500 hover:bg-red-50 hover:text-red-700" title="Remover" > diff --git a/src/components/automations/automation-runs-dialog.tsx b/src/components/automations/automation-runs-dialog.tsx new file mode 100644 index 0000000..8c0ac7b --- /dev/null +++ b/src/components/automations/automation-runs-dialog.tsx @@ -0,0 +1,241 @@ +"use client" + +import { useMemo, useState } from "react" +import { usePaginatedQuery } from "convex/react" +import { toast } from "sonner" + +import { api } from "@/convex/_generated/api" +import type { Id } from "@/convex/_generated/dataModel" +import { useAuth } from "@/lib/auth-client" +import { DEFAULT_TENANT_ID } from "@/lib/constants" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { DialogClose, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" + +type AutomationRunRow = { + id: Id<"ticketAutomationRuns"> + createdAt: number + status: "SUCCESS" | "SKIPPED" | "ERROR" + matched: boolean + eventType: string + error: string | null + actionsApplied: Array<{ type: string; details?: Record }> | null + ticket: { id: Id<"tickets">; reference: number; subject: string } | null + automation: { id: Id<"ticketAutomations">; name: string } | null +} + +const EVENT_LABELS: Record = { + TICKET_CREATED: "Abertura", + STATUS_CHANGED: "Status", + PRIORITY_CHANGED: "Prioridade", + QUEUE_CHANGED: "Fila", + COMMENT_ADDED: "Comentário", + TICKET_RESOLVED: "Finalização", +} + +function formatDateTime(timestamp: number) { + return new Date(timestamp).toLocaleString("pt-BR") +} + +function statusBadge(status: AutomationRunRow["status"]) { + if (status === "SUCCESS") return { label: "Sucesso", variant: "secondary" as const } + if (status === "SKIPPED") return { label: "Ignorada", variant: "outline" as const } + return { label: "Erro", variant: "destructive" as const } +} + +export function AutomationRunsDialog({ + open, + automationId, + automationName, + onClose, +}: { + open: boolean + automationId: Id<"ticketAutomations"> | null + automationName: string | null + onClose: () => void +}) { + const { session, convexUserId } = useAuth() + const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID + + const [statusFilter, setStatusFilter] = useState<"all" | "SUCCESS" | "SKIPPED" | "ERROR">("all") + + const args = useMemo(() => { + if (!open) return "skip" as const + if (!convexUserId) return "skip" as const + if (!automationId) return "skip" as const + return { + tenantId, + viewerId: convexUserId as Id<"users">, + automationId, + status: statusFilter === "all" ? undefined : statusFilter, + } + }, [open, convexUserId, automationId, tenantId, statusFilter]) + + const { results, status, loadMore } = usePaginatedQuery(api.automations.listRunsPaginated, args, { + initialNumItems: 20, + }) + + const isLoadingFirstPage = status === "LoadingFirstPage" + const isLoadingMore = status === "LoadingMore" + const canLoadMore = status === "CanLoadMore" + + const rows = (results ?? []) as unknown as AutomationRunRow[] + + return ( + + +
+
+ Histórico de execuções + {automationName ? ( +

{automationName}

+ ) : null} +
+
+ + + + +
+
+
+ +
+
+ + + + + + + + + + + + + Data + + + Ticket + + + Evento + + + Resultado + + + Ações + + + Detalhes + + + + + {isLoadingFirstPage ? ( + Array.from({ length: 6 }).map((_, i) => ( + + + + + + + + + )) + ) : rows.length === 0 ? ( + + + Nenhuma execução encontrada. + + + ) : ( + rows.map((run) => { + const badge = statusBadge(run.status) + const eventLabel = EVENT_LABELS[run.eventType] ?? run.eventType + const actionsCount = run.actionsApplied?.length ?? 0 + const details = + run.status === "ERROR" + ? run.error ?? "Erro desconhecido" + : run.status === "SKIPPED" + ? run.matched + ? "Ignorada" + : "Condições não atendidas" + : actionsCount > 0 + ? `Aplicou ${actionsCount} ação(ões)` + : "Sem alterações" + + return ( + + {formatDateTime(run.createdAt)} + + {run.ticket ? ( +
+
#{run.ticket.reference}
+
+ {run.ticket.subject || "—"} +
+
+ ) : ( + "—" + )} +
+ {eventLabel} + + + {badge.label} + + + {actionsCount} + + {details} + +
+ ) + }) + )} +
+
+
+ + {canLoadMore ? ( +
+ +
+ ) : null} +
+
+ ) +} + diff --git a/src/components/automations/automations-manager.tsx b/src/components/automations/automations-manager.tsx index 70a4a2e..67958dc 100644 --- a/src/components/automations/automations-manager.tsx +++ b/src/components/automations/automations-manager.tsx @@ -2,7 +2,7 @@ import { useMemo, useState } from "react" import { useMutation, useQuery } from "convex/react" -import { MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react" +import { History, MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react" import { toast } from "sonner" import { api } from "@/convex/_generated/api" @@ -18,8 +18,10 @@ import { Input } from "@/components/ui/input" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Skeleton } from "@/components/ui/skeleton" import { Switch } from "@/components/ui/switch" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { AutomationEditorDialog } from "@/components/automations/automation-editor-dialog" +import { AutomationRunsDialog } from "@/components/automations/automation-runs-dialog" type AutomationRow = { id: Id<"ticketAutomations"> @@ -38,6 +40,8 @@ type AutomationRow = { const TRIGGER_LABELS: Record = { TICKET_CREATED: "Abertura", STATUS_CHANGED: "Alteração de status", + PRIORITY_CHANGED: "Alteração de prioridade", + QUEUE_CHANGED: "Alteração de fila", COMMENT_ADDED: "Inclusão de comentário", TICKET_RESOLVED: "Finalização", } @@ -51,6 +55,16 @@ function formatLastRun(timestamp: number | null) { return new Date(timestamp).toLocaleString("pt-BR") } +function formatConditionsSummary(conditions: unknown | null) { + if (!conditions || typeof conditions !== "object" || Array.isArray(conditions)) return "—" + const group = conditions as { op?: unknown; conditions?: unknown } + const list = Array.isArray(group.conditions) ? group.conditions : [] + if (list.length === 0) return "—" + const op = typeof group.op === "string" ? group.op.toUpperCase() : "AND" + const opLabel = op === "OR" ? "OU" : "E" + return `${list.length} (${opLabel})` +} + export function AutomationsManager() { const { session, convexUserId } = useAuth() const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID @@ -61,6 +75,8 @@ export function AutomationsManager() { const [editorOpen, setEditorOpen] = useState(false) const [editing, setEditing] = useState(null) + const [runsOpen, setRunsOpen] = useState(false) + const [runsAutomation, setRunsAutomation] = useState(null) const list = useQuery( api.automations.list, @@ -94,6 +110,11 @@ export function AutomationsManager() { setEditorOpen(true) } + const handleOpenRuns = (row: AutomationRow) => { + setRunsAutomation(row) + setRunsOpen(true) + } + const handleToggle = async (row: AutomationRow, nextEnabled: boolean) => { if (!convexUserId) return try { @@ -147,7 +168,9 @@ export function AutomationsManager() { Todos Abertura - Alteração + Status + Prioridade + Fila Comentário Finalização @@ -178,6 +201,23 @@ export function AutomationsManager() { + { + setRunsOpen(next) + if (!next) { + setRunsAutomation(null) + } + }} + > + setRunsOpen(false)} + /> + + {!list ? (
{Array.from({ length: 6 }).map((_, i) => ( @@ -190,76 +230,109 @@ export function AutomationsManager() {

) : (
- - - - - - - - - - - - - +
NomeQuandoAçõesExecuçõesÚltimaStatus
+ + + + + + + + + + + + + + Nome + + + Quando + + + Condições + + + Ações + + + Execuções + + + Última + + + Status + + + Ações + + + + {filtered.map((row) => ( - - - - - - - - - + + ))} - -
{row.name} -
+ + + {row.name} + + +
{triggerLabel(row.trigger)} {row.timing === "DELAYED" && row.delayMs ? ( - + +{Math.round(row.delayMs / 60000)}m - + ) : null}
-
{row.actions?.length ?? 0}{row.runCount ?? 0}{formatLastRun(row.lastRunAt)} -
+ + + {formatConditionsSummary(row.conditions)} + + {row.actions?.length ?? 0} + {row.runCount ?? 0} + {formatLastRun(row.lastRunAt)} + +
handleToggle(row, checked)} + className="data-[state=checked]:bg-black data-[state=unchecked]:bg-slate-300" /> - {row.enabled ? "Ativa" : "Inativa"} + {row.enabled ? "Ativa" : "Inativa"}
-
+ + - - + + handleOpenRuns(row)} className="gap-2"> + + Histórico + handleEdit(row)} className="gap-2"> Editar - handleDelete(row)} - className="gap-2 text-red-600 focus:text-red-600" - > + handleDelete(row)} className="gap-2"> Excluir -
+ +
)} ) } -