fix: automações (gatilhos, histórico) e chat desktop
This commit is contained in:
parent
8ab510bfe9
commit
e4d0c95791
7 changed files with 670 additions and 53 deletions
|
|
@ -4,7 +4,15 @@ import { openUrl as openExternal } from "@tauri-apps/plugin-opener"
|
||||||
import { invoke } from "@tauri-apps/api/core"
|
import { invoke } from "@tauri-apps/api/core"
|
||||||
import { listen } from "@tauri-apps/api/event"
|
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 { 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"
|
import { getMachineStoreConfig } from "./machineStore"
|
||||||
|
|
||||||
const MAX_MESSAGES_IN_MEMORY = 200 // Limite de mensagens para evitar memory leak
|
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<HTMLDivElement>(null)
|
const messagesContainerRef = useRef<HTMLDivElement>(null)
|
||||||
const messageElementsRef = useRef<Map<string, HTMLDivElement>>(new Map())
|
const messageElementsRef = useRef<Map<string, HTMLDivElement>>(new Map())
|
||||||
const prevHasSessionRef = useRef<boolean>(false)
|
const prevHasSessionRef = useRef<boolean>(false)
|
||||||
|
const retryDelayMsRef = useRef<number>(1_000)
|
||||||
|
|
||||||
const [isAtBottom, setIsAtBottom] = useState(true)
|
const [isAtBottom, setIsAtBottom] = useState(true)
|
||||||
const isAtBottomRef = useRef(true)
|
const isAtBottomRef = useRef(true)
|
||||||
|
|
@ -361,6 +370,7 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
setError(null)
|
setError(null)
|
||||||
|
retryDelayMsRef.current = 1_000
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
setError(message || "Erro ao carregar mensagens.")
|
setError(message || "Erro ao carregar mensagens.")
|
||||||
|
|
@ -369,6 +379,22 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
|
||||||
}
|
}
|
||||||
}, [ensureConfig, ticketId, ticketRef])
|
}, [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 () => {
|
const markUnreadMessagesRead = useCallback(async () => {
|
||||||
if (unreadCount <= 0) return
|
if (unreadCount <= 0) return
|
||||||
const ids = getUnreadAgentMessageIds(messages, unreadCount)
|
const ids = getUnreadAgentMessageIds(messages, unreadCount)
|
||||||
|
|
@ -420,6 +446,24 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
|
||||||
}
|
}
|
||||||
}, [ticketId, loadMessages])
|
}, [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<SessionStartedEvent>("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)
|
// Atualizar contador em tempo real (inclui decremento quando a máquina marca como lida)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let unlisten: (() => void) | null = null
|
let unlisten: (() => void) | null = null
|
||||||
|
|
@ -697,10 +741,15 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
|
||||||
// Mostrar chip compacto de erro (compativel com janela minimizada)
|
// Mostrar chip compacto de erro (compativel com janela minimizada)
|
||||||
return (
|
return (
|
||||||
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent p-2">
|
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent p-2">
|
||||||
<div className="pointer-events-auto flex items-center gap-2 rounded-full bg-red-100 px-4 py-2 text-red-600 shadow-lg">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => loadMessages()}
|
||||||
|
className="pointer-events-auto flex items-center gap-2 rounded-full bg-red-100 px-4 py-2 text-red-600 shadow-lg transition hover:bg-red-200/60"
|
||||||
|
title="Tentar novamente"
|
||||||
|
>
|
||||||
<X className="size-4" />
|
<X className="size-4" />
|
||||||
<span className="text-sm font-medium">Erro no chat</span>
|
<span className="text-sm font-medium">Erro no chat</span>
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { ConvexError, v } from "convex/values"
|
import { ConvexError, v } from "convex/values"
|
||||||
|
import { paginationOptsValidator } from "convex/server"
|
||||||
|
|
||||||
import { api } from "./_generated/api"
|
import { api } from "./_generated/api"
|
||||||
import type { Doc, Id } from "./_generated/dataModel"
|
import type { Doc, Id } from "./_generated/dataModel"
|
||||||
|
|
@ -22,7 +23,18 @@ type AutomationAction =
|
||||||
| { type: "SET_CHAT_ENABLED"; enabled: boolean }
|
| { type: "SET_CHAT_ENABLED"; enabled: boolean }
|
||||||
| { type: "ADD_INTERNAL_COMMENT"; body: string }
|
| { 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<string, unknown> }
|
||||||
|
|
||||||
|
const TRIGGERS: AutomationTrigger[] = [
|
||||||
|
"TICKET_CREATED",
|
||||||
|
"STATUS_CHANGED",
|
||||||
|
"PRIORITY_CHANGED",
|
||||||
|
"QUEUE_CHANGED",
|
||||||
|
"COMMENT_ADDED",
|
||||||
|
"TICKET_RESOLVED",
|
||||||
|
]
|
||||||
const TIMINGS = ["IMMEDIATE", "DELAYED"] as const
|
const TIMINGS = ["IMMEDIATE", "DELAYED"] as const
|
||||||
|
|
||||||
const VISIT_QUEUE_KEYWORDS = ["visita", "visitas", "in loco", "laboratório", "laboratorio", "lab"]
|
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<string, unknown> | null {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null
|
||||||
|
return value as Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
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[] {
|
function parseActions(raw: unknown): AutomationAction[] {
|
||||||
if (!Array.isArray(raw)) {
|
if (!Array.isArray(raw)) {
|
||||||
throw new ConvexError("Ações inválidas")
|
throw new ConvexError("Ações inválidas")
|
||||||
|
|
@ -69,7 +133,11 @@ function parseActions(raw: unknown): AutomationAction[] {
|
||||||
if (raw.length === 0) {
|
if (raw.length === 0) {
|
||||||
throw new ConvexError("Adicione pelo menos uma ação")
|
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">) {
|
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<string, unknown>)
|
||||||
|
: undefined
|
||||||
|
parsed.push({ type, details })
|
||||||
|
}
|
||||||
|
return parsed.length > 0 ? parsed : []
|
||||||
|
}
|
||||||
|
|
||||||
export const list = query({
|
export const list = query({
|
||||||
args: { tenantId: v.string(), viewerId: v.id("users") },
|
args: { tenantId: v.string(), viewerId: v.id("users") },
|
||||||
handler: async (ctx, { tenantId, viewerId }) => {
|
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<string>()
|
||||||
|
const automationIds = new Set<string>()
|
||||||
|
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<string, Doc<"tickets"> | null>()
|
||||||
|
const automationDocs = new Map<string, Doc<"ticketAutomations"> | 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({
|
export const getById = query({
|
||||||
args: { tenantId: v.string(), viewerId: v.id("users"), automationId: v.id("ticketAutomations") },
|
args: { tenantId: v.string(), viewerId: v.id("users"), automationId: v.id("ticketAutomations") },
|
||||||
handler: async (ctx, { tenantId, viewerId, automationId }) => {
|
handler: async (ctx, { tenantId, viewerId, automationId }) => {
|
||||||
|
|
@ -318,6 +508,7 @@ async function executeAutomationInternal(
|
||||||
matched: false,
|
matched: false,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
})
|
})
|
||||||
|
await enforceAutomationRunsRetention(ctx, automation._id, now)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -338,6 +529,7 @@ async function executeAutomationInternal(
|
||||||
actionsApplied: applied,
|
actionsApplied: applied,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
})
|
})
|
||||||
|
await enforceAutomationRunsRetention(ctx, automation._id, now)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error)
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
await ctx.db.insert("ticketAutomationRuns", {
|
await ctx.db.insert("ticketAutomationRuns", {
|
||||||
|
|
@ -350,6 +542,44 @@ async function executeAutomationInternal(
|
||||||
error: message,
|
error: message,
|
||||||
createdAt: now,
|
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<string, Id<"ticketAutomationRuns">>()
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
import type { Doc } from "./_generated/dataModel"
|
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"
|
export type AutomationConditionOperator = "AND" | "OR"
|
||||||
|
|
||||||
|
|
@ -130,4 +136,3 @@ export function evaluateAutomationConditions(
|
||||||
|
|
||||||
return op === "OR" ? results.some(Boolean) : results.every(Boolean)
|
return op === "OR" ? results.some(Boolean) : results.every(Boolean)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3930,6 +3930,12 @@ export const changeQueue = mutation({
|
||||||
payload: { queueId, queueName, actorId },
|
payload: { queueId, queueName, actorId },
|
||||||
createdAt: now,
|
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 },
|
payload: { to: priority, toLabel: pt[priority] ?? priority, actorId },
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await runTicketAutomationsForEvent(ctx, {
|
||||||
|
tenantId: ticket.tenantId,
|
||||||
|
ticketId,
|
||||||
|
eventType: "PRIORITY_CHANGED",
|
||||||
|
})
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,8 @@ const CHANNELS = [
|
||||||
const TRIGGERS = [
|
const TRIGGERS = [
|
||||||
{ value: "TICKET_CREATED", label: "Abertura" },
|
{ value: "TICKET_CREATED", label: "Abertura" },
|
||||||
{ value: "STATUS_CHANGED", label: "Alteração de status" },
|
{ 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: "COMMENT_ADDED", label: "Inclusão de comentário" },
|
||||||
{ value: "TICKET_RESOLVED", label: "Finalização" },
|
{ value: "TICKET_RESOLVED", label: "Finalização" },
|
||||||
]
|
]
|
||||||
|
|
@ -320,12 +322,16 @@ export function AutomationEditorDialog({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogContent className="max-w-4xl">
|
<DialogContent className="max-w-4xl">
|
||||||
<DialogHeader className="gap-2">
|
<DialogHeader className="gap-4 pb-2">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<DialogTitle>{automation ? "Editar automação" : "Nova automação"}</DialogTitle>
|
<DialogTitle>{automation ? "Editar automação" : "Nova automação"}</DialogTitle>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-sm text-neutral-600">Ativa</span>
|
<span className="text-sm font-medium text-neutral-700">Ativa</span>
|
||||||
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
<Switch
|
||||||
|
checked={enabled}
|
||||||
|
onCheckedChange={setEnabled}
|
||||||
|
className="data-[state=checked]:bg-black data-[state=unchecked]:bg-slate-300"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
|
@ -654,7 +660,7 @@ export function AutomationEditorDialog({
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => handleRemoveCondition(c.id)}
|
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"
|
title="Remover"
|
||||||
>
|
>
|
||||||
<Trash2 className="size-4" />
|
<Trash2 className="size-4" />
|
||||||
|
|
@ -799,6 +805,7 @@ export function AutomationEditorDialog({
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
setActions((prev) => prev.map((item) => (item.id === a.id ? { ...item, enabled: checked } : item)))
|
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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -818,7 +825,7 @@ export function AutomationEditorDialog({
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => handleRemoveAction(a.id)}
|
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"
|
title="Remover"
|
||||||
>
|
>
|
||||||
<Trash2 className="size-4" />
|
<Trash2 className="size-4" />
|
||||||
|
|
|
||||||
241
src/components/automations/automation-runs-dialog.tsx
Normal file
241
src/components/automations/automation-runs-dialog.tsx
Normal file
|
|
@ -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<string, unknown> }> | null
|
||||||
|
ticket: { id: Id<"tickets">; reference: number; subject: string } | null
|
||||||
|
automation: { id: Id<"ticketAutomations">; name: string } | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const EVENT_LABELS: Record<string, string> = {
|
||||||
|
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 (
|
||||||
|
<DialogContent className="max-w-5xl">
|
||||||
|
<DialogHeader className="gap-4 pb-2">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<DialogTitle>Histórico de execuções</DialogTitle>
|
||||||
|
{automationName ? (
|
||||||
|
<p className="text-sm text-neutral-600">{automationName}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select value={statusFilter} onValueChange={(value) => setStatusFilter(value as typeof statusFilter)}>
|
||||||
|
<SelectTrigger className="w-[160px]">
|
||||||
|
<SelectValue placeholder="Status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="rounded-xl">
|
||||||
|
<SelectItem value="all">Todos</SelectItem>
|
||||||
|
<SelectItem value="SUCCESS">Sucesso</SelectItem>
|
||||||
|
<SelectItem value="SKIPPED">Ignorada</SelectItem>
|
||||||
|
<SelectItem value="ERROR">Erro</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button type="button" variant="outline" onClick={onClose}>
|
||||||
|
Fechar
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table className="w-full" style={{ tableLayout: "fixed", minWidth: "980px" }}>
|
||||||
|
<colgroup>
|
||||||
|
<col style={{ width: "210px" }} />
|
||||||
|
<col style={{ width: "260px" }} />
|
||||||
|
<col style={{ width: "120px" }} />
|
||||||
|
<col style={{ width: "140px" }} />
|
||||||
|
<col style={{ width: "110px" }} />
|
||||||
|
<col style={{ width: "280px" }} />
|
||||||
|
</colgroup>
|
||||||
|
<TableHeader className="bg-slate-100/80">
|
||||||
|
<TableRow className="bg-transparent text-[11px] uppercase tracking-wide text-neutral-600 hover:bg-transparent">
|
||||||
|
<TableHead className="px-3 py-3 text-center text-[11px] font-semibold uppercase tracking-wide text-neutral-600">
|
||||||
|
Data
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="border-l border-slate-200 px-3 py-3 text-center text-[11px] font-semibold uppercase tracking-wide text-neutral-600">
|
||||||
|
Ticket
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="border-l border-slate-200 px-3 py-3 text-center text-[11px] font-semibold uppercase tracking-wide text-neutral-600">
|
||||||
|
Evento
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="border-l border-slate-200 px-3 py-3 text-center text-[11px] font-semibold uppercase tracking-wide text-neutral-600">
|
||||||
|
Resultado
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="border-l border-slate-200 px-3 py-3 text-center text-[11px] font-semibold uppercase tracking-wide text-neutral-600">
|
||||||
|
Ações
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="border-l border-slate-200 px-3 py-3 text-center text-[11px] font-semibold uppercase tracking-wide text-neutral-600">
|
||||||
|
Detalhes
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{isLoadingFirstPage ? (
|
||||||
|
Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<TableRow key={`skeleton-${i}`} className="animate-pulse">
|
||||||
|
<TableCell className="text-center"><Skeleton className="mx-auto h-4 w-28" /></TableCell>
|
||||||
|
<TableCell className="text-center"><Skeleton className="mx-auto h-4 w-44" /></TableCell>
|
||||||
|
<TableCell className="text-center"><Skeleton className="mx-auto h-4 w-20" /></TableCell>
|
||||||
|
<TableCell className="text-center"><Skeleton className="mx-auto h-8 w-24 rounded-full" /></TableCell>
|
||||||
|
<TableCell className="text-center"><Skeleton className="mx-auto h-4 w-10" /></TableCell>
|
||||||
|
<TableCell className="text-center"><Skeleton className="mx-auto h-4 w-56" /></TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="py-10 text-center text-sm text-neutral-500">
|
||||||
|
Nenhuma execução encontrada.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
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 (
|
||||||
|
<TableRow key={run.id} className="transition-colors hover:bg-cyan-50/30">
|
||||||
|
<TableCell className="text-center text-sm text-neutral-700">{formatDateTime(run.createdAt)}</TableCell>
|
||||||
|
<TableCell className="text-center text-sm text-neutral-700">
|
||||||
|
{run.ticket ? (
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="font-semibold text-neutral-900">#{run.ticket.reference}</div>
|
||||||
|
<div className="truncate text-xs text-neutral-600" title={run.ticket.subject}>
|
||||||
|
{run.ticket.subject || "—"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center text-sm text-neutral-700">{eventLabel}</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Badge variant={badge.variant} className="rounded-full">
|
||||||
|
{badge.label}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center text-sm text-neutral-700">{actionsCount}</TableCell>
|
||||||
|
<TableCell className="text-center text-sm text-neutral-700 truncate" title={details}>
|
||||||
|
{details}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canLoadMore ? (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
try {
|
||||||
|
loadMore(20)
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "Falha ao carregar mais")
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isLoadingMore}
|
||||||
|
className="rounded-full"
|
||||||
|
>
|
||||||
|
{isLoadingMore ? "Carregando..." : "Carregar mais"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { useMemo, useState } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { useMutation, useQuery } from "convex/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 { toast } from "sonner"
|
||||||
|
|
||||||
import { api } from "@/convex/_generated/api"
|
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Switch } from "@/components/ui/switch"
|
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 { AutomationEditorDialog } from "@/components/automations/automation-editor-dialog"
|
||||||
|
import { AutomationRunsDialog } from "@/components/automations/automation-runs-dialog"
|
||||||
|
|
||||||
type AutomationRow = {
|
type AutomationRow = {
|
||||||
id: Id<"ticketAutomations">
|
id: Id<"ticketAutomations">
|
||||||
|
|
@ -38,6 +40,8 @@ type AutomationRow = {
|
||||||
const TRIGGER_LABELS: Record<string, string> = {
|
const TRIGGER_LABELS: Record<string, string> = {
|
||||||
TICKET_CREATED: "Abertura",
|
TICKET_CREATED: "Abertura",
|
||||||
STATUS_CHANGED: "Alteração de status",
|
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",
|
COMMENT_ADDED: "Inclusão de comentário",
|
||||||
TICKET_RESOLVED: "Finalização",
|
TICKET_RESOLVED: "Finalização",
|
||||||
}
|
}
|
||||||
|
|
@ -51,6 +55,16 @@ function formatLastRun(timestamp: number | null) {
|
||||||
return new Date(timestamp).toLocaleString("pt-BR")
|
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() {
|
export function AutomationsManager() {
|
||||||
const { session, convexUserId } = useAuth()
|
const { session, convexUserId } = useAuth()
|
||||||
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
||||||
|
|
@ -61,6 +75,8 @@ export function AutomationsManager() {
|
||||||
|
|
||||||
const [editorOpen, setEditorOpen] = useState(false)
|
const [editorOpen, setEditorOpen] = useState(false)
|
||||||
const [editing, setEditing] = useState<AutomationRow | null>(null)
|
const [editing, setEditing] = useState<AutomationRow | null>(null)
|
||||||
|
const [runsOpen, setRunsOpen] = useState(false)
|
||||||
|
const [runsAutomation, setRunsAutomation] = useState<AutomationRow | null>(null)
|
||||||
|
|
||||||
const list = useQuery(
|
const list = useQuery(
|
||||||
api.automations.list,
|
api.automations.list,
|
||||||
|
|
@ -94,6 +110,11 @@ export function AutomationsManager() {
|
||||||
setEditorOpen(true)
|
setEditorOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleOpenRuns = (row: AutomationRow) => {
|
||||||
|
setRunsAutomation(row)
|
||||||
|
setRunsOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
const handleToggle = async (row: AutomationRow, nextEnabled: boolean) => {
|
const handleToggle = async (row: AutomationRow, nextEnabled: boolean) => {
|
||||||
if (!convexUserId) return
|
if (!convexUserId) return
|
||||||
try {
|
try {
|
||||||
|
|
@ -147,7 +168,9 @@ export function AutomationsManager() {
|
||||||
<SelectContent className="rounded-xl">
|
<SelectContent className="rounded-xl">
|
||||||
<SelectItem value="all">Todos</SelectItem>
|
<SelectItem value="all">Todos</SelectItem>
|
||||||
<SelectItem value="TICKET_CREATED">Abertura</SelectItem>
|
<SelectItem value="TICKET_CREATED">Abertura</SelectItem>
|
||||||
<SelectItem value="STATUS_CHANGED">Alteração</SelectItem>
|
<SelectItem value="STATUS_CHANGED">Status</SelectItem>
|
||||||
|
<SelectItem value="PRIORITY_CHANGED">Prioridade</SelectItem>
|
||||||
|
<SelectItem value="QUEUE_CHANGED">Fila</SelectItem>
|
||||||
<SelectItem value="COMMENT_ADDED">Comentário</SelectItem>
|
<SelectItem value="COMMENT_ADDED">Comentário</SelectItem>
|
||||||
<SelectItem value="TICKET_RESOLVED">Finalização</SelectItem>
|
<SelectItem value="TICKET_RESOLVED">Finalização</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|
@ -178,6 +201,23 @@ export function AutomationsManager() {
|
||||||
</CardAction>
|
</CardAction>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
<Dialog
|
||||||
|
open={runsOpen}
|
||||||
|
onOpenChange={(next) => {
|
||||||
|
setRunsOpen(next)
|
||||||
|
if (!next) {
|
||||||
|
setRunsAutomation(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AutomationRunsDialog
|
||||||
|
open={runsOpen}
|
||||||
|
automationId={runsAutomation?.id ?? null}
|
||||||
|
automationName={runsAutomation?.name ?? null}
|
||||||
|
onClose={() => setRunsOpen(false)}
|
||||||
|
/>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
{!list ? (
|
{!list ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
|
@ -190,76 +230,109 @@ export function AutomationsManager() {
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="min-w-full border-separate border-spacing-y-2">
|
<Table className="w-full" style={{ tableLayout: "fixed", minWidth: "980px" }}>
|
||||||
<thead>
|
<colgroup>
|
||||||
<tr className="text-left text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
<col style={{ width: "320px" }} />
|
||||||
<th className="px-2 py-1">Nome</th>
|
<col style={{ width: "190px" }} />
|
||||||
<th className="px-2 py-1">Quando</th>
|
<col style={{ width: "120px" }} />
|
||||||
<th className="px-2 py-1">Ações</th>
|
<col style={{ width: "100px" }} />
|
||||||
<th className="px-2 py-1">Execuções</th>
|
<col style={{ width: "120px" }} />
|
||||||
<th className="px-2 py-1">Última</th>
|
<col style={{ width: "230px" }} />
|
||||||
<th className="px-2 py-1">Status</th>
|
<col style={{ width: "140px" }} />
|
||||||
<th className="px-2 py-1 text-right"> </th>
|
<col style={{ width: "70px" }} />
|
||||||
</tr>
|
</colgroup>
|
||||||
</thead>
|
<TableHeader className="bg-slate-100/80">
|
||||||
<tbody>
|
<TableRow className="bg-transparent text-[11px] uppercase tracking-wide text-neutral-600 hover:bg-transparent">
|
||||||
|
<TableHead className="px-3 py-3 text-center text-[11px] font-semibold uppercase tracking-wide text-neutral-600">
|
||||||
|
Nome
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="border-l border-slate-200 px-3 py-3 text-center text-[11px] font-semibold uppercase tracking-wide text-neutral-600">
|
||||||
|
Quando
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="border-l border-slate-200 px-3 py-3 text-center text-[11px] font-semibold uppercase tracking-wide text-neutral-600">
|
||||||
|
Condições
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="border-l border-slate-200 px-3 py-3 text-center text-[11px] font-semibold uppercase tracking-wide text-neutral-600">
|
||||||
|
Ações
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="border-l border-slate-200 px-3 py-3 text-center text-[11px] font-semibold uppercase tracking-wide text-neutral-600">
|
||||||
|
Execuções
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="border-l border-slate-200 px-3 py-3 text-center text-[11px] font-semibold uppercase tracking-wide text-neutral-600">
|
||||||
|
Última
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="border-l border-slate-200 px-3 py-3 text-center text-[11px] font-semibold uppercase tracking-wide text-neutral-600">
|
||||||
|
Status
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="border-l border-slate-200 px-3 py-3 text-center text-[11px] font-semibold uppercase tracking-wide text-neutral-600">
|
||||||
|
Ações
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
{filtered.map((row) => (
|
{filtered.map((row) => (
|
||||||
<tr key={row.id} className="rounded-xl border border-slate-200 bg-white">
|
<TableRow key={row.id} className="transition-colors hover:bg-cyan-50/30">
|
||||||
<td className="px-2 py-2 text-sm font-medium text-neutral-900">{row.name}</td>
|
<TableCell className="text-center font-semibold text-neutral-900 truncate" title={row.name}>
|
||||||
<td className="px-2 py-2 text-sm text-neutral-700">
|
{row.name}
|
||||||
<div className="flex items-center gap-2">
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
<Badge variant="secondary" className="rounded-full">
|
<Badge variant="secondary" className="rounded-full">
|
||||||
{triggerLabel(row.trigger)}
|
{triggerLabel(row.trigger)}
|
||||||
</Badge>
|
</Badge>
|
||||||
{row.timing === "DELAYED" && row.delayMs ? (
|
{row.timing === "DELAYED" && row.delayMs ? (
|
||||||
<span className="text-xs text-neutral-500">
|
<Badge variant="outline" className="rounded-full">
|
||||||
+{Math.round(row.delayMs / 60000)}m
|
+{Math.round(row.delayMs / 60000)}m
|
||||||
</span>
|
</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</TableCell>
|
||||||
<td className="px-2 py-2 text-sm text-neutral-700">{row.actions?.length ?? 0}</td>
|
<TableCell className="text-center text-sm text-neutral-700">
|
||||||
<td className="px-2 py-2 text-sm text-neutral-700">{row.runCount ?? 0}</td>
|
{formatConditionsSummary(row.conditions)}
|
||||||
<td className="px-2 py-2 text-sm text-neutral-700">{formatLastRun(row.lastRunAt)}</td>
|
</TableCell>
|
||||||
<td className="px-2 py-2">
|
<TableCell className="text-center text-sm text-neutral-700">{row.actions?.length ?? 0}</TableCell>
|
||||||
<div className="flex items-center gap-2">
|
<TableCell className="text-center text-sm text-neutral-700">{row.runCount ?? 0}</TableCell>
|
||||||
|
<TableCell className="text-center text-sm text-neutral-700">{formatLastRun(row.lastRunAt)}</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
<Switch
|
<Switch
|
||||||
checked={row.enabled}
|
checked={row.enabled}
|
||||||
onCheckedChange={(checked) => handleToggle(row, checked)}
|
onCheckedChange={(checked) => handleToggle(row, checked)}
|
||||||
|
className="data-[state=checked]:bg-black data-[state=unchecked]:bg-slate-300"
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-neutral-600">{row.enabled ? "Ativa" : "Inativa"}</span>
|
<span className="text-xs font-medium text-neutral-700">{row.enabled ? "Ativa" : "Inativa"}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</TableCell>
|
||||||
<td className="px-2 py-2 text-right">
|
<TableCell className="text-center">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
<Button variant="ghost" size="icon" className="h-9 w-9 rounded-full">
|
||||||
<MoreHorizontal className="size-4" />
|
<MoreHorizontal className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="rounded-xl">
|
<DropdownMenuContent align="end" className="rounded-xl">
|
||||||
|
<DropdownMenuItem onClick={() => handleOpenRuns(row)} className="gap-2">
|
||||||
|
<History className="size-4" />
|
||||||
|
Histórico
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => handleEdit(row)} className="gap-2">
|
<DropdownMenuItem onClick={() => handleEdit(row)} className="gap-2">
|
||||||
<Pencil className="size-4" />
|
<Pencil className="size-4" />
|
||||||
Editar
|
Editar
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem variant="destructive" onClick={() => handleDelete(row)} className="gap-2">
|
||||||
onClick={() => handleDelete(row)}
|
|
||||||
className="gap-2 text-red-600 focus:text-red-600"
|
|
||||||
>
|
|
||||||
<Trash2 className="size-4" />
|
<Trash2 className="size-4" />
|
||||||
Excluir
|
Excluir
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</td>
|
</TableCell>
|
||||||
</tr>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</TableBody>
|
||||||
</table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue