Ajusta placeholders, formulários e widgets

This commit is contained in:
Esdras Renan 2025-11-06 23:13:41 -03:00
parent 343f0c8c64
commit b94cea2f9a
33 changed files with 2122 additions and 462 deletions

View file

@ -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;

View file

@ -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,

View file

@ -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,
})
)
)
},
})

View file

@ -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<string, Doc<"deviceFields">>();
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<Doc<"deviceFields">> = {};
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,
});
}
}

View file

@ -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 }
},
})

View file

@ -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")

View file

@ -725,6 +725,143 @@ export const agentProductivity = query({
handler: agentProductivityHandler,
})
type CategoryAgentAccumulator = {
id: Id<"ticketCategories"> | null
name: string
total: number
resolved: number
agents: Map<string, { agentId: Id<"users"> | 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<Id<"ticketCategories">, Doc<"ticketCategories">>()
for (const category of categories) {
categoriesById.set(category._id, category)
}
const stats = new Map<string, CategoryAgentAccumulator>()
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"> }

View file

@ -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(),

View file

@ -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) {

View file

@ -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<Doc<"ticketFormTemplates">> = {};
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<Doc<"ticketFormTemplates"> | 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,
});
},
});

View file

@ -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<string, TicketFormFieldSeed[]> = {
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.",
},
],
};

View file

@ -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<string, TicketFormFieldSeed[]> = {
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, "&#39;");
}
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<TemplateSummary[]> {
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;
for (const key of OPTIONAL_ADMISSION_FIELD_KEYS) {
const field = existing.find((f) => f.key === key);
if (!field) continue;
const updates: Partial<Doc<"ticketFields">> = {};
if (field.required) {
updates.required = false;
}
if (key === "colaborador_patrimonio") {
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,
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,

View file

@ -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.

View file

@ -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() {
>
<div className="mx-auto w-full max-w-6xl space-y-8 px-6 lg:px-8">
<CategoriesManager />
<TicketFormTemplatesManager />
<FieldsManager />
</div>
</AppShell>

View file

@ -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() {
<SiteHeader
title="Modo play"
lead="Distribua tickets automaticamente conforme prioridade"
secondaryAction={<SiteHeaderSecondaryButton>Pausar notificações</SiteHeaderSecondaryButton>}
primaryAction={<SiteHeaderPrimaryButton>Iniciar sessão</SiteHeaderPrimaryButton>}
/>
}

View file

@ -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 (
<AppShell
header={
<SiteHeader
title="Categorias e agentes"
lead="Acompanhe os temas mais atendidos e descubra quais agentes concentram cada tipo de solicitação."
/>
}
>
<div className="mx-auto w-full max-w-6xl px-4 pb-12 lg:px-6">
<CategoryReport />
</div>
</AppShell>
)
}

View file

@ -36,7 +36,6 @@ export function TicketsPageClient() {
<SiteHeader
title="Tickets"
lead="Visão consolidada de filas e SLAs"
primaryAction={<SiteHeader.SecondaryButton>Exportar CSV</SiteHeader.SecondaryButton>}
secondaryAction={<NewTicketDialog />}
/>
}

View file

@ -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
<AccordionTrigger className="py-3 font-semibold">Tipos de solicitação</AccordionTrigger>
<AccordionContent className="pb-5">
<CompanyRequestTypesControls tenantId={tenantId} companyId={editor?.mode === "edit" ? editor.company.id : null} />
<CompanyExportTemplateSelector tenantId={tenantId} companyId={editor?.mode === "edit" ? editor.company.id : null} />
</AccordionContent>
</AccordionItem>
@ -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 (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">Defina quais tipos de solicitação estão disponíveis para colaboradores/gestores desta empresa. Administradores e agentes sempre veem todas as opções.</p>
<div className="grid gap-3 sm:grid-cols-2">
<label className="flex items-center gap-2 text-sm text-foreground">
<Checkbox
checked={admissaoEnabled}
onCheckedChange={(v) => handleToggle("admissao", Boolean(v))}
disabled={!companyId}
/>
<span>Admissão de colaborador</span>
</label>
<label className="flex items-center gap-2 text-sm text-foreground">
<Checkbox
checked={desligamentoEnabled}
onCheckedChange={(v) => handleToggle("desligamento", Boolean(v))}
disabled={!companyId}
/>
<span>Desligamento de colaborador</span>
</label>
{!templates ? (
<div className="space-y-2">
<Skeleton className="h-10 w-full rounded-lg" />
<Skeleton className="h-10 w-full rounded-lg" />
</div>
) : templates.length === 0 ? (
<p className="text-sm text-neutral-500">Nenhum formulário disponível.</p>
) : (
<div className="grid gap-3 sm:grid-cols-2">
{templates.map((template) => {
const enabled = resolveEnabled(template.key)
return (
<label key={template.key} className="flex items-center gap-2 text-sm text-foreground">
<Checkbox
checked={enabled}
onCheckedChange={(v) => handleToggle(template.key, Boolean(v))}
disabled={!companyId}
/>
<span>{template.label}</span>
</label>
)
})}
</div>
)}
</div>
)
}
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 (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Defina o template padrão das exportações de inventário para esta empresa. Ao herdar, o template global será utilizado.
</p>
{!companyId ? (
<p className="text-xs text-neutral-500">Salve a empresa antes de configurar o template.</p>
) : !templates ? (
<Skeleton className="h-10 w-full rounded-md" />
) : companyTemplates.length === 0 ? (
<p className="text-xs text-neutral-500">
Nenhum template específico para esta empresa. Crie um template em <span className="font-semibold">Dispositivos &gt; Exportações</span> e associe a esta empresa para habilitar aqui.
</p>
) : (
<Select value={selectValue} onValueChange={handleChange} disabled={!companyId}>
<SelectTrigger>
<SelectValue placeholder="Herdar template global" />
</SelectTrigger>
<SelectContent>
<SelectItem value="inherit">Herdar template global</SelectItem>
{companyTemplates.map((tpl) => (
<SelectItem key={tpl.id} value={tpl.id}>
{tpl.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
)
}

View file

@ -2441,6 +2441,9 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
const effectiveStatus = device ? resolveDeviceStatus(device) : "unknown"
const [isActiveLocal, setIsActiveLocal] = useState<boolean>(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) {
<h1 className="break-words text-2xl font-semibold text-neutral-900">
{device.displayName ?? device.hostname ?? "Dispositivo"}
</h1>
{isManualMobile ? (
<span className="rounded-full border border-slate-200 bg-slate-50 px-2 py-0.5 text-xs font-semibold uppercase tracking-wide text-neutral-600">
Identificação interna
</span>
) : null}
<Button
size="icon"
variant="ghost"
@ -3723,6 +3731,8 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
<ShieldCheck className="size-4" />
Ajustar acesso
</Button>
{!isManualMobile ? (
<>
<Button
size="sm"
variant="outline"
@ -3746,6 +3756,8 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
{isActiveLocal ? <Power className="size-4" /> : <PlayCircle className="size-4" />}
{isActiveLocal ? (togglingActive ? "Desativando..." : "Desativar") : togglingActive ? "Reativando..." : "Reativar"}
</Button>
</>
) : null}
{device.registeredBy ? (
<span
className={cn(
@ -4157,6 +4169,7 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
</DialogContent>
</Dialog>
{!isManualMobile ? (
<section className="space-y-2">
<h4 className="text-sm font-semibold">Sincronização</h4>
<div className="grid gap-2 text-sm text-muted-foreground">
@ -4192,7 +4205,9 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
</div>
</div>
</section>
) : null}
{!isManualMobile ? (
<section className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<h4 className="text-sm font-semibold">Métricas recentes</h4>
@ -4204,8 +4219,9 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
</div>
<MetricsGrid metrics={metrics} hardware={hardware} disks={disks} />
</section>
) : null}
{hardware || network || (labels && labels.length > 0) ? (
{!isManualMobile && (hardware || network || (labels && labels.length > 0)) ? (
<section className="space-y-3">
<div>
<h4 className="text-sm font-semibold">Inventário</h4>
@ -5145,6 +5161,7 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
</section>
) : null}
{!isManualMobile ? (
<section className="space-y-2">
<div className="flex items-center justify-between">
<h4 className="text-sm font-semibold">Histórico de alertas</h4>
@ -5178,6 +5195,7 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
<p className="text-xs text-muted-foreground">Nenhum alerta registrado para este dispositivo.</p>
)}
</section>
) : null}
<div className="flex flex-wrap gap-2 pt-2">
{Array.isArray(software) && software.length > 0 ? (

View file

@ -29,6 +29,7 @@ type Field = {
required: boolean
options: FieldOption[]
order: number
scope: string
}
const TYPE_LABELS: Record<Field["type"], string> = {
@ -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<string, string>()
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<Field["type"]>("text")
const [required, setRequired] = useState(false)
const [options, setOptions] = useState<FieldOption[]>([])
const [scopeSelection, setScopeSelection] = useState<string>("all")
const [saving, setSaving] = useState(false)
const [editingField, setEditingField] = useState<Field | null>(null)
const [editingScope, setEditingScope] = useState<string>("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
</Label>
</div>
<div className="space-y-2">
<Label>Aplicar em</Label>
<Select value={scopeSelection} onValueChange={setScopeSelection}>
<SelectTrigger>
<SelectValue placeholder="Todos os formulários" />
</SelectTrigger>
<SelectContent>
{scopeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-4">
<div className="space-y-2">
@ -378,7 +421,12 @@ export function FieldsManager() {
</CardHeader>
</Card>
) : (
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 (
<Card key={field.id} className="border-slate-200">
<CardHeader>
<div className="flex items-start justify-between gap-4">
@ -395,6 +443,9 @@ export function FieldsManager() {
) : null}
</div>
<CardDescription className="text-neutral-600">Identificador: {field.key}</CardDescription>
<Badge variant="secondary" className="rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-semibold text-neutral-700">
{scopeLabel}
</Badge>
{field.description ? (
<p className="text-sm text-neutral-600">{field.description}</p>
) : null}
@ -446,7 +497,8 @@ export function FieldsManager() {
</CardContent>
) : null}
</Card>
))
)
})
)}
</div>
@ -487,6 +539,21 @@ export function FieldsManager() {
Campo obrigatório na abertura
</Label>
</div>
<div className="space-y-2">
<Label>Aplicar em</Label>
<Select value={editingScope} onValueChange={setEditingScope}>
<SelectTrigger>
<SelectValue placeholder="Todos os formulários" />
</SelectTrigger>
<SelectContent>
{scopeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-4">
<div className="space-y-2">

View file

@ -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<string>("")
const [cloneFields, setCloneFields] = useState(true)
const [creating, setCreating] = useState(false)
const [editingTemplate, setEditingTemplate] = useState<Template | null>(null)
const [editLabel, setEditLabel] = useState("")
const [editDescription, setEditDescription] = useState("")
const [savingEdit, setSavingEdit] = useState(false)
const activeTemplates = useMemo(() => {
if (!templates) return []
return templates.filter((tpl) => !tpl.isArchived).sort((a, b) => a.order - b.order)
}, [templates])
const archivedTemplates = useMemo(() => {
if (!templates) return []
return templates.filter((tpl) => tpl.isArchived).sort((a, b) => a.label.localeCompare(b.label, "pt-BR"))
}, [templates])
const baseOptions = useMemo(() => {
return (templates ?? []).filter((tpl) => !tpl.isArchived)
}, [templates])
const handleCreate = async () => {
if (!viewerId) return
const label = newLabel.trim()
if (label.length < 3) {
toast.error("Informe um nome com pelo menos 3 caracteres")
return
}
setCreating(true)
try {
await createTemplate({
tenantId,
actorId: viewerId,
label,
description: newDescription.trim() || undefined,
baseTemplateKey: baseTemplate || undefined,
cloneFields,
})
toast.success("Formulário criado com sucesso.")
setCreateDialogOpen(false)
setNewLabel("")
setNewDescription("")
setBaseTemplate("")
setCloneFields(true)
} catch (error) {
console.error("[ticket-templates] create failed", error)
toast.error("Não foi possível criar o formulário.")
} finally {
setCreating(false)
}
}
const handleSaveEdit = async () => {
if (!viewerId || !editingTemplate) return
const label = editLabel.trim()
if (label.length < 3) {
toast.error("Informe um nome com pelo menos 3 caracteres")
return
}
setSavingEdit(true)
try {
await updateTemplate({
tenantId,
actorId: viewerId,
templateId: editingTemplate.id as Id<"ticketFormTemplates">,
label,
description: editDescription.trim() || undefined,
})
toast.success("Formulário atualizado.")
setEditingTemplate(null)
} catch (error) {
console.error("[ticket-templates] update failed", error)
toast.error("Não foi possível atualizar o formulário.")
} finally {
setSavingEdit(false)
}
}
const handleToggleArchive = async (template: Template, archived: boolean) => {
if (!viewerId) return
try {
await archiveTemplate({
tenantId,
actorId: viewerId,
templateId: template.id as Id<"ticketFormTemplates">,
archived,
})
toast.success(archived ? "Formulário arquivado." : "Formulário reativado.")
} catch (error) {
console.error("[ticket-templates] toggle archive failed", error)
toast.error("Não foi possível atualizar o formulário.")
}
}
const renderTemplateCard = (template: Template) => (
<Card key={template.id} className="border-slate-200">
<CardHeader className="flex flex-row items-start justify-between gap-3">
<div className="space-y-1">
<CardTitle className="text-base font-semibold text-neutral-900">{template.label}</CardTitle>
<CardDescription>{template.description || "Sem descrição"}</CardDescription>
<div className="flex flex-wrap gap-2 pt-1">
<Badge variant="outline" className="rounded-full px-2 py-0.5 text-xs font-semibold">
{template.isSystem ? "Padrão do sistema" : "Personalizado"}
</Badge>
{!template.defaultEnabled ? (
<Badge variant="secondary" className="rounded-full px-2 py-0.5 text-xs font-semibold">
Desabilitado por padrão
</Badge>
) : null}
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={() => {
setEditingTemplate(template)
setEditLabel(template.label)
setEditDescription(template.description)
}}
>
Renomear
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => handleToggleArchive(template, !template.isArchived)}>
{template.isArchived ? (
<span className="flex items-center gap-2 text-emerald-600">
<RefreshCcw className="size-3.5" />
Reativar
</span>
) : (
<span className="flex items-center gap-2 text-rose-600">
<Archive className="size-3.5" />
Arquivar
</span>
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</CardHeader>
</Card>
)
return (
<Card className="border-slate-200">
<CardHeader className="flex flex-row items-center justify-between gap-3">
<div>
<CardTitle className="text-lg font-semibold text-neutral-900">Modelos de formulário</CardTitle>
<CardDescription>Controle quais formulários especiais ficam disponíveis na abertura de tickets.</CardDescription>
</div>
<Button size="sm" className="gap-2" onClick={() => setCreateDialogOpen(true)}>
<Plus className="size-4" />
Novo formulário
</Button>
</CardHeader>
<CardContent className="space-y-4">
{!templates ? (
<div className="space-y-3">
{Array.from({ length: 2 }).map((_, index) => (
<Skeleton key={index} className="h-28 w-full rounded-2xl" />
))}
</div>
) : (
<>
{activeTemplates.length === 0 ? (
<div className="rounded-lg border border-dashed border-slate-300 bg-slate-50/70 p-6 text-center text-sm text-muted-foreground">
Nenhum formulário personalizado. Clique em &quot;Novo formulário&quot; para começar.
</div>
) : (
<div className="grid gap-3 md:grid-cols-2">
{activeTemplates.map(renderTemplateCard)}
</div>
)}
{archivedTemplates.length > 0 ? (
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Arquivados</p>
<div className="grid gap-3 md:grid-cols-2">
{archivedTemplates.map(renderTemplateCard)}
</div>
</div>
) : null}
</>
)}
</CardContent>
<Dialog open={createDialogOpen} onOpenChange={(open) => !creating && setCreateDialogOpen(open)}>
<DialogContent className="max-w-lg space-y-4">
<DialogHeader>
<DialogTitle>Novo formulário</DialogTitle>
<DialogDescription>Crie formulários específicos para fluxos como admissões, desligamentos ou demandas especiais.</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium text-neutral-700">Nome</label>
<Input
value={newLabel}
onChange={(event) => setNewLabel(event.target.value)}
placeholder="Ex.: Troca de equipamento"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-neutral-700">Descrição</label>
<Textarea
value={newDescription}
onChange={(event) => setNewDescription(event.target.value)}
placeholder="Explique quando este formulário deve ser usado"
rows={3}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-neutral-700">Basear em</label>
<Select value={baseTemplate} onValueChange={setBaseTemplate}>
<SelectTrigger>
<SelectValue placeholder="Em branco" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Começar do zero</SelectItem>
{baseOptions.map((tpl) => (
<SelectItem key={tpl.key} value={tpl.key}>
{tpl.label}
</SelectItem>
))}
</SelectContent>
</Select>
{baseTemplate ? (
<label className="flex items-center gap-2 text-sm text-neutral-700">
<Checkbox
checked={cloneFields}
onCheckedChange={(value) => setCloneFields(Boolean(value))}
/>
<span>Copiar campos do formulário base</span>
</label>
) : null}
</div>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => !creating && setCreateDialogOpen(false)}>
Cancelar
</Button>
<Button onClick={handleCreate} disabled={creating}>
{creating ? "Criando..." : "Criar formulário"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={Boolean(editingTemplate)} onOpenChange={(open) => !savingEdit && !open && setEditingTemplate(null)}>
<DialogContent className="max-w-lg space-y-4">
<DialogHeader>
<DialogTitle>Editar formulário</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-2">
<label className="text-sm font-medium text-neutral-700">Nome</label>
<Input value={editLabel} onChange={(event) => setEditLabel(event.target.value)} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-neutral-700">Descrição</label>
<Textarea
value={editDescription}
onChange={(event) => setEditDescription(event.target.value)}
rows={3}
/>
</div>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => !savingEdit && setEditingTemplate(null)}>
Cancelar
</Button>
<Button onClick={handleSaveEdit} disabled={savingEdit}>
{savingEdit ? "Salvando..." : "Salvar alterações"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
)
}

View file

@ -1,6 +1,6 @@
"use client"
import { useCallback, useEffect, useMemo, useState, useTransition } from "react"
import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react"
import { format } from "date-fns"
import { ptBR } from "date-fns/locale"
import { Controller, FormProvider, useFieldArray, useForm } from "react-hook-form"
@ -70,6 +70,7 @@ import { Textarea } from "@/components/ui/textarea"
import { Badge } from "@/components/ui/badge"
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
import { EmptyIndicator } from "@/components/ui/empty-indicator"
import { Skeleton } from "@/components/ui/skeleton"
export type AdminAccount = {
id: string
@ -265,9 +266,24 @@ function AccountsTable({
api.ticketFormSettings.list,
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
) as Array<{ template: string; scope: string; userId?: string | null; enabled: boolean; updatedAt: number }> | undefined
const templates = useQuery(
api.ticketFormTemplates.listActive,
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
) as Array<{ key: string; label: string }> | undefined
const upsertFormSetting = useMutation(api.ticketFormSettings.upsert)
const ensureTicketForms = useMutation(api.tickets.ensureTicketFormDefaults)
const ensuredRef = useRef(false)
const resolveUserFormEnabled = useCallback((template: "admissao" | "desligamento") => {
useEffect(() => {
if (!convexUserId || ensuredRef.current) return
ensuredRef.current = true
ensureTicketForms({ tenantId, actorId: convexUserId as Id<"users"> }).catch((error) => {
console.error("Falha ao garantir formulários padrão", error)
ensuredRef.current = false
})
}, [convexUserId, ensureTicketForms, tenantId])
const resolveUserFormEnabled = useCallback((template: string) => {
if (!editAccount) return true
const scoped = (formSettings ?? []).filter((s) => s.template === template)
const latest = scoped
@ -276,7 +292,7 @@ function AccountsTable({
return typeof latest?.enabled === "boolean" ? latest.enabled : true
}, [formSettings, editAccount])
const handleToggleUserForm = useCallback(async (template: "admissao" | "desligamento", enabled: boolean) => {
const handleToggleUserForm = useCallback(async (template: string, enabled: boolean) => {
if (!convexUserId || !editAccount) return
try {
await upsertFormSetting({
@ -1019,24 +1035,27 @@ function AccountsTable({
<div className="rounded-lg border border-border/60 bg-muted/20 p-4">
<p className="text-sm font-semibold text-foreground">Tipos de solicitação</p>
<p className="mb-2 text-xs text-muted-foreground">Disponíveis para este colaborador/gestor no portal. Administradores e agentes sempre veem todas as opções.</p>
<div className="grid gap-2 sm:grid-cols-2">
<label className="flex items-center gap-2 text-sm text-foreground">
<Checkbox
checked={resolveUserFormEnabled("admissao")}
onCheckedChange={(v) => handleToggleUserForm("admissao", Boolean(v))}
disabled={!editAccount || isSavingAccount}
/>
<span>Admissão de colaborador</span>
</label>
<label className="flex items-center gap-2 text-sm text-foreground">
<Checkbox
checked={resolveUserFormEnabled("desligamento")}
onCheckedChange={(v) => handleToggleUserForm("desligamento", Boolean(v))}
disabled={!editAccount || isSavingAccount}
/>
<span>Desligamento de colaborador</span>
</label>
{!templates ? (
<div className="space-y-2">
<Skeleton className="h-8 w-full rounded-md" />
<Skeleton className="h-8 w-full rounded-md" />
</div>
) : templates.length === 0 ? (
<p className="text-xs text-neutral-500">Nenhum formulário configurado.</p>
) : (
<div className="grid gap-2 sm:grid-cols-2">
{templates.map((template) => (
<label key={template.key} className="flex items-center gap-2 text-sm text-foreground">
<Checkbox
checked={resolveUserFormEnabled(template.key)}
onCheckedChange={(v) => handleToggleUserForm(template.key, Boolean(v))}
disabled={!editAccount || isSavingAccount}
/>
<span>{template.label}</span>
</label>
))}
</div>
)}
</div>
</div>

View file

@ -21,6 +21,7 @@ import {
ChevronDown,
ShieldCheck,
Users,
Layers3,
} from "lucide-react"
import { usePathname } from "next/navigation"
import Link from "next/link"
@ -90,6 +91,7 @@ const navigation: NavigationGroup[] = [
{ title: "Qualidade (CSAT)", url: "/reports/csat", icon: LifeBuoy, requiredRole: "staff" },
{ title: "Backlog", url: "/reports/backlog", icon: BarChart3, requiredRole: "staff" },
{ title: "Empresas", url: "/reports/company", icon: Building2, requiredRole: "staff" },
{ title: "Categorias", url: "/reports/categories", icon: Layers3, requiredRole: "staff" },
{ title: "Horas", url: "/reports/hours", icon: Clock4, requiredRole: "staff" },
],
},

View file

@ -34,6 +34,7 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
import { Textarea } from "@/components/ui/textarea"
import {
Dialog,
@ -88,6 +89,7 @@ import {
PauseCircle,
PlayCircle,
Plus,
Share2,
Sparkles,
Table2,
Trash2,
@ -569,6 +571,7 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" }
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
const [isDeletingDashboard, setIsDeletingDashboard] = useState(false)
const fullscreenContainerRef = useRef<HTMLDivElement | null>(null)
const autoFullscreenRef = useRef(false)
const previousSidebarStateRef = useRef<{ open: boolean; openMobile: boolean } | null>(null)
const ensureQueueSummaryRequestedRef = useRef(false)
@ -709,6 +712,28 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" }
}
}, [isMobile, open, openMobile, setOpen, setOpenMobile])
useEffect(() => {
if (typeof document === "undefined") return
if (enforceTv) {
if (!document.fullscreenElement) {
handleToggleFullscreen()
.then(() => {
autoFullscreenRef.current = true
})
.catch(() => {
autoFullscreenRef.current = false
})
} else {
autoFullscreenRef.current = true
}
} else if (autoFullscreenRef.current && document.fullscreenElement) {
document.exitFullscreen?.().catch(() => null)
autoFullscreenRef.current = false
} else {
autoFullscreenRef.current = false
}
}, [enforceTv, handleToggleFullscreen])
const packedLayout = useMemo(() => packLayout(layoutState, GRID_COLUMNS), [layoutState])
const metricOptions = useMemo(() => getMetricOptionsForRole(userRole), [userRole])
@ -1133,7 +1158,7 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" }
className={cn(
"flex flex-1 flex-col gap-6",
isFullscreen &&
"min-h-screen bg-gradient-to-br from-background via-background to-primary/5 pb-10 pt-16",
"min-h-screen bg-gradient-to-b from-white via-slate-50 to-slate-100 pb-10 pt-16",
isFullscreen && (enforceTv ? "px-0" : "px-4 md:px-8 lg:px-12"),
)}
>
@ -1183,27 +1208,59 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" }
) : null}
{visibleCount === 0 ? (
<Card className="border-dashed border-muted-foreground/40 bg-muted/10 py-12 text-center">
<CardHeader>
<CardTitle className="flex items-center justify-center gap-2 text-lg font-semibold">
<Sparkles className="size-4 text-primary" />
Comece adicionando widgets
</CardTitle>
<CardDescription>
KPIs, gráficos ou tabelas podem ser combinados para contar histórias relevantes para a operação.
</CardDescription>
</CardHeader>
<CardContent className="flex justify-center">
<section className="rounded-2xl border border-dashed border-slate-200 bg-white/90 p-6 shadow-sm">
<Empty className="border-none bg-transparent p-0">
<EmptyMedia variant="icon" className="bg-slate-100 text-slate-600">
<Sparkles className="size-5" />
</EmptyMedia>
<EmptyHeader>
<EmptyTitle className="text-xl">Canvas em branco</EmptyTitle>
<EmptyDescription>
Adicione cards de KPI, gráficos ou texto para montar a visão diária da operação.
</EmptyDescription>
</EmptyHeader>
{canEdit ? (
<Button onClick={() => handleAddWidget("kpi")} disabled={isAddingWidget}>
<Plus className="mr-2 size-4" />
Adicionar primeiro widget
<EmptyContent>
<Button onClick={() => handleAddWidget("kpi")} disabled={isAddingWidget} className="gap-2">
<Plus className="size-4" />
Adicionar bloco
</Button>
<Button
type="button"
variant="ghost"
className="gap-2 text-sm text-muted-foreground"
onClick={() => handleAddWidget("text")}
disabled={isAddingWidget}
>
<LayoutTemplate className="size-4" />
Ver biblioteca
</Button>
</EmptyContent>
) : (
<p className="text-sm text-muted-foreground">Nenhum widget visível para esta seção.</p>
<p className="text-sm text-neutral-500">Nenhum widget disponível para esta seção.</p>
)}
</CardContent>
</Card>
</Empty>
<div className="mt-6 grid gap-3 text-left text-sm text-neutral-600 md:grid-cols-3">
<div className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
<div className="mb-2 inline-flex size-8 items-center justify-center rounded-lg bg-slate-100 text-slate-600">
<LayoutTemplate className="size-4" />
</div>
<p>Distribua widgets em slides e ajuste o grid para focar cada indicador.</p>
</div>
<div className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
<div className="mb-2 inline-flex size-8 items-center justify-center rounded-lg bg-slate-100 text-slate-600">
<Share2 className="size-4" />
</div>
<p>Salve filtros padrão, replique layouts e gere PDFs/PNGs.</p>
</div>
<div className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
<div className="mb-2 inline-flex size-8 items-center justify-center rounded-lg bg-slate-100 text-slate-600">
<MonitorPlay className="size-4" />
</div>
<p>Ative o modo apresentação/TV para loops automáticos em tela cheia.</p>
</div>
</div>
</section>
) : null}
{enforceTv ? (
@ -1341,6 +1398,9 @@ function BuilderHeader({
? dashboard.theme
: null
const metricBadgeClass =
"inline-flex h-9 items-center gap-2 rounded-full border border-slate-200 bg-white px-4 text-sm font-medium text-neutral-800"
const handleStartEditHeader = () => {
setDraftName(name)
setDraftDescription(description ?? "")
@ -1430,26 +1490,17 @@ function BuilderHeader({
</div>
)}
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<Badge
variant="outline"
className="inline-flex items-center gap-2 rounded-full border-slate-200 bg-white px-4 py-1.5 text-xs font-semibold text-neutral-700 shadow-sm"
>
<Badge variant="outline" className={metricBadgeClass}>
<span className="h-2 w-2 rounded-full bg-neutral-400" />
Formato {dashboard.aspectRatio ?? "16:9"}
</Badge>
{themeLabel ? (
<Badge
variant="outline"
className="inline-flex items-center gap-2 rounded-full border-slate-200 bg-white px-4 py-1.5 text-xs font-semibold text-neutral-700 shadow-sm"
>
<Badge variant="outline" className={metricBadgeClass}>
<span className="h-2 w-2 rounded-full bg-neutral-400" />
Tema {themeLabel}
</Badge>
) : null}
<Badge
variant="outline"
className="inline-flex items-center gap-2 rounded-full border-slate-200 bg-white px-4 py-1.5 text-xs font-semibold text-neutral-700 shadow-sm"
>
<Badge variant="outline" className={metricBadgeClass}>
<span className="h-2 w-2 rounded-full bg-neutral-400" />
{totalWidgets} bloco{totalWidgets === 1 ? "" : "s"}
</Badge>
@ -1483,12 +1534,17 @@ function BuilderHeader({
</Button>
</div>
) : null}
<div className="flex flex-wrap items-center gap-2">
{canEdit ? <WidgetPicker onSelect={onAddWidget} disabled={isAddingWidget} /> : null}
<div className="flex flex-1 flex-col gap-3">
{canEdit ? (
<div className="flex flex-wrap items-center justify-end gap-2">
<WidgetPicker onSelect={onAddWidget} disabled={isAddingWidget} />
</div>
) : null}
<div className="flex flex-wrap items-center gap-2 rounded-2xl border border-slate-200 bg-white/80 px-2 py-1.5">
<Button
variant="outline"
variant="ghost"
size="sm"
className="gap-2"
className="gap-2 rounded-full border border-slate-200 px-3 font-medium text-neutral-700 hover:border-slate-300 hover:bg-white"
onClick={onToggleFullscreen}
>
{isFullscreen ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
@ -1497,18 +1553,21 @@ function BuilderHeader({
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant={isTvMode ? "secondary" : "default"}
variant={isTvMode ? "secondary" : "ghost"}
size="sm"
className="gap-2"
className={cn(
"gap-2 rounded-full border px-3",
isTvMode ? "border-slate-200 bg-slate-900 text-white" : "border-slate-200 text-neutral-700",
)}
>
{isTvMode ? <PauseCircle className="size-4" /> : <PlayCircle className="size-4" />}
{isTvMode ? "Modo apresentação ativo" : "Modo apresentação"}
Modo apresentação
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-60">
<DropdownMenuLabel>Modo apresentação</DropdownMenuLabel>
<DropdownMenuItem onSelect={onToggleTvMode}>
{isTvMode ? "Encerrar modo apresentação" : "Iniciar modo apresentação"}
{isTvMode ? "Encerrar" : "Iniciar"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>Tempo por seção</DropdownMenuLabel>
@ -1527,16 +1586,13 @@ function BuilderHeader({
</DropdownMenuItem>
))}
{canEdit ? null : (
<DropdownMenuItem disabled>
Apenas edição permite ajustar o tempo
</DropdownMenuItem>
<DropdownMenuItem disabled>Apenas edição permite ajustar o tempo</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<Button variant="ghost" size="sm" className="gap-2 rounded-full border border-slate-200 px-3 font-medium text-neutral-700">
<Download className="size-4" />
Exportar
</Button>
@ -1554,9 +1610,9 @@ function BuilderHeader({
</DropdownMenu>
{canEdit ? (
<Button
variant="outline"
variant="ghost"
size="sm"
className="gap-2 border border-rose-300 bg-white text-rose-600 transition hover:bg-rose-50 focus-visible:ring-rose-200 [&>svg]:transition [&>svg]:text-rose-600 hover:[&>svg]:text-rose-700 hover:text-rose-700"
className="gap-2 rounded-full border border-rose-200 px-3 font-medium text-rose-600 hover:bg-rose-50"
onClick={onDeleteRequest}
>
<Trash2 className="size-4" />
@ -1565,6 +1621,8 @@ function BuilderHeader({
) : null}
</div>
</div>
</div>
</div>
</CardContent>
</Card>
)
@ -1749,7 +1807,7 @@ function TvCanvas({ items, isFullscreen }: { items: CanvasRenderableItem[]; isFu
<div
key={item.key}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-2xl border border-border/40 bg-gradient-to-br from-white via-white to-slate-100 shadow-lg",
"flex h-full w-full flex-col overflow-visible rounded-2xl border border-border/40 bg-gradient-to-br from-white via-white to-slate-100 shadow-lg",
isSingle ? "max-w-4xl justify-self-center" : "",
)}
>

View file

@ -6,7 +6,7 @@ import { useRouter } from "next/navigation"
import { useMutation, useQuery } from "convex/react"
import { formatDistanceToNow } from "date-fns"
import { ptBR } from "date-fns/locale"
import { Plus, Sparkles, Trash2 } from "lucide-react"
import { LayoutTemplate, MonitorPlay, Plus, Share2, Sparkles, Trash2 } from "lucide-react"
import type { Id } from "@/convex/_generated/dataModel"
import { api } from "@/convex/_generated/api"
@ -239,23 +239,42 @@ export function DashboardListView() {
</div>
{activeDashboards.length === 0 ? (
<Card className="border-dashed border-muted-foreground/40 bg-muted/10">
<CardHeader className="flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<Sparkles className="size-4 text-primary" />
Crie o seu primeiro dashboard
</CardTitle>
<CardDescription>
Monte painéis por cliente, fila ou operação e compartilhe com a equipe.
</CardDescription>
<Card className="overflow-hidden border-dashed border-slate-200 bg-gradient-to-br from-white via-white to-slate-50 shadow-sm">
<CardContent className="flex flex-col gap-6 p-6 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-4">
<div className="flex items-center gap-3">
<div className="inline-flex size-12 items-center justify-center rounded-full bg-sky-50 text-sky-700">
<Sparkles className="size-5" />
</div>
<div>
<h3 className="text-xl font-semibold text-neutral-900">Nenhum dashboard ainda</h3>
<p className="text-sm text-muted-foreground">Use KPIs, filas e texto para contar a história da operação.</p>
</div>
</div>
<ul className="space-y-2 text-sm text-neutral-600">
<li className="flex items-start gap-2">
<LayoutTemplate className="mt-0.5 size-4 text-slate-500" />
<span>Escolha widgets arrastando no canvas e organize por seções.</span>
</li>
<li className="flex items-start gap-2">
<Share2 className="mt-0.5 size-4 text-slate-500" />
<span>Compartilhe com a equipe, salve filtros padrão e gere PDFs/PNGs.</span>
</li>
<li className="flex items-start gap-2">
<MonitorPlay className="mt-0.5 size-4 text-slate-500" />
<span>Entre no modo apresentação/TV para um loop automático em tela cheia.</span>
</li>
</ul>
</div>
<div className="flex w-full flex-col gap-3 rounded-2xl border border-slate-200 bg-white/90 p-4 lg:w-auto lg:min-w-[220px]">
{renderCreateButton()}
</CardHeader>
<CardContent className="space-y-2 text-sm text-muted-foreground">
<p> Arraste e redimensione widgets livremente no canvas.</p>
<p> Salve filtros padrão por dashboard e gere exportações em PDF/PNG.</p>
<p> Ative o modo TV ou compartilhe via link público com token rotativo.</p>
<Button variant="outline" className="gap-2" asChild>
<Link href="/views">
<LayoutTemplate className="size-4" />
Ver exemplos
</Link>
</Button>
</div>
</CardContent>
</Card>
) : (

View file

@ -67,7 +67,8 @@ const percentFormatter = new Intl.NumberFormat("pt-BR", { style: "percent", maxi
const CHART_COLORS = ["var(--chart-1)", "var(--chart-2)", "var(--chart-3)", "var(--chart-4)", "var(--chart-5)"]
const DEFAULT_CHART_HEIGHT = 320
const PRESENTATION_CHART_HEIGHT = 420
// Em modo apresentação o card já define a altura disponível; evitar valores fixos previne cortes.
const PRESENTATION_CHART_HEIGHT = 0
export type DashboardFilters = {
range?: "7d" | "30d" | "90d" | "custom"
@ -415,7 +416,7 @@ function WidgetCard({ title, description, children, isLoading }: WidgetCardProps
<CardTitle className="text-base font-semibold">{title}</CardTitle>
{description ? <CardDescription>{description}</CardDescription> : null}
</CardHeader>
<CardContent className="flex-1 pb-4 pt-0">
<CardContent className="flex-1 overflow-visible pb-4 pt-0">
{isLoading ? (
<Skeleton className="h-full min-h-[240px] w-full rounded-xl animate-pulse" />
) : (
@ -514,6 +515,7 @@ function renderBarChart({
const allowDecimals = valueFormatter === "percent"
const isPresentation = mode === "tv" || mode === "print"
const minHeight = isPresentation ? PRESENTATION_CHART_HEIGHT : DEFAULT_CHART_HEIGHT
const legendPadClass = isPresentation ? "pb-8" : "pb-6"
return (
<WidgetCard title={title} description={description} isLoading={metric.isLoading}>
@ -522,8 +524,13 @@ function renderBarChart({
) : (
<ChartContainer
config={chartConfig as ChartConfig}
className="group/chart h-full w-full px-2 pb-4 [&_.recharts-legend-wrapper]:px-2 [&_.recharts-legend-wrapper]:pb-1 [&_.recharts-default-legend]:flex [&_.recharts-default-legend]:flex-wrap [&_.recharts-default-legend]:justify-center"
style={{ minHeight, height: "100%" }}
className={cn(
"group/chart h-full w-full px-2",
legendPadClass,
"[&_.recharts-legend-wrapper]:px-2 [&_.recharts-legend-wrapper]:pb-1",
"[&_.recharts-default-legend]:flex [&_.recharts-default-legend]:flex-wrap [&_.recharts-default-legend]:justify-center",
)}
style={{ minHeight, height: "100%", overflow: "visible" }}
>
<BarChart data={chartData} accessibilityLayer>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
@ -594,6 +601,7 @@ function renderLineChart({
valueFormatter === "percent" ? (value: number) => percentFormatter.format(value) : undefined
const isPresentation = mode === "tv" || mode === "print"
const minHeight = isPresentation ? PRESENTATION_CHART_HEIGHT : DEFAULT_CHART_HEIGHT
const legendPadClass = isPresentation ? "pb-8" : "pb-6"
return (
<WidgetCard title={title} description={description} isLoading={metric.isLoading}>
@ -602,8 +610,13 @@ function renderLineChart({
) : (
<ChartContainer
config={chartConfig as ChartConfig}
className="group/chart h-full w-full px-2 pb-4 [&_.recharts-legend-wrapper]:px-2 [&_.recharts-legend-wrapper]:pb-1 [&_.recharts-default-legend]:flex [&_.recharts-default-legend]:flex-wrap [&_.recharts-default-legend]:justify-center"
style={{ minHeight, height: "100%" }}
className={cn(
"group/chart h-full w-full px-2",
legendPadClass,
"[&_.recharts-legend-wrapper]:px-2 [&_.recharts-legend-wrapper]:pb-1",
"[&_.recharts-default-legend]:flex [&_.recharts-default-legend]:flex-wrap [&_.recharts-default-legend]:justify-center",
)}
style={{ minHeight, height: "100%", overflow: "visible" }}
>
<LineChart data={chartData} accessibilityLayer>
<CartesianGrid strokeDasharray="3 3" />
@ -669,6 +682,7 @@ function renderAreaChart({
valueFormatter === "percent" ? (value: number) => percentFormatter.format(value) : undefined
const isPresentation = mode === "tv" || mode === "print"
const minHeight = isPresentation ? PRESENTATION_CHART_HEIGHT : DEFAULT_CHART_HEIGHT
const legendPadClass = isPresentation ? "pb-8" : "pb-6"
return (
<WidgetCard title={title} description={description} isLoading={metric.isLoading}>
@ -677,8 +691,13 @@ function renderAreaChart({
) : (
<ChartContainer
config={chartConfig as ChartConfig}
className="group/chart h-full w-full px-2 pb-4 [&_.recharts-legend-wrapper]:px-2 [&_.recharts-legend-wrapper]:pb-1 [&_.recharts-default-legend]:flex [&_.recharts-default-legend]:flex-wrap [&_.recharts-default-legend]:justify-center"
style={{ minHeight, height: "100%" }}
className={cn(
"group/chart h-full w-full px-2",
legendPadClass,
"[&_.recharts-legend-wrapper]:px-2 [&_.recharts-legend-wrapper]:pb-1",
"[&_.recharts-default-legend]:flex [&_.recharts-default-legend]:flex-wrap [&_.recharts-default-legend]:justify-center",
)}
style={{ minHeight, height: "100%", overflow: "visible" }}
>
<AreaChart data={chartData} accessibilityLayer>
<defs>
@ -742,6 +761,7 @@ function renderPieChart({
const tooltipValueFormatter = (value: unknown) => formatMetricValue(value, valueFormatter)
const isPresentation = mode === "tv" || mode === "print"
const minHeight = isPresentation ? PRESENTATION_CHART_HEIGHT : DEFAULT_CHART_HEIGHT
const legendPadClass = isPresentation ? "pb-8" : "pb-6"
return (
<WidgetCard title={title} description={description} isLoading={metric.isLoading}>
{chartData.length === 0 ? (
@ -753,8 +773,13 @@ function renderPieChart({
acc[key] = { label: key, color: CHART_COLORS[index % CHART_COLORS.length] }
return acc
}, {}) as ChartConfig}
className="group/chart flex h-full w-full items-center justify-center px-2 pb-4 [&_.recharts-legend-wrapper]:px-2 [&_.recharts-legend-wrapper]:pb-1 [&_.recharts-default-legend]:flex [&_.recharts-default-legend]:flex-wrap [&_.recharts-default-legend]:justify-center"
style={{ minHeight, height: "100%" }}
className={cn(
"group/chart flex h-full w-full items-center justify-center px-2",
legendPadClass,
"[&_.recharts-legend-wrapper]:px-2 [&_.recharts-legend-wrapper]:pb-1",
"[&_.recharts-default-legend]:flex [&_.recharts-default-legend]:flex-wrap [&_.recharts-default-legend]:justify-center",
)}
style={{ minHeight, height: "100%", overflow: "visible" }}
>
<PieChart>
{showTooltip ? (
@ -807,6 +832,7 @@ function renderRadarChart({
const tooltipValueFormatter = (value: unknown) => formatMetricValue(value, valueFormatter)
const isPresentation = mode === "tv" || mode === "print"
const minHeight = isPresentation ? PRESENTATION_CHART_HEIGHT : DEFAULT_CHART_HEIGHT
const legendPadClass = isPresentation ? "pb-8" : "pb-6"
return (
<WidgetCard title={title} description={description} isLoading={metric.isLoading}>
{chartData.length === 0 ? (
@ -814,8 +840,13 @@ function renderRadarChart({
) : (
<ChartContainer
config={{ [radiusKey]: { label: radiusKey, color: "var(--chart-1)" } }}
className="group/chart flex h-full w-full items-center justify-center px-2 pb-4 [&_.recharts-legend-wrapper]:px-2 [&_.recharts-legend-wrapper]:pb-1 [&_.recharts-default-legend]:flex [&_.recharts-default-legend]:flex-wrap [&_.recharts-default-legend]:justify-center"
style={{ minHeight, height: "100%" }}
className={cn(
"group/chart flex h-full w-full items-center justify-center px-2",
legendPadClass,
"[&_.recharts-legend-wrapper]:px-2 [&_.recharts-legend-wrapper]:pb-1",
"[&_.recharts-default-legend]:flex [&_.recharts-default-legend]:flex-wrap [&_.recharts-default-legend]:justify-center",
)}
style={{ minHeight, height: "100%", overflow: "visible" }}
>
<RadarChart data={chartData} accessibilityLayer>
<PolarGrid />
@ -863,12 +894,18 @@ function renderGauge({
const display = Math.max(0, Math.min(1, value))
const isPresentation = mode === "tv" || mode === "print"
const minHeight = isPresentation ? PRESENTATION_CHART_HEIGHT : DEFAULT_CHART_HEIGHT
const legendPadClass = isPresentation ? "pb-8" : "pb-6"
return (
<WidgetCard title={title} description={description} isLoading={metric.isLoading}>
<ChartContainer
config={{ value: { label: "SLA", color: "var(--chart-1)" } }}
className="group/chart flex h-full w-full items-center justify-center px-2 pb-4 [&_.recharts-legend-wrapper]:px-2 [&_.recharts-legend-wrapper]:pb-1 [&_.recharts-default-legend]:flex [&_.recharts-default-legend]:flex-wrap [&_.recharts-default-legend]:justify-center"
style={{ minHeight, height: "100%" }}
className={cn(
"group/chart flex h-full w-full items-center justify-center px-2",
legendPadClass,
"[&_.recharts-legend-wrapper]:px-2 [&_.recharts-legend-wrapper]:pb-1",
"[&_.recharts-default-legend]:flex [&_.recharts-default-legend]:flex-wrap [&_.recharts-default-legend]:justify-center",
)}
style={{ minHeight, height: "100%", overflow: "visible" }}
>
<RadialBarChart
startAngle={180}

View file

@ -0,0 +1,300 @@
"use client"
import { useMemo, useState } from "react"
import { useQuery } from "convex/react"
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts"
import { Layers3, PieChart as PieChartIcon, Award } 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 { usePersistentCompanyFilter } from "@/lib/use-company-filter"
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
import { Skeleton } from "@/components/ui/skeleton"
import { Badge } from "@/components/ui/badge"
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
type CategoryInsightsResponse = {
rangeDays: number
totalTickets: number
categories: Array<{
id: string | null
name: string
total: number
resolved: number
topAgent: { id: string | null; name: string | null; total: number } | null
agents: Array<{ id: string | null; name: string | null; total: number }>
}>
spotlight: {
categoryId: string | null
categoryName: string
agentId: string | null
agentName: string | null
tickets: number
} | null
}
const numberFormatter = new Intl.NumberFormat("pt-BR")
const percentFormatter = new Intl.NumberFormat("pt-BR", { maximumFractionDigits: 1 })
const chartConfig = {
tickets: {
label: "Tickets",
color: "hsl(var(--chart-1))",
},
} as const
export function CategoryReport() {
const { session, convexUserId, isStaff } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
const [timeRange, setTimeRange] = useState("90d")
const [companyId, setCompanyId] = usePersistentCompanyFilter("all")
const enabled = Boolean(isStaff && convexUserId)
const companyFilter = companyId !== "all" ? (companyId as Id<"companies">) : undefined
const data = useQuery(
api.reports.categoryInsights,
enabled
? {
tenantId,
viewerId: convexUserId as Id<"users">,
range: timeRange,
companyId: companyFilter,
}
: "skip",
) as CategoryInsightsResponse | undefined
const companies = useQuery(
api.companies.list,
enabled ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip",
) as Array<{ id: Id<"companies">; name: string }> | undefined
const companyOptions = useMemo<SearchableComboboxOption[]>(() => {
const base: SearchableComboboxOption[] = [{ value: "all", label: "Todas as empresas" }]
if (!companies || companies.length === 0) return base
const sorted = [...companies].sort((a, b) => a.name.localeCompare(b.name, "pt-BR"))
return [
base[0],
...sorted.map((company) => ({
value: company.id,
label: company.name,
})),
]
}, [companies])
const categories = data?.categories ?? []
const leadingCategory = categories[0] ?? null
const spotlight = data?.spotlight ?? null
const chartData = categories.length
? categories.slice(0, 8).map((category) => ({
name: category.name,
tickets: category.total,
topAgent: category.topAgent?.name ?? "—",
agentTickets: category.topAgent?.total ?? 0,
}))
: []
const tableData = categories.slice(0, 10)
const totalTickets = data?.totalTickets ?? 0
const chartHeight = Math.max(240, chartData.length * 48)
const summarySkeleton = (
<div className="grid gap-4 md:grid-cols-3">
{Array.from({ length: 3 }).map((_, index) => (
<Card key={index} className="border-slate-200">
<CardContent className="space-y-3 p-6">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-8 w-24" />
</CardContent>
</Card>
))}
</div>
)
return (
<div className="space-y-8">
{!data ? (
summarySkeleton
) : (
<div className="grid gap-4 md:grid-cols-3">
<Card className="border-slate-200">
<CardContent className="space-y-3 p-6">
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
<Layers3 className="size-4" /> Tickets analisados
</div>
<p className="text-3xl font-semibold text-neutral-900">{numberFormatter.format(totalTickets)}</p>
<p className="text-xs text-neutral-500">Últimos {data.rangeDays} dias</p>
</CardContent>
</Card>
<Card className="border-slate-200">
<CardContent className="space-y-3 p-6">
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
<PieChartIcon className="size-4" /> Categoria líder
</div>
<p className="text-lg font-semibold text-neutral-900">{leadingCategory ? leadingCategory.name : "—"}</p>
<p className="text-sm text-neutral-600">
{leadingCategory
? `${numberFormatter.format(leadingCategory.total)} chamados (${percentFormatter.format(
totalTickets > 0 ? (leadingCategory.total / totalTickets) * 100 : 0,
)}%)`
: "Sem registros no período."}
</p>
</CardContent>
</Card>
<Card className="border-slate-200">
<CardContent className="space-y-3 p-6">
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
<Award className="size-4" /> Agente destaque
</div>
<p className="text-lg font-semibold text-neutral-900">{spotlight?.agentName ?? "—"}</p>
<p className="text-sm text-neutral-600">
{spotlight
? `${spotlight.tickets} chamados em ${spotlight.categoryName}`
: "Nenhum agente se destacou neste período."}
</p>
</CardContent>
</Card>
</div>
)}
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="text-lg font-semibold text-neutral-900">Categorias mais atendidas</CardTitle>
<CardDescription className="text-neutral-600">
Compare o volume de solicitações por categoria e identifique quais agentes concentram o atendimento de cada tema.
</CardDescription>
<CardAction>
<div className="flex flex-wrap items-center justify-end gap-2">
<SearchableCombobox
value={companyId}
onValueChange={(next) => setCompanyId(next ?? "all")}
options={companyOptions}
placeholder="Todas as empresas"
className="w-full min-w-56 md:w-64"
/>
<ToggleGroup
type="single"
value={timeRange}
onValueChange={(value) => value && setTimeRange(value)}
variant="outline"
className="hidden md:flex"
>
<ToggleGroupItem value="90d">90 dias</ToggleGroupItem>
<ToggleGroupItem value="30d">30 dias</ToggleGroupItem>
<ToggleGroupItem value="7d">7 dias</ToggleGroupItem>
</ToggleGroup>
</div>
</CardAction>
</CardHeader>
<CardContent className="space-y-6">
{!data ? (
<div className="space-y-4">
<Skeleton className="h-[260px] w-full rounded-2xl" />
<Skeleton className="h-48 w-full rounded-2xl" />
</div>
) : data.categories.length === 0 ? (
<div className="rounded-2xl border border-dashed border-slate-200 bg-slate-50/70 p-8 text-center text-sm text-neutral-500">
Nenhum ticket encontrado para o período selecionado.
</div>
) : (
<>
<div className="grid gap-6 lg:grid-cols-[1.2fr_1fr]">
<ChartContainer config={chartConfig} className="h-full w-full" style={{ minHeight: chartHeight }}>
<BarChart data={chartData} layout="vertical" margin={{ right: 16, left: 0 }}>
<CartesianGrid strokeDasharray="3 3" horizontal={false} />
<XAxis type="number" hide domain={[0, "dataMax"]} />
<YAxis dataKey="name" type="category" tickLine={false} axisLine={false} width={160} />
<ChartTooltip
cursor={{ fill: "hsl(var(--muted))" }}
content={
<ChartTooltipContent
formatter={(value, name, item) => (
<div className="flex w-full flex-col gap-1">
<span className="text-xs text-muted-foreground">{item?.payload?.topAgent ?? "Sem responsável"}</span>
<span className="font-semibold text-foreground">
{numberFormatter.format(Number(value))} tickets
</span>
</div>
)}
/>
}
/>
<Bar dataKey="tickets" fill="var(--color-tickets)" radius={[0, 6, 6, 0]} barSize={20} />
</BarChart>
</ChartContainer>
<div className="space-y-3">
<h4 className="text-sm font-semibold text-neutral-900">Agentes por categoria</h4>
<div className="space-y-3">
{tableData.slice(0, 4).map((category) => {
const share = totalTickets > 0 ? (category.total / totalTickets) * 100 : 0
const agent = category.topAgent
return (
<div key={`${category.id ?? "uncategorized"}-card`} className="rounded-2xl border border-slate-200 bg-slate-50/70 p-4">
<div className="flex items-center justify-between gap-2">
<div>
<p className="text-sm font-semibold text-neutral-900">{category.name}</p>
<p className="text-xs text-neutral-500">{numberFormatter.format(category.total)} tickets · {percentFormatter.format(share)}%</p>
</div>
<Badge variant="outline" className="rounded-full px-3 py-1 text-xs font-semibold">
{agent?.name ?? "Sem responsável"}
</Badge>
</div>
<p className="mt-2 text-xs text-neutral-500">
{agent ? `${numberFormatter.format(agent.total)} chamados atribuídos a ${agent.name ?? "—"}` : "Sem agentes atribuídos."}
</p>
</div>
)
})}
</div>
</div>
</div>
<div className="rounded-2xl border border-slate-200">
<Table>
<TableHeader>
<TableRow>
<TableHead>Categoria</TableHead>
<TableHead className="text-right">Tickets</TableHead>
<TableHead className="text-right">Participação</TableHead>
<TableHead className="text-right">Agente destaque</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tableData.map((category) => {
const share = totalTickets > 0 ? (category.total / totalTickets) * 100 : 0
return (
<TableRow key={category.id ?? `sem-categoria-${category.name}`}>
<TableCell className="font-medium text-neutral-900">{category.name}</TableCell>
<TableCell className="text-right font-mono text-sm">{numberFormatter.format(category.total)}</TableCell>
<TableCell className="text-right text-sm text-neutral-600">{percentFormatter.format(share)}%</TableCell>
<TableCell className="text-right text-sm text-neutral-700">
{category.topAgent ? (
<div className="flex flex-col items-end">
<span className="font-semibold text-neutral-900">{category.topAgent.name ?? "—"}</span>
<span className="text-xs text-neutral-500">{category.topAgent.total} chamados</span>
</div>
) : (
<span className="text-neutral-400">Sem responsável</span>
)}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</>
)}
</CardContent>
</Card>
</div>
)
}

View file

@ -174,10 +174,9 @@ export function TicketCustomFieldsList({ record, emptyMessage, className }: Tick
type TicketCustomFieldsSectionProps = {
ticket: TicketWithDetails
hidePreview?: boolean
}
export function TicketCustomFieldsSection({ ticket, hidePreview = false }: TicketCustomFieldsSectionProps) {
export function TicketCustomFieldsSection({ ticket }: TicketCustomFieldsSectionProps) {
const { convexUserId, role } = useAuth()
const canEdit = Boolean(convexUserId && (role === "admin" || role === "agent"))
@ -319,14 +318,10 @@ export function TicketCustomFieldsSection({ ticket, hidePreview = false }: Ticke
</Button>
) : null}
</div>
{hidePreview ? (
<p className="text-xs text-neutral-500">Visualize os valores no resumo principal.</p>
) : (
<TicketCustomFieldsList
record={ticket.customFields}
emptyMessage="Nenhum campo adicional preenchido neste chamado."
/>
)}
<Dialog open={editorOpen} onOpenChange={setEditorOpen}>
<DialogContent className="max-w-3xl gap-4">

View file

@ -5,9 +5,9 @@ import { ptBR } from "date-fns/locale"
import type { TicketWithDetails } from "@/lib/schemas/ticket"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { getTicketStatusLabel, getTicketStatusSummaryTone } from "@/lib/ticket-status-style"
import { TicketCustomFieldsSection } from "@/components/tickets/ticket-custom-fields"
import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
import { TicketCustomFieldsSection } from "@/components/tickets/ticket-custom-fields"
interface TicketDetailsPanelProps {
ticket: TicketWithDetails
@ -53,19 +53,19 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
const isAvulso = Boolean(ticket.company?.isAvulso)
const companyLabel = ticket.company?.name ?? (isAvulso ? "Cliente avulso" : "Sem empresa vinculada")
const summaryChips = useMemo(
() => [
const summaryChips = useMemo(() => {
const chips: Array<{ key: string; label: string; value: string; tone: SummaryTone }> = [
{
key: "queue",
label: "Fila",
value: ticket.queue ?? "Sem fila",
tone: ticket.queue ? ("default" as SummaryTone) : ("muted" as SummaryTone),
tone: ticket.queue ? "default" : "muted",
},
{
key: "company",
label: "Empresa",
value: companyLabel,
tone: isAvulso ? ("warning" as SummaryTone) : ("default" as SummaryTone),
tone: isAvulso ? "warning" : "default",
},
{
key: "status",
@ -83,11 +83,19 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
key: "assignee",
label: "Responsável",
value: ticket.assignee?.name ?? "Não atribuído",
tone: ticket.assignee ? ("default" as SummaryTone) : ("muted" as SummaryTone),
tone: ticket.assignee ? "default" : "muted",
},
],
[companyLabel, isAvulso, ticket.assignee, ticket.priority, ticket.queue, ticket.status]
)
]
if (ticket.formTemplateLabel) {
chips.push({
key: "formTemplate",
label: "Tipo de solicitação",
value: ticket.formTemplateLabel,
tone: "info",
})
}
return chips
}, [companyLabel, isAvulso, ticket.assignee, ticket.formTemplateLabel, ticket.priority, ticket.queue, ticket.status])
const agentTotals = useMemo(() => {
const totals = ticket.workSummary?.perAgentTotals ?? []
@ -129,8 +137,6 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
</div>
</section>
<TicketCustomFieldsSection ticket={ticket} hidePreview />
<section className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-neutral-900">SLA & métricas</h3>
@ -184,6 +190,8 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
</div>
</section>
<TicketCustomFieldsSection ticket={ticket} />
<section className="space-y-3">
<h3 className="text-sm font-semibold text-neutral-900">Tempo por agente</h3>
{agentTotals.length > 0 ? (

View file

@ -27,7 +27,6 @@ import { Textarea } from "@/components/ui/textarea"
import { Spinner } from "@/components/ui/spinner"
import { useTicketCategories } from "@/hooks/use-ticket-categories"
import { useDefaultQueues } from "@/hooks/use-default-queues"
import { mapTicketCustomFields } from "@/lib/ticket-custom-fields"
import {
DropdownMenu,
DropdownMenuContent,
@ -214,7 +213,6 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
queuesEnabled ? { tenantId: ticket.tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
)
const queues: TicketQueueSummary[] = Array.isArray(queuesResult) ? queuesResult : []
const customFieldEntries = useMemo(() => mapTicketCustomFields(ticket.customFields), [ticket.customFields])
const { categories, isLoading: categoriesLoading } = useTicketCategories(ticket.tenantId)
const workSummaryRemote = useQuery(
api.tickets.workSummary,
@ -1300,13 +1298,9 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<h1 className="break-words text-2xl font-semibold text-neutral-900">{subject}</h1>
{ticket.formTemplate ? (
{ticket.formTemplateLabel || ticket.formTemplate ? (
<span className="inline-flex items-center rounded-full border border-sky-200 bg-sky-50 px-2.5 py-0.5 text-xs font-semibold text-sky-700">
{ticket.formTemplate === "admissao"
? "Admissão"
: ticket.formTemplate === "desligamento"
? "Desligamento"
: "Chamado"}
{ticket.formTemplateLabel ?? ticket.formTemplate}
</span>
) : null}
</div>
@ -1585,24 +1579,6 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
</div>
) : null}
</div>
<div className="mt-6 space-y-2">
<span className={sectionLabelClass}>Informações adicionais</span>
{customFieldEntries.length > 0 ? (
<div className="grid gap-3 sm:grid-cols-2">
{customFieldEntries.map((entry) => (
<div
key={entry.key}
className="rounded-2xl border border-slate-200 bg-white px-4 py-3 shadow-sm"
>
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">{entry.label}</p>
<p className="mt-1 text-sm font-semibold text-neutral-900">{entry.formattedValue}</p>
</div>
))}
</div>
) : (
<p className="text-xs text-neutral-500">Nenhum campo adicional preenchido para este chamado.</p>
)}
</div>
<Dialog open={pauseDialogOpen} onOpenChange={setPauseDialogOpen}>
<DialogContent>
<DialogHeader>

View file

@ -221,14 +221,10 @@ export function TicketsTable({ tickets, enteringIds }: TicketsTableProps) {
<span className="text-sm text-neutral-600 line-clamp-1 break-words">
{ticket.summary ?? "Sem resumo"}
</span>
{ticket.formTemplate ? (
{ticket.formTemplateLabel || ticket.formTemplate ? (
<div className="flex items-center gap-2">
<Badge className="rounded-full border border-sky-200 bg-sky-50 px-2.5 py-0.5 text-[11px] font-semibold text-sky-700">
{ticket.formTemplate === "admissao"
? "Admissão"
: ticket.formTemplate === "desligamento"
? "Desligamento"
: "Chamado"}
{ticket.formTemplateLabel ?? ticket.formTemplate}
</Badge>
</div>
) : null}

View file

@ -50,6 +50,7 @@ const serverTicketSchema = z.object({
channel: z.string(),
queue: z.string().nullable(),
formTemplate: z.string().nullable().optional(),
formTemplateLabel: z.string().nullable().optional(),
requester: serverUserSchema,
assignee: serverUserSchema.nullable(),
company: z
@ -198,6 +199,7 @@ export function mapTicketFromServer(input: unknown) {
csatComment: typeof csatComment === "string" && csatComment.trim().length > 0 ? csatComment.trim() : null,
csatRatedAt: csatRatedAt ? new Date(csatRatedAt) : null,
csatRatedBy: csatRatedBy ?? null,
formTemplateLabel: base.formTemplateLabel ?? null,
workSummary: s.workSummary
? {
totalWorkedMs: s.workSummary.totalWorkedMs,

View file

@ -158,6 +158,7 @@ export const ticketSchema = z.object({
reopenedBy: z.string().nullable().optional(),
chatEnabled: z.boolean().optional(),
formTemplate: z.string().nullable().optional(),
formTemplateLabel: z.string().nullable().optional(),
csatScore: z.number().nullable().optional(),
csatMaxScore: z.number().nullable().optional(),
csatComment: z.string().nullable().optional(),