From c5ddd54a3e8f5a75c3b054fbe2e71637dd03f71f Mon Sep 17 00:00:00 2001 From: Esdras Renan Date: Sun, 9 Nov 2025 21:09:38 -0300 Subject: [PATCH] chore: prep platform improvements --- convex/categories.ts | 12 +- convex/fields.ts | 21 +- convex/migrations.ts | 3 +- convex/queues.ts | 3 +- convex/schema.ts | 7 + convex/slas.ts | 3 +- convex/teams.ts | 3 +- convex/ticketFormTemplates.ts | 1 + convex/ticketForms.config.ts | 3 + convex/tickets.ts | 97 ++--- .../admin/categories/categories-manager.tsx | 363 +--------------- .../admin/devices/admin-devices-overview.tsx | 200 +++------ .../admin/fields/fields-manager.tsx | 87 +++- .../admin/slas/category-sla-drawer.tsx | 400 ++++++++++++++++++ .../admin/slas/category-sla-manager.tsx | 94 ++++ src/components/admin/slas/slas-manager.tsx | 4 + .../admin/users/admin-users-workspace.tsx | 2 +- src/components/app-sidebar.tsx | 4 +- src/components/reports/hours-report.tsx | 71 ++-- .../tickets/close-ticket-dialog.tsx | 10 - src/components/tickets/new-ticket-dialog.tsx | 11 +- .../tickets/recent-tickets-panel.tsx | 4 +- .../tickets/ticket-custom-fields.tsx | 3 +- src/components/ui/select.tsx | 20 +- 24 files changed, 777 insertions(+), 649 deletions(-) create mode 100644 src/components/admin/slas/category-sla-drawer.tsx create mode 100644 src/components/admin/slas/category-sla-manager.tsx diff --git a/convex/categories.ts b/convex/categories.ts index 9f86853..e7adb2b 100644 --- a/convex/categories.ts +++ b/convex/categories.ts @@ -412,8 +412,7 @@ export const deleteCategory = mutation({ } const ticketsToMove = await ctx.db .query("tickets") - .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) - .filter((q) => q.eq(q.field("categoryId"), categoryId)) + .withIndex("by_tenant_category", (q) => q.eq("tenantId", tenantId).eq("categoryId", categoryId)) .collect() for (const ticket of ticketsToMove) { await ctx.db.patch(ticket._id, { @@ -425,8 +424,7 @@ export const deleteCategory = mutation({ } else { const ticketsLinked = await ctx.db .query("tickets") - .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) - .filter((q) => q.eq(q.field("categoryId"), categoryId)) + .withIndex("by_tenant_category", (q) => q.eq("tenantId", tenantId).eq("categoryId", categoryId)) .first() if (ticketsLinked) { throw new ConvexError("Não é possível remover uma categoria vinculada a tickets sem informar destino") @@ -526,8 +524,7 @@ export const deleteSubcategory = mutation({ } const tickets = await ctx.db .query("tickets") - .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) - .filter((q) => q.eq(q.field("subcategoryId"), subcategoryId)) + .withIndex("by_tenant_subcategory", (q) => q.eq("tenantId", tenantId).eq("subcategoryId", subcategoryId)) .collect() for (const ticket of tickets) { await ctx.db.patch(ticket._id, { @@ -538,8 +535,7 @@ export const deleteSubcategory = mutation({ } else { const linked = await ctx.db .query("tickets") - .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) - .filter((q) => q.eq(q.field("subcategoryId"), subcategoryId)) + .withIndex("by_tenant_subcategory", (q) => q.eq("tenantId", tenantId).eq("subcategoryId", subcategoryId)) .first() if (linked) { throw new ConvexError("Não é possível remover uma subcategoria vinculada a tickets sem informar destino") diff --git a/convex/fields.ts b/convex/fields.ts index c4b62ff..38d6863 100644 --- a/convex/fields.ts +++ b/convex/fields.ts @@ -37,6 +37,15 @@ function validateOptions(type: FieldType, options: { value: string; label: strin } } +async function validateCompanyScope(ctx: AnyCtx, tenantId: string, companyId?: Id<"companies"> | null) { + if (!companyId) return undefined; + const company = await ctx.db.get(companyId); + if (!company || company.tenantId !== tenantId) { + throw new ConvexError("Empresa inválida para o campo"); + } + return companyId; +} + export const list = query({ args: { tenantId: v.string(), viewerId: v.id("users"), scope: v.optional(v.string()) }, handler: async (ctx, { tenantId, viewerId, scope }) => { @@ -64,6 +73,7 @@ export const list = query({ options: field.options ?? [], order: field.order, scope: field.scope ?? "all", + companyId: field.companyId ?? null, createdAt: field.createdAt, updatedAt: field.updatedAt, })); @@ -97,6 +107,7 @@ export const listForTenant = query({ options: field.options ?? [], order: field.order, scope: field.scope ?? "all", + companyId: field.companyId ?? null, })); }, }); @@ -118,8 +129,9 @@ export const create = mutation({ ) ), scope: v.optional(v.string()), + companyId: v.optional(v.id("companies")), }, - handler: async (ctx, { tenantId, actorId, label, description, type, required, options, scope }) => { + handler: async (ctx, { tenantId, actorId, label, description, type, required, options, scope, companyId }) => { await requireAdmin(ctx, actorId, tenantId); const normalizedLabel = label.trim(); if (normalizedLabel.length < 2) { @@ -140,6 +152,7 @@ export const create = mutation({ } return safe; })(); + const companyRef = await validateCompanyScope(ctx, tenantId, companyId ?? undefined); const existing = await ctx.db .query("ticketFields") @@ -158,6 +171,7 @@ export const create = mutation({ options, order: maxOrder + 1, scope: normalizedScope, + companyId: companyRef, createdAt: now, updatedAt: now, }); @@ -183,8 +197,9 @@ export const update = mutation({ ) ), scope: v.optional(v.string()), + companyId: v.optional(v.id("companies")), }, - handler: async (ctx, { tenantId, fieldId, actorId, label, description, type, required, options, scope }) => { + handler: async (ctx, { tenantId, fieldId, actorId, label, description, type, required, options, scope, companyId }) => { await requireAdmin(ctx, actorId, tenantId); const field = await ctx.db.get(fieldId); if (!field || field.tenantId !== tenantId) { @@ -208,6 +223,7 @@ export const update = mutation({ } return safe; })(); + const companyRef = await validateCompanyScope(ctx, tenantId, companyId ?? undefined); let key = field.key; if (field.label !== normalizedLabel) { @@ -223,6 +239,7 @@ export const update = mutation({ required, options, scope: normalizedScope, + companyId: companyRef, updatedAt: Date.now(), }); }, diff --git a/convex/migrations.ts b/convex/migrations.ts index 099ec82..ab1adca 100644 --- a/convex/migrations.ts +++ b/convex/migrations.ts @@ -211,8 +211,7 @@ async function ensureQueue( const byName = await ctx.db .query("queues") - .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) - .filter((q) => q.eq(q.field("name"), data.name)) + .withIndex("by_tenant_name", (q) => q.eq("tenantId", tenantId).eq("name", data.name)) .first() if (byName) { if (byName.slug !== slug) { diff --git a/convex/queues.ts b/convex/queues.ts index 985f493..b18d25b 100644 --- a/convex/queues.ts +++ b/convex/queues.ts @@ -67,8 +67,7 @@ async function ensureUniqueSlug(ctx: AnyCtx, tenantId: string, slug: string, exc async function ensureUniqueName(ctx: AnyCtx, tenantId: string, name: string, excludeId?: Id<"queues">) { const existing = await ctx.db .query("queues") - .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) - .filter((q) => q.eq(q.field("name"), name)) + .withIndex("by_tenant_name", (q) => q.eq("tenantId", tenantId).eq("name", name)) .first(); if (existing && (!excludeId || existing._id !== excludeId)) { throw new ConvexError("Já existe uma fila com este nome"); diff --git a/convex/schema.ts b/convex/schema.ts index 35b0445..b0f1bc3 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -184,6 +184,8 @@ export default defineSchema({ teamId: v.optional(v.id("teams")), }) .index("by_tenant_slug", ["tenantId", "slug"]) + .index("by_tenant_team", ["tenantId", "teamId"]) + .index("by_tenant_name", ["tenantId", "name"]) .index("by_tenant", ["tenantId"]), teams: defineTable({ @@ -322,6 +324,9 @@ export default defineSchema({ .index("by_tenant_requester", ["tenantId", "requesterId"]) .index("by_tenant_company", ["tenantId", "companyId"]) .index("by_tenant_machine", ["tenantId", "machineId"]) + .index("by_tenant_category", ["tenantId", "categoryId"]) + .index("by_tenant_subcategory", ["tenantId", "subcategoryId"]) + .index("by_tenant_sla_policy", ["tenantId", "slaPolicyId"]) .index("by_tenant", ["tenantId"]) .index("by_tenant_created", ["tenantId", "createdAt"]) .index("by_tenant_resolved", ["tenantId", "resolvedAt"]) @@ -480,6 +485,7 @@ export default defineSchema({ key: v.string(), label: v.string(), type: v.string(), + companyId: v.optional(v.id("companies")), description: v.optional(v.string()), required: v.boolean(), order: v.number(), @@ -498,6 +504,7 @@ export default defineSchema({ .index("by_tenant_key", ["tenantId", "key"]) .index("by_tenant_order", ["tenantId", "order"]) .index("by_tenant_scope", ["tenantId", "scope"]) + .index("by_tenant_company", ["tenantId", "companyId"]) .index("by_tenant", ["tenantId"]), ticketFormSettings: defineTable({ diff --git a/convex/slas.ts b/convex/slas.ts index eaadab4..5cfdc6a 100644 --- a/convex/slas.ts +++ b/convex/slas.ts @@ -126,8 +126,7 @@ export const remove = mutation({ const ticketLinked = await ctx.db .query("tickets") - .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) - .filter((q) => q.eq(q.field("slaPolicyId"), policyId)) + .withIndex("by_tenant_sla_policy", (q) => q.eq("tenantId", tenantId).eq("slaPolicyId", policyId)) .first(); if (ticketLinked) { throw new ConvexError("Remova a associação de tickets antes de excluir a política"); diff --git a/convex/teams.ts b/convex/teams.ts index 71988c5..f856e0d 100644 --- a/convex/teams.ts +++ b/convex/teams.ts @@ -141,8 +141,7 @@ export const remove = mutation({ const queuesLinked = await ctx.db .query("queues") - .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) - .filter((q) => q.eq(q.field("teamId"), teamId)) + .withIndex("by_tenant_team", (q) => q.eq("tenantId", tenantId).eq("teamId", teamId)) .first(); if (queuesLinked) { throw new ConvexError("Remova ou realoque as filas associadas antes de excluir o time"); diff --git a/convex/ticketFormTemplates.ts b/convex/ticketFormTemplates.ts index b0b964a..92c25ed 100644 --- a/convex/ticketFormTemplates.ts +++ b/convex/ticketFormTemplates.ts @@ -121,6 +121,7 @@ async function cloneFieldsFromTemplate(ctx: MutationCtx, tenantId: string, sourc required: field.required, options: field.options ?? undefined, scope: targetKey, + companyId: field.companyId ?? undefined, order, createdAt: now, updatedAt: now, diff --git a/convex/ticketForms.config.ts b/convex/ticketForms.config.ts index fe36acb..1b4ac4a 100644 --- a/convex/ticketForms.config.ts +++ b/convex/ticketForms.config.ts @@ -1,5 +1,7 @@ "use server"; +import type { Id } from "./_generated/dataModel"; + export type TicketFormFieldSeed = { key: string; label: string; @@ -7,6 +9,7 @@ export type TicketFormFieldSeed = { required?: boolean; description?: string; options?: Array<{ value: string; label: string }>; + companyId?: Id<"companies"> | null; }; export const TICKET_FORM_CONFIG = [ diff --git a/convex/tickets.ts b/convex/tickets.ts index 50bd0d1..a9f1b51 100644 --- a/convex/tickets.ts +++ b/convex/tickets.ts @@ -3,8 +3,7 @@ import { mutation, query } from "./_generated/server"; import { api } from "./_generated/api"; import type { MutationCtx, QueryCtx } from "./_generated/server"; import { ConvexError, v } from "convex/values"; -import { Id, type Doc, type DataModel } from "./_generated/dataModel"; -import type { NamedTableInfo, Query as ConvexQuery } from "convex/server"; +import { Id, type Doc } from "./_generated/dataModel"; import { requireAdmin, requireStaff, requireUser } from "./rbac"; import { @@ -477,13 +476,15 @@ async function fetchTemplateSummaries(ctx: AnyCtx, tenantId: string): Promise | null ): Promise { const uniqueScopes = Array.from(new Set(scopes.filter((scope) => Boolean(scope)))); if (uniqueScopes.length === 0) { return new Map(); } const scopeSet = new Set(uniqueScopes); + const companyIdStr = companyId ? String(companyId) : null; const result: TicketFieldScopeMap = new Map(); const allFields = await ctx.db .query("ticketFields") @@ -495,6 +496,10 @@ async function fetchTicketFieldsByScopes( if (!scopeSet.has(scope)) { continue; } + const fieldCompanyId = field.companyId ? String(field.companyId) : null; + if (fieldCompanyId && (!companyIdStr || companyIdStr !== fieldCompanyId)) { + continue; + } const current = result.get(scope); if (current) { current.push(field); @@ -634,6 +639,7 @@ async function ensureTicketFormDefaultsForTenant(ctx: MutationCtx, tenantId: str label: option.label, })), scope: template.key, + companyId: field.companyId ?? undefined, order, createdAt: now, updatedAt: now, @@ -1319,9 +1325,6 @@ const MAX_FETCH_LIMIT = 1000; const FETCH_MULTIPLIER_NO_SEARCH = 3; const FETCH_MULTIPLIER_WITH_SEARCH = 5; -type TicketsTableInfo = NamedTableInfo; -type TicketsQueryBuilder = ConvexQuery; - function clampTicketLimit(limit: number) { if (!Number.isFinite(limit)) return DEFAULT_TICKETS_LIST_LIMIT; return Math.max(MIN_TICKETS_LIST_LIMIT, Math.min(MAX_TICKETS_LIST_LIMIT, Math.floor(limit))); @@ -1371,7 +1374,6 @@ export const list = query({ const normalizedStatusFilter = args.status ? normalizeStatus(args.status) : null; const normalizedPriorityFilter = normalizePriorityFilter(args.priority); const prioritySet = normalizedPriorityFilter.length > 0 ? new Set(normalizedPriorityFilter) : null; - const primaryPriorityFilter = normalizedPriorityFilter.length === 1 ? normalizedPriorityFilter[0] : null; const normalizedChannelFilter = args.channel ? args.channel.toUpperCase() : null; const searchTerm = args.search?.trim().toLowerCase() ?? null; @@ -1379,80 +1381,43 @@ export const list = query({ const requestedLimit = clampTicketLimit(requestedLimitRaw); const fetchLimit = computeFetchLimit(requestedLimit, Boolean(searchTerm)); - const applyQueryFilters = (query: TicketsQueryBuilder) => { - let working = query; - if (normalizedStatusFilter) { - working = working.filter((q) => q.eq(q.field("status"), normalizedStatusFilter)); - } - if (primaryPriorityFilter) { - working = working.filter((q) => q.eq(q.field("priority"), primaryPriorityFilter)); - } - if (normalizedChannelFilter) { - working = working.filter((q) => q.eq(q.field("channel"), normalizedChannelFilter)); - } - if (args.queueId) { - working = working.filter((q) => q.eq(q.field("queueId"), args.queueId!)); - } - if (args.assigneeId) { - working = working.filter((q) => q.eq(q.field("assigneeId"), args.assigneeId!)); - } - if (args.requesterId) { - working = working.filter((q) => q.eq(q.field("requesterId"), args.requesterId!)); - } - return working; - }; - let base: Doc<"tickets">[] = []; if (role === "MANAGER") { - const baseQuery = applyQueryFilters( - ctx.db - .query("tickets") - .withIndex("by_tenant_company", (q) => q.eq("tenantId", args.tenantId).eq("companyId", user.companyId!)) - ); + const baseQuery = ctx.db + .query("tickets") + .withIndex("by_tenant_company", (q) => q.eq("tenantId", args.tenantId).eq("companyId", user.companyId!)); base = await baseQuery.order("desc").take(fetchLimit); } else if (args.assigneeId) { - const baseQuery = applyQueryFilters( - ctx.db - .query("tickets") - .withIndex("by_tenant_assignee", (q) => q.eq("tenantId", args.tenantId).eq("assigneeId", args.assigneeId!)) - ); + const baseQuery = ctx.db + .query("tickets") + .withIndex("by_tenant_assignee", (q) => q.eq("tenantId", args.tenantId).eq("assigneeId", args.assigneeId!)); base = await baseQuery.order("desc").take(fetchLimit); } else if (args.requesterId) { - const baseQuery = applyQueryFilters( - ctx.db - .query("tickets") - .withIndex("by_tenant_requester", (q) => q.eq("tenantId", args.tenantId).eq("requesterId", args.requesterId!)) - ); + const baseQuery = ctx.db + .query("tickets") + .withIndex("by_tenant_requester", (q) => q.eq("tenantId", args.tenantId).eq("requesterId", args.requesterId!)); base = await baseQuery.order("desc").take(fetchLimit); } else if (args.queueId) { - const baseQuery = applyQueryFilters( - ctx.db - .query("tickets") - .withIndex("by_tenant_queue", (q) => q.eq("tenantId", args.tenantId).eq("queueId", args.queueId!)) - ); + const baseQuery = ctx.db + .query("tickets") + .withIndex("by_tenant_queue", (q) => q.eq("tenantId", args.tenantId).eq("queueId", args.queueId!)); base = await baseQuery.order("desc").take(fetchLimit); } else if (normalizedStatusFilter) { - const baseQuery = applyQueryFilters( - ctx.db - .query("tickets") - .withIndex("by_tenant_status", (q) => q.eq("tenantId", args.tenantId).eq("status", normalizedStatusFilter)) - ); + const baseQuery = ctx.db + .query("tickets") + .withIndex("by_tenant_status", (q) => q.eq("tenantId", args.tenantId).eq("status", normalizedStatusFilter)); base = await baseQuery.order("desc").take(fetchLimit); } else if (role === "COLLABORATOR") { const viewerEmail = user.email.trim().toLowerCase(); - const directQuery = applyQueryFilters( - ctx.db - .query("tickets") - .withIndex("by_tenant_requester", (q) => q.eq("tenantId", args.tenantId).eq("requesterId", viewerId)) - ); + const directQuery = ctx.db + .query("tickets") + .withIndex("by_tenant_requester", (q) => q.eq("tenantId", args.tenantId).eq("requesterId", viewerId)); const directTickets = await directQuery.order("desc").take(fetchLimit); let combined = directTickets; if (directTickets.length < fetchLimit) { - const fallbackQuery = applyQueryFilters( - ctx.db.query("tickets").withIndex("by_tenant", (q) => q.eq("tenantId", args.tenantId)) - ); + const fallbackQuery = ctx.db.query("tickets").withIndex("by_tenant", (q) => q.eq("tenantId", args.tenantId)); const fallbackRaw = await fallbackQuery.order("desc").take(fetchLimit); const fallbackMatches = fallbackRaw.filter((ticket) => { const snapshotEmail = (ticket.requesterSnapshot as { email?: string } | undefined)?.email; @@ -1463,9 +1428,7 @@ export const list = query({ } base = combined.slice(0, fetchLimit); } else { - const baseQuery = applyQueryFilters( - ctx.db.query("tickets").withIndex("by_tenant", (q) => q.eq("tenantId", args.tenantId)) - ); + const baseQuery = ctx.db.query("tickets").withIndex("by_tenant", (q) => q.eq("tenantId", args.tenantId)); base = await baseQuery.order("desc").take(fetchLimit); } @@ -2829,7 +2792,7 @@ export const listTicketForms = query({ const templates = await fetchTemplateSummaries(ctx, tenantId) const scopes = templates.map((template) => template.key) - const fieldsByScope = await fetchTicketFieldsByScopes(ctx, tenantId, scopes) + const fieldsByScope = await fetchTicketFieldsByScopes(ctx, tenantId, scopes, viewerCompanyId) const staffOverride = viewerRole === "ADMIN" || viewerRole === "AGENT" const settingsByTemplate = staffOverride diff --git a/src/components/admin/categories/categories-manager.tsx b/src/components/admin/categories/categories-manager.tsx index 5f9a427..bb8daf6 100644 --- a/src/components/admin/categories/categories-manager.tsx +++ b/src/components/admin/categories/categories-manager.tsx @@ -1,6 +1,6 @@ "use client" -import { useEffect, useMemo, useState } from "react" +import { useMemo, useState } from "react" import { useMutation, useQuery } from "convex/react" import { toast } from "sonner" import { api } from "@/convex/_generated/api" @@ -22,8 +22,6 @@ import { import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Textarea } from "@/components/ui/textarea" -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" -import { cn } from "@/lib/utils" type DeleteState = | { type: T; targetId: string; reason: string } @@ -38,7 +36,6 @@ export function CategoriesManager() { const [subcategoryDraft, setSubcategoryDraft] = useState("") const [subcategoryList, setSubcategoryList] = useState([]) const [deleteState, setDeleteState] = useState>(null) - const [slaCategory, setSlaCategory] = useState(null) const createCategory = useMutation(api.categories.createCategory) const deleteCategory = useMutation(api.categories.deleteCategory) const updateCategory = useMutation(api.categories.updateCategory) @@ -315,7 +312,6 @@ export function CategoriesManager() { onDeleteSubcategory={(subcategoryId) => setDeleteState({ type: "subcategory", targetId: subcategoryId, reason: "" }) } - onConfigureSla={() => setSlaCategory(category)} disabled={isDisabled} /> )) @@ -378,12 +374,6 @@ export function CategoriesManager() { - setSlaCategory(null)} - /> ) } @@ -396,7 +386,6 @@ interface CategoryItemProps { onCreateSubcategory: (categoryId: string, payload: { name: string }) => Promise onUpdateSubcategory: (subcategory: TicketSubcategory, name: string) => Promise onDeleteSubcategory: (subcategoryId: string) => void - onConfigureSla: () => void } function CategoryItem({ @@ -407,7 +396,6 @@ function CategoryItem({ onCreateSubcategory, onUpdateSubcategory, onDeleteSubcategory, - onConfigureSla, }: CategoryItemProps) { const [isEditing, setIsEditing] = useState(false) const [name, setName] = useState(category.name) @@ -461,9 +449,6 @@ function CategoryItem({ ) : (
- @@ -579,349 +564,3 @@ type RuleFormState = { alertThreshold: number pauseStatuses: string[] } - -const PRIORITY_ROWS = [ - { value: "URGENT", label: "Crítico" }, - { value: "HIGH", label: "Alta" }, - { value: "MEDIUM", label: "Média" }, - { value: "LOW", label: "Baixa" }, - { value: "DEFAULT", label: "Sem prioridade" }, -] as const - -const TIME_UNITS: Array<{ value: RuleFormState["responseUnit"]; label: string; factor: number }> = [ - { value: "minutes", label: "Minutos", factor: 1 }, - { value: "hours", label: "Horas", factor: 60 }, - { value: "days", label: "Dias", factor: 1440 }, -] - -const MODE_OPTIONS: Array<{ value: RuleFormState["responseMode"]; label: string }> = [ - { value: "calendar", label: "Horas corridas" }, - { value: "business", label: "Horas úteis" }, -] - -const PAUSE_STATUS_OPTIONS = [ - { value: "PENDING", label: "Pendente" }, - { value: "AWAITING_ATTENDANCE", label: "Em atendimento" }, - { value: "PAUSED", label: "Pausado" }, -] as const - -const DEFAULT_RULE_STATE: RuleFormState = { - responseValue: "", - responseUnit: "hours", - responseMode: "calendar", - solutionValue: "", - solutionUnit: "hours", - solutionMode: "calendar", - alertThreshold: 80, - pauseStatuses: ["PAUSED"], -} - -type CategorySlaDrawerProps = { - category: TicketCategory | null - tenantId: string - viewerId: Id<"users"> | null - onClose: () => void -} - -function CategorySlaDrawer({ category, tenantId, viewerId, onClose }: CategorySlaDrawerProps) { - const [rules, setRules] = useState>(() => buildDefaultRuleState()) - const [saving, setSaving] = useState(false) - const drawerOpen = Boolean(category) - - const canLoad = Boolean(category && viewerId) - const existing = useQuery( - api.categorySlas.get, - canLoad - ? { - tenantId, - viewerId: viewerId as Id<"users">, - categoryId: category!.id as Id<"ticketCategories">, - } - : "skip" - ) as { rules: Array<{ priority: string; responseTargetMinutes: number | null; responseMode?: string | null; solutionTargetMinutes: number | null; solutionMode?: string | null; alertThreshold?: number | null; pauseStatuses?: string[] | null }> } | undefined - - const saveSla = useMutation(api.categorySlas.save) - - useEffect(() => { - if (!existing?.rules) { - setRules(buildDefaultRuleState()) - return - } - const next = buildDefaultRuleState() - for (const rule of existing.rules) { - const priority = rule.priority?.toUpperCase() ?? "DEFAULT" - next[priority] = convertRuleToForm(rule) - } - setRules(next) - }, [existing, category?.id]) - - const handleChange = (priority: string, patch: Partial) => { - setRules((current) => ({ - ...current, - [priority]: { - ...current[priority], - ...patch, - }, - })) - } - - const togglePause = (priority: string, status: string) => { - setRules((current) => { - const selected = new Set(current[priority].pauseStatuses) - if (selected.has(status)) { - selected.delete(status) - } else { - selected.add(status) - } - if (selected.size === 0) { - selected.add("PAUSED") - } - return { - ...current, - [priority]: { - ...current[priority], - pauseStatuses: Array.from(selected), - }, - } - }) - } - - const handleSave = async () => { - if (!category || !viewerId) return - setSaving(true) - toast.loading("Salvando SLA...", { id: "category-sla" }) - try { - const payload = PRIORITY_ROWS.map((row) => { - const form = rules[row.value] - return { - priority: row.value, - responseTargetMinutes: convertToMinutes(form.responseValue, form.responseUnit), - responseMode: form.responseMode, - solutionTargetMinutes: convertToMinutes(form.solutionValue, form.solutionUnit), - solutionMode: form.solutionMode, - alertThreshold: Math.min(Math.max(form.alertThreshold, 5), 95) / 100, - pauseStatuses: form.pauseStatuses, - } - }) - await saveSla({ - tenantId, - actorId: viewerId, - categoryId: category.id as Id<"ticketCategories">, - rules: payload, - }) - toast.success("SLA atualizado", { id: "category-sla" }) - onClose() - } catch (error) { - console.error(error) - toast.error("Não foi possível salvar as regras de SLA.", { id: "category-sla" }) - } finally { - setSaving(false) - } - } - - return ( - { - if (!open) { - onClose() - } - }} - > - - - Configurar SLA — {category?.name ?? ""} - - Defina metas de resposta e resolução para cada prioridade. Os prazos em horas úteis consideram apenas - segunda a sexta, das 8h às 18h. - - -
- {PRIORITY_ROWS.map((row) => { - const form = rules[row.value] - return ( -
-
-
-

{row.label}

-

- {row.value === "DEFAULT" ? "Aplicado quando o ticket não 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 ( - - ) - })} -
-
-
-
- ) - })} -
- - - - -
-
- ) -} - -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/devices/admin-devices-overview.tsx b/src/components/admin/devices/admin-devices-overview.tsx index fafc54f..c7257c1 100644 --- a/src/components/admin/devices/admin-devices-overview.tsx +++ b/src/components/admin/devices/admin-devices-overview.tsx @@ -42,7 +42,7 @@ import { import { api } from "@/convex/_generated/api" import { Badge } from "@/components/ui/badge" -import { Button, buttonVariants } from "@/components/ui/button" +import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Textarea } from "@/components/ui/textarea" import { Spinner } from "@/components/ui/spinner" @@ -468,67 +468,6 @@ export function normalizeDeviceRemoteAccessList(raw: unknown): DeviceRemoteAcces return entries } -const REMOTE_ACCESS_METADATA_IGNORED_KEYS = new Set([ - "provider", - "tool", - "vendor", - "name", - "identifier", - "code", - "id", - "accessId", - "username", - "user", - "login", - "email", - "account", - "password", - "pass", - "secret", - "pin", - "url", - "link", - "remoteUrl", - "console", - "viewer", - "notes", - "note", - "description", - "obs", - "lastVerifiedAt", - "verifiedAt", - "checkedAt", - "updatedAt", -]) - -function extractRemoteAccessMetadataEntries(metadata: Record | null | undefined) { - if (!metadata) return [] as Array<[string, unknown]> - return Object.entries(metadata).filter(([key, value]) => { - if (REMOTE_ACCESS_METADATA_IGNORED_KEYS.has(key)) return false - if (value === null || value === undefined) return false - if (typeof value === "string" && value.trim().length === 0) return false - return true - }) -} - -function formatRemoteAccessMetadataKey(key: string) { - return key - .replace(/[_.-]+/g, " ") - .replace(/\b\w/g, (char) => char.toUpperCase()) -} - -function formatRemoteAccessMetadataValue(value: unknown): string { - if (value === null || value === undefined) return "" - if (typeof value === "string") return value - if (typeof value === "number" || typeof value === "boolean") return String(value) - if (value instanceof Date) return formatAbsoluteDateTime(value) - try { - return JSON.stringify(value) - } catch { - return String(value) - } -} - function readText(record: Record, ...keys: string[]): string | undefined { const stringValue = readString(record, ...keys) if (stringValue) return stringValue @@ -3029,7 +2968,7 @@ export function DeviceDetails({ device }: DeviceDetailsProps) { const [deleteDialog, setDeleteDialog] = useState(false) const [deleting, setDeleting] = useState(false) const [accessDialog, setAccessDialog] = useState(false) - const [accessEmail, setAccessEmail] = useState(primaryLinkedUser?.email ?? "") + const [accessEmail, setAccessEmail] = useState("") const [accessName, setAccessName] = useState(primaryLinkedUser?.name ?? "") const [accessRole, setAccessRole] = useState<"collaborator" | "manager">(personaRole === "manager" ? "manager" : "collaborator") const [savingAccess, setSavingAccess] = useState(false) @@ -3091,10 +3030,13 @@ export function DeviceDetails({ device }: DeviceDetailsProps) { // removed copy/export inventory JSON buttons as requested useEffect(() => { - setAccessEmail(primaryLinkedUser?.email ?? "") + setAccessEmail("") + }, [device?.id]) + + useEffect(() => { setAccessName(primaryLinkedUser?.name ?? "") setAccessRole(personaRole === "manager" ? "manager" : "collaborator") - }, [device?.id, primaryLinkedUser?.email, primaryLinkedUser?.name, personaRole]) + }, [device?.id, primaryLinkedUser?.name, personaRole]) useEffect(() => { setIsActiveLocal(device?.isActive ?? true) @@ -3711,10 +3653,55 @@ export function DeviceDetails({ device }: DeviceDetailsProps) { ))}
+ +
+
+

Controles do dispositivo

+ {device.registeredBy ? ( + + Registrada via {device.registeredBy} + + ) : null} +
+
+ + {!isManualMobile ? ( + <> + + + + ) : null} +
+
+ {/* Campos personalizados (posicionado logo após métricas) */} -
+
-
+

Campos personalizados

@@ -3816,50 +3803,7 @@ export function DeviceDetails({ device }: DeviceDetailsProps) { ) : null}
-
- - {!isManualMobile ? ( - <> - - - - ) : null} - {device.registeredBy ? ( - - Registrada via {device.registeredBy} - - ) : null} -
-
+

Acesso remoto

@@ -3889,7 +3833,6 @@ export function DeviceDetails({ device }: DeviceDetailsProps) { {hasRemoteAccess ? (
{remoteAccessEntries.map((entry) => { - const metadataEntries = extractRemoteAccessMetadataEntries(entry.metadata) const lastVerifiedDate = entry.lastVerifiedAt && Number.isFinite(entry.lastVerifiedAt) ? new Date(entry.lastVerifiedAt) @@ -3977,12 +3920,12 @@ export function DeviceDetails({ device }: DeviceDetailsProps) { ) : null} {isRustDesk && (entry.identifier || entry.password) ? ( ) : null} {entry.notes ? ( @@ -4020,21 +3963,6 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
) : null}
- {metadataEntries.length ? ( -
- - Metadados adicionais - -
- {metadataEntries.map(([key, value]) => ( -
- {formatRemoteAccessMetadataKey(key)} - {formatRemoteAccessMetadataValue(value)} -
- ))} -
-
- ) : null}
) })} @@ -4047,7 +3975,7 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
-
+

Usuários vinculados

{primaryLinkedUser?.email ? ( @@ -4339,7 +4267,7 @@ export function DeviceDetails({ device }: DeviceDetailsProps) { {!isManualMobile ? ( -
+

Sincronização

@@ -4377,7 +4305,7 @@ export function DeviceDetails({ device }: DeviceDetailsProps) { ) : null} {!isManualMobile ? ( -
+

Métricas recentes

{lastUpdateRelative ? ( @@ -4391,7 +4319,7 @@ export function DeviceDetails({ device }: DeviceDetailsProps) { ) : null} {!isManualMobile && (hardware || network || (labels && labels.length > 0)) ? ( -
+

Inventário

@@ -4500,7 +4428,7 @@ export function DeviceDetails({ device }: DeviceDetailsProps) { {/* Discos (agente) */} {disks.length > 0 ? ( -

+

Discos e partições

@@ -4531,7 +4459,7 @@ export function DeviceDetails({ device }: DeviceDetailsProps) { {/* Inventário estendido por SO */} {extended ? ( -
+

Inventário estendido

Dados ricos coletados pelo agente, variam por sistema operacional.

diff --git a/src/components/admin/fields/fields-manager.tsx b/src/components/admin/fields/fields-manager.tsx index 9432af2..0a492e6 100644 --- a/src/components/admin/fields/fields-manager.tsx +++ b/src/components/admin/fields/fields-manager.tsx @@ -17,6 +17,7 @@ import { Checkbox } from "@/components/ui/checkbox" import { Badge } from "@/components/ui/badge" import { Skeleton } from "@/components/ui/skeleton" import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox" type FieldOption = { value: string; label: string } @@ -30,6 +31,7 @@ type Field = { options: FieldOption[] order: number scope: string + companyId: string | null } const TYPE_LABELS: Record = { @@ -54,6 +56,11 @@ export function FieldsManager() { convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip" ) as Array<{ id: string; key: string; label: string }> | undefined + const companies = useQuery( + api.companies.list, + convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip" + ) as Array<{ id: string; name: string; slug?: string }> | undefined + const scopeOptions = useMemo( () => [ { value: "all", label: "Todos os formulários" }, @@ -62,6 +69,28 @@ export function FieldsManager() { [templates] ) + const companyOptions = useMemo(() => { + if (!companies) return [] + return companies + .map((company) => ({ + value: company.id, + label: company.name, + description: company.slug ?? undefined, + keywords: company.slug ? [company.slug] : [], + })) + .sort((a, b) => a.label.localeCompare(b.label, "pt-BR")) + }, [companies]) + + const companyLabelById = useMemo(() => { + const map = new Map() + companyOptions.forEach((option) => map.set(option.value, option.label)) + return map + }, [companyOptions]) + + const companyComboboxOptions = useMemo(() => { + return [{ value: "all", label: "Todas as empresas" }, ...companyOptions] + }, [companyOptions]) + const templateLabelByKey = useMemo(() => { const map = new Map() templates?.forEach((tpl) => map.set(tpl.key, tpl.label)) @@ -79,9 +108,11 @@ export function FieldsManager() { const [required, setRequired] = useState(false) const [options, setOptions] = useState([]) const [scopeSelection, setScopeSelection] = useState("all") + const [companySelection, setCompanySelection] = useState("all") const [saving, setSaving] = useState(false) const [editingField, setEditingField] = useState(null) const [editingScope, setEditingScope] = useState("all") + const [editingCompanySelection, setEditingCompanySelection] = useState("all") const totals = useMemo(() => { if (!fields) return { total: 0, required: 0, select: 0 } @@ -99,6 +130,8 @@ export function FieldsManager() { setRequired(false) setOptions([]) setScopeSelection("all") + setCompanySelection("all") + setEditingCompanySelection("all") } const normalizeOptions = (source: FieldOption[]) => @@ -121,6 +154,7 @@ export function FieldsManager() { } const preparedOptions = type === "select" ? normalizeOptions(options) : undefined const scopeValue = scopeSelection === "all" ? undefined : scopeSelection + const companyIdValue = companySelection === "all" ? undefined : (companySelection as Id<"companies">) setSaving(true) toast.loading("Criando campo...", { id: "field" }) try { @@ -133,6 +167,7 @@ export function FieldsManager() { required, options: preparedOptions, scope: scopeValue, + companyId: companyIdValue, }) toast.success("Campo criado", { id: "field" }) resetForm() @@ -173,6 +208,7 @@ export function FieldsManager() { setRequired(field.required) setOptions(field.options) setEditingScope(field.scope ?? "all") + setEditingCompanySelection(field.companyId ?? "all") } const handleUpdate = async () => { @@ -187,6 +223,7 @@ export function FieldsManager() { } const preparedOptions = type === "select" ? normalizeOptions(options) : undefined const scopeValue = editingScope === "all" ? undefined : editingScope + const companyIdValue = editingCompanySelection === "all" ? undefined : (editingCompanySelection as Id<"companies">) setSaving(true) toast.loading("Atualizando campo...", { id: "field-edit" }) try { @@ -200,6 +237,7 @@ export function FieldsManager() { required, options: preparedOptions, scope: scopeValue, + companyId: companyIdValue, }) toast.success("Campo atualizado", { id: "field-edit" }) setEditingField(null) @@ -347,6 +385,25 @@ export function FieldsManager() {
+
+ + setCompanySelection(value ?? "all")} + options={companyComboboxOptions} + placeholder="Todas as empresas" + renderValue={(option) => + option ? ( + {option.label} + ) : ( + Todas as empresas + ) + } + /> +

+ Selecione uma empresa para tornar este campo exclusivo dela. Sem seleção, o campo aparecerá em todos os tickets. +

+
@@ -443,9 +500,14 @@ export function FieldsManager() { ) : null}
Identificador: {field.key} - - {scopeLabel} - +
+ + {scopeLabel} + + + {field.companyId ? `Empresa: ${companyLabelById.get(field.companyId) ?? "Específica"}` : "Todas as empresas"} + +
{field.description ? (

{field.description}

) : null} @@ -554,6 +616,25 @@ export function FieldsManager() {
+
+ + setEditingCompanySelection(value ?? "all")} + options={companyComboboxOptions} + placeholder="Todas as empresas" + renderValue={(option) => + option ? ( + {option.label} + ) : ( + Todas as empresas + ) + } + /> +

+ Defina uma empresa para restringir este campo apenas aos tickets dela. +

+
diff --git a/src/components/admin/slas/category-sla-drawer.tsx b/src/components/admin/slas/category-sla-drawer.tsx new file mode 100644 index 0000000..50fbd6f --- /dev/null +++ b/src/components/admin/slas/category-sla-drawer.tsx @@ -0,0 +1,400 @@ +"use client" + +import { useEffect, useState } from "react" +import { useMutation, useQuery } from "convex/react" +import { toast } from "sonner" + +import { api } from "@/convex/_generated/api" +import type { Id } from "@/convex/_generated/dataModel" +import type { TicketCategory } from "@/lib/schemas/category" +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" + +const PRIORITY_ROWS = [ + { value: "URGENT", label: "Crítico" }, + { value: "HIGH", label: "Alta" }, + { value: "MEDIUM", label: "Média" }, + { value: "LOW", label: "Baixa" }, + { value: "DEFAULT", label: "Sem prioridade" }, +] as const + +const TIME_UNITS: Array<{ value: RuleFormState["responseUnit"]; label: string; factor: number }> = [ + { value: "minutes", label: "Minutos", factor: 1 }, + { value: "hours", label: "Horas", factor: 60 }, + { value: "days", label: "Dias", factor: 1440 }, +] + +const MODE_OPTIONS: Array<{ value: RuleFormState["responseMode"]; label: string }> = [ + { value: "calendar", label: "Horas corridas" }, + { value: "business", label: "Horas úteis" }, +] + +const PAUSE_STATUS_OPTIONS = [ + { value: "PENDING", label: "Pendente" }, + { value: "AWAITING_ATTENDANCE", label: "Em atendimento" }, + { value: "PAUSED", label: "Pausado" }, +] as const + +const DEFAULT_RULE_STATE: RuleFormState = { + responseValue: "", + responseUnit: "hours", + responseMode: "calendar", + solutionValue: "", + solutionUnit: "hours", + solutionMode: "calendar", + alertThreshold: 80, + pauseStatuses: ["PAUSED"], +} + +type RuleFormState = { + responseValue: string + responseUnit: "minutes" | "hours" | "days" + responseMode: "calendar" | "business" + solutionValue: string + solutionUnit: "minutes" | "hours" | "days" + solutionMode: "calendar" | "business" + alertThreshold: number + pauseStatuses: string[] +} + +export type CategorySlaDrawerProps = { + category: TicketCategory | null + tenantId: string + viewerId: Id<"users"> | null + onClose: () => void +} + +export function CategorySlaDrawer({ category, tenantId, viewerId, onClose }: CategorySlaDrawerProps) { + const [rules, setRules] = useState>(() => buildDefaultRuleState()) + const [saving, setSaving] = useState(false) + const drawerOpen = Boolean(category) + + const canLoad = Boolean(category && viewerId) + const existing = useQuery( + api.categorySlas.get, + canLoad + ? { + tenantId, + viewerId: viewerId as Id<"users">, + categoryId: category!.id as Id<"ticketCategories">, + } + : "skip" + ) as { + rules: Array<{ + priority: string + responseTargetMinutes: number | null + responseMode?: string | null + solutionTargetMinutes: number | null + solutionMode?: string | null + alertThreshold?: number | null + pauseStatuses?: string[] | null + }> + } | undefined + + const saveSla = useMutation(api.categorySlas.save) + + useEffect(() => { + if (!existing?.rules) { + setRules(buildDefaultRuleState()) + return + } + const next = buildDefaultRuleState() + for (const rule of existing.rules) { + const priority = rule.priority?.toUpperCase() ?? "DEFAULT" + next[priority] = convertRuleToForm(rule) + } + setRules(next) + }, [existing, category?.id]) + + const handleChange = (priority: string, patch: Partial) => { + setRules((current) => ({ + ...current, + [priority]: { + ...current[priority], + ...patch, + }, + })) + } + + const togglePause = (priority: string, status: string) => { + setRules((current) => { + const selected = new Set(current[priority].pauseStatuses) + if (selected.has(status)) { + selected.delete(status) + } else { + selected.add(status) + } + if (selected.size === 0) { + selected.add("PAUSED") + } + return { + ...current, + [priority]: { + ...current[priority], + pauseStatuses: Array.from(selected), + }, + } + }) + } + + const handleSave = async () => { + if (!category || !viewerId) return + setSaving(true) + toast.loading("Salvando SLA...", { id: "category-sla" }) + try { + const payload = PRIORITY_ROWS.map((row) => { + const form = rules[row.value] + return { + priority: row.value, + responseTargetMinutes: convertToMinutes(form.responseValue, form.responseUnit), + responseMode: form.responseMode, + solutionTargetMinutes: convertToMinutes(form.solutionValue, form.solutionUnit), + solutionMode: form.solutionMode, + alertThreshold: Math.min(Math.max(form.alertThreshold, 5), 95) / 100, + pauseStatuses: form.pauseStatuses, + } + }) + await saveSla({ + tenantId, + actorId: viewerId, + categoryId: category.id as Id<"ticketCategories">, + rules: payload, + }) + toast.success("SLA atualizado", { id: "category-sla" }) + onClose() + } catch (error) { + console.error(error) + toast.error("Não foi possível salvar as regras de SLA.", { id: "category-sla" }) + } finally { + setSaving(false) + } + } + + return ( + { + if (!open) { + onClose() + } + }} + > + + + Configurar SLA — {category?.name ?? ""} + + Defina metas de resposta e resolução para cada prioridade. Os prazos em horas úteis consideram apenas + segunda a sexta, das 8h às 18h. + + +
+ {PRIORITY_ROWS.map((row) => { + const form = rules[row.value] + return ( +
+
+
+

{row.label}

+

+ {row.value === "DEFAULT" + ? "Aplicado quando o ticket não 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 ( + + ) + })} +
+
+
+
+ ) + })} +
+ + + + +
+
+ ) +} + +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/category-sla-manager.tsx b/src/components/admin/slas/category-sla-manager.tsx new file mode 100644 index 0000000..38105b5 --- /dev/null +++ b/src/components/admin/slas/category-sla-manager.tsx @@ -0,0 +1,94 @@ +"use client" + +import { useMemo, useState } from "react" +import { useQuery } from "convex/react" + +import { api } from "@/convex/_generated/api" +import type { TicketCategory } from "@/lib/schemas/category" +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 { Skeleton } from "@/components/ui/skeleton" + +import { CategorySlaDrawer } from "./category-sla-drawer" + +export function CategorySlaManager() { + const { session, convexUserId } = useAuth() + const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID + const viewerId = convexUserId ? (convexUserId as Id<"users">) : null + + const categories = useQuery(api.categories.list, { tenantId }) as TicketCategory[] | undefined + const [selectedCategory, setSelectedCategory] = useState(null) + + const sortedCategories = useMemo(() => { + if (!categories) return [] + return [...categories].sort((a, b) => a.name.localeCompare(b.name, "pt-BR")) + }, [categories]) + + return ( + <> + + + SLA por categoria + + Ajuste metas específicas por prioridade para cada categoria. Útil quando determinados temas exigem prazos + diferentes das políticas gerais. + + + + {categories === undefined ? ( +
+ {Array.from({ length: 3 }).map((_, index) => ( + + ))} +
+ ) : sortedCategories.length === 0 ? ( +

+ Cadastre categorias em Admin ▸ Campos personalizados para liberar esta configuração. +

+ ) : ( +
+ {sortedCategories.map((category) => ( +
+
+

{category.name}

+ {category.description ? ( +

{category.description}

+ ) : null} + {category.secondary.length ? ( + + {category.secondary.length} subcategorias + + ) : null} +
+ +
+ ))} +
+ )} +
+
+ + setSelectedCategory(null)} + /> + + ) +} diff --git a/src/components/admin/slas/slas-manager.tsx b/src/components/admin/slas/slas-manager.tsx index 268ba4f..25bbfb3 100644 --- a/src/components/admin/slas/slas-manager.tsx +++ b/src/components/admin/slas/slas-manager.tsx @@ -15,6 +15,8 @@ import { Label } from "@/components/ui/label" import { Skeleton } from "@/components/ui/skeleton" import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { CategorySlaManager } from "./category-sla-manager" + type SlaPolicy = { id: string name: string @@ -327,6 +329,8 @@ export function SlasManager() { )}
+ + (!value ? setEditingSla(null) : null)}> diff --git a/src/components/admin/users/admin-users-workspace.tsx b/src/components/admin/users/admin-users-workspace.tsx index 58f12d3..1beef83 100644 --- a/src/components/admin/users/admin-users-workspace.tsx +++ b/src/components/admin/users/admin-users-workspace.tsx @@ -1043,7 +1043,7 @@ function AccountsTable({ ) : templates.length === 0 ? (

Nenhum formulário configurado.

) : ( -
+
{templates.map((template) => (