From b94cea2f9ae4f850f33f61f402ec0f93739f212b Mon Sep 17 00:00:00 2001 From: Esdras Renan Date: Thu, 6 Nov 2025 23:13:41 -0300 Subject: [PATCH] =?UTF-8?q?Ajusta=20placeholders,=20formul=C3=A1rios=20e?= =?UTF-8?q?=20widgets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- convex/_generated/api.d.ts | 4 + convex/dashboards.ts | 6 +- convex/deviceExportTemplates.ts | 25 ++ convex/deviceFieldDefaults.ts | 131 +++++++ convex/deviceFields.ts | 13 + convex/machines.ts | 2 + convex/reports.ts | 137 +++++++ convex/schema.ts | 20 + convex/ticketFormSettings.ts | 31 +- convex/ticketFormTemplates.ts | 280 ++++++++++++++ convex/ticketForms.config.ts | 127 +++++++ convex/tickets.ts | 214 ++++------- docs/alteracoes-2025-11-03.md | 1 + src/app/admin/fields/page.tsx | 2 + src/app/play/page.tsx | 3 +- src/app/reports/categories/page.tsx | 25 ++ src/app/tickets/tickets-page-client.tsx | 1 - .../companies/admin-companies-manager.tsx | 151 ++++++-- .../admin/devices/admin-devices-overview.tsx | 220 ++++++----- .../admin/fields/fields-manager.tsx | 71 +++- .../fields/ticket-form-templates-manager.tsx | 345 ++++++++++++++++++ .../admin/users/admin-users-workspace.tsx | 61 ++-- src/components/app-sidebar.tsx | 2 + .../dashboards/dashboard-builder.tsx | 202 ++++++---- src/components/dashboards/dashboard-list.tsx | 53 ++- src/components/dashboards/widget-renderer.tsx | 65 +++- src/components/reports/category-report.tsx | 300 +++++++++++++++ .../tickets/ticket-custom-fields.tsx | 15 +- .../tickets/ticket-details-panel.tsx | 30 +- .../tickets/ticket-summary-header.tsx | 28 +- src/components/tickets/tickets-table.tsx | 8 +- src/lib/mappers/ticket.ts | 2 + src/lib/schemas/ticket.ts | 9 +- 33 files changed, 2122 insertions(+), 462 deletions(-) create mode 100644 convex/deviceFieldDefaults.ts create mode 100644 convex/ticketFormTemplates.ts create mode 100644 convex/ticketForms.config.ts create mode 100644 src/app/reports/categories/page.tsx create mode 100644 src/components/admin/fields/ticket-form-templates-manager.tsx create mode 100644 src/components/reports/category-report.tsx diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 4efc273..93e829a 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -17,6 +17,7 @@ import type * as companies from "../companies.js"; import type * as crons from "../crons.js"; import type * as dashboards from "../dashboards.js"; import type * as deviceExportTemplates from "../deviceExportTemplates.js"; +import type * as deviceFieldDefaults from "../deviceFieldDefaults.js"; import type * as deviceFields from "../deviceFields.js"; import type * as devices from "../devices.js"; import type * as fields from "../fields.js"; @@ -33,6 +34,7 @@ import type * as seed from "../seed.js"; import type * as slas from "../slas.js"; import type * as teams from "../teams.js"; import type * as ticketFormSettings from "../ticketFormSettings.js"; +import type * as ticketFormTemplates from "../ticketFormTemplates.js"; import type * as ticketNotifications from "../ticketNotifications.js"; import type * as tickets from "../tickets.js"; import type * as users from "../users.js"; @@ -61,6 +63,7 @@ declare const fullApi: ApiFromModules<{ crons: typeof crons; dashboards: typeof dashboards; deviceExportTemplates: typeof deviceExportTemplates; + deviceFieldDefaults: typeof deviceFieldDefaults; deviceFields: typeof deviceFields; devices: typeof devices; fields: typeof fields; @@ -77,6 +80,7 @@ declare const fullApi: ApiFromModules<{ slas: typeof slas; teams: typeof teams; ticketFormSettings: typeof ticketFormSettings; + ticketFormTemplates: typeof ticketFormTemplates; ticketNotifications: typeof ticketNotifications; tickets: typeof tickets; users: typeof users; diff --git a/convex/dashboards.ts b/convex/dashboards.ts index 43d4481..344a150 100644 --- a/convex/dashboards.ts +++ b/convex/dashboards.ts @@ -639,7 +639,9 @@ export const ensureQueueSummaryWidget = mutation({ ) const widgetKey = generateWidgetKey(dashboardId) const config = normalizeQueueSummaryConfig(undefined) - const layout = queueSummaryLayout(widgetKey) + const layoutWithKey = queueSummaryLayout(widgetKey) + const widgetLayout = { ...layoutWithKey } + delete (widgetLayout as { i?: string }).i const widgetId = await ctx.db.insert("dashboardWidgets", { tenantId, dashboardId, @@ -647,7 +649,7 @@ export const ensureQueueSummaryWidget = mutation({ title: config.title, type: "queue-summary", config, - layout, + layout: widgetLayout, order: 0, createdBy: actorId, updatedBy: actorId, diff --git a/convex/deviceExportTemplates.ts b/convex/deviceExportTemplates.ts index 0e11179..c3e9327 100644 --- a/convex/deviceExportTemplates.ts +++ b/convex/deviceExportTemplates.ts @@ -345,3 +345,28 @@ export const setDefault = mutation({ }) }, }) + +export const clearCompanyDefault = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + companyId: v.id("companies"), + }, + handler: async (ctx, { tenantId, actorId, companyId }) => { + await requireAdmin(ctx, actorId, tenantId) + const templates = await ctx.db + .query("deviceExportTemplates") + .withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId)) + .collect() + const now = Date.now() + await Promise.all( + templates.map((tpl) => + ctx.db.patch(tpl._id, { + isDefault: false, + updatedAt: now, + updatedBy: actorId, + }) + ) + ) + }, +}) diff --git a/convex/deviceFieldDefaults.ts b/convex/deviceFieldDefaults.ts new file mode 100644 index 0000000..97c09da --- /dev/null +++ b/convex/deviceFieldDefaults.ts @@ -0,0 +1,131 @@ +"use server"; + +import type { MutationCtx } from "./_generated/server"; +import type { Doc } from "./_generated/dataModel"; + +const DEFAULT_MOBILE_DEVICE_FIELDS: Array<{ + key: string; + label: string; + type: "text" | "select"; + description?: string; + options?: Array<{ value: string; label: string }>; +}> = [ + { + key: "mobile_identificacao", + label: "Identificação interna", + type: "text", + description: "Como o time reconhece este dispositivo (ex.: iPhone da Ana).", + }, + { + key: "mobile_ram", + label: "Memória RAM", + type: "text", + }, + { + key: "mobile_storage", + label: "Armazenamento (HD/SSD)", + type: "text", + }, + { + key: "mobile_cpu", + label: "Processador", + type: "text", + }, + { + key: "mobile_hostname", + label: "Hostname", + type: "text", + }, + { + key: "mobile_patrimonio", + label: "Patrimônio", + type: "text", + }, + { + key: "mobile_observacoes", + label: "Observações", + type: "text", + }, + { + key: "mobile_situacao", + label: "Situação do equipamento", + type: "select", + options: [ + { value: "em_uso", label: "Em uso" }, + { value: "reserva", label: "Reserva" }, + { value: "manutencao", label: "Em manutenção" }, + { value: "inativo", label: "Inativo" }, + ], + }, + { + key: "mobile_cargo", + label: "Cargo", + type: "text", + }, + { + key: "mobile_setor", + label: "Setor", + type: "text", + }, +]; + +export async function ensureMobileDeviceFields(ctx: MutationCtx, tenantId: string) { + const existingMobileFields = await ctx.db + .query("deviceFields") + .withIndex("by_tenant_scope", (q) => q.eq("tenantId", tenantId).eq("scope", "mobile")) + .collect(); + const allFields = await ctx.db + .query("deviceFields") + .withIndex("by_tenant_order", (q) => q.eq("tenantId", tenantId)) + .collect(); + + const existingByKey = new Map>(); + existingMobileFields.forEach((field) => existingByKey.set(field.key, field)); + + let order = allFields.reduce((max, field) => Math.max(max, field.order ?? 0), 0); + const now = Date.now(); + + for (const definition of DEFAULT_MOBILE_DEVICE_FIELDS) { + const current = existingByKey.get(definition.key); + if (current) { + const updates: Partial> = {}; + if ((current.label ?? "").trim() !== definition.label) { + updates.label = definition.label; + } + if ((current.description ?? "") !== (definition.description ?? "")) { + updates.description = definition.description ?? undefined; + } + const existingOptions = JSON.stringify(current.options ?? null); + const desiredOptions = JSON.stringify(definition.options ?? null); + if (existingOptions !== desiredOptions) { + updates.options = definition.options ?? undefined; + } + if (current.type !== definition.type) { + updates.type = definition.type; + } + if (Object.keys(updates).length) { + await ctx.db.patch(current._id, { + ...updates, + updatedAt: now, + }); + } + continue; + } + + order += 1; + await ctx.db.insert("deviceFields", { + tenantId, + key: definition.key, + label: definition.label, + description: definition.description ?? undefined, + type: definition.type, + required: false, + options: definition.options ?? undefined, + scope: "mobile", + companyId: undefined, + order, + createdAt: now, + updatedAt: now, + }); + } +} diff --git a/convex/deviceFields.ts b/convex/deviceFields.ts index b919f22..61d591f 100644 --- a/convex/deviceFields.ts +++ b/convex/deviceFields.ts @@ -4,6 +4,7 @@ import { ConvexError, v } from "convex/values" import type { Id } from "./_generated/dataModel" import { requireAdmin, requireUser } from "./rbac" +import { ensureMobileDeviceFields } from "./deviceFieldDefaults" const FIELD_TYPES = ["text", "number", "select", "multiselect", "date", "boolean"] as const type FieldType = (typeof FIELD_TYPES)[number] @@ -269,3 +270,15 @@ export const reorder = mutation({ ) }, }) + +export const ensureDefaults = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + }, + handler: async (ctx, { tenantId, actorId }) => { + await requireAdmin(ctx, actorId, tenantId) + await ensureMobileDeviceFields(ctx, tenantId) + return { ok: true } + }, +}) diff --git a/convex/machines.ts b/convex/machines.ts index 3802358..bc919b2 100644 --- a/convex/machines.ts +++ b/convex/machines.ts @@ -9,6 +9,7 @@ import type { Doc, Id } from "./_generated/dataModel" import type { MutationCtx, QueryCtx } from "./_generated/server" import { normalizeStatus } from "./tickets" import { requireAdmin } from "./rbac" +import { ensureMobileDeviceFields } from "./deviceFieldDefaults" const DEFAULT_TENANT_ID = "tenant-atlas" const DEFAULT_TOKEN_TTL_MS = 1000 * 60 * 60 * 24 * 30 // 30 dias @@ -1634,6 +1635,7 @@ export const saveDeviceProfile = mutation({ }, handler: async (ctx, args) => { await requireAdmin(ctx, args.actorId, args.tenantId) + await ensureMobileDeviceFields(ctx, args.tenantId) const displayName = args.displayName.trim() if (!displayName) { throw new ConvexError("Informe o nome do dispositivo") diff --git a/convex/reports.ts b/convex/reports.ts index 989923c..4eba586 100644 --- a/convex/reports.ts +++ b/convex/reports.ts @@ -725,6 +725,143 @@ export const agentProductivity = query({ handler: agentProductivityHandler, }) +type CategoryAgentAccumulator = { + id: Id<"ticketCategories"> | null + name: string + total: number + resolved: number + agents: Map | null; name: string | null; total: number }> +} + +export async function ticketCategoryInsightsHandler( + ctx: QueryCtx, + { + tenantId, + viewerId, + range, + companyId, + }: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies"> } +) { + const viewer = await requireStaff(ctx, viewerId, tenantId) + const days = range === "7d" ? 7 : range === "30d" ? 30 : 90 + const end = new Date() + end.setUTCHours(0, 0, 0, 0) + const endMs = end.getTime() + ONE_DAY_MS + const startMs = endMs - days * ONE_DAY_MS + + const inRange = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId) + const categories = await ctx.db + .query("ticketCategories") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect() + + const categoriesById = new Map, Doc<"ticketCategories">>() + for (const category of categories) { + categoriesById.set(category._id, category) + } + + const stats = new Map() + + for (const ticket of inRange) { + const categoryKey = ticket.categoryId ? String(ticket.categoryId) : "uncategorized" + let stat = stats.get(categoryKey) + if (!stat) { + const categoryDoc = ticket.categoryId ? categoriesById.get(ticket.categoryId) : null + stat = { + id: ticket.categoryId ?? null, + name: categoryDoc?.name ?? (ticket.categoryId ? "Categoria removida" : "Sem categoria"), + total: 0, + resolved: 0, + agents: new Map(), + } + stats.set(categoryKey, stat) + } + stat.total += 1 + if (typeof ticket.resolvedAt === "number" && ticket.resolvedAt >= startMs && ticket.resolvedAt < endMs) { + stat.resolved += 1 + } + + const agentKey = ticket.assigneeId ? String(ticket.assigneeId) : "unassigned" + let agent = stat.agents.get(agentKey) + if (!agent) { + const snapshotName = ticket.assigneeSnapshot?.name ?? null + const fallbackName = ticket.assigneeId ? null : "Sem responsável" + agent = { + agentId: ticket.assigneeId ?? null, + name: snapshotName ?? fallbackName ?? "Agente", + total: 0, + } + stat.agents.set(agentKey, agent) + } + agent.total += 1 + } + + const categoriesData = Array.from(stats.values()) + .map((stat) => { + const agents = Array.from(stat.agents.values()).sort((a, b) => b.total - a.total) + const topAgent = agents[0] ?? null + return { + id: stat.id ? String(stat.id) : null, + name: stat.name, + total: stat.total, + resolved: stat.resolved, + topAgent: topAgent + ? { + id: topAgent.agentId ? String(topAgent.agentId) : null, + name: topAgent.name, + total: topAgent.total, + } + : null, + agents: agents.slice(0, 5).map((agent) => ({ + id: agent.agentId ? String(agent.agentId) : null, + name: agent.name, + total: agent.total, + })), + } + }) + .sort((a, b) => b.total - a.total) + + const spotlight = categoriesData.reduce< + | null + | { + categoryId: string | null + categoryName: string + agentId: string | null + agentName: string | null + tickets: number + } + >((best, current) => { + if (!current.topAgent) return best + if (!best || current.topAgent.total > best.tickets) { + return { + categoryId: current.id, + categoryName: current.name, + agentId: current.topAgent.id, + agentName: current.topAgent.name, + tickets: current.topAgent.total, + } + } + return best + }, null) + + return { + rangeDays: days, + totalTickets: inRange.length, + categories: categoriesData, + spotlight, + } +} + +export const categoryInsights = query({ + args: { + tenantId: v.string(), + viewerId: v.id("users"), + range: v.optional(v.string()), + companyId: v.optional(v.id("companies")), + }, + handler: ticketCategoryInsightsHandler, +}) + export async function dashboardOverviewHandler( ctx: QueryCtx, { tenantId, viewerId }: { tenantId: string; viewerId: Id<"users"> } diff --git a/convex/schema.ts b/convex/schema.ts index 61c2ebd..217972c 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -284,6 +284,7 @@ export default defineSchema({ }) ), formTemplate: v.optional(v.string()), + formTemplateLabel: v.optional(v.string()), relatedTicketIds: v.optional(v.array(v.id("tickets"))), resolvedWithTicketId: v.optional(v.id("tickets")), reopenDeadline: v.optional(v.number()), @@ -477,6 +478,25 @@ export default defineSchema({ .index("by_tenant_template_user", ["tenantId", "template", "userId"]) .index("by_tenant", ["tenantId"]), + ticketFormTemplates: defineTable({ + tenantId: v.string(), + key: v.string(), + label: v.string(), + description: v.optional(v.string()), + defaultEnabled: v.optional(v.boolean()), + baseTemplateKey: v.optional(v.string()), + isSystem: v.optional(v.boolean()), + isArchived: v.optional(v.boolean()), + order: v.number(), + createdAt: v.number(), + updatedAt: v.number(), + createdBy: v.optional(v.id("users")), + updatedBy: v.optional(v.id("users")), + }) + .index("by_tenant", ["tenantId"]) + .index("by_tenant_key", ["tenantId", "key"]) + .index("by_tenant_active", ["tenantId", "isArchived"]), + userInvites: defineTable({ tenantId: v.string(), inviteId: v.string(), diff --git a/convex/ticketFormSettings.ts b/convex/ticketFormSettings.ts index 20fa07e..e85d794 100644 --- a/convex/ticketFormSettings.ts +++ b/convex/ticketFormSettings.ts @@ -4,18 +4,11 @@ import { ConvexError, v } from "convex/values" import type { Id } from "./_generated/dataModel" import { requireAdmin } from "./rbac" +import { getTemplateByKey, normalizeFormTemplateKey } from "./ticketFormTemplates" +import { TICKET_FORM_CONFIG } from "./ticketForms.config" -const KNOWN_TEMPLATES = new Set(["admissao", "desligamento"]) const VALID_SCOPES = new Set(["tenant", "company", "user"]) -function normalizeTemplate(input: string) { - const normalized = input.trim().toLowerCase() - if (!KNOWN_TEMPLATES.has(normalized)) { - throw new ConvexError("Template desconhecido") - } - return normalized -} - function normalizeScope(input: string) { const normalized = input.trim().toLowerCase() if (!VALID_SCOPES.has(normalized)) { @@ -24,6 +17,22 @@ function normalizeScope(input: string) { return normalized } +async function ensureTemplateExists(ctx: MutationCtx | QueryCtx, tenantId: string, template: string) { + const normalized = normalizeFormTemplateKey(template) + if (!normalized) { + throw new ConvexError("Template desconhecido") + } + const existing = await getTemplateByKey(ctx, tenantId, normalized) + if (existing && existing.isArchived !== true) { + return normalized + } + const fallback = TICKET_FORM_CONFIG.find((tpl) => tpl.key === normalized) + if (fallback) { + return normalized + } + throw new ConvexError("Template desconhecido") +} + export const list = query({ args: { tenantId: v.string(), @@ -32,7 +41,7 @@ export const list = query({ }, handler: async (ctx, { tenantId, viewerId, template }) => { await requireAdmin(ctx, viewerId, tenantId) - const normalizedTemplate = template ? normalizeTemplate(template) : null + const normalizedTemplate = template ? normalizeFormTemplateKey(template) : null const settings = await ctx.db .query("ticketFormSettings") .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) @@ -65,7 +74,7 @@ export const upsert = mutation({ }, handler: async (ctx, { tenantId, actorId, template, scope, companyId, userId, enabled }) => { await requireAdmin(ctx, actorId, tenantId) - const normalizedTemplate = normalizeTemplate(template) + const normalizedTemplate = await ensureTemplateExists(ctx, tenantId, template) const normalizedScope = normalizeScope(scope) if (normalizedScope === "company" && !companyId) { diff --git a/convex/ticketFormTemplates.ts b/convex/ticketFormTemplates.ts new file mode 100644 index 0000000..b0b964a --- /dev/null +++ b/convex/ticketFormTemplates.ts @@ -0,0 +1,280 @@ +"use server"; + +import { mutation, query } from "./_generated/server"; +import type { MutationCtx, QueryCtx } from "./_generated/server"; +import { ConvexError, v } from "convex/values"; +import type { Doc, Id } from "./_generated/dataModel"; + +import { requireAdmin, requireStaff } from "./rbac"; +import { TICKET_FORM_CONFIG } from "./ticketForms.config"; + +type AnyCtx = MutationCtx | QueryCtx; + +function slugify(input: string) { + return input + .trim() + .toLowerCase() + .normalize("NFD") + .replace(/[^\w\s-]/g, "") + .replace(/\s+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); +} + +export function normalizeFormTemplateKey(input: string | null | undefined): string | null { + if (!input) return null; + const normalized = slugify(input); + return normalized || null; +} + +async function templateKeyExists(ctx: AnyCtx, tenantId: string, key: string) { + const existing = await ctx.db + .query("ticketFormTemplates") + .withIndex("by_tenant_key", (q) => q.eq("tenantId", tenantId).eq("key", key)) + .first(); + return Boolean(existing); +} + +export async function ensureTicketFormTemplatesForTenant(ctx: MutationCtx, tenantId: string) { + const existing = await ctx.db + .query("ticketFormTemplates") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect(); + let order = existing.reduce((max, tpl) => Math.max(max, tpl.order ?? 0), 0); + const now = Date.now(); + for (const template of TICKET_FORM_CONFIG) { + const match = existing.find((tpl) => tpl.key === template.key); + if (match) { + const updates: Partial> = {}; + if (!match.baseTemplateKey) { + updates.baseTemplateKey = template.key; + } + if (match.isSystem !== true) { + updates.isSystem = true; + } + if (typeof match.defaultEnabled === "undefined") { + updates.defaultEnabled = template.defaultEnabled; + } + if (Object.keys(updates).length) { + await ctx.db.patch(match._id, { + ...updates, + updatedAt: now, + }); + } + continue; + } + order += 1; + await ctx.db.insert("ticketFormTemplates", { + tenantId, + key: template.key, + label: template.label, + description: template.description ?? undefined, + defaultEnabled: template.defaultEnabled, + baseTemplateKey: template.key, + isSystem: true, + isArchived: false, + order, + createdAt: now, + updatedAt: now, + }); + } +} + +export async function getTemplateByKey(ctx: AnyCtx, tenantId: string, key: string): Promise | null> { + return ctx.db + .query("ticketFormTemplates") + .withIndex("by_tenant_key", (q) => q.eq("tenantId", tenantId).eq("key", key)) + .first(); +} + +async function generateTemplateKey(ctx: MutationCtx, tenantId: string, label: string) { + const base = slugify(label) || `template-${Date.now()}`; + let candidate = base; + let suffix = 1; + while (await templateKeyExists(ctx, tenantId, candidate)) { + candidate = `${base}-${suffix}`; + suffix += 1; + } + return candidate; +} + +async function cloneFieldsFromTemplate(ctx: MutationCtx, tenantId: string, sourceKey: string, targetKey: string) { + const sourceFields = await ctx.db + .query("ticketFields") + .withIndex("by_tenant_scope", (q) => q.eq("tenantId", tenantId).eq("scope", sourceKey)) + .collect(); + if (sourceFields.length === 0) return; + const ordered = await ctx.db + .query("ticketFields") + .withIndex("by_tenant_order", (q) => q.eq("tenantId", tenantId)) + .collect(); + let order = ordered.reduce((max, field) => Math.max(max, field.order ?? 0), 0); + const now = Date.now(); + for (const field of sourceFields.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))) { + order += 1; + await ctx.db.insert("ticketFields", { + tenantId, + key: field.key, + label: field.label, + description: field.description ?? undefined, + type: field.type, + required: field.required, + options: field.options ?? undefined, + scope: targetKey, + order, + createdAt: now, + updatedAt: now, + }); + } +} + +function mapTemplate(template: Doc<"ticketFormTemplates">) { + return { + id: template._id, + key: template.key, + label: template.label, + description: template.description ?? "", + defaultEnabled: template.defaultEnabled ?? true, + baseTemplateKey: template.baseTemplateKey ?? null, + isSystem: Boolean(template.isSystem), + isArchived: Boolean(template.isArchived), + order: template.order ?? 0, + createdAt: template.createdAt, + updatedAt: template.updatedAt, + }; +} + +export const list = query({ + args: { + tenantId: v.string(), + viewerId: v.id("users"), + includeArchived: v.optional(v.boolean()), + }, + handler: async (ctx, { tenantId, viewerId, includeArchived }) => { + await requireAdmin(ctx, viewerId, tenantId); + const templates = await ctx.db + .query("ticketFormTemplates") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect(); + return templates + .filter((tpl) => includeArchived || tpl.isArchived !== true) + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || a.label.localeCompare(b.label, "pt-BR")) + .map(mapTemplate); + }, +}); + +export const listActive = query({ + args: { + tenantId: v.string(), + viewerId: v.id("users"), + }, + handler: async (ctx, { tenantId, viewerId }) => { + await requireStaff(ctx, viewerId, tenantId); + const templates = await ctx.db + .query("ticketFormTemplates") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect(); + return templates + .filter((tpl) => tpl.isArchived !== true) + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || a.label.localeCompare(b.label, "pt-BR")) + .map(mapTemplate); + }, +}); + +export const create = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + label: v.string(), + description: v.optional(v.string()), + baseTemplateKey: v.optional(v.string()), + cloneFields: v.optional(v.boolean()), + }, + handler: async (ctx, { tenantId, actorId, label, description, baseTemplateKey, cloneFields }) => { + await requireAdmin(ctx, actorId, tenantId); + const trimmedLabel = label.trim(); + if (trimmedLabel.length < 3) { + throw new ConvexError("Informe um nome com pelo menos 3 caracteres"); + } + const key = await generateTemplateKey(ctx, tenantId, trimmedLabel); + const templates = await ctx.db + .query("ticketFormTemplates") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect(); + const order = (templates.reduce((max, tpl) => Math.max(max, tpl.order ?? 0), 0) ?? 0) + 1; + const now = Date.now(); + const templateId = await ctx.db.insert("ticketFormTemplates", { + tenantId, + key, + label: trimmedLabel, + description: description?.trim() || undefined, + defaultEnabled: true, + baseTemplateKey: baseTemplateKey ?? undefined, + isSystem: false, + isArchived: false, + order, + createdAt: now, + updatedAt: now, + createdBy: actorId, + updatedBy: actorId, + }); + if (baseTemplateKey && cloneFields) { + await cloneFieldsFromTemplate(ctx, tenantId, baseTemplateKey, key); + } + return templateId; + }, +}); + +export const update = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + templateId: v.id("ticketFormTemplates"), + label: v.string(), + description: v.optional(v.string()), + isArchived: v.optional(v.boolean()), + defaultEnabled: v.optional(v.boolean()), + order: v.optional(v.number()), + }, + handler: async (ctx, { tenantId, actorId, templateId, label, description, isArchived, defaultEnabled, order }) => { + await requireAdmin(ctx, actorId, tenantId); + const template = await ctx.db.get(templateId); + if (!template || template.tenantId !== tenantId) { + throw new ConvexError("Template não encontrado"); + } + const trimmedLabel = label.trim(); + if (trimmedLabel.length < 3) { + throw new ConvexError("Informe um nome com pelo menos 3 caracteres"); + } + await ctx.db.patch(templateId, { + label: trimmedLabel, + description: description?.trim() || undefined, + isArchived: typeof isArchived === "boolean" ? isArchived : template.isArchived ?? false, + defaultEnabled: typeof defaultEnabled === "boolean" ? defaultEnabled : template.defaultEnabled ?? true, + order: typeof order === "number" ? order : template.order ?? 0, + updatedAt: Date.now(), + updatedBy: actorId, + }); + }, +}); + +export const archive = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + templateId: v.id("ticketFormTemplates"), + archived: v.boolean(), + }, + handler: async (ctx, { tenantId, actorId, templateId, archived }) => { + await requireAdmin(ctx, actorId, tenantId); + const template = await ctx.db.get(templateId); + if (!template || template.tenantId !== tenantId) { + throw new ConvexError("Template não encontrado"); + } + await ctx.db.patch(templateId, { + isArchived: archived, + updatedAt: Date.now(), + updatedBy: actorId, + }); + }, +}); diff --git a/convex/ticketForms.config.ts b/convex/ticketForms.config.ts new file mode 100644 index 0000000..fe36acb --- /dev/null +++ b/convex/ticketForms.config.ts @@ -0,0 +1,127 @@ +"use server"; + +export type TicketFormFieldSeed = { + key: string; + label: string; + type: "text" | "number" | "date" | "select" | "boolean"; + required?: boolean; + description?: string; + options?: Array<{ value: string; label: string }>; +}; + +export const TICKET_FORM_CONFIG = [ + { + key: "admissao" as const, + label: "Admissão de colaborador", + description: "Coleta dados completos para novos colaboradores, incluindo informações pessoais e provisionamento de acesso.", + defaultEnabled: true, + }, + { + key: "desligamento" as const, + label: "Desligamento de colaborador", + description: "Checklist de desligamento com orientações para revogar acessos e coletar equipamentos.", + defaultEnabled: true, + }, +]; + +export const OPTIONAL_ADMISSION_FIELD_KEYS = [ + "colaborador_observacoes", + "colaborador_permissoes_pasta", + "colaborador_equipamento", + "colaborador_grupos_email", + "colaborador_cpf", + "colaborador_rg", + "colaborador_patrimonio", +]; + +export const TICKET_FORM_DEFAULT_FIELDS: Record = { + admissao: [ + { key: "solicitante_nome", label: "Nome do solicitante", type: "text", required: true, description: "Quem está solicitando a admissão." }, + { key: "solicitante_telefone", label: "Telefone do solicitante", type: "text", required: true }, + { key: "solicitante_ramal", label: "Ramal", type: "text" }, + { + key: "solicitante_email", + label: "E-mail do solicitante", + type: "text", + required: true, + description: "Informe um e-mail válido para retornarmos atualizações.", + }, + { key: "colaborador_nome", label: "Nome do colaborador", type: "text", required: true }, + { key: "colaborador_email_desejado", label: "E-mail do colaborador", type: "text", required: true, description: "Endereço de e-mail que deverá ser criado." }, + { key: "colaborador_data_nascimento", label: "Data de nascimento", type: "date", required: true }, + { key: "colaborador_rg", label: "RG", type: "text", required: false }, + { key: "colaborador_cpf", label: "CPF", type: "text", required: false }, + { key: "colaborador_data_inicio", label: "Data de início", type: "date", required: true }, + { key: "colaborador_departamento", label: "Departamento", type: "text", required: true }, + { + key: "colaborador_nova_contratacao", + label: "O colaborador é uma nova contratação?", + type: "select", + required: true, + description: "Informe se é uma nova contratação ou substituição.", + options: [ + { value: "nova", label: "Sim, nova contratação" }, + { value: "substituicao", label: "Não, irá substituir alguém" }, + ], + }, + { + key: "colaborador_substituicao", + label: "Quem será substituído?", + type: "text", + description: "Preencha somente se for uma substituição.", + }, + { + key: "colaborador_grupos_email", + label: "Grupos de e-mail necessários", + type: "text", + required: false, + description: "Liste os grupos ou escreva 'Não se aplica'.", + }, + { + key: "colaborador_equipamento", + label: "Equipamento disponível", + type: "text", + required: false, + description: "Informe se já existe equipamento ou qual deverá ser disponibilizado.", + }, + { + key: "colaborador_permissoes_pasta", + label: "Permissões de pastas", + type: "text", + required: false, + description: "Indique quais pastas ou qual colaborador servirá de referência.", + }, + { + key: "colaborador_observacoes", + label: "Observações adicionais", + type: "text", + required: false, + }, + { + key: "colaborador_patrimonio", + label: "Patrimônio do computador (se houver)", + type: "text", + required: false, + }, + ], + desligamento: [ + { key: "contato_nome", label: "Contato responsável", type: "text", required: true }, + { key: "contato_email", label: "E-mail do contato", type: "text", required: true }, + { key: "contato_telefone", label: "Telefone do contato", type: "text" }, + { key: "colaborador_nome", label: "Nome do colaborador", type: "text", required: true }, + { key: "colaborador_departamento", label: "Departamento do colaborador", type: "text", required: true }, + { + key: "colaborador_email", + label: "E-mail do colaborador", + type: "text", + required: true, + description: "Informe o e-mail que deve ser desativado.", + }, + { + key: "colaborador_patrimonio", + label: "Patrimônio do computador", + type: "text", + description: "Informe o patrimônio se houver equipamento vinculado.", + }, + ], +}; diff --git a/convex/tickets.ts b/convex/tickets.ts index 4c7ad2f..aec1117 100644 --- a/convex/tickets.ts +++ b/convex/tickets.ts @@ -7,6 +7,17 @@ import { Id, type Doc, type DataModel } from "./_generated/dataModel"; import type { NamedTableInfo, Query as ConvexQuery } from "convex/server"; import { requireAdmin, requireStaff, requireUser } from "./rbac"; +import { + OPTIONAL_ADMISSION_FIELD_KEYS, + TICKET_FORM_CONFIG, + TICKET_FORM_DEFAULT_FIELDS, + type TicketFormFieldSeed, +} from "./ticketForms.config"; +import { + ensureTicketFormTemplatesForTenant, + getTemplateByKey, + normalizeFormTemplateKey, +} from "./ticketFormTemplates"; const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT"]); const INTERNAL_STAFF_ROLES = new Set(["ADMIN", "AGENT"]); @@ -45,120 +56,13 @@ const MAX_COMMENT_CHARS = 20000; const DEFAULT_REOPEN_DAYS = 7; const MAX_REOPEN_DAYS = 14; -const TICKET_FORM_CONFIG = [ - { - key: "admissao" as const, - label: "Admissão de colaborador", - description: "Coleta dados completos para novos colaboradores, incluindo informações pessoais e provisionamento de acesso.", - defaultEnabled: true, - }, - { - key: "desligamento" as const, - label: "Desligamento de colaborador", - description: "Checklist de desligamento com orientações para revogar acessos e coletar equipamentos.", - defaultEnabled: true, - }, -]; +type AnyCtx = QueryCtx | MutationCtx; -type TicketFormFieldSeed = { +type TemplateSummary = { key: string; label: string; - type: "text" | "number" | "date" | "select" | "boolean"; - required?: boolean; - description?: string; - options?: Array<{ value: string; label: string }>; -}; - -const TICKET_FORM_DEFAULT_FIELDS: Record = { - admissao: [ - { key: "solicitante_nome", label: "Nome do solicitante", type: "text", required: true, description: "Quem está solicitando a admissão." }, - { key: "solicitante_telefone", label: "Telefone do solicitante", type: "text", required: true }, - { key: "solicitante_ramal", label: "Ramal", type: "text" }, - { - key: "solicitante_email", - label: "E-mail do solicitante", - type: "text", - required: true, - description: "Informe um e-mail válido para retornarmos atualizações.", - }, - { key: "colaborador_nome", label: "Nome do colaborador", type: "text", required: true }, - { key: "colaborador_email_desejado", label: "E-mail do colaborador", type: "text", required: true, description: "Endereço de e-mail que deverá ser criado." }, - { key: "colaborador_data_nascimento", label: "Data de nascimento", type: "date", required: true }, - { key: "colaborador_rg", label: "RG", type: "text", required: true }, - { key: "colaborador_cpf", label: "CPF", type: "text", required: true }, - { key: "colaborador_data_inicio", label: "Data de início", type: "date", required: true }, - { key: "colaborador_departamento", label: "Departamento", type: "text", required: true }, - { - key: "colaborador_nova_contratacao", - label: "O colaborador é uma nova contratação?", - type: "select", - required: true, - description: "Informe se é uma nova contratação ou substituição.", - options: [ - { value: "nova", label: "Sim, nova contratação" }, - { value: "substituicao", label: "Não, irá substituir alguém" }, - ], - }, - { - key: "colaborador_substituicao", - label: "Quem será substituído?", - type: "text", - description: "Preencha somente se for uma substituição.", - }, - { - key: "colaborador_grupos_email", - label: "Grupos de e-mail necessários", - type: "text", - required: true, - description: "Liste os grupos ou escreva 'Não se aplica'.", - }, - { - key: "colaborador_equipamento", - label: "Equipamento disponível", - type: "text", - required: true, - description: "Informe se já existe equipamento ou qual deverá ser disponibilizado.", - }, - { - key: "colaborador_permissoes_pasta", - label: "Permissões de pastas", - type: "text", - required: true, - description: "Indique quais pastas ou qual colaborador servirá de referência.", - }, - { - key: "colaborador_observacoes", - label: "Observações adicionais", - type: "text", - required: true, - }, - { - key: "colaborador_patrimonio", - label: "Patrimônio do computador (se houver)", - type: "text", - required: false, - }, - ], - desligamento: [ - { key: "contato_nome", label: "Contato responsável", type: "text", required: true }, - { key: "contato_email", label: "E-mail do contato", type: "text", required: true }, - { key: "contato_telefone", label: "Telefone do contato", type: "text" }, - { key: "colaborador_nome", label: "Nome do colaborador", type: "text", required: true }, - { key: "colaborador_departamento", label: "Departamento do colaborador", type: "text", required: true }, - { - key: "colaborador_email", - label: "E-mail do colaborador", - type: "text", - required: true, - description: "Informe o e-mail que deve ser desativado.", - }, - { - key: "colaborador_patrimonio", - label: "Patrimônio do computador", - type: "text", - description: "Informe o patrimônio se houver equipamento vinculado.", - }, - ], + description: string; + defaultEnabled: boolean; }; function plainTextLength(html: string): number { @@ -182,20 +86,6 @@ function escapeHtml(input: string): string { .replace(/'/g, "'"); } -function normalizeFormTemplateKey(input: string | null | undefined): string | null { - if (!input) return null; - const trimmed = input.trim(); - if (!trimmed) return null; - const normalized = trimmed - .normalize("NFD") - .replace(/[^\w\s-]/g, "") - .replace(/\s+/g, "-") - .replace(/-+/g, "-") - .replace(/^-|-$/g, "") - .toLowerCase(); - return normalized || null; -} - function resolveReopenWindowDays(input?: number | null): number { if (typeof input !== "number" || !Number.isFinite(input)) { return DEFAULT_REOPEN_DAYS; @@ -286,7 +176,32 @@ function resolveFormEnabled( return baseEnabled } +async function fetchTemplateSummaries(ctx: AnyCtx, tenantId: string): Promise { + const templates = await ctx.db + .query("ticketFormTemplates") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect(); + if (!templates.length) { + return TICKET_FORM_CONFIG.map((template) => ({ + key: template.key, + label: template.label, + description: template.description, + defaultEnabled: template.defaultEnabled, + })); + } + return templates + .filter((tpl) => tpl.isArchived !== true) + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || a.label.localeCompare(b.label, "pt-BR")) + .map((tpl) => ({ + key: tpl.key, + label: tpl.label, + description: tpl.description ?? "", + defaultEnabled: tpl.defaultEnabled ?? true, + })); +} + async function ensureTicketFormDefaultsForTenant(ctx: MutationCtx, tenantId: string) { + await ensureTicketFormTemplatesForTenant(ctx, tenantId); const now = Date.now(); for (const template of TICKET_FORM_CONFIG) { const defaults = TICKET_FORM_DEFAULT_FIELDS[template.key] ?? []; @@ -297,18 +212,23 @@ async function ensureTicketFormDefaultsForTenant(ctx: MutationCtx, tenantId: str .query("ticketFields") .withIndex("by_tenant_scope", (q) => q.eq("tenantId", tenantId).eq("scope", template.key)) .collect(); - // Hotfix: garantir que "Patrimônio do computador (se houver)" seja opcional na admissão if (template.key === "admissao") { - const patrimonio = existing.find((f) => f.key === "colaborador_patrimonio"); - if (patrimonio) { - const shouldBeOptional = false; - const needsRequiredFix = Boolean(patrimonio.required) !== shouldBeOptional; - const desiredLabel = "Patrimônio do computador (se houver)"; - const needsLabelFix = (patrimonio.label ?? "").trim() !== desiredLabel; - if (needsRequiredFix || needsLabelFix) { - await ctx.db.patch(patrimonio._id, { - required: shouldBeOptional, - label: desiredLabel, + for (const key of OPTIONAL_ADMISSION_FIELD_KEYS) { + const field = existing.find((f) => f.key === key); + if (!field) continue; + const updates: Partial> = {}; + if (field.required) { + updates.required = false; + } + if (key === "colaborador_patrimonio") { + const desiredLabel = "Patrimônio do computador (se houver)"; + if ((field.label ?? "").trim() !== desiredLabel) { + updates.label = desiredLabel; + } + } + if (Object.keys(updates).length) { + await ctx.db.patch(field._id, { + ...updates, updatedAt: now, }); } @@ -1242,6 +1162,7 @@ export const list = query({ csatRatedAt: t.csatRatedAt ?? null, csatRatedBy: t.csatRatedBy ? String(t.csatRatedBy) : null, formTemplate: t.formTemplate ?? null, + formTemplateLabel: t.formTemplateLabel ?? null, company: company ? { id: company._id, name: company.name, isAvulso: company.isAvulso ?? false } : t.companyId || t.companySnapshot @@ -1551,6 +1472,7 @@ export const getById = query({ })), }, formTemplate: t.formTemplate ?? null, + formTemplateLabel: t.formTemplateLabel ?? null, chatEnabled: Boolean(t.chatEnabled), relatedTicketIds: Array.isArray(t.relatedTicketIds) ? t.relatedTicketIds.map((id) => String(id)) : [], resolvedWithTicketId: t.resolvedWithTicketId ? String(t.resolvedWithTicketId) : null, @@ -1668,7 +1590,21 @@ export const create = mutation({ machineDoc = machine } - const formTemplateKey = normalizeFormTemplateKey(args.formTemplate ?? null); + let formTemplateKey = normalizeFormTemplateKey(args.formTemplate ?? null); + let formTemplateLabel: string | null = null; + if (formTemplateKey) { + const templateDoc = await getTemplateByKey(ctx, args.tenantId, formTemplateKey); + if (templateDoc && templateDoc.isArchived !== true) { + formTemplateLabel = templateDoc.label; + } else { + const fallbackTemplate = TICKET_FORM_CONFIG.find((tpl) => tpl.key === formTemplateKey); + if (fallbackTemplate) { + formTemplateLabel = fallbackTemplate.label; + } else { + formTemplateKey = null; + } + } + } const chatEnabled = typeof args.chatEnabled === "boolean" ? args.chatEnabled : true; const normalizedCustomFields = await normalizeCustomFieldValues( ctx, @@ -1752,6 +1688,7 @@ export const create = mutation({ } : undefined, formTemplate: formTemplateKey ?? undefined, + formTemplateLabel: formTemplateLabel ?? undefined, chatEnabled, working: false, activeSessionId: undefined, @@ -2470,6 +2407,7 @@ export const listTicketForms = query({ fieldsByScope.get(scope)!.push(definition) } + const templates = await fetchTemplateSummaries(ctx, tenantId) const forms = [] as Array<{ key: string label: string @@ -2485,7 +2423,7 @@ export const listTicketForms = query({ }> }> - for (const template of TICKET_FORM_CONFIG) { + for (const template of templates) { let enabled = resolveFormEnabled(template.key, template.defaultEnabled, settings as Doc<"ticketFormSettings">[], { companyId: viewerCompanyId, userId: viewer.user._id, diff --git a/docs/alteracoes-2025-11-03.md b/docs/alteracoes-2025-11-03.md index 13064bf..aacefd7 100644 --- a/docs/alteracoes-2025-11-03.md +++ b/docs/alteracoes-2025-11-03.md @@ -25,6 +25,7 @@ - [x] Filtros de empresa nos relatórios/dashboards (Backlog, SLA, Horas, alertas e gráficos) usam combobox pesquisável, facilitando encontrar clientes. - [x] Campos adicionais de admissão/desligamento organizados em grid responsivo de duas colunas (admin e portal), mantendo booleanos/textareas em largura total. - [x] Templates de admissão e desligamento com campos dinâmicos habilitados no painel e no portal/desktop, incluindo garantia automática dos campos padrão via `ensureTicketFormDefaults`. +- [x] Relatório de categorias e agentes com filtros por período/empresa, gráfico de volume e destaque do agente que mais atende cada tema. ## Riscos - Necessário validar migração dos dados existentes (máquinas → dispositivos) antes de entrar em produção. diff --git a/src/app/admin/fields/page.tsx b/src/app/admin/fields/page.tsx index 479df3a..6666ace 100644 --- a/src/app/admin/fields/page.tsx +++ b/src/app/admin/fields/page.tsx @@ -1,5 +1,6 @@ import { CategoriesManager } from "@/components/admin/categories/categories-manager" import { FieldsManager } from "@/components/admin/fields/fields-manager" +import { TicketFormTemplatesManager } from "@/components/admin/fields/ticket-form-templates-manager" import { AppShell } from "@/components/app-shell" import { SiteHeader } from "@/components/site-header" @@ -17,6 +18,7 @@ export default function AdminFieldsPage() { >
+
diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index 6e61335..ffcd538 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -1,5 +1,5 @@ import { AppShell } from "@/components/app-shell" -import { SiteHeader, SiteHeaderPrimaryButton, SiteHeaderSecondaryButton } from "@/components/site-header" +import { SiteHeader, SiteHeaderPrimaryButton } from "@/components/site-header" import { PlayNextTicketCard } from "@/components/tickets/play-next-ticket-card" import { TicketQueueSummaryCards } from "@/components/tickets/ticket-queue-summary" import { requireAuthenticatedSession } from "@/lib/auth-server" @@ -12,7 +12,6 @@ export default async function PlayPage() { Pausar notificações} primaryAction={Iniciar sessão} /> } diff --git a/src/app/reports/categories/page.tsx b/src/app/reports/categories/page.tsx new file mode 100644 index 0000000..6cea5be --- /dev/null +++ b/src/app/reports/categories/page.tsx @@ -0,0 +1,25 @@ +import { AppShell } from "@/components/app-shell" +import { CategoryReport } from "@/components/reports/category-report" +import { SiteHeader } from "@/components/site-header" +import { requireAuthenticatedSession } from "@/lib/auth-server" + +export const dynamic = "force-dynamic" + +export default async function ReportsCategoriesPage() { + await requireAuthenticatedSession() + return ( + + } + > +
+ +
+
+ ) +} + diff --git a/src/app/tickets/tickets-page-client.tsx b/src/app/tickets/tickets-page-client.tsx index e9fd057..bacb255 100644 --- a/src/app/tickets/tickets-page-client.tsx +++ b/src/app/tickets/tickets-page-client.tsx @@ -36,7 +36,6 @@ export function TicketsPageClient() { Exportar CSV} secondaryAction={} /> } diff --git a/src/components/admin/companies/admin-companies-manager.tsx b/src/components/admin/companies/admin-companies-manager.tsx index 760a33b..8135ddd 100644 --- a/src/components/admin/companies/admin-companies-manager.tsx +++ b/src/components/admin/companies/admin-companies-manager.tsx @@ -1,6 +1,6 @@ "use client" -import { useCallback, useEffect, useMemo, useState, useTransition } from "react" +import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react" import { Controller, FormProvider, useFieldArray, useForm, type UseFormReturn } from "react-hook-form" import { zodResolver } from "@hookform/resolvers/zod" import { @@ -80,6 +80,7 @@ import { Textarea } from "@/components/ui/textarea" import { TimePicker } from "@/components/ui/time-picker" import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion" +import { Skeleton } from "@/components/ui/skeleton" import { useQuery, useMutation } from "convex/react" import { useAuth } from "@/lib/auth-client" import type { Id } from "@/convex/_generated/dataModel" @@ -1691,6 +1692,7 @@ function CompanySheet({ tenantId, editor, onClose, onCreated, onUpdated }: Compa Tipos de solicitação + @@ -2187,13 +2189,28 @@ type CompanyRequestTypesControlsProps = { tenantId?: string | null; companyId: s function CompanyRequestTypesControls({ tenantId, companyId }: CompanyRequestTypesControlsProps) { const { convexUserId } = useAuth() const canLoad = Boolean(tenantId && convexUserId) + const ensureDefaults = useMutation(api.tickets.ensureTicketFormDefaults) + const hasEnsuredRef = useRef(false) const settings = useQuery( api.ticketFormSettings.list, canLoad ? { tenantId: tenantId as string, viewerId: convexUserId as Id<"users"> } : "skip" ) as Array<{ template: string; scope: string; companyId?: string | null; enabled: boolean; updatedAt: number }> | undefined + const templates = useQuery( + api.ticketFormTemplates.listActive, + canLoad ? { tenantId: tenantId as string, viewerId: convexUserId as Id<"users"> } : "skip" + ) as Array<{ key: string; label: string }> | undefined const upsert = useMutation(api.ticketFormSettings.upsert) - const resolveEnabled = (template: "admissao" | "desligamento") => { + useEffect(() => { + if (!tenantId || !convexUserId || hasEnsuredRef.current) return + hasEnsuredRef.current = true + ensureDefaults({ tenantId, actorId: convexUserId as Id<"users"> }).catch((error) => { + console.error("Falha ao garantir formulários padrão", error) + hasEnsuredRef.current = false + }) + }, [ensureDefaults, tenantId, convexUserId]) + + const resolveEnabled = (template: string) => { const scoped = (settings ?? []).filter((s) => s.template === template) const base = true if (!companyId) return base @@ -2203,10 +2220,7 @@ function CompanyRequestTypesControls({ tenantId, companyId }: CompanyRequestType return typeof latest?.enabled === "boolean" ? latest.enabled : base } - const admissaoEnabled = resolveEnabled("admissao") - const desligamentoEnabled = resolveEnabled("desligamento") - - const handleToggle = async (template: "admissao" | "desligamento", enabled: boolean) => { + const handleToggle = async (template: string, enabled: boolean) => { if (!tenantId || !convexUserId || !companyId) return try { await upsert({ @@ -2227,24 +2241,113 @@ function CompanyRequestTypesControls({ tenantId, companyId }: CompanyRequestType return (

Defina quais tipos de solicitação estão disponíveis para colaboradores/gestores desta empresa. Administradores e agentes sempre veem todas as opções.

-
- - -
+ {!templates ? ( +
+ + +
+ ) : templates.length === 0 ? ( +

Nenhum formulário disponível.

+ ) : ( +
+ {templates.map((template) => { + const enabled = resolveEnabled(template.key) + return ( + + ) + })} +
+ )} +
+ ) +} + +type CompanyExportTemplateSelectorProps = { tenantId?: string | null; companyId: string | null } +function CompanyExportTemplateSelector({ tenantId, companyId }: CompanyExportTemplateSelectorProps) { + const { convexUserId } = useAuth() + const canLoad = Boolean(tenantId && convexUserId) + const templates = useQuery( + api.deviceExportTemplates.list, + canLoad + ? { + tenantId: tenantId as string, + viewerId: convexUserId as Id<"users">, + companyId: companyId ? (companyId as unknown as Id<"companies">) : undefined, + includeInactive: true, + } + : "skip" + ) as Array<{ id: string; name: string; companyId: string | null; isDefault: boolean; description?: string }> | undefined + const setDefaultTemplate = useMutation(api.deviceExportTemplates.setDefault) + const clearDefaultTemplate = useMutation(api.deviceExportTemplates.clearCompanyDefault) + + const companyTemplates = useMemo(() => { + if (!templates || !companyId) return [] + return templates.filter((tpl) => String(tpl.companyId ?? "") === String(companyId)) + }, [templates, companyId]) + + const companyDefault = useMemo(() => companyTemplates.find((tpl) => tpl.isDefault) ?? null, [companyTemplates]) + + const handleChange = async (value: string) => { + if (!tenantId || !convexUserId || !companyId) return + try { + if (value === "inherit") { + await clearDefaultTemplate({ + tenantId, + actorId: convexUserId as Id<"users">, + companyId: companyId as unknown as Id<"companies">, + }) + toast.success("Template desta empresa voltou a herdar o padrão global.") + } else { + await setDefaultTemplate({ + tenantId, + actorId: convexUserId as Id<"users">, + templateId: value as Id<"deviceExportTemplates">, + }) + toast.success("Template aplicado para esta empresa.") + } + } catch (error) { + console.error("Falha ao definir template de exportação", error) + toast.error("Não foi possível atualizar o template.") + } + } + + const selectValue = companyDefault ? companyDefault.id : "inherit" + + return ( +
+

+ Defina o template padrão das exportações de inventário para esta empresa. Ao herdar, o template global será utilizado. +

+ {!companyId ? ( +

Salve a empresa antes de configurar o template.

+ ) : !templates ? ( + + ) : companyTemplates.length === 0 ? ( +

+ Nenhum template específico para esta empresa. Crie um template em Dispositivos > Exportações e associe a esta empresa para habilitar aqui. +

+ ) : ( + + )}
) } diff --git a/src/components/admin/devices/admin-devices-overview.tsx b/src/components/admin/devices/admin-devices-overview.tsx index 4b8f17a..553a22f 100644 --- a/src/components/admin/devices/admin-devices-overview.tsx +++ b/src/components/admin/devices/admin-devices-overview.tsx @@ -2441,6 +2441,9 @@ export function DeviceDetails({ device }: DeviceDetailsProps) { const effectiveStatus = device ? resolveDeviceStatus(device) : "unknown" const [isActiveLocal, setIsActiveLocal] = useState(device?.isActive ?? true) const isDeactivated = !isActiveLocal || effectiveStatus === "deactivated" + const isManualMobile = + (device?.managementMode ?? "").toLowerCase() === "manual" && + (device?.deviceType ?? "").toLowerCase() === "mobile" const alertsHistory = useQuery( api.devices.listAlerts, device ? { machineId: device.id as Id<"machines">, limit: 50 } : "skip" @@ -3578,6 +3581,11 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {

{device.displayName ?? device.hostname ?? "Dispositivo"}

+ {isManualMobile ? ( + + Identificação interna + + ) : null} - - + {!isManualMobile ? ( + <> + + + + ) : null} {device.registeredBy ? ( -
-

Sincronização

-
-
- Último heartbeat - - {formatRelativeTime(lastHeartbeatDate)} - + {!isManualMobile ? ( +
+

Sincronização

+
+
+ Último heartbeat + + {formatRelativeTime(lastHeartbeatDate)} + +
+
+ Criada em + {formatDate(new Date(device.createdAt))} +
+
+ Atualizada em + {formatDate(new Date(device.updatedAt))} +
+
+ Token expira + + {tokenExpiry ? formatRelativeTime(tokenExpiry) : "—"} + +
+
+ Token usado por último + + {tokenLastUsed ? formatRelativeTime(tokenLastUsed) : "—"} + +
+
+ Uso do token + {device.token?.usageCount ?? 0} trocas +
-
- Criada em - {formatDate(new Date(device.createdAt))} -
-
- Atualizada em - {formatDate(new Date(device.updatedAt))} -
-
- Token expira - - {tokenExpiry ? formatRelativeTime(tokenExpiry) : "—"} - -
-
- Token usado por último - - {tokenLastUsed ? formatRelativeTime(tokenLastUsed) : "—"} - -
-
- Uso do token - {device.token?.usageCount ?? 0} trocas -
-
-
+ + ) : null} -
-
-

Métricas recentes

- {lastUpdateRelative ? ( - - Última atualização {lastUpdateRelative} - - ) : null} -
- -
+ {!isManualMobile ? ( +
+
+

Métricas recentes

+ {lastUpdateRelative ? ( + + Última atualização {lastUpdateRelative} + + ) : null} +
+ +
+ ) : null} - {hardware || network || (labels && labels.length > 0) ? ( + {!isManualMobile && (hardware || network || (labels && labels.length > 0)) ? (

Inventário

@@ -5145,39 +5161,41 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
) : null} -
-
-

Histórico de alertas

- {deviceAlertsHistory.length > 0 ? ( - - Últimos {deviceAlertsHistory.length} {deviceAlertsHistory.length === 1 ? "evento" : "eventos"} - - ) : null} -
- {deviceAlertsHistory.length > 0 ? ( -
-
-
    - {deviceAlertsHistory.map((alert) => { - const date = new Date(alert.createdAt) - return ( -
  1. - -
    - {formatPostureAlertKind(alert.kind)} - {formatRelativeTime(date)} -
    -

    {alert.message ?? formatPostureAlertKind(alert.kind)}

    -

    {format(date, "dd/MM/yyyy HH:mm:ss")}

    -
  2. - ) - })} -
+ {!isManualMobile ? ( +
+
+

Histórico de alertas

+ {deviceAlertsHistory.length > 0 ? ( + + Últimos {deviceAlertsHistory.length} {deviceAlertsHistory.length === 1 ? "evento" : "eventos"} + + ) : null}
- ) : ( -

Nenhum alerta registrado para este dispositivo.

- )} -
+ {deviceAlertsHistory.length > 0 ? ( +
+
+
    + {deviceAlertsHistory.map((alert) => { + const date = new Date(alert.createdAt) + return ( +
  1. + +
    + {formatPostureAlertKind(alert.kind)} + {formatRelativeTime(date)} +
    +

    {alert.message ?? formatPostureAlertKind(alert.kind)}

    +

    {format(date, "dd/MM/yyyy HH:mm:ss")}

    +
  2. + ) + })} +
+
+ ) : ( +

Nenhum alerta registrado para este dispositivo.

+ )} +
+ ) : null}
{Array.isArray(software) && software.length > 0 ? ( diff --git a/src/components/admin/fields/fields-manager.tsx b/src/components/admin/fields/fields-manager.tsx index b46faf0..9432af2 100644 --- a/src/components/admin/fields/fields-manager.tsx +++ b/src/components/admin/fields/fields-manager.tsx @@ -29,6 +29,7 @@ type Field = { required: boolean options: FieldOption[] order: number + scope: string } const TYPE_LABELS: Record = { @@ -48,6 +49,25 @@ export function FieldsManager() { convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip" ) as Field[] | undefined + const templates = useQuery( + api.ticketFormTemplates.listActive, + convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip" + ) as Array<{ id: string; key: string; label: string }> | undefined + + const scopeOptions = useMemo( + () => [ + { value: "all", label: "Todos os formulários" }, + ...((templates ?? []).map((tpl) => ({ value: tpl.key, label: tpl.label })) ?? []), + ], + [templates] + ) + + const templateLabelByKey = useMemo(() => { + const map = new Map() + templates?.forEach((tpl) => map.set(tpl.key, tpl.label)) + return map + }, [templates]) + const createField = useMutation(api.fields.create) const updateField = useMutation(api.fields.update) const removeField = useMutation(api.fields.remove) @@ -58,8 +78,10 @@ export function FieldsManager() { const [type, setType] = useState("text") const [required, setRequired] = useState(false) const [options, setOptions] = useState([]) + const [scopeSelection, setScopeSelection] = useState("all") const [saving, setSaving] = useState(false) const [editingField, setEditingField] = useState(null) + const [editingScope, setEditingScope] = useState("all") const totals = useMemo(() => { if (!fields) return { total: 0, required: 0, select: 0 } @@ -76,6 +98,7 @@ export function FieldsManager() { setType("text") setRequired(false) setOptions([]) + setScopeSelection("all") } const normalizeOptions = (source: FieldOption[]) => @@ -97,6 +120,7 @@ export function FieldsManager() { return } const preparedOptions = type === "select" ? normalizeOptions(options) : undefined + const scopeValue = scopeSelection === "all" ? undefined : scopeSelection setSaving(true) toast.loading("Criando campo...", { id: "field" }) try { @@ -108,6 +132,7 @@ export function FieldsManager() { type, required, options: preparedOptions, + scope: scopeValue, }) toast.success("Campo criado", { id: "field" }) resetForm() @@ -147,6 +172,7 @@ export function FieldsManager() { setType(field.type) setRequired(field.required) setOptions(field.options) + setEditingScope(field.scope ?? "all") } const handleUpdate = async () => { @@ -160,6 +186,7 @@ export function FieldsManager() { return } const preparedOptions = type === "select" ? normalizeOptions(options) : undefined + const scopeValue = editingScope === "all" ? undefined : editingScope setSaving(true) toast.loading("Atualizando campo...", { id: "field-edit" }) try { @@ -172,6 +199,7 @@ export function FieldsManager() { type, required, options: preparedOptions, + scope: scopeValue, }) toast.success("Campo atualizado", { id: "field-edit" }) setEditingField(null) @@ -304,6 +332,21 @@ export function FieldsManager() { Campo obrigatório na abertura
+
+ + +
@@ -378,7 +421,12 @@ export function FieldsManager() { ) : ( - fields.map((field, index) => ( + fields.map((field, index) => { + const scopeLabel = + field.scope === "all" + ? "Todos os formulários" + : templateLabelByKey.get(field.scope) ?? `Formulário: ${field.scope}` + return (
@@ -395,6 +443,9 @@ export function FieldsManager() { ) : null}
Identificador: {field.key} + + {scopeLabel} + {field.description ? (

{field.description}

) : null} @@ -446,7 +497,8 @@ export function FieldsManager() { ) : null}
- )) + ) + }) )}
@@ -487,6 +539,21 @@ export function FieldsManager() { Campo obrigatório na abertura
+
+ + +
diff --git a/src/components/admin/fields/ticket-form-templates-manager.tsx b/src/components/admin/fields/ticket-form-templates-manager.tsx new file mode 100644 index 0000000..7804266 --- /dev/null +++ b/src/components/admin/fields/ticket-form-templates-manager.tsx @@ -0,0 +1,345 @@ +"use client" + +import { useEffect, useMemo, useRef, useState } from "react" +import { useMutation, useQuery } from "convex/react" +import { toast } from "sonner" +import { Plus, MoreHorizontal, Archive, RefreshCcw } from "lucide-react" + +import { api } from "@/convex/_generated/api" +import type { Id } from "@/convex/_generated/dataModel" +import { DEFAULT_TENANT_ID } from "@/lib/constants" +import { useAuth } from "@/lib/auth-client" +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { Badge } from "@/components/ui/badge" +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" +import { Checkbox } from "@/components/ui/checkbox" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Skeleton } from "@/components/ui/skeleton" + +type Template = { + id: string + key: string + label: string + description: string + defaultEnabled: boolean + baseTemplateKey: string | null + isSystem: boolean + isArchived: boolean + order: number +} + +export function TicketFormTemplatesManager() { + const { session, convexUserId } = useAuth() + const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID + const viewerId = convexUserId as Id<"users"> | null + + const ensureDefaults = useMutation(api.tickets.ensureTicketFormDefaults) + const createTemplate = useMutation(api.ticketFormTemplates.create) + const updateTemplate = useMutation(api.ticketFormTemplates.update) + const archiveTemplate = useMutation(api.ticketFormTemplates.archive) + + const hasEnsuredRef = useRef(false) + useEffect(() => { + if (!viewerId || hasEnsuredRef.current) return + hasEnsuredRef.current = true + ensureDefaults({ tenantId, actorId: viewerId }).catch((error) => { + console.error("[ticket-templates] ensure defaults failed", error) + hasEnsuredRef.current = false + }) + }, [ensureDefaults, tenantId, viewerId]) + + const templates = useQuery( + api.ticketFormTemplates.list, + viewerId ? { tenantId, viewerId } : "skip" + ) as Template[] | undefined + + const [createDialogOpen, setCreateDialogOpen] = useState(false) + const [newLabel, setNewLabel] = useState("") + const [newDescription, setNewDescription] = useState("") + const [baseTemplate, setBaseTemplate] = useState("") + const [cloneFields, setCloneFields] = useState(true) + const [creating, setCreating] = useState(false) + + const [editingTemplate, setEditingTemplate] = useState