Ajusta placeholders, formulários e widgets
This commit is contained in:
parent
343f0c8c64
commit
b94cea2f9a
33 changed files with 2122 additions and 462 deletions
4
convex/_generated/api.d.ts
vendored
4
convex/_generated/api.d.ts
vendored
|
|
@ -17,6 +17,7 @@ import type * as companies from "../companies.js";
|
||||||
import type * as crons from "../crons.js";
|
import type * as crons from "../crons.js";
|
||||||
import type * as dashboards from "../dashboards.js";
|
import type * as dashboards from "../dashboards.js";
|
||||||
import type * as deviceExportTemplates from "../deviceExportTemplates.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 deviceFields from "../deviceFields.js";
|
||||||
import type * as devices from "../devices.js";
|
import type * as devices from "../devices.js";
|
||||||
import type * as fields from "../fields.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 slas from "../slas.js";
|
||||||
import type * as teams from "../teams.js";
|
import type * as teams from "../teams.js";
|
||||||
import type * as ticketFormSettings from "../ticketFormSettings.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 ticketNotifications from "../ticketNotifications.js";
|
||||||
import type * as tickets from "../tickets.js";
|
import type * as tickets from "../tickets.js";
|
||||||
import type * as users from "../users.js";
|
import type * as users from "../users.js";
|
||||||
|
|
@ -61,6 +63,7 @@ declare const fullApi: ApiFromModules<{
|
||||||
crons: typeof crons;
|
crons: typeof crons;
|
||||||
dashboards: typeof dashboards;
|
dashboards: typeof dashboards;
|
||||||
deviceExportTemplates: typeof deviceExportTemplates;
|
deviceExportTemplates: typeof deviceExportTemplates;
|
||||||
|
deviceFieldDefaults: typeof deviceFieldDefaults;
|
||||||
deviceFields: typeof deviceFields;
|
deviceFields: typeof deviceFields;
|
||||||
devices: typeof devices;
|
devices: typeof devices;
|
||||||
fields: typeof fields;
|
fields: typeof fields;
|
||||||
|
|
@ -77,6 +80,7 @@ declare const fullApi: ApiFromModules<{
|
||||||
slas: typeof slas;
|
slas: typeof slas;
|
||||||
teams: typeof teams;
|
teams: typeof teams;
|
||||||
ticketFormSettings: typeof ticketFormSettings;
|
ticketFormSettings: typeof ticketFormSettings;
|
||||||
|
ticketFormTemplates: typeof ticketFormTemplates;
|
||||||
ticketNotifications: typeof ticketNotifications;
|
ticketNotifications: typeof ticketNotifications;
|
||||||
tickets: typeof tickets;
|
tickets: typeof tickets;
|
||||||
users: typeof users;
|
users: typeof users;
|
||||||
|
|
|
||||||
|
|
@ -639,7 +639,9 @@ export const ensureQueueSummaryWidget = mutation({
|
||||||
)
|
)
|
||||||
const widgetKey = generateWidgetKey(dashboardId)
|
const widgetKey = generateWidgetKey(dashboardId)
|
||||||
const config = normalizeQueueSummaryConfig(undefined)
|
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", {
|
const widgetId = await ctx.db.insert("dashboardWidgets", {
|
||||||
tenantId,
|
tenantId,
|
||||||
dashboardId,
|
dashboardId,
|
||||||
|
|
@ -647,7 +649,7 @@ export const ensureQueueSummaryWidget = mutation({
|
||||||
title: config.title,
|
title: config.title,
|
||||||
type: "queue-summary",
|
type: "queue-summary",
|
||||||
config,
|
config,
|
||||||
layout,
|
layout: widgetLayout,
|
||||||
order: 0,
|
order: 0,
|
||||||
createdBy: actorId,
|
createdBy: actorId,
|
||||||
updatedBy: actorId,
|
updatedBy: actorId,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
|
||||||
131
convex/deviceFieldDefaults.ts
Normal file
131
convex/deviceFieldDefaults.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import { ConvexError, v } from "convex/values"
|
||||||
import type { Id } from "./_generated/dataModel"
|
import type { Id } from "./_generated/dataModel"
|
||||||
|
|
||||||
import { requireAdmin, requireUser } from "./rbac"
|
import { requireAdmin, requireUser } from "./rbac"
|
||||||
|
import { ensureMobileDeviceFields } from "./deviceFieldDefaults"
|
||||||
|
|
||||||
const FIELD_TYPES = ["text", "number", "select", "multiselect", "date", "boolean"] as const
|
const FIELD_TYPES = ["text", "number", "select", "multiselect", "date", "boolean"] as const
|
||||||
type FieldType = (typeof FIELD_TYPES)[number]
|
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 }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import type { Doc, Id } from "./_generated/dataModel"
|
||||||
import type { MutationCtx, QueryCtx } from "./_generated/server"
|
import type { MutationCtx, QueryCtx } from "./_generated/server"
|
||||||
import { normalizeStatus } from "./tickets"
|
import { normalizeStatus } from "./tickets"
|
||||||
import { requireAdmin } from "./rbac"
|
import { requireAdmin } from "./rbac"
|
||||||
|
import { ensureMobileDeviceFields } from "./deviceFieldDefaults"
|
||||||
|
|
||||||
const DEFAULT_TENANT_ID = "tenant-atlas"
|
const DEFAULT_TENANT_ID = "tenant-atlas"
|
||||||
const DEFAULT_TOKEN_TTL_MS = 1000 * 60 * 60 * 24 * 30 // 30 dias
|
const DEFAULT_TOKEN_TTL_MS = 1000 * 60 * 60 * 24 * 30 // 30 dias
|
||||||
|
|
@ -1634,6 +1635,7 @@ export const saveDeviceProfile = mutation({
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
await requireAdmin(ctx, args.actorId, args.tenantId)
|
await requireAdmin(ctx, args.actorId, args.tenantId)
|
||||||
|
await ensureMobileDeviceFields(ctx, args.tenantId)
|
||||||
const displayName = args.displayName.trim()
|
const displayName = args.displayName.trim()
|
||||||
if (!displayName) {
|
if (!displayName) {
|
||||||
throw new ConvexError("Informe o nome do dispositivo")
|
throw new ConvexError("Informe o nome do dispositivo")
|
||||||
|
|
|
||||||
|
|
@ -725,6 +725,143 @@ export const agentProductivity = query({
|
||||||
handler: agentProductivityHandler,
|
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(
|
export async function dashboardOverviewHandler(
|
||||||
ctx: QueryCtx,
|
ctx: QueryCtx,
|
||||||
{ tenantId, viewerId }: { tenantId: string; viewerId: Id<"users"> }
|
{ tenantId, viewerId }: { tenantId: string; viewerId: Id<"users"> }
|
||||||
|
|
|
||||||
|
|
@ -284,6 +284,7 @@ export default defineSchema({
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
formTemplate: v.optional(v.string()),
|
formTemplate: v.optional(v.string()),
|
||||||
|
formTemplateLabel: v.optional(v.string()),
|
||||||
relatedTicketIds: v.optional(v.array(v.id("tickets"))),
|
relatedTicketIds: v.optional(v.array(v.id("tickets"))),
|
||||||
resolvedWithTicketId: v.optional(v.id("tickets")),
|
resolvedWithTicketId: v.optional(v.id("tickets")),
|
||||||
reopenDeadline: v.optional(v.number()),
|
reopenDeadline: v.optional(v.number()),
|
||||||
|
|
@ -477,6 +478,25 @@ export default defineSchema({
|
||||||
.index("by_tenant_template_user", ["tenantId", "template", "userId"])
|
.index("by_tenant_template_user", ["tenantId", "template", "userId"])
|
||||||
.index("by_tenant", ["tenantId"]),
|
.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({
|
userInvites: defineTable({
|
||||||
tenantId: v.string(),
|
tenantId: v.string(),
|
||||||
inviteId: v.string(),
|
inviteId: v.string(),
|
||||||
|
|
|
||||||
|
|
@ -4,18 +4,11 @@ import { ConvexError, v } from "convex/values"
|
||||||
import type { Id } from "./_generated/dataModel"
|
import type { Id } from "./_generated/dataModel"
|
||||||
|
|
||||||
import { requireAdmin } from "./rbac"
|
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"])
|
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) {
|
function normalizeScope(input: string) {
|
||||||
const normalized = input.trim().toLowerCase()
|
const normalized = input.trim().toLowerCase()
|
||||||
if (!VALID_SCOPES.has(normalized)) {
|
if (!VALID_SCOPES.has(normalized)) {
|
||||||
|
|
@ -24,6 +17,22 @@ function normalizeScope(input: string) {
|
||||||
return normalized
|
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({
|
export const list = query({
|
||||||
args: {
|
args: {
|
||||||
tenantId: v.string(),
|
tenantId: v.string(),
|
||||||
|
|
@ -32,7 +41,7 @@ export const list = query({
|
||||||
},
|
},
|
||||||
handler: async (ctx, { tenantId, viewerId, template }) => {
|
handler: async (ctx, { tenantId, viewerId, template }) => {
|
||||||
await requireAdmin(ctx, viewerId, tenantId)
|
await requireAdmin(ctx, viewerId, tenantId)
|
||||||
const normalizedTemplate = template ? normalizeTemplate(template) : null
|
const normalizedTemplate = template ? normalizeFormTemplateKey(template) : null
|
||||||
const settings = await ctx.db
|
const settings = await ctx.db
|
||||||
.query("ticketFormSettings")
|
.query("ticketFormSettings")
|
||||||
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
.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 }) => {
|
handler: async (ctx, { tenantId, actorId, template, scope, companyId, userId, enabled }) => {
|
||||||
await requireAdmin(ctx, actorId, tenantId)
|
await requireAdmin(ctx, actorId, tenantId)
|
||||||
const normalizedTemplate = normalizeTemplate(template)
|
const normalizedTemplate = await ensureTemplateExists(ctx, tenantId, template)
|
||||||
const normalizedScope = normalizeScope(scope)
|
const normalizedScope = normalizeScope(scope)
|
||||||
|
|
||||||
if (normalizedScope === "company" && !companyId) {
|
if (normalizedScope === "company" && !companyId) {
|
||||||
|
|
|
||||||
280
convex/ticketFormTemplates.ts
Normal file
280
convex/ticketFormTemplates.ts
Normal 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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
127
convex/ticketForms.config.ts
Normal file
127
convex/ticketForms.config.ts
Normal 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.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
@ -7,6 +7,17 @@ import { Id, type Doc, type DataModel } from "./_generated/dataModel";
|
||||||
import type { NamedTableInfo, Query as ConvexQuery } from "convex/server";
|
import type { NamedTableInfo, Query as ConvexQuery } from "convex/server";
|
||||||
|
|
||||||
import { requireAdmin, requireStaff, requireUser } from "./rbac";
|
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 STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT"]);
|
||||||
const INTERNAL_STAFF_ROLES = new Set(["ADMIN", "AGENT"]);
|
const INTERNAL_STAFF_ROLES = new Set(["ADMIN", "AGENT"]);
|
||||||
|
|
@ -45,120 +56,13 @@ const MAX_COMMENT_CHARS = 20000;
|
||||||
const DEFAULT_REOPEN_DAYS = 7;
|
const DEFAULT_REOPEN_DAYS = 7;
|
||||||
const MAX_REOPEN_DAYS = 14;
|
const MAX_REOPEN_DAYS = 14;
|
||||||
|
|
||||||
const TICKET_FORM_CONFIG = [
|
type AnyCtx = QueryCtx | MutationCtx;
|
||||||
{
|
|
||||||
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 TicketFormFieldSeed = {
|
type TemplateSummary = {
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
type: "text" | "number" | "date" | "select" | "boolean";
|
description: string;
|
||||||
required?: boolean;
|
defaultEnabled: 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.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function plainTextLength(html: string): number {
|
function plainTextLength(html: string): number {
|
||||||
|
|
@ -182,20 +86,6 @@ function escapeHtml(input: string): string {
|
||||||
.replace(/'/g, "'");
|
.replace(/'/g, "'");
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeFormTemplateKey(input: string | null | undefined): string | null {
|
|
||||||
if (!input) return null;
|
|
||||||
const trimmed = input.trim();
|
|
||||||
if (!trimmed) return null;
|
|
||||||
const normalized = trimmed
|
|
||||||
.normalize("NFD")
|
|
||||||
.replace(/[^\w\s-]/g, "")
|
|
||||||
.replace(/\s+/g, "-")
|
|
||||||
.replace(/-+/g, "-")
|
|
||||||
.replace(/^-|-$/g, "")
|
|
||||||
.toLowerCase();
|
|
||||||
return normalized || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveReopenWindowDays(input?: number | null): number {
|
function resolveReopenWindowDays(input?: number | null): number {
|
||||||
if (typeof input !== "number" || !Number.isFinite(input)) {
|
if (typeof input !== "number" || !Number.isFinite(input)) {
|
||||||
return DEFAULT_REOPEN_DAYS;
|
return DEFAULT_REOPEN_DAYS;
|
||||||
|
|
@ -286,7 +176,32 @@ function resolveFormEnabled(
|
||||||
return baseEnabled
|
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) {
|
async function ensureTicketFormDefaultsForTenant(ctx: MutationCtx, tenantId: string) {
|
||||||
|
await ensureTicketFormTemplatesForTenant(ctx, tenantId);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
for (const template of TICKET_FORM_CONFIG) {
|
for (const template of TICKET_FORM_CONFIG) {
|
||||||
const defaults = TICKET_FORM_DEFAULT_FIELDS[template.key] ?? [];
|
const defaults = TICKET_FORM_DEFAULT_FIELDS[template.key] ?? [];
|
||||||
|
|
@ -297,18 +212,23 @@ async function ensureTicketFormDefaultsForTenant(ctx: MutationCtx, tenantId: str
|
||||||
.query("ticketFields")
|
.query("ticketFields")
|
||||||
.withIndex("by_tenant_scope", (q) => q.eq("tenantId", tenantId).eq("scope", template.key))
|
.withIndex("by_tenant_scope", (q) => q.eq("tenantId", tenantId).eq("scope", template.key))
|
||||||
.collect();
|
.collect();
|
||||||
// Hotfix: garantir que "Patrimônio do computador (se houver)" seja opcional na admissão
|
|
||||||
if (template.key === "admissao") {
|
if (template.key === "admissao") {
|
||||||
const patrimonio = existing.find((f) => f.key === "colaborador_patrimonio");
|
for (const key of OPTIONAL_ADMISSION_FIELD_KEYS) {
|
||||||
if (patrimonio) {
|
const field = existing.find((f) => f.key === key);
|
||||||
const shouldBeOptional = false;
|
if (!field) continue;
|
||||||
const needsRequiredFix = Boolean(patrimonio.required) !== shouldBeOptional;
|
const updates: Partial<Doc<"ticketFields">> = {};
|
||||||
const desiredLabel = "Patrimônio do computador (se houver)";
|
if (field.required) {
|
||||||
const needsLabelFix = (patrimonio.label ?? "").trim() !== desiredLabel;
|
updates.required = false;
|
||||||
if (needsRequiredFix || needsLabelFix) {
|
}
|
||||||
await ctx.db.patch(patrimonio._id, {
|
if (key === "colaborador_patrimonio") {
|
||||||
required: shouldBeOptional,
|
const desiredLabel = "Patrimônio do computador (se houver)";
|
||||||
label: desiredLabel,
|
if ((field.label ?? "").trim() !== desiredLabel) {
|
||||||
|
updates.label = desiredLabel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(updates).length) {
|
||||||
|
await ctx.db.patch(field._id, {
|
||||||
|
...updates,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -1242,6 +1162,7 @@ export const list = query({
|
||||||
csatRatedAt: t.csatRatedAt ?? null,
|
csatRatedAt: t.csatRatedAt ?? null,
|
||||||
csatRatedBy: t.csatRatedBy ? String(t.csatRatedBy) : null,
|
csatRatedBy: t.csatRatedBy ? String(t.csatRatedBy) : null,
|
||||||
formTemplate: t.formTemplate ?? null,
|
formTemplate: t.formTemplate ?? null,
|
||||||
|
formTemplateLabel: t.formTemplateLabel ?? null,
|
||||||
company: company
|
company: company
|
||||||
? { id: company._id, name: company.name, isAvulso: company.isAvulso ?? false }
|
? { id: company._id, name: company.name, isAvulso: company.isAvulso ?? false }
|
||||||
: t.companyId || t.companySnapshot
|
: t.companyId || t.companySnapshot
|
||||||
|
|
@ -1551,6 +1472,7 @@ export const getById = query({
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
formTemplate: t.formTemplate ?? null,
|
formTemplate: t.formTemplate ?? null,
|
||||||
|
formTemplateLabel: t.formTemplateLabel ?? null,
|
||||||
chatEnabled: Boolean(t.chatEnabled),
|
chatEnabled: Boolean(t.chatEnabled),
|
||||||
relatedTicketIds: Array.isArray(t.relatedTicketIds) ? t.relatedTicketIds.map((id) => String(id)) : [],
|
relatedTicketIds: Array.isArray(t.relatedTicketIds) ? t.relatedTicketIds.map((id) => String(id)) : [],
|
||||||
resolvedWithTicketId: t.resolvedWithTicketId ? String(t.resolvedWithTicketId) : null,
|
resolvedWithTicketId: t.resolvedWithTicketId ? String(t.resolvedWithTicketId) : null,
|
||||||
|
|
@ -1668,7 +1590,21 @@ export const create = mutation({
|
||||||
machineDoc = machine
|
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 chatEnabled = typeof args.chatEnabled === "boolean" ? args.chatEnabled : true;
|
||||||
const normalizedCustomFields = await normalizeCustomFieldValues(
|
const normalizedCustomFields = await normalizeCustomFieldValues(
|
||||||
ctx,
|
ctx,
|
||||||
|
|
@ -1752,6 +1688,7 @@ export const create = mutation({
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
formTemplate: formTemplateKey ?? undefined,
|
formTemplate: formTemplateKey ?? undefined,
|
||||||
|
formTemplateLabel: formTemplateLabel ?? undefined,
|
||||||
chatEnabled,
|
chatEnabled,
|
||||||
working: false,
|
working: false,
|
||||||
activeSessionId: undefined,
|
activeSessionId: undefined,
|
||||||
|
|
@ -2470,6 +2407,7 @@ export const listTicketForms = query({
|
||||||
fieldsByScope.get(scope)!.push(definition)
|
fieldsByScope.get(scope)!.push(definition)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const templates = await fetchTemplateSummaries(ctx, tenantId)
|
||||||
const forms = [] as Array<{
|
const forms = [] as Array<{
|
||||||
key: string
|
key: string
|
||||||
label: 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">[], {
|
let enabled = resolveFormEnabled(template.key, template.defaultEnabled, settings as Doc<"ticketFormSettings">[], {
|
||||||
companyId: viewerCompanyId,
|
companyId: viewerCompanyId,
|
||||||
userId: viewer.user._id,
|
userId: viewer.user._id,
|
||||||
|
|
|
||||||
|
|
@ -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] 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] 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] 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
|
## Riscos
|
||||||
- Necessário validar migração dos dados existentes (máquinas → dispositivos) antes de entrar em produção.
|
- Necessário validar migração dos dados existentes (máquinas → dispositivos) antes de entrar em produção.
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { CategoriesManager } from "@/components/admin/categories/categories-manager"
|
import { CategoriesManager } from "@/components/admin/categories/categories-manager"
|
||||||
import { FieldsManager } from "@/components/admin/fields/fields-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 { AppShell } from "@/components/app-shell"
|
||||||
import { SiteHeader } from "@/components/site-header"
|
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">
|
<div className="mx-auto w-full max-w-6xl space-y-8 px-6 lg:px-8">
|
||||||
<CategoriesManager />
|
<CategoriesManager />
|
||||||
|
<TicketFormTemplatesManager />
|
||||||
<FieldsManager />
|
<FieldsManager />
|
||||||
</div>
|
</div>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { AppShell } from "@/components/app-shell"
|
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 { PlayNextTicketCard } from "@/components/tickets/play-next-ticket-card"
|
||||||
import { TicketQueueSummaryCards } from "@/components/tickets/ticket-queue-summary"
|
import { TicketQueueSummaryCards } from "@/components/tickets/ticket-queue-summary"
|
||||||
import { requireAuthenticatedSession } from "@/lib/auth-server"
|
import { requireAuthenticatedSession } from "@/lib/auth-server"
|
||||||
|
|
@ -12,7 +12,6 @@ export default async function PlayPage() {
|
||||||
<SiteHeader
|
<SiteHeader
|
||||||
title="Modo play"
|
title="Modo play"
|
||||||
lead="Distribua tickets automaticamente conforme prioridade"
|
lead="Distribua tickets automaticamente conforme prioridade"
|
||||||
secondaryAction={<SiteHeaderSecondaryButton>Pausar notificações</SiteHeaderSecondaryButton>}
|
|
||||||
primaryAction={<SiteHeaderPrimaryButton>Iniciar sessão</SiteHeaderPrimaryButton>}
|
primaryAction={<SiteHeaderPrimaryButton>Iniciar sessão</SiteHeaderPrimaryButton>}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
25
src/app/reports/categories/page.tsx
Normal file
25
src/app/reports/categories/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -36,7 +36,6 @@ export function TicketsPageClient() {
|
||||||
<SiteHeader
|
<SiteHeader
|
||||||
title="Tickets"
|
title="Tickets"
|
||||||
lead="Visão consolidada de filas e SLAs"
|
lead="Visão consolidada de filas e SLAs"
|
||||||
primaryAction={<SiteHeader.SecondaryButton>Exportar CSV</SiteHeader.SecondaryButton>}
|
|
||||||
secondaryAction={<NewTicketDialog />}
|
secondaryAction={<NewTicketDialog />}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client"
|
"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 { Controller, FormProvider, useFieldArray, useForm, type UseFormReturn } from "react-hook-form"
|
||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import {
|
import {
|
||||||
|
|
@ -80,6 +80,7 @@ import { Textarea } from "@/components/ui/textarea"
|
||||||
import { TimePicker } from "@/components/ui/time-picker"
|
import { TimePicker } from "@/components/ui/time-picker"
|
||||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
|
||||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { useQuery, useMutation } from "convex/react"
|
import { useQuery, useMutation } from "convex/react"
|
||||||
import { useAuth } from "@/lib/auth-client"
|
import { useAuth } from "@/lib/auth-client"
|
||||||
import type { Id } from "@/convex/_generated/dataModel"
|
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>
|
<AccordionTrigger className="py-3 font-semibold">Tipos de solicitação</AccordionTrigger>
|
||||||
<AccordionContent className="pb-5">
|
<AccordionContent className="pb-5">
|
||||||
<CompanyRequestTypesControls tenantId={tenantId} companyId={editor?.mode === "edit" ? editor.company.id : null} />
|
<CompanyRequestTypesControls tenantId={tenantId} companyId={editor?.mode === "edit" ? editor.company.id : null} />
|
||||||
|
<CompanyExportTemplateSelector tenantId={tenantId} companyId={editor?.mode === "edit" ? editor.company.id : null} />
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
|
|
||||||
|
|
@ -2187,13 +2189,28 @@ type CompanyRequestTypesControlsProps = { tenantId?: string | null; companyId: s
|
||||||
function CompanyRequestTypesControls({ tenantId, companyId }: CompanyRequestTypesControlsProps) {
|
function CompanyRequestTypesControls({ tenantId, companyId }: CompanyRequestTypesControlsProps) {
|
||||||
const { convexUserId } = useAuth()
|
const { convexUserId } = useAuth()
|
||||||
const canLoad = Boolean(tenantId && convexUserId)
|
const canLoad = Boolean(tenantId && convexUserId)
|
||||||
|
const ensureDefaults = useMutation(api.tickets.ensureTicketFormDefaults)
|
||||||
|
const hasEnsuredRef = useRef(false)
|
||||||
const settings = useQuery(
|
const settings = useQuery(
|
||||||
api.ticketFormSettings.list,
|
api.ticketFormSettings.list,
|
||||||
canLoad ? { tenantId: tenantId as string, viewerId: convexUserId as Id<"users"> } : "skip"
|
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
|
) 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 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 scoped = (settings ?? []).filter((s) => s.template === template)
|
||||||
const base = true
|
const base = true
|
||||||
if (!companyId) return base
|
if (!companyId) return base
|
||||||
|
|
@ -2203,10 +2220,7 @@ function CompanyRequestTypesControls({ tenantId, companyId }: CompanyRequestType
|
||||||
return typeof latest?.enabled === "boolean" ? latest.enabled : base
|
return typeof latest?.enabled === "boolean" ? latest.enabled : base
|
||||||
}
|
}
|
||||||
|
|
||||||
const admissaoEnabled = resolveEnabled("admissao")
|
const handleToggle = async (template: string, enabled: boolean) => {
|
||||||
const desligamentoEnabled = resolveEnabled("desligamento")
|
|
||||||
|
|
||||||
const handleToggle = async (template: "admissao" | "desligamento", enabled: boolean) => {
|
|
||||||
if (!tenantId || !convexUserId || !companyId) return
|
if (!tenantId || !convexUserId || !companyId) return
|
||||||
try {
|
try {
|
||||||
await upsert({
|
await upsert({
|
||||||
|
|
@ -2227,24 +2241,113 @@ function CompanyRequestTypesControls({ tenantId, companyId }: CompanyRequestType
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<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>
|
<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">
|
{!templates ? (
|
||||||
<label className="flex items-center gap-2 text-sm text-foreground">
|
<div className="space-y-2">
|
||||||
<Checkbox
|
<Skeleton className="h-10 w-full rounded-lg" />
|
||||||
checked={admissaoEnabled}
|
<Skeleton className="h-10 w-full rounded-lg" />
|
||||||
onCheckedChange={(v) => handleToggle("admissao", Boolean(v))}
|
</div>
|
||||||
disabled={!companyId}
|
) : templates.length === 0 ? (
|
||||||
/>
|
<p className="text-sm text-neutral-500">Nenhum formulário disponível.</p>
|
||||||
<span>Admissão de colaborador</span>
|
) : (
|
||||||
</label>
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<label className="flex items-center gap-2 text-sm text-foreground">
|
{templates.map((template) => {
|
||||||
<Checkbox
|
const enabled = resolveEnabled(template.key)
|
||||||
checked={desligamentoEnabled}
|
return (
|
||||||
onCheckedChange={(v) => handleToggle("desligamento", Boolean(v))}
|
<label key={template.key} className="flex items-center gap-2 text-sm text-foreground">
|
||||||
disabled={!companyId}
|
<Checkbox
|
||||||
/>
|
checked={enabled}
|
||||||
<span>Desligamento de colaborador</span>
|
onCheckedChange={(v) => handleToggle(template.key, Boolean(v))}
|
||||||
</label>
|
disabled={!companyId}
|
||||||
</div>
|
/>
|
||||||
|
<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 > 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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2441,6 +2441,9 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
|
||||||
const effectiveStatus = device ? resolveDeviceStatus(device) : "unknown"
|
const effectiveStatus = device ? resolveDeviceStatus(device) : "unknown"
|
||||||
const [isActiveLocal, setIsActiveLocal] = useState<boolean>(device?.isActive ?? true)
|
const [isActiveLocal, setIsActiveLocal] = useState<boolean>(device?.isActive ?? true)
|
||||||
const isDeactivated = !isActiveLocal || effectiveStatus === "deactivated"
|
const isDeactivated = !isActiveLocal || effectiveStatus === "deactivated"
|
||||||
|
const isManualMobile =
|
||||||
|
(device?.managementMode ?? "").toLowerCase() === "manual" &&
|
||||||
|
(device?.deviceType ?? "").toLowerCase() === "mobile"
|
||||||
const alertsHistory = useQuery(
|
const alertsHistory = useQuery(
|
||||||
api.devices.listAlerts,
|
api.devices.listAlerts,
|
||||||
device ? { machineId: device.id as Id<"machines">, limit: 50 } : "skip"
|
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">
|
<h1 className="break-words text-2xl font-semibold text-neutral-900">
|
||||||
{device.displayName ?? device.hostname ?? "Dispositivo"}
|
{device.displayName ?? device.hostname ?? "Dispositivo"}
|
||||||
</h1>
|
</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
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
@ -3723,29 +3731,33 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
|
||||||
<ShieldCheck className="size-4" />
|
<ShieldCheck className="size-4" />
|
||||||
Ajustar acesso
|
Ajustar acesso
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
{!isManualMobile ? (
|
||||||
size="sm"
|
<>
|
||||||
variant="outline"
|
<Button
|
||||||
className="gap-2 border-dashed border-amber-300 text-amber-700 hover:border-amber-400 hover:text-amber-800"
|
size="sm"
|
||||||
onClick={handleResetAgent}
|
variant="outline"
|
||||||
disabled={isResettingAgent}
|
className="gap-2 border-dashed border-amber-300 text-amber-700 hover:border-amber-400 hover:text-amber-800"
|
||||||
>
|
onClick={handleResetAgent}
|
||||||
<RefreshCcw className={cn("size-4", isResettingAgent && "animate-spin")} />
|
disabled={isResettingAgent}
|
||||||
{isResettingAgent ? "Resetando agente..." : "Resetar agente"}
|
>
|
||||||
</Button>
|
<RefreshCcw className={cn("size-4", isResettingAgent && "animate-spin")} />
|
||||||
<Button
|
{isResettingAgent ? "Resetando agente..." : "Resetar agente"}
|
||||||
size="sm"
|
</Button>
|
||||||
variant={isActiveLocal ? "outline" : "default"}
|
<Button
|
||||||
className={cn(
|
size="sm"
|
||||||
"gap-2 border-dashed",
|
variant={isActiveLocal ? "outline" : "default"}
|
||||||
!isActiveLocal && "bg-emerald-600 text-white hover:bg-emerald-600/90"
|
className={cn(
|
||||||
)}
|
"gap-2 border-dashed",
|
||||||
onClick={handleToggleActive}
|
!isActiveLocal && "bg-emerald-600 text-white hover:bg-emerald-600/90"
|
||||||
disabled={togglingActive}
|
)}
|
||||||
>
|
onClick={handleToggleActive}
|
||||||
{isActiveLocal ? <Power className="size-4" /> : <PlayCircle className="size-4" />}
|
disabled={togglingActive}
|
||||||
{isActiveLocal ? (togglingActive ? "Desativando..." : "Desativar") : togglingActive ? "Reativando..." : "Reativar"}
|
>
|
||||||
</Button>
|
{isActiveLocal ? <Power className="size-4" /> : <PlayCircle className="size-4" />}
|
||||||
|
{isActiveLocal ? (togglingActive ? "Desativando..." : "Desativar") : togglingActive ? "Reativando..." : "Reativar"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
{device.registeredBy ? (
|
{device.registeredBy ? (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -4157,55 +4169,59 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<section className="space-y-2">
|
{!isManualMobile ? (
|
||||||
<h4 className="text-sm font-semibold">Sincronização</h4>
|
<section className="space-y-2">
|
||||||
<div className="grid gap-2 text-sm text-muted-foreground">
|
<h4 className="text-sm font-semibold">Sincronização</h4>
|
||||||
<div className="flex justify-between gap-4">
|
<div className="grid gap-2 text-sm text-muted-foreground">
|
||||||
<span>Último heartbeat</span>
|
<div className="flex justify-between gap-4">
|
||||||
<span className="text-right font-medium text-foreground">
|
<span>Último heartbeat</span>
|
||||||
{formatRelativeTime(lastHeartbeatDate)}
|
<span className="text-right font-medium text-foreground">
|
||||||
</span>
|
{formatRelativeTime(lastHeartbeatDate)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between gap-4">
|
||||||
|
<span>Criada em</span>
|
||||||
|
<span className="text-right font-medium text-foreground">{formatDate(new Date(device.createdAt))}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between gap-4">
|
||||||
|
<span>Atualizada em</span>
|
||||||
|
<span className="text-right font-medium text-foreground">{formatDate(new Date(device.updatedAt))}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between gap-4">
|
||||||
|
<span>Token expira</span>
|
||||||
|
<span className="text-right font-medium text-foreground">
|
||||||
|
{tokenExpiry ? formatRelativeTime(tokenExpiry) : "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between gap-4">
|
||||||
|
<span>Token usado por último</span>
|
||||||
|
<span className="text-right font-medium text-foreground">
|
||||||
|
{tokenLastUsed ? formatRelativeTime(tokenLastUsed) : "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between gap-4">
|
||||||
|
<span>Uso do token</span>
|
||||||
|
<span className="text-right font-medium text-foreground">{device.token?.usageCount ?? 0} trocas</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between gap-4">
|
</section>
|
||||||
<span>Criada em</span>
|
) : null}
|
||||||
<span className="text-right font-medium text-foreground">{formatDate(new Date(device.createdAt))}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between gap-4">
|
|
||||||
<span>Atualizada em</span>
|
|
||||||
<span className="text-right font-medium text-foreground">{formatDate(new Date(device.updatedAt))}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between gap-4">
|
|
||||||
<span>Token expira</span>
|
|
||||||
<span className="text-right font-medium text-foreground">
|
|
||||||
{tokenExpiry ? formatRelativeTime(tokenExpiry) : "—"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between gap-4">
|
|
||||||
<span>Token usado por último</span>
|
|
||||||
<span className="text-right font-medium text-foreground">
|
|
||||||
{tokenLastUsed ? formatRelativeTime(tokenLastUsed) : "—"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between gap-4">
|
|
||||||
<span>Uso do token</span>
|
|
||||||
<span className="text-right font-medium text-foreground">{device.token?.usageCount ?? 0} trocas</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="space-y-2">
|
{!isManualMobile ? (
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<section className="space-y-2">
|
||||||
<h4 className="text-sm font-semibold">Métricas recentes</h4>
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{lastUpdateRelative ? (
|
<h4 className="text-sm font-semibold">Métricas recentes</h4>
|
||||||
<span className="text-xs font-medium text-muted-foreground">
|
{lastUpdateRelative ? (
|
||||||
Última atualização {lastUpdateRelative}
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
</span>
|
Última atualização {lastUpdateRelative}
|
||||||
) : null}
|
</span>
|
||||||
</div>
|
) : null}
|
||||||
<MetricsGrid metrics={metrics} hardware={hardware} disks={disks} />
|
</div>
|
||||||
</section>
|
<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">
|
<section className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-sm font-semibold">Inventário</h4>
|
<h4 className="text-sm font-semibold">Inventário</h4>
|
||||||
|
|
@ -5145,39 +5161,41 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<section className="space-y-2">
|
{!isManualMobile ? (
|
||||||
<div className="flex items-center justify-between">
|
<section className="space-y-2">
|
||||||
<h4 className="text-sm font-semibold">Histórico de alertas</h4>
|
<div className="flex items-center justify-between">
|
||||||
{deviceAlertsHistory.length > 0 ? (
|
<h4 className="text-sm font-semibold">Histórico de alertas</h4>
|
||||||
<span className="text-xs text-muted-foreground">
|
{deviceAlertsHistory.length > 0 ? (
|
||||||
Últimos {deviceAlertsHistory.length} {deviceAlertsHistory.length === 1 ? "evento" : "eventos"}
|
<span className="text-xs text-muted-foreground">
|
||||||
</span>
|
Últimos {deviceAlertsHistory.length} {deviceAlertsHistory.length === 1 ? "evento" : "eventos"}
|
||||||
) : null}
|
</span>
|
||||||
</div>
|
) : null}
|
||||||
{deviceAlertsHistory.length > 0 ? (
|
|
||||||
<div className="relative max-h-64 overflow-y-auto pr-2">
|
|
||||||
<div className="absolute left-3 top-3 bottom-3 w-px bg-slate-200" />
|
|
||||||
<ol className="space-y-3 pl-6">
|
|
||||||
{deviceAlertsHistory.map((alert) => {
|
|
||||||
const date = new Date(alert.createdAt)
|
|
||||||
return (
|
|
||||||
<li key={alert.id} className="relative rounded-md border border-slate-200/80 bg-white px-3 py-2 text-xs shadow-sm">
|
|
||||||
<span className="absolute -left-5 top-3 inline-flex size-3 items-center justify-center rounded-full border border-white bg-slate-200 ring-2 ring-white" />
|
|
||||||
<div className={cn("flex items-center justify-between", postureSeverityClass(alert.severity))}>
|
|
||||||
<span className="text-xs font-medium uppercase tracking-wide text-slate-600">{formatPostureAlertKind(alert.kind)}</span>
|
|
||||||
<span className="text-xs text-slate-500">{formatRelativeTime(date)}</span>
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 text-sm text-foreground">{alert.message ?? formatPostureAlertKind(alert.kind)}</p>
|
|
||||||
<p className="mt-1 text-[11px] text-muted-foreground">{format(date, "dd/MM/yyyy HH:mm:ss")}</p>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</ol>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
{deviceAlertsHistory.length > 0 ? (
|
||||||
<p className="text-xs text-muted-foreground">Nenhum alerta registrado para este dispositivo.</p>
|
<div className="relative max-h-64 overflow-y-auto pr-2">
|
||||||
)}
|
<div className="absolute left-3 top-3 bottom-3 w-px bg-slate-200" />
|
||||||
</section>
|
<ol className="space-y-3 pl-6">
|
||||||
|
{deviceAlertsHistory.map((alert) => {
|
||||||
|
const date = new Date(alert.createdAt)
|
||||||
|
return (
|
||||||
|
<li key={alert.id} className="relative rounded-md border border-slate-200/80 bg-white px-3 py-2 text-xs shadow-sm">
|
||||||
|
<span className="absolute -left-5 top-3 inline-flex size-3 items-center justify-center rounded-full border border-white bg-slate-200 ring-2 ring-white" />
|
||||||
|
<div className={cn("flex items-center justify-between", postureSeverityClass(alert.severity))}>
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wide text-slate-600">{formatPostureAlertKind(alert.kind)}</span>
|
||||||
|
<span className="text-xs text-slate-500">{formatRelativeTime(date)}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-foreground">{alert.message ?? formatPostureAlertKind(alert.kind)}</p>
|
||||||
|
<p className="mt-1 text-[11px] text-muted-foreground">{format(date, "dd/MM/yyyy HH:mm:ss")}</p>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<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">
|
<div className="flex flex-wrap gap-2 pt-2">
|
||||||
{Array.isArray(software) && software.length > 0 ? (
|
{Array.isArray(software) && software.length > 0 ? (
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ type Field = {
|
||||||
required: boolean
|
required: boolean
|
||||||
options: FieldOption[]
|
options: FieldOption[]
|
||||||
order: number
|
order: number
|
||||||
|
scope: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const TYPE_LABELS: Record<Field["type"], string> = {
|
const TYPE_LABELS: Record<Field["type"], string> = {
|
||||||
|
|
@ -48,6 +49,25 @@ export function FieldsManager() {
|
||||||
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
|
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
|
||||||
) as Field[] | undefined
|
) 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 createField = useMutation(api.fields.create)
|
||||||
const updateField = useMutation(api.fields.update)
|
const updateField = useMutation(api.fields.update)
|
||||||
const removeField = useMutation(api.fields.remove)
|
const removeField = useMutation(api.fields.remove)
|
||||||
|
|
@ -58,8 +78,10 @@ export function FieldsManager() {
|
||||||
const [type, setType] = useState<Field["type"]>("text")
|
const [type, setType] = useState<Field["type"]>("text")
|
||||||
const [required, setRequired] = useState(false)
|
const [required, setRequired] = useState(false)
|
||||||
const [options, setOptions] = useState<FieldOption[]>([])
|
const [options, setOptions] = useState<FieldOption[]>([])
|
||||||
|
const [scopeSelection, setScopeSelection] = useState<string>("all")
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [editingField, setEditingField] = useState<Field | null>(null)
|
const [editingField, setEditingField] = useState<Field | null>(null)
|
||||||
|
const [editingScope, setEditingScope] = useState<string>("all")
|
||||||
|
|
||||||
const totals = useMemo(() => {
|
const totals = useMemo(() => {
|
||||||
if (!fields) return { total: 0, required: 0, select: 0 }
|
if (!fields) return { total: 0, required: 0, select: 0 }
|
||||||
|
|
@ -76,6 +98,7 @@ export function FieldsManager() {
|
||||||
setType("text")
|
setType("text")
|
||||||
setRequired(false)
|
setRequired(false)
|
||||||
setOptions([])
|
setOptions([])
|
||||||
|
setScopeSelection("all")
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizeOptions = (source: FieldOption[]) =>
|
const normalizeOptions = (source: FieldOption[]) =>
|
||||||
|
|
@ -97,6 +120,7 @@ export function FieldsManager() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const preparedOptions = type === "select" ? normalizeOptions(options) : undefined
|
const preparedOptions = type === "select" ? normalizeOptions(options) : undefined
|
||||||
|
const scopeValue = scopeSelection === "all" ? undefined : scopeSelection
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
toast.loading("Criando campo...", { id: "field" })
|
toast.loading("Criando campo...", { id: "field" })
|
||||||
try {
|
try {
|
||||||
|
|
@ -108,6 +132,7 @@ export function FieldsManager() {
|
||||||
type,
|
type,
|
||||||
required,
|
required,
|
||||||
options: preparedOptions,
|
options: preparedOptions,
|
||||||
|
scope: scopeValue,
|
||||||
})
|
})
|
||||||
toast.success("Campo criado", { id: "field" })
|
toast.success("Campo criado", { id: "field" })
|
||||||
resetForm()
|
resetForm()
|
||||||
|
|
@ -147,6 +172,7 @@ export function FieldsManager() {
|
||||||
setType(field.type)
|
setType(field.type)
|
||||||
setRequired(field.required)
|
setRequired(field.required)
|
||||||
setOptions(field.options)
|
setOptions(field.options)
|
||||||
|
setEditingScope(field.scope ?? "all")
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUpdate = async () => {
|
const handleUpdate = async () => {
|
||||||
|
|
@ -160,6 +186,7 @@ export function FieldsManager() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const preparedOptions = type === "select" ? normalizeOptions(options) : undefined
|
const preparedOptions = type === "select" ? normalizeOptions(options) : undefined
|
||||||
|
const scopeValue = editingScope === "all" ? undefined : editingScope
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
toast.loading("Atualizando campo...", { id: "field-edit" })
|
toast.loading("Atualizando campo...", { id: "field-edit" })
|
||||||
try {
|
try {
|
||||||
|
|
@ -172,6 +199,7 @@ export function FieldsManager() {
|
||||||
type,
|
type,
|
||||||
required,
|
required,
|
||||||
options: preparedOptions,
|
options: preparedOptions,
|
||||||
|
scope: scopeValue,
|
||||||
})
|
})
|
||||||
toast.success("Campo atualizado", { id: "field-edit" })
|
toast.success("Campo atualizado", { id: "field-edit" })
|
||||||
setEditingField(null)
|
setEditingField(null)
|
||||||
|
|
@ -304,6 +332,21 @@ export function FieldsManager() {
|
||||||
Campo obrigatório na abertura
|
Campo obrigatório na abertura
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</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>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
@ -378,7 +421,12 @@ export function FieldsManager() {
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</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">
|
<Card key={field.id} className="border-slate-200">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
|
@ -395,6 +443,9 @@ export function FieldsManager() {
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<CardDescription className="text-neutral-600">Identificador: {field.key}</CardDescription>
|
<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 ? (
|
{field.description ? (
|
||||||
<p className="text-sm text-neutral-600">{field.description}</p>
|
<p className="text-sm text-neutral-600">{field.description}</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -446,7 +497,8 @@ export function FieldsManager() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
) : null}
|
) : null}
|
||||||
</Card>
|
</Card>
|
||||||
))
|
)
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -487,6 +539,21 @@ export function FieldsManager() {
|
||||||
Campo obrigatório na abertura
|
Campo obrigatório na abertura
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</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>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
|
||||||
345
src/components/admin/fields/ticket-form-templates-manager.tsx
Normal file
345
src/components/admin/fields/ticket-form-templates-manager.tsx
Normal 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 "Novo formulário" 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client"
|
"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 { format } from "date-fns"
|
||||||
import { ptBR } from "date-fns/locale"
|
import { ptBR } from "date-fns/locale"
|
||||||
import { Controller, FormProvider, useFieldArray, useForm } from "react-hook-form"
|
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 { Badge } from "@/components/ui/badge"
|
||||||
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
|
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
|
||||||
import { EmptyIndicator } from "@/components/ui/empty-indicator"
|
import { EmptyIndicator } from "@/components/ui/empty-indicator"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
|
||||||
export type AdminAccount = {
|
export type AdminAccount = {
|
||||||
id: string
|
id: string
|
||||||
|
|
@ -265,9 +266,24 @@ function AccountsTable({
|
||||||
api.ticketFormSettings.list,
|
api.ticketFormSettings.list,
|
||||||
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
|
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
|
||||||
) as Array<{ template: string; scope: string; userId?: string | null; enabled: boolean; updatedAt: number }> | undefined
|
) 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 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
|
if (!editAccount) return true
|
||||||
const scoped = (formSettings ?? []).filter((s) => s.template === template)
|
const scoped = (formSettings ?? []).filter((s) => s.template === template)
|
||||||
const latest = scoped
|
const latest = scoped
|
||||||
|
|
@ -276,7 +292,7 @@ function AccountsTable({
|
||||||
return typeof latest?.enabled === "boolean" ? latest.enabled : true
|
return typeof latest?.enabled === "boolean" ? latest.enabled : true
|
||||||
}, [formSettings, editAccount])
|
}, [formSettings, editAccount])
|
||||||
|
|
||||||
const handleToggleUserForm = useCallback(async (template: "admissao" | "desligamento", enabled: boolean) => {
|
const handleToggleUserForm = useCallback(async (template: string, enabled: boolean) => {
|
||||||
if (!convexUserId || !editAccount) return
|
if (!convexUserId || !editAccount) return
|
||||||
try {
|
try {
|
||||||
await upsertFormSetting({
|
await upsertFormSetting({
|
||||||
|
|
@ -1019,24 +1035,27 @@ function AccountsTable({
|
||||||
<div className="rounded-lg border border-border/60 bg-muted/20 p-4">
|
<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="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>
|
<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">
|
{!templates ? (
|
||||||
<label className="flex items-center gap-2 text-sm text-foreground">
|
<div className="space-y-2">
|
||||||
<Checkbox
|
<Skeleton className="h-8 w-full rounded-md" />
|
||||||
checked={resolveUserFormEnabled("admissao")}
|
<Skeleton className="h-8 w-full rounded-md" />
|
||||||
onCheckedChange={(v) => handleToggleUserForm("admissao", Boolean(v))}
|
</div>
|
||||||
disabled={!editAccount || isSavingAccount}
|
) : templates.length === 0 ? (
|
||||||
/>
|
<p className="text-xs text-neutral-500">Nenhum formulário configurado.</p>
|
||||||
<span>Admissão de colaborador</span>
|
) : (
|
||||||
</label>
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
<label className="flex items-center gap-2 text-sm text-foreground">
|
{templates.map((template) => (
|
||||||
<Checkbox
|
<label key={template.key} className="flex items-center gap-2 text-sm text-foreground">
|
||||||
checked={resolveUserFormEnabled("desligamento")}
|
<Checkbox
|
||||||
onCheckedChange={(v) => handleToggleUserForm("desligamento", Boolean(v))}
|
checked={resolveUserFormEnabled(template.key)}
|
||||||
disabled={!editAccount || isSavingAccount}
|
onCheckedChange={(v) => handleToggleUserForm(template.key, Boolean(v))}
|
||||||
/>
|
disabled={!editAccount || isSavingAccount}
|
||||||
<span>Desligamento de colaborador</span>
|
/>
|
||||||
</label>
|
<span>{template.label}</span>
|
||||||
</div>
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Users,
|
Users,
|
||||||
|
Layers3,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { usePathname } from "next/navigation"
|
import { usePathname } from "next/navigation"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
|
|
@ -90,6 +91,7 @@ const navigation: NavigationGroup[] = [
|
||||||
{ title: "Qualidade (CSAT)", url: "/reports/csat", icon: LifeBuoy, requiredRole: "staff" },
|
{ title: "Qualidade (CSAT)", url: "/reports/csat", icon: LifeBuoy, requiredRole: "staff" },
|
||||||
{ title: "Backlog", url: "/reports/backlog", icon: BarChart3, requiredRole: "staff" },
|
{ title: "Backlog", url: "/reports/backlog", icon: BarChart3, requiredRole: "staff" },
|
||||||
{ title: "Empresas", url: "/reports/company", icon: Building2, 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" },
|
{ title: "Horas", url: "/reports/hours", icon: Clock4, requiredRole: "staff" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ import {
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card"
|
} from "@/components/ui/card"
|
||||||
|
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|
@ -88,6 +89,7 @@ import {
|
||||||
PauseCircle,
|
PauseCircle,
|
||||||
PlayCircle,
|
PlayCircle,
|
||||||
Plus,
|
Plus,
|
||||||
|
Share2,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Table2,
|
Table2,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
|
@ -569,6 +571,7 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" }
|
||||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
|
||||||
const [isDeletingDashboard, setIsDeletingDashboard] = useState(false)
|
const [isDeletingDashboard, setIsDeletingDashboard] = useState(false)
|
||||||
const fullscreenContainerRef = useRef<HTMLDivElement | null>(null)
|
const fullscreenContainerRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const autoFullscreenRef = useRef(false)
|
||||||
const previousSidebarStateRef = useRef<{ open: boolean; openMobile: boolean } | null>(null)
|
const previousSidebarStateRef = useRef<{ open: boolean; openMobile: boolean } | null>(null)
|
||||||
const ensureQueueSummaryRequestedRef = useRef(false)
|
const ensureQueueSummaryRequestedRef = useRef(false)
|
||||||
|
|
||||||
|
|
@ -709,6 +712,28 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" }
|
||||||
}
|
}
|
||||||
}, [isMobile, open, openMobile, setOpen, setOpenMobile])
|
}, [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 packedLayout = useMemo(() => packLayout(layoutState, GRID_COLUMNS), [layoutState])
|
||||||
|
|
||||||
const metricOptions = useMemo(() => getMetricOptionsForRole(userRole), [userRole])
|
const metricOptions = useMemo(() => getMetricOptionsForRole(userRole), [userRole])
|
||||||
|
|
@ -1133,7 +1158,7 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" }
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-1 flex-col gap-6",
|
"flex flex-1 flex-col gap-6",
|
||||||
isFullscreen &&
|
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"),
|
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}
|
) : null}
|
||||||
|
|
||||||
{visibleCount === 0 ? (
|
{visibleCount === 0 ? (
|
||||||
<Card className="border-dashed border-muted-foreground/40 bg-muted/10 py-12 text-center">
|
<section className="rounded-2xl border border-dashed border-slate-200 bg-white/90 p-6 shadow-sm">
|
||||||
<CardHeader>
|
<Empty className="border-none bg-transparent p-0">
|
||||||
<CardTitle className="flex items-center justify-center gap-2 text-lg font-semibold">
|
<EmptyMedia variant="icon" className="bg-slate-100 text-slate-600">
|
||||||
<Sparkles className="size-4 text-primary" />
|
<Sparkles className="size-5" />
|
||||||
Comece adicionando widgets
|
</EmptyMedia>
|
||||||
</CardTitle>
|
<EmptyHeader>
|
||||||
<CardDescription>
|
<EmptyTitle className="text-xl">Canvas em branco</EmptyTitle>
|
||||||
KPIs, gráficos ou tabelas podem ser combinados para contar histórias relevantes para a operação.
|
<EmptyDescription>
|
||||||
</CardDescription>
|
Adicione cards de KPI, gráficos ou texto para montar a visão diária da operação.
|
||||||
</CardHeader>
|
</EmptyDescription>
|
||||||
<CardContent className="flex justify-center">
|
</EmptyHeader>
|
||||||
{canEdit ? (
|
{canEdit ? (
|
||||||
<Button onClick={() => handleAddWidget("kpi")} disabled={isAddingWidget}>
|
<EmptyContent>
|
||||||
<Plus className="mr-2 size-4" />
|
<Button onClick={() => handleAddWidget("kpi")} disabled={isAddingWidget} className="gap-2">
|
||||||
Adicionar primeiro widget
|
<Plus className="size-4" />
|
||||||
</Button>
|
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>
|
</Empty>
|
||||||
</Card>
|
<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}
|
) : null}
|
||||||
|
|
||||||
{enforceTv ? (
|
{enforceTv ? (
|
||||||
|
|
@ -1341,6 +1398,9 @@ function BuilderHeader({
|
||||||
? dashboard.theme
|
? dashboard.theme
|
||||||
: null
|
: 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 = () => {
|
const handleStartEditHeader = () => {
|
||||||
setDraftName(name)
|
setDraftName(name)
|
||||||
setDraftDescription(description ?? "")
|
setDraftDescription(description ?? "")
|
||||||
|
|
@ -1430,26 +1490,17 @@ function BuilderHeader({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
<Badge
|
<Badge variant="outline" className={metricBadgeClass}>
|
||||||
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"
|
|
||||||
>
|
|
||||||
<span className="h-2 w-2 rounded-full bg-neutral-400" />
|
<span className="h-2 w-2 rounded-full bg-neutral-400" />
|
||||||
Formato {dashboard.aspectRatio ?? "16:9"}
|
Formato {dashboard.aspectRatio ?? "16:9"}
|
||||||
</Badge>
|
</Badge>
|
||||||
{themeLabel ? (
|
{themeLabel ? (
|
||||||
<Badge
|
<Badge variant="outline" className={metricBadgeClass}>
|
||||||
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"
|
|
||||||
>
|
|
||||||
<span className="h-2 w-2 rounded-full bg-neutral-400" />
|
<span className="h-2 w-2 rounded-full bg-neutral-400" />
|
||||||
Tema {themeLabel}
|
Tema {themeLabel}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
<Badge
|
<Badge variant="outline" className={metricBadgeClass}>
|
||||||
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"
|
|
||||||
>
|
|
||||||
<span className="h-2 w-2 rounded-full bg-neutral-400" />
|
<span className="h-2 w-2 rounded-full bg-neutral-400" />
|
||||||
{totalWidgets} bloco{totalWidgets === 1 ? "" : "s"}
|
{totalWidgets} bloco{totalWidgets === 1 ? "" : "s"}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
@ -1483,12 +1534,17 @@ function BuilderHeader({
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-1 flex-col gap-3">
|
||||||
{canEdit ? <WidgetPicker onSelect={onAddWidget} disabled={isAddingWidget} /> : null}
|
{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
|
<Button
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
size="sm"
|
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}
|
onClick={onToggleFullscreen}
|
||||||
>
|
>
|
||||||
{isFullscreen ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
|
{isFullscreen ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
|
||||||
|
|
@ -1497,18 +1553,21 @@ function BuilderHeader({
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant={isTvMode ? "secondary" : "default"}
|
variant={isTvMode ? "secondary" : "ghost"}
|
||||||
size="sm"
|
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 ? <PauseCircle className="size-4" /> : <PlayCircle className="size-4" />}
|
||||||
{isTvMode ? "Modo apresentação ativo" : "Modo apresentação"}
|
Modo apresentação
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-60">
|
<DropdownMenuContent align="end" className="w-60">
|
||||||
<DropdownMenuLabel>Modo apresentação</DropdownMenuLabel>
|
<DropdownMenuLabel>Modo apresentação</DropdownMenuLabel>
|
||||||
<DropdownMenuItem onSelect={onToggleTvMode}>
|
<DropdownMenuItem onSelect={onToggleTvMode}>
|
||||||
{isTvMode ? "Encerrar modo apresentação" : "Iniciar modo apresentação"}
|
{isTvMode ? "Encerrar" : "Iniciar"}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuLabel>Tempo por seção</DropdownMenuLabel>
|
<DropdownMenuLabel>Tempo por seção</DropdownMenuLabel>
|
||||||
|
|
@ -1527,42 +1586,41 @@ function BuilderHeader({
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
))}
|
))}
|
||||||
{canEdit ? null : (
|
{canEdit ? null : (
|
||||||
<DropdownMenuItem disabled>
|
<DropdownMenuItem disabled>Apenas edição permite ajustar o tempo</DropdownMenuItem>
|
||||||
Apenas edição permite ajustar o tempo
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
)}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
<DropdownMenu>
|
||||||
<DropdownMenu>
|
<DropdownMenuTrigger asChild>
|
||||||
<DropdownMenuTrigger asChild>
|
<Button variant="ghost" size="sm" className="gap-2 rounded-full border border-slate-200 px-3 font-medium text-neutral-700">
|
||||||
<Button variant="outline" size="sm" className="gap-2">
|
<Download className="size-4" />
|
||||||
<Download className="size-4" />
|
Exportar
|
||||||
Exportar
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuLabel>Exportar dashboard</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem disabled={isExporting} onSelect={() => onExport("pdf")}>
|
||||||
|
Exportar como PDF
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem disabled={isExporting} onSelect={() => onExport("png")}>
|
||||||
|
Exportar como PNG
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
{canEdit ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
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" />
|
||||||
|
Excluir
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
) : null}
|
||||||
<DropdownMenuContent align="end">
|
</div>
|
||||||
<DropdownMenuLabel>Exportar dashboard</DropdownMenuLabel>
|
</div>
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem disabled={isExporting} onSelect={() => onExport("pdf")}>
|
|
||||||
Exportar como PDF
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem disabled={isExporting} onSelect={() => onExport("png")}>
|
|
||||||
Exportar como PNG
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
{canEdit ? (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
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"
|
|
||||||
onClick={onDeleteRequest}
|
|
||||||
>
|
|
||||||
<Trash2 className="size-4" />
|
|
||||||
Excluir
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -1749,7 +1807,7 @@ function TvCanvas({ items, isFullscreen }: { items: CanvasRenderableItem[]; isFu
|
||||||
<div
|
<div
|
||||||
key={item.key}
|
key={item.key}
|
||||||
className={cn(
|
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" : "",
|
isSingle ? "max-w-4xl justify-self-center" : "",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { useRouter } from "next/navigation"
|
||||||
import { useMutation, useQuery } from "convex/react"
|
import { useMutation, useQuery } from "convex/react"
|
||||||
import { formatDistanceToNow } from "date-fns"
|
import { formatDistanceToNow } from "date-fns"
|
||||||
import { ptBR } from "date-fns/locale"
|
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 type { Id } from "@/convex/_generated/dataModel"
|
||||||
|
|
||||||
import { api } from "@/convex/_generated/api"
|
import { api } from "@/convex/_generated/api"
|
||||||
|
|
@ -239,23 +239,42 @@ export function DashboardListView() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{activeDashboards.length === 0 ? (
|
{activeDashboards.length === 0 ? (
|
||||||
<Card className="border-dashed border-muted-foreground/40 bg-muted/10">
|
<Card className="overflow-hidden border-dashed border-slate-200 bg-gradient-to-br from-white via-white to-slate-50 shadow-sm">
|
||||||
<CardHeader className="flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between">
|
<CardContent className="flex flex-col gap-6 p-6 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<div>
|
<div className="space-y-4">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<div className="flex items-center gap-3">
|
||||||
<Sparkles className="size-4 text-primary" />
|
<div className="inline-flex size-12 items-center justify-center rounded-full bg-sky-50 text-sky-700">
|
||||||
Crie o seu primeiro dashboard
|
<Sparkles className="size-5" />
|
||||||
</CardTitle>
|
</div>
|
||||||
<CardDescription>
|
<div>
|
||||||
Monte painéis por cliente, fila ou operação e compartilhe com a equipe.
|
<h3 className="text-xl font-semibold text-neutral-900">Nenhum dashboard ainda</h3>
|
||||||
</CardDescription>
|
<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()}
|
||||||
|
<Button variant="outline" className="gap-2" asChild>
|
||||||
|
<Link href="/views">
|
||||||
|
<LayoutTemplate className="size-4" />
|
||||||
|
Ver exemplos
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{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>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -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 CHART_COLORS = ["var(--chart-1)", "var(--chart-2)", "var(--chart-3)", "var(--chart-4)", "var(--chart-5)"]
|
||||||
const DEFAULT_CHART_HEIGHT = 320
|
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 = {
|
export type DashboardFilters = {
|
||||||
range?: "7d" | "30d" | "90d" | "custom"
|
range?: "7d" | "30d" | "90d" | "custom"
|
||||||
|
|
@ -415,7 +416,7 @@ function WidgetCard({ title, description, children, isLoading }: WidgetCardProps
|
||||||
<CardTitle className="text-base font-semibold">{title}</CardTitle>
|
<CardTitle className="text-base font-semibold">{title}</CardTitle>
|
||||||
{description ? <CardDescription>{description}</CardDescription> : null}
|
{description ? <CardDescription>{description}</CardDescription> : null}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex-1 pb-4 pt-0">
|
<CardContent className="flex-1 overflow-visible pb-4 pt-0">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Skeleton className="h-full min-h-[240px] w-full rounded-xl animate-pulse" />
|
<Skeleton className="h-full min-h-[240px] w-full rounded-xl animate-pulse" />
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -514,6 +515,7 @@ function renderBarChart({
|
||||||
const allowDecimals = valueFormatter === "percent"
|
const allowDecimals = valueFormatter === "percent"
|
||||||
const isPresentation = mode === "tv" || mode === "print"
|
const isPresentation = mode === "tv" || mode === "print"
|
||||||
const minHeight = isPresentation ? PRESENTATION_CHART_HEIGHT : DEFAULT_CHART_HEIGHT
|
const minHeight = isPresentation ? PRESENTATION_CHART_HEIGHT : DEFAULT_CHART_HEIGHT
|
||||||
|
const legendPadClass = isPresentation ? "pb-8" : "pb-6"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WidgetCard title={title} description={description} isLoading={metric.isLoading}>
|
<WidgetCard title={title} description={description} isLoading={metric.isLoading}>
|
||||||
|
|
@ -522,8 +524,13 @@ function renderBarChart({
|
||||||
) : (
|
) : (
|
||||||
<ChartContainer
|
<ChartContainer
|
||||||
config={chartConfig as ChartConfig}
|
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"
|
className={cn(
|
||||||
style={{ minHeight, height: "100%" }}
|
"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>
|
<BarChart data={chartData} accessibilityLayer>
|
||||||
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||||
|
|
@ -594,6 +601,7 @@ function renderLineChart({
|
||||||
valueFormatter === "percent" ? (value: number) => percentFormatter.format(value) : undefined
|
valueFormatter === "percent" ? (value: number) => percentFormatter.format(value) : undefined
|
||||||
const isPresentation = mode === "tv" || mode === "print"
|
const isPresentation = mode === "tv" || mode === "print"
|
||||||
const minHeight = isPresentation ? PRESENTATION_CHART_HEIGHT : DEFAULT_CHART_HEIGHT
|
const minHeight = isPresentation ? PRESENTATION_CHART_HEIGHT : DEFAULT_CHART_HEIGHT
|
||||||
|
const legendPadClass = isPresentation ? "pb-8" : "pb-6"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WidgetCard title={title} description={description} isLoading={metric.isLoading}>
|
<WidgetCard title={title} description={description} isLoading={metric.isLoading}>
|
||||||
|
|
@ -602,8 +610,13 @@ function renderLineChart({
|
||||||
) : (
|
) : (
|
||||||
<ChartContainer
|
<ChartContainer
|
||||||
config={chartConfig as ChartConfig}
|
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"
|
className={cn(
|
||||||
style={{ minHeight, height: "100%" }}
|
"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>
|
<LineChart data={chartData} accessibilityLayer>
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
|
@ -669,6 +682,7 @@ function renderAreaChart({
|
||||||
valueFormatter === "percent" ? (value: number) => percentFormatter.format(value) : undefined
|
valueFormatter === "percent" ? (value: number) => percentFormatter.format(value) : undefined
|
||||||
const isPresentation = mode === "tv" || mode === "print"
|
const isPresentation = mode === "tv" || mode === "print"
|
||||||
const minHeight = isPresentation ? PRESENTATION_CHART_HEIGHT : DEFAULT_CHART_HEIGHT
|
const minHeight = isPresentation ? PRESENTATION_CHART_HEIGHT : DEFAULT_CHART_HEIGHT
|
||||||
|
const legendPadClass = isPresentation ? "pb-8" : "pb-6"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WidgetCard title={title} description={description} isLoading={metric.isLoading}>
|
<WidgetCard title={title} description={description} isLoading={metric.isLoading}>
|
||||||
|
|
@ -677,8 +691,13 @@ function renderAreaChart({
|
||||||
) : (
|
) : (
|
||||||
<ChartContainer
|
<ChartContainer
|
||||||
config={chartConfig as ChartConfig}
|
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"
|
className={cn(
|
||||||
style={{ minHeight, height: "100%" }}
|
"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>
|
<AreaChart data={chartData} accessibilityLayer>
|
||||||
<defs>
|
<defs>
|
||||||
|
|
@ -742,6 +761,7 @@ function renderPieChart({
|
||||||
const tooltipValueFormatter = (value: unknown) => formatMetricValue(value, valueFormatter)
|
const tooltipValueFormatter = (value: unknown) => formatMetricValue(value, valueFormatter)
|
||||||
const isPresentation = mode === "tv" || mode === "print"
|
const isPresentation = mode === "tv" || mode === "print"
|
||||||
const minHeight = isPresentation ? PRESENTATION_CHART_HEIGHT : DEFAULT_CHART_HEIGHT
|
const minHeight = isPresentation ? PRESENTATION_CHART_HEIGHT : DEFAULT_CHART_HEIGHT
|
||||||
|
const legendPadClass = isPresentation ? "pb-8" : "pb-6"
|
||||||
return (
|
return (
|
||||||
<WidgetCard title={title} description={description} isLoading={metric.isLoading}>
|
<WidgetCard title={title} description={description} isLoading={metric.isLoading}>
|
||||||
{chartData.length === 0 ? (
|
{chartData.length === 0 ? (
|
||||||
|
|
@ -753,8 +773,13 @@ function renderPieChart({
|
||||||
acc[key] = { label: key, color: CHART_COLORS[index % CHART_COLORS.length] }
|
acc[key] = { label: key, color: CHART_COLORS[index % CHART_COLORS.length] }
|
||||||
return acc
|
return acc
|
||||||
}, {}) as ChartConfig}
|
}, {}) 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"
|
className={cn(
|
||||||
style={{ minHeight, height: "100%" }}
|
"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>
|
<PieChart>
|
||||||
{showTooltip ? (
|
{showTooltip ? (
|
||||||
|
|
@ -807,6 +832,7 @@ function renderRadarChart({
|
||||||
const tooltipValueFormatter = (value: unknown) => formatMetricValue(value, valueFormatter)
|
const tooltipValueFormatter = (value: unknown) => formatMetricValue(value, valueFormatter)
|
||||||
const isPresentation = mode === "tv" || mode === "print"
|
const isPresentation = mode === "tv" || mode === "print"
|
||||||
const minHeight = isPresentation ? PRESENTATION_CHART_HEIGHT : DEFAULT_CHART_HEIGHT
|
const minHeight = isPresentation ? PRESENTATION_CHART_HEIGHT : DEFAULT_CHART_HEIGHT
|
||||||
|
const legendPadClass = isPresentation ? "pb-8" : "pb-6"
|
||||||
return (
|
return (
|
||||||
<WidgetCard title={title} description={description} isLoading={metric.isLoading}>
|
<WidgetCard title={title} description={description} isLoading={metric.isLoading}>
|
||||||
{chartData.length === 0 ? (
|
{chartData.length === 0 ? (
|
||||||
|
|
@ -814,8 +840,13 @@ function renderRadarChart({
|
||||||
) : (
|
) : (
|
||||||
<ChartContainer
|
<ChartContainer
|
||||||
config={{ [radiusKey]: { label: radiusKey, color: "var(--chart-1)" } }}
|
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"
|
className={cn(
|
||||||
style={{ minHeight, height: "100%" }}
|
"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>
|
<RadarChart data={chartData} accessibilityLayer>
|
||||||
<PolarGrid />
|
<PolarGrid />
|
||||||
|
|
@ -863,12 +894,18 @@ function renderGauge({
|
||||||
const display = Math.max(0, Math.min(1, value))
|
const display = Math.max(0, Math.min(1, value))
|
||||||
const isPresentation = mode === "tv" || mode === "print"
|
const isPresentation = mode === "tv" || mode === "print"
|
||||||
const minHeight = isPresentation ? PRESENTATION_CHART_HEIGHT : DEFAULT_CHART_HEIGHT
|
const minHeight = isPresentation ? PRESENTATION_CHART_HEIGHT : DEFAULT_CHART_HEIGHT
|
||||||
|
const legendPadClass = isPresentation ? "pb-8" : "pb-6"
|
||||||
return (
|
return (
|
||||||
<WidgetCard title={title} description={description} isLoading={metric.isLoading}>
|
<WidgetCard title={title} description={description} isLoading={metric.isLoading}>
|
||||||
<ChartContainer
|
<ChartContainer
|
||||||
config={{ value: { label: "SLA", color: "var(--chart-1)" } }}
|
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"
|
className={cn(
|
||||||
style={{ minHeight, height: "100%" }}
|
"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
|
<RadialBarChart
|
||||||
startAngle={180}
|
startAngle={180}
|
||||||
|
|
|
||||||
300
src/components/reports/category-report.tsx
Normal file
300
src/components/reports/category-report.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -174,10 +174,9 @@ export function TicketCustomFieldsList({ record, emptyMessage, className }: Tick
|
||||||
|
|
||||||
type TicketCustomFieldsSectionProps = {
|
type TicketCustomFieldsSectionProps = {
|
||||||
ticket: TicketWithDetails
|
ticket: TicketWithDetails
|
||||||
hidePreview?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TicketCustomFieldsSection({ ticket, hidePreview = false }: TicketCustomFieldsSectionProps) {
|
export function TicketCustomFieldsSection({ ticket }: TicketCustomFieldsSectionProps) {
|
||||||
const { convexUserId, role } = useAuth()
|
const { convexUserId, role } = useAuth()
|
||||||
const canEdit = Boolean(convexUserId && (role === "admin" || role === "agent"))
|
const canEdit = Boolean(convexUserId && (role === "admin" || role === "agent"))
|
||||||
|
|
||||||
|
|
@ -319,14 +318,10 @@ export function TicketCustomFieldsSection({ ticket, hidePreview = false }: Ticke
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{hidePreview ? (
|
<TicketCustomFieldsList
|
||||||
<p className="text-xs text-neutral-500">Visualize os valores no resumo principal.</p>
|
record={ticket.customFields}
|
||||||
) : (
|
emptyMessage="Nenhum campo adicional preenchido neste chamado."
|
||||||
<TicketCustomFieldsList
|
/>
|
||||||
record={ticket.customFields}
|
|
||||||
emptyMessage="Nenhum campo adicional preenchido neste chamado."
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Dialog open={editorOpen} onOpenChange={setEditorOpen}>
|
<Dialog open={editorOpen} onOpenChange={setEditorOpen}>
|
||||||
<DialogContent className="max-w-3xl gap-4">
|
<DialogContent className="max-w-3xl gap-4">
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,9 @@ import { ptBR } from "date-fns/locale"
|
||||||
import type { TicketWithDetails } from "@/lib/schemas/ticket"
|
import type { TicketWithDetails } from "@/lib/schemas/ticket"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { getTicketStatusLabel, getTicketStatusSummaryTone } from "@/lib/ticket-status-style"
|
import { getTicketStatusLabel, getTicketStatusSummaryTone } from "@/lib/ticket-status-style"
|
||||||
import { TicketCustomFieldsSection } from "@/components/tickets/ticket-custom-fields"
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { TicketCustomFieldsSection } from "@/components/tickets/ticket-custom-fields"
|
||||||
|
|
||||||
interface TicketDetailsPanelProps {
|
interface TicketDetailsPanelProps {
|
||||||
ticket: TicketWithDetails
|
ticket: TicketWithDetails
|
||||||
|
|
@ -53,19 +53,19 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
|
||||||
const isAvulso = Boolean(ticket.company?.isAvulso)
|
const isAvulso = Boolean(ticket.company?.isAvulso)
|
||||||
const companyLabel = ticket.company?.name ?? (isAvulso ? "Cliente avulso" : "Sem empresa vinculada")
|
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",
|
key: "queue",
|
||||||
label: "Fila",
|
label: "Fila",
|
||||||
value: ticket.queue ?? "Sem fila",
|
value: ticket.queue ?? "Sem fila",
|
||||||
tone: ticket.queue ? ("default" as SummaryTone) : ("muted" as SummaryTone),
|
tone: ticket.queue ? "default" : "muted",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "company",
|
key: "company",
|
||||||
label: "Empresa",
|
label: "Empresa",
|
||||||
value: companyLabel,
|
value: companyLabel,
|
||||||
tone: isAvulso ? ("warning" as SummaryTone) : ("default" as SummaryTone),
|
tone: isAvulso ? "warning" : "default",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "status",
|
key: "status",
|
||||||
|
|
@ -83,11 +83,19 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
|
||||||
key: "assignee",
|
key: "assignee",
|
||||||
label: "Responsável",
|
label: "Responsável",
|
||||||
value: ticket.assignee?.name ?? "Não atribuído",
|
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 agentTotals = useMemo(() => {
|
||||||
const totals = ticket.workSummary?.perAgentTotals ?? []
|
const totals = ticket.workSummary?.perAgentTotals ?? []
|
||||||
|
|
@ -129,8 +137,6 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<TicketCustomFieldsSection ticket={ticket} hidePreview />
|
|
||||||
|
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<h3 className="text-sm font-semibold text-neutral-900">SLA & métricas</h3>
|
<h3 className="text-sm font-semibold text-neutral-900">SLA & métricas</h3>
|
||||||
|
|
@ -184,6 +190,8 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<TicketCustomFieldsSection ticket={ticket} />
|
||||||
|
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<h3 className="text-sm font-semibold text-neutral-900">Tempo por agente</h3>
|
<h3 className="text-sm font-semibold text-neutral-900">Tempo por agente</h3>
|
||||||
{agentTotals.length > 0 ? (
|
{agentTotals.length > 0 ? (
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,6 @@ import { Textarea } from "@/components/ui/textarea"
|
||||||
import { Spinner } from "@/components/ui/spinner"
|
import { Spinner } from "@/components/ui/spinner"
|
||||||
import { useTicketCategories } from "@/hooks/use-ticket-categories"
|
import { useTicketCategories } from "@/hooks/use-ticket-categories"
|
||||||
import { useDefaultQueues } from "@/hooks/use-default-queues"
|
import { useDefaultQueues } from "@/hooks/use-default-queues"
|
||||||
import { mapTicketCustomFields } from "@/lib/ticket-custom-fields"
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
|
|
@ -214,7 +213,6 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
queuesEnabled ? { tenantId: ticket.tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
|
queuesEnabled ? { tenantId: ticket.tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
|
||||||
)
|
)
|
||||||
const queues: TicketQueueSummary[] = Array.isArray(queuesResult) ? queuesResult : []
|
const queues: TicketQueueSummary[] = Array.isArray(queuesResult) ? queuesResult : []
|
||||||
const customFieldEntries = useMemo(() => mapTicketCustomFields(ticket.customFields), [ticket.customFields])
|
|
||||||
const { categories, isLoading: categoriesLoading } = useTicketCategories(ticket.tenantId)
|
const { categories, isLoading: categoriesLoading } = useTicketCategories(ticket.tenantId)
|
||||||
const workSummaryRemote = useQuery(
|
const workSummaryRemote = useQuery(
|
||||||
api.tickets.workSummary,
|
api.tickets.workSummary,
|
||||||
|
|
@ -1300,13 +1298,9 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<h1 className="break-words text-2xl font-semibold text-neutral-900">{subject}</h1>
|
<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">
|
<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"
|
{ticket.formTemplateLabel ?? ticket.formTemplate}
|
||||||
? "Admissão"
|
|
||||||
: ticket.formTemplate === "desligamento"
|
|
||||||
? "Desligamento"
|
|
||||||
: "Chamado"}
|
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1585,24 +1579,6 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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}>
|
<Dialog open={pauseDialogOpen} onOpenChange={setPauseDialogOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
|
|
||||||
|
|
@ -221,14 +221,10 @@ export function TicketsTable({ tickets, enteringIds }: TicketsTableProps) {
|
||||||
<span className="text-sm text-neutral-600 line-clamp-1 break-words">
|
<span className="text-sm text-neutral-600 line-clamp-1 break-words">
|
||||||
{ticket.summary ?? "Sem resumo"}
|
{ticket.summary ?? "Sem resumo"}
|
||||||
</span>
|
</span>
|
||||||
{ticket.formTemplate ? (
|
{ticket.formTemplateLabel || ticket.formTemplate ? (
|
||||||
<div className="flex items-center gap-2">
|
<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">
|
<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"
|
{ticket.formTemplateLabel ?? ticket.formTemplate}
|
||||||
? "Admissão"
|
|
||||||
: ticket.formTemplate === "desligamento"
|
|
||||||
? "Desligamento"
|
|
||||||
: "Chamado"}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ const serverTicketSchema = z.object({
|
||||||
channel: z.string(),
|
channel: z.string(),
|
||||||
queue: z.string().nullable(),
|
queue: z.string().nullable(),
|
||||||
formTemplate: z.string().nullable().optional(),
|
formTemplate: z.string().nullable().optional(),
|
||||||
|
formTemplateLabel: z.string().nullable().optional(),
|
||||||
requester: serverUserSchema,
|
requester: serverUserSchema,
|
||||||
assignee: serverUserSchema.nullable(),
|
assignee: serverUserSchema.nullable(),
|
||||||
company: z
|
company: z
|
||||||
|
|
@ -198,6 +199,7 @@ export function mapTicketFromServer(input: unknown) {
|
||||||
csatComment: typeof csatComment === "string" && csatComment.trim().length > 0 ? csatComment.trim() : null,
|
csatComment: typeof csatComment === "string" && csatComment.trim().length > 0 ? csatComment.trim() : null,
|
||||||
csatRatedAt: csatRatedAt ? new Date(csatRatedAt) : null,
|
csatRatedAt: csatRatedAt ? new Date(csatRatedAt) : null,
|
||||||
csatRatedBy: csatRatedBy ?? null,
|
csatRatedBy: csatRatedBy ?? null,
|
||||||
|
formTemplateLabel: base.formTemplateLabel ?? null,
|
||||||
workSummary: s.workSummary
|
workSummary: s.workSummary
|
||||||
? {
|
? {
|
||||||
totalWorkedMs: s.workSummary.totalWorkedMs,
|
totalWorkedMs: s.workSummary.totalWorkedMs,
|
||||||
|
|
|
||||||
|
|
@ -158,6 +158,7 @@ export const ticketSchema = z.object({
|
||||||
reopenedBy: z.string().nullable().optional(),
|
reopenedBy: z.string().nullable().optional(),
|
||||||
chatEnabled: z.boolean().optional(),
|
chatEnabled: z.boolean().optional(),
|
||||||
formTemplate: z.string().nullable().optional(),
|
formTemplate: z.string().nullable().optional(),
|
||||||
|
formTemplateLabel: z.string().nullable().optional(),
|
||||||
csatScore: z.number().nullable().optional(),
|
csatScore: z.number().nullable().optional(),
|
||||||
csatMaxScore: z.number().nullable().optional(),
|
csatMaxScore: z.number().nullable().optional(),
|
||||||
csatComment: z.string().nullable().optional(),
|
csatComment: z.string().nullable().optional(),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue